oh-my-opencode-cohub 1.18.1 → 1.18.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/cli/index.js +1 -1
  2. package/dist/config/loader.test.d.ts +2 -0
  3. package/dist/config/loader.test.d.ts.map +1 -0
  4. package/dist/context/diag-log.d.ts +50 -0
  5. package/dist/context/diag-log.d.ts.map +1 -1
  6. package/dist/context/diag-log.test.d.ts +2 -0
  7. package/dist/context/diag-log.test.d.ts.map +1 -0
  8. package/dist/context/engine.d.ts +18 -0
  9. package/dist/context/engine.d.ts.map +1 -1
  10. package/dist/context/prompt-budget.d.ts +45 -0
  11. package/dist/context/prompt-budget.d.ts.map +1 -0
  12. package/dist/context/prompt-budget.test.d.ts +2 -0
  13. package/dist/context/prompt-budget.test.d.ts.map +1 -0
  14. package/dist/context/types.d.ts +6 -0
  15. package/dist/context/types.d.ts.map +1 -1
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +942 -197
  18. package/dist/orchestration/engine.d.ts +19 -2
  19. package/dist/orchestration/engine.d.ts.map +1 -1
  20. package/dist/orchestration/rate-limit.d.ts +19 -0
  21. package/dist/orchestration/rate-limit.d.ts.map +1 -0
  22. package/dist/orchestration/rate-limit.test.d.ts +21 -0
  23. package/dist/orchestration/rate-limit.test.d.ts.map +1 -0
  24. package/dist/orchestration/types.d.ts +12 -0
  25. package/dist/orchestration/types.d.ts.map +1 -1
  26. package/dist/prompts/oracle.d.ts +1 -1
  27. package/dist/prompts/oracle.d.ts.map +1 -1
  28. package/dist/prompts/orchestrator.d.ts +1 -1
  29. package/dist/prompts/orchestrator.d.ts.map +1 -1
  30. package/dist/prompts/planner.d.ts +1 -1
  31. package/dist/prompts/planner.d.ts.map +1 -1
  32. package/dist/task-manager/tracker.d.ts +149 -13
  33. package/dist/task-manager/tracker.d.ts.map +1 -1
  34. package/dist/task-manager/types.d.ts +15 -0
  35. package/dist/task-manager/types.d.ts.map +1 -1
  36. package/dist/tools/job-control.d.ts +11 -4
  37. package/dist/tools/job-control.d.ts.map +1 -1
  38. package/dist/tools/job-control.test.d.ts +2 -0
  39. package/dist/tools/job-control.test.d.ts.map +1 -0
  40. package/dist/tui.js +121 -0
  41. package/dist/utils/log.d.ts +12 -0
  42. package/dist/utils/log.d.ts.map +1 -1
  43. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -14,7 +14,7 @@ var ORCHESTRATOR_PROMPT = `<角色>
14
14
  @co-council - 只读。多模型并行共识。委派:多专家视角/不可逆决策(数据迁移/API 变更)。错了还能改→@co-oracle,错了就完了→@co-council。
15
15
  @co-rule-user - 只读。分析用户级 AGENTS.md(~/.config/opencode/AGENTS.md)约束。委派:方案需对照用户规则时。
16
16
  @co-rule-project - 只读。分析项目 AGENTS.md / .rules/*.md 等仓库级规范。委派:方案需对照项目规则时。
17
- @co-rule-app - 只读。分析 .opencode/rules/*.md 应用规则。**并行策略**:当 rules 目录下有 N 个文件时,并行启动 N/2(向上取整)个 @co-rule-app 实例,每个实例负责 1-2 个规则文件(在 prompt 中明确指定文件列表)。所有实例完成后,Orchestrator 汇总建议。委派:方案需对照安全/测试/数据库等应用规则时。
17
+ @co-rule-app - 只读。分析 .opencode/rules/*.md 应用规则。**定向分析**:仅当方案确需对照应用规则时,先判断适用范围;同一任务/同一范围已核实过的规则不重复展开,只在发现新规则文件、规则变更或方案涉足新领域时定向分析相关文件(每个实例在 prompt 中明确指定负责的文件列表,可并行实例同条消息同时派发)。**注意**:\`.opencode/rules/\` 是应用级规则,勿与仓库级 \`.rules/*.md\`(归 @co-rule-project)混淆。委派:方案需对照安全/测试/数据库等应用规则时。
18
18
  @co-planner - 只读。综合需求+信息+规范,输出结构化任务分解方案。委派:信息收集和规范分析完成后。
19
19
 
20
20
  </子代理>
@@ -28,47 +28,49 @@ orchestrator 委派时可参考上述原则。不确定时,co-oracle 自身会
28
28
  <工作流>
29
29
 
30
30
  ## 1. 理解需求
31
- 纯知识问答直接回,代码需求继续。
31
+ 纯知识问答直接回答;纯只读需求(查询/探索/审查类)可按需单次委派并综合,不强迫走完整变更管线。涉及代码/文件修改的需求继续后续步骤。
32
32
 
33
33
  ## 2. 信息收集(委派子代理)
34
34
  @co-explorer 搜索定位 → @co-librarian 外部研究 → @co-observer 多媒体。并行启动,不动手。收集完成后汇总各子代理结果 → 进入步骤3 委派 @co-planner 制定方案。
35
35
 
36
- **规则分析并行策略**:需要对照 \`.opencode/rules/*.md\` 时,不要只派发一个 @co-rule-app。策略如下:
37
- 1. 先用 \`glob\`(委派 @co-explorer)列出 \`.opencode/rules/\` 下的所有 .md 文件
38
- 2. 按每 1-2 个文件分一组,并行派发多个 @co-rule-app 实例
39
- 3. 每个实例的 prompt 中明确指定它负责的规则文件列表(如 \`请分析 rules/A.md 和 rules/B.md\`)
40
- 4. 所有实例完成后,由 Orchestrator 汇总各实例返回的建议,作为 @co-planner 的输入之一。
36
+ **规则分析定向策略**:需要对照 \`.opencode/rules/*.md\` 时(注意与仓库级 \`.rules/*.md\` 区分,后者归 @co-rule-project):
37
+ 1. 先判断适用范围:当前方案是否真的需要对照该规则?同一任务/同一范围已核实过的规则**不重复展开**
38
+ 2. 仅在新规则文件、规则变更或方案涉足新领域时,定向分析相关文件(按相关性分组,可并行实例同条消息同时派发)
39
+ 3. 每个实例的 prompt 明确指定负责的文件列表,只传与当前方案相关的事实,不重复全量历史
40
+ 4. 实例完成后,由 Orchestrator 汇总建议,作为 @co-planner 的输入之一。
41
41
 
42
42
  ## 3. 制定方案(委派 @co-planner)
43
43
  将信息收集结果(代码库结构、API文档、规范分析等)汇总后委派给 @co-planner 制定结构化方案。收到 @co-planner 的方案后,orchestrator 审核(检查需求覆盖度、委派对象合理性、并行策略可行性),补充修正后,用 \`todowrite\` 创建正式任务列表。
44
44
 
45
- **审核重点:方案是否按 Wave 分组?** 如果 planner 输出的方案是扁平串行列表(没有按 Wave 分组),你必须手动重新分组:把无依赖的探索/研究任务放 Wave 1、修改不同文件的任务放同一 Wave、验证任务放最后。**方案末尾必须提供选项供用户选择**(如:A. 立即执行 / B. 修改方案 / C. 取消),等待用户回复后再进入调度执行。
45
+ **审核重点:方案是否按 Wave 分组?** 如果 planner 输出的方案是扁平串行列表(没有按 Wave 分组),你必须手动重新分组:把无依赖的探索/研究任务放 Wave 1、修改不同文件的任务放同一 Wave、验证任务放最后。**方案末尾必须提供选项供用户选择**(如:A. 立即执行 / B. 修改方案 / C. 取消),等待用户回复后再进入调度执行;**用户明确批准后续波次后,后续 Wave 执行不再反复要求批准**。同一 Wave 内无依赖、无共享可变状态冲突的任务尽可能在同一条消息中同时派发,不以任务数量/文件数量或猜测的容量预先限制;容量未知不能当作串行理由。
46
46
 
47
47
  ## 4. 调度执行(按波次并行)
48
48
 
49
- **编排引擎自动处理**(无需手动追踪状态):
50
- - 引擎自动维护六状态(pending→ready→running→completed/failed→pending/cancelled)
51
- - 依赖就绪时自动将 pending → ready(DAG 依赖图)
52
- - 失败时引擎自动重试(按 RetryPolicy 策略)或降级到备用代理
53
- - 级联取消:父任务取消 → 所有后继任务自动 cancelled
54
- - **你只需调用 \`task()\`,引擎会自动注册、追踪完成状态、处理重试**
49
+ **插件职责边界**(准确区分三层,不夸大插件能力):
50
+ - **原生 \`task()\`**:任务由 OpenCode 原生 task 执行;task 返回结果与生命周期通知来自宿主工具本身
51
+ - **插件观测**:TaskTracker 仅记录任务生命周期(pending→running→completed/failed)供 TUI 展示与统计,**不自动派发、不自动重试、不自动取消**
52
+ - **主代理显式处理**:依赖推进(仅按任务的实际前置结果推进——任务前置依赖已完成且无共享可变状态冲突即可派发,不等无关任务)、失败重派、取消后继任务,都由你(orchestrator)根据 task 返回结果显式执行
53
+
54
+ **你需自行显式处理**:
55
+ - 失败重派:先分析根因与重试预算,确认先前副作用(已写入文件/已启动后台任务)后再决定重派、降级或换代理;**后台已启动的任务不能重复启动**
56
+ - 取消后继:需要取消时显式执行取消,无自动级联取消
57
+ - **未知状态不能当作成功,无活动不能断言"仍在计算"**——以 task 返回结果与宿主通知为准,不臆测状态
55
58
 
56
59
  **orchestrator 执行方法:按波次(Wave)执行**
57
60
 
58
61
  planner 的方案已按 Wave 分组(或你自己审核时重新分组),执行时:
59
62
 
60
- 1. **从 Wave 1 开始** — 同一 Wave 内的所有任务**一次消息同时启动**
61
- 2. **等待 Wave 1 全部完成** — 等待所有 \`task()\` 返回结果
62
- 3. **进入 Wave 2** — 同一 Wave 内的所有任务**一次消息同时启动**
63
- 4. **重复直到所有 Wave 完成**
63
+ 1. **从 Wave 1 开始** — 同一 Wave 内无依赖、无共享可变状态冲突的任务**在同一条消息中同时派发**(不按数量/容量预先分批)
64
+ 2. **按实际前置结果推进** — 一个任务的**全部实际前置依赖已完成且无共享可变状态冲突,即可派发**,不必等本 Wave / 其他 Wave 的无关任务;同次所有已就绪任务同一条消息同时启动
65
+ 3. **进入下一批已就绪任务** — 按同样的方式派发,重复直到所有任务完成
66
+ 4. **Wave 只展示依赖层次,不构成整体等待屏障** — 若 Wave 1 的 B 已完成、C 仍慢,Wave 2 中只依赖 B 的任务可立即派发,不等 C
64
67
 
65
- **关键:同一 Wave 内的任务必须同时启动,绝不逐个串行。**
68
+ **关键:可并行任务必须同时启动,绝不逐个串行;等待仅因任务自身的真实前置依赖与共享状态冲突,不因 Wave 序号或默认批次人为制造等待。**
66
69
 
67
- ✅ 正确示例(Wave 1 有 3 个独立探索任务):
70
+ ✅ 正确示例(3 个独立探索 + 2 个依赖修改):
68
71
  \`\`\`
69
72
  → 同时发起:task(@explorer 搜索A) + task(@explorer 搜索B) + task(@librarian 查文档)
70
- → 等全部返回
71
- → 同时发起:task(@fixer 修改A) + task(@fixer 修改B) // Wave 2
73
+ → 搜索A、搜索B 已返回(查文档仍慢)→ 立即发起:task(@fixer 修改A) + task(@fixer 修改B) // 只等实际前置,不等无关的查文档
72
74
  \`\`\`
73
75
 
74
76
  ❌ 错误示例(串行):
@@ -80,13 +82,14 @@ planner 的方案已按 Wave 分组(或你自己审核时重新分组),执
80
82
 
81
83
  **⚠️ 执行前并行检查清单——每次准备派发 task 前,必须逐条确认(不可跳过):**
82
84
 
83
- □ **找出当前 Wave 的所有任务**:从 planner 方案中提取当前 Wave 的全部任务列表
84
- □ **识别不同文件的任务**:涉及不同文件?→ **必须并行派发,一次消息同时启动所有**
85
- □ **识别同文件的任务**:涉及同一文件?→ **必须串行排队,当前 Wave 的所有任务完成后,再启动下一批**
85
+ □ **找出当前已就绪的任务**:从 planner 方案中找出**实际前置依赖已完成且无共享可变状态冲突**的任务(不等本 Wave / 其他 Wave 的无关任务)
86
+ □ **识别不同文件的任务**:涉及不同文件且无依赖?→ **同一条消息中同时派发**;独立任务不按数量/猜测容量预先限制,容量未知不能当串行理由
87
+ □ **识别同文件的任务**:涉及同一文件?→ **独立只读可并行**(多个 reader 同时读同文件无冲突);**同文件多写、或要求特定文件版本/读取结果的读写任务**按实际冲突与依赖串行(后一任务等前一任务完成后再派发,不把单文件任务拆给多个 writer),**其他任务不陪等**
88
+ □ **检查背压信号**:收到 429/TPM 限流等背压 → **暂停新增同类委派**,尊重宿主提供的重试时间(retry.next / Retry-After);不无限重派/轮询,不取消他人任务;插件未实现自动退避
86
89
  □ **区分修改 Wave 与验证 Wave**:当前 Wave 是修改 Wave(只改文件不编译)?→ **委派 fixer 时必须注明"本轮只修改不验证,编译/测试统一在后续 Wave 执行"**。当前 Wave 是验证 Wave?→ 正常委派 fixer 执行编译/测试。
87
- □ **确认派发方式**:以上确认完成后 → **一次消息中同时发起当前 Wave 的所有 task 调用,绝不逐个串行**
90
+ □ **确认派发方式**:以上确认完成后 → **发起所有已就绪任务的 task 调用,同条消息同时启动,绝不逐个串行**
88
91
 
89
- 清晰文件范围+背景启动+追踪不重复+协调冲突。委派指令用中文。
92
+ 委派指令用中文,子代理 prompt 只传与当前子任务相关的事实/接口/文件,不重复全历史和无关依赖结果。后台启动使用 OpenCode 原生 \`task.background\`,不虚构自定义参数(如 run_in_background)。轻量任务走已有低成本路由,不私改 provider/variant 配置。
90
93
 
91
94
  ## 5. 验证(全部委派)
92
95
  @co-fixer 编译测试 →(编译通过后)@co-oracle 代码审查 与 @co-designer UI审查 并行。发现问题重新委派。
@@ -102,9 +105,9 @@ planner 的方案已按 Wave 分组(或你自己审核时重新分组),执
102
105
  <rule priority="1" name="先方案后执行">
103
106
  ### 规则 1:理解需求后必须先输出方案
104
107
 
105
- **⚠️ 长会话警告:这是最容易被遗忘的规则。无论会话多长、已经执行了多少步、之前分析过什么,每次收到新需求时,必须重新从头执行:分析需求 → 委派信息收集 → 委派 @co-planner 制定方案 → 审核 → todowrite → 提供选项供用户选择 → 委派执行。禁止"前面分析过了这次直接改"、"改着改着就忘了"。**
108
+ **⚠️ 增量流程边界(避免重复全量分析):这是最容易被遗忘的规则。涉及代码/文件修改的需求,禁止未经方案直接执行;但分析按增量进行——同一任务/同一范围的已核实事实(代码位置、API 行为、规则结论)与已获批准的计划**可直接复用**,无需重新探索;仅当需求变化、代码基线变化或关键文件变化时,定向补充缺失信息,不重复全量规则分析。仍必须走:分析需求 →(必要时定向信息收集)→ 委派 @co-planner 制定方案 → 审核 → todowrite → 提供选项供用户选择 → 委派执行。**
106
109
 
107
- 收到需求后(涉及代码或文件修改时),**禁止立即执行**。必须先分析需求,委派 @co-planner 制定方案,orchestrator 审核后输出可验证的任务分解方案,**末尾提供选项(如"立即执行 / 修改方案")供用户决定**。方案包含:
110
+ 收到需求后(涉及代码或文件修改时),**禁止立即执行**。必须先分析需求,委派 @co-planner 制定方案,orchestrator 审核后输出可验证的任务分解方案,**末尾提供选项(如"立即执行 / 修改方案")供用户决定**。单文件小改仍输出简化但可验证的计划并等待批准;用户明确批准后续波次后,后续 Wave 执行不再反复要求批准。方案包含:
108
111
  (纯信息性问题可直接回答,无需方案。)
109
112
  - 子任务列表及其依赖关系
110
113
  - 每个子任务的委派对象(@co-explorer / @co-librarian / @co-fixer / @co-designer / @co-oracle / @co-observer)
@@ -127,16 +130,19 @@ planner 的方案已按 Wave 分组(或你自己审核时重新分组),执
127
130
  </rule>
128
131
 
129
132
  <rule priority="3" name="并行优先">
130
- ### 规则 3:并行优先
131
- 分析任务依赖后,最大程度并行化——独立任务同时启动。不确定是否独立时,宁可并行(发现冲突再修正比串行等待快)。
133
+ ### 规则 3:并行优先(真实依赖与共享状态为界)
134
+ 分析任务依赖后,最大化并行收益——无依赖、无共享可变状态冲突的独立任务**同一条消息中同时派发**,不按数量/文件数量/猜测容量预先限制(容量未知不能当作串行理由);不确定是否独立时,先确认依赖与文件冲突,而不是盲目串行。
132
135
 
133
136
  **并行决策框架**:
134
- - 信息收集阶段:@co-explorer + @co-librarian + @co-observer 总是并行
135
- - 规则分析阶段:多个 @co-rule-app 实例总是并行
136
- - 执行阶段:修改不同文件的 @co-fixer 任务可并行;同一文件必须串行
137
+ - 信息收集阶段:@co-explorer + @co-librarian + @co-observer 按需并行(定向补充,不重复全量)
138
+ - 规则分析阶段:多个 @co-rule-app 实例按需定向并行(只分析适用规则,已核实事实复用)
139
+ - 执行阶段:修改不同文件的 @co-fixer 任务可并行;同文件独立只读可并行;同文件多写或要求特定版本/读取结果的读写任务按实际冲突与依赖串行(其他任务不陪等)
137
140
  - 验证阶段:编译通过后,@co-oracle 代码审查 与 @co-designer UI审查 可并行
141
+ - **背压信号**:收到 429/TPM 限流等背压 → 暂停新增同类委派,尊重宿主提供的重试时间;不无限重派/轮询,不取消他人任务
138
142
 
139
143
  **⚠️ 并行退火警告**:长会话中,模型易陷入"一次只做一件事"的串行惯性。**每当你准备只发起一个 task 调用时,必须先自问:"还有没有其他可以同时完成的独立任务?"** 如果有——无论多小——必须立即找到并同时发起。单个 task 调用是最后手段,不是默认行为。
144
+
145
+ **收敛重复审查**:审查类委派先交付具体文件与验收标准,一次集中验证后按风险一次主审;仅当存在未解决问题或新增变更时复审,不对同一事实让多代理反复全量调查。
140
146
  </rule>
141
147
 
142
148
  </critical_rules>
@@ -149,7 +155,7 @@ planner 的方案已按 Wave 分组(或你自己审核时重新分组),执
149
155
  → 需要修改代码或文件 → **必须先输出方案 → 提供选项 → 等用户选择后才可委派执行**
150
156
 
151
157
  □ **本轮需要同时发起多个独立操作吗?**
152
- → 有 2+ 个修改不同文件的任务 / 探索任务 / 验证任务 → **必须一次消息同时发起所有 task,不得逐个串行**
158
+ → 有 2+ 个无依赖任务 → **同一条消息中同时派发,不得逐个串行**
153
159
  → 仅 1 个任务(确认无其他独立任务可并行) → 可以单个发起
154
160
 
155
161
  </自检清单>
@@ -235,6 +241,7 @@ var ORACLE_PROMPT = `你是 Oracle——战略技术顾问和代码审查者。
235
241
  - 简要解释推理
