@tea-agent/loop-agent 0.25.2 → 0.25.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1 -0
- package/CHANGELOG.md +22 -0
- package/dist/cli/command-definitions.js +1 -1
- package/dist/cli/program.js +2 -1
- package/dist/commands/client-recovery.js +657 -0
- package/dist/commands/init.js +80 -3
- package/docs/architecture/runtime-boundaries.md +13 -0
- package/docs/init-surface.manifest.json +6 -2
- package/package.json +1 -1
- package/skills/loop-agent/SKILL.md +2 -1
package/AGENTS.md
CHANGED
|
@@ -60,6 +60,7 @@
|
|
|
60
60
|
- 长期决策写入 `docs/`;面向用户变更更新 `CHANGELOG.md`(结果导向中文)。
|
|
61
61
|
- init/投影变更必须同步目标项目生成物与 package assets;init evolution 按 `docs/init-surface.manifest.json` 分级。
|
|
62
62
|
- CLI/skill entry/runtime boundary/发布包变更同步 catalog、脚本与测试。
|
|
63
|
+
- 明确的前端页面/UI/组件/交互实现需求必须设置 `taskKind: "frontend-implementation"`(不是 `--profile`),不得保留默认 `standard`;浏览器/UI 自动化测试继续使用 `taskKind: "frontend-test"`。
|
|
63
64
|
- 没有新鲜验证证据时不声明完成;新债写入 plan/progress/report。
|
|
64
65
|
|
|
65
66
|
## 验证
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,28 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.25.3] - 2026-07-30
|
|
6
|
+
|
|
7
|
+
### 重点更新
|
|
8
|
+
|
|
9
|
+
- 新增 OpenCode 客户端瞬态会话恢复机制,在内置重试遗漏时自动补偿 UnknownError
|
|
10
|
+
- 优化前端实现需求路由,将前端开发与测试任务准确分发至对应 DAG 流程
|
|
11
|
+
|
|
12
|
+
### 新增
|
|
13
|
+
|
|
14
|
+
- loop-agent init 支持 --client-recovery 参数,可在目标项目生成 OpenCode 插件以补偿瞬态 UnknownError
|
|
15
|
+
- 支持在用户级别原子写入 Pi retry 推荐配置,并严格遵守插件 ownership 不覆盖用户改动
|
|
16
|
+
|
|
17
|
+
### 改进
|
|
18
|
+
|
|
19
|
+
- 初始化或刷新 AGENTS.md 时,要求将明确的前端实现需求路由至 frontend-implementation,与浏览器和 UI 自动化测试区分开
|
|
20
|
+
|
|
21
|
+
### 修复
|
|
22
|
+
|
|
23
|
+
- 修复生成的 OpenCode 恢复插件无法按真实 API 工作的问题,现直接返回 Hooks.event 并按事件正确分发与续接
|
|
24
|
+
- 修复 Type validation failed 等真实错误被业务校验规则误杀的问题
|
|
25
|
+
- 修复 Pi retry 配置读取将权限或 I/O 错误误判为缺文件的问题,现仅 ENOENT 视为缺文件
|
|
26
|
+
|
|
5
27
|
## [0.25.2] - 2026-07-30
|
|
6
28
|
|
|
7
29
|
### 重点更新
|
|
@@ -216,7 +216,7 @@ export const COMMAND_DEFINITIONS = [
|
|
|
216
216
|
adapter: "none",
|
|
217
217
|
tier: "primary",
|
|
218
218
|
intent: "Initialize a target repository with loop-agent harness capabilities.",
|
|
219
|
-
usage: "init [instructions|doctor|check-update|update|reconcile] [--profile full|minimal] [--merge] [--json|--markdown] [--bootstrap-surface|--apply-safe]",
|
|
219
|
+
usage: "init [instructions|doctor|check-update|update|reconcile] [--profile full|minimal] [--merge] [--json|--markdown] [--bootstrap-surface|--apply-safe] [--client-recovery=auto|project|user|off]",
|
|
220
220
|
subcommands: [...INIT_SUBCOMMANDS],
|
|
221
221
|
handler: async ({ repoRoot, subcommand, rest }) => {
|
|
222
222
|
await runInit(repoRoot, [subcommand, ...rest].filter(Boolean), { readRuntimeActivity: readInitRuntimeActivity });
|
package/dist/cli/program.js
CHANGED
|
@@ -617,7 +617,8 @@ export function buildLoopAgentProgram(options) {
|
|
|
617
617
|
.option("--json", "print JSON")
|
|
618
618
|
.option("--markdown", "print Markdown")
|
|
619
619
|
.option("--bootstrap-surface", "write an inferred .harness/init-surface.json baseline")
|
|
620
|
-
.option("--apply-safe", "apply deterministic safe init updates")
|
|
620
|
+
.option("--apply-safe", "apply deterministic safe init updates")
|
|
621
|
+
.option("--client-recovery <mode>", "auto|project|user|off — OpenCode project plugin and optional Pi user retry config", "auto");
|
|
621
622
|
command.action(async (args, _options, actionCommand) => runInitCommand(args, actionCommand, options.defaultRepoRoot));
|
|
622
623
|
addStandaloneSubcommands(command, entry.subcommands ?? [], (args, actionCommand) => runInitCommand(args, actionCommand, options.defaultRepoRoot));
|
|
623
624
|
program.addCommand(command);
|
|
@@ -0,0 +1,657 @@
|
|
|
1
|
+
import { mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
export const CLIENT_RECOVERY_MODES = ["auto", "project", "user", "off"];
|
|
5
|
+
export const OPENCODE_TRANSIENT_RETRY_PLUGIN_PATH = ".opencode/plugins/loop-agent-transient-retry.js";
|
|
6
|
+
export const PERMANENT_ERROR_INTERACTION = "plugin-ignore-permanent-error";
|
|
7
|
+
export const BACKOFF_MS = [2000, 4000, 8000, 16000, 30000];
|
|
8
|
+
export const MAX_SESSION_RETRIES = 5;
|
|
9
|
+
export const PI_RECOMMENDED_RETRY = {
|
|
10
|
+
enabled: true,
|
|
11
|
+
maxRetries: 5,
|
|
12
|
+
baseDelayMs: 3000,
|
|
13
|
+
provider: {
|
|
14
|
+
maxRetries: 0,
|
|
15
|
+
maxRetryDelayMs: 60000,
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
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 },
|
|
22
|
+
{
|
|
23
|
+
reason: "context-overflow",
|
|
24
|
+
pattern: /\b(context (length )?overflow|too many tokens|maximum context|context window)\b/i,
|
|
25
|
+
},
|
|
26
|
+
{ reason: "cancelled", pattern: /\b(cancelled|canceled|aborted by user|user cancel)\b/i },
|
|
27
|
+
{
|
|
28
|
+
reason: "business-validation",
|
|
29
|
+
pattern: /\b(business validation|invalid task|schema validation)\b/i,
|
|
30
|
+
},
|
|
31
|
+
];
|
|
32
|
+
const TRANSIENT_MESSAGE_PATTERNS = [
|
|
33
|
+
/\bcode[:\s]*502\b/i,
|
|
34
|
+
/\b502\b/,
|
|
35
|
+
/\bLLMRequestError\b/i,
|
|
36
|
+
/\bTypeValidationError\b/i,
|
|
37
|
+
/\bnetwork fluctuation\b/i,
|
|
38
|
+
/\btimeout\b/i,
|
|
39
|
+
/\btransient provider failure\b/i,
|
|
40
|
+
/\bnon-standard (payload|response)\b/i,
|
|
41
|
+
/\bupstream returned\b/i,
|
|
42
|
+
/\bmodel processing timeout\b/i,
|
|
43
|
+
];
|
|
44
|
+
export function isClientRecoveryMode(value) {
|
|
45
|
+
return (typeof value === "string" &&
|
|
46
|
+
CLIENT_RECOVERY_MODES.includes(value));
|
|
47
|
+
}
|
|
48
|
+
export function parseClientRecoveryMode(value) {
|
|
49
|
+
if (value === undefined || value === null || value === "")
|
|
50
|
+
return "auto";
|
|
51
|
+
if (isClientRecoveryMode(value))
|
|
52
|
+
return value;
|
|
53
|
+
throw new Error(`init --client-recovery must be one of ${CLIENT_RECOVERY_MODES.join("|")}`);
|
|
54
|
+
}
|
|
55
|
+
function asText(value) {
|
|
56
|
+
if (typeof value === "string")
|
|
57
|
+
return value;
|
|
58
|
+
if (value == null)
|
|
59
|
+
return "";
|
|
60
|
+
try {
|
|
61
|
+
return JSON.stringify(value);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return String(value);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function collectErrorText(error) {
|
|
68
|
+
if (typeof error === "string")
|
|
69
|
+
return error;
|
|
70
|
+
if (!error || typeof error !== "object")
|
|
71
|
+
return asText(error);
|
|
72
|
+
const record = error;
|
|
73
|
+
const parts = [
|
|
74
|
+
asText(record.name),
|
|
75
|
+
asText(record.message),
|
|
76
|
+
asText(record.status),
|
|
77
|
+
asText(record.code),
|
|
78
|
+
asText(record.data),
|
|
79
|
+
];
|
|
80
|
+
return parts.filter(Boolean).join(" ");
|
|
81
|
+
}
|
|
82
|
+
function hasUnknownErrorShape(error) {
|
|
83
|
+
const text = collectErrorText(error);
|
|
84
|
+
if (/\bUnknownError\b/i.test(text))
|
|
85
|
+
return true;
|
|
86
|
+
if (error && typeof error === "object") {
|
|
87
|
+
const record = error;
|
|
88
|
+
if (typeof record.name === "string" && /UnknownError/i.test(record.name))
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
function isStandardApiError(error) {
|
|
94
|
+
if (!error || typeof error !== "object")
|
|
95
|
+
return false;
|
|
96
|
+
const record = error;
|
|
97
|
+
if (typeof record.name === "string" && /^APIError$/i.test(record.name))
|
|
98
|
+
return true;
|
|
99
|
+
if (typeof record.name === "string" && /^(AuthError|PermissionError)$/i.test(record.name)) {
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
function permanentReason(error) {
|
|
105
|
+
if (isStandardApiError(error)) {
|
|
106
|
+
const record = error;
|
|
107
|
+
if (typeof record.name === "string" && /AuthError/i.test(record.name))
|
|
108
|
+
return "auth";
|
|
109
|
+
if (typeof record.name === "string" && /PermissionError/i.test(record.name)) {
|
|
110
|
+
return "permission";
|
|
111
|
+
}
|
|
112
|
+
return "api-error";
|
|
113
|
+
}
|
|
114
|
+
const text = collectErrorText(error);
|
|
115
|
+
for (const entry of PERMANENT_MESSAGE_PATTERNS) {
|
|
116
|
+
if (entry.pattern.test(text))
|
|
117
|
+
return entry.reason;
|
|
118
|
+
}
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
function looksTransient(error) {
|
|
122
|
+
const text = collectErrorText(error);
|
|
123
|
+
if (TRANSIENT_MESSAGE_PATTERNS.some((pattern) => pattern.test(text)))
|
|
124
|
+
return true;
|
|
125
|
+
if (error && typeof error === "object") {
|
|
126
|
+
const record = error;
|
|
127
|
+
if (record.code === 502 || record.status === 502)
|
|
128
|
+
return true;
|
|
129
|
+
if (record.data && typeof record.data === "object") {
|
|
130
|
+
const data = record.data;
|
|
131
|
+
if (data.code === 502 || data.status === 502)
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Decide whether an OpenCode error should be resumed by the project plugin.
|
|
139
|
+
* Only compensates transient UnknownError paths that built-in APIError retry misses.
|
|
140
|
+
*/
|
|
141
|
+
export function classifyTransientUnknownError(error) {
|
|
142
|
+
const permanent = permanentReason(error);
|
|
143
|
+
if (permanent) {
|
|
144
|
+
return {
|
|
145
|
+
resumable: false,
|
|
146
|
+
reason: permanent,
|
|
147
|
+
interaction: PERMANENT_ERROR_INTERACTION,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
if (!hasUnknownErrorShape(error)) {
|
|
151
|
+
return {
|
|
152
|
+
resumable: false,
|
|
153
|
+
reason: "not-unknown-error",
|
|
154
|
+
interaction: PERMANENT_ERROR_INTERACTION,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
if (!looksTransient(error)) {
|
|
158
|
+
return {
|
|
159
|
+
resumable: false,
|
|
160
|
+
reason: "unknown-non-transient",
|
|
161
|
+
interaction: PERMANENT_ERROR_INTERACTION,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
return { resumable: true, reason: "transient-unknown-error" };
|
|
165
|
+
}
|
|
166
|
+
export function nextBackoffMs(attempt) {
|
|
167
|
+
if (!Number.isInteger(attempt) || attempt < 0 || attempt >= BACKOFF_MS.length) {
|
|
168
|
+
return undefined;
|
|
169
|
+
}
|
|
170
|
+
return BACKOFF_MS[attempt];
|
|
171
|
+
}
|
|
172
|
+
export function canResumeSession(input) {
|
|
173
|
+
if (input.sessionStatus === "retry") {
|
|
174
|
+
return { allowed: false, reason: "builtin-retry-active" };
|
|
175
|
+
}
|
|
176
|
+
if (input.locked) {
|
|
177
|
+
return { allowed: false, reason: "session-locked" };
|
|
178
|
+
}
|
|
179
|
+
if (!input.idle || input.sessionStatus === "busy" || input.sessionStatus === "running") {
|
|
180
|
+
return { allowed: false, reason: "session-busy" };
|
|
181
|
+
}
|
|
182
|
+
if (input.attempt >= MAX_SESSION_RETRIES) {
|
|
183
|
+
return { allowed: false, reason: "max-retries" };
|
|
184
|
+
}
|
|
185
|
+
return { allowed: true, reason: "ready" };
|
|
186
|
+
}
|
|
187
|
+
export function buildResumePrompt(input) {
|
|
188
|
+
return [
|
|
189
|
+
`[loop-agent transient recovery] session=${input.sessionId} attempt=${input.attempt}`,
|
|
190
|
+
`Previous model turn failed with a transient UnknownError: ${input.errorSummary}`,
|
|
191
|
+
"Before continuing any work, first inspect already completed tool calls and existing file modifications in this session.",
|
|
192
|
+
"Do not re-run side-effecting tools or rewrite files that already reflect successful prior work.",
|
|
193
|
+
"Resume only the remaining unfinished work after that check.",
|
|
194
|
+
"续接前请先检查本 session 已有工具调用与文件改动,避免重复执行有副作用的操作。",
|
|
195
|
+
].join("\n");
|
|
196
|
+
}
|
|
197
|
+
export function createSessionRecoveryTracker() {
|
|
198
|
+
const states = new Map();
|
|
199
|
+
function getOrCreate(sessionId) {
|
|
200
|
+
let state = states.get(sessionId);
|
|
201
|
+
if (!state) {
|
|
202
|
+
state = { attempt: 0, locked: false };
|
|
203
|
+
states.set(sessionId, state);
|
|
204
|
+
}
|
|
205
|
+
return state;
|
|
206
|
+
}
|
|
207
|
+
return {
|
|
208
|
+
getOrCreate,
|
|
209
|
+
isLocked(sessionId) {
|
|
210
|
+
return getOrCreate(sessionId).locked;
|
|
211
|
+
},
|
|
212
|
+
tryAcquire(sessionId) {
|
|
213
|
+
const state = getOrCreate(sessionId);
|
|
214
|
+
if (state.locked)
|
|
215
|
+
return false;
|
|
216
|
+
state.locked = true;
|
|
217
|
+
return true;
|
|
218
|
+
},
|
|
219
|
+
release(sessionId) {
|
|
220
|
+
const state = getOrCreate(sessionId);
|
|
221
|
+
state.locked = false;
|
|
222
|
+
},
|
|
223
|
+
markSuccess(sessionId) {
|
|
224
|
+
const state = getOrCreate(sessionId);
|
|
225
|
+
state.attempt = 0;
|
|
226
|
+
state.locked = false;
|
|
227
|
+
},
|
|
228
|
+
incrementAttempt(sessionId) {
|
|
229
|
+
const state = getOrCreate(sessionId);
|
|
230
|
+
state.attempt += 1;
|
|
231
|
+
return state.attempt;
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
function isRecord(value) {
|
|
236
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
237
|
+
}
|
|
238
|
+
function readRetryEnabled(existing) {
|
|
239
|
+
if (!existing || !isRecord(existing.retry))
|
|
240
|
+
return undefined;
|
|
241
|
+
if (typeof existing.retry.enabled !== "boolean")
|
|
242
|
+
return undefined;
|
|
243
|
+
return existing.retry.enabled;
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Field-level plan for ~/.pi/agent/settings.json retry recommendations.
|
|
247
|
+
* Never clobbers existing values; never overrides enabled:false.
|
|
248
|
+
*/
|
|
249
|
+
export function planPiRetryMerge(existing) {
|
|
250
|
+
const settingsPath = "~/.pi/agent/settings.json";
|
|
251
|
+
if (existing === undefined) {
|
|
252
|
+
return {
|
|
253
|
+
action: "write",
|
|
254
|
+
reason: "missing-file",
|
|
255
|
+
path: settingsPath,
|
|
256
|
+
next: { retry: { ...PI_RECOMMENDED_RETRY, provider: { ...PI_RECOMMENDED_RETRY.provider } } },
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
if (!isRecord(existing)) {
|
|
260
|
+
return {
|
|
261
|
+
action: "report",
|
|
262
|
+
reason: "invalid-json",
|
|
263
|
+
path: settingsPath,
|
|
264
|
+
message: "Pi settings root must be a JSON object",
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
const enabled = readRetryEnabled(existing);
|
|
268
|
+
if (enabled === false) {
|
|
269
|
+
return {
|
|
270
|
+
action: "report",
|
|
271
|
+
reason: "enabled-false",
|
|
272
|
+
path: settingsPath,
|
|
273
|
+
next: existing,
|
|
274
|
+
message: "retry.enabled=false is preserved; human decision required to enable recovery",
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
const retry = isRecord(existing.retry) ? { ...existing.retry } : {};
|
|
278
|
+
const provider = isRecord(retry.provider) ? { ...retry.provider } : {};
|
|
279
|
+
let changed = !isRecord(existing.retry) || !isRecord(existing.retry.provider);
|
|
280
|
+
if (retry.enabled === undefined) {
|
|
281
|
+
retry.enabled = PI_RECOMMENDED_RETRY.enabled;
|
|
282
|
+
changed = true;
|
|
283
|
+
}
|
|
284
|
+
if (retry.maxRetries === undefined) {
|
|
285
|
+
retry.maxRetries = PI_RECOMMENDED_RETRY.maxRetries;
|
|
286
|
+
changed = true;
|
|
287
|
+
}
|
|
288
|
+
if (retry.baseDelayMs === undefined) {
|
|
289
|
+
retry.baseDelayMs = PI_RECOMMENDED_RETRY.baseDelayMs;
|
|
290
|
+
changed = true;
|
|
291
|
+
}
|
|
292
|
+
if (provider.maxRetries === undefined) {
|
|
293
|
+
provider.maxRetries = PI_RECOMMENDED_RETRY.provider.maxRetries;
|
|
294
|
+
changed = true;
|
|
295
|
+
}
|
|
296
|
+
if (provider.maxRetryDelayMs === undefined) {
|
|
297
|
+
provider.maxRetryDelayMs = PI_RECOMMENDED_RETRY.provider.maxRetryDelayMs;
|
|
298
|
+
changed = true;
|
|
299
|
+
}
|
|
300
|
+
retry.provider = provider;
|
|
301
|
+
const next = { ...existing, retry };
|
|
302
|
+
if (!changed) {
|
|
303
|
+
return {
|
|
304
|
+
action: "noop",
|
|
305
|
+
reason: "matches",
|
|
306
|
+
path: settingsPath,
|
|
307
|
+
next,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
return {
|
|
311
|
+
action: "write",
|
|
312
|
+
reason: "missing-fields",
|
|
313
|
+
path: settingsPath,
|
|
314
|
+
next,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
export function piSettingsPath(homeDir) {
|
|
318
|
+
return path.join(homeDir, ".pi", "agent", "settings.json");
|
|
319
|
+
}
|
|
320
|
+
async function writeJsonAtomicHome(filePath, value) {
|
|
321
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
322
|
+
const tempPath = path.join(path.dirname(filePath), `.${path.basename(filePath)}.${process.pid}.${Date.now()}.tmp`);
|
|
323
|
+
try {
|
|
324
|
+
await writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`, "utf-8");
|
|
325
|
+
await rename(tempPath, filePath);
|
|
326
|
+
}
|
|
327
|
+
catch (error) {
|
|
328
|
+
await rm(tempPath, { force: true }).catch(() => undefined);
|
|
329
|
+
throw error;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
function isMissingFileError(error) {
|
|
333
|
+
return (error instanceof Error &&
|
|
334
|
+
"code" in error &&
|
|
335
|
+
error.code === "ENOENT");
|
|
336
|
+
}
|
|
337
|
+
export async function applyPiRetryMerge(input) {
|
|
338
|
+
const settingsPath = piSettingsPath(input.homeDir);
|
|
339
|
+
let raw;
|
|
340
|
+
try {
|
|
341
|
+
raw = await readFile(settingsPath, "utf-8");
|
|
342
|
+
}
|
|
343
|
+
catch (error) {
|
|
344
|
+
if (!isMissingFileError(error))
|
|
345
|
+
throw error;
|
|
346
|
+
raw = undefined;
|
|
347
|
+
}
|
|
348
|
+
if (raw === undefined) {
|
|
349
|
+
const plan = planPiRetryMerge(undefined);
|
|
350
|
+
if (plan.action === "write" && plan.next) {
|
|
351
|
+
await writeJsonAtomicHome(settingsPath, plan.next);
|
|
352
|
+
return { ...plan, path: settingsPath, wrote: true };
|
|
353
|
+
}
|
|
354
|
+
return { ...plan, path: settingsPath, wrote: false };
|
|
355
|
+
}
|
|
356
|
+
let parsed;
|
|
357
|
+
try {
|
|
358
|
+
parsed = JSON.parse(raw);
|
|
359
|
+
}
|
|
360
|
+
catch {
|
|
361
|
+
return {
|
|
362
|
+
action: "report",
|
|
363
|
+
reason: "invalid-json",
|
|
364
|
+
path: settingsPath,
|
|
365
|
+
wrote: false,
|
|
366
|
+
message: "Pi settings.json is not valid JSON; refusing to write",
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
if (!isRecord(parsed)) {
|
|
370
|
+
return {
|
|
371
|
+
action: "report",
|
|
372
|
+
reason: "invalid-json",
|
|
373
|
+
path: settingsPath,
|
|
374
|
+
wrote: false,
|
|
375
|
+
message: "Pi settings.json root must be a JSON object; refusing to write",
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
const plan = planPiRetryMerge(parsed);
|
|
379
|
+
if (plan.action === "write" && plan.next) {
|
|
380
|
+
await writeJsonAtomicHome(settingsPath, plan.next);
|
|
381
|
+
// Best-effort cleanup of any stray temp files from interrupted prior runs.
|
|
382
|
+
try {
|
|
383
|
+
const dir = path.dirname(settingsPath);
|
|
384
|
+
const entries = await readdir(dir);
|
|
385
|
+
await Promise.all(entries
|
|
386
|
+
.filter((name) => name.startsWith(`.${path.basename(settingsPath)}.`) && name.endsWith(".tmp"))
|
|
387
|
+
.map((name) => rm(path.join(dir, name), { force: true })));
|
|
388
|
+
}
|
|
389
|
+
catch {
|
|
390
|
+
// ignore cleanup failures
|
|
391
|
+
}
|
|
392
|
+
return { ...plan, path: settingsPath, wrote: true };
|
|
393
|
+
}
|
|
394
|
+
return { ...plan, path: settingsPath, wrote: false };
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* Stable generated source for the OpenCode project plugin.
|
|
398
|
+
* The plugin compensates only transient UnknownError after built-in retry is idle.
|
|
399
|
+
*/
|
|
400
|
+
export function buildOpenCodeTransientRetryPluginSource() {
|
|
401
|
+
// Keep this template deterministic: no timestamps, no random ids.
|
|
402
|
+
return `/**
|
|
403
|
+
* loop-agent OpenCode transient recovery plugin
|
|
404
|
+
* Path: ${OPENCODE_TRANSIENT_RETRY_PLUGIN_PATH}
|
|
405
|
+
*
|
|
406
|
+
* Compensates transient UnknownError cases that OpenCode built-in APIError
|
|
407
|
+
* retry does not cover. Never runs concurrently with session.status === "retry".
|
|
408
|
+
* Permanent failures use interaction "${PERMANENT_ERROR_INTERACTION}".
|
|
409
|
+
*/
|
|
410
|
+
const PLUGIN_PATH = ${JSON.stringify(OPENCODE_TRANSIENT_RETRY_PLUGIN_PATH)};
|
|
411
|
+
const PERMANENT_INTERACTION = ${JSON.stringify(PERMANENT_ERROR_INTERACTION)};
|
|
412
|
+
const BACKOFF_MS = ${JSON.stringify([...BACKOFF_MS])};
|
|
413
|
+
const MAX_RETRIES = ${MAX_SESSION_RETRIES};
|
|
414
|
+
|
|
415
|
+
const sessionState = new Map();
|
|
416
|
+
const sessionStatus = new Map();
|
|
417
|
+
|
|
418
|
+
function getState(sessionId) {
|
|
419
|
+
let state = sessionState.get(sessionId);
|
|
420
|
+
if (!state) {
|
|
421
|
+
state = { attempt: 0, locked: false };
|
|
422
|
+
sessionState.set(sessionId, state);
|
|
423
|
+
}
|
|
424
|
+
return state;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function textOf(error) {
|
|
428
|
+
if (!error) return "";
|
|
429
|
+
if (typeof error === "string") return error;
|
|
430
|
+
try {
|
|
431
|
+
return JSON.stringify(error);
|
|
432
|
+
} catch {
|
|
433
|
+
return String(error);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function isPermanent(error) {
|
|
438
|
+
const text = textOf(error);
|
|
439
|
+
if (error && typeof error === "object" && typeof error.name === "string") {
|
|
440
|
+
if (/^APIError$/i.test(error.name)) return true;
|
|
441
|
+
if (/^(AuthError|PermissionError)$/i.test(error.name)) return true;
|
|
442
|
+
}
|
|
443
|
+
return /\\b(401|403|unauthorized|forbidden|quota|rate.?limit|context (length )?overflow|too many tokens|cancelled|canceled|business validation|invalid task|schema validation)\\b/i.test(
|
|
444
|
+
text,
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function isTransientUnknown(error) {
|
|
449
|
+
if (isPermanent(error)) return false;
|
|
450
|
+
const text = textOf(error);
|
|
451
|
+
const hasUnknown = /UnknownError/i.test(text) || (error && /UnknownError/i.test(String(error.name || "")));
|
|
452
|
+
if (!hasUnknown) return false;
|
|
453
|
+
return (
|
|
454
|
+
/\\b(code[:\\s]*502|502|LLMRequestError|TypeValidationError|Type validation failed|network fluctuation|timeout|transient provider failure|non-standard)\\b/i.test(
|
|
455
|
+
text,
|
|
456
|
+
) ||
|
|
457
|
+
(error && (error.code === 502 || error.status === 502))
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function buildResumePrompt(sessionId, error, attempt) {
|
|
462
|
+
return [
|
|
463
|
+
"[loop-agent transient recovery] session=" + sessionId + " attempt=" + attempt,
|
|
464
|
+
"Previous model turn failed with a transient UnknownError: " + textOf(error),
|
|
465
|
+
"Before continuing any work, first inspect already completed tool calls and existing file modifications in this session.",
|
|
466
|
+
"Do not re-run side-effecting tools or rewrite files that already reflect successful prior work.",
|
|
467
|
+
"Resume only the remaining unfinished work after that check.",
|
|
468
|
+
"续接前请先检查本 session 已有工具调用与文件改动,避免重复执行有副作用的操作。",
|
|
469
|
+
].join("\\n");
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
async function wait(ms) {
|
|
473
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function statusType(status) {
|
|
477
|
+
return status && typeof status.type === "string" ? status.type : undefined;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
async function readSessionStatus(client, sessionId) {
|
|
481
|
+
const cached = sessionStatus.get(sessionId);
|
|
482
|
+
if (cached === "retry") return cached;
|
|
483
|
+
try {
|
|
484
|
+
const response = await client.session.status();
|
|
485
|
+
const current = statusType(response?.data?.[sessionId]);
|
|
486
|
+
if (current) sessionStatus.set(sessionId, current);
|
|
487
|
+
return current || cached || "idle";
|
|
488
|
+
} catch {
|
|
489
|
+
return cached || "idle";
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
export default async function loopAgentTransientRetryPlugin({ client, $ }) {
|
|
494
|
+
void $;
|
|
495
|
+
void PLUGIN_PATH;
|
|
496
|
+
|
|
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
|
+
|
|
503
|
+
const state = getState(sessionId);
|
|
504
|
+
if (state.locked) {
|
|
505
|
+
return { interaction: PERMANENT_INTERACTION, reason: "session-locked" };
|
|
506
|
+
}
|
|
507
|
+
if (state.attempt >= MAX_RETRIES) {
|
|
508
|
+
return { interaction: PERMANENT_INTERACTION, reason: "max-retries" };
|
|
509
|
+
}
|
|
510
|
+
|
|
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
|
+
}
|
|
521
|
+
|
|
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({
|
|
533
|
+
path: { id: sessionId },
|
|
534
|
+
body: { parts: [{ type: "text", text: prompt }] },
|
|
535
|
+
});
|
|
536
|
+
return { resumed: true, attempt: state.attempt };
|
|
537
|
+
} finally {
|
|
538
|
+
state.locked = false;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
return {
|
|
543
|
+
event: async ({ event }) => {
|
|
544
|
+
if (!event || typeof event !== "object") return;
|
|
545
|
+
const properties = event.properties || {};
|
|
546
|
+
const sessionId =
|
|
547
|
+
properties.sessionID || properties.sessionId || properties.id;
|
|
548
|
+
|
|
549
|
+
if (event.type === "session.status") {
|
|
550
|
+
const current = statusType(properties.status);
|
|
551
|
+
if (sessionId && current) sessionStatus.set(sessionId, current);
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
if (event.type === "message.updated") {
|
|
556
|
+
const info = properties.info;
|
|
557
|
+
if (
|
|
558
|
+
info?.role === "assistant" &&
|
|
559
|
+
info.sessionID &&
|
|
560
|
+
info.time?.completed != null &&
|
|
561
|
+
info.error == null
|
|
562
|
+
) {
|
|
563
|
+
getState(info.sessionID).attempt = 0;
|
|
564
|
+
}
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
if (event.type === "session.error") {
|
|
569
|
+
return maybeResume(sessionId, properties.error || properties);
|
|
570
|
+
}
|
|
571
|
+
},
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
`;
|
|
575
|
+
}
|
|
576
|
+
export async function installProjectOpenCodePlugin(input) {
|
|
577
|
+
const relativePath = OPENCODE_TRANSIENT_RETRY_PLUGIN_PATH;
|
|
578
|
+
const target = path.join(input.repoRoot, relativePath);
|
|
579
|
+
const content = buildOpenCodeTransientRetryPluginSource();
|
|
580
|
+
if (input.onlyIfMissing) {
|
|
581
|
+
try {
|
|
582
|
+
await readFile(target, "utf-8");
|
|
583
|
+
return { path: relativePath, written: false, reason: "unchanged" };
|
|
584
|
+
}
|
|
585
|
+
catch {
|
|
586
|
+
// missing → write
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
590
|
+
await writeFile(target, content, "utf-8");
|
|
591
|
+
return { path: relativePath, written: true, reason: "written" };
|
|
592
|
+
}
|
|
593
|
+
export async function inspectPiRetryConfig(input) {
|
|
594
|
+
const homeDir = input.homeDir ?? os.homedir();
|
|
595
|
+
const settingsPath = piSettingsPath(homeDir);
|
|
596
|
+
let raw;
|
|
597
|
+
try {
|
|
598
|
+
raw = await readFile(settingsPath, "utf-8");
|
|
599
|
+
}
|
|
600
|
+
catch (error) {
|
|
601
|
+
if (!isMissingFileError(error))
|
|
602
|
+
throw error;
|
|
603
|
+
return {
|
|
604
|
+
action: "report",
|
|
605
|
+
reason: "missing-file",
|
|
606
|
+
path: settingsPath,
|
|
607
|
+
wrote: false,
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
try {
|
|
611
|
+
const parsed = JSON.parse(raw);
|
|
612
|
+
if (!isRecord(parsed)) {
|
|
613
|
+
return {
|
|
614
|
+
action: "report",
|
|
615
|
+
reason: "invalid-json",
|
|
616
|
+
path: settingsPath,
|
|
617
|
+
wrote: false,
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
const plan = planPiRetryMerge(parsed);
|
|
621
|
+
return { ...plan, path: settingsPath, wrote: false };
|
|
622
|
+
}
|
|
623
|
+
catch {
|
|
624
|
+
return {
|
|
625
|
+
action: "report",
|
|
626
|
+
reason: "invalid-json",
|
|
627
|
+
path: settingsPath,
|
|
628
|
+
wrote: false,
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
/**
|
|
633
|
+
* Install project OpenCode plugin and optionally merge Pi user settings.
|
|
634
|
+
* - auto/project: project plugin only (no home writes)
|
|
635
|
+
* - user: project plugin + explicit Pi merge
|
|
636
|
+
* - off: skip all
|
|
637
|
+
*/
|
|
638
|
+
export async function runClientRecovery(input) {
|
|
639
|
+
const mode = input.mode ?? "auto";
|
|
640
|
+
if (mode === "off") {
|
|
641
|
+
return {
|
|
642
|
+
mode,
|
|
643
|
+
plugin: {
|
|
644
|
+
path: OPENCODE_TRANSIENT_RETRY_PLUGIN_PATH,
|
|
645
|
+
written: false,
|
|
646
|
+
reason: "skipped",
|
|
647
|
+
},
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
const plugin = await installProjectOpenCodePlugin({ repoRoot: input.repoRoot });
|
|
651
|
+
if (mode !== "user") {
|
|
652
|
+
return { mode, plugin };
|
|
653
|
+
}
|
|
654
|
+
const homeDir = input.homeDir ?? os.homedir();
|
|
655
|
+
const pi = await applyPiRetryMerge({ homeDir });
|
|
656
|
+
return { mode, plugin, pi };
|
|
657
|
+
}
|
package/dist/commands/init.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { access, copyFile, mkdir, readdir, readFile, rename, rm, rmdir, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
import { isDeepStrictEqual } from "node:util";
|
|
5
6
|
import { fileURLToPath } from "node:url";
|
|
6
7
|
import { copyDir } from "../shared/copy-dir.js";
|
|
7
8
|
import { isInitRuntimeActive, } from "../shared/runtime-activity.js";
|
|
8
9
|
import { loadHarnessManifest } from "../governance/harness.js";
|
|
10
|
+
import { OPENCODE_TRANSIENT_RETRY_PLUGIN_PATH, applyPiRetryMerge, buildOpenCodeTransientRetryPluginSource, inspectPiRetryConfig, parseClientRecoveryMode, runClientRecovery, } from "./client-recovery.js";
|
|
9
11
|
const MANAGED_BLOCK_START = "<!-- LOOP_AGENT_INIT_START -->";
|
|
10
12
|
const MANAGED_BLOCK_END = "<!-- LOOP_AGENT_INIT_END -->";
|
|
11
13
|
const GITIGNORE_BLOCK_START = "# LOOP_AGENT_INIT_START";
|
|
@@ -871,6 +873,9 @@ function buildManagedAgentsBlock(input) {
|
|
|
871
873
|
"- 用户明确提出后端测试、接口/API 测试、pytest,或语境明确为后端的自动化测试时,必须把 `.harness/tasks/<task-id>/task.json` 的 `taskKind` 设置为 `\"backend-test\"`,不得保留默认 `standard`。",
|
|
872
874
|
"- `backend-test` 是 `taskKind`,不是 `--profile` 的可选值;运行 `dag run-task` 时继续使用 `--profile auto`。",
|
|
873
875
|
"- 仅出现“自动化测试”且无法判断前后端时,先阅读任务源与目标项目技术栈再决定,禁止无条件路由到 `backend-test`。",
|
|
876
|
+
"- 用户提示词明确是前端实现需求(例如前端页面、UI、组件或交互开发)时,必须把 `.harness/tasks/<task-id>/task.json` 的 `taskKind` 设置为 `\"frontend-implementation\"`,不得保留默认 `standard`。",
|
|
877
|
+
"- `frontend-implementation` 是 `taskKind`,不是 `--profile` 的可选值;运行 `dag run-task` 时继续使用 `--profile auto`,也可按需显式选择 `minimal` / `standard` / `reviewed` / `supervised`,不要把业务模板名当作 profile。",
|
|
878
|
+
"- 前端自动化测试(浏览器/UI 自动化、Playwright、E2E)继续使用 `taskKind: \"frontend-test\"`,不得设置为 `frontend-implementation`。",
|
|
874
879
|
"",
|
|
875
880
|
"### 运行看板(只读)",
|
|
876
881
|
"",
|
|
@@ -1275,6 +1280,7 @@ function inferInitSurfaceMode(relativePath) {
|
|
|
1275
1280
|
return "managed-block";
|
|
1276
1281
|
}
|
|
1277
1282
|
if (relativePath === "harness.json" ||
|
|
1283
|
+
relativePath === OPENCODE_TRANSIENT_RETRY_PLUGIN_PATH ||
|
|
1278
1284
|
relativePath.startsWith("scripts/") ||
|
|
1279
1285
|
relativePath.startsWith(".harness/prompts/") ||
|
|
1280
1286
|
relativePath.startsWith("docs/README.md") ||
|
|
@@ -1321,6 +1327,9 @@ async function buildDesiredSurfaceContent(input) {
|
|
|
1321
1327
|
}), null, 2)}\n`,
|
|
1322
1328
|
};
|
|
1323
1329
|
}
|
|
1330
|
+
if (manifestPath === OPENCODE_TRANSIENT_RETRY_PLUGIN_PATH) {
|
|
1331
|
+
return { content: buildOpenCodeTransientRetryPluginSource() };
|
|
1332
|
+
}
|
|
1324
1333
|
if (manifestPath.startsWith("scripts/")) {
|
|
1325
1334
|
const scripts = buildInitScriptFiles(input.governanceRoot);
|
|
1326
1335
|
return { content: scripts[manifestPath] };
|
|
@@ -2443,9 +2452,30 @@ export async function initializeLoopAgentProject(options) {
|
|
|
2443
2452
|
}
|
|
2444
2453
|
await ensureHarnessDirs(repoRoot, written);
|
|
2445
2454
|
await writeCompatPrompts({ repoRoot, merge, written, skipped });
|
|
2455
|
+
const clientRecoveryMode = options.clientRecovery ?? "auto";
|
|
2456
|
+
const clientRecovery = clientRecoveryMode === "off"
|
|
2457
|
+
? undefined
|
|
2458
|
+
: await runClientRecovery({
|
|
2459
|
+
repoRoot,
|
|
2460
|
+
mode: clientRecoveryMode,
|
|
2461
|
+
});
|
|
2462
|
+
if (clientRecovery?.plugin.written) {
|
|
2463
|
+
written.push(OPENCODE_TRANSIENT_RETRY_PLUGIN_PATH);
|
|
2464
|
+
}
|
|
2465
|
+
else if (clientRecoveryMode === "off") {
|
|
2466
|
+
skipped.push(OPENCODE_TRANSIENT_RETRY_PLUGIN_PATH);
|
|
2467
|
+
}
|
|
2446
2468
|
await writeInitSurfaceState({ repoRoot, projectName, governanceRoot, stateKind: "recorded" });
|
|
2447
2469
|
written.push(INIT_SURFACE_STATE_PATH);
|
|
2448
|
-
return {
|
|
2470
|
+
return {
|
|
2471
|
+
repoRoot,
|
|
2472
|
+
projectName,
|
|
2473
|
+
governanceRoot,
|
|
2474
|
+
profile,
|
|
2475
|
+
written,
|
|
2476
|
+
skipped,
|
|
2477
|
+
clientRecovery,
|
|
2478
|
+
};
|
|
2449
2479
|
}
|
|
2450
2480
|
function actionForMissing(pathName, state) {
|
|
2451
2481
|
if (state.mode === "state")
|
|
@@ -2642,12 +2672,19 @@ export async function checkInitUpdate(input) {
|
|
|
2642
2672
|
},
|
|
2643
2673
|
};
|
|
2644
2674
|
const recommendedNext = recommendedNextFor(partial);
|
|
2675
|
+
// Pi user config is never part of project surface hash; only report semantics.
|
|
2676
|
+
const piInspection = await inspectPiRetryConfig({
|
|
2677
|
+
homeDir: input.homeDir,
|
|
2678
|
+
});
|
|
2645
2679
|
return {
|
|
2646
2680
|
...partial,
|
|
2647
2681
|
ok: deterministicActions.length === 0 &&
|
|
2648
2682
|
modelMergeTasks.length === 0 &&
|
|
2649
2683
|
humanDecisions.length === 0,
|
|
2650
2684
|
recommendedNext,
|
|
2685
|
+
clientRecovery: {
|
|
2686
|
+
pi: piInspection,
|
|
2687
|
+
},
|
|
2651
2688
|
};
|
|
2652
2689
|
}
|
|
2653
2690
|
async function applySafeAction(input) {
|
|
@@ -2811,6 +2848,7 @@ export async function applyInitUpdate(input) {
|
|
|
2811
2848
|
});
|
|
2812
2849
|
const applied = [];
|
|
2813
2850
|
const skipped = [];
|
|
2851
|
+
const clientRecoveryMode = input.clientRecovery ?? "auto";
|
|
2814
2852
|
if (input.bootstrapSurface) {
|
|
2815
2853
|
await writeInitSurfaceState({ repoRoot, projectName, governanceRoot, stateKind: "inferred-baseline" });
|
|
2816
2854
|
applied.push({
|
|
@@ -2824,7 +2862,13 @@ export async function applyInitUpdate(input) {
|
|
|
2824
2862
|
// Preserve source strength: recorded stays recorded so unchanged owned files
|
|
2825
2863
|
// remain deterministic refresh candidates; bootstrap/inferred stays inferred.
|
|
2826
2864
|
const preservedStateKind = existingSurface?.stateKind === "recorded" ? "recorded" : "inferred-baseline";
|
|
2827
|
-
const report = await checkInitUpdate({
|
|
2865
|
+
const report = await checkInitUpdate({
|
|
2866
|
+
repoRoot,
|
|
2867
|
+
projectName,
|
|
2868
|
+
governanceRoot,
|
|
2869
|
+
clientRecovery: clientRecoveryMode,
|
|
2870
|
+
homeDir: input.homeDir,
|
|
2871
|
+
});
|
|
2828
2872
|
for (const action of report.deterministicActions) {
|
|
2829
2873
|
if (action.type === "bootstrap-surface") {
|
|
2830
2874
|
skipped.push(action);
|
|
@@ -2846,12 +2890,36 @@ export async function applyInitUpdate(input) {
|
|
|
2846
2890
|
preserveOwnershipFrom: existingSurface,
|
|
2847
2891
|
});
|
|
2848
2892
|
}
|
|
2893
|
+
// Pi user config is only mutated with explicit --client-recovery=user.
|
|
2894
|
+
// Do not reinstall the project plugin here — that would bypass ownership and
|
|
2895
|
+
// clobber user-modified plugins; plugin updates stay on deterministic actions.
|
|
2896
|
+
if (clientRecoveryMode === "user") {
|
|
2897
|
+
await applyPiRetryMerge({
|
|
2898
|
+
homeDir: input.homeDir ?? os.homedir(),
|
|
2899
|
+
});
|
|
2900
|
+
}
|
|
2849
2901
|
return {
|
|
2850
2902
|
applied,
|
|
2851
2903
|
skipped,
|
|
2852
|
-
report: await checkInitUpdate({
|
|
2904
|
+
report: await checkInitUpdate({
|
|
2905
|
+
repoRoot,
|
|
2906
|
+
projectName,
|
|
2907
|
+
governanceRoot,
|
|
2908
|
+
clientRecovery: clientRecoveryMode,
|
|
2909
|
+
homeDir: input.homeDir,
|
|
2910
|
+
}),
|
|
2853
2911
|
};
|
|
2854
2912
|
}
|
|
2913
|
+
function formatClientRecoverySummary(report) {
|
|
2914
|
+
if (!report.clientRecovery?.pi)
|
|
2915
|
+
return [];
|
|
2916
|
+
const pi = report.clientRecovery.pi;
|
|
2917
|
+
return [
|
|
2918
|
+
`clientRecovery.pi.reason: ${pi.reason}`,
|
|
2919
|
+
`clientRecovery.pi.action: ${pi.action}`,
|
|
2920
|
+
`clientRecovery.pi.path: ${pi.path}`,
|
|
2921
|
+
];
|
|
2922
|
+
}
|
|
2855
2923
|
function formatCheckUpdateText(report) {
|
|
2856
2924
|
return [
|
|
2857
2925
|
`loop-agent init update check for ${report.repoRoot}`,
|
|
@@ -2860,17 +2928,20 @@ function formatCheckUpdateText(report) {
|
|
|
2860
2928
|
`deterministicActions: ${report.deterministicActions.length}`,
|
|
2861
2929
|
`modelMergeTasks: ${report.modelMergeTasks.length}`,
|
|
2862
2930
|
`humanDecisions: ${report.humanDecisions.length}`,
|
|
2931
|
+
...formatClientRecoverySummary(report),
|
|
2863
2932
|
"recommendedNext:",
|
|
2864
2933
|
...report.recommendedNext.map((item) => `- ${item}`),
|
|
2865
2934
|
].join("\n");
|
|
2866
2935
|
}
|
|
2867
2936
|
function formatCheckUpdateMarkdown(report) {
|
|
2937
|
+
const piLines = formatClientRecoverySummary(report).map((line) => `- ${line}`);
|
|
2868
2938
|
const lines = [
|
|
2869
2939
|
"# loop-agent init check-update",
|
|
2870
2940
|
"",
|
|
2871
2941
|
`- repoRoot: \`${report.repoRoot}\``,
|
|
2872
2942
|
`- controllerVersion: \`${report.controllerVersion}\``,
|
|
2873
2943
|
`- surfaceState: \`${report.surfaceState}\``,
|
|
2944
|
+
...(piLines.length > 0 ? ["", "## Client Recovery (Pi user config, read-only)", "", ...piLines] : []),
|
|
2874
2945
|
"",
|
|
2875
2946
|
"## Deterministic Actions",
|
|
2876
2947
|
"",
|
|
@@ -2971,6 +3042,7 @@ function parseInitArgs(repoRoot, args) {
|
|
|
2971
3042
|
let merge = true;
|
|
2972
3043
|
let provider;
|
|
2973
3044
|
let model;
|
|
3045
|
+
let clientRecovery = "auto";
|
|
2974
3046
|
let json = false;
|
|
2975
3047
|
let markdown = false;
|
|
2976
3048
|
let bootstrapSurface = false;
|
|
@@ -3005,6 +3077,10 @@ function parseInitArgs(repoRoot, args) {
|
|
|
3005
3077
|
model = args[++i];
|
|
3006
3078
|
else if (arg.startsWith("--model="))
|
|
3007
3079
|
model = arg.slice("--model=".length);
|
|
3080
|
+
else if (arg === "--client-recovery")
|
|
3081
|
+
clientRecovery = parseClientRecoveryMode(args[++i]);
|
|
3082
|
+
else if (arg.startsWith("--client-recovery="))
|
|
3083
|
+
clientRecovery = parseClientRecoveryMode(arg.slice("--client-recovery=".length));
|
|
3008
3084
|
else if (arg === "--json")
|
|
3009
3085
|
json = true;
|
|
3010
3086
|
else if (arg === "--markdown")
|
|
@@ -3028,6 +3104,7 @@ function parseInitArgs(repoRoot, args) {
|
|
|
3028
3104
|
merge,
|
|
3029
3105
|
provider,
|
|
3030
3106
|
model,
|
|
3107
|
+
clientRecovery,
|
|
3031
3108
|
subcommand,
|
|
3032
3109
|
json,
|
|
3033
3110
|
markdown,
|
|
@@ -190,6 +190,19 @@ bash scripts/check-skill-entry.sh
|
|
|
190
190
|
|
|
191
191
|
Runtime 变更另需 `npm run typecheck` 及对应 targeted Vitest(见 exec plan 各 Phase 验证关口)。
|
|
192
192
|
|
|
193
|
+
## Client session 瞬态恢复边界
|
|
194
|
+
|
|
195
|
+
主会话模型偶尔会返回非标准 502 / `LLMRequestError` / 网络抖动 / 超时响应;OpenCode 可能先解析为 `TypeValidationError` 再落成 `UnknownError`,导致内置 APIError 重试不命中。loop-agent 通过 **项目级 OpenCode 插件补偿** 与 **Pi 用户级配置显式启用** 处理该缺口,不修改 OpenCode/Pi 上游,也不引入外部监督器。
|
|
196
|
+
|
|
197
|
+
| 面 | 路径 / 入口 | 边界 |
|
|
198
|
+
|---|---|---|
|
|
199
|
+
| OpenCode 项目插件 | `.opencode/plugins/loop-agent-transient-retry.js`(init surface `generated`) | 直接返回真实 `Hooks.event`,分发 `session.error` / `session.status` / `message.updated`;从 `client.session.status()` 的 session map 判断内置 retry,以 `client.session.promptAsync()` 续接同一 session;只有成功完成的 assistant message 清零连续失败计数。认证/权限/配额/上下文溢出/取消/业务错误走 `plugin-ignore-permanent-error` |
|
|
200
|
+
| Pi 用户配置 | `~/.pi/agent/settings.json` | **不**进入项目 `.harness/init-surface.json` hash;仅 `--client-recovery=user` 可字段级补缺并原子写;`auto`/`project`/`off` 与 `check-update` 默认零写 home;读取时只有 `ENOENT` 视为缺文件,其他 I/O 错误 fail closed |
|
|
201
|
+
| CLI mode | `--client-recovery=auto\|project\|user\|off`(默认 `auto`) | `auto`/`project` 只装项目插件;`user` = 项目插件 + 显式 Pi 合并;`off` 全跳过 |
|
|
202
|
+
| Ownership | recorded sha256 + apply-safe | 插件缺失可补、与 recorded hash 一致可升级;用户改过 → model merge / human decision,禁止静默覆盖 |
|
|
203
|
+
|
|
204
|
+
实现落点:`src/commands/client-recovery.ts`(纯逻辑与生成器)+ `src/commands/init.ts`(编排)。
|
|
205
|
+
|
|
193
206
|
## 演进里程碑
|
|
194
207
|
|
|
195
208
|
| Phase | 边界变化 |
|
|
@@ -164,7 +164,8 @@
|
|
|
164
164
|
"docs/templates/backend-test-analysis.schema.json",
|
|
165
165
|
"docs/templates/backend-test-execution.schema.json",
|
|
166
166
|
"docs/templates/backend-test-result.schema.json",
|
|
167
|
-
"docs/templates/backend-test-case-manifest.schema.json"
|
|
167
|
+
"docs/templates/backend-test-case-manifest.schema.json",
|
|
168
|
+
".opencode/plugins/loop-agent-transient-retry.js"
|
|
168
169
|
],
|
|
169
170
|
"initSurface": {
|
|
170
171
|
"README.md": "managed-block",
|
|
@@ -244,7 +245,8 @@
|
|
|
244
245
|
"docs/templates/backend-test-analysis.schema.json": "copied",
|
|
245
246
|
"docs/templates/backend-test-execution.schema.json": "copied",
|
|
246
247
|
"docs/templates/backend-test-result.schema.json": "copied",
|
|
247
|
-
"docs/templates/backend-test-case-manifest.schema.json": "copied"
|
|
248
|
+
"docs/templates/backend-test-case-manifest.schema.json": "copied",
|
|
249
|
+
".opencode/plugins/loop-agent-transient-retry.js": "generated"
|
|
248
250
|
},
|
|
249
251
|
"packageExcluded": [
|
|
250
252
|
"docs/progress/20*.md",
|
|
@@ -264,6 +266,7 @@
|
|
|
264
266
|
"harness.json",
|
|
265
267
|
"package.json",
|
|
266
268
|
"src/commands/init.ts",
|
|
269
|
+
"src/commands/client-recovery.ts",
|
|
267
270
|
"src/cli.ts",
|
|
268
271
|
"src/cli/update/init-surface-notifier.ts",
|
|
269
272
|
"src/cli/update/policy.ts",
|
|
@@ -287,6 +290,7 @@
|
|
|
287
290
|
"description": "Changes that may alter target-project initialization behavior, default DAG role skills, skill resolution, or package/init contracts.",
|
|
288
291
|
"patterns": [
|
|
289
292
|
"src/commands/init.ts",
|
|
293
|
+
"src/commands/client-recovery.ts",
|
|
290
294
|
"src/cli.ts",
|
|
291
295
|
"src/cli/update/init-surface-notifier.ts",
|
|
292
296
|
"src/cli/update/policy.ts",
|
package/package.json
CHANGED
|
@@ -56,9 +56,10 @@ loop-agent run-dag --dag .harness/tasks/<task-id>/dag.json --cwd <repo-root>
|
|
|
56
56
|
6. 主会话不绕过 CLI 直接写业务代码;失败只走 doctor/reconcile/human gate/重跑。
|
|
57
57
|
7. DAG `pi` executor read-only unless `toolProfile: "write"`;completed run facts read-only;不得从 read-only DAG/sidecar 写 root `artifacts/`。
|
|
58
58
|
8. No hidden state in chat only;verify before completion.
|
|
59
|
+
9. Client recovery:`init --client-recovery=auto|project|user|off`;只有 `user` 写 Pi settings;check/update 遵守 ownership。
|
|
59
60
|
|
|
60
61
|
## References
|
|
61
62
|
|
|
62
63
|
Required(按需 inline):`references/harness-policy.md`、`references/hybrid-dag.md`、`references/verification-and-failure-handling.md`
|
|
63
64
|
|
|
64
|
-
Optional
|
|
65
|
+
Optional 索引见 `references/README.md`;常用:`command-reference.md`、`long-running-loop.md`、`orchestrator-and-interventions.md`、`docs-converge.md`。
|