Learn Claude Code
s17

自律エージェント

マルチエージェント基盤

Check the Board, Claim the Task

648 LOC15 ツールAutonomous task claiming
Teammates become useful when they can discover and claim work themselves.

s01 → ... → s15 → s16 → s17s18 → s19 → s20

"ボードを見て、自分で認領" — 空き時にポーリング、仕事があれば開始。

Harness 層: 自治 — チームメイトが自己組織化、リーダーの割り当て不要。


課題

s16 のチームメイトは通信でき、シャットダウンハンドシェイクもできる。しかし各チームメイトは Lead がタスクを割り当てるのを待つ——ボードに 10 個の未認領タスクがあれば、Lead は 10 回手動で assign しなければならない。これはスケールしない。チームメイトは自分でタスクボードを見て、未認領のタスクを見つけて認領し、終わったら次を探すべき。


ソリューション

Autonomous Agents Overview

S16 の教学版 MessageBus とプロトコルツールを踏襲。本章の追加:idle_poll(空き時に 5 秒ごとにポーリング)、scan_unclaimed_tasks(ボード上の認領可能なタスクをスキャン)、自動認領(見つけたら即座に claim、Lead 不要)。

チームメイトのライフサイクルは 2 フェーズから 3 フェーズに:

フェーズ動作終了条件
WORKinbox → LLM → ツールループstop_reason != tool_use
IDLE5s ポーリング inbox + タスクボード60s タイムアウト
SHUTDOWNsummary を送信、終了

仕組み

idle_poll: 空き時ポーリング

チームメイトはタスク完了後も終了せず、IDLE フェーズに入る——5 秒ごとに新しい仕事がないか確認:

IDLE_POLL_INTERVAL = 5   # seconds
IDLE_TIMEOUT = 60         # seconds