236
242
  - 存在不确定性时承认
237
243
  - 除非复杂度明确有收益,否则优先简单设计
244
+ - **收敛复审**:一次集中验证后按风险一次主审;仅当存在未解决问题或新增变更时复审,不对同一事实重复全量调查
238
245
 
239
246
  **约束**:
240
247
  - 只读:你提出建议,不实施
@@ -700,9 +707,9 @@ var PLANNER_PROMPT = `你是方案制定代理——负责任务分解和委派
700
707
 
701
708
  | 波次 | 含义 | 执行方式 |
702
709
  |------|------|---------|
703
- | Wave 1 | 无前置依赖的任务 | **全部并行启动** |
704
- | Wave 2 | 依赖 Wave 1 的任务 | 等 Wave 1 全部完成后**全部并行启动** |
705
- | Wave N | 依赖 Wave N-1 的任务 | 等上一波全部完成后**全部并行启动** |
710
+ | Wave 1 | 无前置依赖的任务 | **同一条消息同时派发**(真实无依赖、无共享可变状态冲突,不按数量/容量预先分批) |
711
+ | Wave 2 | 依赖 Wave 1 的任务 | **任务实际前置依赖完成后即派发**(不等本波无关任务;同次所有已就绪任务同一消息同时启动) |
712
+ | Wave N | 依赖 Wave N-1 的任务 | **任务实际前置依赖完成后即派发**(不等本波无关任务;同次所有已就绪任务同一消息同时启动) |
706
713
 
707
714
  ### 输出格式
708
715
 
@@ -712,7 +719,7 @@ var PLANNER_PROMPT = `你是方案制定代理——负责任务分解和委派
712
719
  - 波次:M 个
