@bolloon/bolloon-agent 0.4.24 → 0.4.26
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/dist/agents/execution-supervisor.js +446 -0
- package/dist/agents/external-events.js +162 -0
- package/dist/agents/goal-criteria.js +124 -0
- package/dist/agents/goal-store.js +526 -0
- package/dist/agents/pi-harness.js +263 -0
- package/dist/agents/pi-sdk.js +607 -126
- package/dist/agents/run-store.js +772 -0
- package/dist/agents/runner-resolver.js +225 -0
- package/dist/agents/skill-readiness.js +133 -0
- package/dist/agents/skill-supervisor-link.js +70 -0
- package/dist/agents/skills-manager.js +717 -0
- package/dist/agents/supervisor-host.js +249 -0
- package/dist/cli/setup-wizard.js +96 -127
- package/dist/cron/tick-lock.js +1 -1
- package/dist/electron/first-run.js +33 -2
- package/dist/electron-build/electron/first-run.js +35 -2
- package/dist/electron-build/electron/first-run.js.map +1 -1
- package/dist/index.js +549 -26
- package/dist/ios/agent-delegate-server.js +58 -12
- package/dist/ios/icons/icon-1024x1024.png +0 -0
- package/dist/ios/icons/icon-1024x1024.webp +0 -0
- package/dist/ios/icons/icon-216x216.png +0 -0
- package/dist/ios/icons/icon-216x216.webp +0 -0
- package/dist/ios/index.html +21 -1
- package/dist/ios/manifest.json +1 -1
- package/dist/ios/mobile-agent.js +195 -1
- package/dist/ios/mobile-core.js +24876 -24723
- package/dist/ios/mobile.css +15 -0
- package/dist/ios/mobile.html +21 -1
- package/dist/ios/mobile.js +143 -0
- package/dist/ios/server.js +51 -4
- package/dist/llm/config-store.js +35 -4
- package/dist/network/agent-network.js +10 -0
- package/dist/network/goal-event-bridge.js +57 -0
- package/dist/setup/onboard.js +549 -0
- package/dist/setup/setup-store.js +592 -0
- package/dist/web/icons/icon-1024x1024.png +0 -0
- package/dist/web/icons/icon-1024x1024.webp +0 -0
- package/dist/web/icons/icon-216x216.png +0 -0
- package/dist/web/icons/icon-216x216.webp +0 -0
- package/dist/web/manifest.json +1 -1
- package/dist/web/mobile-agent.js +2 -2
- package/dist/web/mobile-core.js +24884 -24726
- package/dist/web/mobile-privacy.js +185 -0
- package/dist/web/mobile.css +15 -0
- package/dist/web/mobile.html +21 -1
- package/dist/web/mobile.js +179 -6
- package/dist/web/server.js +633 -0
- package/package.json +2 -2
|
@@ -0,0 +1,772 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* run-store.ts — 持久化 run harness (2026-09-16)
|
|
3
|
+
*
|
|
4
|
+
* 问题 (leo 2026-09-16): web / cli 页面只是**单次执行** —— 一条消息进来跑一遍 agent 循环,
|
|
5
|
+
* 跑完就没了。进程重载 / 刷新页面 / 崩一次, 这次运行的全部状态 (做了什么、做到哪、为什么停)
|
|
6
|
+
* 都不留痕, 也没有任何常驻闸门约束它 (现有 reactHarness 只在单次 prompt 内存里活着)。
|
|
7
|
+
*
|
|
8
|
+
* 这一层补的就是"持久化 + 约束":
|
|
9
|
+
* ① 每次 agent 运行 = 一条落盘记录 ~/.bolloon/runs/<runId>.json (跨进程/跨重载可读)
|
|
10
|
+
* ② 每次工具调用追加一步 (原子写: 临时文件 + rename), 崩了也能看到做到哪一步
|
|
11
|
+
* ③ 预算闸门: maxSteps / deadlineMs 到点必须**如实**结束 (failed/aborted), 不许静默算完成
|
|
12
|
+
* ④ 孤儿对账: 进程启动时把 pid 已死的 running 记录改判 interrupted (不假装还在跑)
|
|
13
|
+
* ⑤ 失速巡检: 常驻巡检把长时间没更新的 running 标 stalled (给 UI/CLI 一个诚实的说法)
|
|
14
|
+
*
|
|
15
|
+
* 记录是**事实**层: 只写实际发生的步骤与结果, 不写"预期/希望"。
|
|
16
|
+
*/
|
|
17
|
+
import * as fs from 'fs/promises';
|
|
18
|
+
import * as fssync from 'fs';
|
|
19
|
+
import * as path from 'path';
|
|
20
|
+
import * as os from 'os';
|
|
21
|
+
import * as crypto from 'crypto';
|
|
22
|
+
/** 合法状态迁移 (协议的一部分: 非法迁移一律拒绝, 防止"偷偷回到 running"这类假状态) */
|
|
23
|
+
export const RUN_TRANSITIONS = {
|
|
24
|
+
queued: ['running', 'aborted', 'interrupted'],
|
|
25
|
+
running: ['recovering', 'paused', 'awaiting_external', 'done', 'failed', 'aborted', 'interrupted', 'stalled', 'needs_human'],
|
|
26
|
+
recovering: ['running', 'failed', 'aborted', 'needs_human', 'interrupted', 'stalled'],
|
|
27
|
+
paused: ['running', 'aborted', 'interrupted'],
|
|
28
|
+
awaiting_external: ['running', 'failed', 'aborted', 'interrupted', 'stalled'],
|
|
29
|
+
done: [],
|
|
30
|
+
failed: [],
|
|
31
|
+
aborted: [],
|
|
32
|
+
interrupted: ['recovering', 'aborted'], // 允许"从 checkpoint 恢复"
|
|
33
|
+
stalled: ['recovering', 'aborted', 'needs_human'],
|
|
34
|
+
needs_human: ['running', 'aborted'],
|
|
35
|
+
};
|
|
36
|
+
export function canTransition(from, to) {
|
|
37
|
+
if (from === to)
|
|
38
|
+
return true;
|
|
39
|
+
return (RUN_TRANSITIONS[from] || []).includes(to);
|
|
40
|
+
}
|
|
41
|
+
export const MAX_HARNESS_EVENTS = 50;
|
|
42
|
+
const DEFAULT_HARNESS = {
|
|
43
|
+
maxSteps: Number(process.env.BOLLOON_RUN_MAX_STEPS || 60),
|
|
44
|
+
deadlineMs: Number(process.env.BOLLOON_RUN_DEADLINE_MS || 30 * 60_000),
|
|
45
|
+
staleMs: Number(process.env.BOLLOON_RUN_STALE_MS || 120_000),
|
|
46
|
+
persistence: process.env.BOLLOON_RUN_PERSIST === 'degraded' ? 'degraded' : 'strict',
|
|
47
|
+
lockStaleMs: Number(process.env.BOLLOON_RUN_LOCK_STALE_MS || 15_000),
|
|
48
|
+
};
|
|
49
|
+
export function runsDir() {
|
|
50
|
+
return path.join(os.homedir(), '.bolloon', 'runs');
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* 核心持久化失败 (不是"工具失败"): run 记录写不进去 = 这次运行在事实层面不存在。
|
|
54
|
+
* 抛这个错的意义是让调用方**停**, 而不是 warn 之后继续跑 (那正是"agent 实际运行了但没记录"的来源)。
|
|
55
|
+
*/
|
|
56
|
+
export class RunPersistenceError extends Error {
|
|
57
|
+
op;
|
|
58
|
+
runId;
|
|
59
|
+
underlying;
|
|
60
|
+
constructor(op, message, runId, underlying) {
|
|
61
|
+
super(message);
|
|
62
|
+
this.name = 'RunPersistenceError';
|
|
63
|
+
this.op = op;
|
|
64
|
+
this.runId = runId;
|
|
65
|
+
this.underlying = underlying;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/** 降级日志位置: 主 (runs 目录内) + 兜底 (runs 目录写不进去时也能留痕, 例: 盘满/只读) */
|
|
69
|
+
function degradationPaths() {
|
|
70
|
+
return [
|
|
71
|
+
path.join(runsDir(), '_degradations.jsonl'),
|
|
72
|
+
path.join(os.homedir(), '.bolloon', 'run-degradations.jsonl'),
|
|
73
|
+
];
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* 记一条降级 (append-only jsonl)。
|
|
77
|
+
* 这是"如实"底线: 观测/核心写失败可以不让运行崩, 但绝不允许静默消失。
|
|
78
|
+
* 主路径写不进去 (典型: runs 目录只读/盘满 —— 正是最需要留痕的时候) → 退到 runs 目录之外再试一次。
|
|
79
|
+
*/
|
|
80
|
+
export async function recordDegradation(d) {
|
|
81
|
+
const line = JSON.stringify({ ts: new Date().toISOString(), ...d }) + '\n';
|
|
82
|
+
const paths = degradationPaths();
|
|
83
|
+
for (const p of paths) {
|
|
84
|
+
try {
|
|
85
|
+
await fs.mkdir(path.dirname(p), { recursive: true });
|
|
86
|
+
await fs.appendFile(p, line, 'utf8');
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
catch { /* 试下一个位置 */ }
|
|
90
|
+
}
|
|
91
|
+
// 哪都写不进去: 只能打到 stderr (绝不抛, 免得把"报告失败"变成"引发失败")
|
|
92
|
+
console.error('[run-store] 降级日志写入失败:', line.trim());
|
|
93
|
+
}
|
|
94
|
+
export async function listDegradations(limit = 20) {
|
|
95
|
+
const all = [];
|
|
96
|
+
for (const p of degradationPaths()) {
|
|
97
|
+
try {
|
|
98
|
+
const raw = await fs.readFile(p, 'utf8');
|
|
99
|
+
for (const l of raw.split('\n')) {
|
|
100
|
+
if (!l.trim())
|
|
101
|
+
continue;
|
|
102
|
+
try {
|
|
103
|
+
all.push(JSON.parse(l));
|
|
104
|
+
}
|
|
105
|
+
catch { /* 坏行跳过 */ }
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
catch { /* 位置不存在 */ }
|
|
109
|
+
}
|
|
110
|
+
all.sort((a, b) => (a.ts < b.ts ? 1 : -1)); // 新的在前
|
|
111
|
+
return all.slice(0, limit);
|
|
112
|
+
}
|
|
113
|
+
async function persistenceMode() {
|
|
114
|
+
return (await readHarnessConfig()).persistence;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* 核心写入的统一入口: 失败 → 记降级; strict 模式下抛 RunPersistenceError 让调用方停。
|
|
118
|
+
* 所有"改 run 状态"的操作都必须走这里, 否则又会出现"某条路径 fail-open"。
|
|
119
|
+
*/
|
|
120
|
+
async function coreWrite(op, runId, fn) {
|
|
121
|
+
try {
|
|
122
|
+
return await fn();
|
|
123
|
+
}
|
|
124
|
+
catch (err) {
|
|
125
|
+
const message = `${op} 失败: ${String(err?.message || err).slice(0, 200)}`;
|
|
126
|
+
await recordDegradation({ kind: 'core', op, runId, message });
|
|
127
|
+
if ((await persistenceMode()) === 'strict') {
|
|
128
|
+
throw new RunPersistenceError(op, message, runId, err);
|
|
129
|
+
}
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/** 单次运行的预算 (可从 ~/.bolloon/harness.json 覆盖) */
|
|
134
|
+
export async function readHarnessConfig() {
|
|
135
|
+
try {
|
|
136
|
+
const raw = await fs.readFile(path.join(os.homedir(), '.bolloon', 'harness.json'), 'utf8');
|
|
137
|
+
const j = JSON.parse(raw);
|
|
138
|
+
return {
|
|
139
|
+
maxSteps: Number(j.maxSteps) > 0 ? Number(j.maxSteps) : DEFAULT_HARNESS.maxSteps,
|
|
140
|
+
deadlineMs: Number(j.deadlineMs) > 0 ? Number(j.deadlineMs) : DEFAULT_HARNESS.deadlineMs,
|
|
141
|
+
staleMs: Number(j.staleMs) > 0 ? Number(j.staleMs) : DEFAULT_HARNESS.staleMs,
|
|
142
|
+
persistence: j.persistence === 'degraded' ? 'degraded' : DEFAULT_HARNESS.persistence,
|
|
143
|
+
lockStaleMs: Number(j.lockStaleMs) > 0 ? Number(j.lockStaleMs) : DEFAULT_HARNESS.lockStaleMs,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return { ...DEFAULT_HARNESS };
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
function runPath(runId) {
|
|
151
|
+
return path.join(runsDir(), `${runId}.json`);
|
|
152
|
+
}
|
|
153
|
+
function backupPath(runId) {
|
|
154
|
+
return `${runPath(runId)}.bak`;
|
|
155
|
+
}
|
|
156
|
+
function lockPath(runId) {
|
|
157
|
+
return path.join(runsDir(), `${runId}.lock`);
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* 原子写 + 保留最后一份有效备份 (2026-09-16 Milestone 1)。
|
|
161
|
+
* .bak = 上一次**能被 JSON.parse 的成功内容** —— 损坏回退的载体 (不是简单复制, 坏内容不会被留成备份)。
|
|
162
|
+
*/
|
|
163
|
+
async function writeRun(rec) {
|
|
164
|
+
await fs.mkdir(runsDir(), { recursive: true });
|
|
165
|
+
const p = runPath(rec.runId);
|
|
166
|
+
const tmp = `${p}.${process.pid}.tmp`;
|
|
167
|
+
const body = JSON.stringify(rec, null, 2);
|
|
168
|
+
try {
|
|
169
|
+
const prev = await fs.readFile(p, 'utf8');
|
|
170
|
+
JSON.parse(prev); // 只有解析得开才值得当备份
|
|
171
|
+
await fs.writeFile(backupPath(rec.runId), prev, 'utf8');
|
|
172
|
+
}
|
|
173
|
+
catch { /* 没有上一版 / 上一版已损坏 → 不覆盖已有备份 */ }
|
|
174
|
+
await fs.writeFile(tmp, body, 'utf8');
|
|
175
|
+
await fs.rename(tmp, p);
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* 读 run 记录。文件损坏时回退到最后一份有效备份 (.bak), 并把这次修复记成一条 corrupt_state 恢复事件
|
|
179
|
+
* (协议要求: 损坏 → 用最后有效状态 + 记数据修复事件, 而不是当成"没有这条运行")。
|
|
180
|
+
*/
|
|
181
|
+
export async function readRun(runId) {
|
|
182
|
+
const p = runPath(runId);
|
|
183
|
+
try {
|
|
184
|
+
return JSON.parse(await fs.readFile(p, 'utf8'));
|
|
185
|
+
}
|
|
186
|
+
catch (err) {
|
|
187
|
+
const isMissing = err?.code === 'ENOENT';
|
|
188
|
+
if (isMissing)
|
|
189
|
+
return null;
|
|
190
|
+
try {
|
|
191
|
+
const recovered = JSON.parse(await fs.readFile(backupPath(runId), 'utf8'));
|
|
192
|
+
recovered.recovery = recovered.recovery || [];
|
|
193
|
+
recovered.recovery.push({
|
|
194
|
+
ts: new Date().toISOString(),
|
|
195
|
+
errorClass: 'corrupt_state',
|
|
196
|
+
message: `运行记录损坏, 已回退到最后一份有效备份 (.bak): ${String(err?.message || err).slice(0, 150)}`,
|
|
197
|
+
action: 'resume',
|
|
198
|
+
attempt: recovered.recovery.length + 1,
|
|
199
|
+
recovered: true,
|
|
200
|
+
});
|
|
201
|
+
recovered.updatedAt = new Date().toISOString();
|
|
202
|
+
await writeRun(recovered); // 用最后有效状态把主文件修回来
|
|
203
|
+
await recordDegradation({ kind: 'core', op: 'readRun.repair', runId, message: '记录损坏, 已用 .bak 修复' });
|
|
204
|
+
return recovered;
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
await recordDegradation({ kind: 'core', op: 'readRun', runId, message: `记录损坏且无有效备份: ${String(err?.message || err).slice(0, 150)}` });
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
export async function listRuns(opts = {}) {
|
|
213
|
+
let files = [];
|
|
214
|
+
try {
|
|
215
|
+
files = await fs.readdir(runsDir());
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
return [];
|
|
219
|
+
}
|
|
220
|
+
const out = [];
|
|
221
|
+
for (const f of files) {
|
|
222
|
+
if (!f.endsWith('.json'))
|
|
223
|
+
continue;
|
|
224
|
+
const r = await readRun(f.replace(/\.json$/, ''));
|
|
225
|
+
if (!r)
|
|
226
|
+
continue;
|
|
227
|
+
if (opts.status) {
|
|
228
|
+
const want = Array.isArray(opts.status) ? opts.status : [opts.status];
|
|
229
|
+
if (!want.includes(r.status))
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
out.push(r);
|
|
233
|
+
}
|
|
234
|
+
out.sort((a, b) => (a.startedAt < b.startedAt ? 1 : -1));
|
|
235
|
+
return typeof opts.limit === 'number' ? out.slice(0, opts.limit) : out;
|
|
236
|
+
}
|
|
237
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
238
|
+
// 2026-09-16 (Milestone 1): 同一 run 的写入串行化 —— 进程内 promise 链 + 跨进程 lock 文件。
|
|
239
|
+
// 没有这一层, 并发 recordStep 是 read-modify-write 竞态: 后写的会覆盖先写的步骤 (丢步)。
|
|
240
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
241
|
+
const runLocks = new Map();
|
|
242
|
+
/** 拿跨进程锁: `wx` 独占创建; 持有者已死或锁已陈旧 → 回收后重试 (只删陈旧的那把) */
|
|
243
|
+
async function acquireFileLock(runId) {
|
|
244
|
+
await fs.mkdir(runsDir(), { recursive: true });
|
|
245
|
+
const lp = lockPath(runId);
|
|
246
|
+
const cfg = await readHarnessConfig();
|
|
247
|
+
const deadline = Date.now() + 5_000;
|
|
248
|
+
for (;;) {
|
|
249
|
+
try {
|
|
250
|
+
const fh = await fs.open(lp, 'wx');
|
|
251
|
+
await fh.writeFile(JSON.stringify({ pid: process.pid, host: os.hostname(), ts: new Date().toISOString() }), 'utf8');
|
|
252
|
+
await fh.close();
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
catch (err) {
|
|
256
|
+
const code = err?.code;
|
|
257
|
+
// 拿不到锁文件本身 (EACCES/ENOSPC/EROFS...) = 这块盘写不了 → 必须让调用方知道这是持久化失败,
|
|
258
|
+
// 不能退化成一个"普通异常"被上层当成偶发错误吞掉 (否则又是一条隐形 fail-open 路径)。
|
|
259
|
+
if (code !== 'EEXIST') {
|
|
260
|
+
throw new RunPersistenceError('acquireFileLock', `run 锁不可创建 (${code || 'unknown'}): ${String(err?.message || err).slice(0, 150)}`, runId, err);
|
|
261
|
+
}
|
|
262
|
+
let stale = false;
|
|
263
|
+
try {
|
|
264
|
+
const info = JSON.parse(await fs.readFile(lp, 'utf8'));
|
|
265
|
+
stale = !pidAlive(Number(info?.pid)) || Date.now() - Date.parse(String(info?.ts || '')) > cfg.lockStaleMs;
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
stale = true;
|
|
269
|
+
} // 锁文件本身坏了 → 当作陈旧
|
|
270
|
+
if (stale) {
|
|
271
|
+
await recordDegradation({ kind: 'observational', op: 'run-lock.reclaim', runId, message: '回收陈旧 run 锁' });
|
|
272
|
+
await fs.rm(lp, { force: true });
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
if (Date.now() > deadline)
|
|
276
|
+
throw new RunPersistenceError('withRunLock', `另一个进程正持有 run 锁: ${runId}`, runId);
|
|
277
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
/** 只释放自己持有的锁 (进程死了会被 acquireFileLock 按 pid 判定为陈旧回收) */
|
|
282
|
+
async function releaseFileLock(runId) {
|
|
283
|
+
try {
|
|
284
|
+
const info = JSON.parse(await fs.readFile(lockPath(runId), 'utf8'));
|
|
285
|
+
if (Number(info?.pid) === process.pid)
|
|
286
|
+
await fs.rm(lockPath(runId), { force: true });
|
|
287
|
+
}
|
|
288
|
+
catch { /* 不存在/已坏 → 无需处理 */ }
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* 在 run 锁内执行一段读改写 (并发写入不覆盖步骤)。
|
|
292
|
+
* 拿不到锁也按同一套等级处理: strict → 抛 (停); degraded → 记降级后**不加锁继续** (仅明确选择降级的弱环境)。
|
|
293
|
+
*/
|
|
294
|
+
export async function withRunLock(runId, fn) {
|
|
295
|
+
const prev = runLocks.get(runId) || Promise.resolve();
|
|
296
|
+
const mine = prev.catch(() => { }).then(async () => {
|
|
297
|
+
let locked = false;
|
|
298
|
+
try {
|
|
299
|
+
await acquireFileLock(runId);
|
|
300
|
+
locked = true;
|
|
301
|
+
}
|
|
302
|
+
catch (err) {
|
|
303
|
+
const message = `run 锁获取失败: ${String(err?.message || err).slice(0, 160)}`;
|
|
304
|
+
await recordDegradation({ kind: 'core', op: 'withRunLock', runId, message });
|
|
305
|
+
if ((await persistenceMode()) === 'strict') {
|
|
306
|
+
throw err instanceof RunPersistenceError ? err : new RunPersistenceError('withRunLock', message, runId, err);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
try {
|
|
310
|
+
return await fn();
|
|
311
|
+
}
|
|
312
|
+
finally {
|
|
313
|
+
if (locked)
|
|
314
|
+
await releaseFileLock(runId);
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
const tail = mine.catch(() => { });
|
|
318
|
+
runLocks.set(runId, tail);
|
|
319
|
+
try {
|
|
320
|
+
return await mine;
|
|
321
|
+
}
|
|
322
|
+
finally {
|
|
323
|
+
if (runLocks.get(runId) === tail)
|
|
324
|
+
runLocks.delete(runId);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
export async function startRun(opts) {
|
|
328
|
+
const cfg = await readHarnessConfig();
|
|
329
|
+
const now = new Date().toISOString();
|
|
330
|
+
const rec = {
|
|
331
|
+
runId: `${Date.now().toString(36)}-${crypto.randomBytes(3).toString('hex')}`,
|
|
332
|
+
goalId: opts.goalId,
|
|
333
|
+
surface: opts.surface,
|
|
334
|
+
goal: String(opts.goal || '').slice(0, 500),
|
|
335
|
+
channelId: opts.channelId,
|
|
336
|
+
agentId: opts.agentId,
|
|
337
|
+
sessionKey: opts.sessionKey,
|
|
338
|
+
pid: process.pid,
|
|
339
|
+
host: os.hostname(),
|
|
340
|
+
startedAt: now,
|
|
341
|
+
updatedAt: now,
|
|
342
|
+
status: 'running',
|
|
343
|
+
steps: [],
|
|
344
|
+
budget: { maxSteps: cfg.maxSteps, deadlineMs: cfg.deadlineMs },
|
|
345
|
+
recovery: [],
|
|
346
|
+
};
|
|
347
|
+
await coreWrite('startRun', rec.runId, () => writeRun(rec));
|
|
348
|
+
return rec;
|
|
349
|
+
}
|
|
350
|
+
/** 错误分类 (Phase 3 表): 决定默认恢复动作, 是"决策的事实"而不是猜测 */
|
|
351
|
+
export function classifyError(message) {
|
|
352
|
+
const m = String(message || '').toLowerCase();
|
|
353
|
+
// 注意顺序: "无响应/504" 属外部等待 (awaiting_external), 不能先被 transient 吃掉
|
|
354
|
+
if (/(external|no reply|无响应|对端|peer.*no|504)/.test(m))
|
|
355
|
+
return 'external_no_reply';
|
|
356
|
+
// 持久化写失败 (run 记录本身写不进去) 优先于鉴权/网络判断: 它的处置是"停", 不是"重试"
|
|
357
|
+
if (/(run-store|run 记录|run 锁|记录损坏|persist_failed|持久化)/.test(m))
|
|
358
|
+
return 'persist_failed';
|
|
359
|
+
// 进程崩了/被杀 (启动对账判的 interrupted) → 从最近 checkpoint 恢复
|
|
360
|
+
if (/(进程 \d+ 已不在|process .*\b(gone|dead)\b|崩溃|crash|killed)/.test(m))
|
|
361
|
+
return 'crash';
|
|
362
|
+
if (/(401|403|402|unauthori[sz]ed|invalid api key|authentication fails|permission denied|鉴权|api.?key)/.test(m))
|
|
363
|
+
return 'auth';
|
|
364
|
+
if (/(429|rate limit|timeout|timed out|econnreset|etimedout|temporarily|503|502|网络|抖动)/.test(m))
|
|
365
|
+
return 'transient';
|
|
366
|
+
if (/(invalid argument|缺少参数|bad args|参数)/.test(m))
|
|
367
|
+
return 'bad_args';
|
|
368
|
+
if (/(no such tool|unknown tool|not found|能力不匹配|no-capability-match)/.test(m))
|
|
369
|
+
return 'no_such_tool';
|
|
370
|
+
if (/(denied|拒绝|blocked|denylist|policy|gate)/.test(m))
|
|
371
|
+
return 'policy_denied';
|
|
372
|
+
if (/(unparsable|parse|解析失败|no tool call)/.test(m))
|
|
373
|
+
return 'unparsable';
|
|
374
|
+
return 'unknown';
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* 状态迁移 (带校验): 非法迁移直接拒绝, 避免"偷偷回到 running"这种假状态。
|
|
378
|
+
* 写入失败在 strict 模式下抛 RunPersistenceError (调用方必须停)。
|
|
379
|
+
*/
|
|
380
|
+
export async function setRunStatus(runId, to, patch = {}) {
|
|
381
|
+
return withRunLock(runId, () => coreWrite('setRunStatus', runId, async () => {
|
|
382
|
+
const rec = await readRun(runId);
|
|
383
|
+
if (!rec)
|
|
384
|
+
return { ok: false, reason: `run 不存在: ${runId}` };
|
|
385
|
+
if (!canTransition(rec.status, to)) {
|
|
386
|
+
return { ok: false, reason: `非法状态迁移 ${rec.status} → ${to}` };
|
|
387
|
+
}
|
|
388
|
+
rec.status = to;
|
|
389
|
+
if (patch.summary)
|
|
390
|
+
rec.summary = String(patch.summary).replace(/\s+/g, ' ').slice(0, 800);
|
|
391
|
+
if (patch.error) {
|
|
392
|
+
rec.error = String(patch.error).replace(/\s+/g, ' ').slice(0, 400);
|
|
393
|
+
rec.errorClass = patch.errorClass || classifyError(patch.error);
|
|
394
|
+
}
|
|
395
|
+
if (patch.evidence)
|
|
396
|
+
rec.evidence = patch.evidence.slice(0, 20);
|
|
397
|
+
rec.updatedAt = new Date().toISOString();
|
|
398
|
+
await writeRun(rec);
|
|
399
|
+
return { ok: true, record: rec };
|
|
400
|
+
}));
|
|
401
|
+
}
|
|
402
|
+
/** 写 checkpoint (恢复入口: 做到哪、下一步是什么) */
|
|
403
|
+
export async function saveCheckpoint(runId, cp) {
|
|
404
|
+
return withRunLock(runId, () => coreWrite('saveCheckpoint', runId, async () => {
|
|
405
|
+
const rec = await readRun(runId);
|
|
406
|
+
if (!rec)
|
|
407
|
+
return null;
|
|
408
|
+
rec.checkpoint = { ...cp, ts: new Date().toISOString() };
|
|
409
|
+
rec.updatedAt = new Date().toISOString();
|
|
410
|
+
await writeRun(rec);
|
|
411
|
+
return rec;
|
|
412
|
+
}));
|
|
413
|
+
}
|
|
414
|
+
/** 记一次恢复尝试 (Phase 3: 分类/策略/前后 checkpoint/是否改计划/是否恢复) */
|
|
415
|
+
export async function recordRecovery(runId, attempt) {
|
|
416
|
+
return withRunLock(runId, () => coreWrite('recordRecovery', runId, async () => {
|
|
417
|
+
const rec = await readRun(runId);
|
|
418
|
+
if (!rec)
|
|
419
|
+
return null;
|
|
420
|
+
rec.recovery = rec.recovery || [];
|
|
421
|
+
rec.recovery.push({
|
|
422
|
+
ts: new Date().toISOString(),
|
|
423
|
+
attempt: attempt.attempt ?? rec.recovery.length + 1,
|
|
424
|
+
...attempt,
|
|
425
|
+
});
|
|
426
|
+
rec.updatedAt = new Date().toISOString();
|
|
427
|
+
await writeRun(rec);
|
|
428
|
+
return rec;
|
|
429
|
+
}));
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* 记一条 Harness 生命周期事件 (Milestone 1-B)。
|
|
433
|
+
*
|
|
434
|
+
* 刻意用**观测级**写入: 决策已经做出了, 记账失败绝不改变决策 (记录是账, 不是闸)。
|
|
435
|
+
* 但失败会落降级日志 —— 不允许"没记上"被当成"没发生"。
|
|
436
|
+
*/
|
|
437
|
+
export async function recordHarnessEvent(runId, evt) {
|
|
438
|
+
try {
|
|
439
|
+
await withRunLock(runId, async () => {
|
|
440
|
+
const rec = await readRun(runId);
|
|
441
|
+
if (!rec)
|
|
442
|
+
return;
|
|
443
|
+
rec.harness = rec.harness || [];
|
|
444
|
+
rec.harness.push(evt);
|
|
445
|
+
if (rec.harness.length > MAX_HARNESS_EVENTS) {
|
|
446
|
+
rec.harness = rec.harness.slice(-MAX_HARNESS_EVENTS); // 审计账本: 只留最近 N 条
|
|
447
|
+
}
|
|
448
|
+
rec.updatedAt = new Date().toISOString();
|
|
449
|
+
await writeRun(rec);
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
catch (err) {
|
|
453
|
+
await recordDegradation({ kind: 'observational', op: 'recordHarnessEvent', runId, message: `${evt.event}/${evt.kind} 写入失败: ${String(err?.message || err).slice(0, 150)}` });
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
/** 防止"同一个工具 + 同一组参数"无限重试: 返回该指纹最近的连续失败次数 */
|
|
457
|
+
export function repeatedFailureCount(rec, tool, argsDigest) {
|
|
458
|
+
let n = 0;
|
|
459
|
+
for (let i = rec.steps.length - 1; i >= 0; i--) {
|
|
460
|
+
const s = rec.steps[i];
|
|
461
|
+
if (s.tool !== tool)
|
|
462
|
+
break;
|
|
463
|
+
if (argsDigest && s.argsDigest && s.argsDigest !== argsDigest)
|
|
464
|
+
break;
|
|
465
|
+
if (s.ok)
|
|
466
|
+
break;
|
|
467
|
+
n++;
|
|
468
|
+
}
|
|
469
|
+
return n;
|
|
470
|
+
}
|
|
471
|
+
function digest(v) {
|
|
472
|
+
try {
|
|
473
|
+
const s = typeof v === 'string' ? v : JSON.stringify(v);
|
|
474
|
+
return String(s || '').replace(/\s+/g, ' ').slice(0, 160);
|
|
475
|
+
}
|
|
476
|
+
catch {
|
|
477
|
+
return '';
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
/** 工具参数指纹 (与 Step.argsDigest 同一算法) — 恢复重放守卫要靠它比对"同一次调用" */
|
|
481
|
+
export function argsDigestOf(v) {
|
|
482
|
+
return digest(v);
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* 追加一步 (工具调用后立即落盘) —— 崩在这里也能看到做到哪一步。
|
|
486
|
+
* 锁内读改写: 并发调用不会互相覆盖步骤。
|
|
487
|
+
*/
|
|
488
|
+
export async function recordStep(runId, step) {
|
|
489
|
+
return withRunLock(runId, () => coreWrite('recordStep', runId, async () => {
|
|
490
|
+
const rec = await readRun(runId);
|
|
491
|
+
if (!rec)
|
|
492
|
+
return null;
|
|
493
|
+
rec.steps.push({
|
|
494
|
+
n: rec.steps.length + 1,
|
|
495
|
+
ts: new Date().toISOString(),
|
|
496
|
+
tool: step.tool,
|
|
497
|
+
argsDigest: step.args === undefined ? undefined : digest(step.args),
|
|
498
|
+
ok: step.ok,
|
|
499
|
+
ms: step.ms,
|
|
500
|
+
summary: step.summary ? String(step.summary).replace(/\s+/g, ' ').slice(0, 200) : undefined,
|
|
501
|
+
error: step.error ? String(step.error).replace(/\s+/g, ' ').slice(0, 200) : undefined,
|
|
502
|
+
});
|
|
503
|
+
rec.updatedAt = new Date().toISOString();
|
|
504
|
+
// 每步自动写 checkpoint (恢复入口: 做到哪一步 + 下一步从哪接)
|
|
505
|
+
rec.checkpoint = {
|
|
506
|
+
completedActions: rec.steps.length,
|
|
507
|
+
pendingAction: step.tool,
|
|
508
|
+
nextAction: '由这一步的结果决定 (恢复时先读最近一步的 summary/error)',
|
|
509
|
+
contextRef: rec.sessionKey || rec.channelId || rec.agentId,
|
|
510
|
+
ts: rec.updatedAt,
|
|
511
|
+
};
|
|
512
|
+
await writeRun(rec);
|
|
513
|
+
return rec;
|
|
514
|
+
}));
|
|
515
|
+
}
|
|
516
|
+
export async function finishRun(runId, patch) {
|
|
517
|
+
return withRunLock(runId, () => coreWrite('finishRun', runId, () => finishRunUnlocked(runId, patch)));
|
|
518
|
+
}
|
|
519
|
+
/** 锁内版本 (给 reconcileOrphans / superviseRuns 等已经持有锁的路径用) */
|
|
520
|
+
async function finishRunUnlocked(runId, patch) {
|
|
521
|
+
const rec = await readRun(runId);
|
|
522
|
+
if (!rec)
|
|
523
|
+
return null;
|
|
524
|
+
if (!canTransition(rec.status, patch.status))
|
|
525
|
+
return null; // 非法迁移拒绝 (协议约束)
|
|
526
|
+
rec.status = patch.status;
|
|
527
|
+
rec.summary = patch.summary ? String(patch.summary).replace(/\s+/g, ' ').slice(0, 800) : rec.summary;
|
|
528
|
+
if (patch.error) {
|
|
529
|
+
rec.error = String(patch.error).replace(/\s+/g, ' ').slice(0, 400);
|
|
530
|
+
rec.errorClass = classifyError(patch.error);
|
|
531
|
+
}
|
|
532
|
+
if (patch.evidence)
|
|
533
|
+
rec.evidence = patch.evidence.slice(0, 20);
|
|
534
|
+
rec.updatedAt = new Date().toISOString();
|
|
535
|
+
await writeRun(rec);
|
|
536
|
+
return rec;
|
|
537
|
+
}
|
|
538
|
+
/** 预算闸门: 超了必须如实结束 (调用方负责把结论写回去) */
|
|
539
|
+
export function budgetVerdict(rec, now = Date.now()) {
|
|
540
|
+
if (rec.steps.length >= rec.budget.maxSteps) {
|
|
541
|
+
return { exceeded: true, reason: `步数预算用尽 (${rec.steps.length}/${rec.budget.maxSteps})` };
|
|
542
|
+
}
|
|
543
|
+
const elapsed = now - Date.parse(rec.startedAt);
|
|
544
|
+
if (elapsed > rec.budget.deadlineMs) {
|
|
545
|
+
return { exceeded: true, reason: `时间预算用尽 (${Math.round(elapsed / 1000)}s/${Math.round(rec.budget.deadlineMs / 1000)}s)` };
|
|
546
|
+
}
|
|
547
|
+
return { exceeded: false };
|
|
548
|
+
}
|
|
549
|
+
function pidAlive(pid) {
|
|
550
|
+
if (!pid || pid === process.pid)
|
|
551
|
+
return pid === process.pid;
|
|
552
|
+
try {
|
|
553
|
+
process.kill(pid, 0);
|
|
554
|
+
return true;
|
|
555
|
+
}
|
|
556
|
+
catch (e) {
|
|
557
|
+
return e?.code === 'EPERM';
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* 孤儿对账: 启动时把 pid 已死的 running 记录改判 interrupted。
|
|
562
|
+
* 不做这一层的话, 重载后 UI/CLI 会显示"还在跑"的幽灵运行 —— 那是最典型的假状态。
|
|
563
|
+
* 单条写失败不影响其它记录 (但会留降级痕迹): 对账本身不能因为一条坏记录而整体放弃。
|
|
564
|
+
*/
|
|
565
|
+
export async function reconcileOrphans() {
|
|
566
|
+
const running = await listRuns({ status: 'running' });
|
|
567
|
+
const interrupted = [];
|
|
568
|
+
const stillRunning = [];
|
|
569
|
+
const failed = [];
|
|
570
|
+
for (const r of running) {
|
|
571
|
+
if (pidAlive(r.pid)) {
|
|
572
|
+
stillRunning.push(r.runId);
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
try {
|
|
576
|
+
await finishRun(r.runId, {
|
|
577
|
+
status: 'interrupted',
|
|
578
|
+
error: `进程 ${r.pid} 已不在 (刷新/重载/崩溃); 运行到此中断`,
|
|
579
|
+
});
|
|
580
|
+
interrupted.push(r.runId);
|
|
581
|
+
}
|
|
582
|
+
catch (err) {
|
|
583
|
+
failed.push(r.runId);
|
|
584
|
+
await recordDegradation({ kind: 'core', op: 'reconcileOrphans', runId: r.runId, message: String(err?.message || err).slice(0, 200) });
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
return { interrupted, stillRunning, failed };
|
|
588
|
+
}
|
|
589
|
+
/**
|
|
590
|
+
* 失速巡检: running 且 updatedAt 超过 staleMs 没动 → 标 stalled。
|
|
591
|
+
* 只标状态不改步骤 (事实层): 让 UI 能说"这个运行卡住了", 而不是永远转圈。
|
|
592
|
+
*/
|
|
593
|
+
export async function superviseRuns(now = Date.now()) {
|
|
594
|
+
const cfg = await readHarnessConfig();
|
|
595
|
+
const running = await listRuns({ status: 'running' });
|
|
596
|
+
const stalled = [];
|
|
597
|
+
const failed = [];
|
|
598
|
+
for (const r of running) {
|
|
599
|
+
if (!pidAlive(r.pid))
|
|
600
|
+
continue; // 交给 reconcileOrphans
|
|
601
|
+
if (now - Date.parse(r.updatedAt) > cfg.staleMs) {
|
|
602
|
+
try {
|
|
603
|
+
await finishRun(r.runId, {
|
|
604
|
+
status: 'stalled',
|
|
605
|
+
error: `超过 ${Math.round(cfg.staleMs / 1000)}s 没有新进展 (可能是工具卡住或模型长时间无响应)`,
|
|
606
|
+
});
|
|
607
|
+
stalled.push(r.runId);
|
|
608
|
+
}
|
|
609
|
+
catch (err) {
|
|
610
|
+
failed.push(r.runId);
|
|
611
|
+
await recordDegradation({ kind: 'core', op: 'superviseRuns', runId: r.runId, message: String(err?.message || err).slice(0, 200) });
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
return { stalled, failed };
|
|
616
|
+
}
|
|
617
|
+
/** 给 CLI / GUI 的一行摘要 */
|
|
618
|
+
export function formatRunLine(r) {
|
|
619
|
+
const ago = Math.max(0, Math.round((Date.now() - Date.parse(r.updatedAt)) / 1000));
|
|
620
|
+
const age = ago < 60 ? `${ago}s 前` : ago < 3600 ? `${Math.round(ago / 60)}m 前` : `${Math.round(ago / 3600)}h 前`;
|
|
621
|
+
return `${r.runId} [${r.surface}] ${r.status.padEnd(11)} steps=${String(r.steps.length).padStart(2)} ${age} ${r.goal.slice(0, 48)}`;
|
|
622
|
+
}
|
|
623
|
+
/** 测试用: 同步读 (避免 vitest 里额外的 await 噪音) */
|
|
624
|
+
export function readRunSync(runId) {
|
|
625
|
+
try {
|
|
626
|
+
return JSON.parse(fssync.readFileSync(runPath(runId), 'utf8'));
|
|
627
|
+
}
|
|
628
|
+
catch {
|
|
629
|
+
return null;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
633
|
+
// 2026-09-16 (Milestone 2): 恢复 —— prepareResume / 重放守卫 / 恢复指令
|
|
634
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
635
|
+
/**
|
|
636
|
+
* 只读/幂等工具白名单。**不在此列一律按"非幂等"保守处理** —— 宁可少重放一次, 也不重复一条副作用。
|
|
637
|
+
*/
|
|
638
|
+
export const IDEMPOTENT_TOOLS = new Set([
|
|
639
|
+
'read_file', 'read_document', 'list_files', 'glob_files', 'grep_files', 'find_files',
|
|
640
|
+
'summarize_document', 'improve_document', 'get_tool_list', 'list_context_layers',
|
|
641
|
+
'read_context_assets', 'list_plans', 'list_skills', 'list_goals', 'list_runs', 'list_questions',
|
|
642
|
+
'git_status', 'git_log', 'git_diff', 'git_branch', 'git_show',
|
|
643
|
+
'ipfs_cat', 'ipfs_ls', 'ipns_resolve', 'list_pending_friend_requests', 'list_peers',
|
|
644
|
+
'go_to_definition', 'find_references', 'hover_info', 'code_completion', 'diagnostics', 'workspace_symbol',
|
|
645
|
+
'browser', 'computer_use', 'clarify', 'x402_info_list', 'x402_info_verify', 'kanban_list', 'kanban_get',
|
|
646
|
+
'list_skill_candidates', 'mcp_list_tools', 'list_decisions', 'trajectory_list',
|
|
647
|
+
]);
|
|
648
|
+
export function isNonIdempotentTool(tool) {
|
|
649
|
+
return !IDEMPOTENT_TOOLS.has(String(tool || ''));
|
|
650
|
+
}
|
|
651
|
+
/**
|
|
652
|
+
* 可恢复的状态 (failed/done/aborted 是终态: 不从这里"复活")。
|
|
653
|
+
* `recovering` 也算可恢复: 认领过的 run 允许"继续/重申领"(幂等) —— 否则 prepareResume 之后的
|
|
654
|
+
* resumeRun 会被自己刚写下的 recovering 挡住。
|
|
655
|
+
* ⚠️ 已知缺口 (归 Supervisor 批次): 还没有 lease, 两个进程可能同时重申领同一个 run。
|
|
656
|
+
*/
|
|
657
|
+
export const RESUMABLE_STATUSES = ['recovering', 'interrupted', 'stalled', 'paused', 'needs_human', 'awaiting_external'];
|
|
658
|
+
/**
|
|
659
|
+
* 准备恢复: 校验状态 → 读 checkpoint → 生成计划 → 落状态 recovering + 记一次 recovery attempt。
|
|
660
|
+
* **不执行任何工具**: 执行由 pi-sdk 用 plan 继续 (同一 runId, 历史保留)。
|
|
661
|
+
*/
|
|
662
|
+
export async function prepareResume(runId) {
|
|
663
|
+
const rec = await readRun(runId);
|
|
664
|
+
if (!rec)
|
|
665
|
+
return { ok: false, reason: `run 不存在: ${runId}` };
|
|
666
|
+
if (!RESUMABLE_STATUSES.includes(rec.status)) {
|
|
667
|
+
return { ok: false, reason: `状态 ${rec.status} 不可恢复 (可恢复: ${RESUMABLE_STATUSES.join('/')})` };
|
|
668
|
+
}
|
|
669
|
+
const plan = await buildPlanFromRecord(rec);
|
|
670
|
+
// 抢占归属 + 状态机: resumed run 归当前进程 (否则启动对账会把它再判成 interrupted)
|
|
671
|
+
const claimed = await withRunLock(runId, () => coreWrite('prepareResume', runId, async () => {
|
|
672
|
+
const cur = await readRun(runId);
|
|
673
|
+
if (!cur)
|
|
674
|
+
return null;
|
|
675
|
+
if (!canTransition(cur.status, 'recovering'))
|
|
676
|
+
return null; // 期间被别的路径改了状态 → 放弃
|
|
677
|
+
cur.status = 'recovering';
|
|
678
|
+
cur.pid = process.pid;
|
|
679
|
+
cur.host = os.hostname();
|
|
680
|
+
cur.updatedAt = new Date().toISOString();
|
|
681
|
+
await writeRun(cur);
|
|
682
|
+
return cur;
|
|
683
|
+
}));
|
|
684
|
+
if (!claimed)
|
|
685
|
+
return { ok: false, reason: `状态已被其它路径改变, 恢复未开始: ${runId}` };
|
|
686
|
+
await recordRecovery(runId, {
|
|
687
|
+
errorClass: rec.errorClass || 'crash',
|
|
688
|
+
message: `从 checkpoint 恢复 (已完成 ${plan.completedSteps.length} 步, 非幂等守卫 ${plan.replayGuards.length} 条)`,
|
|
689
|
+
action: 'resume',
|
|
690
|
+
checkpointBefore: rec.steps.length,
|
|
691
|
+
changedPlan: rec.steps.length > 0,
|
|
692
|
+
});
|
|
693
|
+
return { ok: true, plan };
|
|
694
|
+
}
|
|
695
|
+
/**
|
|
696
|
+
* 2026-09-16 (M2-B): **只读**为一个新 Run 生成"继续同一 Goal"的计划 (Supervisor 跨预算续跑用)。
|
|
697
|
+
* 不改状态、不抢归属 —— 与 prepareResume 的区别是: 这条 Run 已经结束了, 我们要开下一条。
|
|
698
|
+
*/
|
|
699
|
+
export async function buildContinuationPlan(prevRunId) {
|
|
700
|
+
const rec = await readRun(prevRunId);
|
|
701
|
+
if (!rec)
|
|
702
|
+
return null;
|
|
703
|
+
return buildPlanFromRecord(rec);
|
|
704
|
+
}
|
|
705
|
+
/** 计划构造的唯一实现 (resume 与 continuation 共用, 避免两套语义漂移) */
|
|
706
|
+
async function buildPlanFromRecord(rec) {
|
|
707
|
+
const completedSteps = rec.steps.filter((s) => s.ok);
|
|
708
|
+
const replayGuards = rec.steps
|
|
709
|
+
.filter((s) => s.ok && isNonIdempotentTool(s.tool))
|
|
710
|
+
.map((s) => ({ tool: s.tool, argsDigest: s.argsDigest, summary: s.summary || '(已执行)' }));
|
|
711
|
+
let objective;
|
|
712
|
+
const goalId = rec.goalId;
|
|
713
|
+
if (goalId) {
|
|
714
|
+
try {
|
|
715
|
+
const { readGoal } = await import('./goal-store.js');
|
|
716
|
+
const g = await readGoal(goalId);
|
|
717
|
+
objective = g?.objective;
|
|
718
|
+
}
|
|
719
|
+
catch { /* goal-store 不可用不影响恢复 */ }
|
|
720
|
+
}
|
|
721
|
+
// nextAction 要能直接指路 (恢复指令的核心): 上一步成功 → 别重做, 收尾确认; 上一步失败 → 先处理失败
|
|
722
|
+
const last = rec.steps[rec.steps.length - 1];
|
|
723
|
+
const defaultNext = last
|
|
724
|
+
? (last.ok
|
|
725
|
+
? `上一步 ${last.tool} 已成功; 如果目标已达成, 直接给出结论与证据并收尾 (不要重复已完成的动作)`
|
|
726
|
+
: `上一步 ${last.tool} 失败 (${String(last.error || '未知').slice(0, 100)}); 先处理这个失败再继续目标`)
|
|
727
|
+
: '继续未完成的目标 (先读最近失败步骤, 不要重复已成功的动作)';
|
|
728
|
+
return {
|
|
729
|
+
run: rec,
|
|
730
|
+
checkpoint: rec.checkpoint,
|
|
731
|
+
completedSteps,
|
|
732
|
+
lastStep: last,
|
|
733
|
+
nextAction: rec.checkpoint?.nextAction && !/由这一步的结果决定/.test(rec.checkpoint.nextAction)
|
|
734
|
+
? rec.checkpoint.nextAction
|
|
735
|
+
: defaultNext,
|
|
736
|
+
replayGuards,
|
|
737
|
+
objective,
|
|
738
|
+
goalId,
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
/** 恢复真正开始执行时: recovering → running */
|
|
742
|
+
export async function markRunRunning(runId) {
|
|
743
|
+
const res = await setRunStatus(runId, 'running');
|
|
744
|
+
return !!res.ok;
|
|
745
|
+
}
|
|
746
|
+
/**
|
|
747
|
+
* 生成恢复指令 (代替"重新发一遍原 prompt")。
|
|
748
|
+
* 关键: 明确列出已完成动作 + 非幂等守卫 + 下一步, 让 agent 从 checkpoint 继续而不是从头再来。
|
|
749
|
+
*/
|
|
750
|
+
export function buildResumeInstruction(plan) {
|
|
751
|
+
const lines = [];
|
|
752
|
+
lines.push('[从 checkpoint 恢复] 这是一次**中断后恢复**的运行, 不是新任务。');
|
|
753
|
+
if (plan.objective)
|
|
754
|
+
lines.push(`目标: ${plan.objective}`);
|
|
755
|
+
else
|
|
756
|
+
lines.push(`目标: ${plan.run.goal}`);
|
|
757
|
+
lines.push(`已完成 ${plan.completedSteps.length} 步 (不要重复执行它们):`);
|
|
758
|
+
for (const s of plan.completedSteps.slice(-12)) {
|
|
759
|
+
lines.push(` ✓ ${s.n}. ${s.tool}${s.summary ? ` — ${s.summary.slice(0, 80)}` : ''}`);
|
|
760
|
+
}
|
|
761
|
+
if (plan.lastStep && !plan.lastStep.ok) {
|
|
762
|
+
lines.push(`最近一步失败: ${plan.lastStep.tool} — ${plan.lastStep.error || '未知错误'}`);
|
|
763
|
+
}
|
|
764
|
+
if (plan.replayGuards.length) {
|
|
765
|
+
lines.push('以下**非幂等**动作此前已经成功执行, 绝对不要重复执行 (否则会产生重复副作用):');
|
|
766
|
+
for (const g of plan.replayGuards.slice(-10))
|
|
767
|
+
lines.push(` ⛔ ${g.tool} — ${g.summary.slice(0, 80)}`);
|
|
768
|
+
}
|
|
769
|
+
lines.push(`下一步: ${plan.nextAction}`);
|
|
770
|
+
lines.push('请直接从中断处继续完成任务; 完成后按正常格式给出结论与证据。');
|
|
771
|
+
return lines.join('\n');
|
|
772
|
+
}
|