@tea-agent/loop-agent 0.25.4 → 0.25.5

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 (35) hide show
  1. package/AGENTS.md +6 -0
  2. package/CHANGELOG.md +40 -0
  3. package/dist/commands/client-recovery.js +209 -62
  4. package/dist/executors/dag-pi-executor.js +80 -15
  5. package/dist/executors/model-routing.js +1 -1
  6. package/dist/executors/shell-executor.js +127 -0
  7. package/dist/executors/shell-write-guard.js +21 -7
  8. package/dist/worker/console/repo-fingerprint.js +7 -1
  9. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +964 -0
  10. package/dist/workflows/dag/backend-test-case-manifest.js +39 -1
  11. package/dist/workflows/dag/backend-test-markdown-workflow.js +219 -16
  12. package/dist/workflows/dag/convergence/controller.js +134 -9
  13. package/dist/workflows/dag/frontend-test-l5-report.js +138 -0
  14. package/dist/workflows/dag/init-hybrid.js +262 -75
  15. package/dist/workflows/dag/node-execution.js +64 -11
  16. package/dist/workflows/dag/prompt.js +118 -4
  17. package/dist/workflows/dag/retry-policy.js +5 -4
  18. package/dist/workflows/dag/scheduler.js +32 -5
  19. package/dist/workflows/dag/types.js +7 -4
  20. package/dist/workflows/dag/validate.js +3 -2
  21. package/docs/architecture/dag-execution.md +7 -4
  22. package/docs/architecture/runtime-boundaries.md +1 -1
  23. package/docs/templates/agent-dag.base.json +1 -1
  24. package/docs/templates/agent-dag.final-verification.json +1 -1
  25. package/docs/templates/agent-dag.supervised-implementation.json +1 -1
  26. package/docs/templates/backend-test-dag.json +40 -13
  27. package/docs/templates/frontend-test-dag.json +32 -2
  28. package/docs/templates/hybrid-dag.json +1 -1
  29. package/examples/decision-gate-agent-dag.json +1 -1
  30. package/examples/example-dag.json +1 -1
  31. package/examples/hybrid-loop-agent-dag.json +1 -1
  32. package/harness.json +1 -1
  33. package/package.json +1 -1
  34. package/skills/loop-agent/references/hybrid-dag.md +2 -2
  35. package/skills/loop-agent/references/model-routing.md +1 -1
package/AGENTS.md CHANGED
@@ -46,6 +46,12 @@
46
46
 
47
47
  这不是 DAG 节点序列。复杂实现默认 Agent DAG;主 agent 拆任务、写 contract、结构化路径、审查 DAG/writeSet/profile/shell verification。微小任务可用 one-shot escape hatch 并记录边界与验证证据。
48
48
 
49
+ 启动 loop-agent DAG 流程后,主 agent 应自主推进到整个流程结束,不要中途请求无谓的人工确认(如“要我现在执行 DAG 吗?”)。只要 dry-run / validate / writeSet 审查通过就应直接执行并跑完全部 ranks;遇到真问题(契约不一致、writeSet 越界、verify 失败且超出 maxFixLoops、用户明显未授权的高风险动作)才停。即便 profile 是 supervised/reviewed,也不默认在 review-gate 主动停下等人——控制器会在需要人工 approve 时自行提示,主 agent 的职责是推动流程跑完。
50
+
51
+ DAG 执行期间主 agent 必须持续轮询监视直到 run 结束(FINISHED / FAILED / 需要 approve),不能轮询一次就停下等用户;轮询间隔应合理(避免频繁唤醒浪费 token,也不得住一个节点上赌一次就走)。判活必须用可靠方式:看 `state.json` 的 `runner.heartbeatAt` 是否持续刷新 + `session-events.jsonl` 是否在增长 + `dag doctor` 的 `liveness` 字段,不要只用 `tasklist /FI "PID eq X"` 这类过滤语法在 Git Bash 下会误报 DEAD,从而错判一个正常工作的 run 为 orphaned。遇到疑似 stall 先按 `docs/runtime/agent-dag-runner.md` 查 `lastMeaningfulProgressAt` / provider 活动,有真实进展就继续等,绝不盲目 supersede。
52
+
53
+ DAG 执行期间,主 agent 不得修改 writeSet 外的任何工作区文件(包括 AGENTS.md、docs、根配置等)。bounded writer 节点的 write guard 用「节点运行期间的工作区 diff」作为越界证据,不区分改动来自 pi 还是主会话——主会话在 pi 节点跑的时候改了 writeSet 外文件,会让 write guard 把账算到 pi 头上、判节点 ERROR、下游 cascade skip。需要改文档/约束时,要么在 DAG 启动前改完,要么等 run 结束后改;务必与 DAG 写入节点在时间上互斥。排查任务路由问题时优先用 `resolveTaskDagTemplateSelection` / `classifyTaskDemand` 等纯函数探针或 `--output` 指定临时路径,不要反复 `dag run-task`——每次调用(含 `--dry-run`)都会在 `.harness/dag-runs/active/` 新建一个 run 目录,dry-run 不执行节点但目录残留,堆积成「一堆 active run」的假象。
54
+
49
55
  ## 项目地图
