oh-my-opencode-cohub 1.18.1 → 1.18.2

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