# Async Task 生命周期

> 本文描述 sub-agent 异步调用的设计：显式的 task 实体、后台 worker pool，以及
> 一套可查询、可取消的生命周期。接口形态对齐 Claude Code 的 TaskCreate /
> TaskList / TaskGet / TaskUpdate / TaskStop。
>
> 它所依托的底盘 WebUI 已经具备：每个 session 一个 worker thread
> （`_execute_in_context`），以及 `openprogram.agent.run_control` 中按 execution
> 精确登记的 token，
> 一个供 UI spinner 用的 `_running_tasks` dict。`run_agent_turn`
> （`openprogram/agent/sub_agent_run.py`）是那条同步路径，`/task` tool、
> `/spawn` WS action、`_merge.process_merge_turn` 都复用它；task 抽象在其之上
> 叠加。

---

## Part 1. Task 生命周期需要考虑的维度

一套完整的 spawn / list / get / cancel 生命周期，下面 15 个点都得明确。Part 2
按同一张 checklist 逐个场景走一遍。

### D1. Task 实体存什么

`Task` 至少要带：

- `job_id`：spawn 立刻返回的稳定 id（独立于 user_msg_id / assistant_msg_id）
- `subject`：单行简介（用于 list / panel 显示）
- `description`：完整 prompt（agent 调用时复用为 `prompt`）
- `agent_id`：跑哪个 profile
- `status`：D2 的状态枚举
- `created_at / queued_at / started_at / completed_at`：时间戳
- `parent_session_id`：task 跑在哪个 session 上（task 永远绑定一个 session）
- `parent_job_id`：spawn 这个 task 的 task（顶层 user-spawn 时为 None）
- `parent_msg_id`：触发 spawn 的 user / assistant msg id（用于把 attach 卡片挂回去）
- `context_mode`：`inherit` / `clean`
- `head_id`：task 完成后 sub-agent 落地的 assistant msg id（运行中为 None）
- `result_text`：sub-agent 最终回复（运行中为 None）
- `error`：失败时的错误字符串
- `cancel_requested_at`：cancel 信号写入时间
- `attempt`：重试时的计数（初版固定 0）

`cancel_event` / `future` 这类 runtime 对象不进 entity；entity 只描述
"what"，"how to cancel / wait" 由 runner 内部 map 解决（见 D5）。

### D2. 状态机

```
pending → queued → running → completed
                          ↘ cancelled
                          ↘ errored
```

- `pending`：spawn API 收到、entity 写入持久层、还没排上 worker pool。
- `queued`：交给 ThreadPoolExecutor 但还没 pick up（worker 全忙）。
- `running`：worker pick up 并开始 `process_user_turn`。
- `completed`：sub-agent 正常返回，`head_id` 和 `result_text` 写入。
- `cancelled`：cancel 事件被消费且 worker 退出（部分输出可能落地）。
- `errored`：worker 抛异常 / 进程崩溃恢复时未完成的 task。

每次状态转移都更新对应时间戳，不可逆。`pending → cancelled` 直接跳过 queued /
running 也允许（用户在 worker pick up 前就 stop）。

### D3. Worker pool 模型

pool 用 `concurrent.futures.ThreadPoolExecutor`，与现有 `_execute_in_context`
的 daemon thread 模型同构，且不需要把整个 codebase async-color。理由：

- `process_user_turn` 内部已经自己开 `asyncio.new_event_loop()` 跑 agent loop，外部包 thread 不会双重事件循环冲突。
- BashTool / 文件 IO 都是阻塞调用，线程模型对它们零成本。
- 并发上限按 `OPENPROGRAM_JOB_WORKERS` 环境变量配置，默认 4。超过上限 task 停在 `queued`，FIFO 公平。
- Backpressure：单 session 不限制（task 之间是 sibling，UI 显示串行的 spinner 不影响），全局 pool 上限就够了。priority 可留待后续按需加。

### D4. 持久化

持久化复用现有 session-git meta：session repo 的 `meta.json` 旁边放一个
`jobs.json`，结构 `{job_id: TaskRow}`。每个 task 状态转移调一次
`commit_turn("task: ...")`，落 git 历史。

理由：

- task 永远绑定单一 session（D6），存在 session 目录下天然分区。
- session 已经走 git-as-truth，task 状态进 git history 反而帮 debug "为什么 task 卡住"。
- 不需要新一张 SQLite 表或新一个 DB 文件。