713
720
  - 涉及文件:文件列表
714
721
 
715
- ## Wave 1(无依赖,全并行)
722
+ ## Wave 1(无依赖,同 Wave 并行)
716
723
  - 任务 1.1:@explorer 搜索定位 xxx 文件中的关键代码
717
724
  - 依赖:无
718
725
  - 文件:xxx
@@ -750,11 +757,11 @@ var PLANNER_PROMPT = `你是方案制定代理——负责任务分解和委派
750
757
 
751
758
  ## 关键原则
752
759
 
753
- 1. **同一 Wave 内的任务数 = 并行度** — 尽量把更多无依赖的任务塞进同一个 Wave
760
+ 1. **同 Wave 并行** — 无依赖、无共享可变状态冲突的任务放同一 Wave,同一条消息同时派发;任务实际前置依赖完成即可派发(Wave 不构成整体等待屏障,不等无关任务);不按数量/文件数量/猜测容量预先分批(容量未知不能当串行理由),真实 429/TPM 背压按服务反馈处理
754
761
  2. **不同文件的任务 → 同 Wave 并行** — 修改 a.ts 和 b.ts 无冲突,放同一 Wave
755
- 3. **同一文件的任务 → 分不同 Wave 串行** — 先读后写必须分两波
756
- 4. **探索任务永远放 Wave 1** — 搜索/查文档无依赖,先全部并行跑完
757
- 5. **验证任务放最后 Wave** — 所有修改完成后才编译/测试/审查。修改 Wave 中的 fixer 任务仅做文件修改,不包含编译/测试步骤
762
+ 3. **同文件冲突按实际粒度判定** — 同文件**独立只读可并行**(多个 reader 同文件无冲突);同文件**多写、或要求特定文件版本/读取结果的读写任务**按实际冲突与依赖串行(如先读后写必须等读完成),**其他任务不陪等**
763
+ 4. **探索任务定向放 Wave 1** — 搜索/查文档无依赖,先并行跑;但只收集尚未核实、与需求直接相关的信息,同范围已核实信息直接复用,不重复全量扫描
764
+ 5. **验证任务放最后 Wave** — 所有涉及修改完成后才编译/测试/审查(这是真实依赖:编译必须看到全部修改),但不等与验证无关的任务。修改 Wave 中的 fixer 任务仅做文件修改,不包含编译/测试步骤
758
765
 
759
766
  **约束**:只读,不修改文件。方案要具体到文件和操作粒度,不可笼统。`;
760
767
 
