add worktree & up task、teammate etc
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# s01: The Agent Loop
|
||||
|
||||
> The entire secret of AI coding agents is a while loop that feeds tool results back to the model until the model decides to stop.
|
||||
> The core of a coding agent is a while loop that feeds tool results back to the model until the model decides to stop.
|
||||
|
||||
## The Problem
|
||||
|
||||
@@ -59,7 +59,8 @@ messages.append({"role": "assistant", "content": response.content})
|
||||
```
|
||||
|
||||
4. We check the stop reason. If the model did not call a tool, the loop
|
||||
ends. This is the only exit condition.
|
||||
ends. In this minimal lesson implementation, this is the only loop exit
|
||||
condition.
|
||||
|
||||
```python
|
||||
if response.stop_reason != "tool_use":
|
||||
@@ -126,7 +127,7 @@ This is session 1 -- the starting point. There is no prior session.
|
||||
|
||||
## Design Rationale
|
||||
|
||||
This loop is the universal foundation of all LLM-based agents. Production implementations add error handling, token counting, streaming, and retry logic, but the fundamental structure is unchanged. The simplicity is the point: one exit condition (`stop_reason != "tool_use"`) controls the entire flow. Everything else in this course -- tools, planning, compression, teams -- layers on top of this loop without modifying it. Understanding this loop means understanding every agent.
|
||||
This loop is the foundation of LLM-based agents. Production implementations add error handling, token counting, streaming, retry logic, permission policy, and lifecycle orchestration, but the core interaction pattern still starts here. The simplicity is the point for this session: in this minimal implementation, one exit condition (`stop_reason != "tool_use"`) controls the flow we need to learn first. Everything else in this course layers on top of this loop. Understanding this loop gives you the base model, not the full production architecture.
|
||||
|
||||
## Try It
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# s02: Tools
|
||||
|
||||
> A dispatch map routes tool calls to handler functions -- the loop itself does not change at all.
|
||||
> A dispatch map routes tool calls to handler functions. The loop stays identical.
|
||||
|
||||
## The Problem
|
||||
|
||||
@@ -133,7 +133,7 @@ def agent_loop(messages: list):
|
||||
|
||||
## Design Rationale
|
||||
|
||||
The dispatch map pattern scales linearly -- adding a tool means adding one handler and one schema entry. The loop never changes. This separation of concerns (loop vs handlers) is why agent frameworks can support dozens of tools without increasing control flow complexity. The pattern also enables independent testing of each handler in isolation, since handlers are pure functions with no coupling to the loop. Any agent that outgrows a dispatch map has a design problem, not a scaling problem.
|
||||
The dispatch map scales linearly: add a tool, add a handler, add a schema entry. The loop never changes. Handlers are pure functions, so they test in isolation. Any agent that outgrows a dispatch map has a design problem, not a scaling problem.
|
||||
|
||||
## Try It
|
||||
|
||||
|
||||
@@ -19,9 +19,7 @@ explicitly. The model creates a plan, marks items in_progress as it works,
|
||||
and marks them completed when done. A nag reminder injects a nudge if the
|
||||
model goes 3+ rounds without updating its todos.
|
||||
|
||||
Teaching simplification: the nag threshold of 3 rounds is set low for
|
||||
teaching visibility. Production agents typically use a higher threshold
|
||||
around 10 to avoid excessive prompting.
|
||||
Note: the nag threshold of 3 rounds is low for visibility. Production systems tune higher. From s07, this course switches to the Task board for durable multi-step work; TodoWrite remains available for quick checklists.
|
||||
|
||||
## The Solution
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ does this project use?" might require reading 5 files, but the parent
|
||||
agent does not need all 5 file contents in its history -- it just needs
|
||||
the answer: "pytest with conftest.py configuration."
|
||||
|
||||
The solution is process isolation: spawn a child agent with `messages=[]`.
|
||||
In this course, a practical solution is fresh-context isolation: spawn a child agent with `messages=[]`.
|
||||
The child explores, reads files, runs commands. When it finishes, only its
|
||||
final text response returns to the parent. The child's entire message
|
||||
history is discarded.
|
||||
@@ -137,11 +137,10 @@ def run_subagent(prompt: str) -> str:
|
||||
| Context | Single shared | Parent + child isolation |
|
||||
| Subagent | None | `run_subagent()` function |
|
||||
| Return value | N/A | Summary text only |
|
||||
| Todo system | TodoManager | Removed (not needed here) |
|
||||
|
||||
## Design Rationale
|
||||
|
||||
Process isolation gives context isolation for free. A fresh `messages[]` means the subagent cannot be confused by the parent's conversation history. The tradeoff is communication overhead -- results must be compressed back to the parent, losing detail. This is the same tradeoff as OS process isolation: safety and cleanliness in exchange for serialization cost. Limiting subagent depth (no recursive spawning) prevents unbounded resource consumption, and a max iteration count ensures runaway children terminate.
|
||||
Fresh-context isolation is a practical way to approximate context isolation in this session. A fresh `messages[]` means the subagent starts without the parent's conversation history. The tradeoff is communication overhead -- results must be compressed back to the parent, losing detail. This is a message-history isolation strategy, not OS process isolation. Limiting subagent depth (no recursive spawning) prevents unbounded resource consumption, and a max iteration count ensures runaway children terminate.
|
||||
|
||||
## Try It
|
||||
|
||||
|
||||
@@ -144,7 +144,6 @@ class SkillLoader:
|
||||
| System prompt | Static string | + skill descriptions |
|
||||
| Knowledge | None | .skills/*.md files |
|
||||
| Injection | None | Two-layer (system + result)|
|
||||
| Subagent | `run_subagent()` | Removed (different focus) |
|
||||
|
||||
## Design Rationale
|
||||
|
||||
|
||||
@@ -162,7 +162,6 @@ def agent_loop(messages):
|
||||
| Auto-compact | None | Token threshold trigger |
|
||||
| Manual compact | None | `compact` tool |
|
||||
| Transcripts | None | Saved to .transcripts/ |
|
||||
| Skills | load_skill | Removed (different focus) |
|
||||
|
||||
## Design Rationale
|
||||
|
||||
|
||||
+37
-34
@@ -1,28 +1,31 @@
|
||||
# s07: Tasks
|
||||
|
||||
> Tasks persist as JSON files on the filesystem with a dependency graph, so they survive context compression and can be shared across agents.
|
||||
> Tasks are persisted as JSON files with a dependency graph, so state survives context compression and can be shared across agents.
|
||||
|
||||
## The Problem
|
||||
## Problem
|
||||
|
||||
In-memory state like TodoManager (s03) is lost when the context is
|
||||
compressed (s06). After auto_compact replaces messages with a summary,
|
||||
the todo list is gone. The agent has to reconstruct it from the summary
|
||||
text, which is lossy and error-prone.
|
||||
In-memory state (for example the TodoManager from s03) is fragile under compression (s06). Once earlier turns are compacted into summaries, in-memory todo state is gone.
|
||||
|
||||
This is the critical s06-to-s07 bridge: TodoManager items die with
|
||||
compression; file-based tasks don't. Moving state to the filesystem
|
||||
makes it compression-proof.
|
||||
s06 -> s07 is the key transition:
|
||||
|
||||
More fundamentally, in-memory state is invisible to other agents.
|
||||
When we eventually build teams (s09+), teammates need a shared task
|
||||
board. In-memory data structures are process-local.
|
||||
1. Todo list state in memory is conversational and lossy.
|
||||
2. Task board state on disk is durable and recoverable.
|
||||
|
||||
The solution is to persist tasks as JSON files in `.tasks/`. Each task
|
||||
is a separate file with an ID, subject, status, and dependency graph.
|
||||
Completing task 1 automatically unblocks task 2 if task 2 has
|
||||
`blockedBy: [1]`. The file system becomes the source of truth.
|
||||
A second issue is visibility: in-memory structures are process-local, so teammates cannot reliably share that state.
|
||||
|
||||
## The Solution
|
||||
## When to Use Task vs Todo
|
||||
|
||||
From s07 onward, Task is the default. Todo remains for short linear checklists.
|
||||
|
||||
## Quick Decision Matrix
|
||||
|
||||
| Situation | Prefer | Why |
|
||||
|---|---|---|
|
||||
| Short, single-session checklist | Todo | Lowest ceremony, fastest capture |
|
||||
| Cross-session work, dependencies, or teammates | Task | Durable state, dependency graph, shared visibility |
|
||||
| Unsure which one to use | Task | Easier to simplify later than migrate mid-run |
|
||||
|
||||
## Solution
|
||||
|
||||
```
|
||||
.tasks/
|
||||
@@ -42,7 +45,7 @@ Dependency resolution:
|
||||
|
||||
## How It Works
|
||||
|
||||
1. The TaskManager provides CRUD operations. Each task is a JSON file.
|
||||
1. TaskManager provides CRUD with one JSON file per task.
|
||||
|
||||
```python
|
||||
class TaskManager:
|
||||
@@ -61,8 +64,7 @@ class TaskManager:
|
||||
return json.dumps(task, indent=2)
|
||||
```
|
||||
|
||||
2. When a task is marked completed, `_clear_dependency` removes its ID
|
||||
from all other tasks' `blockedBy` lists.
|
||||
2. Completing a task clears that dependency from other tasks.
|
||||
|
||||
```python
|
||||
def _clear_dependency(self, completed_id: int):
|
||||
@@ -73,8 +75,7 @@ def _clear_dependency(self, completed_id: int):
|
||||
self._save(task)
|
||||
```
|
||||
|
||||
3. The `update` method handles status changes and bidirectional dependency
|
||||
wiring.
|
||||
3. `update` handles status transitions and dependency wiring.
|
||||
|
||||
```python
|
||||
def update(self, task_id, status=None,
|
||||
@@ -94,7 +95,7 @@ def update(self, task_id, status=None,
|
||||
self._save(task)
|
||||
```
|
||||
|
||||
4. Four task tools are added to the dispatch map.
|
||||
4. Task tools are added to the dispatch map.
|
||||
|
||||
```python
|
||||
TOOL_HANDLERS = {
|
||||
@@ -109,8 +110,7 @@ TOOL_HANDLERS = {
|
||||
|
||||
## Key Code
|
||||
|
||||
The TaskManager with dependency graph (from `agents/s07_task_system.py`,
|
||||
lines 46-123):
|
||||
TaskManager with dependency graph (from `agents/s07_task_system.py`, lines 46-123):
|
||||
|
||||
```python
|
||||
class TaskManager:
|
||||
@@ -145,17 +145,20 @@ class TaskManager:
|
||||
|
||||
## What Changed From s06
|
||||
|
||||
| Component | Before (s06) | After (s07) |
|
||||
|----------------|------------------|----------------------------|
|
||||
| Tools | 5 | 8 (+task_create/update/list/get)|
|
||||
| State storage | In-memory only | JSON files in .tasks/ |
|
||||
| Dependencies | None | blockedBy + blocks graph |
|
||||
| Compression | Three-layer | Removed (different focus) |
|
||||
| Persistence | Lost on compact | Survives compression |
|
||||
| Component | Before (s06) | After (s07) |
|
||||
|---|---|---|
|
||||
| Tools | 5 | 8 (`task_create/update/list/get`) |
|
||||
| State storage | In-memory only | JSON files in `.tasks/` |
|
||||
| Dependencies | None | `blockedBy + blocks` graph |
|
||||
| Persistence | Lost on compact | Survives compression |
|
||||
|
||||
## Design Rationale
|
||||
|
||||
File-based state survives context compression. When the agent's conversation is compacted, in-memory state is lost, but tasks written to disk persist. The dependency graph ensures correct execution order even after context loss. This is the bridge between ephemeral conversation and persistent work -- the agent can forget conversation details but always has the task board to remind it what needs doing. The filesystem as source of truth also enables future multi-agent sharing, since any process can read the same JSON files.
|
||||
File-based state survives compaction and process restarts. The dependency graph preserves execution order even when conversation details are forgotten. This turns transient chat context into durable work state.
|
||||
|
||||
Durability still needs a write discipline: reload task JSON before each write, validate expected `status/blockedBy`, then persist atomically. Otherwise concurrent writers can overwrite each other.
|
||||
|
||||
Course-level implication: s07+ defaults to Task because it better matches long-running and collaborative engineering workflows.
|
||||
|
||||
## Try It
|
||||
|
||||
@@ -164,7 +167,7 @@ cd learn-claude-code
|
||||
python agents/s07_task_system.py
|
||||
```
|
||||
|
||||
Example prompts to try:
|
||||
Suggested prompts:
|
||||
|
||||
1. `Create 3 tasks: "Setup project", "Write code", "Write tests". Make them depend on each other in order.`
|
||||
2. `List all tasks and show the dependency graph`
|
||||
|
||||
@@ -168,7 +168,6 @@ class BackgroundManager:
|
||||
| Execution | Blocking only | Blocking + background threads|
|
||||
| Notification | None | Queue drained per loop |
|
||||
| Concurrency | None | Daemon threads |
|
||||
| Task system | File-based CRUD | Removed (different focus) |
|
||||
|
||||
## Design Rationale
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# s09: Agent Teams
|
||||
|
||||
> Persistent teammates with JSONL inboxes turn isolated agents into a communicating team -- spawn, message, broadcast, and drain.
|
||||
> Persistent teammates with JSONL inboxes are one teaching protocol for turning isolated agents into a communicating team -- spawn, message, broadcast, and drain.
|
||||
|
||||
## The Problem
|
||||
|
||||
@@ -215,7 +215,7 @@ pattern used here is safe for the teaching scenario.
|
||||
|
||||
## Design Rationale
|
||||
|
||||
File-based mailboxes (append-only JSONL) provide concurrency-safe inter-agent communication. Append is atomic on most filesystems, avoiding lock contention. The "drain on read" pattern (read all, truncate) gives batch delivery. This is simpler and more robust than shared memory or socket-based IPC for agent coordination. The tradeoff is latency -- messages are only seen at the next poll -- but for LLM-driven agents where each turn takes seconds, polling latency is negligible compared to inference time.
|
||||
File-based mailboxes (append-only JSONL) are easy to inspect and reason about in a teaching codebase. The "drain on read" pattern (read all, truncate) gives batch delivery with very little machinery. The tradeoff is latency -- messages are only seen at the next poll -- but for LLM-driven agents where each turn takes seconds, polling latency is acceptable for this course.
|
||||
|
||||
## Try It
|
||||
|
||||
|
||||
@@ -20,10 +20,7 @@ original system prompt identity ("you are alice, role: coder") fades.
|
||||
Identity re-injection solves this by inserting an identity block at the
|
||||
start of compressed contexts.
|
||||
|
||||
Teaching simplification: the token estimation used here is rough
|
||||
(characters / 4). Production systems use proper tokenizer libraries.
|
||||
The nag threshold of 3 rounds (from s03) is set low for teaching
|
||||
visibility; production agents typically use a higher threshold around 10.
|
||||
Note: token estimation here uses characters/4 (rough). The nag threshold of 3 rounds is low for teaching visibility.
|
||||
|
||||
## The Solution
|
||||
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
# s12: Worktree + Task Isolation
|
||||
|
||||
> Isolate by directory, coordinate by task ID -- tasks are the control plane, worktrees are the execution plane, and an event stream makes every lifecycle step observable.
|
||||
|
||||
## The Problem
|
||||
|
||||
By s11, agents can claim and complete tasks autonomously. But every task runs in one shared directory. Ask two agents to refactor different modules at the same time and you hit three failure modes:
|
||||
|
||||
Agent A edits `auth.py`. Agent B edits `auth.py`. Neither knows the other touched it. Unstaged changes collide, task status says "in_progress" but the directory is a mess, and when something breaks there is no way to roll back one agent's work without destroying the other's. The task board tracks _what to do_ but has no opinion about _where to do it_.
|
||||
|
||||
The fix is to separate the two concerns. Tasks manage goals. Worktrees manage execution context. Bind them by task ID, and each agent gets its own directory, its own branch, and a clean teardown path.
|
||||
|
||||
## The Solution
|
||||
|
||||
```
|
||||
Control Plane (.tasks/) Execution Plane (.worktrees/)
|
||||
+---------------------------+ +---------------------------+
|
||||
| task_1.json | | index.json |
|
||||
| id: 1 | | name: "auth-refactor" |
|
||||
| subject: "Auth refactor"| bind | path: ".worktrees/..." |
|
||||
| status: "in_progress" | <----> | branch: "wt/auth-..." |
|
||||
| worktree: "auth-refactor"| | task_id: 1 |
|
||||
+---------------------------+ | status: "active" |
|
||||
+---------------------------+
|
||||
| task_2.json | | |
|
||||
| id: 2 | bind | name: "ui-login" |
|
||||
| subject: "Login page" | <----> | task_id: 2 |
|
||||
| worktree: "ui-login" | | status: "active" |
|
||||
+---------------------------+ +---------------------------+
|
||||
|
|
||||
+---------------------------+
|
||||
| events.jsonl (append-only)|
|
||||
| worktree.create.before |
|
||||
| worktree.create.after |
|
||||
| worktree.remove.after |
|
||||
| task.completed |
|
||||
+---------------------------+
|
||||
```
|
||||
|
||||
Three state layers make this work:
|
||||
|
||||
1. **Control plane** (`.tasks/task_*.json`) -- what is assigned, in progress, or done. Key fields: `id`, `subject`, `status`, `owner`, `worktree`.
|
||||
2. **Execution plane** (`.worktrees/index.json`) -- where commands run and whether the workspace is still valid. Key fields: `name`, `path`, `branch`, `task_id`, `status`.
|
||||
3. **Runtime state** (in-memory) -- per-turn execution continuity: `current_task`, `current_worktree`, `tool_result`, `error`.
|
||||
|
||||
## How It Works
|
||||
|
||||
The lifecycle has five steps. Each step is a tool call.
|
||||
|
||||
1. **Create a task.** Persist the goal first. The task starts as `pending` with an empty `worktree` field.
|
||||
|
||||
```python
|
||||
task = {
|
||||
"id": self._next_id,
|
||||
"subject": subject,
|
||||
"status": "pending",
|
||||
"owner": "",
|
||||
"worktree": "",
|
||||
}
|
||||
self._save(task)
|
||||
```
|
||||
|
||||
2. **Create a worktree.** Allocate an isolated directory and branch. If you pass `task_id`, the task auto-advances to `in_progress` and the binding is written to both sides.
|
||||
|
||||
```python
|
||||
self._run_git(["worktree", "add", "-b", branch, str(path), base_ref])
|
||||
|
||||
entry = {
|
||||
"name": name,
|
||||
"path": str(path),
|
||||
"branch": branch,
|
||||
"task_id": task_id,
|
||||
"status": "active",
|
||||
}
|
||||
idx["worktrees"].append(entry)
|
||||
self._save_index(idx)
|
||||
|
||||
if task_id is not None:
|
||||
self.tasks.bind_worktree(task_id, name)
|
||||
```
|
||||
|
||||
3. **Run commands in the worktree.** `worktree_run` sets `cwd` to the worktree path. Edits happen in the isolated directory, not the shared workspace.
|
||||
|
||||
```python
|
||||
r = subprocess.run(
|
||||
command,
|
||||
shell=True,
|
||||
cwd=path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300,
|
||||
)
|
||||
```
|
||||
|
||||
4. **Observe.** `worktree_status` shows git state inside the isolated context. `worktree_events` queries the append-only event stream.
|
||||
|
||||
5. **Close out.** Two choices:
|
||||
- `worktree_keep(name)` -- preserve the directory, mark lifecycle as `kept`.
|
||||
- `worktree_remove(name, complete_task=True)` -- remove the directory, complete the bound task, unbind, and emit `task.completed`. This is the closeout pattern: one call handles teardown and task completion together.
|
||||
|
||||
## State Machines
|
||||
|
||||
```
|
||||
Task: pending -------> in_progress -------> completed
|
||||
(worktree_create (worktree_remove
|
||||
with task_id) with complete_task=true)
|
||||
|
||||
Worktree: absent --------> active -----------> removed | kept
|
||||
(worktree_create) (worktree_remove | worktree_keep)
|
||||
```
|
||||
|
||||
## Key Code
|
||||
|
||||
The closeout pattern -- teardown + task completion in one operation (from `agents/s12_worktree_task_isolation.py`):
|
||||
|
||||
```python
|
||||
def remove(self, name: str, force: bool = False, complete_task: bool = False) -> str:
|
||||
wt = self._find(name)
|
||||
if not wt:
|
||||
return f"Error: Unknown worktree '{name}'"
|
||||
|
||||
self.events.emit(
|
||||
"worktree.remove.before",
|
||||
task={"id": wt.get("task_id")} if wt.get("task_id") is not None else {},
|
||||
worktree={"name": name, "path": wt.get("path")},
|
||||
)
|
||||
try:
|
||||
args = ["worktree", "remove"]
|
||||
if force:
|
||||
args.append("--force")
|
||||
args.append(wt["path"])
|
||||
self._run_git(args)
|
||||
|
||||
if complete_task and wt.get("task_id") is not None:
|
||||
task_id = wt["task_id"]
|
||||
self.tasks.update(task_id, status="completed")
|
||||
self.tasks.unbind_worktree(task_id)
|
||||
self.events.emit("task.completed", task={
|
||||
"id": task_id, "status": "completed",
|
||||
}, worktree={"name": name})
|
||||
|
||||
idx = self._load_index()
|
||||
for item in idx.get("worktrees", []):
|
||||
if item.get("name") == name:
|
||||
item["status"] = "removed"
|
||||
item["removed_at"] = time.time()
|
||||
self._save_index(idx)
|
||||
|
||||
self.events.emit(
|
||||
"worktree.remove.after",
|
||||
task={"id": wt.get("task_id")} if wt.get("task_id") is not None else {},
|
||||
worktree={"name": name, "path": wt.get("path"), "status": "removed"},
|
||||
)
|
||||
return f"Removed worktree '{name}'"
|
||||
except Exception as e:
|
||||
self.events.emit(
|
||||
"worktree.remove.failed",
|
||||
worktree={"name": name},
|
||||
error=str(e),
|
||||
)
|
||||
raise
|
||||
```
|
||||
|
||||
The task-side binding (from `agents/s12_worktree_task_isolation.py`):
|
||||
|
||||
```python
|
||||
def bind_worktree(self, task_id: int, worktree: str, owner: str = "") -> str:
|
||||
task = self._load(task_id)
|
||||
task["worktree"] = worktree
|
||||
if task["status"] == "pending":
|
||||
task["status"] = "in_progress"
|
||||
task["updated_at"] = time.time()
|
||||
self._save(task)
|
||||
```
|
||||
|
||||
The dispatch map wiring all tools together:
|
||||
|
||||
```python
|
||||
TOOL_HANDLERS = {
|
||||
"bash": lambda **kw: run_bash(kw["command"]),
|
||||
"read_file": lambda **kw: run_read(kw["path"], kw.get("limit")),
|
||||
"write_file": lambda **kw: run_write(kw["path"], kw["content"]),
|
||||
"edit_file": lambda **kw: run_edit(kw["path"], kw["old_text"], kw["new_text"]),
|
||||
"task_create": lambda **kw: TASKS.create(kw["subject"], kw.get("description", "")),
|
||||
"task_list": lambda **kw: TASKS.list_all(),
|
||||
"task_get": lambda **kw: TASKS.get(kw["task_id"]),
|
||||
"task_update": lambda **kw: TASKS.update(kw["task_id"], kw.get("status"), kw.get("owner")),
|
||||
"task_bind_worktree": lambda **kw: TASKS.bind_worktree(kw["task_id"], kw["worktree"]),
|
||||
"worktree_create": lambda **kw: WORKTREES.create(kw["name"], kw.get("task_id")),
|
||||
"worktree_list": lambda **kw: WORKTREES.list_all(),
|
||||
"worktree_status": lambda **kw: WORKTREES.status(kw["name"]),
|
||||
"worktree_run": lambda **kw: WORKTREES.run(kw["name"], kw["command"]),
|
||||
"worktree_keep": lambda **kw: WORKTREES.keep(kw["name"]),
|
||||
"worktree_remove": lambda **kw: WORKTREES.remove(kw["name"], kw.get("force", False), kw.get("complete_task", False)),
|
||||
"worktree_events": lambda **kw: EVENTS.list_recent(kw.get("limit", 20)),
|
||||
}
|
||||
```
|
||||
|
||||
## Event Stream
|
||||
|
||||
Every lifecycle transition emits a before/after/failed triplet to `.worktrees/events.jsonl`. This is an append-only log, not a replacement for task/worktree state files.
|
||||
|
||||
Events emitted:
|
||||
|
||||
- `worktree.create.before` / `worktree.create.after` / `worktree.create.failed`
|
||||
- `worktree.remove.before` / `worktree.remove.after` / `worktree.remove.failed`
|
||||
- `worktree.keep`
|
||||
- `task.completed` (when `complete_task=true` succeeds)
|
||||
|
||||
Payload shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "worktree.remove.after",
|
||||
"task": {"id": 7, "status": "completed"},
|
||||
"worktree": {"name": "auth-refactor", "path": "...", "status": "removed"},
|
||||
"ts": 1730000000
|
||||
}
|
||||
```
|
||||
|
||||
This gives you three things: policy decoupling (audit and notifications stay outside the core flow), failure compensation (`*.failed` records mark partial transitions), and queryability (`worktree_events` tool reads the log directly).
|
||||
|
||||
## What Changed From s11
|
||||
|
||||
| Component | Before (s11) | After (s12) |
|
||||
|--------------------|----------------------------|----------------------------------------------|
|
||||
| Coordination state | Task board (`owner/status`) | Task board + explicit `worktree` binding |
|
||||
| Execution scope | Shared directory | Task-scoped isolated directory |
|
||||
| Recoverability | Task status only | Task status + worktree index |
|
||||
| Teardown semantics | Task completion | Task completion + explicit keep/remove |
|
||||
| Lifecycle visibility | Implicit in logs | Explicit events in `.worktrees/events.jsonl` |
|
||||
|
||||
## Design Rationale
|
||||
|
||||
Separating control plane from execution plane means you can reason about _what to do_ and _where to do it_ independently. A task can exist without a worktree (planning phase). A worktree can exist without a task (ad-hoc exploration). Binding them is an explicit action that writes state to both sides. This composability is the point -- it keeps the system recoverable after crashes. After an interruption, state reconstructs from `.tasks/` + `.worktrees/index.json` on disk. Volatile in-memory session state downgrades into explicit, durable file state. The event stream adds observability without coupling side effects into the critical path: auditing, notifications, and quota checks consume events rather than intercepting state writes.
|
||||
|
||||
## Try It
|
||||
|
||||
```sh
|
||||
cd learn-claude-code
|
||||
python agents/s12_worktree_task_isolation.py
|
||||
```
|
||||
|
||||
Example prompts to try:
|
||||
|
||||
1. `Create tasks for backend auth and frontend login page, then list tasks.`
|
||||
2. `Create worktree "auth-refactor" for task 1, create worktree "ui-login", then bind task 2 to "ui-login".`
|
||||
3. `Run "git status --short" in worktree "auth-refactor".`
|
||||
4. `Keep worktree "ui-login", then list worktrees and inspect worktree events.`
|
||||
5. `Remove worktree "auth-refactor" with complete_task=true, then list tasks/worktrees/events.`
|
||||
@@ -1,6 +1,6 @@
|
||||
# s01: The Agent Loop
|
||||
|
||||
> AIコーディングエージェントの秘密はすべて、モデルが「終了」と判断するまでツール結果をモデルにフィードバックし続けるwhileループにある。
|
||||
> AIコーディングエージェントの中核は、モデルが「終了」と判断するまでツール結果をモデルにフィードバックし続ける while ループにある。
|
||||
|
||||
## 問題
|
||||
|
||||
@@ -49,7 +49,7 @@ response = client.messages.create(
|
||||
messages.append({"role": "assistant", "content": response.content})
|
||||
```
|
||||
|
||||
4. stop reasonを確認する。モデルがツールを呼び出さなかった場合、ループは終了する。これが唯一の終了条件だ。
|
||||
4. stop reasonを確認する。モデルがツールを呼び出さなかった場合、ループは終了する。この最小実装では、これが唯一のループ終了条件だ。
|
||||
|
||||
```python
|
||||
if response.stop_reason != "tool_use":
|
||||
@@ -115,7 +115,7 @@ def agent_loop(messages: list):
|
||||
|
||||
## 設計原理
|
||||
|
||||
このループはすべてのLLMベースエージェントの普遍的な基盤だ。本番実装ではエラーハンドリング、トークンカウント、ストリーミング、リトライロジックが追加されるが、根本的な構造は変わらない。シンプルさこそがポイントだ: 1つの終了条件(`stop_reason != "tool_use"`)がフロー全体を制御する。本コースの他のすべて -- ツール、計画、圧縮、チーム -- はこのループの上に積み重なるが、ループ自体は変更しない。このループを理解することは、すべてのエージェントを理解することだ。
|
||||
このループは LLM ベースエージェントの土台だ。本番実装ではエラーハンドリング、トークン計測、ストリーミング、リトライに加え、権限ポリシーやライフサイクル編成が追加されるが、コアの相互作用パターンはここから始まる。シンプルさこそこの章の狙いであり、この最小実装では 1 つの終了条件(`stop_reason != "tool_use"`)で学習に必要な制御を示す。本コースの他の要素はこのループに積み重なる。つまり、このループの理解は基礎であって、本番アーキテクチャ全体そのものではない。
|
||||
|
||||
## 試してみる
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
解決策は構造化された状態管理だ: モデルが明示的に書き込むTodoManager。モデルは計画を作成し、作業中のアイテムをin_progressとしてマークし、完了時にcompletedとマークする。nagリマインダーは、モデルが3ラウンド以上todoを更新しなかった場合にナッジを注入する。
|
||||
|
||||
教育上の簡略化: nagの閾値3ラウンドは教育目的の可視化のために低く設定されている。本番のエージェントでは過剰なプロンプトを避けるため閾値は約10に設定されている。
|
||||
注: nag 閾値 3 ラウンドは可視化のために低く設定。本番ではより高い値に調整される。s07 以降は永続的なマルチステップ作業に Task ボードを使用。TodoWrite は軽量チェックリストとして引き続き利用可能。
|
||||
|
||||
## 解決策
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
これは探索的タスクで特に深刻だ。「このプロジェクトはどのテストフレームワークを使っているか」という質問には5つのファイルを読む必要があるかもしれないが、親エージェントには5つのファイルの内容すべては不要だ -- 「pytest with conftest.py configuration」という回答だけが必要なのだ。
|
||||
|
||||
解決策はプロセスの分離だ: `messages=[]`で子エージェントを生成する。子は探索し、ファイルを読み、コマンドを実行する。終了時には最終的なテキストレスポンスだけが親に返される。子のメッセージ履歴全体は破棄される。
|
||||
このコースでの実用的な解決策は fresh `messages[]` 分離だ: `messages=[]`で子エージェントを生成する。子は探索し、ファイルを読み、コマンドを実行する。終了時には最終的なテキストレスポンスだけが親に返される。子のメッセージ履歴全体は破棄される。
|
||||
|
||||
## 解決策
|
||||
|
||||
@@ -124,11 +124,10 @@ def run_subagent(prompt: str) -> str:
|
||||
| Context | Single shared | Parent + child isolation |
|
||||
| Subagent | None | `run_subagent()` function |
|
||||
| Return value | N/A | Summary text only |
|
||||
| Todo system | TodoManager | Removed (not needed here) |
|
||||
|
||||
## 設計原理
|
||||
|
||||
プロセス分離はコンテキスト分離を無料で提供する。新しい`messages[]`は、サブエージェントが親の会話履歴に混乱させられないことを意味する。トレードオフは通信オーバーヘッドだ -- 結果は親に圧縮して返す必要があり、詳細が失われる。これはOSのプロセス分離と同じトレードオフだ: シリアライゼーションコストと引き換えに安全性とクリーンさを得る。サブエージェントの深さ制限(再帰的なスポーンは不可)は無制限のリソース消費を防ぎ、最大反復回数は暴走した子プロセスの終了を保証する。
|
||||
このセッションでは、fresh `messages[]` 分離はコンテキスト分離を近似する実用手段だ。新しい`messages[]`により、サブエージェントは親の会話履歴を持たずに開始する。トレードオフは通信オーバーヘッドで、結果を親へ圧縮して返すため詳細が失われる。これはメッセージ履歴の分離戦略であり、OSのプロセス分離そのものではない。サブエージェントの深さ制限(再帰スポーン不可)は無制限のリソース消費を防ぎ、最大反復回数は暴走した子処理の終了を保証する。
|
||||
|
||||
## 試してみる
|
||||
|
||||
|
||||
@@ -132,7 +132,6 @@ class SkillLoader:
|
||||
| System prompt | Static string | + skill descriptions |
|
||||
| Knowledge | None | .skills/*.md files |
|
||||
| Injection | None | Two-layer (system + result)|
|
||||
| Subagent | `run_subagent()` | Removed (different focus) |
|
||||
|
||||
## 設計原理
|
||||
|
||||
|
||||
@@ -149,7 +149,6 @@ def agent_loop(messages):
|
||||
| Auto-compact | None | Token threshold trigger |
|
||||
| Manual compact | None | `compact` tool |
|
||||
| Transcripts | None | Saved to .transcripts/ |
|
||||
| Skills | load_skill | Removed (different focus) |
|
||||
|
||||
## 設計原理
|
||||
|
||||
|
||||
+36
-20
@@ -1,16 +1,29 @@
|
||||
# s07: Tasks
|
||||
|
||||
> タスクはファイルシステム上にJSON形式で依存グラフ付きで永続化され、コンテキスト圧縮後も生き残り、複数エージェント間で共有できる。
|
||||
> タスクを依存グラフ付き JSON として永続化し、コンテキスト圧縮後も状態を保持し、複数エージェントで共有できるようにする。
|
||||
|
||||
## 問題
|
||||
|
||||
インメモリの状態であるTodoManager(s03)は、コンテキストが圧縮(s06)されると失われる。auto_compactがメッセージを要約で置換した後、todoリストは消える。エージェントは要約テキストからそれを再構成しなければならないが、これは不正確でエラーが起きやすい。
|
||||
インメモリ状態(s03 の TodoManager など)は、s06 の圧縮後に失われやすい。古いターンが要約化されると、Todo 状態は会話の外に残らない。
|
||||
|
||||
これがs06からs07への重要な橋渡しだ: TodoManagerのアイテムは圧縮と共に死ぬが、ファイルベースのタスクは死なない。状態をファイルシステムに移すことで、圧縮に対する耐性が得られる。
|
||||
s06 -> s07 の本質は次の切替:
|
||||
|
||||
さらに根本的な問題として、インメモリの状態は他のエージェントからは見えない。最終的にチーム(s09以降)を構築する際、チームメイトには共有のタスクボードが必要だ。インメモリのデータ構造はプロセスローカルだ。
|
||||
1. メモリ上 Todo は会話依存で失われやすい。
|
||||
2. ディスク上 Task は永続で復元しやすい。
|
||||
|
||||
解決策はタスクを`.tasks/`にJSON形式で永続化すること。各タスクはID、件名、ステータス、依存グラフを持つ個別のファイルだ。タスク1を完了すると、タスク2が`blockedBy: [1]`を持つ場合、自動的にタスク2のブロックが解除される。ファイルシステムが信頼できる情報源となる。
|
||||
さらに可視性の問題がある。インメモリ構造はプロセスローカルであり、チームメイト間の共有が不安定になる。
|
||||
|
||||
## Task vs Todo: 使い分け
|
||||
|
||||
s07 以降は Task がデフォルト。Todo は短い直線的チェックリスト用に残る。
|
||||
|
||||
## クイック判定マトリクス
|
||||
|
||||
| 状況 | 優先 | 理由 |
|
||||
|---|---|---|
|
||||
| 短時間・単一セッション・直線的チェック | Todo | 儀式が最小で記録が速い |
|
||||
| セッション跨ぎ・依存関係・複数担当 | Task | 永続性、依存表現、協調可視性が必要 |
|
||||
| 迷う場合 | Task | 後で簡略化する方が、途中移行より低コスト |
|
||||
|
||||
## 解決策
|
||||
|
||||
@@ -32,7 +45,7 @@ Dependency resolution:
|
||||
|
||||
## 仕組み
|
||||
|
||||
1. TaskManagerがCRUD操作を提供する。各タスクは1つのJSONファイル。
|
||||
1. TaskManager はタスクごとに1 JSON ファイルで CRUD を提供する。
|
||||
|
||||
```python
|
||||
class TaskManager:
|
||||
@@ -51,7 +64,7 @@ class TaskManager:
|
||||
return json.dumps(task, indent=2)
|
||||
```
|
||||
|
||||
2. タスクが完了とマークされると、`_clear_dependency`がそのIDを他のすべてのタスクの`blockedBy`リストから除去する。
|
||||
2. タスク完了時、他タスクの依存を解除する。
|
||||
|
||||
```python
|
||||
def _clear_dependency(self, completed_id: int):
|
||||
@@ -62,7 +75,7 @@ def _clear_dependency(self, completed_id: int):
|
||||
self._save(task)
|
||||
```
|
||||
|
||||
3. `update`メソッドがステータス変更と双方向の依存関係の結線を処理する。
|
||||
3. `update` が状態遷移と依存配線を担う。
|
||||
|
||||
```python
|
||||
def update(self, task_id, status=None,
|
||||
@@ -82,7 +95,7 @@ def update(self, task_id, status=None,
|
||||
self._save(task)
|
||||
```
|
||||
|
||||
4. 4つのタスクツールがディスパッチマップに追加される。
|
||||
4. タスクツール群をディスパッチへ追加する。
|
||||
|
||||
```python
|
||||
TOOL_HANDLERS = {
|
||||
@@ -97,7 +110,7 @@ TOOL_HANDLERS = {
|
||||
|
||||
## 主要コード
|
||||
|
||||
依存グラフ付きTaskManager(`agents/s07_task_system.py` 46-123行目):
|
||||
依存グラフ付き TaskManager(`agents/s07_task_system.py` 46-123行):
|
||||
|
||||
```python
|
||||
class TaskManager:
|
||||
@@ -130,19 +143,22 @@ class TaskManager:
|
||||
self._save(task)
|
||||
```
|
||||
|
||||
## s06からの変更点
|
||||
## s06 からの変更
|
||||
|
||||
| Component | Before (s06) | After (s07) |
|
||||
|----------------|------------------|----------------------------|
|
||||
| Tools | 5 | 8 (+task_create/update/list/get)|
|
||||
| State storage | In-memory only | JSON files in .tasks/ |
|
||||
| Dependencies | None | blockedBy + blocks graph |
|
||||
| Compression | Three-layer | Removed (different focus) |
|
||||
| Persistence | Lost on compact | Survives compression |
|
||||
| 項目 | Before (s06) | After (s07) |
|
||||
|---|---|---|
|
||||
| Tools | 5 | 8 (`task_create/update/list/get`) |
|
||||
| 状態保存 | メモリのみ | `.tasks/` の JSON |
|
||||
| 依存関係 | なし | `blockedBy + blocks` グラフ |
|
||||
| 永続性 | compact で消失 | compact 後も維持 |
|
||||
|
||||
## 設計原理
|
||||
|
||||
ファイルベースの状態はコンテキスト圧縮を生き延びる。エージェントの会話が圧縮されるとメモリ内の状態は失われるが、ディスクに書き込まれたタスクは永続する。依存グラフにより、コンテキストが失われた後でも正しい順序で実行される。これは一時的な会話と永続的な作業の橋渡しだ -- エージェントは会話の詳細を忘れても、タスクボードが常に何をすべきかを思い出させてくれる。ファイルシステムを信頼できる情報源とすることで、将来のマルチエージェント共有も可能になる。任意のプロセスが同じJSONファイルを読み取れるからだ。
|
||||
ファイルベース状態は compaction や再起動に強い。依存グラフにより、会話詳細を忘れても実行順序を保てる。これにより、会話中心の状態を作業中心の永続状態へ移せる。
|
||||
|
||||
ただし耐久性には運用前提がある。書き込みのたびに task JSON を再読込し、`status/blockedBy` が期待通りか確認してから原子的に保存しないと、並行更新で状態を上書きしやすい。
|
||||
|
||||
コース設計上、s07 以降で Task を主線に置くのは、長時間・協調開発の実態に近いから。
|
||||
|
||||
## 試してみる
|
||||
|
||||
@@ -151,7 +167,7 @@ cd learn-claude-code
|
||||
python agents/s07_task_system.py
|
||||
```
|
||||
|
||||
試せるプロンプト例:
|
||||
例:
|
||||
|
||||
1. `Create 3 tasks: "Setup project", "Write code", "Write tests". Make them depend on each other in order.`
|
||||
2. `List all tasks and show the dependency graph`
|
||||
|
||||
@@ -157,7 +157,6 @@ class BackgroundManager:
|
||||
| Execution | Blocking only | Blocking + background threads|
|
||||
| Notification | None | Queue drained per loop |
|
||||
| Concurrency | None | Daemon threads |
|
||||
| Task system | File-based CRUD | Removed (different focus) |
|
||||
|
||||
## 設計原理
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# s09: Agent Teams
|
||||
|
||||
> JSONL形式のインボックスを持つ永続的なチームメイトが、孤立したエージェントをコミュニケーションするチームに変える -- spawn、message、broadcast、drain。
|
||||
> JSONL 形式のインボックスを持つ永続的なチームメイトは、孤立したエージェントを連携可能なチームへ変えるための教材プロトコルの一つだ -- spawn、message、broadcast、drain。
|
||||
|
||||
## 問題
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
本物のチームワークには3つのものが必要だ: (1)単一のプロンプトを超えて存続する永続的なエージェント、(2)アイデンティティとライフサイクル管理、(3)エージェント間の通信チャネル。メッセージングがなければ、永続的なチームメイトでさえ聾唖だ -- 並列に作業できるが協調することはない。
|
||||
|
||||
解決策は、名前付きの永続的エージェントを生成するTeammateManagerと、JONSLインボックスファイルを使うMessageBusの組み合わせだ。各チームメイトは自身のagent loopをスレッドで実行し、各LLM呼び出しの前にインボックスを確認し、他のチームメイトやリーダーにメッセージを送れる。
|
||||
解決策は、名前付きの永続的エージェントを生成するTeammateManagerと、JSONL インボックスファイルを使うMessageBusの組み合わせだ。各チームメイトは自身のagent loopをスレッドで実行し、各LLM呼び出しの前にインボックスを確認し、他のチームメイトやリーダーにメッセージを送れる。
|
||||
|
||||
s06からs07への橋渡しについての注記: s03のTodoManagerアイテムは圧縮(s06)と共に死ぬ。ファイルベースのタスク(s07)はディスク上に存在するため圧縮後も生き残る。チームも同じ原則の上に構築されている -- config.jsonとインボックスファイルはコンテキストウィンドウの外に永続化される。
|
||||
|
||||
@@ -194,7 +194,7 @@ class MessageBus:
|
||||
|
||||
## 設計原理
|
||||
|
||||
ファイルベースのメールボックス(追記専用JSONL)は並行性安全なエージェント間通信を提供する。追記はほとんどのファイルシステムでアトミックであり、ロック競合を回避する。「読み取り時にドレイン」パターン(全読み取り、切り詰め)はバッチ配信を提供する。これは共有メモリやソケットベースのIPCよりもシンプルで堅牢だ。トレードオフはレイテンシだ -- メッセージは次のポーリングまで見えない -- しかし各ターンに数秒の推論時間がかかるLLM駆動エージェントにとって、ポーリングレイテンシは推論時間に比べて無視できる。
|
||||
ファイルベースのメールボックス(追記専用 JSONL)は、教材コードとして観察しやすく理解しやすい。「読み取り時にドレイン」パターン(全読み取り、切り詰め)は、少ない仕組みでバッチ配信を実現できる。トレードオフはレイテンシで、メッセージは次のポーリングまで見えない。ただし本コースでは、各ターンに数秒かかる LLM 推論を前提にすると、この遅延は許容範囲である。
|
||||
|
||||
## 試してみる
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ s09-s10では、チームメイトは明示的に指示された時のみ作業
|
||||
|
||||
しかし自律エージェントには微妙な問題がある: コンテキスト圧縮後に、エージェントが自分が誰かを忘れる可能性がある。メッセージが要約されると、元のシステムプロンプトのアイデンティティ(「あなたはalice、役割はcoder」)が薄れる。アイデンティティの再注入は、圧縮されたコンテキストの先頭にアイデンティティブロックを挿入することでこれを解決する。
|
||||
|
||||
教育上の簡略化: ここで使用するトークン推定は大まかなもの(文字数 / 4)だ。本番システムでは適切なトークナイザーライブラリを使用する。nagの閾値3ラウンド(s03から)は教育目的の可視化のために低く設定されている。本番のエージェントでは閾値は約10。
|
||||
注: トークン推定は文字数/4(大まか)。nag 閾値 3 ラウンドは可視化のために低く設定。
|
||||
|
||||
## 解決策
|
||||
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
# s12: Worktree + Task Isolation
|
||||
|
||||
> ディレクトリで分離し、タスクIDで調整する -- タスクボード(制御面)と worktree(実行面)の組み合わせで、並行編集を衝突しやすい状態から追跡可能・復元可能・後片付け可能な状態に変える。
|
||||
|
||||
## 問題
|
||||
|
||||
s11 でエージェントはタスクを自律的に処理できるようになった。だが全タスクが同じ作業ディレクトリで走ると、3つの障害が現れる。
|
||||
|
||||
あるエージェントが認証リファクタリングに取り組みながら、別のエージェントがログインページを作っている。両者が `src/auth.py` を編集する。未コミットの変更が混ざり合い、`git diff` は2つのタスクの差分が入り混じった結果を返す。どちらのエージェントの変更かを後から特定するのは困難になり、片方のタスクを巻き戻すと他方の編集も消える。
|
||||
|
||||
1. 変更汚染: 未コミット変更が相互に干渉する。
|
||||
2. 責務の曖昧化: タスク状態とファイル変更がずれる。
|
||||
3. 終了処理の難化: 実行コンテキストを残すか削除するかの判断が曖昧になる。
|
||||
|
||||
解決の核は「何をやるか」と「どこでやるか」の分離だ。
|
||||
|
||||
## 解決策
|
||||
|
||||
```
|
||||
Control Plane (.tasks/) Execution Plane (.worktrees/)
|
||||
+---------------------+ +------------------------+
|
||||
| task_1.json | | auth-refactor/ |
|
||||
| status: in_progress| bind | branch: wt/auth-ref |
|
||||
| worktree: auth-ref|-------->| cwd for commands |
|
||||
+---------------------+ +------------------------+
|
||||
| task_2.json | | ui-login/ |
|
||||
| status: pending | bind | branch: wt/ui-login |
|
||||
| worktree: ui-login|-------->| cwd for commands |
|
||||
+---------------------+ +------------------------+
|
||||
| |
|
||||
v v
|
||||
"what to do" "where to execute"
|
||||
|
||||
Events (.worktrees/events.jsonl)
|
||||
worktree.create.before -> worktree.create.after
|
||||
worktree.remove.before -> worktree.remove.after
|
||||
task.completed
|
||||
```
|
||||
|
||||
## 仕組み
|
||||
|
||||
1. 状態は3つの層に分かれる。制御面はタスクの目標と担当を管理し、実行面は worktree のパスとブランチを管理し、実行時状態はメモリ上の1ターン情報を保持する。
|
||||
|
||||
```text
|
||||
制御面 (.tasks/task_*.json) -> id/subject/status/owner/worktree
|
||||
実行面 (.worktrees/index.json) -> name/path/branch/task_id/status
|
||||
実行時状態 (メモリ) -> current_task/current_worktree/error
|
||||
```
|
||||
|
||||
2. Task と worktree はそれぞれ独立した状態機械を持つ。
|
||||
|
||||
```text
|
||||
Task: pending -> in_progress -> completed
|
||||
Worktree: absent -> active -> removed | kept
|
||||
```
|
||||
|
||||
3. `task_create` でまず目標を永続化する。worktree はまだ不要だ。
|
||||
|
||||
```python
|
||||
task = {
|
||||
"id": self._next_id,
|
||||
"subject": subject,
|
||||
"status": "pending",
|
||||
"owner": "",
|
||||
"worktree": "",
|
||||
"created_at": time.time(),
|
||||
"updated_at": time.time(),
|
||||
}
|
||||
self._save(task)
|
||||
```
|
||||
|
||||
4. `worktree_create(name, task_id?)` で分離ディレクトリとブランチを作る。`task_id` を渡すと、タスクが `pending` なら自動的に `in_progress` に遷移する。
|
||||
|
||||
```python
|
||||
entry = {
|
||||
"name": name,
|
||||
"path": str(path),
|
||||
"branch": branch,
|
||||
"task_id": task_id,
|
||||
"status": "active",
|
||||
"created_at": time.time(),
|
||||
}
|
||||
idx["worktrees"].append(entry)
|
||||
self._save_index(idx)
|
||||
|
||||
if task_id is not None:
|
||||
self.tasks.bind_worktree(task_id, name)
|
||||
```
|
||||
|
||||
5. `worktree_run(name, command)` で分離ディレクトリ内のコマンドを実行する。`cwd=worktree_path` が実質的な「enter」だ。
|
||||
|
||||
```python
|
||||
r = subprocess.run(
|
||||
command,
|
||||
shell=True,
|
||||
cwd=path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300,
|
||||
)
|
||||
```
|
||||
|
||||
6. 終了処理では `keep` か `remove` を明示的に選ぶ。`worktree_remove(name, complete_task=true)` はディレクトリ削除とタスク完了を一度に行う。
|
||||
|
||||
```python
|
||||
def remove(self, name: str, force: bool = False, complete_task: bool = False) -> str:
|
||||
self._run_git(["worktree", "remove", wt["path"]])
|
||||
if complete_task and wt.get("task_id") is not None:
|
||||
self.tasks.update(wt["task_id"], status="completed")
|
||||
self.tasks.unbind_worktree(wt["task_id"])
|
||||
self.events.emit("task.completed", ...)
|
||||
```
|
||||
|
||||
7. `.worktrees/events.jsonl` にライフサイクルイベントが append-only で記録される。重要な遷移には `before / after / failed` の三段イベントが出力される。
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "worktree.remove.after",
|
||||
"task": {"id": 7, "status": "completed"},
|
||||
"worktree": {"name": "auth-refactor", "path": "...", "status": "removed"},
|
||||
"ts": 1730000000
|
||||
}
|
||||
```
|
||||
|
||||
イベントは可観測性のサイドチャネルであり、task/worktree の主状態機械の書き込みを置き換えるものではない。監査・通知・ポリシーチェックはイベント購読側で処理する。
|
||||
|
||||
## 主要コード
|
||||
|
||||
タスクの worktree バインドと状態遷移(`agents/s12_worktree_task_isolation.py` 182-191行目):
|
||||
|
||||
```python
|
||||
def bind_worktree(self, task_id: int, worktree: str, owner: str = "") -> str:
|
||||
task = self._load(task_id)
|
||||
task["worktree"] = worktree
|
||||
if owner:
|
||||
task["owner"] = owner
|
||||
if task["status"] == "pending":
|
||||
task["status"] = "in_progress"
|
||||
task["updated_at"] = time.time()
|
||||
self._save(task)
|
||||
return json.dumps(task, indent=2)
|
||||
```
|
||||
|
||||
Worktree の作成とイベント発火(`agents/s12_worktree_task_isolation.py` 283-334行目):
|
||||
|
||||
```python
|
||||
def create(self, name: str, task_id: int = None, base_ref: str = "HEAD") -> str:
|
||||
self._validate_name(name)
|
||||
if self._find(name):
|
||||
raise ValueError(f"Worktree '{name}' already exists in index")
|
||||
|
||||
path = self.dir / name
|
||||
branch = f"wt/{name}"
|
||||
self.events.emit("worktree.create.before",
|
||||
task={"id": task_id} if task_id is not None else {},
|
||||
worktree={"name": name, "base_ref": base_ref})
|
||||
try:
|
||||
self._run_git(["worktree", "add", "-b", branch, str(path), base_ref])
|
||||
entry = {
|
||||
"name": name, "path": str(path), "branch": branch,
|
||||
"task_id": task_id, "status": "active",
|
||||
"created_at": time.time(),
|
||||
}
|
||||
idx = self._load_index()
|
||||
idx["worktrees"].append(entry)
|
||||
self._save_index(idx)
|
||||
if task_id is not None:
|
||||
self.tasks.bind_worktree(task_id, name)
|
||||
self.events.emit("worktree.create.after", ...)
|
||||
return json.dumps(entry, indent=2)
|
||||
except Exception as e:
|
||||
self.events.emit("worktree.create.failed", ..., error=str(e))
|
||||
raise
|
||||
```
|
||||
|
||||
ツールディスパッチマップ(`agents/s12_worktree_task_isolation.py` 535-552行目):
|
||||
|
||||
```python
|
||||
TOOL_HANDLERS = {
|
||||
"bash": lambda **kw: run_bash(kw["command"]),
|
||||
"read_file": lambda **kw: run_read(kw["path"], kw.get("limit")),
|
||||
"write_file": lambda **kw: run_write(kw["path"], kw["content"]),
|
||||
"edit_file": lambda **kw: run_edit(kw["path"], kw["old_text"], kw["new_text"]),
|
||||
"task_create": lambda **kw: TASKS.create(kw["subject"], kw.get("description", "")),
|
||||
"task_list": lambda **kw: TASKS.list_all(),
|
||||
"task_get": lambda **kw: TASKS.get(kw["task_id"]),
|
||||
"task_update": lambda **kw: TASKS.update(kw["task_id"], kw.get("status"), kw.get("owner")),
|
||||
"task_bind_worktree": lambda **kw: TASKS.bind_worktree(kw["task_id"], kw["worktree"], kw.get("owner", "")),
|
||||
"worktree_create": lambda **kw: WORKTREES.create(kw["name"], kw.get("task_id"), kw.get("base_ref", "HEAD")),
|
||||
"worktree_list": lambda **kw: WORKTREES.list_all(),
|
||||
"worktree_status": lambda **kw: WORKTREES.status(kw["name"]),
|
||||
"worktree_run": lambda **kw: WORKTREES.run(kw["name"], kw["command"]),
|
||||
"worktree_keep": lambda **kw: WORKTREES.keep(kw["name"]),
|
||||
"worktree_remove": lambda **kw: WORKTREES.remove(kw["name"], kw.get("force", False), kw.get("complete_task", False)),
|
||||
"worktree_events": lambda **kw: EVENTS.list_recent(kw.get("limit", 20)),
|
||||
}
|
||||
```
|
||||
|
||||
## s11 からの変更
|
||||
|
||||
| 観点 | s11 | s12 |
|
||||
|---|---|---|
|
||||
| 調整状態 | Task board (`owner/status`) | Task board + `worktree` 明示バインド |
|
||||
| 実行スコープ | 共有ディレクトリ | タスク単位の分離ディレクトリ |
|
||||
| 復元性 | タスク状態のみ | タスク状態 + worktree index |
|
||||
| 終了意味論 | タスク完了のみ | タスク完了 + 明示的 keep/remove 判断 |
|
||||
| ライフサイクル可視性 | 暗黙的なログ | `.worktrees/events.jsonl` の明示イベント |
|
||||
|
||||
## 設計原理
|
||||
|
||||
制御面と実行面の分離が中核だ。タスクは「何をやるか」を記述し、worktree は「どこでやるか」を提供する。両者は組み合わせ可能だが、強結合ではない。状態遷移は暗黙の自動掃除ではなく、`worktree_keep` / `worktree_remove` という明示的なツール操作として表現する。イベントストリームは `before / after / failed` の三段構造で重要な遷移を記録し、監査や通知をコアロジックから分離する。中断後でも `.tasks/` + `.worktrees/index.json` から状態を再構築できる。揮発的な会話状態を明示的なディスク状態に落とすことが、復元可能性の鍵だ。
|
||||
|
||||
## 試してみる
|
||||
|
||||
```sh
|
||||
cd learn-claude-code
|
||||
python agents/s12_worktree_task_isolation.py
|
||||
```
|
||||
|
||||
試せるプロンプト例:
|
||||
|
||||
1. `Create tasks for backend auth and frontend login page, then list tasks.`
|
||||
2. `Create worktree "auth-refactor" for task 1, create worktree "ui-login", then bind task 2 to "ui-login".`
|
||||
3. `Run "git status --short" in worktree "auth-refactor".`
|
||||
4. `Keep worktree "ui-login", then list worktrees and inspect worktree events.`
|
||||
5. `Remove worktree "auth-refactor" with complete_task=true, then list tasks/worktrees/events.`
|
||||
@@ -1,6 +1,6 @@
|
||||
# s01: Agent Loop (智能体循环)
|
||||
|
||||
> AI 编程智能体的全部秘密就是一个 while 循环 -- 把工具执行结果反馈给模型, 直到模型决定停止。
|
||||
> AI 编程智能体的核心是一个 while 循环 -- 把工具执行结果反馈给模型, 直到模型决定停止。
|
||||
|
||||
## 问题
|
||||
|
||||
@@ -49,7 +49,7 @@ response = client.messages.create(
|
||||
messages.append({"role": "assistant", "content": response.content})
|
||||
```
|
||||
|
||||
4. 检查 stop_reason。如果模型没有调用工具, 循环结束。这是唯一的退出条件。
|
||||
4. 检查 stop_reason。如果模型没有调用工具, 循环结束。在本节最小实现里, 这是唯一的循环退出条件。
|
||||
|
||||
```python
|
||||
if response.stop_reason != "tool_use":
|
||||
@@ -115,7 +115,7 @@ def agent_loop(messages: list):
|
||||
|
||||
## 设计原理
|
||||
|
||||
这个循环是所有基于 LLM 的智能体的通用基础。生产实现会增加错误处理、token 计数、流式输出和重试逻辑, 但基本结构不变。简洁性就是重点: 一个退出条件 (`stop_reason != "tool_use"`) 控制整个流程。本课程中的所有其他内容 -- 工具、规划、压缩、团队 -- 都是在这个循环之上叠加, 而不修改它。理解这个循环就是理解所有智能体。
|
||||
这个循环是所有基于 LLM 的智能体基础。生产实现还会增加错误处理、token 计数、流式输出、重试、权限策略与生命周期编排, 但核心交互模式仍从这里开始。本节强调简洁性: 在本节最小实现里, 一个退出条件 (`stop_reason != "tool_use"`) 就能支撑我们先学会主流程。本课程中的其他内容都在这个循环上叠加。理解这个循环是建立基础心智模型, 不是完整的生产架构。
|
||||
|
||||
## 试一试
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
只有 `bash` 时, 智能体所有操作都通过 shell: 读文件、写文件、编辑文件。这能用但很脆弱。`cat` 的输出会被不可预测地截断。`sed` 替换遇到特殊字符就会失败。模型浪费大量 token 构造 shell 管道, 而一个直接的函数调用会简单得多。
|
||||
|
||||
更重要的是, bash 是一个安全攻击面。每次 bash 调用都能做 shell 能做的一切。有了专用工具如 `read_file` 和 `write_file`, 你可以在工具层面强制路径沙箱化, 阻止危险模式, 而不是寄希望于模型自觉回避。
|
||||
更重要的是, bash 存在安全风险。每次 bash 调用都能做 shell 能做的一切。有了专用工具如 `read_file` 和 `write_file`, 你可以在工具层面强制路径沙箱化, 阻止危险模式, 而不是寄希望于模型自觉回避。
|
||||
|
||||
关键洞察: 添加工具不需要修改循环。s01 的循环保持不变。你只需在工具数组中添加条目, 编写处理函数, 然后通过 dispatch map 把它们关联起来。
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
解决方案是结构化状态: 一个模型显式写入的 TodoManager。模型创建计划, 工作时将项目标记为 in_progress, 完成后标记为 completed。nag reminder 机制在模型连续 3 轮以上不更新待办时注入提醒。
|
||||
|
||||
教学简化说明: 这里 nag 阈值设为 3 轮是为了教学可见性。生产环境的智能体通常使用约 10 轮的阈值以避免过度提醒。
|
||||
注: nag 阈值 3 轮是为教学可见性设的低值, 生产环境通常更高。从 s07 起, 课程转向 Task 看板处理持久化多步工作; TodoWrite 仍可用于轻量清单。
|
||||
|
||||
## 解决方案
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
这对探索性任务尤其糟糕。"这个项目用了什么测试框架?" 可能需要读取 5 个文件, 但父智能体的历史中并不需要这 5 个文件的全部内容 -- 它只需要答案: "pytest, 使用 conftest.py 配置。"
|
||||
|
||||
解决方案是进程隔离: 以 `messages=[]` 启动一个子智能体。子智能体进行探索、读取文件、运行命令。完成后, 只有最终的文本响应返回给父智能体。子智能体的全部消息历史被丢弃。
|
||||
在本课程里, 一个实用解法是 fresh `messages[]` 隔离: 以 `messages=[]` 启动一个子智能体。子智能体进行探索、读取文件、运行命令。完成后, 只有最终的文本响应返回给父智能体。子智能体的全部消息历史被丢弃。
|
||||
|
||||
## 解决方案
|
||||
|
||||
@@ -124,11 +124,10 @@ def run_subagent(prompt: str) -> str:
|
||||
| 上下文 | 单一共享 | 父 + 子隔离 |
|
||||
| Subagent | 无 | `run_subagent()` 函数 |
|
||||
| 返回值 | 不适用 | 仅摘要文本 |
|
||||
| Todo 系统 | TodoManager | 已移除 (非本节重点) |
|
||||
|
||||
## 设计原理
|
||||
|
||||
进程隔离免费提供了上下文隔离。全新的 `messages[]` 意味着子智能体不会被父级的对话历史干扰。代价是通信开销 -- 结果必须压缩回父级, 丢失细节。这与操作系统进程隔离的权衡相同: 用序列化成本换取安全性和整洁性。限制子智能体深度 (不允许递归生成) 防止无限资源消耗, 最大迭代次数确保失控的子进程能终止。
|
||||
在本节中, fresh `messages[]` 隔离是一个近似实现上下文隔离的实用办法。全新的 `messages[]` 意味着子智能体从不携带父级历史开始。代价是通信开销 -- 结果必须压缩回父级, 丢失细节。这是消息历史隔离策略, 不是操作系统进程隔离本身。限制子智能体深度 (不允许递归生成) 防止无限资源消耗, 最大迭代次数确保失控的子任务能终止。
|
||||
|
||||
## 试一试
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
## 问题
|
||||
|
||||
你希望智能体针对不同领域遵循特定的工作流: git 约定、测试模式、代码审查清单。简单粗暴的做法是把所有内容都塞进系统提示。但系统提示的有效注意力是有限的 -- 文本太多, 模型就会开始忽略其中一部分。
|
||||
智能体需要针对不同领域遵循特定的工作流: git 约定、测试模式、代码审查清单。简单粗暴的做法是把所有内容都塞进系统提示。但系统提示的有效注意力是有限的 -- 文本太多, 模型就会开始忽略其中一部分。
|
||||
|
||||
如果你有 10 个技能, 每个 2000 token, 那就是 20,000 token 的系统提示。模型关注开头和结尾, 但会略过中间部分。更糟糕的是, 这些技能中大部分与当前任务无关。文件编辑任务不需要 git 工作流说明。
|
||||
|
||||
@@ -132,7 +132,6 @@ class SkillLoader:
|
||||
| 系统提示 | 静态字符串 | + 技能描述列表 |
|
||||
| 知识库 | 无 | .skills/*.md 文件 |
|
||||
| 注入方式 | 无 | 两层 (系统提示 + result) |
|
||||
| Subagent | `run_subagent()` | 已移除 (非本节重点) |
|
||||
|
||||
## 设计原理
|
||||
|
||||
|
||||
@@ -149,7 +149,6 @@ def agent_loop(messages):
|
||||
| Auto-compact | 无 | token 阈值触发 |
|
||||
| Manual compact | 无 | `compact` 工具 |
|
||||
| Transcripts | 无 | 保存到 .transcripts/ |
|
||||
| Skills | load_skill | 已移除 (非本节重点) |
|
||||
|
||||
## 设计原理
|
||||
|
||||
|
||||
@@ -10,7 +10,19 @@
|
||||
|
||||
更根本地说, 内存中的状态对其他智能体不可见。当我们最终构建团队 (s09+) 时, 队友需要一个共享的任务看板。内存中的数据结构是进程局部的。
|
||||
|
||||
解决方案是将任务作为 JSON 文件持久化在 `.tasks/` 目录中。每个任务是一个单独的文件, 包含 ID、主题、状态和依赖图。完成任务 1 会自动解除任务 2 的阻塞 (如果任务 2 有 `blockedBy: [1]`)。文件系统成为唯一的真实来源。
|
||||
解决方案是将任务作为 JSON 文件持久化在 `.tasks/` 目录中。每个任务是一个单独的文件, 包含 ID、主题、状态和依赖图。完成任务 1 会自动解除任务 2 的阻塞 (如果任务 2 有 `blockedBy: [1]`)。在本教学实现里, 文件系统是任务状态的真实来源。
|
||||
|
||||
## Task vs Todo: 何时用哪个
|
||||
|
||||
从 s07 起, Task 是默认主线。Todo 仍可用于短期线性清单。
|
||||
|
||||
## 快速判定矩阵
|
||||
|
||||
| 场景 | 优先选择 | 原因 |
|
||||
|---|---|---|
|
||||
| 短时、单会话、线性清单 | Todo | 心智负担最低,记录最快 |
|
||||
| 跨会话、存在依赖、多人协作 | Task | 状态可持久、依赖可表达、协作可见 |
|
||||
| 一时拿不准 | Task | 后续降级更容易,半途迁移成本更低 |
|
||||
|
||||
## 解决方案
|
||||
|
||||
@@ -132,17 +144,20 @@ class TaskManager:
|
||||
|
||||
## 相对 s06 的变更
|
||||
|
||||
| 组件 | 之前 (s06) | 之后 (s07) |
|
||||
|----------------|------------------|----------------------------------|
|
||||
| Tools | 5 | 8 (+task_create/update/list/get) |
|
||||
| 状态存储 | 仅内存 | .tasks/ 中的 JSON 文件 |
|
||||
| 依赖关系 | 无 | blockedBy + blocks 图 |
|
||||
| 压缩机制 | 三层 | 已移除 (非本节重点) |
|
||||
| 持久化 | 压缩后丢失 | 压缩后存活 |
|
||||
| 组件 | 之前 (s06) | 之后 (s07) |
|
||||
|---|---|---|
|
||||
| Tools | 5 | 8 (`task_create/update/list/get`) |
|
||||
| 状态存储 | 仅内存 | `.tasks/` 中的 JSON 文件 |
|
||||
| 依赖关系 | 无 | `blockedBy + blocks` 图 |
|
||||
| 持久化 | 压缩后丢失 | 压缩后存活 |
|
||||
|
||||
## 设计原理
|
||||
|
||||
基于文件的状态能在上下文压缩中存活。当智能体的对话被压缩时, 内存中的状态会丢失, 但写入磁盘的任务会持久保存。依赖图确保即使在上下文丢失后也能按正确顺序执行。这是临时对话与持久工作之间的桥梁 -- 智能体可以忘记对话细节, 但始终有任务看板来提醒它还需要做什么。文件系统作为唯一真实来源也为未来的多智能体共享提供了基础, 因为任何进程都可以读取相同的 JSON 文件。
|
||||
基于文件的状态能在上下文压缩中存活。当智能体的对话被压缩时, 内存中的状态会丢失, 但写入磁盘的任务会持久保存。依赖图确保即使在上下文丢失后也能按正确顺序执行。这是临时对话与持久工作之间的桥梁 -- 智能体可以忘记对话细节, 但始终有任务看板来提醒它还需要做什么。在本教学实现里, 文件系统作为任务状态真实来源也为未来的多智能体共享提供了基础, 因为任何进程都可以读取相同的 JSON 文件。
|
||||
|
||||
但“持久化”成立有前提:每次写入前都要重新读取任务文件,确认 `status/blockedBy` 与预期一致,再原子写回。否则并发写入很容易互相覆盖状态。
|
||||
|
||||
从课程设计上看, 这也是为什么 s07 之后我们默认采用 Task 而不是 Todo: 它更接近真实工程中的长期执行与协作需求。
|
||||
|
||||
## 试一试
|
||||
|
||||
|
||||
@@ -157,7 +157,6 @@ class BackgroundManager:
|
||||
| 执行方式 | 仅阻塞 | 阻塞 + 后台线程 |
|
||||
| 通知机制 | 无 | 每轮排空的队列 |
|
||||
| 并发 | 无 | 守护线程 |
|
||||
| 任务系统 | 基于文件的 CRUD | 已移除 (非本节重点) |
|
||||
|
||||
## 设计原理
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# s09: Agent Teams (智能体团队)
|
||||
|
||||
> 持久化的队友通过 JSONL 收件箱将孤立的智能体转变为可通信的团队 -- spawn、message、broadcast 和 drain。
|
||||
> 持久化的队友通过 JSONL 收件箱提供了一种教学协议, 将孤立的智能体转变为可通信的团队 -- spawn、message、broadcast 和 drain。
|
||||
|
||||
## 问题
|
||||
|
||||
@@ -194,7 +194,7 @@ class MessageBus:
|
||||
|
||||
## 设计原理
|
||||
|
||||
基于文件的邮箱 (追加式 JSONL) 提供了并发安全的智能体间通信。追加操作在大多数文件系统上是原子的, 避免了锁竞争。"读取时排空" 模式 (读取全部, 截断) 提供批量传递。这比共享内存或基于 socket 的 IPC 更简单、更健壮。代价是延迟 -- 消息只在下一次轮询时才被看到 -- 但对于每轮需要数秒推理时间的 LLM 驱动智能体来说, 轮询延迟相比推理时间可以忽略不计。
|
||||
基于文件的邮箱 (追加式 JSONL) 在教学代码中具有可观察、易理解的优势。"读取时排空" 模式 (读取全部, 截断) 用很少的机制就能实现批量传递。代价是延迟 -- 消息只在下一次轮询时才被看到 -- 但对于每轮需要数秒推理时间的 LLM 驱动智能体来说, 本课程中该延迟是可接受的。
|
||||
|
||||
## 试一试
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
但自治智能体面临一个微妙问题: 上下文压缩后, 智能体可能忘记自己是谁。如果消息被摘要化, 原始系统提示中的身份 ("你是 alice, 角色: coder") 就会淡化。身份重注入通过在压缩后的上下文开头插入身份块来解决这个问题。
|
||||
|
||||
教学简化说明: 这里的 token 估算比较粗糙 (字符数 / 4)。生产系统使用专业的 tokenizer 库。s03 中的 nag 阈值 3 轮是为教学可见性设的低值; 生产环境的智能体通常使用约 10 轮的阈值。
|
||||
注: token 估算使用字符数/4 (粗略)。nag 阈值 3 轮是为教学可见性设的低值。
|
||||
|
||||
## 解决方案
|
||||
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
# s12: Worktree + 任务隔离
|
||||
|
||||
> 目录隔离, 任务 ID 协调 -- 用"任务板 (控制面) + worktree (执行面)"把并行改动从互相污染变成可追踪、可恢复、可收尾。
|
||||
|
||||
## 问题
|
||||
|
||||
s11 时, agent 已经能认领任务并协同推进。但所有任务共享同一个工作目录。两个 agent 同时改同一棵文件树时, 未提交的变更互相干扰, 任务状态和实际改动对不上, 收尾时也无法判断该保留还是清理哪些文件。
|
||||
|
||||
考虑一个具体场景: agent A 在做 auth 重构, agent B 在做登录页。两者都修改了 `config.py`。A 的半成品改动被 B 的 `git status` 看到, B 以为是自己的遗留, 尝试提交 -- 结果两个任务都坏了。
|
||||
|
||||
根因是"做什么"和"在哪里做"没有分开。任务板管目标, 但执行上下文是共享的。解决方案: 给每个任务分配独立的 git worktree 目录, 用任务 ID 把两边关联起来。
|
||||
|
||||
## 解决方案
|
||||
|
||||
```
|
||||
控制面 (.tasks/) 执行面 (.worktrees/)
|
||||
+------------------+ +------------------------+
|
||||
| task_1.json | | auth-refactor/ |
|
||||
| status: in_progress <----> branch: wt/auth-refactor
|
||||
| worktree: "auth-refactor" | task_id: 1 |
|
||||
+------------------+ +------------------------+
|
||||
| task_2.json | | ui-login/ |
|
||||
| status: pending <----> branch: wt/ui-login
|
||||
| worktree: "ui-login" | task_id: 2 |
|
||||
+------------------+ +------------------------+
|
||||
|
|
||||
index.json (worktree registry)
|
||||
events.jsonl (lifecycle log)
|
||||
```
|
||||
|
||||
三层状态:
|
||||
1. 控制面 (What): `.tasks/task_*.json` -- 任务目标、责任归属、完成状态
|
||||
2. 执行面 (Where): `.worktrees/index.json` -- 隔离目录路径、分支、存活状态
|
||||
3. 运行态 (Now): 单轮内存上下文 -- 当前任务、当前 worktree、工具结果
|
||||
|
||||
状态机:
|
||||
```text
|
||||
Task: pending -> in_progress -> completed
|
||||
Worktree: absent -> active -> removed | kept
|
||||
```
|
||||
|
||||
## 工作原理
|
||||
|
||||
1. 创建任务, 把目标写入任务板。
|
||||
|
||||
```python
|
||||
TASKS.create("Implement auth refactor")
|
||||
# -> .tasks/task_1.json status=pending worktree=""
|
||||
```
|
||||
|
||||
2. 创建 worktree 并绑定任务。传入 `task_id` 时自动把任务推进到 `in_progress`。
|
||||
|
||||
```python
|
||||
WORKTREES.create("auth-refactor", task_id=1)
|
||||
# -> git worktree add -b wt/auth-refactor .worktrees/auth-refactor HEAD
|
||||
# -> index.json 追加 entry, task_1.json 绑定 worktree="auth-refactor"
|
||||
```
|
||||
|
||||
3. 在隔离目录中执行命令。`cwd` 指向 worktree 路径, 主目录不受影响。
|
||||
|
||||
```python
|
||||
WORKTREES.run("auth-refactor", "git status --short")
|
||||
# -> subprocess.run(command, cwd=".worktrees/auth-refactor", ...)
|
||||
```
|
||||
|
||||
4. 观测和回写。`worktree_status` 查看 git 状态, `task_update` 维护进度。
|
||||
|
||||
```python
|
||||
WORKTREES.status("auth-refactor") # git status inside worktree
|
||||
TASKS.update(1, owner="agent-A") # update task metadata
|
||||
```
|
||||
|
||||
5. 收尾: 选择 keep 或 remove。`remove` 配合 `complete_task=true` 会同时完成任务并解绑 worktree。
|
||||
|
||||
```python
|
||||
WORKTREES.remove("auth-refactor", complete_task=True)
|
||||
# -> git worktree remove
|
||||
# -> task_1.json status=completed, worktree=""
|
||||
# -> index.json status=removed
|
||||
# -> events.jsonl 写入 task.completed + worktree.remove.after
|
||||
```
|
||||
|
||||
6. 进程中断后, 从 `.tasks/` + `.worktrees/index.json` 重建现场。会话记忆是易失的, 磁盘状态是持久的。
|
||||
|
||||
## 核心代码
|
||||
|
||||
事件流 -- append-only 生命周期日志 (来自 `agents/s12_worktree_task_isolation.py`):
|
||||
|
||||
```python
|
||||
class EventBus:
|
||||
def emit(self, event, task=None, worktree=None, error=None):
|
||||
payload = {
|
||||
"event": event,
|
||||
"ts": time.time(),
|
||||
"task": task or {},
|
||||
"worktree": worktree or {},
|
||||
}
|
||||
if error:
|
||||
payload["error"] = error
|
||||
with self.path.open("a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(payload) + "\n")
|
||||
```
|
||||
|
||||
事件流写入 `.worktrees/events.jsonl`, 每个关键操作发出三段式事件:
|
||||
- `worktree.create.before / after / failed`
|
||||
- `worktree.remove.before / after / failed`
|
||||
- `task.completed` (当 `complete_task=true` 成功时)
|
||||
|
||||
事件负载形状:
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "worktree.remove.after",
|
||||
"task": {"id": 7, "status": "completed"},
|
||||
"worktree": {"name": "auth-refactor", "path": "...", "status": "removed"},
|
||||
"ts": 1730000000
|
||||
}
|
||||
```
|
||||
|
||||
任务绑定 -- Task 侧持有 worktree 名称:
|
||||
|
||||
```python
|
||||
def bind_worktree(self, task_id: int, worktree: str, owner: str = "") -> str:
|
||||
task = self._load(task_id)
|
||||
task["worktree"] = worktree
|
||||
if task["status"] == "pending":
|
||||
task["status"] = "in_progress"
|
||||
self._save(task)
|
||||
```
|
||||
|
||||
隔离执行 -- cwd 路由到 worktree 目录:
|
||||
|
||||
```python
|
||||
r = subprocess.run(
|
||||
command,
|
||||
shell=True,
|
||||
cwd=path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300,
|
||||
)
|
||||
```
|
||||
|
||||
收尾联动 -- remove 同时完成任务:
|
||||
|
||||
```python
|
||||
def remove(self, name, force=False, complete_task=False):
|
||||
self._run_git(["worktree", "remove", wt["path"]])
|
||||
if complete_task and wt.get("task_id") is not None:
|
||||
self.tasks.update(wt["task_id"], status="completed")
|
||||
self.tasks.unbind_worktree(wt["task_id"])
|
||||
self.events.emit("task.completed", ...)
|
||||
```
|
||||
|
||||
生命周期工具注册:
|
||||
|
||||
```python
|
||||
"worktree_keep": lambda **kw: WORKTREES.keep(kw["name"]),
|
||||
"worktree_events": lambda **kw: EVENTS.list_recent(kw.get("limit", 20)),
|
||||
```
|
||||
|
||||
## 相对 s11 的变更
|
||||
|
||||
| 组件 | 之前 (s11) | 之后 (s12) |
|
||||
|----------------|----------------------------|-----------------------------------------|
|
||||
| 协调状态 | 任务板 (owner/status) | 任务板 + `worktree` 显式绑定 |
|
||||
| 执行上下文 | 共享目录 | 每个任务可分配独立 worktree 目录 |
|
||||
| 可恢复性 | 依赖任务状态 | 任务状态 + worktree 索引双重恢复 |
|
||||
| 收尾语义 | 任务完成 | 任务完成 + worktree 显式 keep/remove |
|
||||
| 生命周期可见性 | 隐式日志 | `.worktrees/events.jsonl` 显式事件流 |
|
||||
|
||||
## 设计原理
|
||||
|
||||
控制面/执行面分离是这一章的核心模式。Task 管"做什么", worktree 管"在哪做", 两者通过 task ID 关联但不强耦合。这意味着一个任务可以先不绑定 worktree (纯规划阶段), 也可以在多个 worktree 之间迁移。
|
||||
|
||||
显式状态机让每次迁移都可审计、可恢复。进程崩溃后, 从 `.tasks/` 和 `.worktrees/index.json` 两个文件就能重建全部现场, 不依赖会话内存。
|
||||
|
||||
事件流是旁路可观测层, 不替代主状态机写入。审计、通知、配额控制等副作用放在事件消费者中处理, 核心流程保持最小。`keep/remove` 作为显式收尾动作存在, 而不是隐式清理 -- agent 必须做出决策, 这个决策本身被记录。
|
||||
|
||||
## 试一试
|
||||
|
||||
```sh
|
||||
cd learn-claude-code
|
||||
python agents/s12_worktree_task_isolation.py
|
||||
```
|
||||
|
||||
可以尝试的提示:
|
||||
|
||||
1. `Create tasks for backend auth and frontend login page, then list tasks.`
|
||||
2. `Create worktree "auth-refactor" for task 1, create worktree "ui-login", then bind task 2 to "ui-login".`
|
||||
3. `Run "git status --short" in worktree "auth-refactor".`
|
||||
4. `Keep worktree "ui-login", then list worktrees and inspect worktree events.`
|
||||
5. `Remove worktree "auth-refactor" with complete_task=true, then list tasks/worktrees/events.`
|
||||
Reference in New Issue
Block a user