feat: refresh course through workflow and goal loops

This commit is contained in:
Haoran
2026-07-30 19:14:04 +08:00
parent 2dd1852d9e
commit cb8fae1bdd
125 changed files with 10882 additions and 7661 deletions

View File

@@ -1,271 +0,0 @@
# s17: Autonomous Agents — Check the Board, Claim the Task
[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
s01 → ... → s15 → s16 → `s17` → [s18](../s18_worktree_isolation/) → s19 → s20
> *"Check the board, claim the task"* — poll when idle, work when found.
>
> **Harness Layer**: Autonomy — Self-organizing teammates, no leader assignment needed.
---
## The Problem
s16's teammates can communicate and handshake shutdown. But each teammate waits for Lead to assign tasks — with 10 unclaimed tasks on the board, Lead has to manually assign 10 times. This doesn't scale. Teammates should check the task board themselves, claim unowned tasks, and look for the next one when done.
---
## The Solution
![Autonomous Agents Overview](images/autonomous-agents-overview.en.svg)
Carries forward S16's teaching-version MessageBus and protocol tools. This chapter adds: **idle_poll** (poll every 5 seconds when idle), **scan_unclaimed_tasks** (scan the board for claimable tasks), **auto-claim** (claim on sight, no Lead needed).
Teammate lifecycle expands from two phases to three:
| Phase | Behavior | Exit condition |
|-------|----------|----------------|
| WORK | inbox → LLM → tool loop | `stop_reason != tool_use` |
| IDLE | 5s poll inbox + task board | 60s timeout |
| SHUTDOWN | Send summary, exit | — |
---
## How It Works
### idle_poll: Idle Polling
After completing a task, the teammate doesn't exit. It enters the IDLE phase — checking every 5 seconds for new work:
```python
IDLE_POLL_INTERVAL = 5 # seconds
IDLE_TIMEOUT = 60 # seconds
def idle_poll(name, messages, role) -> str:
"""Return 'work', 'shutdown', or 'timeout'."""
for _ in range(IDLE_TIMEOUT // IDLE_POLL_INTERVAL):
time.sleep(IDLE_POLL_INTERVAL)
# ① Check inbox (priority)
inbox = BUS.read_inbox(name)
if inbox:
# shutdown_request handled immediately
for msg in inbox:
if msg.get("type") == "shutdown_request":
# ... reply shutdown_response
return "shutdown"
# Regular messages: inject into context, return to WORK
messages.append(...)
return "work"
# ② Scan task board
unclaimed = scan_unclaimed_tasks()
if unclaimed:
task = unclaimed[0]
result = claim_task(task["id"], name)
if "Claimed" in result:
messages.append(...)
return "work"
return "timeout"
```
Inbox takes priority (may contain protocol messages like shutdown_request), task board second. A shutdown_request received during IDLE is dispatched immediately — no need to wait for the next WORK phase.
### scan_unclaimed_tasks: Scan the Task Board
Find tasks that are pending, unowned, with all dependencies completed (`can_start`):
```python
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
```
Three conditions: must be pending, no owner, all blockedBy dependencies completed. `can_start` checks dependency task status — having dependencies doesn't mean the task can't start, only unresolved dependencies block it. Teaching version picks the first by filename; CC uses file locks to prevent multiple teammates from claiming the same task.
### claim_task: Owner Check
Auto-claim checks the claim result, not treating failure as success:
```python
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})"
```
Teaching version has no file locks, so concurrent claims may still race. But the `task.owner` check avoids the most obvious "last writer wins" problem. CC uses `proper-lockfile` to protect task files, with `claimTask` doing read-modify-write inside a file lock (`utils/tasks.ts:541-612`).
### Teammate Lifecycle: WORK → IDLE → SHUTDOWN
s16's teammates exit after finishing. s17 adds the IDLE phase — teammates cycle through WORK → IDLE in an outer loop:
```python
# Outer loop: WORK → IDLE cycle
while True:
# WORK phase: inner loop (max 10 LLM rounds)
for _ in range(10):
# Check inbox, dispatch protocol, call LLM, execute tools
...
if response.stop_reason != "tool_use":
break # WORK phase ends
# IDLE phase
idle_result = idle_poll(name, messages, role)
if idle_result == "shutdown":
break
if idle_result == "timeout":
break # 60s timeout → SHUTDOWN
# SHUTDOWN: send summary to Lead
BUS.send(name, "lead", summary, "result")
```
Key design:
- **Outer while True**: WORK and IDLE alternate until timeout or shutdown request
- **Inner for 10**: WORK phase caps at 10 LLM rounds (prevents infinite loops)
- **IDLE timeout 60s**: 12 polls × 5s = 60s. Timeout sends summary and exits
- **shutdown_request works in both phases**: WORK phase dispatches via `handle_inbox_message`; IDLE phase's `idle_poll` checks and replies directly
### Identity Re-injection
After autoCompact (s08), a teammate's messages list may be compressed into a summary. On each new WORK phase entry, check:
```python
if len(messages) <= 3:
messages.insert(0, {"role": "user",
"content": f"<identity>You are '{name}', role: {role}. "
f"Continue your work.</identity>"})
```
Short messages suggest compression happened — re-inject identity. In real CC, context compaction preserves the system prompt; the teaching version's simplified implementation needs manual handling.
### consume_lead_inbox: Unified Inbox Consumer
Both the `check_inbox` tool and the main loop call the same `consume_lead_inbox()` function: route protocol responses to update state first, then inject all messages into Lead's conversation history. Teammates' summaries and results don't just print to terminal — Lead's LLM can see them and coordinate next steps.
### Putting It Together
```
1. Lead: "Build the backend — too many tasks, let teammates self-claim"
2. Lead → create_task("Create database schema")
3. Lead → create_task("Write API routes")
4. Lead → create_task("Write unit tests")
5. Lead → spawn_teammate("alice", "backend", "You are a backend developer")
6. Lead → spawn_teammate("bob", "backend", "You are a backend developer")
7. alice thread starts → WORK: no initial inbox → spins → IDLE
8. bob thread starts → WORK: no initial inbox → spins → IDLE
9. alice IDLE poll 1 → scan_unclaimed → finds "Create database schema"
10. alice → claim_task → "Create database schema" → back to WORK
11. bob IDLE poll 1 → scan_unclaimed → finds "Write API routes"
12. bob → claim_task → "Write API routes" → back to WORK
13. alice WORK: write_file("schema.sql", ...) → complete_task → WORK ends
14. alice IDLE → scan → "Write unit tests" → claim → WORK
15. alice WORK: write_file("test_api.py", ...) → complete_task → WORK ends
16. alice IDLE → 60s no new tasks → SHUTDOWN
17. bob similar flow → done → SHUTDOWN
18. Lead consume_lead_inbox → sees alice and bob's summaries
```
Two teammates claim and work in parallel. Lead only creates tasks and spawns teammates — no manual assignment needed.
---
## Changes from s16
| Component | Before (s16) | After (s17) |
|-----------|-------------|-------------|
| Task assignment | Lead manually assigns | Teammates auto-claim (can_start checks deps) |
| Teammate state | WORK → IDLE (1s inbox poll) → WORK / SHUTDOWN | WORK → IDLE (5s inbox + task board poll, 60s timeout) → WORK / SHUTDOWN |
| claim_task | No owner check | Rejects tasks that already have an owner |
| IDLE phase shutdown | Exits after receiving shutdown_request | Dispatches shutdown immediately and exits |
| Lead inbox | consume_lead_inbox routes protocol responses and injects into context | Reuses consume_lead_inbox mechanism |
| New functions | consume_lead_inbox already exists | idle_poll, scan_unclaimed_tasks (reuses consume_lead_inbox) |
| Identity persistence | System prompt only | Auto re-inject after compression |
| Lead tools | 14 | 14 (unchanged) |
| Teammate tools | 5 | 8 (+ list_tasks, claim_task, complete_task) |
| Teammate exit | WORK ends → enters IDLE, waits for shutdown_request (no timeout) | Exits after 60s idle timeout or receiving shutdown_request |
---
## Try It
```sh
cd learn-claude-code
python s17_autonomous_agents/code.py
```
Try this prompt:
`Create 3 tasks on the board, then spawn alice and bob. Watch them auto-claim and work.`
What to observe: Do teammates auto-claim unassigned tasks? Are tasks with blockedBy dependencies claimed only after their dependencies complete? Does idle timeout trigger shutdown? Does a shutdown_request in IDLE phase get an immediate response? How do task states change in `.tasks/`?
---
## What's Next
Teammates self-organize now. But Alice and Bob both work in the same directory — Alice edits `config.py`, Bob also edits `config.py`, overwriting each other.
s18 Worktree Isolation → Each task gets its own working directory, no conflicts.
<details>
<summary>Deep Dive into CC Source</summary>
> Teaching note: This chapter's idle_poll + auto-claim mechanism is a teaching design, using a unified polling function to demonstrate "find work when idle." CC's actual implementation combines multiple mechanisms, but shares the same goal — reducing Lead's manual assignment burden.
### 1. CC's Idle Mechanism: Combined Approach, Not Single Polling
Teaching version uses a single `idle_poll()` to handle both inbox checking and task claiming during idle. CC's actual implementation combines four mechanisms:
**idle_notification**: After completing a round of work, `sendIdleNotification()` (`inProcessRunner.ts:569-589`) sends an idle notification to Lead. Lead knows the teammate is available and can assign new tasks or request shutdown.
**mailbox polling**: `waitForNextPromptOrShutdown()` (`inProcessRunner.ts:689-868`) is a **500ms polling loop** that continuously checks three sources: pending user messages, mailbox file messages, and task list. Shutdown requests are prioritized (`inProcessRunner.ts:768-804`), preventing starvation by regular messages.
**task watcher**: `useTaskListWatcher` (`hooks/useTaskListWatcher.ts:34-189`) uses `fs.watch()` to monitor the `.claude/tasks/` directory with 1-second debounce, triggering checks when new tasks are created or dependencies unblock. The dependency check (`L197-207`) verifies "no incomplete tasks in blockedBy", not "blockedBy is empty".
**active claiming**: The polling loop also calls `tryClaimNextTask()` (`inProcessRunner.ts:853-860`) — actively claiming tasks from the task list while waiting. So "teammates don't actively poll for tasks" is inaccurate; CC has both passive notification and active claiming.
### 2. Task Claiming: File Locks + Atomic Operations
`claimTask()` (`utils/tasks.ts:541-612`) uses `proper-lockfile` task-level locks, performing read-check-modify-write within the lock. Checks: owner already exists (`L575-576`), already completed (`L580-581`), unresolved blockers in blockedBy (`L585-594`). `claimTaskWithBusyCheck()` (`utils/tasks.ts:614-692`) uses task-list level locks, making busy check and claim atomic to avoid TOCTOU.
`findAvailableTask()` (`inProcessRunner.ts:595-604`) checks "all blockedBy completed" using `task.blockedBy.every(id => !unresolvedTaskIds.has(id))`. `tryClaimNextTask()` (`inProcessRunner.ts:624-657`) updates status to `in_progress` after claiming, so the UI immediately reflects the change.
### 3. Teaching Version vs CC Comparison
| Dimension | Teaching (s17) | CC |
|-----------|----------------|-----|
| Idle mechanism | idle_poll unified polling (5s) | idle_notification + 500ms mailbox polling + task watcher |
| Task discovery | scan_unclaimed_tasks (polling) | useTaskListWatcher (file watching) + tryClaimNextTask (active polling) |
| Dependency check | can_start (all blockedBy completed) | findAvailableTask (same semantics) |
| Concurrency safety | Owner check (no file lock) | proper-lockfile task lock + task-list lock |
| Shutdown handling | IDLE dispatches directly, WORK via handle_inbox_message | 500ms polling loop prioritizes shutdown_request |
| Timeout exit | 60s with no new tasks | No fixed timeout, Lead manual shutdown |
| Identity persistence | Messages length detection | Context compaction preserves system prompt |
| Claim failure handling | Check return value, skip on failure | File locks guarantee atomicity |
Teaching version's `idle_poll()` merges CC's four mechanisms into one polling function — a reasonable simplification since the core semantics (find work when idle, claim after deps resolve, prioritize shutdown) are consistent.
</details>
<!-- translation-sync: zh@v2, en@v2, ja@v2 -->

View File

@@ -1,13 +1,15 @@
# s17: Autonomous Agents — ボードを見て、自分で認領
[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → ... → s15 → s16 → `s17` → [s18](../s18_worktree_isolation/) → s19 → s20
s01 → ... → s15 → s16 → `s17` → [s18](../s18_worktree_isolation/) → s19 → s20 → s21 → s22
> *"ボードを見て、自分で認領"* — 空き時にポーリング、仕事があれば開始。
>
> **Harness 層**: 自治 — チームメイトが自己組織化、リーダーの割り当て不要。
> **コアと任意項目:** アイドル時の仕事発見と原子的な claim が本章の中心。アイデンティティ再注入は教育版の高度な補助で、初回は読み飛ばしてよい。
---
## 課題
@@ -142,7 +144,7 @@ BUS.send(name, "lead", summary, "result")
- **IDLE タイムアウト 60 秒**12 回ポーリング × 5 秒 = 60 秒。タイムアウト後 summary を送信して終了
- **shutdown_request は両フェーズで応答**WORK フェーズは `handle_inbox_message` でディスパッチ、IDLE フェーズは `idle_poll` が直接確認して返信
### 身份再注入
### 発展(任意):アイデンティティ再注入
autoCompacts08後、チームメイトの messages リストが要約に圧縮される可能性がある。新しい WORK フェーズに入るたびに確認:

View File

@@ -1,42 +1,44 @@
# s17: Autonomous Agents — 自己看板,自己认领
# s17: Autonomous Agents — Check the Board, Claim the Task
[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → ... → s15 → s16 → `s17` → [s18](../s18_worktree_isolation/) → s19 → s20
s01 → ... → s15 → s16 → `s17` → [s18](../s18_worktree_isolation/) → s19 → s20 → s21 → s22
> *"自己看板,自己认领"* — 空闲时轮询,有活就干。
> *"Check the board, claim the task"* — poll when idle, work when found.
>
> **Harness 层**: 自治 — 队友自组织,不依赖 Lead 分配。
> **Harness Layer**: Autonomy — Self-organizing teammates, no leader assignment needed.
> **Core vs optional:** idle work discovery and atomic claiming are the lesson's core. Identity re-injection is an advanced teaching workaround and may be skipped on a first pass.
---
## 问题
## The Problem
s16 的队友能通信、能握手关机。但每个队友等 Lead 分配任务——如果任务看板上有 10 个未认领任务Lead 得手动 assign 10 次。这不能扩展。队友应该自己看任务看板,发现没人做的任务就认领,做完再找下一个。
s16's teammates can communicate and handshake shutdown. But each teammate waits for Lead to assign tasks — with 10 unclaimed tasks on the board, Lead has to manually assign 10 times. This doesn't scale. Teammates should check the task board themselves, claim unowned tasks, and look for the next one when done.
---
## 解决方案
## The Solution
![Autonomous Agents Overview](images/autonomous-agents-overview.svg)
![Autonomous Agents Overview](images/autonomous-agents-overview.en.svg)
沿用 S16 的教学版 MessageBus 和协议工具。本章新增:**idle_poll**(空闲时每 5 秒轮询一次)、**scan_unclaimed_tasks**(扫描看板上可认领的任务)、**自动认领**(找到任务就 claim不用 Lead 操心)。
Carries forward S16's teaching-version MessageBus and protocol tools. This chapter adds: **idle_poll** (poll every 5 seconds when idle), **scan_unclaimed_tasks** (scan the board for claimable tasks), **auto-claim** (claim on sight, no Lead needed).
队友生命周期从两阶段变成三阶段:
Teammate lifecycle expands from two phases to three:
| 阶段 | 行为 | 退出条件 |
|------|------|---------|
| WORK | inbox → LLM → 工具循环 | `stop_reason != tool_use` |
| IDLE | 5s 轮询 inbox + 任务板 | 60s 超时 |
| SHUTDOWN | summary,退出 | — |
| Phase | Behavior | Exit condition |
|-------|----------|----------------|
| WORK | inbox → LLM → tool loop | `stop_reason != tool_use` |
| IDLE | 5s poll inbox + task board | 60s timeout |
| SHUTDOWN | Send summary, exit | — |
---
## 工作原理
## How It Works
### idle_poll: 空闲轮询
### idle_poll: Idle Polling
队友完成当前任务后不退出,进入 IDLE 阶段——每 5 秒检查一次有没有新工作:
After completing a task, the teammate doesn't exit. It enters the IDLE phase — checking every 5 seconds for new work:
```python
IDLE_POLL_INTERVAL = 5 # seconds
@@ -47,19 +49,19 @@ def idle_poll(name, messages, role) -> str:
for _ in range(IDLE_TIMEOUT // IDLE_POLL_INTERVAL):
time.sleep(IDLE_POLL_INTERVAL)
# ① 检查收件箱(优先)
# ① Check inbox (priority)
inbox = BUS.read_inbox(name)
if inbox:
# shutdown_request 立即处理
# shutdown_request handled immediately
for msg in inbox:
if msg.get("type") == "shutdown_request":
# ... 回复 shutdown_response
# ... reply shutdown_response
return "shutdown"
# 普通消息注入上下文,回到 WORK
# Regular messages: inject into context, return to WORK
messages.append(...)
return "work"
# ② 扫描任务看板
# ② Scan task board
unclaimed = scan_unclaimed_tasks()
if unclaimed:
task = unclaimed[0]
@@ -70,11 +72,11 @@ def idle_poll(name, messages, role) -> str:
return "timeout"
```
inbox 优先(可能包含 shutdown_request 等协议消息任务板其次。IDLE 阶段收到 shutdown_request 会直接回复并退出,不等到下一轮 WORK。
Inbox takes priority (may contain protocol messages like shutdown_request), task board second. A shutdown_request received during IDLE is dispatched immediately — no need to wait for the next WORK phase.
### scan_unclaimed_tasks: 扫描任务看板
### scan_unclaimed_tasks: Scan the Task Board
找 pending 状态、无 owner、所有依赖已完成`can_start`)的任务:
Find tasks that are pending, unowned, with all dependencies completed (`can_start`):
```python
def scan_unclaimed_tasks() -> list[dict]:
@@ -88,11 +90,11 @@ def scan_unclaimed_tasks() -> list[dict]:
return unclaimed
```
三个条件:必须是 pending、没有 owner、所有 blockedBy 依赖已完成。`can_start` 检查依赖任务的状态——有依赖不代表不能做只有被未完成的任务阻塞才不能做。教学版按文件名排序取第一个CC 用文件锁防止多个队友同时认领同一个任务。
Three conditions: must be pending, no owner, all blockedBy dependencies completed. `can_start` checks dependency task status — having dependencies doesn't mean the task can't start, only unresolved dependencies block it. Teaching version picks the first by filename; CC uses file locks to prevent multiple teammates from claiming the same task.
### claim_task: owner 检查
### claim_task: Owner Check
自动认领时检查 claim 结果,不把失败当成功:
Auto-claim checks the claim result, not treating failure as success:
```python
def claim_task(task_id: str, owner: str = "agent") -> str:
@@ -109,42 +111,42 @@ def claim_task(task_id: str, owner: str = "agent") -> str:
return f"Claimed {task.id} ({task.subject})"
```
教学版没有文件锁,并发认领可能出现竞争。但至少 `task.owner` 检查避免了最明显的"后写覆盖"问题。CC 用 `proper-lockfile` 保护任务文件,`claimTask` 在文件锁内完成读-改-写(`utils/tasks.ts:541-612`)。
Teaching version has no file locks, so concurrent claims may still race. But the `task.owner` check avoids the most obvious "last writer wins" problem. CC uses `proper-lockfile` to protect task files, with `claimTask` doing read-modify-write inside a file lock (`utils/tasks.ts:541-612`).
### 队友生命周期: WORK → IDLE → SHUTDOWN
### Teammate Lifecycle: WORK → IDLE → SHUTDOWN
s16 的队友做完任务就退出。s17 加了 IDLE 阶段,队友在外层循环中反复 WORK → IDLE
s16's teammates exit after finishing. s17 adds the IDLE phase — teammates cycle through WORK → IDLE in an outer loop:
```python
# Outer loop: WORK → IDLE cycle
while True:
# WORK phase: 内层循环(最多 10 LLM 调用)
# WORK phase: inner loop (max 10 LLM rounds)
for _ in range(10):
# 检查 inbox、处理协议消息、调 LLM、执行工具
# Check inbox, dispatch protocol, call LLM, execute tools
...
if response.stop_reason != "tool_use":
break # WORK 阶段结束
break # WORK phase ends
# IDLE phase
idle_result = idle_poll(name, messages, role)
if idle_result == "shutdown":
break
if idle_result == "timeout":
break # 60s 超时 → SHUTDOWN
break # 60s timeout → SHUTDOWN
# SHUTDOWN: summary Lead
# SHUTDOWN: send summary to 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` 直接检查并回复
Key design:
- **Outer while True**: WORK and IDLE alternate until timeout or shutdown request
- **Inner for 10**: WORK phase caps at 10 LLM rounds (prevents infinite loops)
- **IDLE timeout 60s**: 12 polls × 5s = 60s. Timeout sends summary and exits
- **shutdown_request works in both phases**: WORK phase dispatches via `handle_inbox_message`; IDLE phase's `idle_poll` checks and replies directly
### 身份重注入
### Advanced (Optional): Identity Re-injection
autoCompacts08)之后,队友的 messages 列表可能被压缩成一段摘要。每次进入新的 WORK 阶段时检查:
After autoCompact (s08), a teammate's messages list may be compressed into a summary. On each new WORK phase entry, check:
```python
if len(messages) <= 3:
@@ -153,118 +155,118 @@ if len(messages) <= 3:
f"Continue your work.</identity>"})
```
消息过短说明发生了压缩,此时重新注入身份信息。真实 CC context compaction 会保留 system prompt,教学版的简化实现需要手动处理。
Short messages suggest compression happened — re-inject identity. In real CC, context compaction preserves the system prompt; the teaching version's simplified implementation needs manual handling.
### consume_lead_inbox: 统一 inbox 消费
### consume_lead_inbox: Unified Inbox Consumer
`check_inbox` 工具和主循环末尾都调用同一个 `consume_lead_inbox()` 函数:先路由协议 response 更新状态,再把所有消息注入 Lead 的对话历史。队友发来的 summary/result 不会只打印在终端,Lead LLM 能看到并协调下一步。
Both the `check_inbox` tool and the main loop call the same `consume_lead_inbox()` function: route protocol responses to update state first, then inject all messages into Lead's conversation history. Teammates' summaries and results don't just print to terminal — Lead's LLM can see them and coordinate next steps.
### 合起来跑
### Putting It Together
```
1. Lead: "搭建后端——任务太多,让队友自己认领"
2. Lead → create_task("创建数据库 schema")
3. Lead → create_task("写 API 路由")
4. Lead → create_task("写单元测试")
5. Lead → spawn_teammate("alice", "backend", "你是后端开发者")
6. Lead → spawn_teammate("bob", "backend", "你是后端开发者")
1. Lead: "Build the backend — too many tasks, let teammates self-claim"
2. Lead → create_task("Create database schema")
3. Lead → create_task("Write API routes")
4. Lead → create_task("Write unit tests")
5. Lead → spawn_teammate("alice", "backend", "You are a backend developer")
6. Lead → spawn_teammate("bob", "backend", "You are a backend developer")
7. alice 线程启动 → WORK: 没有初始 inbox → 空转 → IDLE
8. bob 线程启动 → WORK: 没有初始 inbox → 空转 → IDLE
7. alice thread starts → WORK: no initial inbox → spins → IDLE
8. bob thread starts → WORK: no initial inbox → spins → IDLE
9. alice IDLE 第 1 次轮询 → scan_unclaimed → 发现"创建数据库 schema"
10. alice → claim_task → "创建数据库 schema" → 回到 WORK
11. bob IDLE 第 1 次轮询 → scan_unclaimed → 发现"写 API 路由"
12. bob → claim_task → "写 API 路由" → 回到 WORK
9. alice IDLE poll 1 → scan_unclaimed → finds "Create database schema"
10. alice → claim_task → "Create database schema" → back to WORK
11. bob IDLE poll 1 → scan_unclaimed → finds "Write API routes"
12. bob → claim_task → "Write API routes" → back to 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
13. alice WORK: write_file("schema.sql", ...) → complete_task → WORK ends
14. alice IDLE → scan → "Write unit tests" → claim → WORK
15. alice WORK: write_file("test_api.py", ...) → complete_task → WORK ends
16. alice IDLE → 60s no new tasks → SHUTDOWN
17. bob 类似流程 → 做完 → SHUTDOWN
18. Lead consume_lead_inbox → 看到 alice bob summary
17. bob similar flow → done → SHUTDOWN
18. Lead consume_lead_inbox → sees alice and bob's summaries
```
两个队友并行认领、并行工作。Lead 只需要创建任务和启动队友,不需要手动分配。
Two teammates claim and work in parallel. Lead only creates tasks and spawns teammates — no manual assignment needed.
---
## 相对 s16 的变更
## Changes from s16
| 组件 | 之前 (s16) | 之后 (s17) |
|------|-----------|-----------|
| 任务分配 | Lead 手动 assign | 队友自动认领can_start 检查依赖) |
| 队友状态 | WORK → IDLE每 1s 轮询 inbox→ WORK / SHUTDOWN | WORK → IDLE每 5s 轮询 inbox + 任务板60s 超时)→ WORK / SHUTDOWN |
| claim_task | owner 检查 | 拒绝已有 owner 的任务 |
| IDLE 阶段关机 | 收到 shutdown_request 后退出 | 直接 dispatch shutdown 并退出 |
| Lead inbox | consume_lead_inbox 路由协议响应并注入上下文 | 沿用 consume_lead_inbox 机制 |
| 新函数 | 已有 consume_lead_inbox | idle_poll, scan_unclaimed_tasks(沿用 consume_lead_inbox |
| 身份保持 | 仅 system prompt | 压缩后自动重注入 |
| Lead 工具 | 14 | 14(不变) |
| 队友工具 | 5 | 8+ list_tasks, claim_task, complete_task |
| 队友退出条件 | WORK 完进入 IDLE等待 shutdown_request 后退出(无超时) | 60s 无新任务或收到 shutdown_request 后退出 |
| Component | Before (s16) | After (s17) |
|-----------|-------------|-------------|
| Task assignment | Lead manually assigns | Teammates auto-claim (can_start checks deps) |
| Teammate state | WORK → IDLE (1s inbox poll) → WORK / SHUTDOWN | WORK → IDLE (5s inbox + task board poll, 60s timeout) → WORK / SHUTDOWN |
| claim_task | No owner check | Rejects tasks that already have an owner |
| IDLE phase shutdown | Exits after receiving shutdown_request | Dispatches shutdown immediately and exits |
| Lead inbox | consume_lead_inbox routes protocol responses and injects into context | Reuses consume_lead_inbox mechanism |
| New functions | consume_lead_inbox already exists | idle_poll, scan_unclaimed_tasks (reuses consume_lead_inbox) |
| Identity persistence | System prompt only | Auto re-inject after compression |
| Lead tools | 14 | 14 (unchanged) |
| Teammate tools | 5 | 8 (+ list_tasks, claim_task, complete_task) |
| Teammate exit | WORK ends → enters IDLE, waits for shutdown_request (no timeout) | Exits after 60s idle timeout or receiving shutdown_request |
---
## 试一下
## Try It
```sh
cd learn-claude-code
python s17_autonomous_agents/code.py
```
试试这个 prompt
Try this prompt:
`Create 3 tasks on the board, then spawn alice and bob. Watch them auto-claim and work.`
观察重点:队友是否自动认领了未分配的任务?有 blockedBy 依赖的任务是否在前置完成后被正确认领空闲超时后是否自动关机IDLE 阶段收到 shutdown_request 是否立即响应?`.tasks/` 目录下的任务状态如何变化?
What to observe: Do teammates auto-claim unassigned tasks? Are tasks with blockedBy dependencies claimed only after their dependencies complete? Does idle timeout trigger shutdown? Does a shutdown_request in IDLE phase get an immediate response? How do task states change in `.tasks/`?
---
## 接下来
## What's Next
队友自组织了。但 Alice Bob 都在同一个目录下工作——Alice `config.py`Bob 也改 `config.py`,互相覆盖。
Teammates self-organize now. But Alice and Bob both work in the same directory — Alice edits `config.py`, Bob also edits `config.py`, overwriting each other.
s18 Worktree Isolation → 每个任务有自己的工作目录,互不干扰。
s18 Worktree Isolation → Each task gets its own working directory, no conflicts.
<details>
<summary>深入 CC 源码</summary>
<summary>Deep Dive into CC Source</summary>
> 教学说明:本章的 idle_poll + auto-claim 机制是教学设计,用统一的轮询函数演示"空闲后找活干"。CC 的实际实现是多个机制的组合,但目标一致——减少 Lead 的手动分配负担。
> Teaching note: This chapter's idle_poll + auto-claim mechanism is a teaching design, using a unified polling function to demonstrate "find work when idle." CC's actual implementation combines multiple mechanisms, but shares the same goal — reducing Lead's manual assignment burden.
### 一、CC 的空闲机制:组合路径,不是单一轮询
### 1. CC's Idle Mechanism: Combined Approach, Not Single Polling
教学版用一个 `idle_poll()` 统一处理空闲时的 inbox 检查和任务认领。CC 的实际实现是四个机制的组合:
Teaching version uses a single `idle_poll()` to handle both inbox checking and task claiming during idle. CC's actual implementation combines four mechanisms:
**idle_notification**:队友完成一轮工作后,`sendIdleNotification()``inProcessRunner.ts:569-589`)向 Lead 发送空闲通知。Lead 知道队友可用了,可以分配新任务或请求关机。
**idle_notification**: After completing a round of work, `sendIdleNotification()` (`inProcessRunner.ts:569-589`) sends an idle notification to Lead. Lead knows the teammate is available and can assign new tasks or request shutdown.
**mailbox 轮询**`waitForNextPromptOrShutdown()``inProcessRunner.ts:689-868`)是一个 **500ms 轮询循环**,持续检查三类来源:pending user messagesmailbox 文件消息、task list。shutdown_request 被优先处理(`inProcessRunner.ts:768-804`),不会被普通消息饿死。
**mailbox polling**: `waitForNextPromptOrShutdown()` (`inProcessRunner.ts:689-868`) is a **500ms polling loop** that continuously checks three sources: pending user messages, mailbox file messages, and task list. Shutdown requests are prioritized (`inProcessRunner.ts:768-804`), preventing starvation by regular messages.
**task watcher**`useTaskListWatcher``hooks/useTaskListWatcher.ts:34-189`)用 `fs.watch()` 监听 `.claude/tasks/` 目录变化1 秒 debounce当新任务创建或依赖解锁时触发检查。依赖判断`L197-207`)是"blockedBy 中没有未完成的任务",不是"blockedBy 为空"。
**task watcher**: `useTaskListWatcher` (`hooks/useTaskListWatcher.ts:34-189`) uses `fs.watch()` to monitor the `.claude/tasks/` directory with 1-second debounce, triggering checks when new tasks are created or dependencies unblock. The dependency check (`L197-207`) verifies "no incomplete tasks in blockedBy", not "blockedBy is empty".
**主动 claim**:轮询循环内部也会调用 `tryClaimNextTask()``inProcessRunner.ts:853-860`)——在等待期间主动从 task list 领取任务。所以"队友不主动轮询任务"不准确CC 同时有被动通知和主动认领。
**active claiming**: The polling loop also calls `tryClaimNextTask()` (`inProcessRunner.ts:853-860`) — actively claiming tasks from the task list while waiting. So "teammates don't actively poll for tasks" is inaccurate; CC has both passive notification and active claiming.
### 二、任务认领:文件锁 + 原子操作
### 2. Task Claiming: File Locks + Atomic Operations
`claimTask()``utils/tasks.ts:541-612`)用 `proper-lockfile` 的任务文件锁,在锁内完成读-检查-改-写。检查项owner 是否已存在(`L575-576`)、是否已完成(`L580-581`、blockedBy 中是否有未完成任务(`L585-594`)。`claimTaskWithBusyCheck()``utils/tasks.ts:614-692`)用 task-list 级别锁,把 busy check claim 做成原子操作,避免 TOCTOU
`claimTask()` (`utils/tasks.ts:541-612`) uses `proper-lockfile` task-level locks, performing read-check-modify-write within the lock. Checks: owner already exists (`L575-576`), already completed (`L580-581`), unresolved blockers in blockedBy (`L585-594`). `claimTaskWithBusyCheck()` (`utils/tasks.ts:614-692`) uses task-list level locks, making busy check and claim atomic to avoid TOCTOU.
`findAvailableTask()``inProcessRunner.ts:595-604`)的依赖判断也是"所有 blockedBy 已完成",用 `task.blockedBy.every(id => !unresolvedTaskIds.has(id))` 实现。`tryClaimNextTask()``inProcessRunner.ts:624-657`)在认领后把状态更新为 `in_progress`,让 UI 立即反映变化。
`findAvailableTask()` (`inProcessRunner.ts:595-604`) checks "all blockedBy completed" using `task.blockedBy.every(id => !unresolvedTaskIds.has(id))`. `tryClaimNextTask()` (`inProcessRunner.ts:624-657`) updates status to `in_progress` after claiming, so the UI immediately reflects the change.
### 三、教学版 vs CC 对比
### 3. Teaching Version vs CC Comparison
| 维度 | 教学版 (s17) | CC |
|------|-------------|-----|
| 空闲机制 | idle_poll 统一轮询(5s | idle_notification + 500ms mailbox 轮询 + task watcher |
| 任务发现 | scan_unclaimed_tasks(轮询) | useTaskListWatcher(文件监听)+ tryClaimNextTask(主动轮询) |
| 依赖判断 | can_start(所有 blockedBy 已完成) | findAvailableTask(同样语义) |
| 并发安全 | owner 检查(无文件锁) | proper-lockfile 任务锁 + task-list |
| shutdown 处理 | IDLE 直接分发,WORK 通过 handle_inbox_message | 500ms 轮询中优先处理 shutdown_request |
| 超时退出 | 60s 无新任务 | 无固定超时Lead 手动 shutdown |
| 身份保持 | messages 长度检测 | context compaction 保留 system prompt |
| claim 失败处理 | 检查返回值,失败不注入 | 文件锁保证原子性 |
| Dimension | Teaching (s17) | CC |
|-----------|----------------|-----|
| Idle mechanism | idle_poll unified polling (5s) | idle_notification + 500ms mailbox polling + task watcher |
| Task discovery | scan_unclaimed_tasks (polling) | useTaskListWatcher (file watching) + tryClaimNextTask (active polling) |
| Dependency check | can_start (all blockedBy completed) | findAvailableTask (same semantics) |
| Concurrency safety | Owner check (no file lock) | proper-lockfile task lock + task-list lock |
| Shutdown handling | IDLE dispatches directly, WORK via handle_inbox_message | 500ms polling loop prioritizes shutdown_request |
| Timeout exit | 60s with no new tasks | No fixed timeout, Lead manual shutdown |
| Identity persistence | Messages length detection | Context compaction preserves system prompt |
| Claim failure handling | Check return value, skip on failure | File locks guarantee atomicity |
教学版的 `idle_poll()` 把 CC 的四个机制合并成一个轮询函数——简化合理因为核心语义空闲时找活干、依赖解锁后可认领、shutdown 优先)是一致的。
Teaching version's `idle_poll()` merges CC's four mechanisms into one polling function — a reasonable simplification since the core semantics (find work when idle, claim after deps resolve, prioritize shutdown) are consistent.
</details>