@@ -824,6 +831,7 @@ var DEFAULT_CONTEXT_CONFIG = {
824
831
  maxDependencies: 8,
825
832
  dependencyPropagation: true,
826
833
  summarizeMaxTokens: 2000,
834
+ summaryReadMode: "bounded",
827
835
  relevantMessageWindow: 20,
828
836
  maxErrorTotalChars: 600,
829
837
  dependencyKeyOutputChars: 200
@@ -878,20 +886,29 @@ function computeStats(records, window = DEFAULT_STATS_WINDOW) {
878
886
 
879
887
  class TaskTracker {
880
888
  jobs = new Map;
889
+ callIndex = new Map;
881
890
  counters = new Map;
882
891
  _currentParentSessionId = "";
883
892
  _reconciledForParent = "";
884
893
  get currentParentSessionId() {
885
894
  return this._currentParentSessionId;
886
895
  }
896
+ callKey(parentSessionId, callID) {
897
+ return parentSessionId + "\x00" + callID;
898
+ }
887
899
  alias(agentType) {
888
900
  const short = agentType.slice(0, 4).replace(/[^a-z]/gi, "");
889
901
  const n = (this.counters.get(agentType) ?? 0) + 1;
890
902
  this.counters.set(agentType, n);
891
903
  return `${short}-${n}`;
892
904
  }
893
- registerBeforeTask(parentSessionId, args) {
905
+ registerBeforeTask(parentSessionId, args, callID) {
894
906
  this._currentParentSessionId = parentSessionId;
907
+ if (callID) {
908
+ const existing = this.getJobByCall(parentSessionId, callID);
909
+ if (existing)
910
+ return existing.alias;
911
+ }
895
912
  if (this._reconciledForParent !== parentSessionId) {
896
913
  this._reconciledForParent = parentSessionId;
897
914
  for (const job of this.jobs.values()) {
@@ -903,7 +920,7 @@ class TaskTracker {
903
920
  const agent = args.subagent_type ?? "unknown";
904
921
  const alias = this.alias(agent);
905
922
  const label = typeof args.description === "string" && args.description ? args.description : alias;
906
- this.jobs.set(alias, {
923
+ const job = {
907
924
  alias,
908
925
  sessionId: args.task_id ?? "",
909
926
  parentSessionId,
@@ -914,40 +931,154 @@ class TaskTracker {
914
931
  background: args.background ?? false,
915
932
  terminalReconciled: false,
916
933
  createdAt: Date.now()
917
- });
934
+ };
935
+ if (callID)
936
+ job.callID = callID;
937
+ this.jobs.set(alias, job);
938
+ if (callID)
939
+ this.callIndex.set(this.callKey(parentSessionId, callID), alias);
918
940
  return alias;
919
941
  }
942
+ getJobByCall(parentSessionId, callID) {
943
+ const alias = this.callIndex.get(this.callKey(parentSessionId, callID));
944
+ return alias ? this.jobs.get(alias) : undefined;
945
+ }
946
+ updateAfterTaskByCall(parentSessionId, callID, status, sessionId) {
947
+ const job = this.getJobByCall(parentSessionId, callID);
948
+ if (!job)
949
+ return { ok: false, reason: "no-match" };
950
+ return this.applyCallUpdate(job, status, sessionId);
951
+ }
952
+ setBackgroundByCall(parentSessionId, callID, bg) {
953
+ const job = this.getJobByCall(parentSessionId, callID);
954
+ if (!job)
955
+ return { ok: false, reason: "no-match" };
956
+ if (job.status !== "running") {
957
+ return { ok: true, reason: "idempotent", alias: job.alias };
958
+ }
959
+ job.background = bg;
960
+ return { ok: true, reason: "ok", alias: job.alias };
961
+ }
962
+ getJobByAlias(alias) {
963
+ return this.jobs.get(alias);
964
+ }
965
+ updateByAlias(alias, status, sessionId) {
966
+ const job = this.jobs.get(alias);
967
+ if (!job)
968
+ return { ok: false, reason: "no-match" };
969
+ return this.applyCallUpdate(job, status, sessionId);
970
+ }
971
+ applyCallUpdate(job, status, sessionId) {
972
+ if (!job.background) {
973
+ if (job.status !== "running") {
974
+ return { ok: true, reason: "idempotent", alias: job.alias };
975
+ }
976
+ job.status = status;
977
+ job.terminalReconciled = true;
978
+ if (sessionId)
979
+ job.sessionId = sessionId;
980
+ this.finalize(job);
981
+ this.persistStats();
982
+ return { ok: true, reason: "ok", alias: job.alias };
983
+ }
984
+ if (sessionId)
985
+ job.sessionId = sessionId;
986
+ return { ok: true, reason: "ok", alias: job.alias };
987
+ }
920
988
  updateAfterTask(parentSessionId, status, sessionId) {
921
989
  this._currentParentSessionId = parentSessionId;
922
- let latest;
990
+ const running = [];
923
991
  for (const job of this.jobs.values()) {
924
- if (job.parentSessionId === parentSessionId && job.status === "running" && (!latest || job.createdAt > latest.createdAt)) {
925
- latest = job;
992
+ if (job.parentSessionId === parentSessionId && job.status === "running") {
993
+ running.push(job);
926
994
  }
927
995
  }
928
- if (latest) {
929
- if (latest.background) {
930
- if (sessionId)
931
- latest.sessionId = sessionId;
932
- return;
996
+ if (running.length === 0)
997
+ return { ok: false, reason: "no-match" };
998
+ if (running.length > 1)
999
+ return { ok: false, reason: "ambiguous" };
1000
+ return this.applyCallUpdate(running[0], status, sessionId);
1001
+ }
1002
+ updateByChildSessionId(sessionId, status) {
1003
+ let allWithSession = 0;
1004
+ let runningBg;
1005
+ for (const job of this.jobs.values()) {
1006
+ if (job.sessionId === sessionId) {
1007
+ allWithSession++;
1008
+ if (job.background && job.status === "running")
1009
+ runningBg = job;
933
1010
  }
934
- latest.status = status;
935
- latest.terminalReconciled = true;
936
- if (sessionId)
937
- latest.sessionId = sessionId;
938
- this.finalize(latest);
939
- this.persistStats();
940
1011
  }
1012
+ if (allWithSession === 0)
1013
+ return { ok: false, reason: "no-match" };
1014
+ if (allWithSession > 1)
1015
+ return { ok: false, reason: "ambiguous" };
1016
+ if (!runningBg)
1017
+ return { ok: false, reason: "no-match" };
1018
+ runningBg.status = status;
1019
+ this.finalize(runningBg);
1020
+ this.persistStats();
1021
+ return { ok: true, reason: "ok", alias: runningBg.alias };
941
1022
  }
942
- updateByChildSessionId(sessionId, status) {
1023
+ noteChildSessionIdle(sessionId) {
1024
+ let allWithSession = 0;
1025
+ let runningBg;
943
1026
  for (const job of this.jobs.values()) {
944
- if (job.sessionId === sessionId && job.background && job.status === "running") {
945
- job.status = status;
946
- this.finalize(job);
947
- this.persistStats();
948
- return;
1027
+ if (job.sessionId === sessionId) {
1028
+ allWithSession++;
1029
+ if (job.background && job.status === "running")
1030
+ runningBg = job;
1031
+ }
1032
+ }
1033
+ if (allWithSession === 0)
1034
+ return { ok: false, reason: "no-match" };
1035
+ if (allWithSession > 1)
1036
+ return { ok: false, reason: "ambiguous" };
1037
+ if (!runningBg)
1038
+ return { ok: false, reason: "no-match" };
1039
+ return { ok: true, reason: "observed", alias: runningBg.alias };
1040
+ }
1041
+ findActiveByChildSessionId(sessionId) {
1042
+ let active;
1043
+ for (const job of this.jobs.values()) {
1044
+ if (job.sessionId === sessionId && job.status === "running") {
1045
+ if (active)
1046
+ return { ok: false, reason: "ambiguous" };
1047
+ active = job;
1048
+ }
1049
+ }
1050
+ return active ? { ok: true, job: active } : { ok: false, reason: "no-match" };
1051
+ }
1052
+ finalizeByCall(parentSessionId, callID, expectedChildId, status) {
1053
+ const job = this.getJobByCall(parentSessionId, callID);
1054
+ if (!job)
1055
+ return { ok: false, reason: "no-match" };
1056
+ if (job.status !== "running") {
1057
+ return { ok: true, reason: "idempotent", alias: job.alias };
1058
+ }
1059
+ if (status === "running") {
1060
+ return { ok: false, reason: "no-match" };
1061
+ }
1062
+ if (expectedChildId !== undefined) {
1063
+ if (job.sessionId !== "" && job.sessionId !== expectedChildId) {
1064
+ return { ok: false, reason: "no-match" };
1065
+ }
1066
+ if (job.sessionId === "")
1067
+ job.sessionId = expectedChildId;
1068
+ let runningShare = 0;
1069
+ for (const j of this.jobs.values()) {
1070
+ if (j.sessionId === expectedChildId && j.status === "running")
1071
+ runningShare++;
949
1072
  }
1073
+ if (runningShare === 0)
1074
+ return { ok: false, reason: "no-match" };
1075
+ if (runningShare > 1)
1076
+ return { ok: false, reason: "ambiguous" };
950
1077
  }
1078
+ job.status = status;
1079
+ this.finalize(job);
1080
+ this.persistStats();
1081
+ return { ok: true, reason: "ok", alias: job.alias };
951
1082
  }
952
1083
  getBoardText(parentSessionId) {
953
1084
  if (typeof parentSessionId !== "string" || parentSessionId === "")
@@ -1054,6 +1185,10 @@ class TaskTracker {
1054
1185
  }
1055
1186
  }
1056
1187
  for (const alias of toDelete) {
1188
+ const job = this.jobs.get(alias);
1189
+ if (job?.callID) {
1190
+ this.callIndex.delete(this.callKey(job.parentSessionId, job.callID));
1191
+ }
1057
1192
  this.jobs.delete(alias);
1058
1193
  }
1059
1194
  return toDelete.length;
@@ -1089,14 +1224,28 @@ class TaskTracker {
1089
1224
  return;
1090
1225
  }
1091
1226
  updateQuality(sessionId, quality) {
1227
+ let hit;
1092
1228
  for (const job of this.jobs.values()) {
1093
1229
  if (job.sessionId === sessionId) {
1094
- job.quality = quality;
1095
- this.finalize(job);
1096
- this.persistStats();
1097
- return;
1230
+ if (hit)
1231
+ return;
1232
+ hit = job;
1098
1233
  }
1099
1234
  }
1235
+ if (hit) {
1236
+ hit.quality = quality;
1237
+ this.finalize(hit);
1238
+ this.persistStats();
1239
+ }
1240
+ }
1241
+ updateQualityByAlias(alias, quality) {
1242
+ const job = this.jobs.get(alias);
1243
+ if (!job)
1244
+ return { ok: false, reason: "no-match" };
1245
+ job.quality = quality;
1246
+ this.finalize(job);
1247
+ this.persistStats();
1248
+ return { ok: true, reason: "ok", alias };
1100
1249
  }
1101
1250
  finalize(job) {
1102
1251
  if (job.status !== "running") {
@@ -1145,18 +1294,60 @@ class TaskTracker {
1145
1294
  this.persistStats();
1146
1295
  }
1147
1296
  }
1148
- abortJob(taskId) {
1149
- let job = this.jobs.get(taskId);
1150
- if (!job) {
1151
- for (const j of this.jobs.values()) {
1152
- if (j.sessionId === taskId) {
1153
- job = j;
1154
- break;
1155
- }
1297
+ resolveCancelTarget(taskId) {
1298
+ const byAlias = this.jobs.get(taskId);
1299
+ if (byAlias)
1300
+ return { ok: true, job: byAlias };
1301
+ let active;
1302
+ for (const job of this.jobs.values()) {
1303
+ if (job.sessionId === taskId && job.status === "running") {
1304
+ if (active)
1305
+ return { ok: false, reason: "ambiguous" };
1306
+ active = job;
1156
1307
  }
1157
1308
  }
1309
+ return active ? { ok: true, job: active } : { ok: false, reason: "no-match" };
1310
+ }
1311
+ markCancelledByAlias(alias) {
1312
+ const job = this.jobs.get(alias);
1158
1313
  if (!job)
1159
- return;
1314
+ return { ok: false, reason: "no-match" };
1315
+ if (job.status !== "running") {
1316
+ return { ok: true, reason: "idempotent", alias };
1317
+ }
1318
+ job.status = "cancelled";
1319
+ job.terminalReconciled = true;
1320
+ this.finalize(job);
1321
+ this.persistStats();
1322
+ return { ok: true, reason: "ok", alias };
1323
+ }
1324
+ abortJob(taskId) {
1325
+ const byAlias = this.jobs.get(taskId);
1326
+ if (byAlias)
1327
+ return this.abortFound(byAlias);
1328
+ let active;
1329
+ let firstHistory;
1330
+ let historyCount = 0;
1331
+ for (const j of this.jobs.values()) {
1332
+ if (j.sessionId !== taskId)
1333
+ continue;
1334
+ if (j.status === "running") {
1335
+ if (active)
1336
+ return;
1337
+ active = j;
1338
+ } else {
1339
+ if (!firstHistory)
1340
+ firstHistory = j;
1341
+ historyCount++;
1342
+ }
1343
+ }
1344
+ if (active)
1345
+ return this.abortFound(active);
1346
+ if (historyCount === 1)
1347
+ return this.abortFound(firstHistory);
1348
+ return;
1349
+ }
1350
+ abortFound(job) {
1160
1351
  if (job.status !== "running") {
1161
1352
  return { sessionId: job.sessionId || undefined, job };
1162
1353
  }
@@ -1603,7 +1794,44 @@ function replaceMarkerWithContext(messageText, context) {
1603
1794
  return messageText.replace(CONTEXT_MARKER_PATTERN, formatted);
1604
1795
  }
1605
1796
 
1797
+ // src/context/prompt-budget.ts
1798
+ var SUMMARY_READ_MAX_BYTES_PER_FILE = 1024 * 1024;
1799
+ var SUMMARY_READ_LOOKAHEAD_BYTES = 4;
1800
+ function tokenBudgetToByteLimit(budgetTokens) {
1801
+ const n = Number(budgetTokens);
1802
+ if (!Number.isFinite(n) || n <= 0)
1803
+ return 4;
1804
+ const raw = Math.ceil(n * 4 * 2);
1805
+ return Math.min(SUMMARY_READ_MAX_BYTES_PER_FILE, Math.max(4, raw));
1806
+ }
1807
+ function utf8TrimIncomplete(buf) {
1808
+ if (buf.length === 0)
1809
+ return buf;
1810
+ const last = buf[buf.length - 1];
1811
+ if (last < 128)
1812
+ return buf;
1813
+ let i = buf.length - 1;
1814
+ while (i > 0 && (buf[i] & 192) === 128)
1815
+ i--;
1816
+ const head = buf[i];
1817
+ let seqLen = 0;
1818
+ if ((head & 224) === 192)
1819
+ seqLen = 2;
1820
+ else if ((head & 240) === 224)
1821
+ seqLen = 3;
1822
+ else if ((head & 248) === 240)
1823
+ seqLen = 4;
1824
+ else
1825
+ return buf;
1826
+ if (buf.length - i < seqLen)
1827
+ return buf.subarray(0, i);
1828
+ return buf;
1829
+ }
1830
+
1606
1831
  // src/context/engine.ts
1832
+ var READ_LIMIT_TRUNCATION_SUFFIX = `
1833
+ … [正文已按读取上限截断]`;
1834
+
1607
1835
  class ContextEngine {
1608
1836
  registry = new Map;
1609
1837
  registryTouchedAt = new Map;
@@ -1682,11 +1910,39 @@ class ContextEngine {
1682
1910
  const budgetPerFile = Math.max(1, Math.floor(this.config.summarizeMaxTokens / files.length));
1683
1911
  for (const file of files) {
1684
1912
  try {
1685
- const content = fs2.readFileSync(file.path, "utf-8");
1686
- file.body = truncateByTokens(content, budgetPerFile);
1913
+ if (this.config.summaryReadMode === "full") {
1914
+ const content = fs2.readFileSync(file.path, "utf-8");
1915
+ file.body = truncateByTokens(content, budgetPerFile);
1916
+ } else {
1917
+ const { text, truncatedByReadLimit } = this.readBoundedUtf8(file.path, tokenBudgetToByteLimit(budgetPerFile));
1918
+ file.body = truncateByTokens(text, budgetPerFile);
1919
+ if (truncatedByReadLimit && estimateTokens(file.body) <= budgetPerFile) {
1920
+ file.body += READ_LIMIT_TRUNCATION_SUFFIX;
1921
+ }
1922
+ }
1687
1923
  } catch {}
1688
1924
  }
1689
1925
  }
1926
+ readBoundedUtf8(filePath, byteLimit) {
1927
+ const fd = fs2.openSync(filePath, "r");
1928
+ try {
1929
+ const { size } = fs2.fstatSync(fd);
1930
+ const toRead = Math.min(size, byteLimit + SUMMARY_READ_LOOKAHEAD_BYTES);
1931
+ const buf = Buffer.allocUnsafe(Math.max(0, toRead));
1932
+ let filled = 0;
1933
+ while (filled < toRead) {
1934
+ const n = fs2.readSync(fd, buf, filled, toRead - filled, filled);
1935
+ if (n <= 0)
1936
+ break;
1937
+ filled += n;
1938
+ }
1939
+ const truncatedByReadLimit = filled > byteLimit;
1940
+ const raw = buf.subarray(0, Math.min(filled, byteLimit));
1941
+ return { text: utf8TrimIncomplete(raw).toString("utf-8"), truncatedByReadLimit };
1942
+ } finally {
1943
+ fs2.closeSync(fd);
1944
+ }
1945
+ }
1690
1946
  consumeMarkedContext(messageText) {
1691
1947
  const markerMatch = messageText.match(CONTEXT_MARKER_PATTERN);
1692
1948
  if (!markerMatch)
@@ -1973,6 +2229,47 @@ function appendCtxDiag(record, filePath) {
1973
2229
  } catch {}
1974
2230
  });
1975
2231
  }
2232
+ var TASK_DIAG_STRING_MAX = 64;
2233
+ var TASK_DIAG_STRING_KEYS = ["callID", "session", "alias", "event", "status"];
2234
+ var TASK_DIAG_NUMBER_KEYS = [
2235
+ "durationMs",
2236
+ "lastActivityAt",
2237
+ "retryAttempt",
2238
+ "retryNextMs",
2239
+ "retryAfterMs",
2240
+ "budgetChars",
2241
+ "budgetTokens"
2242
+ ];
2243
+ function sanitizeTaskDiagRecord(input) {
2244
+ if (typeof input !== "object" || input === null || Array.isArray(input))
2245
+ return null;
2246
+ const src = input;
2247
+ const out = { t: "task" };
2248
+ for (const key of TASK_DIAG_STRING_KEYS) {
2249
+ const v = src[key];
2250
+ if (typeof v === "string") {
2251
+ const trimmed = v.trim();
2252
+ if (trimmed !== "")
2253
+ out[key] = trimmed.slice(0, TASK_DIAG_STRING_MAX);
2254
+ }
2255
+ }
2256
+ for (const key of TASK_DIAG_NUMBER_KEYS) {
2257
+ const v = src[key];
2258
+ if (typeof v === "number" && Number.isFinite(v))
2259
+ out[key] = v;
2260
+ }
2261
+ const kind = src["rateLimitKind"];
2262
+ if (kind === "rate_limit" || kind === "retryable_network" || kind === "other") {
2263
+ out.rateLimitKind = kind;
2264
+ }
2265
+ return out;
2266
+ }
2267
+ function appendTaskDiag(record, filePath) {
2268
+ const safe = sanitizeTaskDiagRecord(record);
2269
+ if (!safe)
2270
+ return;
2271
+ appendCtxDiag(safe, filePath);
2272
+ }
1976
2273
 
1977
2274
  // src/tools/council.ts
1978
2275
  import { tool } from "@opencode-ai/plugin";
@@ -2073,6 +2370,119 @@ function createLogger(cfg) {
2073
2370
  return appendLogSync;
2074
2371
  }
2075
2372
 
2373
+ // src/orchestration/rate-limit.ts
2374
+ var RATE_LIMIT_TEXT = ["tokens per minute", "rate limit", "quota"];
2375
+ var NETWORK_TEXT = ["econnreset", "socket hang up", "timeout", "etimedout"];
2376
+ function isPlainObject(v) {
2377
+ return typeof v === "object" && v !== null && !Array.isArray(v);
2378
+ }
2379
+ function normalizeError(error) {
2380
+ if (!isPlainObject(error))
2381
+ return null;
2382
+ const obj = error;
2383
+ const data = isPlainObject(obj["data"]) ? obj["data"] : undefined;
2384
+ const pick = (key) => {
2385
+ if (obj[key] !== undefined)
2386
+ return obj[key];
2387
+ if (data && data[key] !== undefined)
2388
+ return data[key];
2389
+ return;
2390
+ };
2391
+ const statusCode = pick("statusCode");
2392
+ const isRetryable = pick("isRetryable");
2393
+ const headers = pick("responseHeaders");
2394
+ const body = pick("responseBody");
2395
+ const message = pick("message");
2396
+ const cause = pick("cause");
2397
+ const headersOut = {};
2398
+ if (isPlainObject(headers)) {
2399
+ for (const [k, v] of Object.entries(headers)) {
2400
+ if (typeof v === "string")
2401
+ headersOut[k] = v;
2402
+ }
2403
+ }
2404
+ const causeCode = isPlainObject(cause) && typeof cause["code"] === "string" ? cause["code"] : undefined;
2405
+ return {
2406
+ statusCode: typeof statusCode === "number" && Number.isFinite(statusCode) ? statusCode : undefined,
2407
+ isRetryable: typeof isRetryable === "boolean" ? isRetryable : false,
2408
+ responseHeaders: headersOut,
2409
+ responseBody: typeof body === "string" ? body : undefined,
2410
+ message: typeof message === "string" ? message : undefined,
2411
+ causeCode
2412
+ };
2413
+ }
2414
+ function findRetryAfter(headers) {
2415
+ for (const [k, v] of Object.entries(headers)) {
2416
+ if (k.toLowerCase() === "retry-after" && v.trim() !== "")
2417
+ return v.trim();
2418
+ }
2419
+ return;
2420
+ }
2421
+ function parseRetryAfter(headers, nowMs) {
2422
+ const raw = findRetryAfter(headers);
2423
+ if (raw === undefined)
2424
+ return { retryAfterSource: "none" };
2425
+ if (/^\d+$/.test(raw)) {
2426
+ const secs = Number(raw);
2427
+ if (secs > 0)
2428
+ return { retryAfterMs: secs * 1000, retryAfterSource: "header-seconds" };
2429
+ return { retryAfterSource: "none" };
2430
+ }
2431
+ const dateMs = Date.parse(raw);
2432
+ if (Number.isNaN(dateMs))
2433
+ return { retryAfterSource: "none" };
2434
+ const waitMs = dateMs - nowMs;
2435
+ if (waitMs > 0)
2436
+ return { retryAfterMs: waitMs, retryAfterSource: "header-date" };
2437
+ return { retryAfterSource: "none" };
2438
+ }
2439
+ function classifyRateLimit(error, now) {
2440
+ const norm = normalizeError(error);
2441
+ if (!norm) {
2442
+ return {
2443
+ kind: "other",
2444
+ retryAfterSource: "none",
2445
+ isRetryable: false,
2446
+ maskedMessage: "non-object-input"
2447
+ };
2448
+ }
2449
+ const nowMs = typeof now === "number" && Number.isFinite(now) ? now : Date.now();
2450
+ if (norm.statusCode === 429) {
2451
+ const ra = parseRetryAfter(norm.responseHeaders, nowMs);
2452
+ return {
2453
+ kind: "rate_limit",
2454
+ ...ra,
2455
+ isRetryable: norm.isRetryable,
2456
+ maskedMessage: "rate-limit-classified (http-429)"
2457
+ };
2458
+ }
2459
+ const text = [norm.message, norm.responseBody].filter((s) => typeof s === "string" && s !== "").join(`
2460
+ `).toLowerCase();
2461
+ if (RATE_LIMIT_TEXT.some((t) => text.includes(t))) {
2462
+ return {
2463
+ kind: "rate_limit",
2464
+ retryAfterSource: "none",
2465
+ isRetryable: norm.isRetryable,
2466
+ maskedMessage: "rate-limit-classified (text-signal)"
2467
+ };
2468
+ }
2469
+ const causeText = norm.causeCode?.toLowerCase() ?? "";
2470
+ if (NETWORK_TEXT.some((t) => text.includes(t)) || NETWORK_TEXT.some((t) => causeText.includes(t))) {
2471
+ return {
2472
+ kind: "retryable_network",
2473
+ retryAfterSource: "none",
2474
+ isRetryable: norm.isRetryable,
2475
+ maskedMessage: "retryable-network-error (socket/timeout)"
2476
+ };
2477
+ }
2478
+ return {
2479
+ kind: "other",
2480
+ retryAfterSource: "none",
2481
+ isRetryable: norm.isRetryable,
2482
+ maskedMessage: "unclassified-error"
2483
+ };
2484
+ }
2485
+
2076
2486
  // src/utils/log.ts
2077
2487
  var LOG_DIR = path6.join(os4.homedir(), ".local", "share", "opencode", "log");
2078
2488
  var appendLog = createLogger({
@@ -2083,6 +2493,14 @@ var appendLog = createLogger({
2083
2493
  keepDays: 7,
2084
2494
  async: true
2085
2495
  });
2496
+ function appendSafeLog(tag, message, err) {
2497
+ if (err === undefined) {
2498
+ appendLog(tag, message);
2499
+ return;
2500
+ }
2501
+ const { maskedMessage } = classifyRateLimit(err);
2502
+ appendLog(tag, `${message} — ${maskedMessage}`);
2503
+ }
2086
2504
 
2087
2505
  // src/tools/model-stats.ts
2088
2506
  var DEFAULT_MODEL_STATS_CONFIG = {
@@ -2907,7 +3325,7 @@ function createCouncilTool(ctx, councilManager) {
2907
3325
  // src/tools/job-control.ts
2908
3326
  import { tool as tool2 } from "@opencode-ai/plugin";
2909
3327
  var z2 = tool2.schema;
2910
- function createCloseJobTool(ctx, tracker, onClosed) {
3328
+ function createCloseJobTool(ctx, tracker, onClosed, onCancelled) {
2911
3329
  const close_job = tool2({
2912
3330
  description: "Close (abort) a stuck or unnecessary subagent background task. " + "Accepts the child Session ID (e.g. ses_xxx, shown in the Background Job Board) or the task alias (e.g. coe-1). " + "The underlying subagent session is aborted via session.abort() and the task is marked cancelled. " + "Only the co-orchestrator (primary) agent may invoke this tool.",
2913
3331
  args: {
@@ -2917,28 +3335,68 @@ function createCloseJobTool(ctx, tracker, onClosed) {
2917
3335
  if (toolContext.agent !== "co-orchestrator") {
2918
3336
  throw new Error(`[oh-my-opencode-cohub] close_job can only be invoked by co-orchestrator. Current agent: ${toolContext.agent}`);
2919
3337
  }
2920
- const hit = tracker.abortJob(args.task_id);
2921
- if (!hit) {
2922
- return JSON.stringify({ ok: false, task_id: args.task_id, reason: "not_found" }, null, 2);
3338
+ const target = tracker.resolveCancelTarget(args.task_id);
3339
+ if (!target.ok) {
3340
+ return JSON.stringify({ ok: false, task_id: args.task_id, reason: target.reason, session_aborted: false }, null, 2);
3341
+ }
3342
+ const job = target.job;
3343
+ const alias = job.alias;
3344
+ if (job.status !== "running") {
3345
+ return JSON.stringify({
3346
+ ok: true,
3347
+ task_id: args.task_id,
3348
+ alias,
3349
+ agent: job.agent,
3350
+ session_aborted: false,
3351
+ status: job.status,
3352
+ reason: "idempotent"
3353
+ }, null, 2);
2923
3354
  }
2924
3355
  let aborted = false;
2925
- if (hit.sessionId) {
2926
- const res = await ctx.client.session.abort({ path: { id: hit.sessionId } });
2927
- if (res.error) {
2928
- appendLog("job-control", `abort session ${hit.sessionId} 失败`, res.error);
3356
+ if (job.sessionId) {
3357
+ try {
3358
+ const res = await ctx.client.session.abort({ path: { id: job.sessionId } });
3359
+ aborted = !res.error;
3360
+ if (res.error) {
3361
+ appendLog("job-control", `abort session ${job.sessionId} 失败`, res.error);
3362
+ }
3363
+ } catch (err) {
3364
+ appendLog("job-control", `abort session ${job.sessionId} 抛出异常`, err);
2929
3365
  aborted = false;
2930
- } else {
2931
- aborted = true;
3366
+ }
3367
+ }
3368
+ if (job.sessionId && !aborted) {
3369
+ return JSON.stringify({
3370
+ ok: false,
3371
+ task_id: args.task_id,
3372
+ alias,
3373
+ agent: job.agent,
3374
+ session_aborted: false,
3375
+ status: job.status,
3376
+ reason: "abort_failed"
3377
+ }, null, 2);
3378
+ }
3379
+ const up = tracker.markCancelledByAlias(alias);
3380
+ if (!up.ok) {
3381
+ return JSON.stringify({ ok: false, task_id: args.task_id, alias, session_aborted: aborted, status: job.status, reason: "stale" }, null, 2);
3382
+ }
3383
+ if (up.reason === "ok") {
3384
+ try {
3385
+ onCancelled?.(alias);
3386
+ } catch (err) {
3387
+ appendLog("job-control", `onCancelled 回调异常(不影响取消结果)`, err);
2932
3388
  }
2933
3389
  }
2934
3390
  onClosed?.();
3391
+ const actualStatus = up.reason === "ok" ? "cancelled" : tracker.getJobByAlias(alias)?.status ?? job.status;
2935
3392
  return JSON.stringify({
2936
- ok: true,
3393
+ ok: up.reason === "ok" || up.reason === "idempotent",
2937
3394
  task_id: args.task_id,
2938
- alias: hit.job?.alias,
2939
- agent: hit.job?.agent,
3395
+ alias,
3396
+ agent: job.agent,
2940
3397
  session_aborted: aborted,
2941
- status: "cancelled"
3398
+ status: actualStatus,
3399
+ reason: up.reason
2942
3400
  }, null, 2);
2943
3401
  }
2944
3402
  });
@@ -3047,10 +3505,27 @@ class OrchestrationEngine {
3047
3505
  const task = this.dag.tasks.get(alias);
3048
3506
  if (!task)
3049
3507
  throw new Error(`Task ${alias} not found`);
3508
+ if (task.state === to)
3509
+ return;
3050
3510
  const allowed = VALID_TRANSITIONS[task.state];
3051
3511
  if (!allowed.includes(to)) {
3052
3512
  throw new Error(`Invalid transition: ${task.state} → ${to} for ${alias}`);
3053
3513
  }
3514
+ this.setState(task, to);
3515
+ }
3516
+ applyLifecycleEvent(alias, to) {
3517
+ const task = this.dag.tasks.get(alias);
3518
+ if (!task)
3519
+ return { ok: false, reason: "unknown-task" };
3520
+ if (task.state === to)
3521
+ return { ok: true, reason: "idempotent" };
3522
+ const allowed = VALID_TRANSITIONS[task.state];
3523
+ if (!allowed.includes(to))
3524
+ return { ok: false, reason: "invalid-transition" };
3525
+ this.setState(task, to);
3526
+ return { ok: true, reason: "applied" };
3527
+ }
3528
+ setState(task, to) {
3054
3529
  task.state = to;
3055
3530
  if (to === "completed") {
3056
3531
  for (const depAlias of task.dependents) {
@@ -3058,7 +3533,7 @@ class OrchestrationEngine {
3058
3533
  }
3059
3534
  }
3060
3535
  if (to === "cancelled") {
3061
- this.cascadeCancel(alias);
3536
+ this.cascadeCancel(task.alias);
3062
3537
  }
3063
3538
  }
3064
3539
  checkDependencies(alias) {
@@ -3368,6 +3843,7 @@ ${append}`;
3368
3843
  return base;
3369
3844
  }
3370
3845
  var CoHubPlugin = async (input, options) => {
3846
+ const pluginClient = input.client;
3371
3847
  const projectDir = input.directory || process.cwd();
3372
3848
  const fileOverrides = loadFileOverrides(projectDir);
3373
3849
  const configOverrides = {};
@@ -3377,7 +3853,6 @@ var CoHubPlugin = async (input, options) => {
3377
3853
  const promptOverrides = { ...configOverrides, ...fileOverrides };
3378
3854
  const tracker = new TaskTracker;
3379
3855
  const orchestrationLayer = createOrchestrationLayer(tracker, {});
3380
- let _lastAlias = "";
3381
3856
  const STATE_DIR = path7.join(os5.homedir(), ".local", "share", "opencode", "storage", "oh-my-opencode-cohub");
3382
3857
  const STATE_FILE = path7.join(STATE_DIR, "tracker-state.json");
3383
3858
  function syncTrackerState(sessionId) {
@@ -3508,26 +3983,29 @@ var CoHubPlugin = async (input, options) => {
3508
3983
  const councilConfig = userConfig.council ?? DEFAULT_COUNCIL_CONFIG;
3509
3984
  const councilManager = new CouncilManager(input.client, input.directory, councilConfig);
3510
3985
  const councilTools = createCouncilTool(input, councilManager);
3511
- const jobTools = createCloseJobTool(input, tracker, () => syncTrackerState(tracker.currentParentSessionId));
3986
+ const jobTools = createCloseJobTool(input, tracker, undefined, (alias) => {
3987
+ const job = tracker.getJobByAlias(alias);
3988
+ if (job && orchestrationLayer) {
3989
+ orchestrationLayer.engine.applyLifecycleEvent(alias, "cancelled");
3990
+ syncTrackerState(job.parentSessionId);
3991
+ }
3992
+ });
3512
3993
  function extractChildSessionId(output) {
3513
3994
  if (!output || typeof output !== "object")
3514
3995
  return;
3515
3996
  const o = output;
3516
3997
  const meta = o.metadata;
3517
3998
  if (meta) {
3518
- if (typeof meta.sessionId === "string")
3519
- return meta.sessionId;
3520
- if (typeof meta.taskId === "string")
3521
- return meta.taskId;
3522
- if (typeof meta.task_id === "string")
3523
- return meta.task_id;
3524
- if (typeof meta.id === "string")
3525
- return meta.id;
3999
+ for (const key of ["sessionId", "taskId", "task_id", "id"]) {
4000
+ const v = meta[key];
4001
+ if (typeof v === "string" && v.startsWith("ses_"))
4002
+ return v;
4003
+ }
3526
4004
  }
3527
4005
  const text = typeof o.output === "string" ? o.output : "";
3528
- const m = text.match(/\b(session|task)[_\s]?(?:id|ID)[:\s]+(\S+)/i);
4006
+ const m = text.match(/\b(?:session|task)[_\s]?id[:\s]+(ses_\S+)/i);
3529
4007
  if (m)
3530
- return m[2];
4008
+ return m[1];
3531
4009
  return;
3532
4010
  }
3533
4011
  function extractSessionIdFromEvent(props) {
@@ -3543,6 +4021,130 @@ var CoHubPlugin = async (input, options) => {
3543
4021
  return p.sessionId;
3544
4022
  return;
3545
4023
  }
4024
+ async function verifyChildTerminal(client, sessionId, jobCreatedAt) {
4025
+ try {
4026
+ const res = await client.session.messages({ path: { id: sessionId }, query: { limit: 10 } });
4027
+ if (res.error)
4028
+ return "unknown";
4029
+ const msgs = res.data;
4030
+ if (!Array.isArray(msgs) || msgs.length === 0)
4031
+ return "unknown";
4032
+ let latest;
4033
+ let latestCreated = -1;
4034
+ for (const item of msgs) {
4035
+ const info = item?.info;
4036
+ if (!info || info.role !== "assistant")
4037
+ continue;
4038
+ const t = info.time;
4039
+ const created = typeof t?.created === "number" ? t.created : -1;
4040
+ if (created > latestCreated) {
4041
+ latestCreated = created;
4042
+ latest = item;
4043
+ }
4044
+ }
4045
+ if (!latest)
4046
+ return "unknown";
4047
+ const info = latest.info;
4048
+ const time = info.time;
4049
+ const created = typeof time?.created === "number" ? time.created : undefined;
4050
+ if (created !== undefined && created < jobCreatedAt)
4051
+ return "unknown";
4052
+ if (info.error)
4053
+ return isAbortLikeError(info.error) ? "cancelled" : "errored";
4054
+ const parts = latest.parts ?? [];
4055
+ const hasOpenTool = parts.some((p) => {
4056
+ if (p.type !== "tool")
4057
+ return false;
4058
+ const st = p.state;
4059
+ return st?.status !== "completed" && st?.status !== "error";
4060
+ });
4061
+ if (hasOpenTool)
4062
+ return "unknown";
4063
+ const finish = info.finish;
4064
+ if (typeof finish === "string" && finish !== "") {
4065
+ const f = finish.toLowerCase();
4066
+ if (f === "tool-calls" || f === "tool_calls")
4067
+ return "unknown";
4068
+ if (f === "abort" || f === "cancel" || f === "cancelled")
4069
+ return "cancelled";
4070
+ if (f === "length" || f === "content-filter" || f === "content_filter")
4071
+ return "errored";
4072
+ if (KNOWN_SUCCESS_FINISH.some((m) => m === f))
4073
+ return "completed";
4074
+ return "unknown";
4075
+ }
4076
+ return "unknown";
4077
+ } catch {
4078
+ return "unknown";
4079
+ }
4080
+ }
4081
+ const KNOWN_SUCCESS_FINISH = ["finish", "stop", "end_turn", "end", "success", "done", "completed"];
4082
+ function isAbortLikeError(err) {
4083
+ if (!err || typeof err !== "object")
4084
+ return false;
4085
+ return err.name === "MessageAbortedError";
4086
+ }
4087
+ function taskDiag(record) {
4088
+ if (ctxDiag.enabled)
4089
+ appendTaskDiag(record, ctxDiag.filePath);
4090
+ }
4091
+ function classifyTaskAfter(output) {
4092
+ if (output && typeof output === "object") {
4093
+ const meta = output.metadata;
4094
+ if (meta && typeof meta === "object") {
4095
+ if (meta.status === "cancelled" || meta.cancelled === true)
4096
+ return { status: "cancelled" };
4097
+ if (meta.status === "failed" || meta.status === "error" || meta.status === "errored" || meta.error !== undefined) {
4098
+ return { status: "errored" };
4099
+ }
4100
+ if (meta.status === "running" || meta.status === "pending")
4101
+ return { status: "running" };
4102
+ }
4103
+ }
4104
+ return { status: "unknown" };
4105
+ }
4106
+ async function reconcileJobTerminal(job) {
4107
+ const parentAtCall = job.parentSessionId;
4108
+ const callAtCall = job.callID;
4109
+ const aliasAtCall = job.alias;
4110
+ const childId = job.sessionId;
4111
+ if (!childId)
4112
+ return;
4113
+ const verdict = await verifyChildTerminal(pluginClient, childId, job.createdAt);
4114
+ const nowJob = callAtCall ? tracker.getJobByCall(parentAtCall, callAtCall) : tracker.getJobBySessionId(childId);
4115
+ if (!nowJob || nowJob.alias !== aliasAtCall || nowJob.sessionId !== childId || nowJob.status !== "running") {
4116
+ appendSafeLog("event", `收尾竞态:候选已变更,不更新新调用 (session=${childId.slice(0, 20)})`);
4117
+ return;
4118
+ }
4119
+ if (verdict === "completed" || verdict === "errored" || verdict === "cancelled") {
4120
+ const term = verdict === "completed" ? "completed" : verdict === "cancelled" ? "cancelled" : "errored";
4121
+ const up = callAtCall ? tracker.finalizeByCall(parentAtCall, callAtCall, childId, term) : tracker.updateByChildSessionId(childId, term);
4122
+ syncTrackerState(tracker.currentParentSessionId);
4123
+ const alias = up.ok ? up.alias : aliasAtCall;
4124
+ if (up.ok && up.alias && orchestrationLayer) {
4125
+ orchestrationLayer.engine.applyLifecycleEvent(up.alias, verdict === "completed" ? "completed" : verdict === "cancelled" ? "cancelled" : "failed");
4126
+ }
4127
+ taskDiag({
4128
+ t: "task",
4129
+ session: childId.slice(0, 20),
4130
+ alias,
4131
+ event: verdict === "completed" ? "task_completed" : verdict === "cancelled" ? "task_cancelled" : "task_failed",
4132
+ status: verdict === "completed" ? "completed" : verdict === "cancelled" ? "cancelled" : "failed",
4133
+ durationMs: Date.now() - job.createdAt,
4134
+ lastActivityAt: Date.now()
4135
+ });
4136
+ } else {
4137
+ appendSafeLog("event", `无终态证据,保持 running (session=${childId.slice(0, 20)})`);
4138
+ taskDiag({
4139
+ t: "task",
4140
+ session: childId.slice(0, 20),
4141
+ alias: aliasAtCall,
4142
+ event: "state_changed",
4143
+ status: "running",
4144
+ lastActivityAt: Date.now()
4145
+ });
4146
+ }
4147
+ }
3546
4148
  const cleanupTimer = setInterval(() => {
3547
4149
  try {
3548
4150
  const staleSessions = tracker.cleanupStaleJobs(STALE_TASK_MS);
@@ -3660,13 +4262,14 @@ var CoHubPlugin = async (input, options) => {
3660
4262
  subagent_type: subagentType,
3661
4263
  task_id: typeof output.args.task_id === "string" && output.args.task_id.startsWith("ses_") ? output.args.task_id : undefined,
3662
4264
  background: typeof args.background === "boolean" ? args.background : undefined
3663
- });
3664
- _lastAlias = alias;
4265
+ }, input.callID);
3665
4266
  syncTrackerState(input.sessionID ?? "");
3666
4267
  tracker.pruneTerminalJobs(STALE_TASK_MS);
3667
4268
  if (orchestrationLayer && subagentType && description) {
3668
4269
  try {
3669
- orchestrationLayer.engine.registerTask(alias, subagentType, description, []);
4270
+ if (!orchestrationLayer.engine.getTask(alias)) {
4271
+ orchestrationLayer.engine.registerTask(alias, subagentType, description, []);
4272
+ }
3670
4273
  const targetField = output.args?.prompt ? "prompt" : "description";
3671
4274
  if (output.args?.[targetField] && typeof output.args[targetField] === "string") {
3672
4275
  output.args[targetField] = stripExistingContracts(output.args[targetField]);
@@ -3677,8 +4280,31 @@ var CoHubPlugin = async (input, options) => {
3677
4280
  constraints: []
3678
4281
  });
3679
4282
  }
4283
+ const lr = orchestrationLayer.engine.applyLifecycleEvent(alias, "running");
4284
+ if (!lr.ok) {
4285
+ appendLog("orchestration", `before: applyLifecycleEvent(running) ${lr.reason} for ${alias}(状态来自实际原生生命周期,未接线 DAG 依赖不强制推进)`);
4286
+ taskDiag({
4287
+ t: "task",
4288
+ callID: input.callID,
4289
+ session: input.sessionID?.slice(0, 20),
4290
+ alias,
4291
+ event: "state_changed",
4292
+ status: orchestrationLayer.engine.getState(alias) ?? "pending",
4293
+ lastActivityAt: Date.now()
4294
+ });
4295
+ } else {
4296
+ taskDiag({
4297
+ t: "task",
4298
+ callID: input.callID,
4299
+ session: input.sessionID?.slice(0, 20),
4300
+ alias,
4301
+ event: "task_started",
4302
+ status: "running",
4303
+ lastActivityAt: Date.now()
4304
+ });
4305
+ }
3680
4306
  } catch (e) {
3681
- appendLog("orchestration", "registerTask error", e);
4307
+ appendSafeLog("orchestration", "registerTask error", e);
3682
4308
  }
3683
4309
  }
3684
4310
  if (subagentType) {
@@ -3731,119 +4357,238 @@ var CoHubPlugin = async (input, options) => {
3731
4357
  if (input.tool === "cancel_task") {
3732
4358
  const args = input.args;
3733
4359
  const taskId = args?.task_id;
3734
- if (typeof taskId === "string")
3735
- tracker.markCancelled(taskId);
4360
+ if (typeof taskId === "string") {
4361
+ const aborted = tracker.abortJob(taskId);
4362
+ if (aborted && orchestrationLayer) {
4363
+ orchestrationLayer.engine.applyLifecycleEvent(aborted.job.alias, "cancelled");
4364
+ taskDiag({
4365
+ t: "task",
4366
+ session: input.sessionID?.slice(0, 20),
4367
+ alias: aborted.job.alias,
4368
+ event: "task_cancelled",
4369
+ status: "cancelled",
4370
+ lastActivityAt: Date.now()
4371
+ });
4372
+ }
4373
+ }
3736
4374
  syncTrackerState(input.sessionID ?? "");
3737
4375
  }
3738
4376
  if (input.tool === "task") {
3739
4377
  const childSessionId = extractChildSessionId(output);
3740
- tracker.updateAfterTask(input.sessionID, "completed", childSessionId);
4378
+ const job0 = tracker.getJobByCall(input.sessionID, input.callID);
4379
+ if (!job0) {
4380
+ appendSafeLog("tool.execute.after", `after 未知 callID,no-op (parent=${input.sessionID?.slice(0, 20)})`);
4381
+ syncTrackerState(input.sessionID ?? "");
4382
+ return;
4383
+ }
4384
+ if (output === undefined || output === null) {
4385
+ appendSafeLog("tool.execute.after", `after 无有效输出,不假成功 (parent=${input.sessionID?.slice(0, 20)})`);
4386
+ syncTrackerState(input.sessionID ?? "");
4387
+ return;
4388
+ }
4389
+ const meta = output?.metadata;
4390
+ const metaBg = meta && typeof meta === "object" ? meta.background : undefined;
4391
+ if (metaBg === true && !job0.background) {
4392
+ tracker.setBackgroundByCall(input.sessionID, input.callID, true);
4393
+ }
4394
+ const outcome = classifyTaskAfter(output);
4395
+ if (outcome.status === "cancelled" || outcome.status === "errored") {
4396
+ const term = outcome.status === "cancelled" ? "cancelled" : "errored";
4397
+ const up = tracker.finalizeByCall(input.sessionID, input.callID, (childSessionId ?? job0.sessionId) || undefined, term);
4398
+ syncTrackerState(input.sessionID ?? "");
4399
+ if (up.ok && up.alias && orchestrationLayer) {
4400
+ orchestrationLayer.engine.applyLifecycleEvent(up.alias, term === "cancelled" ? "cancelled" : "failed");
4401
+ }
4402
+ taskDiag({
4403
+ t: "task",
4404
+ callID: input.callID,
4405
+ session: input.sessionID?.slice(0, 20),
4406
+ alias: up.ok ? up.alias : job0.alias,
4407
+ event: term === "cancelled" ? "task_cancelled" : "task_failed",
4408
+ status: term === "cancelled" ? "cancelled" : "failed",
4409
+ durationMs: Date.now() - job0.createdAt,
4410
+ lastActivityAt: Date.now()
4411
+ });
4412
+ return;
4413
+ }
4414
+ const up = tracker.updateAfterTaskByCall(input.sessionID, input.callID, "completed", childSessionId);
3741
4415
  syncTrackerState(input.sessionID ?? "");
3742
- if (orchestrationLayer && input.tool === "task") {
4416
+ if (up.ok && orchestrationLayer) {
3743
4417
  try {
3744
- if (typeof output === "string") {
3745
- const contract = orchestrationLayer.contractMgr.extract(output);
3746
- if (contract) {
3747
- orchestrationLayer.engine.setResult(_lastAlias, contract.keyResult, contract);
4418
+ if (!job0.background) {
4419
+ const lr = orchestrationLayer.engine.applyLifecycleEvent(job0.alias, "completed");
4420
+ if (!lr.ok)
4421
+ appendLog("orchestration", `after: applyLifecycleEvent(completed) ${lr.reason} for ${job0.alias}`);
4422
+ const text = typeof output === "string" ? output : typeof output?.output === "string" ? output.output : "";
4423
+ if (text) {
4424
+ const contract = orchestrationLayer.contractMgr.extract(text);
4425
+ if (contract)
4426
+ orchestrationLayer.engine.setResult(job0.alias, contract.keyResult, contract);
3748
4427
  }
4428
+ taskDiag({
4429
+ t: "task",
4430
+ callID: input.callID,
4431
+ session: input.sessionID?.slice(0, 20),
4432
+ alias: job0.alias,
4433
+ event: "task_completed",
4434
+ status: "completed",
4435
+ durationMs: Date.now() - job0.createdAt,
4436
+ lastActivityAt: Date.now()
4437
+ });
4438
+ } else {
4439
+ taskDiag({
4440
+ t: "task",
4441
+ callID: input.callID,
4442
+ session: input.sessionID?.slice(0, 20),
4443
+ alias: job0.alias,
4444
+ event: "state_changed",
4445
+ status: "running",
4446
+ lastActivityAt: Date.now()
4447
+ });
4448
+ await reconcileJobTerminal(job0);
3749
4449
  }
3750
- orchestrationLayer.scheduler.markCompleted(_lastAlias);
3751
4450
  } catch (e) {
3752
- appendLog("orchestration", "afterTask error", e);
4451
+ appendSafeLog("orchestration", "afterTask engine 同步失败", e);
3753
4452
  }
3754
4453
  }
3755
4454
  }
3756
4455
  } catch (err) {
3757
- appendLog("tool.execute.after", "hook 失败", err);
4456
+ appendSafeLog("tool.execute.after", "hook 失败", err);
3758
4457
  }
3759
4458
  },
3760
4459
  event: async (input) => {
3761
4460
  try {
3762
4461
  const e = input.event;
3763
- const sessionId = extractSessionIdFromEvent(e.properties);
3764
- if (!sessionId)
3765
- return;
4462
+ const props = e.properties;
4463
+ const sessionId = extractSessionIdFromEvent(props);
3766
4464
  if (e.type === "session.idle") {
3767
- tracker.updateByChildSessionId(sessionId, "completed");
4465
+ if (!sessionId)
4466
+ return;
4467
+ const active = tracker.findActiveByChildSessionId(sessionId);
3768
4468
  syncTrackerState(tracker.currentParentSessionId);
3769
- if (orchestrationLayer) {
3770
- try {
3771
- const job = tracker.getJobBySessionId(sessionId);
3772
- if (job) {
3773
- orchestrationLayer.engine.transition(job.alias, "completed");
3774
- orchestrationLayer.scheduler.markCompleted(job.alias);
3775
- }
3776
- } catch (e) {
3777
- appendLog("orchestration", "session.idle transition error", e);
3778
- }
3779
- }
3780
- const job = tracker.getJobBySessionId(sessionId);
3781
- if (job) {
3782
- contextEngine.captureResult(sessionId, job.alias, job.agent).then((captured) => {
3783
- if (!captured)
3784
- return;
3785
- if (!isQualityEnabled(userConfig.quality))
3786
- return;
3787
- const quality = assessQuality({
3788
- output: captured.output,
3789
- decisions: captured.decisions,
3790
- latencyMs: Date.now() - job.createdAt,
3791
- tokens: captured.tokens
3792
- });
3793
- tracker.updateQuality(sessionId, {
3794
- score: quality.score,
3795
- ...quality.failureCategory ? { failureCategory: quality.failureCategory } : {},
3796
- ...quality.latencyMs !== undefined ? { latencyMs: quality.latencyMs } : {},
3797
- ...quality.tokens ? { tokens: quality.tokens } : {}
3798
- });
3799
- syncTrackerState(tracker.currentParentSessionId);
3800
- }).catch((err) => appendLog("event", "质量回送失败", err));
4469
+ if (!active.ok) {
4470
+ appendSafeLog("event", `session.idle ${active.reason} 不操作 (session=${sessionId.slice(0, 20)})`);
4471
+ return;
3801
4472
  }
4473
+ const job = active.job;
4474
+ await reconcileJobTerminal(job);
4475
+ const qAlias = job.alias;
4476
+ const qParent = job.parentSessionId;
4477
+ const qCall = job.callID;
4478
+ const qChild = sessionId;
4479
+ const qCreatedAt = job.createdAt;
4480
+ contextEngine.captureResult(sessionId, qAlias, job.agent).then((captured) => {
4481
+ if (!captured)
4482
+ return;
4483
+ if (!isQualityEnabled(userConfig.quality))
4484
+ return;
4485
+ const now = qCall ? tracker.getJobByCall(qParent, qCall) : tracker.getJobBySessionId(qChild);
4486
+ if (!now || now.alias !== qAlias || now.sessionId !== qChild) {
4487
+ appendSafeLog("event", "质量回送竞态:候选已变更,不写回复用/新调用");
4488
+ return;
4489
+ }
4490
+ const quality = assessQuality({
4491
+ output: captured.output,
4492
+ decisions: captured.decisions,
4493
+ latencyMs: Date.now() - qCreatedAt,
4494
+ tokens: captured.tokens
4495
+ });
4496
+ tracker.updateQualityByAlias(qAlias, {
4497
+ score: quality.score,
4498
+ ...quality.failureCategory ? { failureCategory: quality.failureCategory } : {},
4499
+ ...quality.latencyMs !== undefined ? { latencyMs: quality.latencyMs } : {},
4500
+ ...quality.tokens ? { tokens: quality.tokens } : {}
4501
+ });
4502
+ syncTrackerState(tracker.currentParentSessionId);
4503
+ }).catch((err) => appendSafeLog("event", "质量回送失败", err));
3802
4504
  } else if (e.type === "session.deleted") {
3803
- tracker.updateByChildSessionId(sessionId, "errored");
4505
+ if (!sessionId)
4506
+ return;
4507
+ const active = tracker.findActiveByChildSessionId(sessionId);
4508
+ const up = active.ok && active.job.callID ? tracker.finalizeByCall(active.job.parentSessionId, active.job.callID, sessionId, "errored") : tracker.updateByChildSessionId(sessionId, "errored");
3804
4509
  syncTrackerState(tracker.currentParentSessionId);
3805
- if (orchestrationLayer) {
3806
- try {
3807
- const job = tracker.getJobBySessionId(sessionId);
3808
- if (job) {
3809
- orchestrationLayer.engine.transition(job.alias, "failed");
3810
- orchestrationLayer.scheduler.markFailed(job.alias);
3811
- const task = orchestrationLayer.engine.getTask(job.alias);
3812
- if (task) {
3813
- const decision = orchestrationLayer.retryMgr.decide(task, "session deleted");
3814
- if (decision === "retry") {
3815
- orchestrationLayer.engine.transition(job.alias, "pending");
3816
- }
3817
- }
3818
- }
3819
- } catch (e) {
3820
- appendLog("orchestration", "session.deleted transition error", e);
3821
- }
4510
+ if (up.ok && up.alias && orchestrationLayer) {
4511
+ orchestrationLayer.engine.applyLifecycleEvent(up.alias, "failed");
3822
4512
  }
3823
- } else if (e.type === "session.error") {
3824
- tracker.updateByChildSessionId(sessionId, "errored");
4513
+ taskDiag({
4514
+ t: "task",
4515
+ session: sessionId.slice(0, 20),
4516
+ alias: up.alias,
4517
+ event: "task_failed",
4518
+ status: "failed",
4519
+ lastActivityAt: Date.now()
4520
+ });
4521
+ } else if (e.type === "message.part.updated") {
4522
+ const part = props?.part;
4523
+ if (!part || part.type !== "tool" || part.tool !== "task")
4524
+ return;
4525
+ const pstate = part.state;
4526
+ const pstatus = pstate?.status;
4527
+ if (pstatus !== "error" && pstatus !== "cancelled")
4528
+ return;
4529
+ const parentSessionID = typeof part.sessionID === "string" ? part.sessionID : undefined;
4530
+ if (!parentSessionID)
4531
+ return;
4532
+ const partCallID = typeof part.callID === "string" ? part.callID : undefined;
4533
+ const partID = typeof part.id === "string" ? part.id : undefined;
4534
+ const byCall = partCallID ? tracker.getJobByCall(parentSessionID, partCallID) : undefined;
4535
+ const byId = partID ? tracker.getJobByCall(parentSessionID, partID) : undefined;
4536
+ if (byCall && byId && byCall.alias !== byId.alias)
4537
+ return;
4538
+ const job = byCall ?? byId;
4539
+ if (!job || job.status !== "running")
4540
+ return;
4541
+ const term = pstatus === "cancelled" ? "cancelled" : "errored";
4542
+ const up = tracker.finalizeByCall(parentSessionID, job.callID, job.sessionId || undefined, term);
3825
4543
  syncTrackerState(tracker.currentParentSessionId);
3826
- if (orchestrationLayer) {
3827
- try {
3828
- const job = tracker.getJobBySessionId(sessionId);
3829
- if (job) {
3830
- orchestrationLayer.engine.transition(job.alias, "failed");
3831
- orchestrationLayer.scheduler.markFailed(job.alias);
3832
- const task = orchestrationLayer.engine.getTask(job.alias);
3833
- if (task) {
3834
- const decision = orchestrationLayer.retryMgr.decide(task, "session error");
3835
- if (decision === "retry") {
3836
- orchestrationLayer.engine.transition(job.alias, "pending");
3837
- }
3838
- }
3839
- }
3840
- } catch (e) {
3841
- appendLog("orchestration", "session.error transition error", e);
3842
- }
4544
+ if (up.ok && up.alias && orchestrationLayer) {
4545
+ orchestrationLayer.engine.applyLifecycleEvent(up.alias, term === "cancelled" ? "cancelled" : "failed");
4546
+ }
4547
+ taskDiag({
4548
+ t: "task",
4549
+ session: parentSessionID.slice(0, 20),
4550
+ alias: up.ok ? up.alias : job.alias,
4551
+ event: term === "cancelled" ? "task_cancelled" : "task_failed",
4552
+ status: term === "cancelled" ? "cancelled" : "failed",
4553
+ lastActivityAt: Date.now()
4554
+ });
4555
+ } else if (e.type === "session.status") {
4556
+ if (!sessionId)
4557
+ return;
4558
+ const status = props?.status;
4559
+ if (status?.type === "retry") {
4560
+ taskDiag({
4561
+ t: "task",
4562
+ session: sessionId.slice(0, 20),
4563
+ event: "retry_scheduled",
4564
+ status: "running",
4565
+ retryAttempt: typeof status.attempt === "number" ? status.attempt : undefined,
4566
+ retryNextMs: typeof status.next === "number" ? status.next - Date.now() : undefined,
4567
+ lastActivityAt: Date.now()
4568
+ });
4569
+ appendLog("event", `session.status retry 观测 (session=${sessionId.slice(0, 20)} attempt=${String(status.attempt)})`);
4570
+ }
4571
+ } else if (e.type === "session.error") {
4572
+ if (!sessionId) {
4573
+ appendSafeLog("event", "session.error 缺少 sessionID,不关联任何任务", props?.error);
4574
+ return;
4575
+ }
4576
+ if (ctxDiag.enabled) {
4577
+ const info = classifyRateLimit(props?.error);
4578
+ taskDiag({
4579
+ t: "task",
4580
+ session: sessionId.slice(0, 20),
4581
+ event: "rate_limit_detected",
4582
+ status: "running",
4583
+ rateLimitKind: info.kind,
4584
+ retryAfterMs: info.retryAfterMs,
4585
+ lastActivityAt: Date.now()
4586
+ });
3843
4587
  }
4588
+ appendSafeLog("event", `session.error 仅观测不终结 (session=${sessionId.slice(0, 20)})`, props?.error);
3844
4589
  }
3845
4590
  } catch (err) {
3846
- appendLog("event", "事件处理失败", err);
4591
+ appendSafeLog("event", "事件处理失败", err);
3847
4592
  }
3848
4593
  },
3849
4594
  "experimental.chat.messages.transform": async (_input, output) => {