@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,446 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ExecutionSupervisor — 长期执行层 (2026-09-16, M2-B / 计划 §13 2-A+2-B)
|
|
3
|
+
*
|
|
4
|
+
* 职责边界 (刻意与 Harness 分开):
|
|
5
|
+
* Harness 只管「这一段能不能安全执行」
|
|
6
|
+
* Supervisor 才管「这个目标还要不要继续执行」
|
|
7
|
+
*
|
|
8
|
+
* 它是一个常驻 worker: 扫描可执行的 Goal → 抢 lease → 开新 Run 或恢复旧 Run → 执行 →
|
|
9
|
+
* 按结果决策 Goal 状态 → 写下一次唤醒信息 → 释放 lease → 换下一个 Goal。
|
|
10
|
+
*
|
|
11
|
+
* 它不塞进 Web request, 也不依赖浏览器是否打开。触发器可以复用 cron/heartbeat 的 tick,
|
|
12
|
+
* 但**事实来源永远是持久化记录** (GoalStore / RunStore / lease 文件), 不是内存状态。
|
|
13
|
+
*/
|
|
14
|
+
import * as os from 'os';
|
|
15
|
+
import { listRunnableGoals, claimGoal, heartbeatGoal, releaseGoal, readGoal, setContinuation, bumpContinuationAttempts, evaluateGoalCompletion, completeGoalIfEligible, addEvidence, } from './goal-store.js';
|
|
16
|
+
import { readRun, reconcileOrphans, superviseRuns, buildContinuationPlan, RESUMABLE_STATUSES, } from './run-store.js';
|
|
17
|
+
/** 自动继续的退避 (attempts → 等待毫秒)。0 次 = 立刻。 */
|
|
18
|
+
export function continuationBackoffMs(attempts) {
|
|
19
|
+
const steps = [0, 15_000, 60_000, 5 * 60_000, 15 * 60_000];
|
|
20
|
+
return steps[Math.min(Math.max(attempts, 0), steps.length - 1)];
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Run 结果 → Goal 下一步。**Run done ≠ Goal completed** 是这里的核心不变式:
|
|
24
|
+
* Run 结束从来不自动等于目标完成, 必须过 evaluateGoalCompletion。
|
|
25
|
+
*/
|
|
26
|
+
export function decideGoalOutcome(goal, run, opts = {}) {
|
|
27
|
+
const now = opts.now ?? Date.now();
|
|
28
|
+
// maxAttempts = 允许的自动继续次数 (默认 2) → 第 3 次失败进 needs_human
|
|
29
|
+
const maxAttempts = opts.maxAttempts ?? 2;
|
|
30
|
+
const attempts = (goal.continuation?.attempts || 0);
|
|
31
|
+
const nextAction = run?.checkpoint?.nextAction;
|
|
32
|
+
// 这一轮没有失败/没有等待 → 自动继续计数清零 (连续失败才累加)
|
|
33
|
+
const base = { autoContinue: true, lastRunId: run?.runId, nextAction };
|
|
34
|
+
if (!run) {
|
|
35
|
+
return { goalStatus: 'active', continuation: { ...base, wakeReason: 'new_goal' }, reason: '还没有 Run' };
|
|
36
|
+
}
|
|
37
|
+
// 人定的状态优先, 不覆盖 (外部 pause/abort 是人的决定)
|
|
38
|
+
if (run.status === 'paused') {
|
|
39
|
+
return { goalStatus: 'paused', continuation: { ...base, autoContinue: false, wakeReason: 'paused' }, reason: '运行被人工暂停: 等 resume' };
|
|
40
|
+
}
|
|
41
|
+
switch (run.status) {
|
|
42
|
+
case 'interrupted':
|
|
43
|
+
return {
|
|
44
|
+
goalStatus: 'recovering',
|
|
45
|
+
continuation: { ...base, wakeReason: 'recovering' },
|
|
46
|
+
reason: '进程中断 (crash): 从 checkpoint 恢复, 不重头开始',
|
|
47
|
+
};
|
|
48
|
+
case 'stalled':
|
|
49
|
+
return {
|
|
50
|
+
goalStatus: 'stalled',
|
|
51
|
+
continuation: { ...base, wakeReason: 'stalled' },
|
|
52
|
+
reason: '运行失速 (心跳过期): 交 Supervisor 决策恢复或转人工',
|
|
53
|
+
};
|
|
54
|
+
case 'awaiting_external':
|
|
55
|
+
return {
|
|
56
|
+
goalStatus: 'awaiting_external',
|
|
57
|
+
continuation: {
|
|
58
|
+
...base,
|
|
59
|
+
wakeReason: 'awaiting_external',
|
|
60
|
+
needsExternal: String(run.error || '外部节点回复'),
|
|
61
|
+
},
|
|
62
|
+
reason: '在等外部事件: 不重发请求, 由事件唤醒',
|
|
63
|
+
};
|
|
64
|
+
case 'aborted':
|
|
65
|
+
return {
|
|
66
|
+
goalStatus: 'active',
|
|
67
|
+
continuation: { ...base, wakeReason: 'active', wakeAt: undefined },
|
|
68
|
+
reason: `运行被中止 (${run.errorClass || run.error || '预算/人工'}): 目标仍 active, 交给下一个 Run 继续`,
|
|
69
|
+
};
|
|
70
|
+
case 'done': {
|
|
71
|
+
const verdict = evaluateGoalCompletion(goal, { lastRunStatus: opts.lastRunStatus || run.status });
|
|
72
|
+
if (verdict.complete) {
|
|
73
|
+
return {
|
|
74
|
+
goalStatus: 'completed',
|
|
75
|
+
continuation: { ...base, autoContinue: false, wakeReason: 'completed' },
|
|
76
|
+
reason: `判据全部满足: ${verdict.reason}`,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
// Run 说完成, 但 Goal 的判据/证据不足 → 不许装作完成 (这一轮没失败, 自动继续计数清零)
|
|
80
|
+
return {
|
|
81
|
+
goalStatus: 'active',
|
|
82
|
+
continuation: { ...base, wakeReason: 'active', wakeAt: undefined, attempts: 0 },
|
|
83
|
+
reason: `Run 已 done 但目标未达成 (${verdict.reason}) → 继续下一个 Run`,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
case 'failed':
|
|
87
|
+
case 'needs_human': {
|
|
88
|
+
const cls = run.errorClass || 'unknown';
|
|
89
|
+
const needsHuman = ['auth', 'persist_failed', 'corrupt_state', 'policy_denied', 'repeat_failure', 'bad_args', 'no_such_tool'].includes(cls)
|
|
90
|
+
|| attempts >= maxAttempts;
|
|
91
|
+
if (needsHuman) {
|
|
92
|
+
return {
|
|
93
|
+
goalStatus: 'needs_human',
|
|
94
|
+
continuation: { ...base, autoContinue: false, wakeReason: 'needs_human', attempts },
|
|
95
|
+
reason: `${cls} 需要人工介入${attempts >= maxAttempts ? ` (自动继续已试 ${attempts} 次)` : ''}`,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
const wait = continuationBackoffMs(attempts);
|
|
99
|
+
return {
|
|
100
|
+
goalStatus: 'retry_wait',
|
|
101
|
+
continuation: {
|
|
102
|
+
...base,
|
|
103
|
+
wakeReason: 'retry_wait',
|
|
104
|
+
wakeAt: new Date(now + wait).toISOString(),
|
|
105
|
+
attempts: attempts + 1,
|
|
106
|
+
},
|
|
107
|
+
reason: `可恢复错误 ${cls}: 第 ${attempts + 1} 次自动继续, ${Math.round(wait / 1000)}s 后唤醒`,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
case 'recovering':
|
|
111
|
+
return { goalStatus: 'recovering', continuation: { ...base, wakeReason: 'recovering' }, reason: '正在恢复' };
|
|
112
|
+
default: { // queued / running
|
|
113
|
+
return { goalStatus: 'active', continuation: { ...base, wakeReason: 'active' }, reason: `运行中 (${run.status})` };
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
118
|
+
/** 调度上下文的时间戳 (不含 lease 镜像: 认领本身会写 lease, 不应把快照判成过期) */
|
|
119
|
+
function stampOf(g) {
|
|
120
|
+
return [g.status, g.currentRunId || '', String(g.runs.length), g.continuation?.updatedAt || '', g.goalId].join('|');
|
|
121
|
+
}
|
|
122
|
+
export class ExecutionSupervisor {
|
|
123
|
+
owner;
|
|
124
|
+
tickIntervalMs;
|
|
125
|
+
leaseTtlMs;
|
|
126
|
+
maxPerTick;
|
|
127
|
+
runner;
|
|
128
|
+
resolver;
|
|
129
|
+
now;
|
|
130
|
+
maxRetries;
|
|
131
|
+
onEvent;
|
|
132
|
+
logFn;
|
|
133
|
+
timer = null;
|
|
134
|
+
ticking = false;
|
|
135
|
+
tickCount = 0;
|
|
136
|
+
reconciledOnce = false;
|
|
137
|
+
lastReport = null;
|
|
138
|
+
constructor(opts = {}) {
|
|
139
|
+
this.owner = opts.owner || `${os.hostname()}:${process.pid}`;
|
|
140
|
+
this.tickIntervalMs = opts.tickIntervalMs ?? 30_000;
|
|
141
|
+
this.leaseTtlMs = opts.leaseTtlMs ?? 90_000;
|
|
142
|
+
this.maxPerTick = opts.maxPerTick ?? 1;
|
|
143
|
+
this.runner = opts.runner;
|
|
144
|
+
this.resolver = opts.resolver;
|
|
145
|
+
this.now = opts.now ?? (() => Date.now());
|
|
146
|
+
this.maxRetries = opts.maxRetries ?? (Number(process.env.BOLLOON_GOAL_MAX_RETRIES) >= 0 ? Number(process.env.BOLLOON_GOAL_MAX_RETRIES) : 2);
|
|
147
|
+
this.onEvent = opts.onEvent;
|
|
148
|
+
this.logFn = opts.log;
|
|
149
|
+
}
|
|
150
|
+
/** 有固定执行器或解析器 → 可以真执行; 都没有 → 只诊断 (dry-run) */
|
|
151
|
+
get canExecute() { return !!(this.runner || this.resolver); }
|
|
152
|
+
log(msg) {
|
|
153
|
+
this.logFn?.(msg);
|
|
154
|
+
}
|
|
155
|
+
emit(e) {
|
|
156
|
+
try {
|
|
157
|
+
this.onEvent?.(e);
|
|
158
|
+
}
|
|
159
|
+
catch { /* 观测失败不影响调度 */ }
|
|
160
|
+
}
|
|
161
|
+
get running() { return this.timer !== null; }
|
|
162
|
+
status() {
|
|
163
|
+
return {
|
|
164
|
+
owner: this.owner,
|
|
165
|
+
running: this.running,
|
|
166
|
+
tickIntervalMs: this.tickIntervalMs,
|
|
167
|
+
leaseTtlMs: this.leaseTtlMs,
|
|
168
|
+
maxPerTick: this.maxPerTick,
|
|
169
|
+
ticks: this.tickCount,
|
|
170
|
+
dryRun: !this.canExecute,
|
|
171
|
+
hasResolver: !!this.resolver,
|
|
172
|
+
lastReport: this.lastReport,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
start() {
|
|
176
|
+
if (this.timer)
|
|
177
|
+
return;
|
|
178
|
+
if (!this.canExecute)
|
|
179
|
+
this.log('[supervisor] 未注入 runner/resolver → 只诊断不执行 (dry-run)');
|
|
180
|
+
this.timer = setInterval(() => { void this.tickOnce().catch((err) => this.log(`[supervisor] tick 失败: ${err?.message}`)); }, this.tickIntervalMs);
|
|
181
|
+
this.timer.unref?.();
|
|
182
|
+
this.log(`[supervisor] 启动 owner=${this.owner} tick=${this.tickIntervalMs}ms leaseTtl=${this.leaseTtlMs}ms`);
|
|
183
|
+
void this.tickOnce().catch(() => { });
|
|
184
|
+
}
|
|
185
|
+
stop() {
|
|
186
|
+
if (this.timer) {
|
|
187
|
+
clearInterval(this.timer);
|
|
188
|
+
this.timer = null;
|
|
189
|
+
this.log('[supervisor] 停止');
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
/** 一个调度周期 (可单测/可手动触发)。所有决策基于持久化记录。 */
|
|
193
|
+
async tickOnce() {
|
|
194
|
+
if (this.ticking)
|
|
195
|
+
return this.lastReport || this.emptyReport();
|
|
196
|
+
this.ticking = true;
|
|
197
|
+
this.tickCount++;
|
|
198
|
+
const report = { ...this.emptyReport(), tick: this.tickCount };
|
|
199
|
+
try {
|
|
200
|
+
// 1. 对账孤儿 (进程死了的 run) —— 只在启动后第一次做, 之后靠 tick 的常规巡检
|
|
201
|
+
if (!this.reconciledOnce) {
|
|
202
|
+
report.reconciled = await reconcileOrphans();
|
|
203
|
+
this.reconciledOnce = true;
|
|
204
|
+
if (report.reconciled.interrupted.length)
|
|
205
|
+
this.log(`[supervisor] 对账: ${report.reconciled.interrupted.length} 条僵尸 run → interrupted`);
|
|
206
|
+
}
|
|
207
|
+
report.supervised = await superviseRuns();
|
|
208
|
+
// 1.5 (2026-09-16, 2-C.4): 外部等待超时 → 明确转人工 (不允许无限等待)
|
|
209
|
+
try {
|
|
210
|
+
const { expireExternalWaits } = await import('./external-events.js');
|
|
211
|
+
const expired = await expireExternalWaits({ now: this.now() });
|
|
212
|
+
for (const e of expired) {
|
|
213
|
+
this.log(`[supervisor] 外部等待超时 → needs_human: ${e.goalId} (${e.wait.expectedSource})`);
|
|
214
|
+
this.emit({ kind: 'needs_human', goalId: e.goalId, message: e.reason });
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
catch (err) {
|
|
218
|
+
this.log(`[supervisor] 外部等待超时检查失败: ${err?.message}`);
|
|
219
|
+
}
|
|
220
|
+
// 2. 扫描可执行 Goal
|
|
221
|
+
const { runnable, skipped } = await listRunnableGoals({ now: this.now(), owner: this.owner });
|
|
222
|
+
report.skipped = skipped;
|
|
223
|
+
// 3. 逐个推进 (每个 Goal: 抢 lease → 执行 → 决策 → 释放 lease)
|
|
224
|
+
for (const goal of runnable.slice(0, this.maxPerTick)) {
|
|
225
|
+
const claimed = await claimGoal(goal.goalId, { owner: this.owner, ttlMs: this.leaseTtlMs });
|
|
226
|
+
if (!claimed.ok) {
|
|
227
|
+
report.skipped.push({ goalId: goal.goalId, reason: claimed.reason || 'claim 失败' });
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
report.claimed.push(goal.goalId);
|
|
231
|
+
const leaseId = claimed.lease.leaseId;
|
|
232
|
+
try {
|
|
233
|
+
const res = await this.runGoal(goal, leaseId, report);
|
|
234
|
+
report.executed.push(res);
|
|
235
|
+
}
|
|
236
|
+
catch (err) {
|
|
237
|
+
report.errors.push(`${goal.goalId}: ${err?.message || err}`);
|
|
238
|
+
}
|
|
239
|
+
finally {
|
|
240
|
+
const rel = await releaseGoal(goal.goalId, leaseId);
|
|
241
|
+
if (!rel.ok)
|
|
242
|
+
report.skipped.push({ goalId: goal.goalId, reason: rel.reason || 'release 失败' });
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
catch (err) {
|
|
247
|
+
report.errors.push(`tick: ${err?.message || err}`);
|
|
248
|
+
}
|
|
249
|
+
finally {
|
|
250
|
+
this.ticking = false;
|
|
251
|
+
this.lastReport = report;
|
|
252
|
+
}
|
|
253
|
+
return report;
|
|
254
|
+
}
|
|
255
|
+
emptyReport() {
|
|
256
|
+
return {
|
|
257
|
+
at: new Date().toISOString(), owner: this.owner, tick: this.tickCount,
|
|
258
|
+
reconciled: { interrupted: [], stillRunning: [], failed: [] },
|
|
259
|
+
supervised: { stalled: [], failed: [] },
|
|
260
|
+
claimed: [], executed: [], skipped: [], errors: [], dryRun: !this.runner,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
/** 认领后执行一个 Goal: 决定 resume 还是开新 Run → 跑 → 决策 Goal 状态 */
|
|
264
|
+
async runGoal(goal, leaseId, report) {
|
|
265
|
+
// 乐观并发检查: 认领后重新读一次 —— 若这个 Goal 在我扫描之后已被别的 worker 推进
|
|
266
|
+
// (状态/当前 run/run 列表/continuation 变了), 就让路。否则同一个状态版本会被两个 worker 各跑一次。
|
|
267
|
+
const freshGoal = await readGoal(goal.goalId);
|
|
268
|
+
if (!freshGoal || stampOf(freshGoal) !== stampOf(goal)) {
|
|
269
|
+
this.log(`[supervisor] goal=${goal.goalId} 扫描后状态已变 (别的 worker 推进过) → 让路`);
|
|
270
|
+
report.skipped.push({ goalId: goal.goalId, reason: '状态在扫描后被其它 worker 推进 (乐观并发检查) → 本周期不重复执行' });
|
|
271
|
+
return { goalId: goal.goalId, status: 'stale_skip' };
|
|
272
|
+
}
|
|
273
|
+
// 到点唤醒 (2-C.3): 这条 Goal 之前是 retry_wait —— 现在唤醒它, 旧的 wakeAt/wakeReason 必须清掉,
|
|
274
|
+
// 否则下一轮 tick 还会把它当"等时间"重复跳过 (或留下过期的等待事实)。
|
|
275
|
+
if (goal.continuation?.wakeReason === 'retry_wait' || goal.status === 'retry_wait') {
|
|
276
|
+
await setContinuation(goal.goalId, { wakeReason: 'active', wakeAt: undefined });
|
|
277
|
+
report.skipped.push({ goalId: goal.goalId, reason: `到点唤醒: 已清 wakeAt (第 ${(goal.continuation?.attempts || 0) + 1} 次自动继续)` });
|
|
278
|
+
this.emit({ kind: 'retry_woke', goalId: goal.goalId, message: `到点唤醒, 第 ${(goal.continuation?.attempts || 0) + 1} 次自动继续` });
|
|
279
|
+
this.log(`[supervisor] goal=${goal.goalId} retry_wait 到点 → 唤醒并清 wakeAt`);
|
|
280
|
+
}
|
|
281
|
+
const prevRunId = goal.currentRunId;
|
|
282
|
+
const prevRun = prevRunId ? await readRun(prevRunId) : null;
|
|
283
|
+
const plan = prevRunId ? await buildContinuationPlan(prevRunId).catch(() => null) : null;
|
|
284
|
+
const guards = plan?.replayGuards || [];
|
|
285
|
+
const instruction = plan
|
|
286
|
+
? `继续这个目标 (不要重头开始):\n目标: ${plan.objective || goal.objective}\n已完成 ${plan.completedSteps.length} 步; 下一步: ${plan.nextAction}`
|
|
287
|
+
: `开始执行这个目标:\n目标: ${goal.objective}${goal.successCriteria.length ? `\n完成判据: ${goal.successCriteria.join('; ')}` : ''}`;
|
|
288
|
+
const kind = !prevRun ? 'first_run'
|
|
289
|
+
: RESUMABLE_STATUSES.includes(prevRun.status) ? 'resume'
|
|
290
|
+
: 'continue_new_run';
|
|
291
|
+
if (!this.canExecute) {
|
|
292
|
+
this.log(`[supervisor] (dry-run) 会执行 goal=${goal.goalId} kind=${kind} prevRun=${prevRunId || '-'}`);
|
|
293
|
+
return { goalId: goal.goalId, runId: prevRunId, status: 'dry_run' };
|
|
294
|
+
}
|
|
295
|
+
// 执行器解析 (2-C.1): 解析不出来 → **只诊断, 不执行, 不写任何 Goal 状态**
|
|
296
|
+
let runner = this.runner;
|
|
297
|
+
if (!runner && this.resolver) {
|
|
298
|
+
let res;
|
|
299
|
+
try {
|
|
300
|
+
res = await this.resolver({ goal, kind, prevRunId, instruction, guards });
|
|
301
|
+
}
|
|
302
|
+
catch (err) {
|
|
303
|
+
res = { ok: false, kind: 'none', reason: `resolver 抛错: ${String(err?.message || err).slice(0, 140)}` };
|
|
304
|
+
}
|
|
305
|
+
if (!res.ok || !res.runner) {
|
|
306
|
+
const why = res.reason || '解析不到执行器';
|
|
307
|
+
report.skipped.push({ goalId: goal.goalId, reason: `无执行器: ${why} (Goal 状态未改动)` });
|
|
308
|
+
this.emit({ kind: 'no_runner', goalId: goal.goalId, message: why });
|
|
309
|
+
this.log(`[supervisor] goal=${goal.goalId} 无执行器 → 只诊断, 不执行也不改状态: ${why}`);
|
|
310
|
+
return { goalId: goal.goalId, runId: prevRunId, status: 'unresolved', error: why };
|
|
311
|
+
}
|
|
312
|
+
runner = res.runner;
|
|
313
|
+
}
|
|
314
|
+
if (!runner) {
|
|
315
|
+
report.skipped.push({ goalId: goal.goalId, reason: '无执行器 (Goal 状态未改动)' });
|
|
316
|
+
return { goalId: goal.goalId, status: 'unresolved' };
|
|
317
|
+
}
|
|
318
|
+
// 2026-09-16 (2-G.2): 执行前技能就绪门禁 —— 缺/未启用/损坏/漂移 → 不启动 Run, Goal → needs_human
|
|
319
|
+
try {
|
|
320
|
+
const { ensureGoalSkillsReady, blockGoalOnSkills } = await import('./skill-readiness.js');
|
|
321
|
+
const ready = await ensureGoalSkillsReady(goal);
|
|
322
|
+
for (const d of ready.degradations)
|
|
323
|
+
this.log(`[supervisor] goal=${goal.goalId} 技能降级: ${d}`);
|
|
324
|
+
if (!ready.ok) {
|
|
325
|
+
await blockGoalOnSkills(goal.goalId, ready);
|
|
326
|
+
report.skipped.push({ goalId: goal.goalId, reason: `技能未就绪: ${ready.reason}` });
|
|
327
|
+
this.emit({ kind: 'needs_human', goalId: goal.goalId, message: ready.reason || '技能未就绪' });
|
|
328
|
+
this.log(`[supervisor] goal=${goal.goalId} 技能门禁拦截 → needs_human (未启动 Run): ${ready.reason}`);
|
|
329
|
+
return { goalId: goal.goalId, runId: prevRunId, status: 'blocked_by_skills', error: ready.reason };
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
catch (err) {
|
|
333
|
+
// 门禁自身失败 → fail-closed: 不执行 (宁可停, 不用未校验的技能跑)
|
|
334
|
+
const why = `技能门禁自身失败 (fail-closed, 未执行): ${String(err?.message || err).slice(0, 140)}`;
|
|
335
|
+
report.skipped.push({ goalId: goal.goalId, reason: why });
|
|
336
|
+
this.log(`[supervisor] ${why}`);
|
|
337
|
+
return { goalId: goal.goalId, runId: prevRunId, status: 'blocked_by_skills', error: why };
|
|
338
|
+
}
|
|
339
|
+
// 执行期间持续续租: 续租失败 = 已被别人接管 → 记录 (不掩盖)
|
|
340
|
+
const hb = setInterval(() => {
|
|
341
|
+
void heartbeatGoal(goal.goalId, leaseId, this.leaseTtlMs).then((r) => {
|
|
342
|
+
if (!r.ok)
|
|
343
|
+
this.emit({ kind: 'lease_lost', goalId: goal.goalId, message: r.reason || '续租失败' });
|
|
344
|
+
});
|
|
345
|
+
}, Math.max(5_000, Math.floor(this.leaseTtlMs / 3)));
|
|
346
|
+
hb.unref?.();
|
|
347
|
+
let result;
|
|
348
|
+
const t0 = Date.now();
|
|
349
|
+
try {
|
|
350
|
+
result = await runner({ goal, kind, prevRunId, instruction, guards });
|
|
351
|
+
}
|
|
352
|
+
catch (err) {
|
|
353
|
+
result = { error: err?.message || String(err) };
|
|
354
|
+
}
|
|
355
|
+
finally {
|
|
356
|
+
clearInterval(hb);
|
|
357
|
+
}
|
|
358
|
+
// Run 结束 → Goal 决策 (确定性 reducer, 落盘)
|
|
359
|
+
const finalRunId = result.runId || prevRunId;
|
|
360
|
+
const finalRun = finalRunId ? await readRun(finalRunId) : null;
|
|
361
|
+
if (kind !== 'resume' && finalRun && finalRun.goalId !== goal.goalId) {
|
|
362
|
+
// 新 Run 必须挂在同一 Goal 下; 没挂上就是执行器没接住 goalId → 如实记, 不掩盖
|
|
363
|
+
report.errors.push(`${goal.goalId}: 新 Run ${finalRunId} 未绑定本 Goal (goalId=${finalRun.goalId || '空'})`);
|
|
364
|
+
}
|
|
365
|
+
// 2026-09-16 (2-F): 决策前先做两件事 —— ① 跨 Run 汇总证据; ② 没判据就提候选 (未确认 → 不许完成)。
|
|
366
|
+
// 放在决策之前, 因为"有没有判据/判据是否被满足"正是决策的输入 (之前放在 completed 分支里 = 永远轮不到)。
|
|
367
|
+
try {
|
|
368
|
+
const { aggregateEvidence, proposeForGoal } = await import('./goal-criteria.js');
|
|
369
|
+
await aggregateEvidence(goal.goalId);
|
|
370
|
+
const refreshedBefore = await readGoal(goal.goalId);
|
|
371
|
+
// 只在"这一轮正常跑完"时提候选判据 —— 失败/中断要留给 retry 退避逻辑, 不能把重试变成"交人"
|
|
372
|
+
const ranClean = String(finalRun?.status || '') === 'done';
|
|
373
|
+
if (ranClean && refreshedBefore && !refreshedBefore.successCriteria.length && !['completed', 'failed', 'abandoned'].includes(refreshedBefore.status)) {
|
|
374
|
+
const p = await proposeForGoal(goal.goalId);
|
|
375
|
+
if (p.ok)
|
|
376
|
+
this.log(`[supervisor] goal=${goal.goalId} 已提候选判据 (待确认, 未确认前不会判完成)`);
|
|
377
|
+
else
|
|
378
|
+
this.log(`[supervisor] goal=${goal.goalId} 判据生成失败 → 交人: ${p.reason}`);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
catch (err) {
|
|
382
|
+
this.log(`[supervisor] 证据/判据处理失败: ${err?.message}`);
|
|
383
|
+
}
|
|
384
|
+
// 重新读一次 Goal: Run 期间判据可能已被满足 (否则会拿旧快照判决)
|
|
385
|
+
const goalForDecision = (await readGoal(goal.goalId)) || goal;
|
|
386
|
+
const decision = decideGoalOutcome(goalForDecision, finalRun, { now: this.now(), maxAttempts: this.maxRetries, lastRunStatus: finalRun?.status });
|
|
387
|
+
await this.applyDecision(goal, decision, finalRun);
|
|
388
|
+
this.emit({ kind: 'goal_decision', goalId: goal.goalId, runId: finalRunId, message: `${finalRun?.status || result.status || '?'} → ${decision.goalStatus}: ${decision.reason}` });
|
|
389
|
+
this.log(`[supervisor] goal=${goal.goalId} run=${finalRunId || '-'} ${finalRun?.status || result.status || '?'} → goal=${decision.goalStatus} (${decision.reason}) ${Date.now() - t0}ms`);
|
|
390
|
+
return { goalId: goal.goalId, runId: finalRunId, status: finalRun?.status || result.status, error: result.error };
|
|
391
|
+
}
|
|
392
|
+
/** 把决策写进 Goal (+ 证据同步 + 完成出口), 并写下一次唤醒信息 */
|
|
393
|
+
async applyDecision(goal, decision, run) {
|
|
394
|
+
const { updateGoal } = await import('./goal-store.js');
|
|
395
|
+
// 证据同步: Run 的成功步骤 → Goal 证据 (长期执行的判据要有据可依)
|
|
396
|
+
if (run) {
|
|
397
|
+
const ev = run.steps.filter((s) => s.ok).slice(-5).map((s) => `${run.runId}/${s.tool}: ${String(s.summary || '(完成)').slice(0, 120)}`);
|
|
398
|
+
if (ev.length)
|
|
399
|
+
await addEvidence(goal.goalId, ev).catch(() => null);
|
|
400
|
+
}
|
|
401
|
+
// 唯一完成出口: 只有经 completeGoalIfEligible 才能把 Goal 判成 completed
|
|
402
|
+
if (decision.goalStatus === 'completed') {
|
|
403
|
+
// 完成门 (判据存在 + 已确认 + 全满足 + 有证据 + 无未解决项 + 最近 Run 健康)
|
|
404
|
+
const r = await completeGoalIfEligible(goal.goalId);
|
|
405
|
+
if (!r.ok) {
|
|
406
|
+
// 完成门拒绝 → 如实退回 active, 并保留原因 (不许装作完成)
|
|
407
|
+
await setContinuation(goal.goalId, { ...decision.continuation, wakeReason: 'active', autoContinue: true });
|
|
408
|
+
await updateGoal(goal.goalId, { status: 'active' });
|
|
409
|
+
this.log(`[supervisor] goal=${goal.goalId} 完成门拒绝: ${r.reason}`);
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
if (decision.continuation.wakeReason !== 'completed')
|
|
413
|
+
await setContinuation(goal.goalId, decision.continuation);
|
|
414
|
+
this.emit({ kind: 'goal_completed', goalId: goal.goalId, runId: run?.runId, message: decision.reason });
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
if (decision.goalStatus !== goal.status)
|
|
418
|
+
await updateGoal(goal.goalId, { status: decision.goalStatus });
|
|
419
|
+
await setContinuation(goal.goalId, decision.continuation);
|
|
420
|
+
if (decision.continuation.wakeReason === 'needs_human') {
|
|
421
|
+
this.emit({ kind: 'needs_human', goalId: goal.goalId, message: decision.reason });
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
/** 外部事件到达 → 给对应 Goal 清除等待并加速唤醒 (2-E 第 4 类的入口) */
|
|
425
|
+
async notifyExternal(goalId) {
|
|
426
|
+
const g = await readGoal(goalId);
|
|
427
|
+
if (!g)
|
|
428
|
+
return false;
|
|
429
|
+
if (g.continuation?.wakeReason !== 'awaiting_external' && !g.continuation?.needsExternal)
|
|
430
|
+
return false;
|
|
431
|
+
await setContinuation(goalId, { wakeReason: 'active', needsExternal: undefined, autoContinue: true, wakeAt: undefined });
|
|
432
|
+
await bumpContinuationAttempts(goalId); // 记一次唤醒 (可观测)
|
|
433
|
+
return true;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
/** 单例 (Web/CLI 共享同一个进程内调度器; 跨进程靠 lease 排他) */
|
|
437
|
+
let singleton = null;
|
|
438
|
+
export function getSupervisor(opts) {
|
|
439
|
+
if (!singleton)
|
|
440
|
+
singleton = new ExecutionSupervisor(opts);
|
|
441
|
+
return singleton;
|
|
442
|
+
}
|
|
443
|
+
export function resetSupervisorForTest() {
|
|
444
|
+
singleton?.stop();
|
|
445
|
+
singleton = null;
|
|
446
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* external-events.ts — 外部等待 / 外部事件唤醒协议 (批次 2-C.4, 2026-09-16)
|
|
3
|
+
*
|
|
4
|
+
* 问题: `notifyExternal()` 之前只是个手动入口 (CLI `/wake`、`POST /api/goals/:id/wake`) ——
|
|
5
|
+
* 真实 P2P / delegate 回包到达时**没有任何东西**知道该唤醒哪个 Goal, 也没有来源/关联/去重/过期校验。
|
|
6
|
+
*
|
|
7
|
+
* 协议 (与 leo 的规格 1:1):
|
|
8
|
+
* Goal 在等待时必须绑定: externalRequestId · continuationId · expectedSource · expectedEvent · createdAt · expiresAt
|
|
9
|
+
* 收到事件时必须依次: ① 校验来源 ② 校验 correlation ③ 校验属于当前 continuation
|
|
10
|
+
* ④ 用 eventId 去重 ⑤ 写入事件事实 (Goal 证据) ⑥ 唤醒 Goal ⑦ 由 Supervisor 下一轮继续
|
|
11
|
+
* **事件处理器不直接启动 agent** —— 它只写事实 + 唤醒; 谁来执行由 Supervisor 决定。
|
|
12
|
+
*/
|
|
13
|
+
import { readGoal, setContinuation, updateGoal, listGoals, addEvidence, } from './goal-store.js';
|
|
14
|
+
// ── 绑定等待 ────────────────────────────────────────────────────────────────
|
|
15
|
+
export function defaultWaitExpiry(now = Date.now(), ttlMs = 30 * 60_000) {
|
|
16
|
+
return new Date(now + ttlMs).toISOString();
|
|
17
|
+
}
|
|
18
|
+
/** 生成 continuationId / requestId (调用方也可自带) */
|
|
19
|
+
export function newContinuationId(goalId) {
|
|
20
|
+
return `${goalId}:c${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* 把"这个 Goal 在等什么外部事件"写进 Goal continuation (持久化)。
|
|
24
|
+
* 事件处理器只读这份事实, 不猜。
|
|
25
|
+
*/
|
|
26
|
+
export async function bindExternalWait(goalId, wait) {
|
|
27
|
+
const rec = await setContinuation(goalId, { external: wait, needsExternal: wait.note || `${wait.expectedSource}${wait.expectedEvent ? `:${wait.expectedEvent}` : ''} (requestId=${wait.requestId})` });
|
|
28
|
+
// 等待中也要让状态机一致: awaiting_external 由调用方/reducer 设置, 这里只保证等待事实在
|
|
29
|
+
return rec;
|
|
30
|
+
}
|
|
31
|
+
/** 清掉等待事实 (唤醒/超时后) */
|
|
32
|
+
export async function clearExternalWait(goalId) {
|
|
33
|
+
return setContinuation(goalId, { external: undefined });
|
|
34
|
+
}
|
|
35
|
+
// ── 匹配 ────────────────────────────────────────────────────────────────────
|
|
36
|
+
function sourceMatches(expected, actual) {
|
|
37
|
+
if (expected === 'any')
|
|
38
|
+
return true;
|
|
39
|
+
return expected === actual;
|
|
40
|
+
}
|
|
41
|
+
async function candidates(event) {
|
|
42
|
+
const out = [];
|
|
43
|
+
if (event.goalId) {
|
|
44
|
+
const g = await readGoal(event.goalId);
|
|
45
|
+
if (g?.continuation?.external)
|
|
46
|
+
out.push({ goal: g, wait: g.continuation.external });
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
const all = await listGoals({ limit: 200 });
|
|
50
|
+
for (const g of all) {
|
|
51
|
+
const w = g.continuation?.external;
|
|
52
|
+
if (w)
|
|
53
|
+
out.push({ goal: g, wait: w });
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* 投递一个外部事件。校验顺序固定: 来源 → correlation → 属于当前 continuation → 过期 → 去重。
|
|
59
|
+
* 任何一步不过 → **不唤醒**, 只返回原因 (调用方可以把不匹配的事件当普通消息继续走)。
|
|
60
|
+
*/
|
|
61
|
+
export async function deliverExternalEvent(event, deps = {}) {
|
|
62
|
+
const now = deps.now ? deps.now() : Date.now();
|
|
63
|
+
if (!event?.eventId)
|
|
64
|
+
return { ok: false, reason: 'correlation_mismatch', detail: '事件缺少 eventId (无法去重)' };
|
|
65
|
+
const cands = await candidates(event);
|
|
66
|
+
if (cands.length === 0)
|
|
67
|
+
return { ok: false, reason: 'no_match', detail: '没有 Goal 在等这个事件' };
|
|
68
|
+
// 选出唯一匹配的 Goal (按 requestId/continuationId 精确; 退而按来源+事件名)
|
|
69
|
+
let chosen;
|
|
70
|
+
let failReason;
|
|
71
|
+
let failDetail;
|
|
72
|
+
for (const c of cands) {
|
|
73
|
+
const w = c.wait;
|
|
74
|
+
if (!sourceMatches(w.expectedSource, event.source)) {
|
|
75
|
+
failReason ??= 'source_mismatch';
|
|
76
|
+
failDetail ??= `等待 ${w.expectedSource}, 收到 ${event.source}`;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (event.requestId && event.requestId !== w.requestId) {
|
|
80
|
+
failReason ??= 'correlation_mismatch';
|
|
81
|
+
failDetail ??= `requestId 不匹配 (等 ${w.requestId}, 收到 ${event.requestId})`;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (!event.requestId && event.continuationId && event.continuationId !== w.continuationId) {
|
|
85
|
+
failReason ??= 'correlation_mismatch';
|
|
86
|
+
failDetail ??= 'continuationId 不匹配';
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (!event.requestId && !event.continuationId) {
|
|
90
|
+
failReason ??= 'correlation_mismatch';
|
|
91
|
+
failDetail ??= '事件没有 requestId/continuationId, 不能确认属于这次等待';
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (w.expectedEvent && event.eventName && event.eventName !== w.expectedEvent) {
|
|
95
|
+
failReason ??= 'event_mismatch';
|
|
96
|
+
failDetail ??= `等 ${w.expectedEvent}, 收到 ${event.eventName}`;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
chosen = c;
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
if (!chosen)
|
|
103
|
+
return { ok: false, reason: failReason || 'no_match', detail: failDetail };
|
|
104
|
+
const { goal, wait } = chosen;
|
|
105
|
+
// 过期
|
|
106
|
+
if (Date.parse(wait.expiresAt) <= now) {
|
|
107
|
+
return { ok: false, reason: 'expired', goalId: goal.goalId, detail: `等待已过期 (${wait.expiresAt})` };
|
|
108
|
+
}
|
|
109
|
+
// 去重 (同一 eventId 只处理一次; 跨进程看盘上事实)
|
|
110
|
+
const seen = goal.continuation?.deliveredEventIds || [];
|
|
111
|
+
if (seen.includes(event.eventId))
|
|
112
|
+
return { ok: false, reason: 'duplicate', goalId: goal.goalId, detail: `eventId ${event.eventId} 已处理过` };
|
|
113
|
+
// 写入事件事实 (证据 + 去重表) —— 不在这里启动 agent
|
|
114
|
+
await addEvidence(goal.goalId, [
|
|
115
|
+
`外部事件 (${event.source}${event.fromDid ? `, ${String(event.fromDid).slice(0, 16)}…` : ''}): ${event.eventName || '结果'} eventId=${event.eventId} requestId=${event.requestId || '-'} payload=${JSON.stringify(event.payload ?? null).slice(0, 300)}`,
|
|
116
|
+
]).catch(() => { });
|
|
117
|
+
await setContinuation(goal.goalId, {
|
|
118
|
+
deliveredEventIds: [...seen, event.eventId].slice(-20),
|
|
119
|
+
external: undefined,
|
|
120
|
+
externalResult: { eventId: event.eventId, source: event.source, fromDid: event.fromDid, at: new Date(now).toISOString(), payload: event.payload },
|
|
121
|
+
wakeReason: 'active',
|
|
122
|
+
needsExternal: undefined,
|
|
123
|
+
autoContinue: true,
|
|
124
|
+
wakeAt: undefined,
|
|
125
|
+
});
|
|
126
|
+
// 状态拉回 active (只有还在"等外部"的状态才动它) —— Supervisor 下一轮才会真的执行
|
|
127
|
+
try {
|
|
128
|
+
if (['awaiting_external', 'retry_wait', 'recovering'].includes(String(goal.status))) {
|
|
129
|
+
await updateGoal(goal.goalId, { status: 'active' });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
catch { /* 状态写失败 → 下一轮仍会跳过等待; 事件事实已写入, 不丢 */ }
|
|
133
|
+
// 唤醒 (由 Supervisor 下一轮真正执行)
|
|
134
|
+
let woke = false;
|
|
135
|
+
if (deps.wake)
|
|
136
|
+
woke = await deps.wake(goal.goalId).catch(() => false);
|
|
137
|
+
return { ok: true, reason: 'delivered', goalId: goal.goalId, woke };
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* 把所有"等待已过期"的 Goal 转成明确状态 (needs_human, autoContinue=false)。
|
|
141
|
+
* 由 Supervisor 每轮 tick 开头调用 —— 超时不是失败, 但不能无限等。
|
|
142
|
+
*/
|
|
143
|
+
export async function expireExternalWaits(opts = {}) {
|
|
144
|
+
const now = opts.now ?? Date.now();
|
|
145
|
+
const goals = opts.goalIds
|
|
146
|
+
? (await Promise.all(opts.goalIds.map((id) => readGoal(id)))).filter(Boolean)
|
|
147
|
+
: await listGoals({ limit: 200 });
|
|
148
|
+
const out = [];
|
|
149
|
+
for (const g of goals) {
|
|
150
|
+
const w = g.continuation?.external;
|
|
151
|
+
if (!w)
|
|
152
|
+
continue;
|
|
153
|
+
if (Date.parse(w.expiresAt) > now)
|
|
154
|
+
continue;
|
|
155
|
+
const reason = `外部事件超时 (${w.expectedSource}${w.expectedEvent ? `:${w.expectedEvent}` : ''}, requestId=${w.requestId}, 过期于 ${w.expiresAt}) → 转人工`;
|
|
156
|
+
out.push({ goalId: g.goalId, wait: w, reason });
|
|
157
|
+
await setContinuation(g.goalId, { external: undefined, wakeReason: 'needs_human', needsExternal: undefined, autoContinue: false, lastExternalTimeout: reason });
|
|
158
|
+
await updateGoal(g.goalId, { status: 'needs_human' }).catch(() => { });
|
|
159
|
+
await addEvidence(g.goalId, [`外部事件超时: ${reason}`]).catch(() => { });
|
|
160
|
+
}
|
|
161
|
+
return out;
|
|
162
|
+
}
|