50
56
 
51
57
  - `CONTEXT.md`:术语表
package/CHANGELOG.md CHANGED
@@ -2,10 +2,50 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.25.5] - 2026-07-31
6
+
7
+ ### 重点更新
8
+
9
+ - 后端测试 DAG 新增确定性场景覆盖分析与 Markdown 到 pytest 的精准对应,显著提升测试质量度量的准确性与透明度
10
+ - 受监督的 DAG 默认启用有界自愈闭环,能够自动处理可恢复的执行失败并安全重算后续节点
11
+ - 全面加固客户端会话恢复机制,精准识别并补偿由模型服务瞬时异常引发的结构校验错误
12
+ - 大幅增强 DAG 执行的确定性收尾能力,确保在模型输出不稳定或前置快照失败时安全降级
13
+
14
+ ### 新增
15
+
16
+ - 后端测试固定 9 节点 DAG 新增可审计的场景覆盖与 Markdown→pytest 一一对应事实:节点 4 从严格 Coverage Matrix、Case Rule/Test Point 绑定及 OpenAPI required/enum/boundary/pattern/format 规则生成覆盖报告,节点 6 生成 1:1/1:0/1:N/0:1 对应报告,节点 7 校验一致性后物化 canonical manifest
17
+ - 受监督的 DAG 默认启用有界自愈闭环:通过 maxFixLoops 派生收敛预算,将可恢复失败与合法的审查修订请求统一纳入恢复链,并确定性注入上一轮失败证据指针,同时保持对安全失败的不自动重试
18
+ - 后端测试用例生成与独立 Review 改为产品需求优先,并穷举文档化 API 规则,涵盖生命周期、枚举等价类、长度数值边界及业务状态迁移等场景
19
+ - 后端测试 HTML/facts 新增可展开的“用例场景覆盖分析”和“Markdown → pytest 一一对应”质量详情
20
+
21
+ ### 改进
22
+
23
+ - 减少全量测试中的重复 CLI 冷启动和 self-host canary 二次打包安装场景,收敛低价值重复用例并保留关键端到端主路径
24
+ - DAG 执行期间的主会话行为约束得到增补:启动后自主推进至结束、持续可靠判活、严格限制工作区文件修改,并优先使用纯函数探针排查路由
25
+
26
+ ### 修复
27
+
28
+ - 修复后端测试节点 4/6 的确定性覆盖与对应分析在自身崩溃时被静默吞掉的问题,现会写入诊断信息并按基础设施故障安全停止
29
+ - 修复后端测试节点 4、6、7 在缺失 spec.sourceBinding 时未统一停止的问题,避免上游回退导致下游 manifest 突发报错
30
+ - 修复成功执行路径下 HTTP 请求/响应日志解析不规范的问题,现统一规范化大小写不敏感的日志前缀并完整解析 JSON,恢复报告中的请求参数与大响应正文
31
+ - 修复 Pi writer 已输出合法结果却因前置说明、Markdown 装饰或中文标点被误判失败的问题,现确定性规范化唯一 outcome 且不影响无副作用操作
32
+ - 修复 Pi writer 前置 Git 快照失败被延后处理的问题,现调用模型前即安全停止,并在失败信息中保留完整的尝试次数与错误上下文
33
+ - 修复不稳定模型输出导致受监督 DAG 无法可信收尾的问题,现使用精确判定协议和有限重试,确保最终状态准确且保持安全关闭
34
+ - 修复合并后 canonical DAG Pi MED 路由错误回退到旧模型的问题,统一恢复为正确配置
35
+ - 修复脚手架样例漂移检查误判当前 worktree 目录名的问题,避免附加 worktree 触发稳定测试失败
36
+ - 修复 Windows 8.3 短路径与长路径指向同一仓库时生成不同指纹的问题,避免应用数据命名空间冲突
37
+ - 修复生成的 OpenCode 恢复插件在 busy→idle 竞态下丢失续接的问题,现保留待处理错误并在明确空闲后安全重试
38
+ - 修复 Pi retry 配置读取将权限或 I/O 错误误判为缺文件的问题,现仅缺失文件时允许新建配置
39
+ - 修复客户端瞬态恢复无法正确处理 TypeValidation 瞬时错误的问题,现能准确识别携带瞬时信号的错误并安全重试,纯 schema 校验失败仍被拒绝
40
+
5
41
  ## [0.25.4] - 2026-07-31
6
42
 
7
43
  ### 新增
8
44
 
45
+ - stream run progress heartbeats
46
+
47
+ ### 新增
48
+
9
49
  - stream run progress heartbeats
10
50
  - improve HTML failure triage