def idle_poll(agent_name, messages, name, role) -> str:
    """Return 'work', 'shutdown', or 'timeout'."""
    for _ in range(IDLE_TIMEOUT // IDLE_POLL_INTERVAL):
        time.sleep(IDLE_POLL_INTERVAL)

        # ① 受信箱確認(優先)
        inbox = BUS.read_inbox(agent_name)
        if inbox:
            # shutdown_request は即座に処理
            for msg in inbox:
                if msg.get("type") == "shutdown_request":
                    # ... shutdown_response 返信
                    return "shutdown"
            # 通常メッセージ:コンテキストに注入、WORK に戻る
            messages.append(...)
            return "work"

        # ② タスクボードスキャン
        unclaimed = scan_unclaimed_tasks()
        if unclaimed:
            task = unclaimed[0]
            result = claim_task(task["id"], agent_name)
            if "Claimed" in result:
                messages.append(...)
                return "work"
    return "timeout"

inbox を優先(shutdown_request 等のプロトコルメッセージの可能性)、タスクボードが次。IDLE フェーズで shutdown_request を受信すると即座に返信して終了し、次の WORK を待つ必要がない。

scan_unclaimed_tasks: タスクボードスキャン

pending 状態、owner なし、全依存関係完了(can_start)のタスクを検索:

def scan_unclaimed_tasks() -> list[dict]:
    unclaimed = []
    for f in sorted(TASKS_DIR.glob("task_*.json")):
        task = json.loads(f.read_text())
        if (task.get("status") == "pending"
                and not task.get("owner")
                and can_start(task["id"])):
            unclaimed.append(task)
    return unclaimed

3 つの条件:pending であること、owner がないこと、全 blockedBy 依存が完了していること。can_start は依存タスクの状態を確認——依存があるからといってタスクを開始できないわけではなく、未解決の依存のみがブロックする。教学版はファイル名順で最初のものを選択、CC はファイルロックで複数チームメイトの同時認領を防止。

claim_task: owner チェック

自動認領時に claim 結果を確認し、失敗を成功として扱わない:

def claim_task(task_id: str, owner: str = "agent") -> str:
    task = load_task(task_id)
    if task.status != "pending":
        return f"Task {task_id} is {task.status}, cannot claim"
    if task.owner:
        return f"Task {task_id} already owned by {task.owner}"
    if not can_start(task_id):
        return f"Blocked by: {deps}"
    task.owner = owner
    task.status = "in_progress"
    save_task(task)
    return f"Claimed {task.id} ({task.subject})"

教学版にはファイルロックがないため、並行認領で競合する可能性がある。しかし task.owner チェックで最も明白な「後書き上書き」問題を回避。CC は proper-lockfile でタスクファイルを保護、claimTask はファイルロック内で read-modify-write を実行(utils/tasks.ts:541-612)。

チームメイトライフサイクル: WORK → IDLE → SHUTDOWN

s16 のチームメイトはタスク完了後に終了。s17 は IDLE フェーズを追加——外側ループで WORK → IDLE を繰り返す:

# 外側ループ: WORK → IDLE サイクル
while True:
    # WORK フェーズ: 内側ループ(最大 10 ラウンド LLM 呼び出し)
    for _ in range(10):
        # inbox 確認、プロトコルメッセージ処理、LLM 呼び出し、ツール実行
        ...
        if response.stop_reason != "tool_use":
            break  # WORK フェーズ終了

    # IDLE フェーズ
    idle_result = idle_poll(name, messages, name, role)
    if idle_result == "shutdown":
        break
    if idle_result == "timeout":
        break  # 60s タイムアウト → SHUTDOWN

# SHUTDOWN: summary を Lead に送信
BUS.send(name, "lead", summary, "result")

主要設計:

  • 外側 while True:WORK と IDLE がタイムアウトまたはシャットダウン要求まで交互に続く
  • 内側 for 10:WORK フェーズは最大 10 ラウンドの LLM 呼び出し(無限ループ防止)
  • IDLE タイムアウト 60 秒:12 回ポーリング × 5 秒 = 60 秒。タイムアウト後 summary を送信して終了
  • shutdown_request は両フェーズで応答:WORK フェーズは handle_inbox_message でディスパッチ、IDLE フェーズは idle_poll が直接確認して返信

身份再注入

autoCompact(s08)後、チームメイトの messages リストが要約に圧縮される可能性がある。新しい WORK フェーズに入るたびに確認:

if len(messages) <= 3:
    messages.insert(0, {"role": "user",
        "content": f"<identity>You are '{name}', role: {role}. "
                   f"Continue your work.</identity>"})

メッセージが短い場合、圧縮が発生したことを示す——身份情報を再注入。真实 CC では context compaction が system prompt を保持、教学版の簡略実装は手動処理が必要。

consume_lead_inbox: 統一 inbox コンシューマ

check_inbox ツールとメインループ末尾の両方が同じ consume_lead_inbox() 関数を呼び出す:プロトコル response を先にルーティングして状態を更新し、全メッセージを Lead の会話履歴に注入。チームメイトからの summary/result は端末に表示されるだけでなく、Lead の LLM も確認して次のステップを調整可能。

組み合わせて実行

1. Lead: "バックエンド構築——タスクが多すぎる、チームメイトに自己認領させる"
2. Lead → create_task("データベーススキーマを作成")
3. Lead → create_task("API ルートを書く")
4. Lead → create_task("ユニットテストを書く")
5. Lead → spawn_teammate("alice", "backend", "あなたはバックエンド開発者")
6. Lead → spawn_teammate("bob", "backend", "あなたはバックエンド開発者")

7. alice スレッド起動 → WORK: 初期 inbox なし → 空転 → IDLE
8. bob スレッド起動 → WORK: 初期 inbox なし → 空転 → IDLE

9. alice IDLE ポーリング 1 回目 → scan_unclaimed → "データベーススキーマを作成" を発見
10. alice → claim_task → "データベーススキーマを作成" → WORK に戻る
11. bob IDLE ポーリング 1 回目 → scan_unclaimed → "API ルートを書く" を発見
12. bob → claim_task → "API ルートを書く" → WORK に戻る

13. alice WORK: write_file("schema.sql", ...) → complete_task → WORK 終了
14. alice IDLE → scan → "ユニットテストを書く" → claim → WORK
15. alice WORK: write_file("test_api.py", ...) → complete_task → WORK 終了
16. alice IDLE → 60s 新しいタスクなし → SHUTDOWN

17. bob も同様のフロー → 完了 → SHUTDOWN
18. Lead consume_lead_inbox → alice と bob の summary を確認

2 人のチームメイトが並行して認領・作業。Lead はタスクを作成してチームメイトを起動するだけで、手動割り当て不要。


s16 からの変更

コンポーネント変更前 (s16)変更後 (s17)
タスク割り当てLead が手動 assignチームメイトが自動認領(can_start で依存確認)
チームメイト状態WORK または終了WORK → IDLE(60s ポーリング) → SHUTDOWN
claim_taskowner チェックなし既に owner があるタスクを拒否
IDLE フェーズシャットダウンshutdown_request を処理しない即座にシャットダウンをディスパッチして終了
Lead inbox印刷のみ、コンテキストに入らないconsume_lead_inbox で history に注入
新規関数idle_poll, scan_unclaimed_tasks, consume_lead_inbox
身份保持system prompt のみ圧縮後に自動再注入
Lead ツール14 (s16)14(変更なし)
チームメイトツール58(+ list_tasks, claim_task, complete_task)
チームメイト終了条件タスク完了後即終了60s アイドルタイムアウト後のみ終了

試してみる

cd learn-claude-code
python s17_autonomous_agents/code.py

以下のプロンプトを試してください:

Create 3 tasks on the board, then spawn alice and bob. Watch them auto-claim and work.

観察ポイント:チームメイトは未割り当てのタスクを自動認領したか?blockedBy 依存のあるタスクは依存完了後に正しく認領されたか?アイドルタイムアウトでシャットダウンしたか?IDLE フェーズで shutdown_request に即座に応答したか?.tasks/ ディレクトリのタスク状態はどう変化したか?


次の章

チームメイトが自己組織化した。しかし Alice も Bob も同じディレクトリで作業——Alice が config.py を編集し、Bob も config.py を編集して互いに上書きしてしまう。

s18 Worktree Isolation → 各タスクに専用の作業ディレクトリ、競合なし。

CC ソースコード深掘り

教学注記:本章の idle_poll + auto-claim 機構は教学設計であり、統一ポーリング関数で「空き時に仕事を探す」をデモ。CC の実際の実装は複数機構の組み合わせだが、目標は同じ——Lead の手動割り当て負担を軽減。

一、CC の空き機構:組み合わせ路径、単一ポーリングではない

教学版は 1 つの idle_poll() で空き時の inbox 確認とタスク認領を統一処理。CC の実際の実装は 4 つの機構の組み合わせ:

idle_notification:チームメイトが 1 ラウンドの作業を完了後、sendIdleNotification()inProcessRunner.ts:569-589)が Lead に空き通知を送信。Lead はチームメイトが利用可能であることを知り、新しいタスクを割り当てたりシャットダウンを要求可能。