不可恢复策略：进程 crash 后，所有 status=`running` / `queued` / `pending` 的
task 在 startup 时一律标记 `errored`（error="worker died before completion"）。
理由是 LLM call 已经发出去拿不回来；让用户重 spawn 是最干净的语义。

### D5. Cancel 信号传播

每个 running task 在 runner 内有一份 `threading.Event`（不存 entity）。
Cancel API 触发时：

1. 写 `cancel_requested_at` 到 entity，状态转 `cancelled`（如果还在 queued / pending），或保持 `running` 等 worker 自然退出。
2. `cancel_event.set()` — 通过 `openprogram.agent.run_control` 的精确 execution 登记，
   传到 `process_user_turn(cancel_event=...)`。
3. `process_user_turn` 已经把 cancel_event bridge 进 asyncio.Event（`agent_loop` 调用），LLM provider stream 会在下一个 chunk 检查到 cancel 然后中断。
4. BashTool / 其他 subprocess 使用精确 execution id 调用
   `process_runner.kill_active_subprocess`。Tool 层的 cancel 是合作式：每个
   `Agent` method 的 pre-invocation hook 检查 `is_cancelled`，下一个 tool call
   入口会 raise `CancelledError`。
5. 兜底 timeout：cancel 后 30 秒 worker 还没退出，runner 把 entity 标 `cancelled`（error="cancel timed out, worker may be stuck"），然后 detach worker thread（不强杀，等 GC）。

工具自身原子操作（比如一个 `Write` 写一半）不中断，等当前 atomic 完成再退出。

### D6. Task ↔ session 的关系

一个 task 永远只在**一个**目标 session 执行。caller 可以位于另一个 session，
但执行并不会因此跨多个 session：canonical entity 保持
`parent_session_id=<target>`，两者不同时再记录
`caller_session_id=<source>`。

`task.parent_session_id` 就是 sub-agent `process_user_turn(session_id=...)`
用的那个——与 `run_agent_turn` 的行为一致。sub-agent 的输出落地为该 session 的
一个 branch（或新 root，看 `context_mode`），所以目标 session repo 同时存 task
entity 和 task 产出。源 session 的 linked mirror 只负责可见性和 ownership 检查，
不是第二个执行身份。

### D7. Task ↔ sub-agent 的关系

所有 spawn 都走 task entity，因此 `/task` 不同步阻塞。语义如下：

- agent-facing tool `agent(prompt, ...)` 是 **async** 的——立刻返回 `job_id`，不阻塞主对话。LLM 拿到 id 后选择继续干别的，或者立刻调 `job_output(job_id)` 取得同步语义（D15）。
- 兼容旗子：`/task --sync`（或工具的前台默认 `run_in_background=False`）在 tool 层包一层 `spawn → await`，对 LLM 透明地同步返回结果。
- `_task_impl` 仍保留 `run_agent_turn` 调用，但走 runner 入口。

`/spawn`（用户在 chat 输入 `/spawn label: prompt`）走同一条 spawn API，区别只是
caller 是 WS handler 而非 LLM。

### D8. Task ↔ ContextCommit 的关系

attach pointer（`function="attach"` 节点）由 `_run_spawn` / `_task_impl` 写入：

- admission 成功后、dispatch 开始前写一个 **placeholder attach card**（`function="attach"`，`extra.attach.job_id = <job_id>`，`extra.attach.status = "running"`），content="(running)"，`source_commit_id` 留空。跨 session spawn 的卡片存在源 session，但 `attach.session_id` 指向目标 session。
- task 完成时 runner update 同一个 attach card 节点：填 `head_id` / `source_commit_id` / 替换 content 为 `final_text`，`status` 改 `completed` / `cancelled` / `errored`。
- generator 看到 `status="running"` 的 attach 节点：跳过展开（不进 commit items），只在 UI 显示卡片占位。看到 `status="completed"` 走现有 attach 展开路径（见 `context.md` 场景 B）。

这样 LLM 在 task 跑完前再触发新 turn 不会看到半成品 attach 内容，但用户能看到
spinner。

<div id="d9-ws-api"></div>

### D9. WebSocket API

四个 ws action（参考 `ws_actions/` 现有命名）：

- `spawn_job` — `{action, session_id, prompt, description, agent_id?, context?, wait?}` → 立刻回 `{job_id, status, parent_msg_id}`。
- `list_jobs` — `{action, session_id?, status_filter?, limit?}` → `{tasks: [...]}`。无 session_id 等于全局列。
- `get_job` — `{action, job_id}` → 单条 entity + 当前 head_id / 部分 result（如果 running）。
- `cancel_job` — `{action, job_id}` → `{job_id, status}`（同步返回当前 status，不等 worker 退出）。