11
51
  - add native checklist and HTML report gates
@@ -1,7 +1,12 @@
1
- import { mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
1
+ import { mkdir, readFile, readdir, rename, rm, writeFile, } from "node:fs/promises";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
- export const CLIENT_RECOVERY_MODES = ["auto", "project", "user", "off"];
4
+ export const CLIENT_RECOVERY_MODES = [
5
+ "auto",
6
+ "project",
7
+ "user",
8
+ "off",
9
+ ];
5
10
  export const OPENCODE_TRANSIENT_RETRY_PLUGIN_PATH = ".opencode/plugins/loop-agent-transient-retry.js";
6
11
  export const PERMANENT_ERROR_INTERACTION = "plugin-ignore-permanent-error";
7
12
  export const BACKOFF_MS = [2000, 4000, 8000, 16000, 30000];
@@ -16,14 +21,26 @@ export const PI_RECOMMENDED_RETRY = {
16
21
  },
17
22
  };
18
23
  const PERMANENT_MESSAGE_PATTERNS = [
19
- { reason: "auth", pattern: /\b(401|unauthorized|invalid api key|authentication)\b/i },
20
- { reason: "permission", pattern: /\b(403|forbidden|permission denied|not allowed)\b/i },
21
- { reason: "quota", pattern: /\b(quota|rate.?limit|billing|insufficient.?credit)\b/i },
24
+ {
25
+ reason: "auth",
26
+ pattern: /\b(401|unauthorized|invalid api key|authentication)\b/i,
27
+ },
28
+ {
29
+ reason: "permission",
30
+ pattern: /\b(403|forbidden|permission denied|not allowed)\b/i,
31
+ },
32
+ {
33
+ reason: "quota",
34
+ pattern: /\b(quota|rate.?limit|billing|insufficient.?credit)\b/i,
35
+ },
22
36
  {
23
37
  reason: "context-overflow",
24
38
  pattern: /\b(context (length )?overflow|too many tokens|maximum context|context window)\b/i,
25
39
  },
26
- { reason: "cancelled", pattern: /\b(cancelled|canceled|aborted by user|user cancel)\b/i },
40
+ {
41
+ reason: "cancelled",
42
+ pattern: /\b(cancelled|canceled|aborted by user|user cancel)\b/i,
43
+ },
27
44
  {
28
45
  reason: "business-validation",
29
46
  pattern: /\b(business validation|invalid task|schema validation)\b/i,
@@ -33,7 +50,6 @@ const TRANSIENT_MESSAGE_PATTERNS = [
33
50
  /\bcode[:\s]*502\b/i,
34
51
  /\b502\b/,
35
52
  /\bLLMRequestError\b/i,
36
- /\bTypeValidationError\b/i,
37
53
  /\bnetwork fluctuation\b/i,
38
54
  /\btimeout\b/i,
39
55
  /\btransient provider failure\b/i,
@@ -90,13 +106,34 @@ function hasUnknownErrorShape(error) {
90
106
  }
91
107
  return false;
92
108
  }
109
+ /**
110
+ * TypeValidation bypass shape: bare TypeValidationError (zod invalid_union
111
+ * surfaced directly by the provider/SDK instead of wrapped as UnknownError).
112
+ * Bypass only fires when combined with a transient signal (see classifyTransientUnknownError).
113
+ */
114
+ function hasTypeValidationShape(error) {
115
+ const text = collectErrorText(error);
116
+ if (/\bTypeValidationError\b/i.test(text))
117
+ return true;
118
+ if (/Type validation failed/i.test(text))
119
+ return true;
120
+ if (error && typeof error === "object") {
121
+ const record = error;
122
+ if (typeof record.name === "string" &&
123
+ /TypeValidationError/i.test(record.name)) {
124
+ return true;
125
+ }
126
+ }
127
+ return false;
128
+ }
93
129
  function isStandardApiError(error) {
94
130
  if (!error || typeof error !== "object")
95
131
  return false;
96
132
  const record = error;
97
133
  if (typeof record.name === "string" && /^APIError$/i.test(record.name))
98
134
  return true;
99
- if (typeof record.name === "string" && /^(AuthError|PermissionError)$/i.test(record.name)) {
135
+ if (typeof record.name === "string" &&
136
+ /^(AuthError|PermissionError)$/i.test(record.name)) {
100
137
  return true;
101
138
  }
102
139
  return false;
@@ -106,7 +143,8 @@ function permanentReason(error) {
106
143
  const record = error;
107
144
  if (typeof record.name === "string" && /AuthError/i.test(record.name))
108
145
  return "auth";
109
- if (typeof record.name === "string" && /PermissionError/i.test(record.name)) {
146
+ if (typeof record.name === "string" &&
147
+ /PermissionError/i.test(record.name)) {
110
148
  return "permission";
111
149
  }
112
150
  return "api-error";
@@ -137,6 +175,12 @@ function looksTransient(error) {
137
175
  /**
138
176
  * Decide whether an OpenCode error should be resumed by the project plugin.
139
177
  * Only compensates transient UnknownError paths that built-in APIError retry misses.
178
+ *
179
+ * TypeValidation bypass: a bare TypeValidationError (provider/SDK surfaces
180
+ * zod invalid_union directly, without the UnknownError wrapper) is treated as
181
+ * a retryable transient-unknown-error ONLY when it also carries a transient
182
+ * signal (502 / LLMRequestError / timeout / etc.). A pure schema mismatch
183
+ * without a transient signal is still rejected (resumable: false).
140
184
  */
141
185
  export function classifyTransientUnknownError(error) {
142
186
  const permanent = permanentReason(error);
@@ -147,7 +191,9 @@ export function classifyTransientUnknownError(error) {
147
191
  interaction: PERMANENT_ERROR_INTERACTION,
148
192
  };
149
193
  }
150
- if (!hasUnknownErrorShape(error)) {
194
+ const isUnknownShape = hasUnknownErrorShape(error);
195
+ const isTypeValidationShape = hasTypeValidationShape(error);
196
+ if (!isUnknownShape && !isTypeValidationShape) {
151
197
  return {
152
198
  resumable: false,
153
199
  reason: "not-unknown-error",
@@ -164,7 +210,9 @@ export function classifyTransientUnknownError(error) {
164
210
  return { resumable: true, reason: "transient-unknown-error" };
165
211
  }
166
212
  export function nextBackoffMs(attempt) {
167
- if (!Number.isInteger(attempt) || attempt < 0 || attempt >= BACKOFF_MS.length) {
213
+ if (!Number.isInteger(attempt) ||
214
+ attempt < 0 ||
215
+ attempt >= BACKOFF_MS.length) {
168
216
  return undefined;
169
217
  }
170
218
  return BACKOFF_MS[attempt];
@@ -173,10 +221,15 @@ export function canResumeSession(input) {
173
221
  if (input.sessionStatus === "retry") {
174
222
  return { allowed: false, reason: "builtin-retry-active" };
175
223
  }
224
+ if (!input.sessionStatus) {
225
+ return { allowed: false, reason: "session-status-unknown" };
226
+ }
176
227
  if (input.locked) {
177
228
  return { allowed: false, reason: "session-locked" };
178
229
  }
179
- if (!input.idle || input.sessionStatus === "busy" || input.sessionStatus === "running") {
230
+ if (!input.idle ||
231
+ input.sessionStatus === "busy" ||
232
+ input.sessionStatus === "running") {
180
233
  return { allowed: false, reason: "session-busy" };
181
234
  }
182
235
  if (input.attempt >= MAX_SESSION_RETRIES) {
@@ -253,7 +306,12 @@ export function planPiRetryMerge(existing) {
253
306
  action: "write",
254
307
  reason: "missing-file",
255
308
  path: settingsPath,
256
- next: { retry: { ...PI_RECOMMENDED_RETRY, provider: { ...PI_RECOMMENDED_RETRY.provider } } },
309
+ next: {
310
+ retry: {
311
+ ...PI_RECOMMENDED_RETRY,
312
+ provider: { ...PI_RECOMMENDED_RETRY.provider },
313
+ },
314
+ },
257
315
  };
258
316
  }
259
317
  if (!isRecord(existing)) {
@@ -276,7 +334,8 @@ export function planPiRetryMerge(existing) {
276
334
  }
277
335
  const retry = isRecord(existing.retry) ? { ...existing.retry } : {};
278
336
  const provider = isRecord(retry.provider) ? { ...retry.provider } : {};
279
- let changed = !isRecord(existing.retry) || !isRecord(existing.retry.provider);
337
+ let changed = !isRecord(existing.retry) ||
338
+ !isRecord(existing.retry.provider);
280
339
  if (retry.enabled === undefined) {
281
340
  retry.enabled = PI_RECOMMENDED_RETRY.enabled;
282
341
  changed = true;
@@ -383,7 +442,8 @@ export async function applyPiRetryMerge(input) {
383
442
  const dir = path.dirname(settingsPath);
384
443
  const entries = await readdir(dir);
385
444
  await Promise.all(entries
386
- .filter((name) => name.startsWith(`.${path.basename(settingsPath)}.`) && name.endsWith(".tmp"))
445
+ .filter((name) => name.startsWith(`.${path.basename(settingsPath)}.`) &&
446
+ name.endsWith(".tmp"))
387
447
  .map((name) => rm(path.join(dir, name), { force: true })));
388
448
  }
389
449
  catch {
@@ -414,11 +474,13 @@ const MAX_RETRIES = ${MAX_SESSION_RETRIES};
414
474
 
415
475
  const sessionState = new Map();
416
476
  const sessionStatus = new Map();
477
+ const pendingErrors = new Map();
478
+ const recoveryWorkers = new Map();
417
479
 
418
480
  function getState(sessionId) {
419
481
  let state = sessionState.get(sessionId);
420
482
  if (!state) {
421
- state = { attempt: 0, locked: false };
483
+ state = { attempt: 0, locked: false, rerunRequested: false };
422
484
  sessionState.set(sessionId, state);
423
485
  }
424
486
  return state;
@@ -449,9 +511,13 @@ function isTransientUnknown(error) {
449
511
  if (isPermanent(error)) return false;
450
512
  const text = textOf(error);
451
513
  const hasUnknown = /UnknownError/i.test(text) || (error && /UnknownError/i.test(String(error.name || "")));
452
- if (!hasUnknown) return false;
514
+ const hasTypeValidation =
515
+ /\\bTypeValidationError\\b/i.test(text) ||
516
+ /Type validation failed/i.test(text) ||
517
+ (error && /TypeValidationError/i.test(String(error.name || "")));
518
+ if (!hasUnknown && !hasTypeValidation) return false;
453
519
  return (
454
- /\\b(code[:\\s]*502|502|LLMRequestError|TypeValidationError|Type validation failed|network fluctuation|timeout|transient provider failure|non-standard)\\b/i.test(
520
+ /\\b(code[:\\s]*502|502|LLMRequestError|network fluctuation|timeout|transient provider failure|non-standard)\\b/i.test(
455
521
  text,
456
522
  ) ||
457
523
  (error && (error.code === 502 || error.status === 502))
@@ -474,68 +540,121 @@ async function wait(ms) {
474
540
  }
475
541
 
476
542
  function statusType(status) {
477
- return status && typeof status.type === "string" ? status.type : undefined;
543
+ const type = status && typeof status.type === "string" ? status.type : undefined;
544
+ return type === "idle" || type === "retry" || type === "busy" ? type : undefined;
478
545
  }
479
546
 
480
547
  async function readSessionStatus(client, sessionId) {
481
548
  const cached = sessionStatus.get(sessionId);
482
- if (cached === "retry") return cached;
549
+ if (cached === "retry" || cached === "busy") {
550
+ return { ok: true, type: cached };
551
+ }
483
552
  try {
484
- const response = await client.session.status();
553
+ const response = await client.session.status({ throwOnError: true });
554
+ if (!response || typeof response !== "object" || response.error != null) {
555
+ return { ok: false };
556
+ }
485
557
  const current = statusType(response?.data?.[sessionId]);
486
- if (current) sessionStatus.set(sessionId, current);
487
- return current || cached || "idle";
558
+ if (!current) return { ok: false };
559
+ sessionStatus.set(sessionId, current);
560
+ return { ok: true, type: current };
488
561
  } catch {
489
- return cached || "idle";
562
+ return { ok: false };
490
563
  }
491
564
  }
492
565
 
566
+ function promptWasAccepted(response) {
567
+ return Boolean(
568
+ response &&
569
+ typeof response === "object" &&
570
+ response.error == null &&
571
+ Object.prototype.hasOwnProperty.call(response, "data") &&
572
+ response.data !== null &&
573
+ typeof response.data === "object",
574
+ );
575
+ }
576
+
493
577
  export default async function loopAgentTransientRetryPlugin({ client, $ }) {
494
578
  void $;
495
579
  void PLUGIN_PATH;
496
580
 
497
- async function maybeResume(sessionId, error) {
498
- if (!sessionId) return { interaction: PERMANENT_INTERACTION, reason: "missing-session" };
499
- if (!isTransientUnknown(error)) {
500
- return { interaction: PERMANENT_INTERACTION, reason: "plugin-ignore-permanent-error" };
501
- }
502
-
581
+ async function drainPendingRecovery(sessionId) {
503
582
  const state = getState(sessionId);
504
- if (state.locked) {
505
- return { interaction: PERMANENT_INTERACTION, reason: "session-locked" };
506
- }
583
+ const pending = pendingErrors.get(sessionId);
584
+ if (!pending) return;
507
585
  if (state.attempt >= MAX_RETRIES) {
508
- return { interaction: PERMANENT_INTERACTION, reason: "max-retries" };
586
+ pendingErrors.delete(sessionId);
587
+ return;
509
588
  }
510
589
 
511
- state.locked = true;
512
- try {
513
- let status = await readSessionStatus(client, sessionId);
514
- // Never race OpenCode built-in retry.
515
- if (status === "retry") {
516
- return { interaction: PERMANENT_INTERACTION, reason: "builtin-retry-active" };
517
- }
518
- if (status === "busy" || status === "running") {
519
- return { interaction: PERMANENT_INTERACTION, reason: "session-busy" };
520
- }
590
+ // A status event may be stale by the time this worker runs. Confirm idle via
591
+ // the SDK before and after backoff; every other outcome keeps the error pending.
592
+ const beforeDelay = await readSessionStatus(client, sessionId);
593
+ if (pendingErrors.get(sessionId) !== pending) return;
594
+ if (!beforeDelay.ok || beforeDelay.type !== "idle") return;
521
595
 
522
- const delay = BACKOFF_MS[state.attempt];
523
- const nextAttempt = state.attempt + 1;
524
- if (typeof delay === "number") await wait(delay);
525
- // Re-check idle after backoff.
526
- status = await readSessionStatus(client, sessionId);
527
- if (status === "retry" || status === "busy" || status === "running") {
528
- return { interaction: PERMANENT_INTERACTION, reason: "session-not-idle" };
529
- }
530
- const prompt = buildResumePrompt(sessionId, error, nextAttempt);
531
- state.attempt = nextAttempt;
532
- await client.session.promptAsync({
596
+ const delay = BACKOFF_MS[state.attempt];
597
+ if (typeof delay !== "number") {
598
+ pendingErrors.delete(sessionId);
599
+ return;
600
+ }
601
+ await wait(delay);
602
+ if (pendingErrors.get(sessionId) !== pending) return;
603
+
604
+ const beforePrompt = await readSessionStatus(client, sessionId);
605
+ if (pendingErrors.get(sessionId) !== pending) return;
606
+ if (!beforePrompt.ok || beforePrompt.type !== "idle") return;
607
+
608
+ const nextAttempt = state.attempt + 1;
609
+ const prompt = buildResumePrompt(sessionId, pending.error, nextAttempt);
610
+ let response;
611
+ try {
612
+ response = await client.session.promptAsync({
533
613
  path: { id: sessionId },
534
614
  body: { parts: [{ type: "text", text: prompt }] },
615
+ throwOnError: true,
535
616
  });
536
- return { resumed: true, attempt: state.attempt };
537
- } finally {
538
- state.locked = false;
617
+ } catch {
618
+ return;
619
+ }
620
+ if (!promptWasAccepted(response)) return;
621
+
622
+ state.attempt = nextAttempt;
623
+ if (pendingErrors.get(sessionId) === pending) {
624
+ pendingErrors.delete(sessionId);
625
+ }
626
+ }
627
+
628
+ function startRecoveryWorker(sessionId) {
629
+ const state = getState(sessionId);
630
+ if (recoveryWorkers.has(sessionId)) {
631
+ state.rerunRequested = true;
632
+ return;
633
+ }
634
+ state.locked = true;
635
+ state.rerunRequested = false;
636
+ const worker = drainPendingRecovery(sessionId)
637
+ .catch(() => {})
638
+ .finally(() => {
639
+ recoveryWorkers.delete(sessionId);
640
+ const rerun = state.rerunRequested;
641
+ state.rerunRequested = false;
642
+ state.locked = false;
643
+ if (rerun && pendingErrors.has(sessionId)) {
644
+ startRecoveryWorker(sessionId);
645
+ }
646
+ });
647
+ recoveryWorkers.set(sessionId, worker);
648
+ }
649
+
650
+ function clearSession(sessionId) {
651
+ pendingErrors.delete(sessionId);
652
+ sessionStatus.delete(sessionId);
653
+ const state = sessionState.get(sessionId);
654
+ if (state) {
655
+ state.attempt = 0;
656
+ state.rerunRequested = false;
657
+ if (!recoveryWorkers.has(sessionId)) state.locked = false;
539
658
  }
540
659
  }
541
660
 
@@ -544,11 +663,27 @@ export default async function loopAgentTransientRetryPlugin({ client, $ }) {
544
663
  if (!event || typeof event !== "object") return;
545
664
  const properties = event.properties || {};
546
665
  const sessionId =
547
- properties.sessionID || properties.sessionId || properties.id;
666
+ properties.sessionID || properties.sessionId || properties.id || properties.info?.id;
548
667
 
549
668
  if (event.type === "session.status") {
550
669
  const current = statusType(properties.status);
551
670
  if (sessionId && current) sessionStatus.set(sessionId, current);
671
+ if (sessionId && current === "idle" && pendingErrors.has(sessionId)) {
672
+ startRecoveryWorker(sessionId);
673
+ }
674
+ return;
675
+ }
676
+
677
+ if (event.type === "session.idle") {
678
+ if (sessionId) {
679
+ sessionStatus.set(sessionId, "idle");
680
+ if (pendingErrors.has(sessionId)) startRecoveryWorker(sessionId);
681
+ }
682
+ return;
683
+ }
684
+
685
+ if (event.type === "session.deleted") {
686
+ if (sessionId) clearSession(sessionId);
552
687
  return;
553
688
  }
554
689
 
@@ -557,16 +692,26 @@ export default async function loopAgentTransientRetryPlugin({ client, $ }) {
557
692
  if (
558
693
  info?.role === "assistant" &&
559
694
  info.sessionID &&
695
+ (info.status === undefined || info.status === "completed") &&
560
696
  info.time?.completed != null &&
561
697
  info.error == null
562
698
  ) {
563
- getState(info.sessionID).attempt = 0;
699
+ clearSession(info.sessionID);
564
700
  }
565
701
  return;
566
702
  }
567
703
 
568
704
  if (event.type === "session.error") {
569
- return maybeResume(sessionId, properties.error || properties);
705
+ if (!sessionId) return;
706
+ const error = properties.error || properties;
707
+ if (!isTransientUnknown(error)) {
708
+ pendingErrors.delete(sessionId);
709
+ getState(sessionId).rerunRequested = false;
710
+ return { interaction: PERMANENT_INTERACTION, reason: "plugin-ignore-permanent-error" };
711
+ }
712
+ pendingErrors.set(sessionId, { error });
713
+ startRecoveryWorker(sessionId);
714
+ return { pending: true };
570
715
  }
571
716
  },
572
717
  };
@@ -647,7 +792,9 @@ export async function runClientRecovery(input) {
647
792
  },
648
793
  };
649
794
  }
650
- const plugin = await installProjectOpenCodePlugin({ repoRoot: input.repoRoot });
795
+ const plugin = await installProjectOpenCodePlugin({
796
+ repoRoot: input.repoRoot,
797
+ });
651
798
  if (mode !== "user") {
652
799
  return { mode, plugin };
653
800
  }
@@ -4,6 +4,7 @@ import { writeDagNodeJsonArtifact, writeTextArtifactFile, } from "../infrastruct
4
4
  import { executePiStep, } from "./pi-executor.js";
5
5
  import { redactPromptForLog, truncateOutput, } from "../shared/output-truncation.js";
6
6
  import { pathsChangedDuringRun, readGitStatusPorcelain, snapshotGitStatusPathFingerprints, snapshotGitStatusPorcelain, validateShellWriteGuard, } from "./shell-write-guard.js";
7
+ import { redactSecrets, truncateUtf8Preview } from "../shared/preview.js";
7
8
  export const DAG_PI_READONLY_TOOLS = ["read", "grep", "find", "ls"];
8
9
  export const DAG_PI_WRITE_TOOLS = [
9
10
  "read",
@@ -15,6 +16,7 @@ export const DAG_PI_WRITE_TOOLS = [
15
16
  "ls",
16
17
  ];
17
18
  export const DEFAULT_DAG_PI_PROVIDER = "wizard-local";
19
+ const WRITER_OUTCOME_PROTOCOL_LINE = "IMPLEMENTATION_OUTCOME:";
18
20
  export const DAG_PI_MODEL_PROVIDERS = {
19
21
  "gpt-5.3-codex-spark": "wizard-local",
20
22
  "gpt-5.5": "wizard-local",
@@ -266,9 +268,14 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep) {
266
268
  beforeStatus = await readGitStatusPorcelain(input.cwd);
267
269
  beforePathFingerprints = await snapshotGitStatusPathFingerprints(input.cwd, snapshotGitStatusPorcelain(beforeStatus));
268
270
  }
269
- catch {
270
- beforeStatus = undefined;
271
- beforePathFingerprints = undefined;
271
+ catch (error) {
272
+ return {
273
+ ok: false,
274
+ stdout: "",
275
+ stderr: `writer Git baseline unavailable before Pi execution: ${error instanceof Error ? error.message : String(error)}`,
276
+ failureCategory: "write-guard",
277
+ durationMs: Date.now() - started,
278
+ };
272
279
  }
273
280
  }
274
281
  const reportActivity = input.reportActivity;
@@ -319,7 +326,9 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep) {
319
326
  persona,
320
327
  step,
321
328
  });
322
- const mapped = mapPiResultToDagNodeResult(result, input.task.firstProtocolLine);
329
+ const mapped = mapPiResultToDagNodeResult(result, input.task.writerOutcomePolicy
330
+ ? WRITER_OUTCOME_PROTOCOL_LINE
331
+ : input.task.firstProtocolLine);
323
332
  if (!isWriteTask) {
324
333
  return mapped;
325
334
  }
@@ -399,38 +408,94 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep) {
399
408
  };
400
409
  }
401
410
  export function validateWriterImplementationOutcome(text, changedFiles) {
402
- const firstLine = text
403
- .split(/\r?\n/)
404
- .map((line) => line.trim())
405
- .find(Boolean);
406
- const match = firstLine?.match(/^IMPLEMENTATION_OUTCOME: (changed|already-satisfied|blocked)$/);
407
- if (!match) {
411
+ const parsed = parseWriterImplementationOutcome(text);
412
+ const diagnostics = writerOutcomeDiagnostics(text, parsed, changedFiles.length);
413
+ if (parsed.kind !== "valid") {
408
414
  return {
409
415
  ok: false,
410
- reason: "writer outcome validation failed: missing or invalid first line; expected IMPLEMENTATION_OUTCOME: changed, IMPLEMENTATION_OUTCOME: already-satisfied, or IMPLEMENTATION_OUTCOME: blocked",
416
+ reason: `writer outcome validation failed: ${parsed.kind} outcome; ${diagnostics}; expected IMPLEMENTATION_OUTCOME: changed, IMPLEMENTATION_OUTCOME: already-satisfied, or IMPLEMENTATION_OUTCOME: blocked`,
411
417
  };
412
418
  }
413
- const outcome = match[1];
419
+ const outcome = parsed.outcome;
414
420
  if (outcome === "blocked") {
415
421
  return {
416
422
  ok: false,
417
- reason: "writer outcome validation failed: IMPLEMENTATION_OUTCOME: blocked cannot complete successfully",
423
+ reason: `writer outcome validation failed: IMPLEMENTATION_OUTCOME: blocked cannot complete successfully; ${diagnostics}`,
418
424
  };
419
425
  }
420
426
  if (outcome === "changed" && changedFiles.length === 0) {
421
427
  return {
422
428
  ok: false,
423
- reason: "writer outcome validation failed: changed outcome has an empty diff",
429
+ reason: `writer outcome validation failed: changed outcome has an empty diff; ${diagnostics}`,
424
430
  };
425
431
  }
426
432
  if (outcome === "already-satisfied" && changedFiles.length > 0) {
427
433
  return {
428
434
  ok: false,
429
- reason: "writer outcome validation failed: already-satisfied outcome has a non-empty diff",
435
+ reason: `writer outcome validation failed: already-satisfied outcome has a non-empty diff; ${diagnostics}`,
430
436
  };
431
437
  }
432
438
  return { ok: true, outcome };
433
439
  }
440
+ function parseWriterImplementationOutcome(text) {
441
+ const lines = text.split(/\r?\n/);
442
+ const candidates = [];
443
+ for (const [lineIndex, line] of lines.entries()) {
444
+ const nextLine = lines[lineIndex + 1];
445
+ const normalized = normalizeProtocolLine(line, WRITER_OUTCOME_PROTOCOL_LINE, nextLine);
446
+ if (normalized === undefined)
447
+ continue;
448
+ const value = normalized
449
+ .slice(WRITER_OUTCOME_PROTOCOL_LINE.length)
450
+ .trim();
451
+ const outcome = isWriterImplementationOutcome(value)
452
+ ? value
453
+ : undefined;
454
+ candidates.push({
455
+ lineIndex,
456
+ value,
457
+ outcome,
458
+ });
459
+ }
460
+ if (candidates.length === 0) {
461
+ return { kind: "missing", candidates };
462
+ }
463
+ if (candidates.some((candidate) => candidate.outcome === undefined)) {
464
+ return { kind: "unknown", candidates };
465
+ }
466
+ const outcomes = new Set(candidates.map((candidate) => candidate.outcome));
467
+ if (outcomes.size !== 1) {
468
+ return { kind: "conflicting", candidates };
469
+ }
470
+ return {
471
+ kind: "valid",
472
+ outcome: candidates[0].outcome,
473
+ candidates,
474
+ };
475
+ }
476
+ function isWriterImplementationOutcome(value) {
477
+ return (value === "changed" ||
478
+ value === "already-satisfied" ||
479
+ value === "blocked");
480
+ }
481
+ function writerOutcomeDiagnostics(text, parsed, changedFiles) {
482
+ const firstNonEmpty = text
483
+ .split(/\r?\n/)
484
+ .map((line) => line.trim())
485
+ .find(Boolean) ?? "(empty)";
486
+ const candidateSummary = parsed.candidates.length === 0
487
+ ? "none"
488
+ : parsed.candidates
489
+ .slice(0, 4)
490
+ .map((candidate) => candidate.outcome
491
+ ? `${candidate.outcome}@line${candidate.lineIndex + 1}`
492
+ : `unknown(${boundedWriterDiagnostic(candidate.value)})@line${candidate.lineIndex + 1}`)
493
+ .join(",");
494
+ return `firstNonEmpty=${boundedWriterDiagnostic(firstNonEmpty)}; candidates=${candidateSummary}; changedFiles=${changedFiles}`;
495
+ }
496
+ function boundedWriterDiagnostic(value) {
497
+ return JSON.stringify(truncateUtf8Preview(redactSecrets(value), 160));
498
+ }
434
499
  export function mapPiResultToDagNodeResult(result, firstProtocolLine) {
435
500
  const assistantText = canonicalizeProtocolFirstLine(result.assistantText, firstProtocolLine);
436
501
  return {
@@ -1,7 +1,7 @@
1
1
  import { DEFAULT_DAG_EXECUTOR_MODELS, } from '../workflows/dag/types.js';
2
2
  export const DEFAULT_DAG_MODELS = {
3
3
  HIGH: "gpt-5.5",
4
- MED: "glm-5.2",
4
+ MED: "gpt-5.5",
5
5
  LOW: "gpt-5.3-codex-spark",
6
6
  };
7
7
  /**