add worktree & up task、teammate etc

This commit is contained in:
CrazyBoyM
2026-02-24 01:44:44 +08:00
parent c6a27ef1d7
commit aea8844bac
54 changed files with 2404 additions and 210 deletions

View File

@@ -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"`)で学習に必要な制御を示す。本コースの他の要素はこのループに積み重なる。つまり、このループ理解は基礎であって、本番アーキテクチャ全体そのものではない
## 試してみる

View File

@@ -10,7 +10,7 @@
解決策は構造化された状態管理だ: モデルが明示的に書き込むTodoManager。モデルは計画を作成し、作業中のアイテムをin_progressとしてマークし、完了時にcompletedとマークする。nagリマインダーは、モデルが3ラウンド以上todoを更新しなかった場合にナッジを注入する。
教育上の簡略化: nag閾値3ラウンドは教育目的の可視化のために低く設定されている。本番のエージェントでは過剰なプロンプトを避けるため閾値は約10に設定されている
: nag 閾値 3 ラウンドは可視化のために低く設定。本番ではより高い値に調整される。s07 以降は永続的なマルチステップ作業に Task ボードを使用。TodoWrite は軽量チェックリストとして引き続き利用可能
## 解決策

View File

@@ -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のプロセス分離そのものではない。サブエージェントの深さ制限(再帰スポーン不可)は無制限のリソース消費を防ぎ、最大反復回数は暴走した子処理の終了を保証する。
## 試してみる

View File

@@ -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) |
## 設計原理

View File

@@ -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) |
## 設計原理

View File

@@ -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`

View File

@@ -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) |
## 設計原理

View File

@@ -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 推論を前提にすると、この遅延は許容範囲である。
## 試してみる

View File

@@ -10,7 +10,7 @@ s09-s10では、チームメイトは明示的に指示された時のみ作業
しかし自律エージェントには微妙な問題がある: コンテキスト圧縮後に、エージェントが自分が誰かを忘れる可能性がある。メッセージが要約されると、元のシステムプロンプトのアイデンティティ(「あなたはalice、役割はcoder」)が薄れる。アイデンティティの再注入は、圧縮されたコンテキストの先頭にアイデンティティブロックを挿入することでこれを解決する。
教育上の簡略化: ここで使用するトークン推定は大まかなもの(文字数 / 4)だ。本番システムでは適切なトークナイザーライブラリを使用する。nag閾値3ラウンド(s03から)は教育目的の可視化のために低く設定されている。本番のエージェントでは閾値は約10
注: トークン推定は文字数/4大まか。nag 閾値 3 ラウンドは可視化のために低く設定
## 解決策

View File

@@ -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.`