@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,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* runner-resolver.ts — 独立宿主的**分阶段**执行器解析 (2026-09-16, 批次 2-C.2)
|
|
3
|
+
*
|
|
4
|
+
* 起因: 独立宿主"没有 tick 日志、没有建 Run"这种故障无法定位 —— 20 秒超时只能说"超时",
|
|
5
|
+
* 说不出卡在哪。这一层把解析拆成有序阶段, 每阶段记 开始/结束/耗时/失败分类/超时原因:
|
|
6
|
+
*
|
|
7
|
+
* resolve_goal → resolve_agent → load_identity → load_session → load_skills
|
|
8
|
+
* → init_llm → create_session → prepare_resume → ready
|
|
9
|
+
*
|
|
10
|
+
* 任一必需阶段失败 → `{ ok:false, kind:'unresolved', reason:'<阶段>: <原因>', stages }`
|
|
11
|
+
* → Supervisor **只诊断**: 不建 Run、不改 Goal 状态、不伪造失败; 原因进 wakeReport/宿主状态。
|
|
12
|
+
*
|
|
13
|
+
* 顺带修一个真根因: 独立宿主此前**没有初始化 LLM 层**, 于是 `PiAgentSession.prompt()` 判定
|
|
14
|
+
* `minimaxAvailable=false` 走 fallback 提前返回 —— 既不调模型也不建 Run, 上层只看到"执行完成但没有 Run"。
|
|
15
|
+
* 现在 `init_llm` 是必需阶段, LLM 不可用直接 unresolved (而不是偷偷退化成 fallback)。
|
|
16
|
+
*/
|
|
17
|
+
import * as os from 'os';
|
|
18
|
+
import * as path from 'path';
|
|
19
|
+
import * as fsp from 'fs/promises';
|
|
20
|
+
import { readGoal } from './goal-store.js';
|
|
21
|
+
import { buildContinuationPlan } from './run-store.js';
|
|
22
|
+
export const RESOLVE_STAGES = [
|
|
23
|
+
'resolve_goal',
|
|
24
|
+
'resolve_agent',
|
|
25
|
+
'load_identity',
|
|
26
|
+
'load_session',
|
|
27
|
+
'load_skills',
|
|
28
|
+
'init_llm',
|
|
29
|
+
'create_session',
|
|
30
|
+
'prepare_resume',
|
|
31
|
+
'ready',
|
|
32
|
+
];
|
|
33
|
+
function classify(err, stage) {
|
|
34
|
+
const m = String(err?.message || err || '').toLowerCase();
|
|
35
|
+
if (m.includes('超时') || m.includes('timeout'))
|
|
36
|
+
return 'timeout';
|
|
37
|
+
if (stage === 'init_llm')
|
|
38
|
+
return 'config';
|
|
39
|
+
if (m.includes('eacces') || m.includes('enoent') || m.includes('eperm'))
|
|
40
|
+
return 'io';
|
|
41
|
+
return 'unknown';
|
|
42
|
+
}
|
|
43
|
+
/** 读本机 LLM 配置摘要 (只看结构与有无, 不打印任何密钥) */
|
|
44
|
+
export async function probeLlmConfig(home = os.homedir()) {
|
|
45
|
+
try {
|
|
46
|
+
const raw = JSON.parse(await fsp.readFile(path.join(home, '.bolloon', 'llm-config.json'), 'utf8'));
|
|
47
|
+
const active = raw.activeProvider || raw.provider;
|
|
48
|
+
const providers = raw.providers || {};
|
|
49
|
+
const p = providers[active] || {};
|
|
50
|
+
const needsKey = p.requiresApiKey !== false;
|
|
51
|
+
const hasKey = !!(p.apiKey || p.api_key || process.env[`${String(active).toUpperCase()}_API_KEY`]);
|
|
52
|
+
if (!active)
|
|
53
|
+
return { ok: false, reason: 'llm-config.json 没有 activeProvider' };
|
|
54
|
+
if (needsKey && !hasKey)
|
|
55
|
+
return { ok: false, provider: active, model: p.model, hasKey: false, reason: `provider ${active} 需要 apiKey 但没配` };
|
|
56
|
+
return { ok: true, provider: active, model: p.model, hasKey };
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
return { ok: false, reason: `llm-config.json 不可读: ${String(err?.message || err).slice(0, 100)}` };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
async function stage(stages, name, required, timeoutMs, fn, log, noteOf) {
|
|
63
|
+
const t0 = Date.now();
|
|
64
|
+
log?.(`[resolver] ${name} 开始`);
|
|
65
|
+
try {
|
|
66
|
+
const value = await Promise.race([
|
|
67
|
+
fn(),
|
|
68
|
+
new Promise((_r, rej) => {
|
|
69
|
+
const t = setTimeout(() => rej(new Error(`${name} 超时 (${timeoutMs}ms)`)), timeoutMs);
|
|
70
|
+
t.unref?.();
|
|
71
|
+
}),
|
|
72
|
+
]);
|
|
73
|
+
const report = { stage: name, ok: true, ms: Date.now() - t0, required, note: noteOf?.(value) };
|
|
74
|
+
stages.push(report);
|
|
75
|
+
log?.(`[resolver] ${name} 完成 ${report.ms}ms`);
|
|
76
|
+
return { ok: true, value, report };
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
const report = {
|
|
80
|
+
stage: name, ok: false, ms: Date.now() - t0, required,
|
|
81
|
+
error: String(err?.message || err).slice(0, 200),
|
|
82
|
+
errorClass: classify(err, name),
|
|
83
|
+
};
|
|
84
|
+
stages.push(report);
|
|
85
|
+
log?.(`[resolver] ${name} 失败 ${report.ms}ms: ${report.error}`);
|
|
86
|
+
return { ok: false, report };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/** 阶段报告一行摘要 (CLI / 日志 / wakeReport 用) */
|
|
90
|
+
export function formatStages(stages) {
|
|
91
|
+
return stages.map((s) => `${s.stage}${s.ok ? '✓' : '✗'}${s.ms}ms`).join(' → ');
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* 分阶段解析"谁来执行这个 Goal"。
|
|
95
|
+
* 与 web 宿主同构: 都返回一个 runner; 差别只在"用什么上下文建 session"。
|
|
96
|
+
*/
|
|
97
|
+
export async function resolveGoalRunner(req, opts = {}) {
|
|
98
|
+
const home = opts.home ?? os.homedir();
|
|
99
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
100
|
+
const log = opts.log;
|
|
101
|
+
const stageTimeout = opts.stageTimeoutMs ?? 30_000;
|
|
102
|
+
const createTimeout = opts.createTimeoutMs ?? (Number(process.env.BOLLOON_SUPERVISE_CREATE_TIMEOUT_MS) || 20_000);
|
|
103
|
+
const stages = [];
|
|
104
|
+
const fail = (report, reason) => ({
|
|
105
|
+
ok: false, kind: 'none', reason, stages, failedStage: report.stage,
|
|
106
|
+
});
|
|
107
|
+
if (opts.allow === false) {
|
|
108
|
+
return fail({ stage: 'resolve_goal', ok: false, ms: 0, required: true, error: '本地执行被显式关闭' }, 'resolve_goal: 本地执行被显式关闭 (BOLLOON_SUPERVISE_AGENT=0) → 只诊断');
|
|
109
|
+
}
|
|
110
|
+
// 1. resolve_goal —— 目标是否可执行
|
|
111
|
+
const g = req.goal;
|
|
112
|
+
const goalCheck = await stage(stages, 'resolve_goal', true, stageTimeout, async () => {
|
|
113
|
+
if (!g?.goalId)
|
|
114
|
+
throw new Error('没有 goalId');
|
|
115
|
+
const fresh = await readGoal(g.goalId);
|
|
116
|
+
if (!fresh)
|
|
117
|
+
throw new Error(`Goal 不存在: ${g.goalId}`);
|
|
118
|
+
if (fresh.status === 'paused' || fresh.status === 'needs_human')
|
|
119
|
+
throw new Error(`Goal 状态 ${fresh.status} 不允许自动执行`);
|
|
120
|
+
return fresh;
|
|
121
|
+
}, log);
|
|
122
|
+
if (!goalCheck.ok)
|
|
123
|
+
return fail(goalCheck.report, `resolve_goal: ${goalCheck.report.error} → 只诊断, Goal 状态未改动`);
|
|
124
|
+
const goal = goalCheck.value;
|
|
125
|
+
// 2. resolve_agent —— 用哪个 agent 身份执行
|
|
126
|
+
const agentCheck = await stage(stages, 'resolve_agent', true, stageTimeout, async () => {
|
|
127
|
+
if (!goal.channelId && !req.prevRunId)
|
|
128
|
+
throw new Error('Goal 没有 channelId (无法确定 agent 上下文)');
|
|
129
|
+
return { channelId: goal.channelId || '', agentId: goal.agentId || '' };
|
|
130
|
+
}, log);
|
|
131
|
+
if (!agentCheck.ok)
|
|
132
|
+
return fail(agentCheck.report, `resolve_agent: ${agentCheck.report.error} → 只诊断 (不建 Run)`);
|
|
133
|
+
const { channelId, agentId } = agentCheck.value;
|
|
134
|
+
// 3. load_identity —— 身份可读性 (缺身份不致命, 但要留痕)
|
|
135
|
+
await stage(stages, 'load_identity', false, stageTimeout, async () => {
|
|
136
|
+
const files = ['keypair.json', 'agent-registry.json'].map((f) => path.join(home, '.bolloon', f));
|
|
137
|
+
const found = [];
|
|
138
|
+
for (const f of files)
|
|
139
|
+
if (await fsp.stat(f).then(() => true).catch(() => false))
|
|
140
|
+
found.push(path.basename(f));
|
|
141
|
+
return found;
|
|
142
|
+
}, log, (found) => (found.length ? `身份文件: ${found.join(', ')}` : '无身份文件 (用系统临时身份)'));
|
|
143
|
+
// 4. load_session —— 会话存储可读性
|
|
144
|
+
await stage(stages, 'load_session', false, stageTimeout, async () => {
|
|
145
|
+
const dir = path.join(home, '.bolloon', 'sessions');
|
|
146
|
+
return fsp.stat(dir).then((st) => st.isDirectory()).catch(() => false);
|
|
147
|
+
}, log, (ok) => (ok ? `会话目录可读: ${path.join(home, '.bolloon', 'sessions')}` : '会话目录不存在 (将新建)'));
|
|
148
|
+
// 5. load_skills —— 技能视图可读性 (2-G.2 起会变成必需阶段)
|
|
149
|
+
await stage(stages, 'load_skills', false, stageTimeout, async () => {
|
|
150
|
+
const { getSkillsManager } = await import('./skills-manager.js');
|
|
151
|
+
const list = await getSkillsManager({ home, cwd }).view();
|
|
152
|
+
return { total: list.length, enabled: list.filter((s) => s.status === 'enabled').length, invalid: list.filter((s) => s.status === 'invalid').length };
|
|
153
|
+
}, log, (r) => `技能视图: ${r.total} 个 (enabled ${r.enabled}, invalid ${r.invalid})`);
|
|
154
|
+
// 6. init_llm —— **必需**: LLM 可用 (否则 agent 会退化成 fallback, 既不调模型也不建 Run)
|
|
155
|
+
const llmCheck = await stage(stages, 'init_llm', true, stageTimeout, async () => {
|
|
156
|
+
const probe = opts.probeLlm ? await opts.probeLlm() : await probeLlmConfig(home);
|
|
157
|
+
if (!probe.ok)
|
|
158
|
+
throw new Error(probe.reason || 'LLM 不可用');
|
|
159
|
+
const { initMinimax } = await import('../constraints/index.js');
|
|
160
|
+
initMinimax();
|
|
161
|
+
return probe;
|
|
162
|
+
}, log, (probe) => `provider=${probe.provider} model=${probe.model ?? '(默认)'} key=${probe.hasKey ? '有' : '免'}`);
|
|
163
|
+
if (!llmCheck.ok) {
|
|
164
|
+
return fail(llmCheck.report, `init_llm: ${llmCheck.report.error} → 只诊断 (不发起会退化成 fallback 的执行)`);
|
|
165
|
+
}
|
|
166
|
+
// 7. create_session —— 真 agent session
|
|
167
|
+
let agent;
|
|
168
|
+
const sessCheck = await stage(stages, 'create_session', true, createTimeout, async () => {
|
|
169
|
+
if (opts.createAgent) {
|
|
170
|
+
agent = await opts.createAgent(channelId, goal.goalId);
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
const { createAgentSession } = await import('./pi-sdk.js');
|
|
174
|
+
agent = await createAgentSession({ cwd, peerId: `supervise:${channelId || agentId}`, channelId: channelId || undefined }, true);
|
|
175
|
+
return !!agent;
|
|
176
|
+
}, log);
|
|
177
|
+
if (!sessCheck.ok || !agent)
|
|
178
|
+
return fail(sessCheck.report, `create_session: ${sessCheck.report.error} → 只诊断 (不建 Run)`);
|
|
179
|
+
// 8. prepare_resume —— 恢复计划 (只读; 真正 prepareResume 由 runner 在运行时做, 保证状态迁移与执行同一时刻)
|
|
180
|
+
let resumeNote = '新 Run (无历史)';
|
|
181
|
+
if (req.prevRunId) {
|
|
182
|
+
const planCheck = await stage(stages, 'prepare_resume', false, stageTimeout, async () => {
|
|
183
|
+
const plan = await buildContinuationPlan(req.prevRunId);
|
|
184
|
+
if (!plan)
|
|
185
|
+
throw new Error(`读不到上一条 Run: ${req.prevRunId}`);
|
|
186
|
+
resumeNote = `已完成 ${plan.completedSteps.length} 步, 非幂等守卫 ${plan.replayGuards.length} 条`;
|
|
187
|
+
return plan;
|
|
188
|
+
}, log);
|
|
189
|
+
if (!planCheck.ok)
|
|
190
|
+
resumeNote = `恢复计划读取失败: ${planCheck.report.error}`;
|
|
191
|
+
}
|
|
192
|
+
stages.push({ stage: 'ready', ok: true, ms: 0, required: true, note: `runner 就绪 (channel=${channelId || '-'}, ${resumeNote})` });
|
|
193
|
+
const runner = async (r) => {
|
|
194
|
+
if (r.kind === 'resume' && r.prevRunId && typeof agent.resumeRun === 'function') {
|
|
195
|
+
const res = await agent.resumeRun(r.prevRunId);
|
|
196
|
+
return { runId: r.prevRunId, status: res?.ok ? 'done' : 'failed', error: res?.ok ? undefined : res?.reason };
|
|
197
|
+
}
|
|
198
|
+
agent.setGoalId?.(goal.goalId);
|
|
199
|
+
agent.setContinuationGuards?.(r.guards || []);
|
|
200
|
+
const reply = await agent.prompt(r.instruction);
|
|
201
|
+
const runId = agent.getLastRunId?.() || agent.getRunId?.() || '';
|
|
202
|
+
// 没有 Run = 这次执行没有事实记录: 如实报告, 不让上层把"没跑"当"跑完"
|
|
203
|
+
if (!runId) {
|
|
204
|
+
return { status: 'failed', error: `执行没有产生 Run 记录 (agent 可能走了 fallback/未初始化路径): ${String(reply || '').slice(0, 120)}` };
|
|
205
|
+
}
|
|
206
|
+
return { runId, status: 'done', reply: typeof reply === 'string' ? reply.slice(0, 500) : undefined };
|
|
207
|
+
};
|
|
208
|
+
return { ok: true, kind: 'standalone', runner, stages };
|
|
209
|
+
}
|
|
210
|
+
/** 已启动的独立宿主把最近一次解析报告写给上层 (wakeReport / API / CLI 用) */
|
|
211
|
+
export function latestFailedStage(res) {
|
|
212
|
+
return { stage: res.failedStage, reason: res.ok ? undefined : res.reason };
|
|
213
|
+
}
|
|
214
|
+
/** 供 CLI/诊断: 一次解析的完整人类可读报告 */
|
|
215
|
+
export function describeResolution(res) {
|
|
216
|
+
const failed = res.stages.find((s) => !s.ok);
|
|
217
|
+
const head = res.ok ? '✅ 可执行' : `⛔ 不可执行 (卡在 ${res.failedStage})`;
|
|
218
|
+
const lines = [`${head}${res.reason ? ` — ${res.reason}` : ''}`, `阶段: ${formatStages(res.stages)}`];
|
|
219
|
+
for (const s of res.stages)
|
|
220
|
+
if (s.note)
|
|
221
|
+
lines.push(` · ${s.stage}: ${s.note}`);
|
|
222
|
+
if (failed?.error)
|
|
223
|
+
lines.push(` ✗ 失败分类=${failed.errorClass} 原因=${failed.error}`);
|
|
224
|
+
return lines.join('\n');
|
|
225
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* skill-readiness.ts — Goal 级技能快照 + 执行前就绪门禁 (批次 2-G.2, 2026-09-16)
|
|
3
|
+
*
|
|
4
|
+
* 定位 (与 leo 的规格一致): 这属于**执行前准备**, 由 Supervisor / runner resolver 负责 ——
|
|
5
|
+
* **不放进 PiAgentHarness** (Harness 只管"这一段能不能安全执行")。
|
|
6
|
+
*
|
|
7
|
+
* 规则:
|
|
8
|
+
* · Goal 首次执行时把 requiredSkills 解析成 snapshot (name/version/contentHash/source/resolvedAt) 并冻结;
|
|
9
|
+
* · 后续 Run 只认快照: 缺技能 / 未启用 / 损坏 / hash 漂移 / 版本变化 → **不启动 Run**, Goal → needs_human;
|
|
10
|
+
* · 前缀 '?' 的技能算可选: 缺失不阻塞, 但写一条 degradation;
|
|
11
|
+
* · **不允许静默用新版本** —— 漂移必须人工批准 (approve) 才会更新快照。
|
|
12
|
+
*/
|
|
13
|
+
import * as os from 'os';
|
|
14
|
+
import { SkillsManager } from './skills-manager.js';
|
|
15
|
+
import { readGoal, setContinuation, updateGoal, addEvidence, } from './goal-store.js';
|
|
16
|
+
import { recordDegradation } from './run-store.js';
|
|
17
|
+
export function parseSkillSpecs(list = []) {
|
|
18
|
+
const required = [];
|
|
19
|
+
const optional = [];
|
|
20
|
+
for (const raw of list) {
|
|
21
|
+
const n = String(raw || '').trim();
|
|
22
|
+
if (!n)
|
|
23
|
+
continue;
|
|
24
|
+
if (n.startsWith('?'))
|
|
25
|
+
optional.push(n.slice(1).trim());
|
|
26
|
+
else
|
|
27
|
+
required.push(n);
|
|
28
|
+
}
|
|
29
|
+
return { required, optional };
|
|
30
|
+
}
|
|
31
|
+
function userHome(home) {
|
|
32
|
+
return home || process.env.HOME || os.homedir();
|
|
33
|
+
}
|
|
34
|
+
function manager(home) {
|
|
35
|
+
return new SkillsManager({ home: userHome(home), cwd: process.cwd() });
|
|
36
|
+
}
|
|
37
|
+
/** 冻结技能快照 (首次执行时调用; 已冻结则不重复解析) */
|
|
38
|
+
export async function freezeGoalSkills(goal, opts = {}) {
|
|
39
|
+
const { required } = parseSkillSpecs(goal.requiredSkills || []);
|
|
40
|
+
if (!required.length)
|
|
41
|
+
return { ok: true, snapshot: goal.skillSnapshot || [], missing: [], notEnabled: [] };
|
|
42
|
+
if (goal.skillSnapshot?.length && !opts.force)
|
|
43
|
+
return { ok: true, snapshot: goal.skillSnapshot, missing: [], notEnabled: [] };
|
|
44
|
+
const sm = manager(opts.home);
|
|
45
|
+
const res = await sm.snapshot(required, { home: userHome(opts.home) });
|
|
46
|
+
if (!res.ok || res.missing.length) {
|
|
47
|
+
return { ok: false, missing: res.missing, notEnabled: [], reason: `必需技能缺失: ${res.missing.join(', ')}` };
|
|
48
|
+
}
|
|
49
|
+
const snapshot = res.entries.map((e) => ({ name: e.name, version: e.version, contentHash: e.contentHash, source: e.source, resolvedAt: e.resolvedAt }));
|
|
50
|
+
await updateGoal(goal.goalId, { skillSnapshot: snapshot });
|
|
51
|
+
await addEvidence(goal.goalId, [`技能快照已冻结: ${snapshot.map((x) => `${x.name}@${x.version}:${x.contentHash.slice(0, 8)}`).join(', ')}`]).catch(() => { });
|
|
52
|
+
return { ok: true, snapshot, missing: [], notEnabled: [] };
|
|
53
|
+
}
|
|
54
|
+
/** 执行前就绪门禁 (Supervisor 每次准备起 Run 之前调) */
|
|
55
|
+
export async function ensureGoalSkillsReady(goal, opts = {}) {
|
|
56
|
+
const { required, optional } = parseSkillSpecs(goal.requiredSkills || []);
|
|
57
|
+
const out = { ok: true, missing: [], notEnabled: [], invalid: [], drift: [], degradations: [] };
|
|
58
|
+
if (!required.length && !optional.length)
|
|
59
|
+
return out;
|
|
60
|
+
const frozen = await freezeGoalSkills(goal, opts);
|
|
61
|
+
if (!frozen.ok) {
|
|
62
|
+
out.ok = false;
|
|
63
|
+
out.missing = frozen.missing;
|
|
64
|
+
out.reason = frozen.reason || '技能快照无法冻结';
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
const snapshot = frozen.snapshot || [];
|
|
68
|
+
out.snapshot = snapshot;
|
|
69
|
+
// 逐个校验快照 (真实 registry + 真实文件 hash)
|
|
70
|
+
const sm = manager(opts.home);
|
|
71
|
+
const home = userHome(opts.home);
|
|
72
|
+
for (const entry of snapshot) {
|
|
73
|
+
const rec = await sm.inspect(entry.name, { home });
|
|
74
|
+
if (!rec) {
|
|
75
|
+
out.missing.push(entry.name);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (rec.status !== 'enabled' && rec.status !== 'installed')
|
|
79
|
+
out.notEnabled.push(`${entry.name}(${rec.status})`);
|
|
80
|
+
if (rec.contentHash !== entry.contentHash)
|
|
81
|
+
out.drift.push({ name: entry.name, expected: entry.contentHash, actual: rec.contentHash });
|
|
82
|
+
if (rec.version !== entry.version)
|
|
83
|
+
out.drift.push({ name: entry.name, expected: entry.version, actual: rec.version });
|
|
84
|
+
const v = await sm.validate(entry.name, { home }).catch(() => null);
|
|
85
|
+
if (v && !v.ok)
|
|
86
|
+
out.invalid.push(`${entry.name}: ${(v.issues || []).slice(0, 2).join('; ')}`);
|
|
87
|
+
}
|
|
88
|
+
// 可选技能: 缺失/未启用 → 只记 degradation (不阻塞)
|
|
89
|
+
for (const name of optional) {
|
|
90
|
+
const rec = await sm.inspect(name, { home });
|
|
91
|
+
if (!rec) {
|
|
92
|
+
out.degradations.push(`可选技能 ${name} 不存在 → 继续执行 (已记降级)`);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (rec.status !== 'enabled' && rec.status !== 'installed')
|
|
96
|
+
out.degradations.push(`可选技能 ${name} 未启用 (${rec.status}) → 继续执行`);
|
|
97
|
+
}
|
|
98
|
+
if (out.missing.length)
|
|
99
|
+
out.reason = `必需技能缺失: ${out.missing.join(', ')}`;
|
|
100
|
+
else if (out.notEnabled.length)
|
|
101
|
+
out.reason = `必需技能未启用: ${out.notEnabled.join(', ')}`;
|
|
102
|
+
else if (out.invalid.length)
|
|
103
|
+
out.reason = `必需技能损坏: ${out.invalid.join(', ')}`;
|
|
104
|
+
else if (out.drift.length)
|
|
105
|
+
out.reason = `技能内容漂移 (需人工批准才能升级): ${out.drift.map((d) => `${d.name} ${String(d.expected).slice(0, 8)}→${String(d.actual).slice(0, 8)}`).join(', ')}`;
|
|
106
|
+
out.ok = !(out.missing.length || out.notEnabled.length || out.invalid.length || out.drift.length);
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
/** 门禁不过 → 写清事实并把 Goal 交给人 (不启动 Run, 不伪造失败) */
|
|
110
|
+
export async function blockGoalOnSkills(goalId, res) {
|
|
111
|
+
const reason = res.reason || '技能未就绪';
|
|
112
|
+
await setContinuation(goalId, {
|
|
113
|
+
wakeReason: 'needs_human', autoContinue: false, needsExternal: undefined,
|
|
114
|
+
skillReadiness: { ok: false, at: new Date().toISOString(), reason, missing: res.missing, drift: res.drift, degradations: res.degradations },
|
|
115
|
+
});
|
|
116
|
+
await updateGoal(goalId, { status: 'needs_human' }).catch(() => { });
|
|
117
|
+
await addEvidence(goalId, [`技能门禁拦截: ${reason}`]).catch(() => { });
|
|
118
|
+
for (const d of res.degradations)
|
|
119
|
+
await recordDegradation({ kind: 'observational', op: 'skill-readiness', message: d }).catch(() => { });
|
|
120
|
+
}
|
|
121
|
+
/** 人工批准技能升级: 重新冻结快照 (显式动作, 不隐式切换) */
|
|
122
|
+
export async function approveSkillUpgrade(goalId, opts = {}) {
|
|
123
|
+
const goal = await readGoal(goalId);
|
|
124
|
+
if (!goal)
|
|
125
|
+
return { ok: false, reason: 'Goal 不存在' };
|
|
126
|
+
const frozen = await freezeGoalSkills(goal, { ...opts, force: true });
|
|
127
|
+
if (!frozen.ok)
|
|
128
|
+
return { ok: false, reason: frozen.reason };
|
|
129
|
+
await setContinuation(goalId, { skillReadiness: { ok: true, at: new Date().toISOString(), reason: '人工批准技能升级' }, wakeReason: 'active', autoContinue: true });
|
|
130
|
+
await updateGoal(goalId, { status: 'active' }).catch(() => { });
|
|
131
|
+
await addEvidence(goalId, [`技能升级已被人工批准: ${(frozen.snapshot || []).map((s) => `${s.name}@${s.version}`).join(', ')}`]).catch(() => { });
|
|
132
|
+
return { ok: true, snapshot: frozen.snapshot };
|
|
133
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* skill-supervisor-link.ts — 技能状态与长期执行的联动 (批次 2-G.4, 2026-09-16)
|
|
3
|
+
*
|
|
4
|
+
* 规则 (与 leo 的规格一致):
|
|
5
|
+
* · 技能导入成功 / 又能用了 → 被它拦住的 Goal 重新冻结快照并回到 active (等 Supervisor 继续调度);
|
|
6
|
+
* · 技能被禁用 / 隔离 / 内容漂移 → **不打断正在跑的 Run**, 但下一次 Run 之前 readiness 必然失败 (2-G.2 门禁) → needs_human;
|
|
7
|
+
* · 漂移**不允许隐式切换版本**: 继续固定旧快照, 或等人工 approve 升级 (approveSkillUpgrade)。
|
|
8
|
+
*
|
|
9
|
+
* 这里只改"该不该继续"的事实, 不直接启动执行 —— 执行权始终在 Supervisor。
|
|
10
|
+
*/
|
|
11
|
+
import { listGoals } from './goal-store.js';
|
|
12
|
+
import { ensureGoalSkillsReady, blockGoalOnSkills, approveSkillUpgrade, parseSkillSpecs } from './skill-readiness.js';
|
|
13
|
+
import { SkillsManager } from './skills-manager.js';
|
|
14
|
+
import { recordDegradation } from './run-store.js';
|
|
15
|
+
/**
|
|
16
|
+
* 重评所有"因技能被拦"的 Goal。
|
|
17
|
+
* @param opts.action 触发原因 (import / enable / disable / quarantine / drift) —— 只用于事实记录
|
|
18
|
+
*/
|
|
19
|
+
export async function reconsiderSkillBlockedGoals(opts = {}) {
|
|
20
|
+
const out = { rechecked: 0, resumed: [], stillBlocked: [] };
|
|
21
|
+
const home = opts.home;
|
|
22
|
+
const goals = await listGoals({ limit: 200 });
|
|
23
|
+
const interesting = goals.filter((g) => {
|
|
24
|
+
const r = g.continuation?.skillReadiness;
|
|
25
|
+
const specs = parseSkillSpecs(g.requiredSkills || []);
|
|
26
|
+
return (r && r.ok === false) || specs.required.length > 0;
|
|
27
|
+
});
|
|
28
|
+
for (const g of interesting) {
|
|
29
|
+
// 只处理"被技能拦住"的 Goal: 其它原因等人的不要乱动
|
|
30
|
+
const blockedBySkill = g.continuation?.skillReadiness?.ok === false;
|
|
31
|
+
if (!blockedBySkill)
|
|
32
|
+
continue;
|
|
33
|
+
// 若是被人工标记 needs_human 且与技能无关, 跳过
|
|
34
|
+
out.rechecked++;
|
|
35
|
+
const res = await ensureGoalSkillsReady(g, { home });
|
|
36
|
+
if (res.ok) {
|
|
37
|
+
// 技能恢复了 → 重新冻结快照 + 回 active (Supervisor 下一轮继续)
|
|
38
|
+
const approved = await approveSkillUpgrade(g.goalId, { home });
|
|
39
|
+
if (approved.ok) {
|
|
40
|
+
out.resumed.push(g.goalId);
|
|
41
|
+
console.log(`[skill-link] ${g.goalId} 技能已恢复 (${opts.action || 'unknown'}${opts.name ? `:${opts.name}` : ''}) → 重新冻结快照并回到 active`);
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
out.stillBlocked.push(g.goalId);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
await blockGoalOnSkills(g.goalId, res);
|
|
49
|
+
out.stillBlocked.push(g.goalId);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
/** 技能被禁用/隔离/漂移时, 把依赖它的活跃 Goal 标清事实 (不打断当前 Run) */
|
|
55
|
+
export async function markDependentsOfSkill(name, opts = { reason: '技能不可用' }) {
|
|
56
|
+
const sm = new SkillsManager({ home: opts.home, cwd: process.cwd() });
|
|
57
|
+
const health = await sm.health({ home: opts.home }).catch(() => null);
|
|
58
|
+
const goals = await listGoals({ limit: 200 });
|
|
59
|
+
const affected = [];
|
|
60
|
+
for (const g of goals) {
|
|
61
|
+
const { required } = parseSkillSpecs(g.requiredSkills || []);
|
|
62
|
+
if (!required.includes(name))
|
|
63
|
+
continue;
|
|
64
|
+
if (['completed', 'failed', 'abandoned'].includes(g.status))
|
|
65
|
+
continue;
|
|
66
|
+
affected.push(g.goalId);
|
|
67
|
+
await recordDegradation({ kind: 'observational', op: 'skill-link', message: `Goal ${g.goalId} 依赖的技能 ${name} 状态: ${JSON.stringify(health?.byStatus || {})} (${opts.reason}) — 下一次 Run 前会重新门禁` }).catch(() => { });
|
|
68
|
+
}
|
|
69
|
+
return affected;
|
|
70
|
+
}
|