mailbox ポーリングwaitForNextPromptOrShutdown()inProcessRunner.ts:689-868)は 500ms ポーリングループで、3 つのソースを継続チェック:pending user messages、mailbox ファイルメッセージ、task list。shutdown_request は優先処理(inProcessRunner.ts:768-804)、通常メッセージによる飢餓を防止。

task watcheruseTaskListWatcherhooks/useTaskListWatcher.ts:34-189)が fs.watch().claude/tasks/ ディレクトリの変化を監視、1 秒 debounce で新タスク作成や依存アンロック時にチェックをトリガー。依存判断(L197-207)は「blockedBy に未完了タスクがない」で、「blockedBy が空」ではない。

能動 claim:ポーリングループ内でも tryClaimNextTask()inProcessRunner.ts:853-860)を呼び出し——待機中に task list から能動的にタスクを認領。したがって「チームメイトは能動的にタスクをポーリングしない」は不正確、CC は受動通知と能動認領の両方を持つ。

二、タスク認領:ファイルロック + 原子操作

claimTask()utils/tasks.ts:541-612)は proper-lockfile のタスクファイルロックを使用、ロック内で read-check-modify-write を実行。チェック項目:owner が既に存在(L575-576)、完了済み(L580-581)、blockedBy に未完了タスクがあるか(L585-594)。claimTaskWithBusyCheck()utils/tasks.ts:614-692)はタスクリストレベルロックを使用、busy check と claim を原子操作にして TOCTOU を回避。

findAvailableTask()inProcessRunner.ts:595-604)の依存判断も「全 blockedBy 完了」で、task.blockedBy.every(id => !unresolvedTaskIds.has(id)) で実装。tryClaimNextTask()inProcessRunner.ts:624-657)は認領後 status を in_progress に更新、UI に即座に反映。

三、教学版 vs CC 対比

次元教学版 (s17)CC
空き機構idle_poll 統一ポーリング(5s)idle_notification + 500ms mailbox ポーリング + task watcher
タスク発見scan_unclaimed_tasks(ポーリング)useTaskListWatcher(ファイル監視)+ tryClaimNextTask(能動ポーリング)
依存チェックcan_start(全 blockedBy 完了)findAvailableTask(同じセマンティクス)
並行安全性owner チェック(ファイルロックなし)proper-lockfile タスクロック + タスクリストロック
shutdown 処理IDLE 直接ディスパッチ、WORK は handle_inbox_message500ms ポーリングループで shutdown_request を優先
タイムアウト終了60s 新しいタスクなし固定タイムアウトなし、Lead 手動 shutdown
身份保持messages 長さ検出context compaction が system prompt を保持
claim 失敗処理戻り値を確認、失敗時はスキップファイルロックで原子性を保証

教学版の idle_poll() は CC の 4 つの機構を 1 つのポーリング関数に統合——核心セマンティクス(空き時に仕事を探す、依存アンロック後に認領、shutdown 優先)が一致するため、合理的な簡略化。