广播事件：

- `task_created`、`job_status`（queued / running / completed / cancelled / errored）、`task_progress`（可选，未来可加 token 进度）。
- 复用现有 `running_task` 广播并加 `job_id` 字段（UI 兼容）。

### D10. agent-facing 工具

agent 用三件套：

- `agent(prompt, description, agent_id?, context?, run_in_background=False)` → 前台默认返回最终 result，`run_in_background=True` 返回 `job_id`。包装 `runner.submit(...)`。
- `job_output(job_id, block=True, timeout=30000)` → 等待(timeout 毫秒,上限 600000,对齐 Claude Code 的 TaskOutput 参数形状)直到完成 / cancelled / 超时,返回回复文本+终态;`block=false` 立即窥探当前状态。`list_jobs()` 列出本会话的后台任务(id、状态、主题),LLM 用它掌握并行工作的全景。
- `job_stop(job_id, reason?)` → `{ok, status}`。

变体 `await_tasks([id1, id2, ...], mode="all"|"any", timeout)` 用于 plan mode
的 wait_all（D14）。三件套加 wait_all = 四个工具，但 plan-mode 不暴露 wait_all
给普通 agent——普通 agent 只能 await 单个 id，以免被误用。

### D11. UI 表达

- 右侧 panel 一个 **Tasks** tab（紧挨现有 Branches / Context Commits panel）。列出当前 session 的所有 task entity：spinner + subject + status + 创建时间。
- 点开 task：跳到对应的 attach card（chat 里已有 placeholder）→ checkout 它的 head_id branch（如果 completed）。
- 每个 attach card 自带 status badge：`running` / `done` / `cancelled` / `error`。完成后的行为与普通 attach card 一致。
- 全局 sidebar 显示一个总计数徽章（多少 running）：复用现有 `running_task` 机制。

### D12. 错误恢复

- worker 抛异常：runner 捕获 → 状态 `errored` → `error` 字段填 `f"{type}: {msg}"` → attach card status 同步更新 → 广播 job_status 事件。
- 主进程 crash：参考 D4。startup hook 扫 `jobs.json`，把所有非终止态记为 `errored`。
- pool shutdown：进程关闭时 wait 5 秒；超时的 task 标 `errored`（"worker pool shutdown"）。
- 同一 job_id 不允许重 spawn（spawn API idempotent on job_id 但默认是 mint 新 id）。

### D13. 测试边界

unit test 不跑真 LLM。需要的 seam：

- `runner.submit(req, *, sync_fn=...)` —— 把 `process_user_turn` 替成 fake 同步函数（接收 cancel_event，返回 fake `TurnResult`）。
- entity store 用 in-memory `MockTaskStore`（dict 而非 git，但同样接口）。
- 状态机测试矩阵：每对合法转移一个 case，每对非法转移一个 reject case。

集成测试覆盖：spawn → await → completed 一路、spawn → cancel mid-flight、
spawn → worker raise → errored、多 task FIFO 排队、crash recovery 把 running
标 errored。

### D14. Plan mode 集成

plan agent 产出一个 plan（一组 sub-task spec），exit_plan_mode 完成时：

- plan 工具 / executor agent 接收 spec list（每条含 `description` + `prompt` + 可选 `agent_id`）。
- 顺序调 `spawn_job` 拿 N 个 job_id。
- 调 `await_tasks(ids, mode="all")` 等全部完成。
- 拿到结果后由 executor 综合（写一条 user-visible summary，或自动触发 `merge`）。

并发上限就是 D3 的 pool size — plan 列 10 个 task、pool 4 个，会有 6 个停在
`queued`，UI 显示排队。

### D15. 向后兼容

`/task` 在 chat 里继续是用户输入入口，行为不变（用户看到的是 attach card +
完整结果），但底层走 task entity。

LLM-facing `agent(prompt, ...)` tool 提供两个语义：

- `run_in_background=False`（默认）：内部 spawn + await + 把 result_text 当 return value 给 LLM。LLM 视角与同步工具等同。
- `run_in_background=True`：返回 `job_id`，LLM 自己决定何时 await。新代码 / plan mode 用。

切换开关位于 tool 签名里，按同步工具写的老 prompt 不改也能跑。工具能力广播给
LLM 的描述里说明两种模式 + 推荐用法。

---

## Part 2. 每种场景按维度过一遍

