@tea-agent/loop-agent 0.25.4 → 0.25.6
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.
- package/AGENTS.md +6 -0
- package/CHANGELOG.md +60 -0
- package/dist/commands/client-recovery.js +209 -62
- package/dist/commands/init.js +68 -129
- package/dist/executors/dag-pi-executor.js +80 -15
- package/dist/executors/model-routing.js +1 -1
- package/dist/executors/shell-executor.js +127 -0
- package/dist/executors/shell-write-guard.js +21 -7
- package/dist/worker/console/repo-fingerprint.js +7 -1
- package/dist/workflows/dag/backend-test-case-coverage-analysis.js +1281 -0
- package/dist/workflows/dag/backend-test-case-manifest.js +59 -1
- package/dist/workflows/dag/backend-test-markdown-workflow.js +236 -16
- package/dist/workflows/dag/convergence/controller.js +134 -9
- package/dist/workflows/dag/frontend-test-l5-report.js +138 -0
- package/dist/workflows/dag/init-hybrid.js +270 -80
- package/dist/workflows/dag/node-execution.js +64 -11
- package/dist/workflows/dag/prompt.js +118 -4
- package/dist/workflows/dag/retry-policy.js +5 -4
- package/dist/workflows/dag/scheduler.js +32 -5
- package/dist/workflows/dag/types.js +7 -4
- package/dist/workflows/dag/validate.js +3 -2
- package/docs/architecture/dag-execution.md +7 -4
- package/docs/architecture/runtime-boundaries.md +1 -1
- package/docs/init-surface.manifest.json +3 -1
- package/docs/templates/README.md +1 -0
- package/docs/templates/agent-dag.base.json +1 -1
- package/docs/templates/agent-dag.final-verification.json +1 -1
- package/docs/templates/agent-dag.supervised-implementation.json +1 -1
- package/docs/templates/backend-test-dag.json +41 -14
- package/docs/templates/frontend-test-dag.json +32 -2
- package/docs/templates/hybrid-dag.json +1 -1
- package/docs/templates/init-managed-agents.md +137 -0
- package/examples/decision-gate-agent-dag.json +1 -1
- package/examples/example-dag.json +1 -1
- package/examples/hybrid-loop-agent-dag.json +1 -1
- package/harness.json +1 -1
- package/package.json +1 -1
- package/skills/loop-agent/references/command-reference.md +5 -4
- package/skills/loop-agent/references/hybrid-dag.md +2 -2
- 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,70 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.25.6] - 2026-08-01
|
|
6
|
+
|
|
7
|
+
### 重点更新
|
|
8
|
+
|
|
9
|
+
- 后端测试新增按变更类型界定覆盖深度的机制,精准控制测试范围并避免无关用例膨胀
|
|
10
|
+
- 初始化托管块改为从独立模板文件渲染,大幅降低维护难度并提升可读性
|
|
11
|
+
|
|
12
|
+
### 新增
|
|
13
|
+
|
|
14
|
+
- backend-test 新增机器可读的 Coverage Scope,支持按新增接口、契约变化、行为变化、缺陷修复和纯实现优化选择覆盖深度
|
|
15
|
+
- 目标项目的 AGENTS.md 托管块改为从 docs/templates/init-managed-agents.md 渲染,便于直接编辑维护
|
|
16
|
+
|
|
17
|
+
### 改进
|
|
18
|
+
|
|
19
|
+
- 纯优化类变更现聚焦于需求、确定性影响面与最小回归集,避免无关接口全量用例膨胀
|
|
20
|
+
- 该初始化模板仅随 npm 包发布,不投影到目标项目治理目录,同时同步了多项推荐路径与精确命令
|
|
21
|
+
|
|
22
|
+
## [0.25.5] - 2026-07-31
|
|
23
|
+
|
|
24
|
+
### 重点更新
|
|
25
|
+
|
|
26
|
+
- 后端测试 DAG 新增确定性场景覆盖分析与 Markdown 到 pytest 的精准对应,显著提升测试质量度量的准确性与透明度
|
|
27
|
+
- 受监督的 DAG 默认启用有界自愈闭环,能够自动处理可恢复的执行失败并安全重算后续节点
|
|
28
|
+
- 全面加固客户端会话恢复机制,精准识别并补偿由模型服务瞬时异常引发的结构校验错误
|
|
29
|
+
- 大幅增强 DAG 执行的确定性收尾能力,确保在模型输出不稳定或前置快照失败时安全降级
|
|
30
|
+
|
|
31
|
+
### 新增
|
|
32
|
+
|
|
33
|
+
- 后端测试固定 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
|
|
34
|
+
- 受监督的 DAG 默认启用有界自愈闭环:通过 maxFixLoops 派生收敛预算,将可恢复失败与合法的审查修订请求统一纳入恢复链,并确定性注入上一轮失败证据指针,同时保持对安全失败的不自动重试
|
|
35
|
+
- 后端测试用例生成与独立 Review 改为产品需求优先,并穷举文档化 API 规则,涵盖生命周期、枚举等价类、长度数值边界及业务状态迁移等场景
|
|
36
|
+
- 后端测试 HTML/facts 新增可展开的“用例场景覆盖分析”和“Markdown → pytest 一一对应”质量详情
|
|
37
|
+
|
|
38
|
+
### 改进
|
|
39
|
+
|
|
40
|
+
- backend-test 用例生成/独立 Review 改为产品需求优先,再穷举文档化 API 规则:覆盖 active/deleted duplicate-name 生命周期、每个合法枚举值、非法枚举等价类、长度/数值边界、字符格式、null/missing/type 错误和业务状态迁移;每个 Case 绑定唯一 primary pytest symbol
|
|
41
|
+
- backend-test 将 Coverage Test Point 与 pytest 执行单元解耦:Test Point 明确分为 `variant`、`assertion`、`cross-cutting`,只有真实输入/状态/结果变体生成参数 item,同一业务旅程检查点通过 symbol docstring 追溯,HTTP 日志等横切证据不再制造重复业务 item;报告区分 Markdown Case、primary symbol、pytest item 并展示执行放大率
|
|
42
|
+
- backend-test HTML/facts 新增可展开的“用例场景覆盖分析”和“Markdown → pytest 一一对应”质量详情,并由最终报告分别消费节点 4/6 详细 findings 与节点 7 machine totals
|
|
43
|
+
- 减少全量测试中的重复 CLI 冷启动和 self-host canary 二次打包安装场景,收敛低价值重复用例并保留关键端到端主路径
|
|
44
|
+
- DAG 执行期间的主会话行为约束得到增补:启动后自主推进至结束、持续可靠判活、严格限制工作区文件修改,并优先使用纯函数探针排查路由
|
|
45
|
+
|
|
46
|
+
### 修复
|
|
47
|
+
|
|
48
|
+
- 修复后端测试节点 4/6 的确定性覆盖与对应分析在自身崩溃时被静默吞掉的问题,现会写入诊断信息并按基础设施故障安全停止
|
|
49
|
+
- 修复后端测试节点 4、6、7 在缺失 spec.sourceBinding 时未统一停止的问题,避免上游回退导致下游 manifest 突发报错
|
|
50
|
+
- 修复成功执行路径下 HTTP 请求/响应日志解析不规范的问题,现统一规范化大小写不敏感的日志前缀并完整解析 JSON,恢复报告中的请求参数与大响应正文
|
|
51
|
+
- 修复 Pi writer 已输出合法结果却因前置说明、Markdown 装饰或中文标点被误判失败的问题,现确定性规范化唯一 outcome 且不影响无副作用操作
|
|
52
|
+
- 修复 Pi writer 前置 Git 快照失败被延后处理的问题,现调用模型前即安全停止,并在失败信息中保留完整的尝试次数与错误上下文
|
|
53
|
+
- 修复不稳定模型输出导致受监督 DAG 无法可信收尾的问题,现使用精确判定协议和有限重试,确保最终状态准确且保持安全关闭
|
|
54
|
+
- 修复合并后 canonical DAG Pi MED 路由错误回退到旧模型的问题,统一恢复为正确配置
|
|
55
|
+
- 修复脚手架样例漂移检查误判当前 worktree 目录名的问题,避免附加 worktree 触发稳定测试失败
|
|
56
|
+
- 修复 Windows 8.3 短路径与长路径指向同一仓库时生成不同指纹的问题,避免应用数据命名空间冲突
|
|
57
|
+
- 修复生成的 OpenCode 恢复插件在 busy→idle 竞态下丢失续接的问题,现保留待处理错误并在明确空闲后安全重试
|
|
58
|
+
- 修复 Pi retry 配置读取将权限或 I/O 错误误判为缺文件的问题,现仅缺失文件时允许新建配置
|
|
59
|
+
- 修复客户端瞬态恢复无法正确处理 TypeValidation 瞬时错误的问题,现能准确识别携带瞬时信号的错误并安全重试,纯 schema 校验失败仍被拒绝
|
|
60
|
+
|
|
5
61
|
## [0.25.4] - 2026-07-31
|
|
6
62
|
|
|
7
63
|
### 新增
|
|
8
64
|
|
|
65
|
+
- stream run progress heartbeats
|
|
66
|
+
|
|
67
|
+
### 新增
|
|
68
|
+
|
|
9
69
|
- stream run progress heartbeats
|
|
10
70
|
- improve HTML failure triage
|
|
11
71
|
- 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 = [
|
|
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
|
-
{
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
{
|
|
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" &&
|
|
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" &&
|
|
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
|
-
|
|
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) ||
|
|
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 ||
|
|
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: {
|
|
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) ||
|
|
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)}.`) &&
|
|
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
|
-
|
|
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|
|
|
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
|
-
|
|
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"
|
|
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)
|
|
487
|
-
|
|
558
|
+
if (!current) return { ok: false };
|
|
559
|
+
sessionStatus.set(sessionId, current);
|
|
560
|
+
return { ok: true, type: current };
|
|
488
561
|
} catch {
|
|
489
|
-
return
|
|
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
|
|
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
|
-
|
|
505
|
-
|
|
506
|
-
}
|
|
583
|
+
const pending = pendingErrors.get(sessionId);
|
|
584
|
+
if (!pending) return;
|
|
507
585
|
if (state.attempt >= MAX_RETRIES) {
|
|
508
|
-
|
|
586
|
+
pendingErrors.delete(sessionId);
|
|
587
|
+
return;
|
|
509
588
|
}
|
|
510
589
|
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
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
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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
|
-
|
|
537
|
-
|
|
538
|
-
|
|
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
|
-
|
|
699
|
+
clearSession(info.sessionID);
|
|
564
700
|
}
|
|
565
701
|
return;
|
|
566
702
|
}
|
|
567
703
|
|
|
568
704
|
if (event.type === "session.error") {
|
|
569
|
-
|
|
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({
|
|
795
|
+
const plugin = await installProjectOpenCodePlugin({
|
|
796
|
+
repoRoot: input.repoRoot,
|
|
797
|
+
});
|
|
651
798
|
if (mode !== "user") {
|
|
652
799
|
return { mode, plugin };
|
|
653
800
|
}
|