@bolloon/bolloon-agent 0.4.24 → 0.4.25
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 +391 -0
- package/dist/agents/goal-store.js +451 -0
- package/dist/agents/pi-harness.js +263 -0
- package/dist/agents/pi-sdk.js +568 -126
- package/dist/agents/run-store.js +772 -0
- package/dist/agents/runner-resolver.js +225 -0
- package/dist/agents/skills-manager.js +435 -0
- package/dist/agents/supervisor-host.js +249 -0
- package/dist/cron/tick-lock.js +1 -1
- package/dist/index.js +428 -22
- 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/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 +386 -0
- package/package.json +2 -2
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* goal-store.ts — GoalStore: **目标事实来源** (2026-09-16 Milestone 2)
|
|
3
|
+
*
|
|
4
|
+
* 与既有模型的关系 (leo 2026-09-16: 先明确唯一关系, 不急着删旧模块):
|
|
5
|
+
* GoalStore (本文件, `~/.bolloon/goals/<goalId>.json`) = 目标的**唯一事实来源**
|
|
6
|
+
* RunStore (`~/.bolloon/runs/<runId>.json`) = 一次执行的**唯一事实来源**
|
|
7
|
+
* SessionStore = 对话上下文来源
|
|
8
|
+
* Task/Plan = Goal 的执行辅助结构 (不承载目标状态)
|
|
9
|
+
* 旧模型保留但降级为"入口/草稿": `pi-ecosystem-goals` 的 queue.json (目标队列) 与
|
|
10
|
+
* `goal-resume` 的 park/resume (双栖接力) 仍是生产者, 迁移留待后续批次 —— 不删。
|
|
11
|
+
*
|
|
12
|
+
* 关键规则 (协议 §「关键规则」):
|
|
13
|
+
* - Goal 永远不能因为一次 prompt 结束就自动消失
|
|
14
|
+
* - Run 结束 ≠ Goal 完成; 只有 successCriteria 全部满足 (且有证据) 才能 completed
|
|
15
|
+
* - 不允许"done 但目标没达成"伪装成功
|
|
16
|
+
*/
|
|
17
|
+
import * as fs from 'fs/promises';
|
|
18
|
+
import * as path from 'path';
|
|
19
|
+
import * as os from 'os';
|
|
20
|
+
import * as crypto from 'crypto';
|
|
21
|
+
/** 可被 Supervisor 自动唤醒推进的状态 (其余必须等人或等外部事件) */
|
|
22
|
+
export const GOAL_RUNNABLE_STATUSES = ['open', 'active', 'recovering', 'retry_wait', 'stalled'];
|
|
23
|
+
export function goalsDir() {
|
|
24
|
+
return path.join(os.homedir(), '.bolloon', 'goals');
|
|
25
|
+
}
|
|
26
|
+
function goalPath(goalId) {
|
|
27
|
+
return path.join(goalsDir(), `${goalId}.json`);
|
|
28
|
+
}
|
|
29
|
+
/** 原子写 (tmp + rename): 读到的永远是完整 JSON */
|
|
30
|
+
async function writeGoal(rec) {
|
|
31
|
+
await fs.mkdir(goalsDir(), { recursive: true });
|
|
32
|
+
const p = goalPath(rec.goalId);
|
|
33
|
+
const tmp = `${p}.${process.pid}.tmp`;
|
|
34
|
+
await fs.writeFile(tmp, JSON.stringify(rec, null, 2), 'utf8');
|
|
35
|
+
await fs.rename(tmp, p);
|
|
36
|
+
}
|
|
37
|
+
/** 同 goal 的进程内串行化 (并发写不覆盖判据/证据) */
|
|
38
|
+
const goalLocks = new Map();
|
|
39
|
+
async function withGoalLock(goalId, fn) {
|
|
40
|
+
const prev = goalLocks.get(goalId) || Promise.resolve();
|
|
41
|
+
const mine = prev.catch(() => { }).then(fn);
|
|
42
|
+
const tail = mine.catch(() => { });
|
|
43
|
+
goalLocks.set(goalId, tail);
|
|
44
|
+
try {
|
|
45
|
+
return await mine;
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
if (goalLocks.get(goalId) === tail)
|
|
49
|
+
goalLocks.delete(goalId);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export function newGoalId() {
|
|
53
|
+
return `g-${Date.now().toString(36)}-${crypto.randomBytes(3).toString('hex')}`;
|
|
54
|
+
}
|
|
55
|
+
export async function createGoal(opts) {
|
|
56
|
+
const now = new Date().toISOString();
|
|
57
|
+
const rec = {
|
|
58
|
+
goalId: newGoalId(),
|
|
59
|
+
objective: String(opts.objective || '').slice(0, 500),
|
|
60
|
+
successCriteria: (opts.successCriteria || []).map((c) => String(c).slice(0, 200)).slice(0, 20),
|
|
61
|
+
constraints: (opts.constraints || []).map((c) => String(c).slice(0, 200)).slice(0, 20),
|
|
62
|
+
budget: opts.budget,
|
|
63
|
+
status: 'open',
|
|
64
|
+
channelId: opts.channelId,
|
|
65
|
+
agentId: opts.agentId,
|
|
66
|
+
createdBy: opts.createdBy,
|
|
67
|
+
createdAt: now,
|
|
68
|
+
updatedAt: now,
|
|
69
|
+
runs: [],
|
|
70
|
+
completedCriteria: [],
|
|
71
|
+
unresolvedItems: [],
|
|
72
|
+
evidence: [],
|
|
73
|
+
};
|
|
74
|
+
await writeGoal(rec);
|
|
75
|
+
return rec;
|
|
76
|
+
}
|
|
77
|
+
export async function readGoal(goalId) {
|
|
78
|
+
if (!goalId)
|
|
79
|
+
return null;
|
|
80
|
+
try {
|
|
81
|
+
return JSON.parse(await fs.readFile(goalPath(goalId), 'utf8'));
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
export async function listGoals(opts = {}) {
|
|
88
|
+
let files = [];
|
|
89
|
+
try {
|
|
90
|
+
files = await fs.readdir(goalsDir());
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return [];
|
|
94
|
+
}
|
|
95
|
+
const out = [];
|
|
96
|
+
for (const f of files) {
|
|
97
|
+
if (!f.endsWith('.json'))
|
|
98
|
+
continue;
|
|
99
|
+
const g = await readGoal(f.replace(/\.json$/, ''));
|
|
100
|
+
if (!g)
|
|
101
|
+
continue;
|
|
102
|
+
if (opts.status) {
|
|
103
|
+
const want = Array.isArray(opts.status) ? opts.status : [opts.status];
|
|
104
|
+
if (!want.includes(g.status))
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
out.push(g);
|
|
108
|
+
}
|
|
109
|
+
out.sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1));
|
|
110
|
+
return typeof opts.limit === 'number' ? out.slice(0, opts.limit) : out;
|
|
111
|
+
}
|
|
112
|
+
export async function updateGoal(goalId, patch) {
|
|
113
|
+
return withGoalLock(goalId, async () => {
|
|
114
|
+
const rec = await readGoal(goalId);
|
|
115
|
+
if (!rec)
|
|
116
|
+
return null;
|
|
117
|
+
const next = { ...rec, ...patch, goalId: rec.goalId, updatedAt: new Date().toISOString() };
|
|
118
|
+
await writeGoal(next);
|
|
119
|
+
return next;
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* 把一次 Run 挂到 Goal 上 (Run 反查 Goal 的入口)。
|
|
124
|
+
* 幂等: 同一 runId 重复挂不会产生重复条目。
|
|
125
|
+
*/
|
|
126
|
+
export async function attachRun(goalId, runId, opts = {}) {
|
|
127
|
+
return withGoalLock(goalId, async () => {
|
|
128
|
+
const rec = await readGoal(goalId);
|
|
129
|
+
if (!rec)
|
|
130
|
+
return null;
|
|
131
|
+
if (!rec.runs.includes(runId))
|
|
132
|
+
rec.runs.push(runId);
|
|
133
|
+
if (opts.makeCurrent !== false)
|
|
134
|
+
rec.currentRunId = runId;
|
|
135
|
+
if (rec.status === 'open')
|
|
136
|
+
rec.status = 'active';
|
|
137
|
+
rec.updatedAt = new Date().toISOString();
|
|
138
|
+
await writeGoal(rec);
|
|
139
|
+
return rec;
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
/** 标记某条判据已满足 (+可选证据) */
|
|
143
|
+
export async function markCriterion(goalId, index, satisfied, evidence) {
|
|
144
|
+
return withGoalLock(goalId, async () => {
|
|
145
|
+
const rec = await readGoal(goalId);
|
|
146
|
+
if (!rec)
|
|
147
|
+
return null;
|
|
148
|
+
if (index < 0 || index >= rec.successCriteria.length)
|
|
149
|
+
return rec;
|
|
150
|
+
const set = new Set(rec.completedCriteria);
|
|
151
|
+
if (satisfied)
|
|
152
|
+
set.add(index);
|
|
153
|
+
else
|
|
154
|
+
set.delete(index);
|
|
155
|
+
rec.completedCriteria = Array.from(set).sort((a, b) => a - b);
|
|
156
|
+
if (evidence)
|
|
157
|
+
rec.evidence = [...rec.evidence, String(evidence).slice(0, 300)].slice(-50);
|
|
158
|
+
rec.updatedAt = new Date().toISOString();
|
|
159
|
+
await writeGoal(rec);
|
|
160
|
+
return rec;
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
/** 记录未解决项 (失败步骤 / 待人工确认) —— 有未解决项就不许判完成 */
|
|
164
|
+
export async function setUnresolved(goalId, items) {
|
|
165
|
+
return updateGoal(goalId, { unresolvedItems: items.map((i) => String(i).slice(0, 300)).slice(0, 30) });
|
|
166
|
+
}
|
|
167
|
+
export async function addEvidence(goalId, evidence) {
|
|
168
|
+
return withGoalLock(goalId, async () => {
|
|
169
|
+
const rec = await readGoal(goalId);
|
|
170
|
+
if (!rec)
|
|
171
|
+
return null;
|
|
172
|
+
rec.evidence = [...rec.evidence, ...evidence.map((e) => String(e).slice(0, 300))].slice(-50);
|
|
173
|
+
rec.updatedAt = new Date().toISOString();
|
|
174
|
+
await writeGoal(rec);
|
|
175
|
+
return rec;
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* 完成门 (Milestone 4): **确定性**判定, 不看模型怎么说。
|
|
180
|
+
* 全部必要判据满足 + 有证据 + 无未解决项 → 才允许 completed。
|
|
181
|
+
* 未声明 successCriteria 的目标**永不**自动完成 (需要人显式确认) —— 否则"模型说完成"就变成了完成。
|
|
182
|
+
*/
|
|
183
|
+
export function evaluateGoalCompletion(goal) {
|
|
184
|
+
if (!goal.successCriteria.length) {
|
|
185
|
+
return { complete: false, reason: '未声明 successCriteria: 不允许自动判完成 (需人工确认)', missing: [] };
|
|
186
|
+
}
|
|
187
|
+
const missing = goal.successCriteria
|
|
188
|
+
.map((c, i) => ({ c, i }))
|
|
189
|
+
.filter(({ i }) => !goal.completedCriteria.includes(i))
|
|
190
|
+
.map(({ c, i }) => `[${i}] ${c}`);
|
|
191
|
+
if (missing.length) {
|
|
192
|
+
return { complete: false, reason: `还有 ${missing.length} 条判据未满足`, missing };
|
|
193
|
+
}
|
|
194
|
+
if (!goal.evidence.length) {
|
|
195
|
+
return { complete: false, reason: '没有证据 (evidence 为空): 不许判完成', missing: [] };
|
|
196
|
+
}
|
|
197
|
+
if (goal.unresolvedItems.length) {
|
|
198
|
+
return { complete: false, reason: `还有 ${goal.unresolvedItems.length} 项未解决`, missing: goal.unresolvedItems };
|
|
199
|
+
}
|
|
200
|
+
return { complete: true, reason: '全部判据满足 + 有证据 + 无未解决项', missing: [] };
|
|
201
|
+
}
|
|
202
|
+
/** 通过完成门就落 completed, 否则保持原状态并回传原因 (不静默) */
|
|
203
|
+
export async function completeGoalIfEligible(goalId) {
|
|
204
|
+
const goal = await readGoal(goalId);
|
|
205
|
+
if (!goal)
|
|
206
|
+
return { ok: false, reason: `goal 不存在: ${goalId}`, goal: null };
|
|
207
|
+
const verdict = evaluateGoalCompletion(goal);
|
|
208
|
+
if (!verdict.complete)
|
|
209
|
+
return { ok: false, reason: verdict.reason, goal, missing: verdict.missing };
|
|
210
|
+
const next = await updateGoal(goalId, {
|
|
211
|
+
status: 'completed',
|
|
212
|
+
resolution: { reason: verdict.reason, at: new Date().toISOString() },
|
|
213
|
+
});
|
|
214
|
+
return { ok: true, reason: verdict.reason, goal: next };
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* 找一个"还在进行中"的目标 (供 prompt 入口判断"继续还是新建")。
|
|
218
|
+
* 只看 open/active, 且限定 channel+agent (不同智能体的目标不混)。
|
|
219
|
+
*/
|
|
220
|
+
export async function findActiveGoal(opts) {
|
|
221
|
+
const all = await listGoals({ status: ['open', 'active'] });
|
|
222
|
+
for (const g of all) {
|
|
223
|
+
if (opts.channelId && g.channelId && g.channelId !== opts.channelId)
|
|
224
|
+
continue;
|
|
225
|
+
if (opts.agentId && g.agentId && g.agentId !== opts.agentId)
|
|
226
|
+
continue;
|
|
227
|
+
return g;
|
|
228
|
+
}
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
/** 给 CLI/Web 的一行摘要 */
|
|
232
|
+
export function formatGoalLine(g) {
|
|
233
|
+
const done = `${g.completedCriteria.length}/${g.successCriteria.length || 0}`;
|
|
234
|
+
return `${g.goalId} [${g.status.padEnd(9)}] 判据 ${done.padStart(4)} run=${(g.currentRunId || '-').slice(0, 12)} ${g.objective.slice(0, 44)}`;
|
|
235
|
+
}
|
|
236
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
237
|
+
// 2026-09-16 (M2-A): continuation —— "下一次何时、因为什么被唤醒"
|
|
238
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
239
|
+
/**
|
|
240
|
+
* 写调度元数据 (合并式)。
|
|
241
|
+
* 这是 2-A 的落地: Goal 永远能回答"下一步是什么/还需不需要自动继续/在等什么"。
|
|
242
|
+
*/
|
|
243
|
+
export async function setContinuation(goalId, patch) {
|
|
244
|
+
return withGoalLock(goalId, async () => {
|
|
245
|
+
const rec = await readGoal(goalId);
|
|
246
|
+
if (!rec)
|
|
247
|
+
return null;
|
|
248
|
+
rec.continuation = {
|
|
249
|
+
...(rec.continuation || { autoContinue: true }),
|
|
250
|
+
...patch,
|
|
251
|
+
updatedAt: new Date().toISOString(),
|
|
252
|
+
};
|
|
253
|
+
rec.updatedAt = new Date().toISOString();
|
|
254
|
+
await writeGoal(rec);
|
|
255
|
+
return rec;
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
/** 幂等地累加自动继续尝试次数 (退避用) */
|
|
259
|
+
export async function bumpContinuationAttempts(goalId) {
|
|
260
|
+
const rec = await readGoal(goalId);
|
|
261
|
+
const n = (rec?.continuation?.attempts || 0) + 1;
|
|
262
|
+
await setContinuation(goalId, { attempts: n });
|
|
263
|
+
return n;
|
|
264
|
+
}
|
|
265
|
+
export async function resetContinuationAttempts(goalId) {
|
|
266
|
+
await setContinuation(goalId, { attempts: 0 });
|
|
267
|
+
}
|
|
268
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
269
|
+
// 2026-09-16 (M2-B): 执行权租约 (跨进程排他) —— 真值在 <goalId>.lease 文件, 用 O_EXCL 独占创建
|
|
270
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
271
|
+
function leasePath(goalId) {
|
|
272
|
+
return path.join(goalsDir(), `${goalId}.lease`);
|
|
273
|
+
}
|
|
274
|
+
function pidAlive(pid) {
|
|
275
|
+
if (!pid)
|
|
276
|
+
return false;
|
|
277
|
+
if (pid === process.pid)
|
|
278
|
+
return true;
|
|
279
|
+
try {
|
|
280
|
+
process.kill(pid, 0);
|
|
281
|
+
return true;
|
|
282
|
+
}
|
|
283
|
+
catch (e) {
|
|
284
|
+
return e?.code === 'EPERM';
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
export async function readLease(goalId) {
|
|
288
|
+
try {
|
|
289
|
+
return JSON.parse(await fs.readFile(leasePath(goalId), 'utf8'));
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* 抢执行权 (原子: 独占创建 lease 文件)。
|
|
297
|
+
* 可回收条件 (任一):
|
|
298
|
+
* ① leaseUntil 已过期 (TTL)
|
|
299
|
+
* ② 持有者进程已不在 (更早回收 —— 持有者可证明已死, 不必等满 TTL)
|
|
300
|
+
* 不可回收: 持有者活着且未过期 → 明确返回 ok:false + holder (调用方据此"让路", 不是报错)
|
|
301
|
+
*/
|
|
302
|
+
export async function claimGoal(goalId, opts = { owner: 'unknown' }) {
|
|
303
|
+
const ttl = opts.ttlMs ?? 90_000;
|
|
304
|
+
const now = opts.now ?? Date.now();
|
|
305
|
+
await fs.mkdir(goalsDir(), { recursive: true });
|
|
306
|
+
const p = leasePath(goalId);
|
|
307
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
308
|
+
const claimedAt = new Date(now).toISOString();
|
|
309
|
+
const lease = {
|
|
310
|
+
owner: opts.owner,
|
|
311
|
+
leaseId: `${Date.now().toString(36)}-${crypto.randomBytes(3).toString('hex')}`,
|
|
312
|
+
claimedAt,
|
|
313
|
+
lastHeartbeat: claimedAt,
|
|
314
|
+
leaseUntil: new Date(now + ttl).toISOString(),
|
|
315
|
+
pid: process.pid,
|
|
316
|
+
host: os.hostname(),
|
|
317
|
+
};
|
|
318
|
+
try {
|
|
319
|
+
const fh = await fs.open(p, 'wx');
|
|
320
|
+
await fh.writeFile(JSON.stringify(lease, null, 2), 'utf8');
|
|
321
|
+
await fh.close();
|
|
322
|
+
// 镜像到 Goal 文件 (只为可读; 真值在 lease 文件)
|
|
323
|
+
await updateGoal(goalId, { lease: { owner: lease.owner, leaseId: lease.leaseId, claimedAt, lastHeartbeat: claimedAt, leaseUntil: lease.leaseUntil } });
|
|
324
|
+
return { ok: true, lease };
|
|
325
|
+
}
|
|
326
|
+
catch (err) {
|
|
327
|
+
if (err?.code !== 'EEXIST') {
|
|
328
|
+
return { ok: false, reason: `lease 文件不可创建: ${String(err?.message || err).slice(0, 120)}` };
|
|
329
|
+
}
|
|
330
|
+
const holder = await readLease(goalId);
|
|
331
|
+
if (!holder) {
|
|
332
|
+
await fs.rm(p, { force: true });
|
|
333
|
+
continue;
|
|
334
|
+
} // 坏文件 → 当陈旧回收
|
|
335
|
+
const expired = Date.parse(String(holder.leaseUntil || '')) <= now;
|
|
336
|
+
const dead = holder.pid ? !pidAlive(Number(holder.pid)) : false;
|
|
337
|
+
if (expired || dead) {
|
|
338
|
+
await fs.rm(p, { force: true });
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
return { ok: false, reason: `lease 被占用 (owner=${holder.owner}, until=${holder.leaseUntil})`, holder };
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return { ok: false, reason: 'lease 抢占重试后仍失败' };
|
|
345
|
+
}
|
|
346
|
+
/** 续租 (必须带自己的 leaseId: 被接管后旧 worker 不能再续租, 也就不能再写) */
|
|
347
|
+
export async function heartbeatGoal(goalId, leaseId, ttlMs = 90_000, now = Date.now()) {
|
|
348
|
+
const cur = await readLease(goalId);
|
|
349
|
+
if (!cur)
|
|
350
|
+
return { ok: false, reason: 'lease 不存在 (可能已过期被回收)' };
|
|
351
|
+
if (cur.leaseId !== leaseId)
|
|
352
|
+
return { ok: false, reason: `lease 已被接管 (当前 owner=${cur.owner})` };
|
|
353
|
+
const next = { ...cur, lastHeartbeat: new Date(now).toISOString(), leaseUntil: new Date(now + ttlMs).toISOString() };
|
|
354
|
+
try {
|
|
355
|
+
await fs.writeFile(leasePath(goalId), JSON.stringify(next, null, 2), 'utf8');
|
|
356
|
+
}
|
|
357
|
+
catch (err) {
|
|
358
|
+
return { ok: false, reason: `续租写失败: ${String(err?.message || err).slice(0, 120)}` };
|
|
359
|
+
}
|
|
360
|
+
await updateGoal(goalId, { lease: { owner: next.owner, leaseId: next.leaseId, claimedAt: next.claimedAt, lastHeartbeat: next.lastHeartbeat, leaseUntil: next.leaseUntil } });
|
|
361
|
+
return { ok: true, lease: next };
|
|
362
|
+
}
|
|
363
|
+
/** 释放执行权 (只释放自己的那把) */
|
|
364
|
+
export async function releaseGoal(goalId, leaseId) {
|
|
365
|
+
const cur = await readLease(goalId);
|
|
366
|
+
if (!cur) {
|
|
367
|
+
await updateGoal(goalId, { lease: undefined });
|
|
368
|
+
return { ok: true };
|
|
369
|
+
}
|
|
370
|
+
if (cur.leaseId !== leaseId)
|
|
371
|
+
return { ok: false, reason: `lease 已被接管, 未释放 (当前 owner=${cur.owner})` };
|
|
372
|
+
await fs.rm(leasePath(goalId), { force: true });
|
|
373
|
+
await updateGoal(goalId, { lease: undefined });
|
|
374
|
+
return { ok: true };
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* 扫出"现在就该跑"的 Goal (M2-B 第 1-2 步: 扫描 + 判断可否唤醒)。
|
|
378
|
+
* 规则: 状态 ∈ active/recovering; autoContinue !== false; wakeAt 未到则跳过; 有活租约则跳过。
|
|
379
|
+
* 返回附带"为什么没被选"的说明, 便于诊断 (不静默)。
|
|
380
|
+
*/
|
|
381
|
+
export async function listRunnableGoals(opts = {}) {
|
|
382
|
+
const now = opts.now ?? Date.now();
|
|
383
|
+
const all = await listGoals({ limit: 100 });
|
|
384
|
+
const runnable = [];
|
|
385
|
+
const skipped = [];
|
|
386
|
+
for (const g of all) {
|
|
387
|
+
const c = g.continuation;
|
|
388
|
+
const skip = (reason) => skipped.push({ goalId: g.goalId, status: g.status, reason });
|
|
389
|
+
// 终态 / 等人 / 等外部事件 → 一律不自动唤醒 (这是 2-C 唤醒表的硬规则)
|
|
390
|
+
if (g.status === 'completed' || g.status === 'failed' || g.status === 'abandoned')
|
|
391
|
+
continue;
|
|
392
|
+
if (g.status === 'paused') {
|
|
393
|
+
skip('paused: 等用户 resume (重启也不会自动跑)');
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
if (g.status === 'needs_human') {
|
|
397
|
+
skip(`needs_human: 等人工 approve (${c?.wakeReason || ''})`);
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
if (g.status === 'awaiting_external' || c?.wakeReason === 'awaiting_external') {
|
|
401
|
+
skip(`awaiting_external: 等外部事件${c?.needsExternal ? ` (${c.needsExternal})` : ''}, 不重复发送`);
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
if (c?.autoContinue === false) {
|
|
405
|
+
skip(`autoContinue=false (${c.wakeReason || '等人'})`);
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
if (g.status === 'retry_wait' || (c?.wakeAt && Date.parse(c.wakeAt) > now)) {
|
|
409
|
+
if (c?.wakeAt && Date.parse(c.wakeAt) > now) {
|
|
410
|
+
skip(`retry_wait: 时间未到 (${c.wakeAt})`);
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
// retry_wait 且时间已到 → 可跑
|
|
414
|
+
}
|
|
415
|
+
const lease = await readLease(g.goalId);
|
|
416
|
+
const held = !!lease && Date.parse(String(lease.leaseUntil || '')) > now && (lease.pid ? pidAlive(Number(lease.pid)) : true);
|
|
417
|
+
if (held) {
|
|
418
|
+
skip(`lease 被 ${lease.owner} 持有至 ${lease.leaseUntil}`);
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
runnable.push(g);
|
|
422
|
+
}
|
|
423
|
+
// 先跑等着跑最久的 (公平性: 不让一个 Goal 霸占所有 tick)
|
|
424
|
+
runnable.sort((a, b) => Date.parse(a.updatedAt || a.createdAt) - Date.parse(b.updatedAt || b.createdAt));
|
|
425
|
+
return { runnable, skipped };
|
|
426
|
+
}
|
|
427
|
+
/** CLI/Web 可见的长期执行诊断 (每个 Goal 为什么在/不在跑) */
|
|
428
|
+
export async function wakeReport(now = Date.now()) {
|
|
429
|
+
const goals = await listGoals({ limit: 50 });
|
|
430
|
+
const out = [];
|
|
431
|
+
for (const g of goals) {
|
|
432
|
+
const c = g.continuation;
|
|
433
|
+
const lease = await readLease(g.goalId);
|
|
434
|
+
const live = lease && Date.parse(String(lease.leaseUntil || '')) > now && (lease.pid ? pidAlive(Number(lease.pid)) : true);
|
|
435
|
+
let wake = '立即';
|
|
436
|
+
if (g.status === 'completed')
|
|
437
|
+
wake = '不再唤醒';
|
|
438
|
+
else if (g.status === 'paused' || c?.autoContinue === false)
|
|
439
|
+
wake = `等人 (${c?.wakeReason || g.status})`;
|
|
440
|
+
else if (c?.wakeAt && Date.parse(c.wakeAt) > now) {
|
|
441
|
+
const left = Math.round((Date.parse(c.wakeAt) - now) / 1000);
|
|
442
|
+
wake = `等时间 (${c.wakeAt}, 还剩 ${left}s${c.attempts ? `, 已自动继续 ${c.attempts} 次` : ''})`;
|
|
443
|
+
}
|
|
444
|
+
else if (c?.wakeReason === 'awaiting_external')
|
|
445
|
+
wake = `等外部事件${c.needsExternal ? ` (${c.needsExternal})` : ''}`;
|
|
446
|
+
else if (live)
|
|
447
|
+
wake = `已被 ${lease.owner} 认领`;
|
|
448
|
+
out.push({ goalId: g.goalId, status: g.status, wake, autoContinue: c?.autoContinue !== false, lease: live ? lease.owner : undefined });
|
|
449
|
+
}
|
|
450
|
+
return out;
|
|
451
|
+
}
|