### 场景 A：单个 sync `/task`

最常见情况：LLM 调 `agent(prompt="探查 X")`，希望阻塞拿到结果。行为与同步工具
一致，但底层走 task entity。

| 维度 | 设计 |
|---|---|
| **D1 entity** | spawn 时创建 entity（含 prompt / agent_id / context_mode = inherit），`parent_job_id=None`，前台 |
| **D2 状态机** | 仍走完整 pending → queued → running → completed |
| **D3 worker pool** | submit 到 pool；pool 满则等 queued（同步语义下 LLM 会感知一点延迟但不变结果） |
| **D4 持久化** | 完整流程：每次状态转移写 jobs.json + git commit |
| **D5 cancel** | 用户 stop session → cancel 事件传到 task → sub-agent loop 中断；最终 status=`cancelled`，tool 返回部分输出 + `[cancelled]` 标记 |
| **D6 session 绑定** | parent_session = caller 的 session |
| **D7 sub-agent** | tool wrapper 内部 spawn + await：对 LLM 调用现场零变化 |
| **D8 ContextCommit** | placeholder attach card 短暂存在（毫秒到秒级，因为同步等结果），完成后立即更新；UI 几乎看不到 running 状态 |
| **D9 WS API** | tool 调用走 in-process API（不必经 WS）；UI 仍能通过 list_jobs 看到 |
| **D10 agent tool** | `agent(...)` 默认前台（`run_in_background=False`），覆盖 99% 既存代码路径 |
| **D11 UI** | Tasks panel 闪一下；attach card 直接出现完成态 |
| **D12 错误** | worker 抛错 → status=`errored` → tool 拿到 `[task error] ...` 字符串（保留现有错误格式） |
| **D13 测试** | unit：mock runner，验证 spawn + await 两次顺序 call；integration：真跑一个 trivial agent |
| **D14 plan mode** | 不适用（这是单 task） |
| **D15 兼容** | tool 签名不变，既有代码无需改动 |

---

### 场景 B：单个 async task（agent 主动选 async）

LLM 决定干个长活，先 spawn 拿 id，回头再 await 或 cancel。

| 维度 | 设计 |
|---|---|
| **D1 entity** | spawn 时后台创建；entity 立刻写盘 |
| **D2 状态机** | spawn 返回时通常已经 `queued` 或 `running`，对 LLM 透明 |
| **D3 worker pool** | submit 不阻塞 caller thread；caller LLM 继续下一个 tool call |
| **D4 持久化** | 同 A |
| **D5 cancel** | LLM 调 `job_stop(id)` 或用户 UI cancel；两条路径一样（都进 runner.cancel） |
| **D6 session 绑定** | 同 A |
| **D7 sub-agent** | spawn / await 解耦：LLM 在两个 tool call 之间可以读文件、搜代码等 |
| **D8 ContextCommit** | placeholder attach card 在 spawn 那一 turn 就写出，状态=running；后续 turn LLM 看到 ContextCommit 里这块仍是占位（generator 看 status=running 不展开） |
| **D9 WS API** | spawn_job → 返回 job_id；UI 立刻看到 Tasks panel 增一行 |
| **D10 agent tool** | `spawn_job` 返回 `job_id` 给 LLM；后续 `job_output(job_id)` 拿结果 |
| **D11 UI** | Tasks panel running 行 + sidebar 总计数 + attach card with status badge |
| **D12 错误** | 同 A，但 LLM 是在 await 时才感知 error（也可能在 await 之前用 get_job 查到） |
| **D13 测试** | unit：spawn 返回 job_id 后状态= queued/running；await 后状态正确转移 |
| **D14 plan mode** | 是 plan mode 的基础 building block |
| **D15 兼容** | 新工具，既有 prompt 不会触发 |

---

### 场景 C：并发 N 个 async task（plan mode）

plan agent 列出 5 个调研任务，spawn 5 个 task，调 `await_tasks(ids,
mode="all")` 等齐。

