@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,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* goal-criteria.ts — 判据生成 / 确认 / 跨 Run 证据汇总 (批次 2-F, 2026-09-16)
|
|
3
|
+
*
|
|
4
|
+
* 规则 (与 leo 的规格一致):
|
|
5
|
+
* · 用户明确给出完成条件 → 直接使用 (criteriaSource = 'user', 已确认);
|
|
6
|
+
* · 用户没给 → **agent 提候选判据** (criteriaSource = 'agent_proposed', 未确认);
|
|
7
|
+
* 候选判据**不能**直接成为最终完成条件 —— 未确认就永不自动完成;
|
|
8
|
+
* · 目标过于模糊 / 候选生成失败 → **不自动完成**, 交人 (needs_human);
|
|
9
|
+
* · 证据跨 Run 汇总 (Run 证据 → Goal 证据, 去重), unresolvedItems 跨 Run 保留。
|
|
10
|
+
*/
|
|
11
|
+
import { readGoal, setCriteria, addEvidence, updateGoal, } from './goal-store.js';
|
|
12
|
+
import { readRun } from './run-store.js';
|
|
13
|
+
/** 太模糊的目标: 判据无从谈起 (短且没有可核验的动作词) */
|
|
14
|
+
export function looksVague(objective) {
|
|
15
|
+
const o = String(objective || '').trim();
|
|
16
|
+
if (!o)
|
|
17
|
+
return { vague: true, reason: '目标为空' };
|
|
18
|
+
const hasVerb = /(写|改|建|加|删|修|跑|测|验证|发布|提交|导出|生成|读|查|部署|完成|实现|接入|更新|安装|配置|整理|对比|汇总)/.test(o);
|
|
19
|
+
if (o.length <= 4 && !hasVerb)
|
|
20
|
+
return { vague: true, reason: `目标太短且没有可核验动作: "${o}"` };
|
|
21
|
+
if (/^(优化|改进|提升|完善|处理|搞|弄|看看|研究一下)/.test(o) && o.length < 12) {
|
|
22
|
+
return { vague: true, reason: `目标过于笼统: "${o}" (缺可核验的产出)` };
|
|
23
|
+
}
|
|
24
|
+
return { vague: false };
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* 从目标推导候选判据 (确定性启发式, 不依赖 LLM —— 生成失败也不会伪造成功)。
|
|
28
|
+
* 候选**必须**经人确认才能用作完成条件。
|
|
29
|
+
*/
|
|
30
|
+
export function proposeCriteria(objective) {
|
|
31
|
+
const v = looksVague(objective);
|
|
32
|
+
if (v.vague)
|
|
33
|
+
return { ok: false, criteria: [], needsHuman: true, reason: `无法生成可核验判据: ${v.reason} → 请补一句"做完的样子"` };
|
|
34
|
+
const o = String(objective).trim();
|
|
35
|
+
const out = [];
|
|
36
|
+
// 动作拆解: 目标里出现的"动作 + 对象"直接成为候选
|
|
37
|
+
const actions = ['写出', '创建', '修改', '删除', '跑通', '验证', '发布', '提交', '导出', '生成', '读入', '部署', '安装', '配置', '整理', '汇总', '对比'];
|
|
38
|
+
const hits = actions.filter((a) => o.includes(a));
|
|
39
|
+
for (const h of hits.slice(0, 3))
|
|
40
|
+
out.push(`完成"${h}"这一步并留下可核验产物`);
|
|
41
|
+
if (!out.length)
|
|
42
|
+
out.push(`产出与目标一致的结果: ${o.slice(0, 60)}`);
|
|
43
|
+
out.push('有证据可复现 (命令/文件/输出留痕)');
|
|
44
|
+
out.push('没有遗留未解决项');
|
|
45
|
+
return { ok: true, criteria: out.slice(0, 5) };
|
|
46
|
+
}
|
|
47
|
+
/** 给 Goal 提候选判据 (不确认; 留痕) */
|
|
48
|
+
export async function proposeForGoal(goalId) {
|
|
49
|
+
const goal = await readGoal(goalId);
|
|
50
|
+
if (!goal)
|
|
51
|
+
return { ok: false, criteria: [], needsHuman: true, reason: 'Goal 不存在' };
|
|
52
|
+
if (goal.successCriteria.length && goal.criteriaSource === 'user') {
|
|
53
|
+
return { ok: true, criteria: goal.successCriteria, reason: '用户已给出判据 (不改)' };
|
|
54
|
+
}
|
|
55
|
+
const p = proposeCriteria(goal.objective);
|
|
56
|
+
if (!p.ok) {
|
|
57
|
+
// 太模糊 → 明确交人, 不写假判据
|
|
58
|
+
await addEvidence(goalId, [`判据生成失败: ${p.reason}`]).catch(() => { });
|
|
59
|
+
await updateGoal(goalId, { status: 'needs_human' }).catch(() => { });
|
|
60
|
+
return p;
|
|
61
|
+
}
|
|
62
|
+
await setCriteria(goalId, { criteria: p.criteria, source: 'agent_proposed', confirm: false });
|
|
63
|
+
await addEvidence(goalId, [`agent 提出候选判据 (待确认): ${p.criteria.join(' / ')}`]).catch(() => { });
|
|
64
|
+
return p;
|
|
65
|
+
}
|
|
66
|
+
/** 人确认判据 (可同时改内容) */
|
|
67
|
+
export async function confirmCriteria(goalId, opts = {}) {
|
|
68
|
+
const goal = await readGoal(goalId);
|
|
69
|
+
if (!goal)
|
|
70
|
+
return { ok: false, reason: 'Goal 不存在' };
|
|
71
|
+
const criteria = opts.criteria?.length ? opts.criteria : goal.successCriteria;
|
|
72
|
+
if (!criteria.length)
|
|
73
|
+
return { ok: false, reason: '没有可确认的判据 (先给判据或让 agent 提候选)' };
|
|
74
|
+
const next = await setCriteria(goalId, { criteria, source: opts.criteria?.length ? 'user' : (goal.criteriaSource || 'user'), confirm: true, by: opts.by || 'human' });
|
|
75
|
+
await addEvidence(goalId, [`判据已确认 (v${next?.criteriaVersion}): ${criteria.join(' / ')}`]).catch(() => { });
|
|
76
|
+
return { ok: true, goal: next };
|
|
77
|
+
}
|
|
78
|
+
/** 把 Goal 名下所有 Run 的证据汇总进 Goal.evidence (去重, 保留跨 Run 历史) */
|
|
79
|
+
export async function aggregateEvidence(goalId) {
|
|
80
|
+
const goal = await readGoal(goalId);
|
|
81
|
+
if (!goal)
|
|
82
|
+
return { runs: 0, runEvidence: [], goalEvidence: [], unresolved: [] };
|
|
83
|
+
const runEvidence = [];
|
|
84
|
+
let lastRunStatus;
|
|
85
|
+
for (const rid of goal.runs || []) {
|
|
86
|
+
const run = await readRun(rid).catch(() => null);
|
|
87
|
+
if (!run)
|
|
88
|
+
continue;
|
|
89
|
+
lastRunStatus = run.status;
|
|
90
|
+
for (const ev of (run.evidence || []))
|
|
91
|
+
runEvidence.push(`[${rid.slice(0, 8)} ${run.status}] ${String(ev).slice(0, 200)}`);
|
|
92
|
+
}
|
|
93
|
+
const merged = [...new Set([...(goal.evidence || []), ...runEvidence])].slice(-50);
|
|
94
|
+
if (merged.length !== (goal.evidence || []).length) {
|
|
95
|
+
await updateGoal(goalId, { evidence: merged }).catch(() => { });
|
|
96
|
+
}
|
|
97
|
+
return { runs: (goal.runs || []).length, runEvidence, goalEvidence: merged, unresolved: goal.unresolvedItems || [], lastRunStatus };
|
|
98
|
+
}
|
|
99
|
+
/** 长期完成的整体判据 (给 CLI/Web/Supervisor 一个统一解释) */
|
|
100
|
+
export async function longTermStatus(goalId) {
|
|
101
|
+
const goal = await readGoal(goalId);
|
|
102
|
+
if (!goal)
|
|
103
|
+
return { canComplete: false, reason: 'Goal 不存在', checks: {} };
|
|
104
|
+
const sum = await aggregateEvidence(goalId);
|
|
105
|
+
const checks = {
|
|
106
|
+
hasCriteria: goal.successCriteria.length > 0,
|
|
107
|
+
criteriaConfirmed: goal.criteriaConfirmed === true && goal.criteriaSource !== undefined,
|
|
108
|
+
allSatisfied: goal.successCriteria.length > 0 && goal.successCriteria.every((_, i) => goal.completedCriteria.includes(i)),
|
|
109
|
+
hasEvidence: (sum.goalEvidence || []).length > 0,
|
|
110
|
+
noUnresolved: (goal.unresolvedItems || []).length === 0,
|
|
111
|
+
lastRunHealthy: !sum.lastRunStatus || !['failed', 'interrupted', 'stalled'].includes(String(sum.lastRunStatus)),
|
|
112
|
+
};
|
|
113
|
+
const canComplete = Object.values(checks).every(Boolean);
|
|
114
|
+
const firstFail = Object.entries(checks).find(([, v]) => !v)?.[0];
|
|
115
|
+
const reasonMap = {
|
|
116
|
+
hasCriteria: '没有完成判据',
|
|
117
|
+
criteriaConfirmed: '判据未经人确认 (候选判据不算数)',
|
|
118
|
+
allSatisfied: '还有判据没满足',
|
|
119
|
+
hasEvidence: '没有证据',
|
|
120
|
+
noUnresolved: '还有未解决项',
|
|
121
|
+
lastRunHealthy: `最近一条 Run 状态异常 (${sum.lastRunStatus})`,
|
|
122
|
+
};
|
|
123
|
+
return { canComplete, reason: canComplete ? '判据全满足 + 已确认 + 有证据 + 无未解决项' : (reasonMap[firstFail] || '未满足完成条件'), checks };
|
|
124
|
+
}
|
|
@@ -0,0 +1,526 @@
|
|
|
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
|
+
// 2026-09-16 (Phase 3 硬门禁): 初始化未就绪时**不允许创建长期 Goal**。
|
|
57
|
+
// 生产环境生效; 测试环境跳过 (用例跑在隔离 HOME, 本来就没有真实配置)。
|
|
58
|
+
if (!process.env.VITEST && process.env.BOLLOON_SETUP_IN_PROGRESS !== '1') {
|
|
59
|
+
try {
|
|
60
|
+
const { getSetupGateCached } = await import('../setup/setup-store.js');
|
|
61
|
+
const { gate, state } = await getSetupGateCached();
|
|
62
|
+
if (gate !== 'ready') {
|
|
63
|
+
throw new Error(`初始化未就绪 (${gate}, 阶段 ${state.stage}) — 不允许创建 Goal; 先 \`bolloon setup\``);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
if (/初始化未就绪/.test(String(err?.message || '')))
|
|
68
|
+
throw err;
|
|
69
|
+
// 门禁自身不可读 → fail-closed (不放行)
|
|
70
|
+
throw new Error(`初始化状态不可读, 拒绝创建 Goal (fail-closed): ${String(err?.message || err).slice(0, 120)}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const now = new Date().toISOString();
|
|
74
|
+
const rec = {
|
|
75
|
+
goalId: newGoalId(),
|
|
76
|
+
objective: String(opts.objective || '').slice(0, 500),
|
|
77
|
+
successCriteria: (opts.successCriteria || []).map((c) => String(c).slice(0, 200)).slice(0, 20),
|
|
78
|
+
// 用户明确给出判据 → 直接视为已确认 (criteriaSource=user); 没给 → unknown, 之后由 agent 提候选
|
|
79
|
+
criteriaSource: (opts.successCriteria && opts.successCriteria.length) ? 'user' : 'unknown',
|
|
80
|
+
criteriaConfirmed: !!(opts.successCriteria && opts.successCriteria.length),
|
|
81
|
+
criteriaVersion: 1,
|
|
82
|
+
constraints: (opts.constraints || []).map((c) => String(c).slice(0, 200)).slice(0, 20),
|
|
83
|
+
requiredSkills: (opts.requiredSkills || []).map((c) => String(c).slice(0, 120)).slice(0, 20),
|
|
84
|
+
budget: opts.budget,
|
|
85
|
+
status: 'open',
|
|
86
|
+
channelId: opts.channelId,
|
|
87
|
+
agentId: opts.agentId,
|
|
88
|
+
createdBy: opts.createdBy,
|
|
89
|
+
createdAt: now,
|
|
90
|
+
updatedAt: now,
|
|
91
|
+
runs: [],
|
|
92
|
+
completedCriteria: [],
|
|
93
|
+
unresolvedItems: [],
|
|
94
|
+
evidence: [],
|
|
95
|
+
};
|
|
96
|
+
await writeGoal(rec);
|
|
97
|
+
return rec;
|
|
98
|
+
}
|
|
99
|
+
export async function readGoal(goalId) {
|
|
100
|
+
if (!goalId)
|
|
101
|
+
return null;
|
|
102
|
+
try {
|
|
103
|
+
return JSON.parse(await fs.readFile(goalPath(goalId), 'utf8'));
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
export async function listGoals(opts = {}) {
|
|
110
|
+
let files = [];
|
|
111
|
+
try {
|
|
112
|
+
files = await fs.readdir(goalsDir());
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return [];
|
|
116
|
+
}
|
|
117
|
+
const out = [];
|
|
118
|
+
for (const f of files) {
|
|
119
|
+
if (!f.endsWith('.json'))
|
|
120
|
+
continue;
|
|
121
|
+
const g = await readGoal(f.replace(/\.json$/, ''));
|
|
122
|
+
if (!g)
|
|
123
|
+
continue;
|
|
124
|
+
if (opts.status) {
|
|
125
|
+
const want = Array.isArray(opts.status) ? opts.status : [opts.status];
|
|
126
|
+
if (!want.includes(g.status))
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
out.push(g);
|
|
130
|
+
}
|
|
131
|
+
out.sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1));
|
|
132
|
+
return typeof opts.limit === 'number' ? out.slice(0, opts.limit) : out;
|
|
133
|
+
}
|
|
134
|
+
export async function updateGoal(goalId, patch) {
|
|
135
|
+
return withGoalLock(goalId, async () => {
|
|
136
|
+
const rec = await readGoal(goalId);
|
|
137
|
+
if (!rec)
|
|
138
|
+
return null;
|
|
139
|
+
const next = { ...rec, ...patch, goalId: rec.goalId, updatedAt: new Date().toISOString() };
|
|
140
|
+
await writeGoal(next);
|
|
141
|
+
return next;
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* 把一次 Run 挂到 Goal 上 (Run 反查 Goal 的入口)。
|
|
146
|
+
* 幂等: 同一 runId 重复挂不会产生重复条目。
|
|
147
|
+
*/
|
|
148
|
+
export async function attachRun(goalId, runId, opts = {}) {
|
|
149
|
+
return withGoalLock(goalId, async () => {
|
|
150
|
+
const rec = await readGoal(goalId);
|
|
151
|
+
if (!rec)
|
|
152
|
+
return null;
|
|
153
|
+
if (!rec.runs.includes(runId))
|
|
154
|
+
rec.runs.push(runId);
|
|
155
|
+
if (opts.makeCurrent !== false)
|
|
156
|
+
rec.currentRunId = runId;
|
|
157
|
+
if (rec.status === 'open')
|
|
158
|
+
rec.status = 'active';
|
|
159
|
+
rec.updatedAt = new Date().toISOString();
|
|
160
|
+
await writeGoal(rec);
|
|
161
|
+
return rec;
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
/** 标记某条判据已满足 (+可选证据) */
|
|
165
|
+
export async function markCriterion(goalId, index, satisfied, evidence) {
|
|
166
|
+
return withGoalLock(goalId, async () => {
|
|
167
|
+
const rec = await readGoal(goalId);
|
|
168
|
+
if (!rec)
|
|
169
|
+
return null;
|
|
170
|
+
if (index < 0 || index >= rec.successCriteria.length)
|
|
171
|
+
return rec;
|
|
172
|
+
const set = new Set(rec.completedCriteria);
|
|
173
|
+
if (satisfied)
|
|
174
|
+
set.add(index);
|
|
175
|
+
else
|
|
176
|
+
set.delete(index);
|
|
177
|
+
rec.completedCriteria = Array.from(set).sort((a, b) => a - b);
|
|
178
|
+
if (evidence)
|
|
179
|
+
rec.evidence = [...rec.evidence, String(evidence).slice(0, 300)].slice(-50);
|
|
180
|
+
rec.updatedAt = new Date().toISOString();
|
|
181
|
+
await writeGoal(rec);
|
|
182
|
+
return rec;
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
/** 记录未解决项 (失败步骤 / 待人工确认) —— 有未解决项就不许判完成 */
|
|
186
|
+
export async function setUnresolved(goalId, items) {
|
|
187
|
+
return updateGoal(goalId, { unresolvedItems: items.map((i) => String(i).slice(0, 300)).slice(0, 30) });
|
|
188
|
+
}
|
|
189
|
+
export async function addEvidence(goalId, evidence) {
|
|
190
|
+
return withGoalLock(goalId, async () => {
|
|
191
|
+
const rec = await readGoal(goalId);
|
|
192
|
+
if (!rec)
|
|
193
|
+
return null;
|
|
194
|
+
rec.evidence = [...rec.evidence, ...evidence.map((e) => String(e).slice(0, 300))].slice(-50);
|
|
195
|
+
rec.updatedAt = new Date().toISOString();
|
|
196
|
+
await writeGoal(rec);
|
|
197
|
+
return rec;
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* 完成门 (Milestone 4): **确定性**判定, 不看模型怎么说。
|
|
202
|
+
* 全部必要判据满足 + 有证据 + 无未解决项 → 才允许 completed。
|
|
203
|
+
* 未声明 successCriteria 的目标**永不**自动完成 (需要人显式确认) —— 否则"模型说完成"就变成了完成。
|
|
204
|
+
*/
|
|
205
|
+
export function evaluateGoalCompletion(goal, opts = {}) {
|
|
206
|
+
if (!goal.successCriteria.length) {
|
|
207
|
+
return { complete: false, reason: '未声明 successCriteria: 不允许自动判完成 (需人工确认)', missing: [] };
|
|
208
|
+
}
|
|
209
|
+
// 2-F: 候选判据 (未确认) 不许当作完成条件
|
|
210
|
+
if (goal.criteriaSource === 'agent_proposed' && goal.criteriaConfirmed !== true) {
|
|
211
|
+
return { complete: false, reason: `判据是 agent 提的候选 (v${goal.criteriaVersion || 1}), 未经人确认 → 不许判完成`, missing: goal.successCriteria };
|
|
212
|
+
}
|
|
213
|
+
if (goal.criteriaConfirmed === false) {
|
|
214
|
+
return { complete: false, reason: '判据未确认 → 不许判完成', missing: goal.successCriteria };
|
|
215
|
+
}
|
|
216
|
+
// 2-F: 最近一条 Run 还处于"没跑完"的状态 (失败/中断/失速) → 不许判完成
|
|
217
|
+
const lastRunStatus = opts.lastRunStatus;
|
|
218
|
+
if (lastRunStatus && ['failed', 'interrupted', 'stalled'].includes(String(lastRunStatus))) {
|
|
219
|
+
return { complete: false, reason: `最近一条 Run 状态是 ${lastRunStatus} (未处理好) → 不许判完成`, missing: [] };
|
|
220
|
+
}
|
|
221
|
+
const missing = goal.successCriteria
|
|
222
|
+
.map((c, i) => ({ c, i }))
|
|
223
|
+
.filter(({ i }) => !goal.completedCriteria.includes(i))
|
|
224
|
+
.map(({ c, i }) => `[${i}] ${c}`);
|
|
225
|
+
if (missing.length) {
|
|
226
|
+
return { complete: false, reason: `还有 ${missing.length} 条判据未满足`, missing };
|
|
227
|
+
}
|
|
228
|
+
if (!goal.evidence.length) {
|
|
229
|
+
return { complete: false, reason: '没有证据 (evidence 为空): 不许判完成', missing: [] };
|
|
230
|
+
}
|
|
231
|
+
if (goal.unresolvedItems.length) {
|
|
232
|
+
return { complete: false, reason: `还有 ${goal.unresolvedItems.length} 项未解决`, missing: goal.unresolvedItems };
|
|
233
|
+
}
|
|
234
|
+
return { complete: true, reason: '全部判据满足 + 有证据 + 无未解决项', missing: [] };
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* 设置/确认判据 (2-F)。
|
|
238
|
+
* source: 谁给的 (user 显式给 / agent_proposed 候选 / imported)
|
|
239
|
+
* confirm: 是否视为已确认 —— **候选判据必须显式 confirm 才可能完成**
|
|
240
|
+
*/
|
|
241
|
+
export async function setCriteria(goalId, opts) {
|
|
242
|
+
return withGoalLock(goalId, async () => {
|
|
243
|
+
const rec = await readGoal(goalId);
|
|
244
|
+
if (!rec)
|
|
245
|
+
return null;
|
|
246
|
+
if (opts.criteria) {
|
|
247
|
+
rec.successCriteria = opts.criteria.map((c) => String(c).slice(0, 200)).slice(0, 20);
|
|
248
|
+
rec.criteriaVersion = (rec.criteriaVersion || 1) + 1;
|
|
249
|
+
rec.completedCriteria = []; // 判据变了 → 之前的满足记录作废 (避免拿旧判据凑完成)
|
|
250
|
+
}
|
|
251
|
+
if (opts.source)
|
|
252
|
+
rec.criteriaSource = opts.source;
|
|
253
|
+
if (opts.confirm !== undefined) {
|
|
254
|
+
rec.criteriaConfirmed = opts.confirm;
|
|
255
|
+
rec.criteriaConfirmedBy = opts.confirm ? (opts.by || 'human') : undefined;
|
|
256
|
+
rec.criteriaConfirmedAt = opts.confirm ? new Date().toISOString() : undefined;
|
|
257
|
+
}
|
|
258
|
+
if (opts.source === 'agent_proposed' && !opts.confirm)
|
|
259
|
+
rec.proposedCriteria = rec.successCriteria;
|
|
260
|
+
rec.updatedAt = new Date().toISOString();
|
|
261
|
+
await writeGoal(rec);
|
|
262
|
+
return rec;
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
/** 通过完成门就落 completed, 否则保持原状态并回传原因 (不静默) */
|
|
266
|
+
export async function completeGoalIfEligible(goalId) {
|
|
267
|
+
const goal = await readGoal(goalId);
|
|
268
|
+
if (!goal)
|
|
269
|
+
return { ok: false, reason: `goal 不存在: ${goalId}`, goal: null };
|
|
270
|
+
// 2-F: 把"最近一条 Run 的真实状态"一起纳入完成门 (失败/中断/失速时不许判完成)
|
|
271
|
+
let lastRunStatus;
|
|
272
|
+
try {
|
|
273
|
+
const lastRunId = goal.currentRunId || goal.runs?.[goal.runs.length - 1];
|
|
274
|
+
if (lastRunId) {
|
|
275
|
+
const { readRun } = await import('./run-store.js');
|
|
276
|
+
lastRunStatus = (await readRun(lastRunId))?.status;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
catch { /* 读不到就不加这一条约束, 其余判据照旧 */ }
|
|
280
|
+
const verdict = evaluateGoalCompletion(goal, { lastRunStatus });
|
|
281
|
+
if (!verdict.complete)
|
|
282
|
+
return { ok: false, reason: verdict.reason, goal, missing: verdict.missing };
|
|
283
|
+
const next = await updateGoal(goalId, {
|
|
284
|
+
status: 'completed',
|
|
285
|
+
resolution: { reason: verdict.reason, at: new Date().toISOString() },
|
|
286
|
+
});
|
|
287
|
+
return { ok: true, reason: verdict.reason, goal: next };
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* 找一个"还在进行中"的目标 (供 prompt 入口判断"继续还是新建")。
|
|
291
|
+
* 只看 open/active, 且限定 channel+agent (不同智能体的目标不混)。
|
|
292
|
+
*/
|
|
293
|
+
export async function findActiveGoal(opts) {
|
|
294
|
+
const all = await listGoals({ status: ['open', 'active'] });
|
|
295
|
+
for (const g of all) {
|
|
296
|
+
if (opts.channelId && g.channelId && g.channelId !== opts.channelId)
|
|
297
|
+
continue;
|
|
298
|
+
if (opts.agentId && g.agentId && g.agentId !== opts.agentId)
|
|
299
|
+
continue;
|
|
300
|
+
return g;
|
|
301
|
+
}
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
/** 给 CLI/Web 的一行摘要 */
|
|
305
|
+
export function formatGoalLine(g) {
|
|
306
|
+
const done = `${g.completedCriteria.length}/${g.successCriteria.length || 0}`;
|
|
307
|
+
return `${g.goalId} [${g.status.padEnd(9)}] 判据 ${done.padStart(4)} run=${(g.currentRunId || '-').slice(0, 12)} ${g.objective.slice(0, 44)}`;
|
|
308
|
+
}
|
|
309
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
310
|
+
// 2026-09-16 (M2-A): continuation —— "下一次何时、因为什么被唤醒"
|
|
311
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
312
|
+
/**
|
|
313
|
+
* 写调度元数据 (合并式)。
|
|
314
|
+
* 这是 2-A 的落地: Goal 永远能回答"下一步是什么/还需不需要自动继续/在等什么"。
|
|
315
|
+
*/
|
|
316
|
+
export async function setContinuation(goalId, patch) {
|
|
317
|
+
return withGoalLock(goalId, async () => {
|
|
318
|
+
const rec = await readGoal(goalId);
|
|
319
|
+
if (!rec)
|
|
320
|
+
return null;
|
|
321
|
+
rec.continuation = {
|
|
322
|
+
...(rec.continuation || { autoContinue: true }),
|
|
323
|
+
...patch,
|
|
324
|
+
updatedAt: new Date().toISOString(),
|
|
325
|
+
};
|
|
326
|
+
rec.updatedAt = new Date().toISOString();
|
|
327
|
+
await writeGoal(rec);
|
|
328
|
+
return rec;
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
/** 幂等地累加自动继续尝试次数 (退避用) */
|
|
332
|
+
export async function bumpContinuationAttempts(goalId) {
|
|
333
|
+
const rec = await readGoal(goalId);
|
|
334
|
+
const n = (rec?.continuation?.attempts || 0) + 1;
|
|
335
|
+
await setContinuation(goalId, { attempts: n });
|
|
336
|
+
return n;
|
|
337
|
+
}
|
|
338
|
+
export async function resetContinuationAttempts(goalId) {
|
|
339
|
+
await setContinuation(goalId, { attempts: 0 });
|
|
340
|
+
}
|
|
341
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
342
|
+
// 2026-09-16 (M2-B): 执行权租约 (跨进程排他) —— 真值在 <goalId>.lease 文件, 用 O_EXCL 独占创建
|
|
343
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
344
|
+
function leasePath(goalId) {
|
|
345
|
+
return path.join(goalsDir(), `${goalId}.lease`);
|
|
346
|
+
}
|
|
347
|
+
function pidAlive(pid) {
|
|
348
|
+
if (!pid)
|
|
349
|
+
return false;
|
|
350
|
+
if (pid === process.pid)
|
|
351
|
+
return true;
|
|
352
|
+
try {
|
|
353
|
+
process.kill(pid, 0);
|
|
354
|
+
return true;
|
|
355
|
+
}
|
|
356
|
+
catch (e) {
|
|
357
|
+
return e?.code === 'EPERM';
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
export async function readLease(goalId) {
|
|
361
|
+
try {
|
|
362
|
+
return JSON.parse(await fs.readFile(leasePath(goalId), 'utf8'));
|
|
363
|
+
}
|
|
364
|
+
catch {
|
|
365
|
+
return null;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* 抢执行权 (原子: 独占创建 lease 文件)。
|
|
370
|
+
* 可回收条件 (任一):
|
|
371
|
+
* ① leaseUntil 已过期 (TTL)
|
|
372
|
+
* ② 持有者进程已不在 (更早回收 —— 持有者可证明已死, 不必等满 TTL)
|
|
373
|
+
* 不可回收: 持有者活着且未过期 → 明确返回 ok:false + holder (调用方据此"让路", 不是报错)
|
|
374
|
+
*/
|
|
375
|
+
export async function claimGoal(goalId, opts = { owner: 'unknown' }) {
|
|
376
|
+
const ttl = opts.ttlMs ?? 90_000;
|
|
377
|
+
const now = opts.now ?? Date.now();
|
|
378
|
+
await fs.mkdir(goalsDir(), { recursive: true });
|
|
379
|
+
const p = leasePath(goalId);
|
|
380
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
381
|
+
const claimedAt = new Date(now).toISOString();
|
|
382
|
+
const lease = {
|
|
383
|
+
owner: opts.owner,
|
|
384
|
+
leaseId: `${Date.now().toString(36)}-${crypto.randomBytes(3).toString('hex')}`,
|
|
385
|
+
claimedAt,
|
|
386
|
+
lastHeartbeat: claimedAt,
|
|
387
|
+
leaseUntil: new Date(now + ttl).toISOString(),
|
|
388
|
+
pid: process.pid,
|
|
389
|
+
host: os.hostname(),
|
|
390
|
+
};
|
|
391
|
+
try {
|
|
392
|
+
const fh = await fs.open(p, 'wx');
|
|
393
|
+
await fh.writeFile(JSON.stringify(lease, null, 2), 'utf8');
|
|
394
|
+
await fh.close();
|
|
395
|
+
// 镜像到 Goal 文件 (只为可读; 真值在 lease 文件)
|
|
396
|
+
await updateGoal(goalId, { lease: { owner: lease.owner, leaseId: lease.leaseId, claimedAt, lastHeartbeat: claimedAt, leaseUntil: lease.leaseUntil } });
|
|
397
|
+
return { ok: true, lease };
|
|
398
|
+
}
|
|
399
|
+
catch (err) {
|
|
400
|
+
if (err?.code !== 'EEXIST') {
|
|
401
|
+
return { ok: false, reason: `lease 文件不可创建: ${String(err?.message || err).slice(0, 120)}` };
|
|
402
|
+
}
|
|
403
|
+
const holder = await readLease(goalId);
|
|
404
|
+
if (!holder) {
|
|
405
|
+
await fs.rm(p, { force: true });
|
|
406
|
+
continue;
|
|
407
|
+
} // 坏文件 → 当陈旧回收
|
|
408
|
+
const expired = Date.parse(String(holder.leaseUntil || '')) <= now;
|
|
409
|
+
const dead = holder.pid ? !pidAlive(Number(holder.pid)) : false;
|
|
410
|
+
if (expired || dead) {
|
|
411
|
+
await fs.rm(p, { force: true });
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
return { ok: false, reason: `lease 被占用 (owner=${holder.owner}, until=${holder.leaseUntil})`, holder };
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
return { ok: false, reason: 'lease 抢占重试后仍失败' };
|
|
418
|
+
}
|
|
419
|
+
/** 续租 (必须带自己的 leaseId: 被接管后旧 worker 不能再续租, 也就不能再写) */
|
|
420
|
+
export async function heartbeatGoal(goalId, leaseId, ttlMs = 90_000, now = Date.now()) {
|
|
421
|
+
const cur = await readLease(goalId);
|
|
422
|
+
if (!cur)
|
|
423
|
+
return { ok: false, reason: 'lease 不存在 (可能已过期被回收)' };
|
|
424
|
+
if (cur.leaseId !== leaseId)
|
|
425
|
+
return { ok: false, reason: `lease 已被接管 (当前 owner=${cur.owner})` };
|
|
426
|
+
const next = { ...cur, lastHeartbeat: new Date(now).toISOString(), leaseUntil: new Date(now + ttlMs).toISOString() };
|
|
427
|
+
try {
|
|
428
|
+
await fs.writeFile(leasePath(goalId), JSON.stringify(next, null, 2), 'utf8');
|
|
429
|
+
}
|
|
430
|
+
catch (err) {
|
|
431
|
+
return { ok: false, reason: `续租写失败: ${String(err?.message || err).slice(0, 120)}` };
|
|
432
|
+
}
|
|
433
|
+
await updateGoal(goalId, { lease: { owner: next.owner, leaseId: next.leaseId, claimedAt: next.claimedAt, lastHeartbeat: next.lastHeartbeat, leaseUntil: next.leaseUntil } });
|
|
434
|
+
return { ok: true, lease: next };
|
|
435
|
+
}
|
|
436
|
+
/** 释放执行权 (只释放自己的那把) */
|
|
437
|
+
export async function releaseGoal(goalId, leaseId) {
|
|
438
|
+
const cur = await readLease(goalId);
|
|
439
|
+
if (!cur) {
|
|
440
|
+
await updateGoal(goalId, { lease: undefined });
|
|
441
|
+
return { ok: true };
|
|
442
|
+
}
|
|
443
|
+
if (cur.leaseId !== leaseId)
|
|
444
|
+
return { ok: false, reason: `lease 已被接管, 未释放 (当前 owner=${cur.owner})` };
|
|
445
|
+
await fs.rm(leasePath(goalId), { force: true });
|
|
446
|
+
await updateGoal(goalId, { lease: undefined });
|
|
447
|
+
return { ok: true };
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* 扫出"现在就该跑"的 Goal (M2-B 第 1-2 步: 扫描 + 判断可否唤醒)。
|
|
451
|
+
* 规则: 状态 ∈ active/recovering; autoContinue !== false; wakeAt 未到则跳过; 有活租约则跳过。
|
|
452
|
+
* 返回附带"为什么没被选"的说明, 便于诊断 (不静默)。
|
|
453
|
+
*/
|
|
454
|
+
export async function listRunnableGoals(opts = {}) {
|
|
455
|
+
const now = opts.now ?? Date.now();
|
|
456
|
+
const all = await listGoals({ limit: 100 });
|
|
457
|
+
const runnable = [];
|
|
458
|
+
const skipped = [];
|
|
459
|
+
for (const g of all) {
|
|
460
|
+
const c = g.continuation;
|
|
461
|
+
const skip = (reason) => skipped.push({ goalId: g.goalId, status: g.status, reason });
|
|
462
|
+
// 终态 / 等人 / 等外部事件 → 一律不自动唤醒 (这是 2-C 唤醒表的硬规则)
|
|
463
|
+
if (g.status === 'completed' || g.status === 'failed' || g.status === 'abandoned')
|
|
464
|
+
continue;
|
|
465
|
+
if (g.status === 'paused') {
|
|
466
|
+
skip('paused: 等用户 resume (重启也不会自动跑)');
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
469
|
+
if (g.status === 'needs_human') {
|
|
470
|
+
skip(`needs_human: 等人工 approve (${c?.wakeReason || ''})`);
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
if (g.status === 'awaiting_external' || c?.wakeReason === 'awaiting_external') {
|
|
474
|
+
skip(`awaiting_external: 等外部事件${c?.needsExternal ? ` (${c.needsExternal})` : ''}, 不重复发送`);
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
if (c?.autoContinue === false) {
|
|
478
|
+
skip(`autoContinue=false (${c.wakeReason || '等人'})`);
|
|
479
|
+
continue;
|
|
480
|
+
}
|
|
481
|
+
if (g.status === 'retry_wait' || (c?.wakeAt && Date.parse(c.wakeAt) > now)) {
|
|
482
|
+
if (c?.wakeAt && Date.parse(c.wakeAt) > now) {
|
|
483
|
+
skip(`retry_wait: 时间未到 (${c.wakeAt})`);
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
// retry_wait 且时间已到 → 可跑
|
|
487
|
+
}
|
|
488
|
+
const lease = await readLease(g.goalId);
|
|
489
|
+
const held = !!lease && Date.parse(String(lease.leaseUntil || '')) > now && (lease.pid ? pidAlive(Number(lease.pid)) : true);
|
|
490
|
+
if (held) {
|
|
491
|
+
skip(`lease 被 ${lease.owner} 持有至 ${lease.leaseUntil}`);
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
runnable.push(g);
|
|
495
|
+
}
|
|
496
|
+
// 先跑等着跑最久的 (公平性: 不让一个 Goal 霸占所有 tick)
|
|
497
|
+
runnable.sort((a, b) => Date.parse(a.updatedAt || a.createdAt) - Date.parse(b.updatedAt || b.createdAt));
|
|
498
|
+
return { runnable, skipped };
|
|
499
|
+
}
|
|
500
|
+
/** CLI/Web 可见的长期执行诊断 (每个 Goal 为什么在/不在跑) */
|
|
501
|
+
export async function wakeReport(now = Date.now()) {
|
|
502
|
+
const goals = await listGoals({ limit: 50 });
|
|
503
|
+
const out = [];
|
|
504
|
+
for (const g of goals) {
|
|
505
|
+
const c = g.continuation;
|
|
506
|
+
const lease = await readLease(g.goalId);
|
|
507
|
+
const live = lease && Date.parse(String(lease.leaseUntil || '')) > now && (lease.pid ? pidAlive(Number(lease.pid)) : true);
|
|
508
|
+
let wake = '立即';
|
|
509
|
+
if (g.status === 'completed')
|
|
510
|
+
wake = '不再唤醒';
|
|
511
|
+
else if (g.status === 'paused' || c?.autoContinue === false)
|
|
512
|
+
wake = `等人 (${c?.wakeReason || g.status})`;
|
|
513
|
+
else if (c?.wakeAt && Date.parse(c.wakeAt) > now) {
|
|
514
|
+
const left = Math.round((Date.parse(c.wakeAt) - now) / 1000);
|
|
515
|
+
wake = `等时间 (${c.wakeAt}, 还剩 ${left}s${c.attempts ? `, 已自动继续 ${c.attempts} 次` : ''})`;
|
|
516
|
+
}
|
|
517
|
+
else if (g.status === 'awaiting_external' || c?.wakeReason === 'awaiting_external' || c?.external) {
|
|
518
|
+
const what = c?.needsExternal || (c?.external ? `${c.external.expectedSource}${c.external.expectedEvent ? `:${c.external.expectedEvent}` : ''} (requestId=${c.external.requestId}, 过期 ${c.external.expiresAt})` : '');
|
|
519
|
+
wake = `等外部事件${what ? ` (${what})` : ''}`;
|
|
520
|
+
}
|
|
521
|
+
else if (live)
|
|
522
|
+
wake = `已被 ${lease.owner} 认领`;
|
|
523
|
+
out.push({ goalId: g.goalId, status: g.status, wake, autoContinue: c?.autoContinue !== false, lease: live ? lease.owner : undefined });
|
|
524
|
+
}
|
|
525
|
+
return out;
|
|
526
|
+
}
|