View File

@@ -0,0 +1,273 @@
# s17: Autonomous Agents — 自己看板,自己认领
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → ... → s15 → s16 → `s17` → [s18](../s18_worktree_isolation/) → s19 → s20 → s21 → s22
> *"自己看板,自己认领"* — 空闲时轮询,有活就干。
>
> **Harness 层**: 自治 — 队友自组织,不依赖 Lead 分配。
> **核心与选学:** 空闲发现工作与原子认领是本章核心;身份重注入只是教学版的进阶补丁,第一次学习可以跳过。
---
## 问题
s16 的队友能通信、能握手关机。但每个队友等 Lead 分配任务——如果任务看板上有 10 个未认领任务Lead 得手动 assign 10 次。这不能扩展。队友应该自己看任务看板,发现没人做的任务就认领,做完再找下一个。
---
## 解决方案
![Autonomous Agents Overview](images/autonomous-agents-overview.svg)
沿用 S16 的教学版 MessageBus 和协议工具。本章新增:**idle_poll**(空闲时每 5 秒轮询一次)、**scan_unclaimed_tasks**(扫描看板上可认领的任务)、**自动认领**(找到任务就 claim不用 Lead 操心)。
队友生命周期从两阶段变成三阶段:
| 阶段 | 行为 | 退出条件 |
|------|------|---------|
| WORK | inbox → LLM → 工具循环 | `stop_reason != tool_use` |
| IDLE | 每 5s 轮询 inbox + 任务板 | 60s 超时 |
| SHUTDOWN | 发 summary退出 | — |
---
## 工作原理
### idle_poll: 空闲轮询
队友完成当前任务后不退出,进入 IDLE 阶段——每 5 秒检查一次有没有新工作:
```python
IDLE_POLL_INTERVAL = 5 # seconds
IDLE_TIMEOUT = 60 # seconds
def idle_poll(name, messages, role) -> str:
"""Return 'work', 'shutdown', or 'timeout'."""
for _ in range(IDLE_TIMEOUT // IDLE_POLL_INTERVAL):
time.sleep(IDLE_POLL_INTERVAL)
# ① 检查收件箱(优先)
inbox = BUS.read_inbox(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"], 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`)的任务:
```python
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
```
三个条件:必须是 pending、没有 owner、所有 blockedBy 依赖已完成。`can_start` 检查依赖任务的状态——有依赖不代表不能做只有被未完成的任务阻塞才不能做。教学版按文件名排序取第一个CC 用文件锁防止多个队友同时认领同一个任务。
### claim_task: owner 检查
自动认领时检查 claim 结果,不把失败当成功:
```python
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` 在文件锁内完成读-改-写(`utils/tasks.ts:541-612`)。
### 队友生命周期: WORK → IDLE → SHUTDOWN
s16 的队友做完任务就退出。s17 加了 IDLE 阶段,队友在外层循环中反复 WORK → IDLE
```python
# Outer loop: WORK → IDLE cycle
while True:
# WORK phase: 内层循环(最多 10 轮 LLM 调用)
for _ in range(10):
# 检查 inbox、处理协议消息、调 LLM、执行工具
...
if response.stop_reason != "tool_use":
break # WORK 阶段结束
# IDLE phase
idle_result = idle_poll(name, messages, 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` 直接检查并回复
### 进阶(选学):身份重注入
autoCompacts08之后队友的 messages 列表可能被压缩成一段摘要。每次进入新的 WORK 阶段时检查:
```python
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("创建数据库 schema")
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 → 发现"创建数据库 schema"
10. alice → claim_task → "创建数据库 schema" → 回到 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
```
两个队友并行认领、并行工作。Lead 只需要创建任务和启动队友,不需要手动分配。
---
## 相对 s16 的变更
| 组件 | 之前 (s16) | 之后 (s17) |
|------|-----------|-----------|
| 任务分配 | Lead 手动 assign | 队友自动认领can_start 检查依赖) |
| 队友状态 | WORK → IDLE每 1s 轮询 inbox→ WORK / SHUTDOWN | WORK → IDLE每 5s 轮询 inbox + 任务板60s 超时)→ WORK / SHUTDOWN |
| claim_task | 无 owner 检查 | 拒绝已有 owner 的任务 |
| IDLE 阶段关机 | 收到 shutdown_request 后退出 | 直接 dispatch shutdown 并退出 |
| Lead inbox | consume_lead_inbox 路由协议响应并注入上下文 | 沿用 consume_lead_inbox 机制 |
| 新函数 | 已有 consume_lead_inbox | idle_poll, scan_unclaimed_tasks沿用 consume_lead_inbox |
| 身份保持 | 仅 system prompt | 压缩后自动重注入 |
| Lead 工具 | 14 | 14不变 |
| 队友工具 | 5 | 8+ list_tasks, claim_task, complete_task |
| 队友退出条件 | WORK 完进入 IDLE等待 shutdown_request 后退出(无超时) | 60s 无新任务或收到 shutdown_request 后退出 |
---
## 试一下
```sh
cd learn-claude-code
python s17_autonomous_agents/code.py
```
试试这个 prompt
`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 → 每个任务有自己的工作目录,互不干扰。
<details>
<summary>深入 CC 源码</summary>
> 教学说明:本章的 idle_poll + auto-claim 机制是教学设计,用统一的轮询函数演示"空闲后找活干"。CC 的实际实现是多个机制的组合,但目标一致——减少 Lead 的手动分配负担。
### 一、CC 的空闲机制:组合路径,不是单一轮询
教学版用一个 `idle_poll()` 统一处理空闲时的 inbox 检查和任务认领。CC 的实际实现是四个机制的组合:
**idle_notification**:队友完成一轮工作后,`sendIdleNotification()``inProcessRunner.ts:569-589`)向 Lead 发送空闲通知。Lead 知道队友可用了,可以分配新任务或请求关机。
**mailbox 轮询**`waitForNextPromptOrShutdown()``inProcessRunner.ts:689-868`)是一个 **500ms 轮询循环**持续检查三类来源pending user messages、mailbox 文件消息、task list。shutdown_request 被优先处理(`inProcessRunner.ts:768-804`),不会被普通消息饿死。
**task watcher**`useTaskListWatcher``hooks/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` 的任务文件锁,在锁内完成读-检查-改-写。检查项owner 是否已存在(`L575-576`)、是否已完成(`L580-581`、blockedBy 中是否有未完成任务(`L585-594`)。`claimTaskWithBusyCheck()``utils/tasks.ts:614-692`)用 task-list 级别锁,把 busy check 和 claim 做成原子操作,避免 TOCTOU。
`findAvailableTask()``inProcessRunner.ts:595-604`)的依赖判断也是"所有 blockedBy 已完成",用 `task.blockedBy.every(id => !unresolvedTaskIds.has(id))` 实现。`tryClaimNextTask()``inProcessRunner.ts:624-657`)在认领后把状态更新为 `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 任务锁 + task-list 锁 |
| shutdown 处理 | IDLE 直接分发WORK 通过 handle_inbox_message | 500ms 轮询中优先处理 shutdown_request |
| 超时退出 | 60s 无新任务 | 无固定超时Lead 手动 shutdown |
| 身份保持 | messages 长度检测 | context compaction 保留 system prompt |
| claim 失败处理 | 检查返回值,失败不注入 | 文件锁保证原子性 |
教学版的 `idle_poll()` 把 CC 的四个机制合并成一个轮询函数——简化合理因为核心语义空闲时找活干、依赖解锁后可认领、shutdown 优先)是一致的。
</details>
<!-- translation-sync: zh@v2, en@v2, ja@v2 -->