| 维度 | 设计 |
|---|---|
| **D1 entity** | 5 个 entity，`parent_job_id` 都指向 plan agent 当前的 turn (`parent_msg_id`)，便于 list_jobs 按 plan 分组 |
| **D2 状态机** | pool size=4 时，4 个 → running，1 个 → queued；先完成的转 completed，queued 的开跑 |
| **D3 worker pool** | 关键场景。FIFO 公平；pool 满时 spawn 立即返回 job_id，状态=`pending`/`queued`，await_tasks 自动等 |
| **D4 持久化** | 每个 task 自己一行 jobs.json；每次状态转移 commit。可以期望一次 plan 有 ~10-20 个 git commit |
| **D5 cancel** | `job_stop(id)` 单个；plan 整体撤销时 plan agent 自己遍历 cancel 所有 children（也可以加个 `cancel_jobs(parent_msg_id=...)` 批量 API 作为后续扩展） |
| **D6 session 绑定** | 5 个 task 全部跑在同一个 parent_session；落地后是 5 个并列 branch（branch label = task description）|
| **D7 sub-agent** | 5 个并发 sub-agent 同时跑，靠 thread pool 隔离；ContextVar (`current_session_id`) 是 thread-local，互不干扰 |
| **D8 ContextCommit** | 5 个 placeholder attach card 一字排开挂在 plan agent 的 fork point；完成顺序无关，UI 各自 update。后续 LLM turn 看到的 ContextCommit 里这 5 块是 attach 展开，按 `context.md` 场景 C 处理 |
| **D9 WS API** | spawn 5 次 + 1 次 await_tasks（包一层服务端等聚合，避免 LLM 多次轮询）|
| **D10 agent tool** | plan agent 用 `spawn_job` ×5 + `await_tasks(mode="all")` ×1 |
| **D11 UI** | Tasks panel 5 行；其中 1 行 queued 状态有时钟 icon。完成的逐个翻 completed |
| **D12 错误** | 部分失败：await_tasks 收齐所有终止态后返回 list，每条带自己的 status / error；plan agent 自己决定 partial 还是 retry |
| **D13 测试** | 关键覆盖 pool backpressure：spawn 6 个 task 但 pool=2，断言第 3-6 个停在 queued 直到前两个完成 |
| **D14 plan mode** | 这就是 plan mode 主要场景 |
| **D15 兼容** | 新 API，既有代码无影响 |

---

### 场景 D：长时间 task + cancel

agent spawn 一个 30 分钟的 deep research task，10 分钟后用户在 UI 点 Stop 或
agent 自己想撤。

| 维度 | 设计 |
|---|---|
| **D1 entity** | 跟 B 一样；cancel 时填 `cancel_requested_at` |
| **D2 状态机** | running → cancelled（或如果 worker 在 timeout 内未退则 forced cancelled） |
| **D3 worker pool** | thread 一直 occupy 到 worker 真的退出；pool slot 在 worker function return 后释放 |
| **D4 持久化** | cancel 请求立刻 commit；worker 退出后再 commit 一次（最终状态） |
| **D5 cancel** | 这是设计核心。cancel_event.set() → (a) `process_user_turn` 内 asyncio.Event 触发 → agent_loop 在下一个 stream chunk break → LLM call 中止；(b) `is_cancelled(session)` hook 让下一个 `Agent` method raise CancelledError；(c) BashTool 通过 `kill_active_runtime` 杀子进程；(d) 30 秒兜底 timeout 强转 status |
| **D6 session 绑定** | 不变 |
| **D7 sub-agent** | sub-agent loop 拿到 cancel 后走 dispatcher 现有的 cancelled 分支：placeholder 已经 insert，error 折进同一行 → status=cancelled，部分输出落盘 |
| **D8 ContextCommit** | attach card 状态从 running → cancelled；content 写部分输出 + `[cancelled at T]` 标记；generator 看 cancelled 也可以选择性展开（初版：不展开 cancelled，只显示 marker）|
| **D9 WS API** | cancel_job 立刻返回（不等 worker），UI 显示 "cancelling..." 状态；worker 真退出时再一条 job_status 广播 |
| **D10 agent tool** | LLM 可以调 `job_stop(id)`；后续 `job_output(id)` 立刻返回 cancelled 终态 |
| **D11 UI** | Stop 按钮已有；点击触发 cancel_job；spinner 变成 spinner + dim 颜色直到 worker 退出 |
| **D12 错误** | cancel timeout: 30s 后强转 status 但保留 worker thread；记 warn 日志；UI 提示 "task may still be running in background" |
| **D13 测试** | 关键测试：fake sync_fn 故意忽略 cancel_event 30 秒，断言 runner 在 30s 后强转 cancelled 状态 |
| **D14 plan mode** | plan agent 可能在 partial 完成时主动 cancel 剩下的 queued task（节省 budget）|
| **D15 兼容** | 前台默认下，用户 stop session 会同时 cancel sub-agent + 父 turn（已有行为，不变） |

---

## Part 3. 关键不变式

