s17: Autonomous Agents — Check the Board, Claim the Task
s01 → ... → s15 → s16 → s17 → s18 → 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
)
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:
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:
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):
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:
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:
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'sidle_pollchecks 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:
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
Two teammates claim and work in parallel. Lead only creates tasks and spawns teammates — no manual assignment needed.
Changes from s16
Try It
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.
Deep Dive into CC Source
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
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.