1. **终止态唯一**：每个 task entity 最终必有 completed / cancelled / errored 之一，不存在永久 running（pool shutdown / crash recovery 必转 errored）。
2. **状态单调**：状态机每条边只走一次。一旦 completed 不会再 → cancelled，不允许 running → pending。
3. **cancel 可达**：cancel API 返回后，30 秒内 entity 必出现 cancelled / errored 终态（强制 timeout 兜底）。
4. **placeholder 一致**：spawn 写的 attach card 跟 entity 同步状态；entity 状态变化必同步 update card。
5. **持久化 idempotent**：crash 后 reload，未完成 task 的状态确定为 errored；同 job_id 永不复活。
6. **session 绑定不可变**：task 创建后 `parent_session_id` 不变；同一 task 不跑两个 session。
7. **并发安全**：runner 内 `_tasks` + `_cancel_events` map 全程持锁；状态读写不竞争。
8. **测试可控**：runner 必须接受可注入的 `sync_fn` / `store`，不依赖真 LLM 也能跑全状态机。

---

## Part 4. 不在本设计范围

- **跨进程 task**：所有 worker 都在主进程内。分布式 / multi-host 留给后续，需要 message broker。
- **Task 优先级 / SLA**：FIFO 即可，没有高优先级抢占。后续按需加 priority queue。
- **Resume / 续跑**：cancelled / errored task 不能"接着跑"。用户 retry 等于新 spawn 一个 task。
- **Task 重试策略**：runner 不自动 retry；上层 agent / plan 自己决定。
- **多目标 task**：一个 task 只有一个不可变的执行 session。跨 session caller 通过 `caller_session_id` 与源侧 attach 支持，但同一 entity 不会在多个目标 session 执行。
- **DAG-shaped task 依赖**：`await_tasks(mode="all"|"any")` 已经够 plan mode；显式 DAG / pipeline 留给后续。
- **Task 输出流式订阅**：初版只在 task 完成时拿 final_text。中途订阅 stream（让父 agent 看到 sub-agent 边想边说）留给后续。
- **资源配额**：单 user 同时 task 数 / token 上限不在本设计，需要先有 multi-tenant 模型。

---

## 附录：实现状态

本设计已经部分实现。当前代码已有持久化 Job 记录、明确状态机、持久化 store、worker runner，以及查询、等待和取消操作。

| 能力 | 当前证据 | 状态 |
|---|---|---|
| Job 实体与状态机 | openprogram/agent/job/types.py：Job、JobStatus、转移校验 | 已实现 |
| 持久化存储 | openprogram/agent/job/store.py：load_job、list_jobs、update_job_status | 已实现 |
| Worker admission 与执行 | openprogram/agent/job/runner.py：JobRunner、spawn_job、OPENPROGRAM_JOB_WORKERS | 已实现，但仍需完整执行链和资源链验收 |
| 查询与等待 | JobRunner 的 get/list/await 方法；programs/tools/agents/agent/list_jobs 和 job_output | 已实现 |
| 取消 | JobRunner.cancel_execution 与 runner 的终态收敛 | JobRunner 层已实现；UI/工具覆盖取决于具体入口 |
| WebSocket 状态 | apps/server/openprogram_server/_webui/ws_actions/job.py（spawn/list/get）；apps/web/lib/net/use-ws.ts（job_status、spawn_job_result） | 这些 action 已实现 |
| 分支侧状态视图 | apps/web/components/right-sidebar/branches/index.tsx 订阅 job 状态广播 | 已实现；不是独立 Tasks panel |
| Attach card 生命周期 | Job.attach_pointer_id 与 runner/dispatcher 接线 | 部分实现；各状态和恢复路径仍需逐项验收 |
| 资源治理 | JobRunner 构造并使用 ResourceGovernor，并提供资源投影 | 部分接入；token/cost/runtime/idle 强制是独立契约 |
| 测试与崩溃矩阵 | 已有状态机和 runner 测试，但完整 spawn 到 await、并发取消和崩溃恢复矩阵仍需验证 | 不宣称全部验收通过 |

因此，实现已支持持久化 Job 以及常规查询/取消流程，但不能据此宣称所有 UI、资源、attach card 和崩溃恢复要求都已通过。独立 Tasks panel、中途输出流式订阅、跨进程执行、自动重试和 DAG 形状依赖仍属于范围外或后续工作。

更新本附录时应以上述路径为准；不要重新写回旧的“只有 _running_tasks”描述，也不要把已经存在的文件列为新文件。
