@bolloon/bolloon-agent 0.4.33 → 0.5.1
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/contacts/chain.js +27 -7
- package/dist/agents/execution-supervisor.js +580 -40
- package/dist/agents/external-events.js +35 -6
- package/dist/agents/gateway-group.js +100 -12
- package/dist/agents/goal-criteria.js +10 -3
- package/dist/agents/goal-flywheel/block-executor.js +346 -0
- package/dist/agents/goal-flywheel/continuation-decision.js +592 -0
- package/dist/agents/goal-flywheel/goal-change.js +754 -0
- package/dist/agents/goal-flywheel/index.js +26 -0
- package/dist/agents/goal-flywheel/memory-layers.js +227 -0
- package/dist/agents/goal-flywheel/run-closure.js +809 -0
- package/dist/agents/goal-flywheel/skill-candidate.js +628 -0
- package/dist/agents/goal-flywheel/types.js +329 -0
- package/dist/agents/goal-flywheel/wiring/change.js +288 -0
- package/dist/agents/goal-flywheel/wiring/closure.js +697 -0
- package/dist/agents/goal-flywheel/wiring/continuation.js +432 -0
- package/dist/agents/goal-flywheel/wiring/contract.js +270 -0
- package/dist/agents/goal-flywheel/wiring/index.js +26 -0
- package/dist/agents/goal-flywheel/wiring/monitor.js +254 -0
- package/dist/agents/goal-flywheel/wiring/seams.js +592 -0
- package/dist/agents/goal-flywheel/work-contract.js +478 -0
- package/dist/agents/goal-flywheel/work-monitor.js +488 -0
- package/dist/agents/goal-flywheel-wiring.js +1870 -0
- package/dist/agents/goal-state-reducer.js +406 -0
- package/dist/agents/goal-store.js +35 -1
- package/dist/agents/mobile-flywheel-view.js +144 -0
- package/dist/agents/mobile-task-actions-verify.js +74 -0
- package/dist/agents/mobile-task-actions.js +178 -0
- package/dist/agents/mobile-task-views.js +199 -0
- package/dist/agents/model-policy.js +651 -0
- package/dist/agents/network-pulse.js +304 -18
- package/dist/agents/pi-sdk-tools.js +84 -33
- package/dist/agents/pi-sdk.js +157 -42
- package/dist/agents/run-store.js +71 -5
- package/dist/agents/skill-readiness.js +52 -10
- package/dist/agents/subagent-manager.js +209 -3
- package/dist/agents/task/task-runner.js +17 -4
- package/dist/agents/task-group.js +8 -51
- package/dist/agents/task-public-text.js +89 -0
- package/dist/agents/workflow-pivot-loop.js +19 -12
- package/dist/cli/clipboard.js +65 -0
- package/dist/cli/commands/index.js +1 -0
- package/dist/cli/commands/tasks.js +350 -2
- package/dist/cli/identity-command.js +177 -0
- package/dist/cli/ink-app.js +145 -17
- package/dist/cli/log-gate.js +386 -0
- package/dist/cli/mention-data.js +2 -0
- package/dist/cli/model-selector.js +538 -0
- package/dist/cli/reply-hygiene.js +54 -0
- package/dist/cli/setup-wizard.js +757 -69
- package/dist/cli/startup-notice.js +160 -0
- package/dist/cli/stores.js +22 -1
- package/dist/cli/theme.js +49 -0
- package/dist/cli/tui-select.js +970 -0
- package/dist/cli/update-commands.js +150 -24
- package/dist/cli-entry.js +137 -20
- package/dist/electron-build/agents/contacts/chain.js +26 -6
- package/dist/electron-build/agents/contacts/chain.js.map +1 -1
- package/dist/electron-build/agents/execution-supervisor.js +578 -38
- package/dist/electron-build/agents/execution-supervisor.js.map +1 -1
- package/dist/electron-build/agents/external-events.js +34 -5
- package/dist/electron-build/agents/external-events.js.map +1 -1
- package/dist/electron-build/agents/goal-criteria.js +10 -3
- package/dist/electron-build/agents/goal-criteria.js.map +1 -1
- package/dist/electron-build/agents/goal-flywheel/continuation-decision.js +598 -0
- package/dist/electron-build/agents/goal-flywheel/continuation-decision.js.map +1 -0
- package/dist/electron-build/agents/goal-flywheel/goal-change.js +773 -0
- package/dist/electron-build/agents/goal-flywheel/goal-change.js.map +1 -0
- package/dist/electron-build/agents/goal-flywheel/memory-layers.js +269 -0
- package/dist/electron-build/agents/goal-flywheel/memory-layers.js.map +1 -0
- package/dist/electron-build/agents/goal-flywheel/run-closure.js +818 -0
- package/dist/electron-build/agents/goal-flywheel/run-closure.js.map +1 -0
- package/dist/electron-build/agents/goal-flywheel/skill-candidate.js +642 -0
- package/dist/electron-build/agents/goal-flywheel/skill-candidate.js.map +1 -0
- package/dist/electron-build/agents/goal-flywheel/types.js +333 -0
- package/dist/electron-build/agents/goal-flywheel/types.js.map +1 -0
- package/dist/electron-build/agents/goal-flywheel/wiring/change.js +294 -0
- package/dist/electron-build/agents/goal-flywheel/wiring/change.js.map +1 -0
- package/dist/electron-build/agents/goal-flywheel/wiring/closure.js +742 -0
- package/dist/electron-build/agents/goal-flywheel/wiring/closure.js.map +1 -0
- package/dist/electron-build/agents/goal-flywheel/wiring/continuation.js +444 -0
- package/dist/electron-build/agents/goal-flywheel/wiring/continuation.js.map +1 -0
- package/dist/electron-build/agents/goal-flywheel/wiring/contract.js +279 -0
- package/dist/electron-build/agents/goal-flywheel/wiring/contract.js.map +1 -0
- package/dist/electron-build/agents/goal-flywheel/wiring/index.js +111 -0
- package/dist/electron-build/agents/goal-flywheel/wiring/index.js.map +1 -0
- package/dist/electron-build/agents/goal-flywheel/wiring/monitor.js +264 -0
- package/dist/electron-build/agents/goal-flywheel/wiring/monitor.js.map +1 -0
- package/dist/electron-build/agents/goal-flywheel/wiring/seams.js +612 -0
- package/dist/electron-build/agents/goal-flywheel/wiring/seams.js.map +1 -0
- package/dist/electron-build/agents/goal-flywheel/work-contract.js +488 -0
- package/dist/electron-build/agents/goal-flywheel/work-contract.js.map +1 -0
- package/dist/electron-build/agents/goal-flywheel/work-monitor.js +501 -0
- package/dist/electron-build/agents/goal-flywheel/work-monitor.js.map +1 -0
- package/dist/electron-build/agents/goal-flywheel-wiring.js +1962 -0
- package/dist/electron-build/agents/goal-flywheel-wiring.js.map +1 -0
- package/dist/electron-build/agents/goal-state-reducer.js +414 -0
- package/dist/electron-build/agents/goal-state-reducer.js.map +1 -0
- package/dist/electron-build/agents/goal-store.js +35 -1
- package/dist/electron-build/agents/goal-store.js.map +1 -1
- package/dist/electron-build/agents/model-policy.js +706 -0
- package/dist/electron-build/agents/model-policy.js.map +1 -0
- package/dist/electron-build/agents/network-pulse.js +307 -19
- package/dist/electron-build/agents/network-pulse.js.map +1 -1
- package/dist/electron-build/agents/pi-sdk-tools.js +84 -33
- package/dist/electron-build/agents/pi-sdk-tools.js.map +1 -1
- package/dist/electron-build/agents/pi-sdk-types.js.map +1 -1
- package/dist/electron-build/agents/pi-sdk.js +156 -41
- package/dist/electron-build/agents/pi-sdk.js.map +1 -1
- package/dist/electron-build/agents/run-store.js +75 -6
- package/dist/electron-build/agents/run-store.js.map +1 -1
- package/dist/electron-build/agents/skill-readiness.js +52 -9
- package/dist/electron-build/agents/skill-readiness.js.map +1 -1
- package/dist/electron-build/agents/task/task-runner.js +16 -3
- package/dist/electron-build/agents/task/task-runner.js.map +1 -1
- package/dist/electron-build/agents/workflow-pivot-loop.js +19 -12
- package/dist/electron-build/agents/workflow-pivot-loop.js.map +1 -1
- package/dist/electron-build/llm/config-store.js +146 -6
- package/dist/electron-build/llm/config-store.js.map +1 -1
- package/dist/electron-build/llm/connection-probe.js +764 -0
- package/dist/electron-build/llm/connection-probe.js.map +1 -0
- package/dist/electron-build/llm/custom-provider-store.js +272 -0
- package/dist/electron-build/llm/custom-provider-store.js.map +1 -0
- package/dist/electron-build/llm/data/provider-catalog-baked.js +23 -0
- package/dist/electron-build/llm/data/provider-catalog-baked.js.map +1 -0
- package/dist/electron-build/llm/model-catalog.js +778 -0
- package/dist/electron-build/llm/model-catalog.js.map +1 -0
- package/dist/electron-build/llm/model-discovery.js +1202 -0
- package/dist/electron-build/llm/model-discovery.js.map +1 -0
- package/dist/electron-build/llm/model-selection.js +1315 -0
- package/dist/electron-build/llm/model-selection.js.map +1 -0
- package/dist/electron-build/llm/pi-ai.js +70 -11
- package/dist/electron-build/llm/pi-ai.js.map +1 -1
- package/dist/electron-build/llm/provider-catalog.js +969 -0
- package/dist/electron-build/llm/provider-catalog.js.map +1 -0
- package/dist/electron-build/llm/provider-registry.js +950 -0
- package/dist/electron-build/llm/provider-registry.js.map +1 -0
- package/dist/electron-build/llm/tool-name.js +266 -0
- package/dist/electron-build/llm/tool-name.js.map +1 -0
- package/dist/electron-build/orbitdb/cid-database.js +83 -17
- package/dist/electron-build/orbitdb/cid-database.js.map +1 -1
- package/dist/electron-build/orbitdb/ipfs-node.js +31 -2
- package/dist/electron-build/orbitdb/ipfs-node.js.map +1 -1
- package/dist/electron-build/utils/dual-source-facts.js +236 -0
- package/dist/electron-build/utils/dual-source-facts.js.map +1 -0
- package/dist/electron-build/utils/dual-source.js +347 -0
- package/dist/electron-build/utils/dual-source.js.map +1 -0
- package/dist/electron-build/utils/update-manager.js +328 -104
- package/dist/electron-build/utils/update-manager.js.map +1 -1
- package/dist/electron-build/utils/update-state.js +9 -11
- package/dist/electron-build/utils/update-state.js.map +1 -1
- package/dist/electron-build/utils/version-identity.js +193 -0
- package/dist/electron-build/utils/version-identity.js.map +1 -0
- package/dist/electron-build/utils/version-info.js +57 -31
- package/dist/electron-build/utils/version-info.js.map +1 -1
- package/dist/index.js +385 -108
- package/dist/ios/bolloon-web.json +10 -0
- package/dist/ios/chain-explorer.js +617 -0
- package/dist/ios/explorer.html +163 -0
- package/dist/ios/index.html +96 -0
- package/dist/ios/mobile-agent.js +2 -2
- package/dist/ios/mobile-contacts.js +362 -0
- package/dist/ios/mobile-core.js +3102 -310
- package/dist/ios/mobile-privacy.js +185 -0
- package/dist/ios/mobile-tasks.js +339 -0
- package/dist/ios/mobile-update.js +1442 -0
- package/dist/ios/mobile.css +19 -0
- package/dist/ios/mobile.html +96 -0
- package/dist/ios/mobile.js +1013 -41
- package/dist/ios/routes-chain-index.js +108 -0
- package/dist/ios/routes-contacts.js +420 -0
- package/dist/ios/routes-llm-config.js +344 -52
- package/dist/ios/routes-mobile-tasks.js +353 -0
- package/dist/ios/routes-onchain-trade.js +99 -0
- package/dist/ios/routes-x402-info.js +1 -0
- package/dist/ios/server.js +921 -0
- package/dist/ios/sw.js +1 -1
- package/dist/ios/task-frame-server.js +211 -0
- package/dist/llm/config-store.js +146 -6
- package/dist/llm/connection-probe.js +744 -0
- package/dist/llm/custom-provider-store.js +224 -0
- package/dist/llm/data/provider-catalog-baked.js +19 -0
- package/dist/llm/model-catalog.js +717 -0
- package/dist/llm/model-discovery.js +1137 -0
- package/dist/llm/model-selection.js +1241 -0
- package/dist/llm/pi-ai.js +70 -11
- package/dist/llm/provider-catalog.js +898 -0
- package/dist/llm/provider-registry.js +917 -0
- package/dist/llm/tool-name.js +220 -0
- package/dist/orbitdb/cid-database.js +81 -16
- package/dist/orbitdb/ipfs-node.js +31 -2
- package/dist/setup/onboard.js +34 -8
- package/dist/utils/dual-source-facts.js +223 -0
- package/dist/utils/dual-source.js +297 -0
- package/dist/utils/update-cli.js +16 -21
- package/dist/utils/update-health.js +35 -5
- package/dist/utils/update-manager.js +325 -96
- package/dist/utils/update-state.js +13 -10
- package/dist/utils/version-identity.js +176 -0
- package/dist/utils/version-info.js +27 -22
- package/dist/web/bolloon-web.json +10 -0
- package/dist/web/mobile-contacts.js +44 -11
- package/dist/web/mobile-core.js +2901 -580
- package/dist/web/mobile-tasks.js +339 -0
- package/dist/web/mobile-update.js +1442 -0
- package/dist/web/mobile.css +19 -0
- package/dist/web/mobile.html +64 -0
- package/dist/web/mobile.js +838 -35
- package/dist/web/routes-llm-config.js +344 -52
- package/dist/web/routes-mobile-tasks.js +353 -0
- package/dist/web/server.js +163 -4
- package/dist/web/sw.js +1 -1
- package/package.json +3 -1
- package/scripts/build-web.ts +36 -0
|
@@ -0,0 +1,1870 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* goal-flywheel-wiring.ts — 把 P0–P4 的**七个独立模块**接进真实执行路径 (2026-09-25)
|
|
3
|
+
*
|
|
4
|
+
* 为什么单独一个文件 (而不是改 `src/agents/goal-flywheel/` 里已有的文件):
|
|
5
|
+
* §13 的所有权表把 P0–P4 的文件锁成"只准各阶段自己写", 且 `goal-flywheel-types.test.ts`
|
|
6
|
+
* 有一条**名册白名单**门 (目录里出现名册外的 .ts 就判红)。接线层属于"改现有调用方"这一类,
|
|
7
|
+
* 因此它落在目录**外面**, 只 import, 不侵入。
|
|
8
|
+
*
|
|
9
|
+
* 本文件是**唯一**的适配层 (设计 §2 "Goal 上只有一个权威 continuation"):
|
|
10
|
+
* ① 节奏 (P0): `decideGoalStep` = 读权威 continuation → 读最近 Run 进展 →
|
|
11
|
+
* `decideContinuation` + `applyHardLimits` + `isRunnable` → 回答"现在该不该跑这一轮"。
|
|
12
|
+
* **有进展就继续, 没有进展就交人** —— 轮次/时长不是继续的依据 (只由熔断阈值兜底)。
|
|
13
|
+
* ② 收尾 (P1): `closeGoalRun` 用真 store 注入 `closeRun` 的依赖 (Memory 落盘 / Skill 候选 /
|
|
14
|
+
* P0 决策器), 成功 / 失败 / 中断恢复的 Run 结尾都走同一条 9 步流水线。
|
|
15
|
+
* ③ 合同 (P2): `dispatchChildWork` (派遣必签合同) / `handleChildReport`
|
|
16
|
+
* (`validateChildReport` + `acceptsAsComplete` —— 漂亮但无证据的回报不算完成)。
|
|
17
|
+
* ④ 阻塞 (P3): `collectWorkBlocks` + `applyBlockHandling` (子无心跳先查执行权, 升级/接管)。
|
|
18
|
+
* ⑤ 变更 (P4): `ingestGoalChange` 原话逐字入档 + 版本号 + 下一 Run 指令; 当前 Run 历史不改写。
|
|
19
|
+
* ⑥ 视图: `goalVisibleState` = `toUserVisibleState` (用户只看到六类, 不出现内部词)。
|
|
20
|
+
*
|
|
21
|
+
* 与既有 reducer 的分工 (必须说清, 否则两套机制会打架):
|
|
22
|
+
* · **飞轮决定"还要不要继续"** (是否停 / 是否交人 / 是否熔断);
|
|
23
|
+
* · 既有的 `decideGoalOutcome` (execution-supervisor) 退化为**运输层**: 可恢复失败等多久
|
|
24
|
+
* (退避/wakeAt/attempts/retry_wait)。它**只能被飞轮收紧** —— 见 `mergeGoalOutcome`:
|
|
25
|
+
* - 飞轮判 fail/complete/pause/熔断 → 一律照飞轮的停 (运输层无权放开);
|
|
26
|
+
* - 运输层因"轮次用尽"要停, 但飞轮说**有进展**且不是硬错误 → 继续跑 (轮次不再是主停止条件);
|
|
27
|
+
* - 其余情况运输层照旧 (退避语义不变)。
|
|
28
|
+
* 无进展熔断阈值默认 = 旧的自动继续次数 + 1 (`BOLLOON_GOAL_NO_PROGRESS_BREAKER` 可调),
|
|
29
|
+
* 因此"连续 N 轮没证据就交人"的行为与旧阈值一致, 但依据从"第几轮"换成了"有没有新证据"。
|
|
30
|
+
*
|
|
31
|
+
* 落盘布局 (全部相对 `os.homedir()`, 与 goal-store / run-store 同一手法):
|
|
32
|
+
* <home>/.bolloon/goal-decisions/<goalId>--<runId>.json 每次决策的可回放记录
|
|
33
|
+
* <home>/.bolloon/goal-works/<goalId>/<workId>.json 子 Agent 工作合同 (+ .hb / .report)
|
|
34
|
+
* <home>/.bolloon/goal-reports/<goalId>--<runId>.json 给用户的汇报 (P4b 第一份输出)
|
|
35
|
+
* <home>/.bolloon/skill-candidates/<candidateId>.json Skill 改进候选 (永不写 skills/)
|
|
36
|
+
* <home>/.bolloon/memory-layers/<layer>/<memoryId>.json 分层记忆 (memory-layers.ts 自己落)
|
|
37
|
+
*
|
|
38
|
+
* 不做 (设计 §10): 不新增调度器 / 不另起 workflow engine / 不建第二个任务库。
|
|
39
|
+
* 本文件的全部状态都挂在既有 Goal / Run 文件与上面几个目录里。
|
|
40
|
+
*/
|
|
41
|
+
import * as fs from 'fs/promises';
|
|
42
|
+
import * as path from 'path';
|
|
43
|
+
import * as os from 'os';
|
|
44
|
+
import { addEvidence, listGoals, readGoal, setContinuation, updateGoal, } from './goal-store.js';
|
|
45
|
+
// 2026-09-25 (M0 接线冻结, 规则 ②): Goal 状态只有一个写入出口
|
|
46
|
+
import { reduceGoalState } from './goal-state-reducer.js';
|
|
47
|
+
// 接缝的拒绝类型与判别器一并从这里转发 (调用方不必再 import 接缝目录)
|
|
48
|
+
export { isRefusal } from './goal-flywheel/wiring/index.js';
|
|
49
|
+
import { addRunEvidence, readRun, setRunStatus } from './run-store.js';
|
|
50
|
+
import { applyHardLimits, decideContinuation, isRunnable } from './goal-flywheel/continuation-decision.js';
|
|
51
|
+
import { closeRun, CLOSURE_STEP_ORDER } from './goal-flywheel/run-closure.js';
|
|
52
|
+
import { writeMemoryRecords } from './goal-flywheel/memory-layers.js';
|
|
53
|
+
import { assessCandidate, openSkillTrial, settleSkillTrial, } from './goal-flywheel/skill-candidate.js';
|
|
54
|
+
import { acceptsAsComplete, issueWorkContract, stableHash, validateChildReport } from './goal-flywheel/work-contract.js';
|
|
55
|
+
import { detectBlocks, planBlockHandling, toUserVisibleState } from './goal-flywheel/work-monitor.js';
|
|
56
|
+
import { applyChange, classifyChange, ingestChange, nextStatusFor, shouldSupersedePending } from './goal-flywheel/goal-change.js';
|
|
57
|
+
// 2026-09-25 (M0 接线冻结): 唯一责任链的五个接缝 (各阶段独占一个文件; 依赖在这里注入)
|
|
58
|
+
import { createChangeSeam, createClosureSeam, createContinuationSeam, createContractSeam, createMonitorSeam, bindingCaps, closureTerminalKindFor, isRefusal, } from './goal-flywheel/wiring/index.js';
|
|
59
|
+
// ============================================================================
|
|
60
|
+
// §0. 路径 / 常量 (全部导出: 测试与调用方不许自己手抄布局)
|
|
61
|
+
// ============================================================================
|
|
62
|
+
export const GOAL_DECISIONS_ROOT = '.bolloon/goal-decisions';
|
|
63
|
+
export const GOAL_WORKS_ROOT = '.bolloon/goal-works';
|
|
64
|
+
export const GOAL_REPORTS_ROOT = '.bolloon/goal-reports';
|
|
65
|
+
export const SKILL_CANDIDATES_ROOT = '.bolloon/skill-candidates';
|
|
66
|
+
/** 三类硬底线 (安全线, 不是任务节奏) 的接线层默认值 —— 字面量常量, 便于与 env 比对 */
|
|
67
|
+
export const HARD_LIMIT_DEFAULTS = {
|
|
68
|
+
maxRunDurationMs: 30 * 60_000,
|
|
69
|
+
maxGoalBudget: 50,
|
|
70
|
+
noProgressCircuitBreaker: 3,
|
|
71
|
+
};
|
|
72
|
+
/** 同上, 冻结面类型形态 */
|
|
73
|
+
export const DEFAULT_HARD_LIMITS = {
|
|
74
|
+
/** 单 Run 时间上限 (30 分钟; `BOLLOON_GOAL_MAX_RUN_MS` 可调) */
|
|
75
|
+
maxRunDurationMs: 30 * 60_000,
|
|
76
|
+
/**
|
|
77
|
+
* 单 Goal 预算上限 (默认 50 个 Run; `BOLLOON_GOAL_MAX_RUNS` 可调)。
|
|
78
|
+
* 刻意**不**留 null: 冻结语义里 `maxGoalBudget === null` = "必须由人显式确认",
|
|
79
|
+
* 留 null 会把**每一次**自治继续都收紧成"交人" (等于没有飞轮)。所以这里注入一个
|
|
80
|
+
* 真实上限 (达到就停), 而不是把上限留空。
|
|
81
|
+
*/
|
|
82
|
+
maxGoalBudget: 50,
|
|
83
|
+
/** 无进展熔断阈值 (默认 3; `BOLLOON_GOAL_NO_PROGRESS_BREAKER` 可调) */
|
|
84
|
+
noProgressCircuitBreaker: HARD_LIMIT_DEFAULTS.noProgressCircuitBreaker,
|
|
85
|
+
};
|
|
86
|
+
/** 不可恢复的 Run 失败分类 (运输层与飞轮都不许因此自动续跑) */
|
|
87
|
+
export const HARD_STOP_ERROR_CLASSES = [
|
|
88
|
+
'auth',
|
|
89
|
+
'persist_failed',
|
|
90
|
+
'corrupt_state',
|
|
91
|
+
'policy_denied',
|
|
92
|
+
'repeat_failure',
|
|
93
|
+
'bad_args',
|
|
94
|
+
'no_such_tool',
|
|
95
|
+
];
|
|
96
|
+
export function bolloonHome() {
|
|
97
|
+
return os.homedir();
|
|
98
|
+
}
|
|
99
|
+
function safeName(id) {
|
|
100
|
+
return String(id).replace(/[^A-Za-z0-9._-]/g, '_');
|
|
101
|
+
}
|
|
102
|
+
function atomicWrite(file, payload) {
|
|
103
|
+
return (async () => {
|
|
104
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
105
|
+
const tmp = `${file}.${process.pid}.${Math.random().toString(36).slice(2, 8)}.tmp`;
|
|
106
|
+
await fs.writeFile(tmp, payload, 'utf8');
|
|
107
|
+
await fs.rename(tmp, file);
|
|
108
|
+
})();
|
|
109
|
+
}
|
|
110
|
+
async function readJson(file) {
|
|
111
|
+
try {
|
|
112
|
+
return JSON.parse(await fs.readFile(file, 'utf8'));
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
function envNum(name, dflt) {
|
|
119
|
+
const raw = Number(process.env[name]);
|
|
120
|
+
return Number.isFinite(raw) && raw > 0 ? raw : dflt;
|
|
121
|
+
}
|
|
122
|
+
function uniqNonEmpty(xs) {
|
|
123
|
+
const out = [];
|
|
124
|
+
for (const x of xs) {
|
|
125
|
+
const s = typeof x === 'string' ? x.trim() : '';
|
|
126
|
+
if (s && !out.includes(s))
|
|
127
|
+
out.push(s);
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
/** GoalStatus → 冻结面的生命周期状态 (`open` = 还没起第一个 Run → 读作 active) */
|
|
132
|
+
export function lifecycleOf(status) {
|
|
133
|
+
return (status === 'open' ? 'active' : String(status));
|
|
134
|
+
}
|
|
135
|
+
// ============================================================================
|
|
136
|
+
// §1. 三类硬底线 (只保留、只收紧)
|
|
137
|
+
// ============================================================================
|
|
138
|
+
/**
|
|
139
|
+
* 从 Goal / Run 事实 + 环境变量算出三类硬底线。
|
|
140
|
+
*
|
|
141
|
+
* - `maxRunDurationMs`: Run 自己的预算 (`run.budget.deadlineMs`) 优先, 否则接线层默认;
|
|
142
|
+
* - `maxGoalBudget`: Goal 自己的预算 (`budget.maxRuns` / `budget.deadlineMs`) 优先, 否则默认;
|
|
143
|
+
* - `noProgressCircuitBreaker`: **默认 = 旧的自动继续次数 + 1** —— 这样"连续 N 轮无证据 → 交人"
|
|
144
|
+
* 的阈值与旧行为一致, 但依据是"有没有新证据"而不是"第几轮"(见文件头)。
|
|
145
|
+
*/
|
|
146
|
+
export function hardLimitsFor(input) {
|
|
147
|
+
const runDeadline = input.run?.budget?.deadlineMs;
|
|
148
|
+
const goalBudget = input.goal.budget?.maxRuns ?? input.goal.budget?.deadlineMs;
|
|
149
|
+
const breakerBase = typeof input.maxRetries === 'number' && Number.isFinite(input.maxRetries) && input.maxRetries >= 0
|
|
150
|
+
? input.maxRetries + 1
|
|
151
|
+
: HARD_LIMIT_DEFAULTS.noProgressCircuitBreaker;
|
|
152
|
+
return {
|
|
153
|
+
maxRunDurationMs: typeof runDeadline === 'number' && runDeadline > 0
|
|
154
|
+
? runDeadline
|
|
155
|
+
: envNum('BOLLOON_GOAL_MAX_RUN_MS', HARD_LIMIT_DEFAULTS.maxRunDurationMs),
|
|
156
|
+
maxGoalBudget: typeof goalBudget === 'number' && goalBudget > 0
|
|
157
|
+
? goalBudget
|
|
158
|
+
: envNum('BOLLOON_GOAL_MAX_RUNS', HARD_LIMIT_DEFAULTS.maxGoalBudget),
|
|
159
|
+
noProgressCircuitBreaker: Math.max(1, Math.floor(envNum('BOLLOON_GOAL_NO_PROGRESS_BREAKER', breakerBase))),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
export function decisionFilePath(goalId, runId, phase, home = bolloonHome()) {
|
|
163
|
+
return path.join(home, GOAL_DECISIONS_ROOT, `${safeName(goalId)}--${safeName(runId)}--${phase}.json`);
|
|
164
|
+
}
|
|
165
|
+
export async function writeDecisionRecord(rec, home = bolloonHome()) {
|
|
166
|
+
const file = decisionFilePath(rec.goalId, rec.runId, rec.phase, home);
|
|
167
|
+
await atomicWrite(file, JSON.stringify(rec, null, 2));
|
|
168
|
+
return file;
|
|
169
|
+
}
|
|
170
|
+
export async function readDecisionRecords(goalId, home = bolloonHome()) {
|
|
171
|
+
const dir = path.join(home, GOAL_DECISIONS_ROOT);
|
|
172
|
+
let files = [];
|
|
173
|
+
try {
|
|
174
|
+
files = await fs.readdir(dir);
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
return [];
|
|
178
|
+
}
|
|
179
|
+
const prefix = `${safeName(goalId)}--`;
|
|
180
|
+
const out = [];
|
|
181
|
+
for (const f of files) {
|
|
182
|
+
if (!f.startsWith(prefix) || !f.endsWith('.json'))
|
|
183
|
+
continue;
|
|
184
|
+
const rec = await readJson(path.join(dir, f));
|
|
185
|
+
if (rec && rec.goalId === goalId)
|
|
186
|
+
out.push(rec);
|
|
187
|
+
}
|
|
188
|
+
// 回放顺序 = 决策时间 (同一时间戳时按 phase: preflight 在 closure 之前)
|
|
189
|
+
out.sort((a, b) => {
|
|
190
|
+
const t = String(a.recordedAt).localeCompare(String(b.recordedAt));
|
|
191
|
+
if (t !== 0)
|
|
192
|
+
return t;
|
|
193
|
+
return a.phase === b.phase ? 0 : a.phase === 'preflight' ? -1 : 1;
|
|
194
|
+
});
|
|
195
|
+
return out;
|
|
196
|
+
}
|
|
197
|
+
// ============================================================================
|
|
198
|
+
// §3. 进展与无进展连续计数 (进展 = 新的可核验证据 / 新满足的判据)
|
|
199
|
+
// ============================================================================
|
|
200
|
+
/** 一个 Run 产出的可核验证据 (Run 级 evidence + 成功步骤的事实摘要, 与既有证据写法一致) */
|
|
201
|
+
export function runEvidenceOf(run) {
|
|
202
|
+
return uniqNonEmpty([
|
|
203
|
+
...(run.evidence ?? []),
|
|
204
|
+
...(run.steps ?? [])
|
|
205
|
+
.filter((s) => s.ok)
|
|
206
|
+
.map((s) => `${run.runId}/${s.tool}: ${String(s.summary || '(完成)').slice(0, 120)}`),
|
|
207
|
+
]);
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* 本轮的 `ProgressDelta`。
|
|
211
|
+
* - `newEvidence` = 本轮新增的可核验证据 (Run 的 evidence + 成功步骤);
|
|
212
|
+
* - `newlyCompletedCriteria` = 判据面**相对上一次决策快照**的增量 (拿不到快照 = 不猜, 记空);
|
|
213
|
+
* - `stepsAdvanced` = 成功步数 (只作观察, **不算进展**);
|
|
214
|
+
* - `unresolvedDelta` = 未解决项相对快照的变化。
|
|
215
|
+
*/
|
|
216
|
+
export function computeRunProgress(goal, run, prev) {
|
|
217
|
+
return {
|
|
218
|
+
newEvidence: runEvidenceOf(run),
|
|
219
|
+
newlyCompletedCriteria: prev
|
|
220
|
+
? goal.completedCriteria.filter((i) => !prev.completedCriteria.includes(i))
|
|
221
|
+
: [],
|
|
222
|
+
stepsAdvanced: (run.steps ?? []).filter((s) => s.ok).length,
|
|
223
|
+
unresolvedDelta: prev ? goal.unresolvedItems.length - prev.unresolvedItems.length : 0,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
function isEmptyProgress(p) {
|
|
227
|
+
return (p?.newEvidence?.length ?? 0) === 0 && (p?.newlyCompletedCriteria?.length ?? 0) === 0;
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* 连续"无新证据且无新判据"的 Run 数 (**含本轮**)。
|
|
231
|
+
*
|
|
232
|
+
* 依据全部来自盘上的事实 (不猜): 每个历史 Run 优先用它那次收尾记下的 `progressDelta`;
|
|
233
|
+
* 没有收尾记录的历史 Run (接线之前就存在的) 用它自己的 Run 事实判空 (evidence + 成功步骤都为空)。
|
|
234
|
+
*/
|
|
235
|
+
export async function deriveNoProgressStreak(goal, currentRunId, currentProgress, records, home = bolloonHome()) {
|
|
236
|
+
if (!isEmptyProgress(currentProgress))
|
|
237
|
+
return 0;
|
|
238
|
+
const closureByRun = new Map();
|
|
239
|
+
for (const r of records) {
|
|
240
|
+
if (r.phase !== 'closure')
|
|
241
|
+
continue;
|
|
242
|
+
closureByRun.set(r.runId, r.decision.progressDelta);
|
|
243
|
+
}
|
|
244
|
+
let streak = 1;
|
|
245
|
+
const history = (goal.runs ?? []).filter((id) => id !== currentRunId);
|
|
246
|
+
for (let i = history.length - 1; i >= 0; i--) {
|
|
247
|
+
const rid = history[i];
|
|
248
|
+
let p = closureByRun.get(rid);
|
|
249
|
+
if (!p) {
|
|
250
|
+
const run = await readRun(rid).catch(() => null);
|
|
251
|
+
if (!run)
|
|
252
|
+
break; // 读不到事实 → 不猜, 停止回数
|
|
253
|
+
p = { newEvidence: runEvidenceOf(run), newlyCompletedCriteria: [], stepsAdvanced: 0, unresolvedDelta: 0 };
|
|
254
|
+
}
|
|
255
|
+
if (!isEmptyProgress(p))
|
|
256
|
+
break;
|
|
257
|
+
streak++;
|
|
258
|
+
}
|
|
259
|
+
void home;
|
|
260
|
+
return streak;
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* "这个 Goal 现在该不该开下一轮" —— 飞轮节奏的唯一入口。
|
|
264
|
+
*
|
|
265
|
+
* 顺序: 读权威 continuation (goal.continuation) → 读最近 Run 事实 → 读历史决策记录 →
|
|
266
|
+
* `decideContinuation` → `applyHardLimits` (只收紧) → `isRunnable` → 三类硬底线复核。
|
|
267
|
+
*
|
|
268
|
+
* `writeRecord` 打开时把这次判定按 `phase='preflight'` 落盘 (可回放)。
|
|
269
|
+
*/
|
|
270
|
+
export async function decideGoalStep(input) {
|
|
271
|
+
const home = input.home ?? bolloonHome();
|
|
272
|
+
const now = input.now ?? new Date().toISOString();
|
|
273
|
+
const goal = await readGoal(input.goalId);
|
|
274
|
+
if (!goal)
|
|
275
|
+
return null;
|
|
276
|
+
const lastRunId = goal.currentRunId || goal.runs?.[goal.runs.length - 1];
|
|
277
|
+
const run = lastRunId ? await readRun(lastRunId).catch(() => null) : null;
|
|
278
|
+
const hardLimits = hardLimitsFor({ goal, run, maxRetries: input.maxRetries });
|
|
279
|
+
// 还没有 Run 事实 → 首个 Run 直接开 (目标本身就是人的意图, 无需先自证进展)
|
|
280
|
+
if (!run) {
|
|
281
|
+
const reason = 'first_run: 还没有 Run 事实 —— 首个 Run 直接开 (目标由人写, 不需要先自证进展)';
|
|
282
|
+
if (input.writeRecord) {
|
|
283
|
+
await writeDecisionRecord({
|
|
284
|
+
goalId: goal.goalId,
|
|
285
|
+
runId: '(none)',
|
|
286
|
+
phase: 'preflight',
|
|
287
|
+
decision: null,
|
|
288
|
+
runnable: true,
|
|
289
|
+
runnableReason: reason,
|
|
290
|
+
noProgressStreak: 0,
|
|
291
|
+
hardLimits,
|
|
292
|
+
goalSnapshot: snapshotOf(goal),
|
|
293
|
+
recordedAt: now,
|
|
294
|
+
}, home).catch(() => null);
|
|
295
|
+
}
|
|
296
|
+
return { goalId: goal.goalId, run: null, decision: null, runnable: true, reason, noProgressStreak: 0, progress: null, hardLimits };
|
|
297
|
+
}
|
|
298
|
+
const records = await readDecisionRecords(goal.goalId, home);
|
|
299
|
+
const prevSnapshot = [...records].reverse().find((r) => r.phase === 'closure')?.goalSnapshot ?? null;
|
|
300
|
+
const progress = computeRunProgress(goal, run, prevSnapshot);
|
|
301
|
+
const streak = await deriveNoProgressStreak(goal, run.runId, progress, records, home);
|
|
302
|
+
const raw = decideContinuation({ goal, run, now, progress, noProgressStreak: streak, hardLimits });
|
|
303
|
+
const tightened = applyHardLimits(raw, hardLimits);
|
|
304
|
+
const gate = isRunnable(tightened, now);
|
|
305
|
+
// 三类硬底线复核 (单 Goal 预算上限: 飞轮的 BudgetDelta 之外必须真的拦住)
|
|
306
|
+
let runnable = gate.runnable;
|
|
307
|
+
let reason = gate.reason;
|
|
308
|
+
const runsUsed = (goal.runs ?? []).length;
|
|
309
|
+
const budgetCap = hardLimits.maxGoalBudget;
|
|
310
|
+
if (runnable && typeof budgetCap === 'number' && runsUsed >= budgetCap) {
|
|
311
|
+
runnable = false;
|
|
312
|
+
reason = `硬底线: 单 Goal 预算已用尽 (${runsUsed} >= ${budgetCap} 个 Run) → 交人复核, 不自动开下一轮`;
|
|
313
|
+
}
|
|
314
|
+
if (input.writeRecord) {
|
|
315
|
+
await writeDecisionRecord({
|
|
316
|
+
goalId: goal.goalId,
|
|
317
|
+
runId: run.runId,
|
|
318
|
+
phase: 'preflight',
|
|
319
|
+
decision: tightened,
|
|
320
|
+
runnable,
|
|
321
|
+
runnableReason: reason,
|
|
322
|
+
noProgressStreak: streak,
|
|
323
|
+
hardLimits,
|
|
324
|
+
goalSnapshot: snapshotOf(goal),
|
|
325
|
+
recordedAt: now,
|
|
326
|
+
}, home).catch(() => null);
|
|
327
|
+
}
|
|
328
|
+
return {
|
|
329
|
+
goalId: goal.goalId,
|
|
330
|
+
run,
|
|
331
|
+
decision: tightened,
|
|
332
|
+
runnable,
|
|
333
|
+
reason,
|
|
334
|
+
noProgressStreak: streak,
|
|
335
|
+
progress,
|
|
336
|
+
hardLimits,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
/** 留痕上限 (有界: 长跑进程不许攒一条无限长的数组) */
|
|
340
|
+
export const FACTS_READ_NOTE_MAX = 50;
|
|
341
|
+
let factsReadLog = [];
|
|
342
|
+
/** 事实读留痕 (最近 FACTS_READ_NOTE_MAX 条, 最旧的在最前) */
|
|
343
|
+
export function factsReadNotes() {
|
|
344
|
+
return factsReadLog;
|
|
345
|
+
}
|
|
346
|
+
/** 测试用: 清空留痕 (不影响接缝实例) */
|
|
347
|
+
export function resetFactsReadNotesForTest() {
|
|
348
|
+
factsReadLog = [];
|
|
349
|
+
}
|
|
350
|
+
function noteFactsRead(n) {
|
|
351
|
+
factsReadLog.push({ ...n, at: new Date().toISOString() });
|
|
352
|
+
while (factsReadLog.length > FACTS_READ_NOTE_MAX)
|
|
353
|
+
factsReadLog.shift();
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* `readFacts({goalId, now})` 的**真实现** —— 只读事实, 不判定 (`readRhythmFacts` 的名字在说这件事)。
|
|
357
|
+
*
|
|
358
|
+
* 读的是: Goal (权威 continuation / 判据 / 跑过几条 Run) + 最近一条 Run (证据 / 步骤) +
|
|
359
|
+
* 历史决策记录 (算进展增量与无进展连击)。判定交给 M1 接缝 (它用 P0 自己编), 本函数**不**碰决策。
|
|
360
|
+
*
|
|
361
|
+
* 返回 null 的两种情形**都如实留痕**, 且语义不同:
|
|
362
|
+
* · `factsFound=false` + `error=null` = 还没有 Run 事实 (首个 Run 之前: 没有"上一轮"可读);
|
|
363
|
+
* · `error!=null` = 读盘失败 (拿不到事实 ≠ 没有事实)。
|
|
364
|
+
* 两种情形下接缝都会走注入兜底 (结果为 `source='injected'`, 不假装是事实判的)。
|
|
365
|
+
*/
|
|
366
|
+
export async function readRhythmFacts(input) {
|
|
367
|
+
const home = input.home ?? bolloonHome();
|
|
368
|
+
try {
|
|
369
|
+
const goal = await readGoal(input.goalId);
|
|
370
|
+
if (!goal) {
|
|
371
|
+
noteFactsRead({ goalId: input.goalId, now: input.now, runId: null, factsFound: false, error: `goal 不存在: ${input.goalId}` });
|
|
372
|
+
return null;
|
|
373
|
+
}
|
|
374
|
+
const lastRunId = goal.currentRunId || goal.runs?.[goal.runs.length - 1];
|
|
375
|
+
const run = lastRunId ? await readRun(lastRunId).catch(() => null) : null;
|
|
376
|
+
if (!run) {
|
|
377
|
+
noteFactsRead({ goalId: goal.goalId, now: input.now, runId: null, factsFound: false, error: null });
|
|
378
|
+
return null; // 首个 Run 之前没有"上一轮"事实 —— 不编一条空 Run 出来
|
|
379
|
+
}
|
|
380
|
+
const records = await readDecisionRecords(goal.goalId, home);
|
|
381
|
+
const prevSnapshot = [...records].reverse().find((r) => r.phase === 'closure')?.goalSnapshot ?? null;
|
|
382
|
+
const progress = computeRunProgress(goal, run, prevSnapshot);
|
|
383
|
+
const noProgressStreak = await deriveNoProgressStreak(goal, run.runId, progress, records, home);
|
|
384
|
+
noteFactsRead({ goalId: goal.goalId, now: input.now, runId: run.runId, factsFound: true, error: null });
|
|
385
|
+
return { goal, run, now: input.now, progress, noProgressStreak };
|
|
386
|
+
}
|
|
387
|
+
catch (e) {
|
|
388
|
+
noteFactsRead({ goalId: input.goalId, now: input.now, runId: null, factsFound: false, error: String(e?.message || e) });
|
|
389
|
+
return null;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Supervisor **唯一**的节奏判定入口: 走 M1 接缝 (`flywheelSeams().continuation.preflight`)。
|
|
394
|
+
*
|
|
395
|
+
* 为什么还要经过接缝而不是直接 `decideGoalStep`: 接缝是**规范口径** (`readFacts` → P0 → 归因),
|
|
396
|
+
* 而 `decideGoalStep` 是"接线层自己再判一遍"的旧路。主循环走接缝 = 全仓只有一套节奏判定。
|
|
397
|
+
*
|
|
398
|
+
* 上限 (`limits`) 为什么由这里先算一次: M1 刻意**不许事实读夹带上限** (`RhythmFacts` 里没有
|
|
399
|
+
* `hardLimits`) —— 上限是宿主配置 (goal.budget / run.deadlineMs / `BOLLOON_GOAL_MAX_RUNS`),
|
|
400
|
+
* 必须由调用方经 `safetyCapsFrom()` 注入。所以这里先读一次 Goal/Run 算上限, 事实读本身由接缝
|
|
401
|
+
* 通过 `readRhythmFacts` 再做 (两次读都在同一次判定里, 且**上限只可能来自宿主配置**, 不会被
|
|
402
|
+
* 事实读改写)。
|
|
403
|
+
*
|
|
404
|
+
* 回放记录 (`writeRecord`): 接缝不写记录 (它不碰盘), 所以 preflight 记录由**这里**落盘 ——
|
|
405
|
+
* 与 `decideGoalStep` 写的是同一份形状 (事实 → 结论), 保证 `.bolloon/goal-decisions/` 的记录流不断。
|
|
406
|
+
*/
|
|
407
|
+
export async function preflightGoalStep(input) {
|
|
408
|
+
const home = input.home ?? bolloonHome();
|
|
409
|
+
const goal = await readGoal(input.goalId);
|
|
410
|
+
if (!goal)
|
|
411
|
+
return null;
|
|
412
|
+
const lastRunId = goal.currentRunId || goal.runs?.[goal.runs.length - 1];
|
|
413
|
+
const run = lastRunId ? await readRun(lastRunId).catch(() => null) : null;
|
|
414
|
+
const hardLimits = hardLimitsFor({ goal, run, maxRetries: input.maxRetries });
|
|
415
|
+
const pre = await flywheelSeams().continuation.preflight({
|
|
416
|
+
goalId: input.goalId,
|
|
417
|
+
caller: 'supervisor',
|
|
418
|
+
now: input.now,
|
|
419
|
+
maxRetries: input.maxRetries,
|
|
420
|
+
maxRounds: input.maxRounds,
|
|
421
|
+
maxRunDurationMs: input.maxRunDurationMs,
|
|
422
|
+
limits: hardLimits,
|
|
423
|
+
writeRecord: false, // 记录由本函数按**同一份事实**落盘 (见下), 不让接缝去写
|
|
424
|
+
});
|
|
425
|
+
if (pre && !isRefusal(pre) && pre.source === 'facts' && pre.facts) {
|
|
426
|
+
const facts = pre.facts;
|
|
427
|
+
const step = {
|
|
428
|
+
goalId: facts.goal.goalId,
|
|
429
|
+
run: facts.run,
|
|
430
|
+
decision: pre.fullDecision,
|
|
431
|
+
runnable: pre.runnable,
|
|
432
|
+
reason: pre.reason,
|
|
433
|
+
noProgressStreak: pre.noProgressStreak,
|
|
434
|
+
progress: facts.progress,
|
|
435
|
+
hardLimits: pre.capsInEffect,
|
|
436
|
+
source: 'facts',
|
|
437
|
+
basis: pre.basis,
|
|
438
|
+
caps: pre.caps,
|
|
439
|
+
bindingCap: pre.bindingCap,
|
|
440
|
+
};
|
|
441
|
+
if (input.writeRecord) {
|
|
442
|
+
await writeDecisionRecord({
|
|
443
|
+
goalId: step.goalId,
|
|
444
|
+
runId: facts.run.runId,
|
|
445
|
+
phase: 'preflight',
|
|
446
|
+
decision: step.decision,
|
|
447
|
+
runnable: step.runnable,
|
|
448
|
+
runnableReason: step.reason,
|
|
449
|
+
noProgressStreak: step.noProgressStreak,
|
|
450
|
+
hardLimits: step.hardLimits,
|
|
451
|
+
goalSnapshot: snapshotOf(goal),
|
|
452
|
+
recordedAt: input.now,
|
|
453
|
+
}, home).catch(() => null);
|
|
454
|
+
}
|
|
455
|
+
return step;
|
|
456
|
+
}
|
|
457
|
+
// 事实拿不到 (还没有 Run 事实 / 读盘失败) 或接缝真拒 → 退回旧口径, 并**如实标 source='injected'**
|
|
458
|
+
const legacy = await decideGoalStep({
|
|
459
|
+
goalId: input.goalId,
|
|
460
|
+
now: input.now,
|
|
461
|
+
maxRetries: input.maxRetries,
|
|
462
|
+
home,
|
|
463
|
+
writeRecord: input.writeRecord,
|
|
464
|
+
});
|
|
465
|
+
if (!legacy) {
|
|
466
|
+
// 连旧口径都拿不到目标 → 接缝的拒绝原样上报 (有拒绝就是有拒绝), 否则如实返回 null
|
|
467
|
+
return pre && isRefusal(pre) ? pre : null;
|
|
468
|
+
}
|
|
469
|
+
return { ...legacy, source: 'injected', basis: null, caps: [], bindingCap: null };
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* 反事实归因: **这一步在说话的是哪条安全上限** (M1 `bindingCaps` 的真调用方, 2026-09-25 串行收口)。
|
|
473
|
+
*
|
|
474
|
+
* 判据是反事实, 不是把上限数字比一遍 (手算就是第二套事实): 把某条上限**连它的事实层来源**放宽 →
|
|
475
|
+
* 结论投影变了 ⟹ 是它在说话; 结论没变 ⟹ 这次停跟它无关。见 `continuation.ts` 的 `bindingCaps`。
|
|
476
|
+
*
|
|
477
|
+
* 事实不足时如实说: 还没有 Run 事实 (首个 Run 之前 / 读盘失败) → `factsFound=false` + `bindingCap=null`
|
|
478
|
+
* + `reason` 说清,**不猜**一条"最像的"上限出来。
|
|
479
|
+
*
|
|
480
|
+
* 上限来源与 `preflightGoalStep` 同一份推导 (`hardLimitsFor`: Goal 自报的 `budget` 优先于 env/默认)。
|
|
481
|
+
*/
|
|
482
|
+
export async function explainBindingCap(input) {
|
|
483
|
+
const home = input.home ?? bolloonHome();
|
|
484
|
+
const now = input.now ?? new Date().toISOString();
|
|
485
|
+
const goal = await readGoal(input.goalId);
|
|
486
|
+
if (!goal) {
|
|
487
|
+
return { goalId: input.goalId, now, factsFound: false, caps: [], bindingCap: null, reason: `goal 不存在: ${input.goalId} → 没有事实就没有上限归因` };
|
|
488
|
+
}
|
|
489
|
+
const lastRunId = goal.currentRunId || goal.runs?.[goal.runs.length - 1];
|
|
490
|
+
const run = lastRunId ? await readRun(lastRunId).catch(() => null) : null;
|
|
491
|
+
const facts = await readRhythmFacts({ goalId: input.goalId, now, home });
|
|
492
|
+
if (!facts) {
|
|
493
|
+
return { goalId: input.goalId, now, factsFound: false, caps: [], bindingCap: null, reason: '读不到节奏事实 (还没有 Run / 读盘失败) → 上限无从核验, 不做归因' };
|
|
494
|
+
}
|
|
495
|
+
const caps = bindingCaps(facts, hardLimitsFor({ goal, run, maxRetries: input.maxRetries }));
|
|
496
|
+
return {
|
|
497
|
+
goalId: input.goalId,
|
|
498
|
+
now,
|
|
499
|
+
factsFound: true,
|
|
500
|
+
caps,
|
|
501
|
+
bindingCap: caps[0] ?? null,
|
|
502
|
+
reason: caps.length > 0
|
|
503
|
+
? `在说话的安全上限 (反事实: 放宽它结论就变): ${caps.join(', ')}`
|
|
504
|
+
: '没有任何安全上限在说话 (这一步的结论不是被上限拦下的)',
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
export function snapshotOf(goal) {
|
|
508
|
+
return {
|
|
509
|
+
status: goal.status,
|
|
510
|
+
completedCriteria: [...goal.completedCriteria],
|
|
511
|
+
evidenceCount: (goal.evidence ?? []).length,
|
|
512
|
+
unresolvedItems: [...goal.unresolvedItems],
|
|
513
|
+
criteriaVersion: goal.criteriaVersion ?? 1,
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
// ============================================================================
|
|
517
|
+
// §5. 收尾 (P1) —— 真 store 注入 closeRun 的全部依赖
|
|
518
|
+
// ============================================================================
|
|
519
|
+
/** `<home>/.bolloon/skills/<name>/SKILL.md` 的 contentHash (用来证明"没覆盖正在执行的 snapshot") */
|
|
520
|
+
export async function skillSnapshotHash(name, home = bolloonHome()) {
|
|
521
|
+
try {
|
|
522
|
+
const raw = await fs.readFile(path.join(home, '.bolloon', 'skills', safeName(name), 'SKILL.md'), 'utf8');
|
|
523
|
+
const { createHash } = await import('crypto');
|
|
524
|
+
return createHash('sha256').update(raw).digest('hex').slice(0, 16);
|
|
525
|
+
}
|
|
526
|
+
catch {
|
|
527
|
+
return null;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
export const SKILL_CANDIDATE_BOUNDARY_NOTE = 'Skill 自动更新不覆盖正在执行的 snapshot: 候选只写进 <home>/.bolloon/skill-candidates/, '
|
|
531
|
+
+ "`snapshotScope='next_run_only'` 且 `appliesToRunningRun=false` (新版本只影响下一次 Run)。";
|
|
532
|
+
/**
|
|
533
|
+
* 收尾候选的 `contentHash` (P5 验收修复)。
|
|
534
|
+
*
|
|
535
|
+
* 只覆盖**草案内容** —— 名字 / 用途 / 输入输出契约 / 保证与不保证 / 失败边界 / 证据面 / 出现次数与边界清晰度;
|
|
536
|
+
* **不含** candidateId / proposedAt / sourceRunIds (同一份内容来自不同 Run 必须是同一个哈希, 否则
|
|
537
|
+
* `assessCandidate` 的 `duplicate_of_existing` 永远判不出来)。算法用 `work-contract.stableHash`
|
|
538
|
+
* (与工作合同/快照同一套哈希口径), 不是 crypto —— 内容哈希只需**可比**, 不需要抗碰撞强度。
|
|
539
|
+
*/
|
|
540
|
+
export function candidateContentHash(c) {
|
|
541
|
+
const parts = [
|
|
542
|
+
`name=${c.name}`,
|
|
543
|
+
`purpose=${c.purpose}`,
|
|
544
|
+
`input=${c.inputSchema}`,
|
|
545
|
+
`output=${c.outputSchema}`,
|
|
546
|
+
`guarantees=${[...(c.guarantees ?? [])].join('|')}`,
|
|
547
|
+
`doesNotGuarantee=${[...(c.doesNotGuarantee ?? [])].join('|')}`,
|
|
548
|
+
`failureCases=${[...(c.failureCases ?? [])].join('|')}`,
|
|
549
|
+
`evidenceRefs=${[...(c.evidenceRefs ?? [])].join('|')}`,
|
|
550
|
+
`occurrences=${c.occurrences}`,
|
|
551
|
+
`boundaryClear=${c.boundaryClear === true}`,
|
|
552
|
+
];
|
|
553
|
+
return stableHash(parts.join('\u0000'));
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* 写一条 Skill 改进候选 (closeRun 的 `deps.writeCandidate`)。
|
|
557
|
+
*
|
|
558
|
+
* 两道门:
|
|
559
|
+
* ① 垃圾候选**不落盘** (`assessCandidate` 的垃圾理由非空 → 返回 null, 并留下原因);
|
|
560
|
+
* ② **永不写** `<home>/.bolloon/skills/` —— 正在执行的 Skill snapshot 不可能被自动更新覆盖;
|
|
561
|
+
* 候选记录里显式标注 `snapshotScope='next_run_only'` 与冻结时的运行中哈希 (可核验)。
|
|
562
|
+
*/
|
|
563
|
+
export async function writeSkillCandidate(c, ctx = {}) {
|
|
564
|
+
const home = ctx.home ?? bolloonHome();
|
|
565
|
+
const existing = ctx.existing ?? [];
|
|
566
|
+
const assessment = assessCandidate(c, existing);
|
|
567
|
+
if (assessment.junkReasons.length > 0)
|
|
568
|
+
return null;
|
|
569
|
+
const runningSnapshotHash = ctx.runningSnapshotHash ?? (await skillSnapshotHash(c.name, home));
|
|
570
|
+
const file = path.join(home, SKILL_CANDIDATES_ROOT, `${safeName(c.candidateId)}.json`);
|
|
571
|
+
const prev = await readJson(file);
|
|
572
|
+
const payload = {
|
|
573
|
+
...c,
|
|
574
|
+
snapshotScope: 'next_run_only',
|
|
575
|
+
appliesToRunningRun: false,
|
|
576
|
+
runningSnapshotHash,
|
|
577
|
+
comparison: assessment.comparison,
|
|
578
|
+
promotable: assessment.promotable,
|
|
579
|
+
boundaryNote: SKILL_CANDIDATE_BOUNDARY_NOTE,
|
|
580
|
+
recordedAt: ctx.now ?? new Date().toISOString(),
|
|
581
|
+
/**
|
|
582
|
+
* ★ 通道状态 (2026-09-25 串行收口): 候选文件是**这条候选唯一的事实落点** ——
|
|
583
|
+
* 准入 (①–⑤) 与试用记录写在这里, 结算 (⑥ 提升/回退) 也写回这里。
|
|
584
|
+
* 不新开目录/不新增存储: `trial` 就是"这份候选当前处在试用的哪一步"。
|
|
585
|
+
* 准入**不是**晋升 (`promotion` 在准入侧恒为 null, 由 `settleSkillTrial` 才能给出)。
|
|
586
|
+
*/
|
|
587
|
+
channel: ctx.admission
|
|
588
|
+
? {
|
|
589
|
+
ok: ctx.admission.ok,
|
|
590
|
+
stages: ctx.admission.stages,
|
|
591
|
+
refusal: ctx.admission.refusal,
|
|
592
|
+
note: '准入 ≠ 晋升: 提升的唯一出口是 settleSkillTrial (下一条 Run 成功复用并带证据)',
|
|
593
|
+
}
|
|
594
|
+
: null,
|
|
595
|
+
trial: ctx.admission?.trial ?? null,
|
|
596
|
+
/**
|
|
597
|
+
* 提升记录: 候选产出时**显式**写 `null` (不是省略这个键) ——
|
|
598
|
+
* 「键缺席」会被读成"没查过", `null` 才是"查过, 还没有"。已有记录一律保留 (提升过的不许被
|
|
599
|
+
* 一次重复收尾抹掉; 与下面那条"同一候选 id 不同内容不覆盖"的守卫同一方向)。
|
|
600
|
+
*/
|
|
601
|
+
promotion: (prev?.promotion ?? null),
|
|
602
|
+
};
|
|
603
|
+
if (prev && JSON.stringify(prev) !== JSON.stringify(payload) && prev.runningSnapshotHash === runningSnapshotHash) {
|
|
604
|
+
// 同一候选 id 已有不同内容 → 不覆盖 (候选是事实记录, 不是可变状态)
|
|
605
|
+
return null;
|
|
606
|
+
}
|
|
607
|
+
await atomicWrite(file, JSON.stringify(payload, null, 2));
|
|
608
|
+
return file;
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* Run 收尾 (P1): **成功 / 失败 / 中断恢复的 Run 结尾都走这一条**。
|
|
612
|
+
*
|
|
613
|
+
* 注入真依赖: `writeMemory` = `memory-layers.writeMemoryRecords` (真落盘),
|
|
614
|
+
* `writeCandidate` = `writeSkillCandidate` (过垃圾门, 绝不覆盖运行中的 snapshot),
|
|
615
|
+
* `decide` = `decideContinuation` **外面套 `applyHardLimits`** (只收紧)。
|
|
616
|
+
*
|
|
617
|
+
* 返回的 `continuationPatch` 是"Goal 上唯一的权威 continuation"的飞轮部分;
|
|
618
|
+
* 与运输层合并由 `mergeGoalOutcome` 负责 (单一处收敛)。
|
|
619
|
+
*/
|
|
620
|
+
export async function closeGoalRun(input) {
|
|
621
|
+
const home = input.home ?? bolloonHome();
|
|
622
|
+
const now = input.now ?? new Date().toISOString();
|
|
623
|
+
const run = await readRun(input.runId).catch(() => null);
|
|
624
|
+
const goal = await readGoal(input.goalId);
|
|
625
|
+
if (!run || !goal)
|
|
626
|
+
return null;
|
|
627
|
+
const records = await readDecisionRecords(goal.goalId, home);
|
|
628
|
+
const prevSnapshot = [...records].reverse().find((r) => r.phase === 'closure')?.goalSnapshot ?? null;
|
|
629
|
+
const progress = computeRunProgress(goal, run, prevSnapshot);
|
|
630
|
+
const streak = await deriveNoProgressStreak(goal, run.runId, progress, records, home);
|
|
631
|
+
const hardLimits = hardLimitsFor({ goal, run, maxRetries: input.maxRetries });
|
|
632
|
+
// ★ 进展口径对齐 (接线层的判断, 单一处):
|
|
633
|
+
// `closeRun` 内部按 `run.evidence` 算"本轮可核验证据", 而真实执行器 (pi-sdk harness / CLI)
|
|
634
|
+
// 通常只记 `steps`; 只认 evidence 的话"有进展就不停"在真实路径里永远不成立 (每个 Run 都会被
|
|
635
|
+
// 判成 ⑪ 没有继续的资格) —— 与飞轮设计 (P0: 节奏由进展决定) 相反。
|
|
636
|
+
// 这里把**成功步骤的事实摘要**并入 Run 的证据面, 与既有 `applyDecision` 的证据同步
|
|
637
|
+
// (run.steps.filter(ok) → addEvidence) 和 `runEvidenceOf` 同一口径。
|
|
638
|
+
const runForClosure = {
|
|
639
|
+
...run,
|
|
640
|
+
evidence: uniqNonEmpty([
|
|
641
|
+
...(run.evidence ?? []),
|
|
642
|
+
...(run.steps ?? []).filter((s) => s.ok).map((s) => `${run.runId}/${s.tool}: ${String(s.summary || '(完成)').slice(0, 120)}`),
|
|
643
|
+
]),
|
|
644
|
+
};
|
|
645
|
+
const existingSkills = (goal.skillSnapshot ?? []).map((s) => ({ name: s.name, contentHash: s.contentHash, version: s.version }));
|
|
646
|
+
// ★ 串行收口: 盘上**还挂在试用位**的候选也算"已有" —— 同一条要求被下一条 Run 又提一遍时,
|
|
647
|
+
// 那是 `duplicate_of_existing` (不需要新版本), 不该再写一份候选、更不该再开一次试用
|
|
648
|
+
// (候选 id 带 Run, 所以"同名同内容"在这里是常态; 见 trialingSkillsOfGoal)。
|
|
649
|
+
for (const t of await trialingSkillsOfGoal(home, goal.runs ?? [])) {
|
|
650
|
+
if (!existingSkills.some((e) => e.name === t.name && e.contentHash === t.contentHash))
|
|
651
|
+
existingSkills.push(t);
|
|
652
|
+
}
|
|
653
|
+
const candidatePaths = [];
|
|
654
|
+
const trialOpenings = [];
|
|
655
|
+
/** 同一批里**排在前面**的候选 (批内去重: 同名第二次不许再开一次试用) */
|
|
656
|
+
const batchSiblings = [];
|
|
657
|
+
const result = await closeRun({
|
|
658
|
+
goalId: goal.goalId,
|
|
659
|
+
runId: run.runId,
|
|
660
|
+
run: runForClosure,
|
|
661
|
+
finalReview: input.finalReview ?? '',
|
|
662
|
+
now,
|
|
663
|
+
goal,
|
|
664
|
+
noProgressStreak: streak,
|
|
665
|
+
hardLimits,
|
|
666
|
+
}, {
|
|
667
|
+
writeMemory: (batch, at) => writeMemoryRecords(home, batch, at),
|
|
668
|
+
writeCandidate: async (c) => {
|
|
669
|
+
// ★ 候选产出处 = 通道入口 (2026-09-25 串行收口): 候选**一被写出来**就过 ①–⑤ 准入。
|
|
670
|
+
// 准入不是晋升 (promotion 恒为 null), 但"开过试用没有"必须当场有结论 ——
|
|
671
|
+
// 否则下一条 Run 的复用确认 (settleSkillTrial) 根本没有试用记录可结算 (M2 报的钩子)。
|
|
672
|
+
// 试用记录写进**候选文件本身** (已有落点, 不新开目录/不新增存储)。
|
|
673
|
+
const admission = openSkillTrial({
|
|
674
|
+
candidate: c,
|
|
675
|
+
existing: existingSkills,
|
|
676
|
+
siblings: batchSiblings,
|
|
677
|
+
requestedBy: 'closure',
|
|
678
|
+
runningSnapshotHash: await skillSnapshotHash(c.name, home),
|
|
679
|
+
now,
|
|
680
|
+
});
|
|
681
|
+
const p = await writeSkillCandidate(c, { home, existing: existingSkills, now, admission });
|
|
682
|
+
if (p) {
|
|
683
|
+
candidatePaths.push(p);
|
|
684
|
+
batchSiblings.push(c);
|
|
685
|
+
}
|
|
686
|
+
trialOpenings.push({ candidateId: c.candidateId, skillName: c.name, admission, path: p });
|
|
687
|
+
return p;
|
|
688
|
+
},
|
|
689
|
+
decide: (decisionInput) => applyHardLimits(decideContinuation(decisionInput), decisionInput.hardLimits),
|
|
690
|
+
// ★ 2026-09-25 (P5 验收修复): 候选的 contentHash 由**这里**注入 (run-closure 不 import 别的阶段
|
|
691
|
+
// 实现, 见它的文件头边界) —— 缺了它, 每份候选都被第二道门以 `unverifiable_result` 拒收。
|
|
692
|
+
contentHashOf: candidateContentHash,
|
|
693
|
+
});
|
|
694
|
+
// 用户汇报落盘 (每 Run 一份, 可回放)
|
|
695
|
+
const reportPath = path.join(home, GOAL_REPORTS_ROOT, `${safeName(goal.goalId)}--${safeName(run.runId)}.json`);
|
|
696
|
+
await atomicWrite(reportPath, JSON.stringify(result.userReport, null, 2));
|
|
697
|
+
// 决策记录落盘 (closure 阶段 = 权威决策 + 收尾产物本体: 收尾幂等靠它读回事实)
|
|
698
|
+
const decisionRecordPath = await writeDecisionRecord({
|
|
699
|
+
goalId: goal.goalId,
|
|
700
|
+
runId: run.runId,
|
|
701
|
+
phase: 'closure',
|
|
702
|
+
decision: result.decision,
|
|
703
|
+
runnable: isRunnable(result.decision, now).runnable,
|
|
704
|
+
runnableReason: isRunnable(result.decision, now).reason,
|
|
705
|
+
noProgressStreak: streak,
|
|
706
|
+
hardLimits,
|
|
707
|
+
goalSnapshot: snapshotOf(goal),
|
|
708
|
+
recordedAt: now,
|
|
709
|
+
continuation: result.continuation,
|
|
710
|
+
userReport: result.userReport,
|
|
711
|
+
}, home);
|
|
712
|
+
const memoryFiles = result.facts.map((f) => path.join(home, '.bolloon', 'memory-layers', 'run_fact', `${safeName(f.memoryId)}.json`));
|
|
713
|
+
return {
|
|
714
|
+
result,
|
|
715
|
+
written: memoryFiles,
|
|
716
|
+
rejected: [],
|
|
717
|
+
candidatePaths,
|
|
718
|
+
trials: trialOpenings,
|
|
719
|
+
reportPath,
|
|
720
|
+
decisionRecordPath,
|
|
721
|
+
continuationPatch: toGoalStoreContinuation(result.continuation),
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
/**
|
|
725
|
+
* 把通道状态写回候选文件 —— **只动 `trial` / `promotion` 两个键**, 草案内容一个字节都不改。
|
|
726
|
+
*
|
|
727
|
+
* 为什么绕过 `writeSkillCandidate` 的"同名不同内容不覆盖"守卫: 那条守卫保护的是**草案**
|
|
728
|
+
* (候选是事实记录), 而通道状态 (trialing → promoted / rolled_back) 本来就是会变的状态 ——
|
|
729
|
+
* 这是它唯一允许的写回点 (而且写回的就是"试用被兑现/回退"这个事实)。
|
|
730
|
+
*/
|
|
731
|
+
async function writeCandidateChannelState(file, patch) {
|
|
732
|
+
const cur = await readJson(file);
|
|
733
|
+
if (!cur)
|
|
734
|
+
return;
|
|
735
|
+
await atomicWrite(file, JSON.stringify({
|
|
736
|
+
...cur,
|
|
737
|
+
trial: patch.trial,
|
|
738
|
+
promotion: patch.promotion ?? cur.promotion ?? null,
|
|
739
|
+
}, null, 2));
|
|
740
|
+
}
|
|
741
|
+
/**
|
|
742
|
+
* `existing` 里要计入的**已挂试用**的 Skill 身份 (2026-09-25 串行收口)。
|
|
743
|
+
*
|
|
744
|
+
* 为什么必须有这一条: 候选 id 里带 Run (`cand:<runId>:<i>:<name>`), 所以同一条要求被**下一条 Run
|
|
745
|
+
* 又提一遍**时是一份**新候选** —— 盘上于是出现两份同名同内容的候选, 各开一次试用。而"同名同内容"
|
|
746
|
+
* 在冻结纪律里就是 `duplicate_of_existing` (不需要新版本): **已经挂在试用位 (还没提升) 的那一份
|
|
747
|
+
* 就是"已有"**。不认它, 一条变更会有两条提升记录, 且第二份候选永远等不到兑现。
|
|
748
|
+
*
|
|
749
|
+
* 归属只认**可核验的 Run 来源**: 候选的 `sourceRunIds` 里有一条属于本 Goal (`goal.runs`)。
|
|
750
|
+
* 读不到目录 = 没有候选 (不是"候选都通过了")。
|
|
751
|
+
*/
|
|
752
|
+
async function trialingSkillsOfGoal(home, goalRunIds) {
|
|
753
|
+
const ids = new Set((goalRunIds ?? []).map((x) => String(x)));
|
|
754
|
+
const out = [];
|
|
755
|
+
for (const { rec } of await listTrialingCandidates(home)) {
|
|
756
|
+
const trial = rec.trial;
|
|
757
|
+
const src = Array.isArray(rec.sourceRunIds) ? rec.sourceRunIds.map((x) => String(x)) : [];
|
|
758
|
+
if (!src.some((r) => ids.has(r)))
|
|
759
|
+
continue;
|
|
760
|
+
out.push({
|
|
761
|
+
name: String(rec.name ?? trial.skillName),
|
|
762
|
+
contentHash: String(rec.contentHash ?? trial.contentHash),
|
|
763
|
+
version: String(trial.toVersion),
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
return out;
|
|
767
|
+
}
|
|
768
|
+
/**
|
|
769
|
+
* 盘上"还在试用位"的候选 (通道 ⑥ 的输入面)。
|
|
770
|
+
* 读不到目录 = 没有任何候选 (不是"候选都通过/都失败")。
|
|
771
|
+
*/
|
|
772
|
+
async function listTrialingCandidates(home) {
|
|
773
|
+
const dir = path.join(home, SKILL_CANDIDATES_ROOT);
|
|
774
|
+
let files = [];
|
|
775
|
+
try {
|
|
776
|
+
files = await fs.readdir(dir);
|
|
777
|
+
}
|
|
778
|
+
catch {
|
|
779
|
+
return [];
|
|
780
|
+
}
|
|
781
|
+
const out = [];
|
|
782
|
+
for (const f of files.filter((x) => x.endsWith('.json')).sort()) {
|
|
783
|
+
const file = path.join(dir, f);
|
|
784
|
+
const rec = await readJson(file);
|
|
785
|
+
if (rec?.trial && rec.trial.status === 'trialing')
|
|
786
|
+
out.push({ file, rec });
|
|
787
|
+
}
|
|
788
|
+
return out;
|
|
789
|
+
}
|
|
790
|
+
/**
|
|
791
|
+
* ★ 通道 ⑥ 的真调用方 (2026-09-25 串行收口): **下一条 Run 的成功点**结算所有还挂在试用位上的候选。
|
|
792
|
+
*
|
|
793
|
+
* 判定纪律 (全部来自盘上事实, 不靠调用方"记得"):
|
|
794
|
+
* · 兑现试用的 Run 必须**不是**开试用那条 (`settleSkillTrial` 会拒同一条 Run 自称成功);
|
|
795
|
+
* · 兑现的 Run 必须属于**同一个 Goal** (读 `trial.startedByRunId` 那条 Run 的 goalId 对照) ——
|
|
796
|
+
* 别的 Goal 的成功不许把这份候选提升上去;
|
|
797
|
+
* · **复用必须带可核验证据**: 该 Run 的证据面里出现这条 Skill 名字的行; 一条都没有 → 不算复用
|
|
798
|
+
* (留在试用位, 与"无证据不许判目标完成"同一纪律);
|
|
799
|
+
* · Run 没成功 (status !== 'done') → `rolled_back`, **不提升**。
|
|
800
|
+
*
|
|
801
|
+
* 只产生**记录**: `SkillPromotionRecord` (校验 + 快照 + 可回退版本), 永不写 `skills/` (冻结规则 ⑥)。
|
|
802
|
+
*/
|
|
803
|
+
export async function settleSkillTrialsForRun(input) {
|
|
804
|
+
const home = input.home ?? bolloonHome();
|
|
805
|
+
const run = await readRun(input.runId).catch(() => null);
|
|
806
|
+
const evidencePool = input.evidenceRefs
|
|
807
|
+
? input.evidenceRefs.map((x) => String(x ?? ''))
|
|
808
|
+
: run ? runEvidenceOf(run) : [];
|
|
809
|
+
const succeeded = String(input.runStatus) === 'done';
|
|
810
|
+
const out = [];
|
|
811
|
+
for (const { file, rec } of await listTrialingCandidates(home)) {
|
|
812
|
+
const trial = rec.trial;
|
|
813
|
+
const candidateId = String(rec.candidateId ?? trial.candidateId ?? '');
|
|
814
|
+
const skillName = String(rec.name ?? trial.skillName ?? '');
|
|
815
|
+
const push = (v) => {
|
|
816
|
+
out.push({ candidateId, skillName, path: file, ...v });
|
|
817
|
+
};
|
|
818
|
+
if (trial.startedByRunId === input.runId) {
|
|
819
|
+
// 这份试用**就是本条 Run 开的** → 它不是"被本条 Run 结算"的对象 (试用的兑现窗口在它之后)。
|
|
820
|
+
// 所以这里**跳过**而不是记一条 `same_run_cannot_promote` 的"结算结论": 报告里的
|
|
821
|
+
// `skillTrials` 说的是"这一轮的结局对哪份试用做了裁决", 把刚开出的那份算进来会读成
|
|
822
|
+
// "结算过了但没提升" —— 事实是**还没到结算的时候** (候选文件原封不动, 下一条 Run 再结算)。
|
|
823
|
+
// (纯函数 `settleSkillTrial` 的同一守卫仍然在: 直连调用它的地方照样判红, 见 closure 测试。)
|
|
824
|
+
continue;
|
|
825
|
+
}
|
|
826
|
+
// 跨 Goal 保护: 读不到开试用那条 Run 就**不结算** (拿不到事实不下结论, 也不提升)
|
|
827
|
+
const fromRun = trial.startedByRunId ? await readRun(trial.startedByRunId).catch(() => null) : null;
|
|
828
|
+
if (!fromRun) {
|
|
829
|
+
push({ status: trial.status, promoted: false, toVersion: null, reason: `trial_origin_run_unreadable: 读不到开试用那条 Run (${trial.startedByRunId || '(空)'}) → 不结算 (拿不到事实就不提升)` });
|
|
830
|
+
continue;
|
|
831
|
+
}
|
|
832
|
+
if (String(fromRun.goalId ?? '') !== input.goalId) {
|
|
833
|
+
push({ status: trial.status, promoted: false, toVersion: null, reason: `trial_belongs_to_other_goal: 试用属于 ${fromRun.goalId} 的 Run, 与本 Goal (${input.goalId}) 不同 → 不结算` });
|
|
834
|
+
continue;
|
|
835
|
+
}
|
|
836
|
+
const reuseEvidence = succeeded
|
|
837
|
+
? evidencePool.filter((e) => skillName && e.includes(skillName))
|
|
838
|
+
: [];
|
|
839
|
+
const settlement = settleSkillTrial({
|
|
840
|
+
trial,
|
|
841
|
+
candidate: rec,
|
|
842
|
+
reuse: {
|
|
843
|
+
runId: input.runId,
|
|
844
|
+
succeeded,
|
|
845
|
+
evidenceRefs: reuseEvidence,
|
|
846
|
+
approvedBy: input.approvedBy ?? 'supervisor',
|
|
847
|
+
changeReason: succeeded
|
|
848
|
+
? `复用确认: 下一条 Run (${input.runId}) 成功并带 ${reuseEvidence.length} 条点名「${skillName}」的证据`
|
|
849
|
+
: `下一条 Run (${input.runId}) 未成功 (status=${input.runStatus}) → 复用失败`,
|
|
850
|
+
now: input.now,
|
|
851
|
+
},
|
|
852
|
+
});
|
|
853
|
+
await writeCandidateChannelState(file, { trial: settlement.trial, promotion: settlement.promotion });
|
|
854
|
+
push({
|
|
855
|
+
status: settlement.status,
|
|
856
|
+
promoted: settlement.ok,
|
|
857
|
+
toVersion: settlement.promotion?.toVersion ?? null,
|
|
858
|
+
reason: settlement.reason,
|
|
859
|
+
});
|
|
860
|
+
}
|
|
861
|
+
return out;
|
|
862
|
+
}
|
|
863
|
+
/**
|
|
864
|
+
* `GoalContinuationRecord` (飞轮权威) → `GoalContinuation` (goal-store 落盘形态)。
|
|
865
|
+
*
|
|
866
|
+
* 这是**唯一**的收敛点 (§2 "Goal 上只有一个权威 continuation"): 别处不许再拼一套。
|
|
867
|
+
* 共享调度核心字段直接对齐; 旧字段 (attempts / external / lastRunId / replayGuards …) 不在这里
|
|
868
|
+
* 覆盖 (它们由运输层提供), 由调用方合并。
|
|
869
|
+
*/
|
|
870
|
+
export function toGoalStoreContinuation(rec) {
|
|
871
|
+
return {
|
|
872
|
+
nextAction: rec.nextAction,
|
|
873
|
+
wakeAt: rec.wakeAt ?? undefined,
|
|
874
|
+
wakeReason: wakeReasonFor(rec.state),
|
|
875
|
+
autoContinue: rec.autoContinue,
|
|
876
|
+
updatedAt: rec.updatedAt,
|
|
877
|
+
state: rec.state,
|
|
878
|
+
lastDecisionId: rec.lastDecisionId ?? undefined,
|
|
879
|
+
requiredAgent: rec.requiredAgent ?? undefined,
|
|
880
|
+
unresolvedItems: [...rec.unresolvedItems],
|
|
881
|
+
pendingReports: [...rec.pendingReports],
|
|
882
|
+
};
|
|
883
|
+
}
|
|
884
|
+
/** GoalLifecycleState → goal-store 的 wakeReason 闭集 (别造新取值: 类型面是字面量联合) */
|
|
885
|
+
export function wakeReasonFor(state) {
|
|
886
|
+
switch (state) {
|
|
887
|
+
case 'active':
|
|
888
|
+
return 'active';
|
|
889
|
+
case 'recovering':
|
|
890
|
+
return 'recovering';
|
|
891
|
+
case 'retry_wait':
|
|
892
|
+
return 'retry_wait';
|
|
893
|
+
case 'awaiting_external':
|
|
894
|
+
return 'awaiting_external';
|
|
895
|
+
case 'stalled':
|
|
896
|
+
return 'stalled';
|
|
897
|
+
case 'paused':
|
|
898
|
+
return 'paused';
|
|
899
|
+
case 'needs_human':
|
|
900
|
+
return 'needs_human';
|
|
901
|
+
case 'completed':
|
|
902
|
+
return 'completed';
|
|
903
|
+
case 'failed':
|
|
904
|
+
return 'failed';
|
|
905
|
+
default:
|
|
906
|
+
return 'active';
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
/**
|
|
910
|
+
* 把飞轮决策与既有运输层 reducer 合并成**一份**落盘 continuation。
|
|
911
|
+
*
|
|
912
|
+
* 规则 (与文件头一致):
|
|
913
|
+
* ① 飞轮终态 / 熔断 / 暂停 → 照飞轮停 (`flywheelStop=true`, autoContinue=false);
|
|
914
|
+
* ② 运输层因"轮次用尽"要停, 但**飞轮说还有进展**且不是硬错误 → 继续 (轮次不再是主停止条件);
|
|
915
|
+
* ③ 其余情况运输层照旧 (退避 / wakeAt / attempts 语义不变), 飞轮提供 nextAction / 决策 id /
|
|
916
|
+
* 未解决项 / 待回报 / state。
|
|
917
|
+
*/
|
|
918
|
+
export function mergeGoalOutcome(input) {
|
|
919
|
+
const { legacy, flywheel, run } = input;
|
|
920
|
+
const fy = flywheel.decision;
|
|
921
|
+
const rec = flywheel.continuation;
|
|
922
|
+
const base = { ...legacy.continuation };
|
|
923
|
+
const hardError = HARD_STOP_ERROR_CLASSES.includes(String(run?.errorClass || ''));
|
|
924
|
+
const flywheelFields = toGoalStoreContinuation({ ...rec, pendingReports: input.pendingReports ?? rec.pendingReports });
|
|
925
|
+
let goalStatus = legacy.goalStatus;
|
|
926
|
+
let reason = legacy.reason;
|
|
927
|
+
let flywheelStop = false;
|
|
928
|
+
let roundsOverridden = false;
|
|
929
|
+
if (fy.decision === 'fail') {
|
|
930
|
+
goalStatus = 'failed';
|
|
931
|
+
flywheelStop = true;
|
|
932
|
+
reason = `飞轮判不可达: ${fy.reason}`;
|
|
933
|
+
}
|
|
934
|
+
else if (fy.decision === 'complete') {
|
|
935
|
+
goalStatus = 'completed';
|
|
936
|
+
reason = `飞轮判完成 (仍要过 goal-store 完成门): ${fy.reason}`;
|
|
937
|
+
}
|
|
938
|
+
else if (fy.decision === 'pause') {
|
|
939
|
+
goalStatus = 'paused';
|
|
940
|
+
flywheelStop = true;
|
|
941
|
+
reason = `飞轮: 人主动暂停 (不自动唤醒): ${fy.reason}`;
|
|
942
|
+
}
|
|
943
|
+
else if (fy.decision === 'ask_human' && (fy.state === 'no_progress' || fy.state === 'blocked')) {
|
|
944
|
+
goalStatus = 'needs_human';
|
|
945
|
+
flywheelStop = true;
|
|
946
|
+
reason = `飞轮收紧 (${fy.state}): ${fy.reason}`;
|
|
947
|
+
}
|
|
948
|
+
else if ((fy.decision === 'continue' || fy.decision === 'delegate' || fy.decision === 'wait')
|
|
949
|
+
&& legacy.goalStatus === 'needs_human'
|
|
950
|
+
&& !hardError) {
|
|
951
|
+
// ② 轮次用尽 ≠ 该停: 只要本轮有可核验进展, 继续
|
|
952
|
+
goalStatus = run && run.status === 'failed' ? 'retry_wait' : 'active';
|
|
953
|
+
roundsOverridden = true;
|
|
954
|
+
reason = `飞轮覆盖轮次上限 (本轮有进展, 不是硬错误): ${fy.reason}`;
|
|
955
|
+
}
|
|
956
|
+
else if (fy.decision === 'wait' && !hardError && legacy.goalStatus !== 'retry_wait') {
|
|
957
|
+
// ★ M5-⑤ (2026-09-25) 真跑发现的不一致: 飞轮说"等"的时候, Goal 状态**和**用户可见态也必须说"等"。
|
|
958
|
+
// `lifecycleOf('active')` 会把 continuation.state 写成 `active` → `toUserVisibleState` 读出
|
|
959
|
+
// "执行中", 而真相是"在等外部": 没有任何 Run 会再跑它 (等事件/等到点)。页面上的"在跑"是假的。
|
|
960
|
+
// 等外部/等子 Agent (没有到点时间) = `awaiting_external` (只能由事件唤醒);
|
|
961
|
+
// 有 `wakeAt` 的等待 = `retry_wait` (到点自动醒来跑下一轮)。
|
|
962
|
+
goalStatus = fy.wakeAt ? 'retry_wait' : 'awaiting_external';
|
|
963
|
+
reason = `飞轮: 在等 (${fy.state}): ${fy.reason}`;
|
|
964
|
+
}
|
|
965
|
+
const continuation = flywheelStop
|
|
966
|
+
? {
|
|
967
|
+
...base,
|
|
968
|
+
...flywheelFields,
|
|
969
|
+
autoContinue: false,
|
|
970
|
+
wakeAt: undefined,
|
|
971
|
+
wakeReason: goalStatus === 'completed' ? 'completed' : goalStatus === 'failed' ? 'failed' : goalStatus === 'paused' ? 'paused' : 'needs_human',
|
|
972
|
+
}
|
|
973
|
+
: {
|
|
974
|
+
...base,
|
|
975
|
+
...flywheelFields,
|
|
976
|
+
// 运输层是"何时再唤醒"的权威; 飞轮只是补上"下一步是什么"(没有既定时才用它)
|
|
977
|
+
autoContinue: base.autoContinue !== false,
|
|
978
|
+
wakeAt: base.wakeAt ?? flywheelFields.wakeAt,
|
|
979
|
+
wakeReason: base.wakeReason ?? flywheelFields.wakeReason,
|
|
980
|
+
state: lifecycleOf(goalStatus),
|
|
981
|
+
};
|
|
982
|
+
// ② 的落盘面: 覆盖了"轮次用尽"就必须把运输层的**停**一并撤掉 ——
|
|
983
|
+
// 否则 legacy 那边写下的 `autoContinue:false` 仍会把 Goal 锁在"等人"里 (等于没覆盖)。
|
|
984
|
+
// `wakeAt = now`: 退避已用尽, 立刻可跑; 且 `isRunnable('wait')` 对"没有 wakeAt"是
|
|
985
|
+
// **拒绝**的 (只能靠事件唤醒) —— 不写就会在下一 tick 被自己的前置判定拦住 (前后不自洽)。
|
|
986
|
+
if (roundsOverridden) {
|
|
987
|
+
continuation.autoContinue = true;
|
|
988
|
+
continuation.wakeAt = input.now ?? base.wakeAt;
|
|
989
|
+
continuation.wakeReason = goalStatus === 'retry_wait' ? 'retry_wait' : 'active';
|
|
990
|
+
}
|
|
991
|
+
continuation.state = lifecycleOf(goalStatus);
|
|
992
|
+
return { goalStatus, continuation, reason, flywheelStop, roundsOverridden };
|
|
993
|
+
}
|
|
994
|
+
// ============================================================================
|
|
995
|
+
// §7. P2 子 Agent 工作合同 (派遣必发合同 / 回报必核验)
|
|
996
|
+
// ============================================================================
|
|
997
|
+
export function workDirFor(goalId, home = bolloonHome()) {
|
|
998
|
+
return path.join(home, GOAL_WORKS_ROOT, safeName(goalId));
|
|
999
|
+
}
|
|
1000
|
+
export function contractPathFor(goalId, workId, home = bolloonHome()) {
|
|
1001
|
+
return path.join(workDirFor(goalId, home), `${safeName(workId)}.json`);
|
|
1002
|
+
}
|
|
1003
|
+
export function reportPathFor(goalId, workId, home = bolloonHome()) {
|
|
1004
|
+
return path.join(workDirFor(goalId, home), `${safeName(workId)}.report.json`);
|
|
1005
|
+
}
|
|
1006
|
+
export function heartbeatPathFor(goalId, workId, home = bolloonHome()) {
|
|
1007
|
+
return path.join(workDirFor(goalId, home), `${safeName(workId)}.hb.json`);
|
|
1008
|
+
}
|
|
1009
|
+
/**
|
|
1010
|
+
* 派遣一个子 Agent 工作: **必发合同** (`issueWorkContract`), 并把"还没回报"这件事写进
|
|
1011
|
+
* Goal 的权威 continuation (`pendingReports`), 而不是只发一句话。
|
|
1012
|
+
*
|
|
1013
|
+
* 合同不合法 → `issueWorkContract` 抛 `WorkContractError` (不做"差不多就行")。
|
|
1014
|
+
*/
|
|
1015
|
+
export async function dispatchChildWork(input) {
|
|
1016
|
+
const home = input.home ?? bolloonHome();
|
|
1017
|
+
const now = input.now ?? new Date().toISOString();
|
|
1018
|
+
const contract = issueWorkContract({
|
|
1019
|
+
goalId: input.goalId,
|
|
1020
|
+
parentRunId: input.parentRunId,
|
|
1021
|
+
childAgentId: input.childAgentId,
|
|
1022
|
+
capability: input.capability,
|
|
1023
|
+
objective: input.objective,
|
|
1024
|
+
inputs: input.inputs ?? {},
|
|
1025
|
+
allowedTools: input.allowedTools ?? [],
|
|
1026
|
+
budget: input.budget,
|
|
1027
|
+
deadline: input.deadline ?? null,
|
|
1028
|
+
successCriteria: input.successCriteria,
|
|
1029
|
+
now,
|
|
1030
|
+
issuedBy: input.issuedBy ?? 'supervisor',
|
|
1031
|
+
});
|
|
1032
|
+
await atomicWrite(contractPathFor(contract.goalId, contract.workId, home), JSON.stringify(contract, null, 2));
|
|
1033
|
+
const goal = await readGoal(input.goalId);
|
|
1034
|
+
if (goal) {
|
|
1035
|
+
const pending = [
|
|
1036
|
+
...(goal.continuation?.pendingReports ?? []).filter((p) => p.workId !== contract.workId),
|
|
1037
|
+
{
|
|
1038
|
+
workId: contract.workId,
|
|
1039
|
+
childAgentId: contract.childAgentId,
|
|
1040
|
+
capability: contract.capability,
|
|
1041
|
+
requestedAt: now,
|
|
1042
|
+
deadlineAt: contract.deadline,
|
|
1043
|
+
lastHeartbeatAt: null,
|
|
1044
|
+
},
|
|
1045
|
+
];
|
|
1046
|
+
await setContinuation(input.goalId, { pendingReports: pending, wakeReason: 'active', state: lifecycleOf(goal.status), updatedAt: now });
|
|
1047
|
+
}
|
|
1048
|
+
return contract;
|
|
1049
|
+
}
|
|
1050
|
+
/** 收下子 Agent 回报 (谁收到谁调): 核验 + 是否接受为完成; **不接受就不算完成** */
|
|
1051
|
+
export async function handleChildReport(input) {
|
|
1052
|
+
const home = input.home ?? bolloonHome();
|
|
1053
|
+
const now = input.now ?? new Date().toISOString();
|
|
1054
|
+
const contract = await readJson(contractPathFor(input.goalId, input.workId, home));
|
|
1055
|
+
if (!contract) {
|
|
1056
|
+
return { outcome: 'no_contract', accepted: false, reason: `没有找到合同 ${input.workId} → 没有合同的回报不核验也不接受`, missingFields: [], missingEvidence: [], violation: null };
|
|
1057
|
+
}
|
|
1058
|
+
const validation = validateChildReport(contract, input.report);
|
|
1059
|
+
const verdict = acceptsAsComplete(contract, input.report);
|
|
1060
|
+
await atomicWrite(reportPathFor(input.goalId, input.workId, home), JSON.stringify({ report: input.report, validation, verdict, recordedAt: now }, null, 2));
|
|
1061
|
+
if (!verdict.accepted) {
|
|
1062
|
+
// 不接受为完成 → pendingReports 保持 (下一步还是"等/要"这个回报), 绝不当完成记账
|
|
1063
|
+
const goal = await readGoal(input.goalId);
|
|
1064
|
+
if (goal) {
|
|
1065
|
+
const pending = (goal.continuation?.pendingReports ?? []).filter((p) => p.workId !== input.workId);
|
|
1066
|
+
pending.push({
|
|
1067
|
+
workId: contract.workId,
|
|
1068
|
+
childAgentId: contract.childAgentId,
|
|
1069
|
+
capability: contract.capability,
|
|
1070
|
+
requestedAt: goal.continuation?.pendingReports?.find((p) => p.workId === input.workId)?.requestedAt ?? now,
|
|
1071
|
+
deadlineAt: contract.deadline,
|
|
1072
|
+
lastHeartbeatAt: input.report.reportedAt ?? null,
|
|
1073
|
+
});
|
|
1074
|
+
await setContinuation(input.goalId, { pendingReports: pending, updatedAt: now });
|
|
1075
|
+
}
|
|
1076
|
+
return {
|
|
1077
|
+
outcome: 'incomplete',
|
|
1078
|
+
accepted: false,
|
|
1079
|
+
reason: verdict.reason,
|
|
1080
|
+
missingFields: validation.missingFields,
|
|
1081
|
+
missingEvidence: validation.missingEvidence,
|
|
1082
|
+
violation: validation.violation,
|
|
1083
|
+
};
|
|
1084
|
+
}
|
|
1085
|
+
const goal = await readGoal(input.goalId);
|
|
1086
|
+
if (goal) {
|
|
1087
|
+
await addEvidence(input.goalId, input.report.evidence.slice(0, 5).map((e) => `${contract.workId}: ${e.ref} (${e.note || e.kind})`)).catch(() => null);
|
|
1088
|
+
await setContinuation(input.goalId, {
|
|
1089
|
+
pendingReports: (goal.continuation?.pendingReports ?? []).filter((p) => p.workId !== input.workId),
|
|
1090
|
+
updatedAt: now,
|
|
1091
|
+
});
|
|
1092
|
+
}
|
|
1093
|
+
return { outcome: 'accepted', accepted: true, reason: verdict.reason, missingFields: [], missingEvidence: [], violation: null };
|
|
1094
|
+
}
|
|
1095
|
+
// ============================================================================
|
|
1096
|
+
// §8. P3 阻塞监控 (Watchdog 只看进程存活 ≠ 看任务是否卡住)
|
|
1097
|
+
// ============================================================================
|
|
1098
|
+
export async function recordWorkHeartbeat(goalId, workId, at, home = bolloonHome()) {
|
|
1099
|
+
const at2 = at ?? new Date().toISOString();
|
|
1100
|
+
await atomicWrite(heartbeatPathFor(goalId, workId, home), JSON.stringify({ workId, at: at2 }, null, 2));
|
|
1101
|
+
}
|
|
1102
|
+
/**
|
|
1103
|
+
* 读盘上的待回报工作 → `detectBlocks`。**只诊断**: 接管 / 上报这些副作用由 `applyBlockHandling` 做。
|
|
1104
|
+
*/
|
|
1105
|
+
export async function collectWorkBlocks(input) {
|
|
1106
|
+
const home = input.home ?? bolloonHome();
|
|
1107
|
+
const now = input.now ?? new Date().toISOString();
|
|
1108
|
+
const goal = await readGoal(input.goalId);
|
|
1109
|
+
if (!goal)
|
|
1110
|
+
return [];
|
|
1111
|
+
const pending = goal.continuation?.pendingReports ?? [];
|
|
1112
|
+
if (pending.length === 0)
|
|
1113
|
+
return [];
|
|
1114
|
+
const lease = goal.lease;
|
|
1115
|
+
const leaseOwner = lease && Date.parse(String(lease.leaseUntil || '')) > Date.parse(now) ? lease.owner : null;
|
|
1116
|
+
const out = [];
|
|
1117
|
+
for (const p of pending) {
|
|
1118
|
+
const contract = await readJson(contractPathFor(input.goalId, p.workId, home));
|
|
1119
|
+
if (!contract)
|
|
1120
|
+
continue;
|
|
1121
|
+
const hb = await readJson(heartbeatPathFor(input.goalId, p.workId, home));
|
|
1122
|
+
const reportFile = await readJson(reportPathFor(input.goalId, p.workId, home));
|
|
1123
|
+
const report = reportFile?.report ?? null;
|
|
1124
|
+
const blocks = detectBlocks({
|
|
1125
|
+
contract,
|
|
1126
|
+
report,
|
|
1127
|
+
lastHeartbeatAt: hb?.at ?? p.lastHeartbeatAt ?? null,
|
|
1128
|
+
lastProgressAt: p.requestedAt ?? contract.issuedAt,
|
|
1129
|
+
now,
|
|
1130
|
+
leaseOwner,
|
|
1131
|
+
runnerAvailable: input.runnerAvailable !== false,
|
|
1132
|
+
});
|
|
1133
|
+
// 有回报但**核验不过** → 这份回报不能被当成"已回报" (报告不完整 → 要求补齐)
|
|
1134
|
+
if (report) {
|
|
1135
|
+
const validation = validateChildReport(contract, report);
|
|
1136
|
+
if (!validation.ok) {
|
|
1137
|
+
const already = blocks.some((b) => b.kind === 'report_missing');
|
|
1138
|
+
if (!already) {
|
|
1139
|
+
blocks.push({
|
|
1140
|
+
blockId: `blk:${contract.workId}:report_missing`,
|
|
1141
|
+
kind: 'report_missing',
|
|
1142
|
+
goalId: contract.goalId,
|
|
1143
|
+
runId: contract.parentRunId,
|
|
1144
|
+
workId: contract.workId,
|
|
1145
|
+
childAgentId: contract.childAgentId,
|
|
1146
|
+
blockedAt: report.reportedAt ?? now,
|
|
1147
|
+
lastProgressAt: report.reportedAt ?? now,
|
|
1148
|
+
owner: 'child',
|
|
1149
|
+
dependency: `report:${contract.reportSchema}`,
|
|
1150
|
+
suggestedAction: 'request_report',
|
|
1151
|
+
escalationAt: null,
|
|
1152
|
+
resolvedAt: null,
|
|
1153
|
+
resolution: null,
|
|
1154
|
+
note: `报告核验不过 (${[...validation.missingFields, ...validation.missingEvidence].join('; ')}${validation.violation ? `; 越界=${validation.violation}` : ''}) → 不接受为完成, 要求补充`,
|
|
1155
|
+
});
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
out.push(...blocks);
|
|
1160
|
+
}
|
|
1161
|
+
return out;
|
|
1162
|
+
}
|
|
1163
|
+
/**
|
|
1164
|
+
* 按 `planBlockHandling` 处理阻塞 (副作用: 升级 = Goal → needs_human; 接管 = 记事实 + 清待回报)。
|
|
1165
|
+
* 工具/权限/预算被阻**永不**自动绕过 Harness (`planBlockHandling` 本身就不会给出 takeover)。
|
|
1166
|
+
*/
|
|
1167
|
+
export async function applyBlockHandling(input) {
|
|
1168
|
+
const home = input.home ?? bolloonHome();
|
|
1169
|
+
const now = input.now ?? new Date().toISOString();
|
|
1170
|
+
const out = { actions: [], escalated: [], takeovers: [], requests: [] };
|
|
1171
|
+
const escalatedBlocks = [];
|
|
1172
|
+
for (const b of input.blocks.filter((x) => x.resolvedAt === null)) {
|
|
1173
|
+
const action = planBlockHandling(b);
|
|
1174
|
+
out.actions.push({ workId: b.workId ?? '-', kind: b.kind, action, note: b.note });
|
|
1175
|
+
if (action === 'needs_human' || action === 'escalate_parent' || action === 'change_plan') {
|
|
1176
|
+
escalatedBlocks.push(`${b.kind}(${b.workId ?? '-'}): ${b.note}`);
|
|
1177
|
+
if (action === 'needs_human')
|
|
1178
|
+
out.escalated.push(b.workId ?? '-');
|
|
1179
|
+
}
|
|
1180
|
+
else if (action === 'takeover') {
|
|
1181
|
+
out.takeovers.push(b.workId ?? '-');
|
|
1182
|
+
}
|
|
1183
|
+
else {
|
|
1184
|
+
out.requests.push(`${b.kind}(${b.workId ?? '-'}) → ${action}`);
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
if (escalatedBlocks.length > 0) {
|
|
1188
|
+
const goal = await readGoal(input.goalId);
|
|
1189
|
+
if (goal && !['completed', 'failed', 'abandoned'].includes(goal.status)) {
|
|
1190
|
+
// 规则 ②: 状态 + continuation 一起经 Goal reducer 写。
|
|
1191
|
+
// 旧写法是 setContinuation(...) + updateGoal(status:'needs_human') 两连 —— 后者是绕过 reducer 的
|
|
1192
|
+
// 状态写入 (被 M0 门禁按文件粒度判红后改道到这里)。语义不变: 交人 + 不再自动唤醒 + 记阻塞摘要。
|
|
1193
|
+
await reduceGoalState({
|
|
1194
|
+
goalId: input.goalId,
|
|
1195
|
+
intent: 'block_escalated_human',
|
|
1196
|
+
now,
|
|
1197
|
+
by: 'supervisor',
|
|
1198
|
+
reason: escalatedBlocks.join(' | '),
|
|
1199
|
+
needsExternalText: escalatedBlocks.join(' | ').slice(0, 300),
|
|
1200
|
+
unresolvedItems: uniqNonEmpty([
|
|
1201
|
+
...(goal.continuation?.unresolvedItems ?? []),
|
|
1202
|
+
...escalatedBlocks.map((s) => s.slice(0, 200)),
|
|
1203
|
+
]).slice(0, 30),
|
|
1204
|
+
});
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
if (out.takeovers.length > 0) {
|
|
1208
|
+
const goal = await readGoal(input.goalId);
|
|
1209
|
+
if (goal) {
|
|
1210
|
+
const taken = new Set(out.takeovers);
|
|
1211
|
+
await setContinuation(input.goalId, {
|
|
1212
|
+
pendingReports: (goal.continuation?.pendingReports ?? []).filter((p) => !taken.has(p.workId)),
|
|
1213
|
+
updatedAt: now,
|
|
1214
|
+
});
|
|
1215
|
+
await addEvidence(input.goalId, out.takeovers.map((w) => `父接管: ${w} (子无心跳且执行权空闲, 合同允许 takeover)`)).catch(() => null);
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
void home;
|
|
1219
|
+
return out;
|
|
1220
|
+
}
|
|
1221
|
+
/**
|
|
1222
|
+
* 还没回报的子 Agent 工作的合同摘要 (写进下一个 Run 的指令 —— 规则 5 要求子拿到同一份合同)。
|
|
1223
|
+
* 没有合同时返回 null (不编)。
|
|
1224
|
+
*/
|
|
1225
|
+
export async function pendingContractDigest(goalId, home = bolloonHome()) {
|
|
1226
|
+
const goal = await readGoal(goalId);
|
|
1227
|
+
const pending = goal?.continuation?.pendingReports ?? [];
|
|
1228
|
+
if (pending.length === 0)
|
|
1229
|
+
return null;
|
|
1230
|
+
const lines = [];
|
|
1231
|
+
for (const p of pending) {
|
|
1232
|
+
const c = await readJson(contractPathFor(goalId, p.workId, home));
|
|
1233
|
+
if (!c) {
|
|
1234
|
+
lines.push(`- workId=${p.workId} 能力「${p.capability}」: 合同文件缺失 (不许凭记忆派活, 必须重新签发合同)`);
|
|
1235
|
+
continue;
|
|
1236
|
+
}
|
|
1237
|
+
lines.push(`- workId=${c.workId} 能力「${c.capability}」报告协议=${c.reportSchema} 必带证据=[${c.requiredEvidence.join(', ')}] `
|
|
1238
|
+
+ `成功判据=[${c.successCriteria.join(' | ')}] 心跳=${c.heartbeatIntervalMs}ms`);
|
|
1239
|
+
}
|
|
1240
|
+
return `还在等回报的子 Agent 工作 (回报必须过 ${WORK_REPORT_SCHEMA_LIKE} 核验, 漂亮但没有逐条证据的回报不算完成):\n${lines.join('\n')}`;
|
|
1241
|
+
}
|
|
1242
|
+
const WORK_REPORT_SCHEMA_LIKE = 'validateChildReport/acceptsAsComplete';
|
|
1243
|
+
/** 用户可见状态 (六类里的一类): 内部词 (lease / reducer / retry attempt / owner) 绝不出现 */
|
|
1244
|
+
export async function goalVisibleState(input) {
|
|
1245
|
+
const goal = await readGoal(input.goalId);
|
|
1246
|
+
if (!goal)
|
|
1247
|
+
return 'needs_your_decision';
|
|
1248
|
+
const blocks = input.blocks ?? (await collectWorkBlocks({ goalId: input.goalId, now: input.now, home: input.home }));
|
|
1249
|
+
const decision = input.decision ?? null;
|
|
1250
|
+
// ★ M5-⑥ (2026-09-25): 把 Goal **本体**的状态一起交给投影 —— 只喂 continuation 时, 一份落后的
|
|
1251
|
+
// 派生记录就能把"已完成"说成"正在执行" (真跑复现: status=completed / visible=executing)。
|
|
1252
|
+
return toUserVisibleState(goal.continuation ? { ...goal.continuation } : null, blocks, decision, goal.status);
|
|
1253
|
+
}
|
|
1254
|
+
/**
|
|
1255
|
+
* 接收一条用户(或 agent 提出的)新要求:
|
|
1256
|
+
* `ingestChange` (逐字) → `classifyChange` (8 类分诊) → `applyChange` (生效判定) →
|
|
1257
|
+
* 落盘 + 写进 Goal (变更记录 / 判据版本 / 下一 Run 指令)。
|
|
1258
|
+
*
|
|
1259
|
+
* **当前 Run 的历史不改写** (规则 4): 本函数只写 Goal 上的变更记录与"下一次 Run"的指令,
|
|
1260
|
+
* 不碰任何已发生 Run 的记录 (`job change` 里没有任何回填路径)。
|
|
1261
|
+
*/
|
|
1262
|
+
export async function ingestGoalChange(input) {
|
|
1263
|
+
const home = input.home ?? bolloonHome();
|
|
1264
|
+
const now = input.now ?? new Date().toISOString();
|
|
1265
|
+
const goal = await readGoal(input.goalId);
|
|
1266
|
+
if (!goal)
|
|
1267
|
+
return null;
|
|
1268
|
+
const source = input.source ?? 'user';
|
|
1269
|
+
const requestRaw = ingestChange({
|
|
1270
|
+
goalId: goal.goalId,
|
|
1271
|
+
source,
|
|
1272
|
+
instruction: input.instruction,
|
|
1273
|
+
recordedBy: input.recordedBy ?? 'user',
|
|
1274
|
+
now,
|
|
1275
|
+
});
|
|
1276
|
+
const classified = classifyChange(requestRaw, { successCriteria: goal.successCriteria, budget: goal.budget });
|
|
1277
|
+
const application = applyChange(classified, { criteriaVersion: goal.criteriaVersion ?? 1 });
|
|
1278
|
+
const status = nextStatusFor(application.outcome);
|
|
1279
|
+
// 用户撤销 → 之前未生效的变更一律作废 (规则 1 的推论)
|
|
1280
|
+
const changes = (goal.goalChanges ?? []).map((c) => {
|
|
1281
|
+
if (shouldSupersedePending(classified) && (c.status === 'needs_approval' || c.status === 'triaged')) {
|
|
1282
|
+
return { ...c, status: 'superseded' };
|
|
1283
|
+
}
|
|
1284
|
+
return c;
|
|
1285
|
+
});
|
|
1286
|
+
changes.push({ ...classified, status });
|
|
1287
|
+
const patch = { goalChanges: changes };
|
|
1288
|
+
let bumped = null;
|
|
1289
|
+
if (application.outcome === 'next_run' && classified.kind === 'success_criteria_change' && application.criteriaVersion !== (goal.criteriaVersion ?? 1)) {
|
|
1290
|
+
patch.criteriaVersion = application.criteriaVersion;
|
|
1291
|
+
bumped = application.criteriaVersion;
|
|
1292
|
+
}
|
|
1293
|
+
const revoked = application.outcome === 'next_run' && classified.kind === 'abort';
|
|
1294
|
+
if (revoked) {
|
|
1295
|
+
// 规则 ②: 状态只有 Goal reducer 能写。这里走的是"用户明确撤销"这一类 (只有人能提, 见 change 接缝)。
|
|
1296
|
+
const applied = await reduceGoalState({
|
|
1297
|
+
goalId: goal.goalId,
|
|
1298
|
+
intent: 'user_revoked_goal',
|
|
1299
|
+
now,
|
|
1300
|
+
by: input.recordedBy ?? 'user',
|
|
1301
|
+
reason: `用户明确撤销 (${classified.changeId}): ${classified.instruction.slice(0, 120)}`,
|
|
1302
|
+
});
|
|
1303
|
+
if (!applied.ok)
|
|
1304
|
+
throw new Error(`撤销状态写入失败 (不静默): ${applied.reason}`);
|
|
1305
|
+
}
|
|
1306
|
+
// 这一条 updateGoal 只写"变更记录/判据版本", **不写状态** (状态已由 reducer 负责)
|
|
1307
|
+
await updateGoal(goal.goalId, patch);
|
|
1308
|
+
const nextRunDirective = application.outcome === 'rejected' ? null : application.nextRunDirective;
|
|
1309
|
+
if (nextRunDirective) {
|
|
1310
|
+
await setContinuation(goal.goalId, {
|
|
1311
|
+
nextAction: nextRunDirective,
|
|
1312
|
+
wakeReason: 'active',
|
|
1313
|
+
autoContinue: revoked ? false : goal.continuation?.autoContinue !== false,
|
|
1314
|
+
state: lifecycleOf(revoked ? 'abandoned' : goal.status),
|
|
1315
|
+
updatedAt: now,
|
|
1316
|
+
});
|
|
1317
|
+
}
|
|
1318
|
+
const persistedPath = path.join(home, '.bolloon', 'goal-changes', `${safeName(goal.goalId)}.json`);
|
|
1319
|
+
await atomicWrite(persistedPath, JSON.stringify({ goalId: goal.goalId, changes, updatedAt: now }, null, 2));
|
|
1320
|
+
return { request: classified, application, criteriaVersionBumpedTo: bumped, persistedPath, nextRunDirective };
|
|
1321
|
+
}
|
|
1322
|
+
/**
|
|
1323
|
+
* 下一个 Run 要读到的动态要求: 已生效但还没被任何 Run 消费的变更 → 拼成指令。
|
|
1324
|
+
* (父/子 Agent 与下一 Run 都从这里读同一份事实。)
|
|
1325
|
+
*/
|
|
1326
|
+
export async function nextRunChangeDirective(goalId) {
|
|
1327
|
+
const goal = await readGoal(goalId);
|
|
1328
|
+
if (!goal)
|
|
1329
|
+
return null;
|
|
1330
|
+
const applied = (goal.goalChanges ?? []).filter((c) => c.status === 'scheduled_next_run' || c.status === 'applied');
|
|
1331
|
+
if (applied.length === 0)
|
|
1332
|
+
return null;
|
|
1333
|
+
const tail = (goal.runs ?? []).length;
|
|
1334
|
+
const live = applied.filter((c) => c.consumedAtRunCount === undefined
|
|
1335
|
+
|| c.consumedAtRunCount === tail);
|
|
1336
|
+
if (live.length === 0)
|
|
1337
|
+
return null;
|
|
1338
|
+
const lines = live.map((c) => `- [${c.kind}] 原话: ${c.instruction}`);
|
|
1339
|
+
return `用户/外部注入的新要求 (只影响后续 Run, 不改写已发生历史; criteriaVersion=v${goal.criteriaVersion ?? 1}):\n${lines.join('\n')}`;
|
|
1340
|
+
}
|
|
1341
|
+
/** 标记"这批变更已经被第 N 个 Run 读到" (版本下发可核验) */
|
|
1342
|
+
export async function markChangesConsumed(goalId, runCount, now = new Date().toISOString()) {
|
|
1343
|
+
const goal = await readGoal(goalId);
|
|
1344
|
+
if (!goal)
|
|
1345
|
+
return;
|
|
1346
|
+
const changes = (goal.goalChanges ?? []).map((c) => (c.status === 'scheduled_next_run'
|
|
1347
|
+
? { ...c, status: 'applied', consumedAtRunCount: runCount, consumedAt: now }
|
|
1348
|
+
: c));
|
|
1349
|
+
await updateGoal(goalId, { goalChanges: changes });
|
|
1350
|
+
}
|
|
1351
|
+
export async function flywheelTickNote(input) {
|
|
1352
|
+
const blocks = input.blocks ?? await collectWorkBlocks({ goalId: input.goalId, now: input.now, home: input.home });
|
|
1353
|
+
const goal = await readGoal(input.goalId);
|
|
1354
|
+
const visible = toUserVisibleState(goal?.continuation ?? null, blocks, input.step.decision,
|
|
1355
|
+
// ★ M5-⑥: 调度报告里的用户可见态也必须认 Goal 本体的终态 —— 否则同一 tick 的报告中
|
|
1356
|
+
// 一个已完成目标会显示"正在执行", 与盘上事实相反。
|
|
1357
|
+
goal?.status ?? null);
|
|
1358
|
+
const step = input.step;
|
|
1359
|
+
return {
|
|
1360
|
+
goalId: input.goalId,
|
|
1361
|
+
decision: input.step.decision?.decision ?? 'first_run',
|
|
1362
|
+
state: input.step.decision?.state ?? 'progressing',
|
|
1363
|
+
runnable: input.step.runnable,
|
|
1364
|
+
reason: input.step.reason,
|
|
1365
|
+
noProgressStreak: input.step.noProgressStreak,
|
|
1366
|
+
visibleState: visible,
|
|
1367
|
+
nextAction: input.step.decision?.nextAction ?? '(首个 Run)',
|
|
1368
|
+
// 旧口径 (`GoalStepDecision` 不含 source) 如实标 injected —— 不假装是事实判的
|
|
1369
|
+
source: step.source ?? 'injected',
|
|
1370
|
+
basis: step.basis ?? null,
|
|
1371
|
+
caps: step.caps ?? [],
|
|
1372
|
+
};
|
|
1373
|
+
}
|
|
1374
|
+
/** 扫出"有待回报子工作"的 Goal (阻塞巡检的输入面, 有界) */
|
|
1375
|
+
export async function listGoalsWithPendingWork(limit = 20) {
|
|
1376
|
+
const goals = await listGoals({ limit: 200 });
|
|
1377
|
+
return goals.filter((g) => (g.continuation?.pendingReports?.length ?? 0) > 0).slice(0, limit);
|
|
1378
|
+
}
|
|
1379
|
+
// ============================================================================
|
|
1380
|
+
// §11. 唯一责任链的接线 (M0) —— 接缝实例 / 收尾函数 / Run 终止钩子
|
|
1381
|
+
// ============================================================================
|
|
1382
|
+
/**
|
|
1383
|
+
* 收尾事实在 **Run 证据**里的锚点。
|
|
1384
|
+
*
|
|
1385
|
+
* 幂等判据为什么需要两个落点 (都是**已有**存储, 不新增):
|
|
1386
|
+
* · 落点一 = Goal 的 closure 决策记录 (`.bolloon/goal-decisions/<goal>--<run>--closure.json`);
|
|
1387
|
+
* · 落点二 = Run 自己的 evidence。
|
|
1388
|
+
* 只认落点一的话: 那条记录一丢 (误删 / 目录被清 / 换机器只搬了 runs/), 下一次调用会**当作没收过
|
|
1389
|
+
* 再收一遍** —— 于是第二条 Memory / 第二份 Skill 候选 / 第二条 closure 记录 = 又是两套事实。
|
|
1390
|
+
* Run 记录是跟着 Run 走的, 它上面的锚点不会因为 Goal 目录被动而消失。
|
|
1391
|
+
*/
|
|
1392
|
+
export const CLOSURE_EVIDENCE_PREFIX = '收尾已执行 (closeRun 9 步队列)';
|
|
1393
|
+
/** 同一条 Run 是否已经收过尾 (两个已有落点的并集; 拿不到事实就返回 false = 会去收, 而不是假装收过) */
|
|
1394
|
+
export async function hasClosureRecord(goalId, runId, home = bolloonHome()) {
|
|
1395
|
+
if (!goalId || !runId)
|
|
1396
|
+
return false;
|
|
1397
|
+
const records = await readDecisionRecords(goalId, home).catch(() => []);
|
|
1398
|
+
if (records.some((r) => r.phase === 'closure' && r.runId === runId))
|
|
1399
|
+
return true;
|
|
1400
|
+
const run = await readRun(runId).catch(() => null);
|
|
1401
|
+
return (run?.evidence || []).some((e) => String(e).includes(CLOSURE_EVIDENCE_PREFIX));
|
|
1402
|
+
}
|
|
1403
|
+
/** 把 CloseGoalRunOutcome 收成接缝视图 (接缝不 import 本文件的类型, 所以在这里做形状转换) */
|
|
1404
|
+
function toClosureView(outcome, runStatus) {
|
|
1405
|
+
return {
|
|
1406
|
+
runId: outcome.result.decision.runId,
|
|
1407
|
+
goalId: outcome.result.decision.goalId,
|
|
1408
|
+
steps: outcome.result.steps.length,
|
|
1409
|
+
decision: outcome.result.decision,
|
|
1410
|
+
continuation: outcome.result.continuation,
|
|
1411
|
+
userReport: outcome.result.userReport,
|
|
1412
|
+
runStatus,
|
|
1413
|
+
memories: outcome.written.length,
|
|
1414
|
+
candidates: outcome.result.candidates.length,
|
|
1415
|
+
// 候选产出处开出的试用: 只转发结构化结论 (准入的六阶段逐条裁决 + 卡在哪一步)
|
|
1416
|
+
trials: (outcome.trials ?? []).map((t) => ({
|
|
1417
|
+
candidateId: t.candidateId,
|
|
1418
|
+
skillName: t.skillName,
|
|
1419
|
+
ok: !!t.admission?.ok,
|
|
1420
|
+
stages: (t.admission?.stages ?? []).map((s) => ({ stage: String(s.stage), status: String(s.status), reason: String(s.reason ?? '') })),
|
|
1421
|
+
refusal: t.admission?.refusal ? { stage: String(t.admission.refusal.stage), reason: String(t.admission.refusal.reason ?? '') } : null,
|
|
1422
|
+
path: t.path,
|
|
1423
|
+
})),
|
|
1424
|
+
reportPath: outcome.reportPath,
|
|
1425
|
+
decisionRecordPath: outcome.decisionRecordPath,
|
|
1426
|
+
};
|
|
1427
|
+
}
|
|
1428
|
+
let seamsCache = null;
|
|
1429
|
+
// ============================================================================
|
|
1430
|
+
// §11.1 非 web 宿主的接入面 (CLI / supervisor / 崩溃恢复)
|
|
1431
|
+
// —— 与 web 路由走**同一份**接缝, 不再"只有 /api 那条路能停 Run"
|
|
1432
|
+
// ============================================================================
|
|
1433
|
+
/** Goal 上当前那条 Run 的记录 (currentRunId 优先; 否则 runs 列表末条 —— 与 decideGoalStep 同一口径) */
|
|
1434
|
+
async function readCurrentRunRecord(goalId) {
|
|
1435
|
+
const g = await readGoal(goalId);
|
|
1436
|
+
if (!g)
|
|
1437
|
+
return null;
|
|
1438
|
+
const id = g.currentRunId || g.runs?.[g.runs.length - 1];
|
|
1439
|
+
if (!id)
|
|
1440
|
+
return null;
|
|
1441
|
+
return readRun(id).catch(() => null);
|
|
1442
|
+
}
|
|
1443
|
+
/**
|
|
1444
|
+
* 在跑的 Run 的事实 (注入 change 接缝的 `runningRun` 端口)。
|
|
1445
|
+
*
|
|
1446
|
+
* `null` 的语义是**明确"当前没有在跑的 Run"** (目标上没挂 Run) —— 与 `undefined`(宿主不知道)
|
|
1447
|
+
* 严格区分: 前者接缝可以断言"变更只影响后续 Run", 后者只能如实标 `factsMissing`。读盘失败时
|
|
1448
|
+
* 抛错 (由接缝捕获 → 也是 factsMissing), 绝不谎报"没有在跑的 Run"。
|
|
1449
|
+
*/
|
|
1450
|
+
export async function readRunningRunFact(goalId) {
|
|
1451
|
+
const run = await readCurrentRunRecord(goalId);
|
|
1452
|
+
return run ? { runId: run.runId, status: String(run.status) } : null;
|
|
1453
|
+
}
|
|
1454
|
+
/**
|
|
1455
|
+
* 停 Run 的执行器 (注入 change 接缝的 `stopRunningRun` 端口)。
|
|
1456
|
+
*
|
|
1457
|
+
* 真实现就是**既有外部控制面原语** `setRunStatus` —— 与 `/api/runs/:id/abort` 同一条路,
|
|
1458
|
+
* 不新造停止通道; 写的是 Run 记录, 执行器下一轮读到就自行停下 (非法迁移会被 store 拒,
|
|
1459
|
+
* 这时如实返回 `ok:false` + 原因, 不假装停过)。
|
|
1460
|
+
*/
|
|
1461
|
+
export async function stopRunByExternalControl(i) {
|
|
1462
|
+
const r = await setRunStatus(i.runId, i.runStatus, {
|
|
1463
|
+
error: `变更注入 (${i.goalId}): ${String(i.reason).slice(0, 120)}`,
|
|
1464
|
+
});
|
|
1465
|
+
return { ok: !!r.ok, reason: r.reason || (r.ok ? `已写 ${i.runId} → ${i.runStatus} (at ${i.now})` : '') };
|
|
1466
|
+
}
|
|
1467
|
+
/**
|
|
1468
|
+
* **非 web 宿主**提一条要求/撤销的唯一入口 (CLI / supervisor / 恢复脚本都走它)。
|
|
1469
|
+
*
|
|
1470
|
+
* 与 `/api/goals/:id/requirement` 的关系: **同一个接缝**, 同一份判定 (`planChangeInjection`),
|
|
1471
|
+
* 同一个停止原语 (`setRunStatus`)。路由做的是"从 HTTP 读事实再注入", 这里做的是"宿主自己读事实"
|
|
1472
|
+
* —— 事实来源相同 (`readRunningRunFact` / `pendingReports`), 所以两条路的结论一致。
|
|
1473
|
+
*
|
|
1474
|
+
* 返回 `SeamRefusal`/`null` 时**照原样**交给调用方 (拒绝了就是拒绝了, 不包装成 ok)。
|
|
1475
|
+
*/
|
|
1476
|
+
export async function ingestRequirementViaSeam(input) {
|
|
1477
|
+
const caller = input.caller ?? 'human';
|
|
1478
|
+
const now = input.now ?? new Date().toISOString();
|
|
1479
|
+
const seam = flywheelSeams().change;
|
|
1480
|
+
const view = await seam.ingestChange({
|
|
1481
|
+
goalId: input.goalId,
|
|
1482
|
+
instruction: input.instruction,
|
|
1483
|
+
source: input.source ?? 'user',
|
|
1484
|
+
recordedBy: input.recordedBy ?? `${caller}:${process.pid}`,
|
|
1485
|
+
now,
|
|
1486
|
+
caller,
|
|
1487
|
+
workId: input.workId ?? null,
|
|
1488
|
+
...(input.liveWorkIds !== undefined ? { liveWorkIds: input.liveWorkIds } : {}),
|
|
1489
|
+
});
|
|
1490
|
+
if (isRefusal(view) || view === null)
|
|
1491
|
+
return view;
|
|
1492
|
+
// 与 web 路由同一条兜底: 接缝自己没执行器 (老依赖注入下的形态) → 这里按计划把"停"落到真 Run 上。
|
|
1493
|
+
// M0 已注入 stopRunningRun, 正常情况下 `stopped` 已经是 true, 这段不会重复动手 (见 runBoundary.stopped)。
|
|
1494
|
+
if (view.runBoundary.action === 'stop_running_run' && !view.runBoundary.stopped) {
|
|
1495
|
+
const boundary = await seam.applyRunBoundary({
|
|
1496
|
+
plan: view.plan,
|
|
1497
|
+
now,
|
|
1498
|
+
stop: (i) => stopRunByExternalControl(i),
|
|
1499
|
+
});
|
|
1500
|
+
return { ...view, runBoundary: boundary };
|
|
1501
|
+
}
|
|
1502
|
+
return view;
|
|
1503
|
+
}
|
|
1504
|
+
/**
|
|
1505
|
+
* 五个接缝的实例 (依赖在这里注入 —— 接缝文件自己不读盘)。
|
|
1506
|
+
*
|
|
1507
|
+
* 只构造一次 (memoize): 派生接缝与 Run 的事实面无关, 重建没有意义。
|
|
1508
|
+
* 测试想换注入 (例如假的 purgeTemporary) 走 `resetFlywheelSeamsForTest()`。
|
|
1509
|
+
*/
|
|
1510
|
+
export function flywheelSeams() {
|
|
1511
|
+
if (seamsCache)
|
|
1512
|
+
return seamsCache;
|
|
1513
|
+
seamsCache = {
|
|
1514
|
+
continuation: createContinuationSeam({
|
|
1515
|
+
/**
|
|
1516
|
+
* ★ 规范路径 (2026-09-25 串行收口): 主循环的节奏判定**只读事实, 判定归接缝**。
|
|
1517
|
+
* 以前这里注入的是 `decide` (整条判定结论) —— 于是"接线层又判了一遍", M1 的自适应节奏
|
|
1518
|
+
* 在真路径上从未接管。现在 `readFacts` 是权威口径, `decide` 只作事实拿不到时的兜底
|
|
1519
|
+
* (兜底结果会被接缝标成 `source='injected'`, 不假装是事实判的)。
|
|
1520
|
+
*/
|
|
1521
|
+
readFacts: readRhythmFacts,
|
|
1522
|
+
decide: async (i) => {
|
|
1523
|
+
const step = await decideGoalStep({ goalId: i.goalId, now: i.now, maxRetries: i.maxRetries, writeRecord: i.writeRecord });
|
|
1524
|
+
if (!step)
|
|
1525
|
+
return null;
|
|
1526
|
+
return {
|
|
1527
|
+
goalId: step.goalId,
|
|
1528
|
+
runnable: step.runnable,
|
|
1529
|
+
reason: step.reason,
|
|
1530
|
+
noProgressStreak: step.noProgressStreak,
|
|
1531
|
+
decision: step.decision
|
|
1532
|
+
? {
|
|
1533
|
+
decision: step.decision.decision,
|
|
1534
|
+
state: step.decision.state,
|
|
1535
|
+
nextAction: step.decision.nextAction,
|
|
1536
|
+
requiredCapability: step.decision.requiredCapability,
|
|
1537
|
+
decisionId: step.decision.decisionId,
|
|
1538
|
+
}
|
|
1539
|
+
: null,
|
|
1540
|
+
};
|
|
1541
|
+
},
|
|
1542
|
+
}),
|
|
1543
|
+
closure: createClosureSeam({
|
|
1544
|
+
hasClosure: (goalId, runId) => hasClosureRecord(goalId, runId),
|
|
1545
|
+
closeGoalRun: async (i) => {
|
|
1546
|
+
// Run 的状态是收尾事实的一部分 (用户汇报 / 决策记录都要它) —— 在这里读一次并带进视图
|
|
1547
|
+
const runBefore = await readRun(i.runId).catch(() => null);
|
|
1548
|
+
const out = await closeGoalRun({ goalId: i.goalId, runId: i.runId, now: i.now, finalReview: i.finalReview, maxRetries: i.maxRetries });
|
|
1549
|
+
// 幂等的第二锚点: 把"这条 Run 收过尾了"刻在 **Run 自己的证据**上 (见 CLOSURE_EVIDENCE_PREFIX)
|
|
1550
|
+
if (out) {
|
|
1551
|
+
await addRunEvidence(i.runId, [
|
|
1552
|
+
`${CLOSURE_EVIDENCE_PREFIX} → ${out.result.decision.decision} (goal=${i.goalId}, at=${i.now})`,
|
|
1553
|
+
]).catch(() => null);
|
|
1554
|
+
}
|
|
1555
|
+
return out ? toClosureView(out, String(runBefore?.status ?? '')) : null;
|
|
1556
|
+
},
|
|
1557
|
+
purgeTemporary: async () => {
|
|
1558
|
+
// P1b: temporary 层任务结束自动过期归档 (没有 temporary 记录时是 0)
|
|
1559
|
+
const dir = path.join(bolloonHome(), '.bolloon', 'memory-layers', 'temporary');
|
|
1560
|
+
let files = [];
|
|
1561
|
+
try {
|
|
1562
|
+
files = await fs.readdir(dir);
|
|
1563
|
+
}
|
|
1564
|
+
catch {
|
|
1565
|
+
return 0;
|
|
1566
|
+
}
|
|
1567
|
+
let archived = 0;
|
|
1568
|
+
for (const f of files.filter((x) => x.endsWith('.json'))) {
|
|
1569
|
+
const rec = await readJson(path.join(dir, f));
|
|
1570
|
+
if (!rec?.expiresAt)
|
|
1571
|
+
continue;
|
|
1572
|
+
if (Date.parse(rec.expiresAt) > Date.now())
|
|
1573
|
+
continue;
|
|
1574
|
+
const dest = path.join(bolloonHome(), '.bolloon', 'memory-layers', 'archived');
|
|
1575
|
+
await fs.mkdir(dest, { recursive: true }).catch(() => null);
|
|
1576
|
+
await fs.rename(path.join(dir, f), path.join(dest, f)).then(() => { archived++; }).catch(() => null);
|
|
1577
|
+
}
|
|
1578
|
+
return archived;
|
|
1579
|
+
},
|
|
1580
|
+
}),
|
|
1581
|
+
contract: createContractSeam({
|
|
1582
|
+
issue: (i) => dispatchChildWork({
|
|
1583
|
+
goalId: i.goalId,
|
|
1584
|
+
parentRunId: i.parentRunId,
|
|
1585
|
+
childAgentId: i.childAgentId,
|
|
1586
|
+
capability: i.capability,
|
|
1587
|
+
objective: i.objective,
|
|
1588
|
+
inputs: i.inputs,
|
|
1589
|
+
allowedTools: i.allowedTools,
|
|
1590
|
+
budget: i.budget,
|
|
1591
|
+
deadline: i.deadline,
|
|
1592
|
+
successCriteria: i.successCriteria,
|
|
1593
|
+
now: i.now,
|
|
1594
|
+
issuedBy: i.issuedBy,
|
|
1595
|
+
}),
|
|
1596
|
+
accept: (i) => handleChildReport({ goalId: i.goalId, workId: i.workId, report: i.report, now: i.now }),
|
|
1597
|
+
}),
|
|
1598
|
+
monitor: createMonitorSeam({
|
|
1599
|
+
collect: (i) => collectWorkBlocks({ goalId: i.goalId, now: i.now, runnerAvailable: i.runnerAvailable }),
|
|
1600
|
+
handle: (i) => applyBlockHandling({ goalId: i.goalId, blocks: i.blocks, now: i.now }),
|
|
1601
|
+
goalsWithPendingWork: async (limit) => (await listGoalsWithPendingWork(limit)).map((g) => ({ goalId: g.goalId })),
|
|
1602
|
+
/**
|
|
1603
|
+
* ★ 用户可见面 (2026-09-25 串行收口): 注进去, `sweepAll` 才不是"半个人"
|
|
1604
|
+
* —— 没有它每一格都会因可见态拒绝而记 error, 于是 `silentRisk=true` +
|
|
1605
|
+
* `escalated` 里塞满"巡检未完成: <goalId>" (把"没注入探针"演成"巡检失败")。
|
|
1606
|
+
* 走的是**同一份**判定 (`goalVisibleState` → `toUserVisibleState`), 界面不另拼一套状态。
|
|
1607
|
+
*/
|
|
1608
|
+
visible: (i) => goalVisibleState({ goalId: i.goalId, blocks: i.blocks, now: i.now }),
|
|
1609
|
+
}),
|
|
1610
|
+
change: createChangeSeam({
|
|
1611
|
+
ingest: (i) => ingestGoalChange({
|
|
1612
|
+
goalId: i.goalId,
|
|
1613
|
+
instruction: i.instruction,
|
|
1614
|
+
source: i.source,
|
|
1615
|
+
recordedBy: i.recordedBy,
|
|
1616
|
+
now: i.now,
|
|
1617
|
+
scopeWorkIds: i.workId ? [i.workId] : undefined,
|
|
1618
|
+
}).then((out) => {
|
|
1619
|
+
if (!out)
|
|
1620
|
+
throw new Error(`变更无法入档 (goal=${i.goalId})`);
|
|
1621
|
+
return { request: out.request, classification: out.application, application: out.application };
|
|
1622
|
+
}),
|
|
1623
|
+
nextRunDirective: (goalId) => nextRunChangeDirective(goalId),
|
|
1624
|
+
markConsumed: (goalId, runIndex, now) => markChangesConsumed(goalId, runIndex, now),
|
|
1625
|
+
visibleState: (i) => goalVisibleState({ goalId: i.goalId, now: i.now }),
|
|
1626
|
+
pending: async (goalId) => {
|
|
1627
|
+
const g = await readGoal(goalId);
|
|
1628
|
+
return (g?.goalChanges ?? []).filter((c) => c.status === 'needs_approval' || c.status === 'scheduled_next_run' || c.status === 'triaged');
|
|
1629
|
+
},
|
|
1630
|
+
/**
|
|
1631
|
+
* ★ 在跑的 Run 的事实 (2026-09-25 串行收口): 以前**只有** web 路由按参数传,
|
|
1632
|
+
* 于是任何非 web 宿主 (CLI / supervisor / 崩溃恢复脚本) 提撤销时, 接缝拿到的是
|
|
1633
|
+
* `factsMissing=true` → 判定"不断言停不停" → **在跑的 Run 停不下来**。
|
|
1634
|
+
* 现在把"读当前 Run 状态"这件事实注入接缝 (真实现 = 读 Goal 的 currentRunId → readRun)。
|
|
1635
|
+
*/
|
|
1636
|
+
runningRun: (goalId) => readRunningRunFact(goalId),
|
|
1637
|
+
/** ★ 停 Run 的执行器 = 既有的 `setRunStatus` 原语 (与 web 的 abort 路由同一个) */
|
|
1638
|
+
stopRunningRun: (i) => stopRunByExternalControl(i),
|
|
1639
|
+
/** ★ 在跑的子 Agent 工作 (规则 5 的下发对象): 从父 Goal 登记的 pendingReports 读 */
|
|
1640
|
+
liveWorkIds: async (goalId) => {
|
|
1641
|
+
const g = await readGoal(goalId);
|
|
1642
|
+
return (g?.continuation?.pendingReports ?? []).map((p) => String(p?.workId ?? '')).filter(Boolean);
|
|
1643
|
+
},
|
|
1644
|
+
}),
|
|
1645
|
+
};
|
|
1646
|
+
return seamsCache;
|
|
1647
|
+
}
|
|
1648
|
+
/** 测试用: 丢掉接缝实例 (下次 `flywheelSeams()` 重新注入) */
|
|
1649
|
+
export function resetFlywheelSeamsForTest() {
|
|
1650
|
+
seamsCache = null;
|
|
1651
|
+
}
|
|
1652
|
+
/**
|
|
1653
|
+
* **收尾唯一入口** (规则 ③ + ④): 所有终止路径都调它, 它再调 `closeRun` (经 closure 接缝)。
|
|
1654
|
+
*
|
|
1655
|
+
* 幂等: 同一条 runId 只真收一次 (第二次 `alreadyClosed=true`)。因此
|
|
1656
|
+
* "Runner 自己收 + Supervisor 又收"不会写出两套产物。
|
|
1657
|
+
*
|
|
1658
|
+
* 返回值刻意是 union (`CloseRunOnceResult | SeamRefusal`), 让调用方能如实区分
|
|
1659
|
+
* "收过了 / 真收了 / 拒绝 (说不清是哪条 Run)" 三种情形, 而不是一个 boolean。
|
|
1660
|
+
*/
|
|
1661
|
+
export async function closeRunOnce(input) {
|
|
1662
|
+
const seamResult = await flywheelSeams().closure.closeRunOnce({
|
|
1663
|
+
goalId: input.goalId,
|
|
1664
|
+
runId: input.runId,
|
|
1665
|
+
caller: input.caller ?? 'supervisor',
|
|
1666
|
+
now: input.now ?? new Date().toISOString(),
|
|
1667
|
+
finalReview: input.finalReview,
|
|
1668
|
+
maxRetries: input.maxRetries,
|
|
1669
|
+
terminalKind: input.terminalKind,
|
|
1670
|
+
});
|
|
1671
|
+
if (isRefusal(seamResult))
|
|
1672
|
+
return seamResult;
|
|
1673
|
+
if (!seamResult.alreadyClosed)
|
|
1674
|
+
return seamResult;
|
|
1675
|
+
// 已经收过尾 → 把**那次**的事实读回来 (幂等但不丢事实): 上层仍能拿到 continuation 与用户汇报
|
|
1676
|
+
const back = await readClosureOutcome(input.goalId, input.runId);
|
|
1677
|
+
return {
|
|
1678
|
+
...seamResult,
|
|
1679
|
+
outcome: back,
|
|
1680
|
+
reason: back
|
|
1681
|
+
? seamResult.reason
|
|
1682
|
+
: `${seamResult.reason} —— 但收尾事实读不回来 (closure 决策记录缺 continuation/userReport): 上层必须如实记"收过了、事实缺失", 不许当没收过再收一遍`,
|
|
1683
|
+
};
|
|
1684
|
+
}
|
|
1685
|
+
/**
|
|
1686
|
+
* 从 Run 的事实**推**一个宿主该申明的终止原因 (喂给 `closeRunOnce` 的 `terminalKind`)。
|
|
1687
|
+
*
|
|
1688
|
+
* 为什么不让调用方(或这里)随便编: 申明的种类必须与该 Run 状态**相容** (由 `terminalKindAccepts`
|
|
1689
|
+
* 校验), 否则收尾审计会把它记成"申明与事实不符" —— 自己给自己造审计缺口。
|
|
1690
|
+
* 所以这里只在事实**足够具体**时给出更细的原因:
|
|
1691
|
+
* · `failed` + 权限/支付类 errorClass → 权限·支付·工具失败 (最常被"重试掉"的三类);
|
|
1692
|
+
* · `failed` + 超时/预算类文本 → 超时·预算耗尽;
|
|
1693
|
+
* · `stalled` + 子 Agent 文本 → 子 Agent 被阻塞; 否则失速;
|
|
1694
|
+
* · 其余一律交回 `closureTerminalKindFor` 的推导 (退回一个**主归属**, 不硬编更细的类别)。
|
|
1695
|
+
*/
|
|
1696
|
+
export function claimedTerminalKindForRun(run) {
|
|
1697
|
+
if (!run)
|
|
1698
|
+
return undefined;
|
|
1699
|
+
const status = String(run.status ?? '');
|
|
1700
|
+
const errorClass = String(run.errorClass ?? '');
|
|
1701
|
+
const text = `${errorClass} ${String(run.error ?? '')} ${String(run.summary ?? '')}`;
|
|
1702
|
+
if (status === 'failed' && (errorClass === 'auth' || errorClass === 'policy_denied')) {
|
|
1703
|
+
return 'permission_or_payment_or_tool_failure';
|
|
1704
|
+
}
|
|
1705
|
+
if (status === 'failed' && /timeout|超时|deadline|budget|预算|quota/i.test(text))
|
|
1706
|
+
return 'timeout';
|
|
1707
|
+
if (status === 'stalled')
|
|
1708
|
+
return /子|child/i.test(text) ? 'child_agent_blocked' : 'stall';
|
|
1709
|
+
const derived = closureTerminalKindFor(status);
|
|
1710
|
+
return derived ? derived : undefined;
|
|
1711
|
+
}
|
|
1712
|
+
/**
|
|
1713
|
+
* 一条 Run **开出**的 Skill 试用 —— 从盘上候选文件读回来 (读回路径的 `trials`, 2026-09-25 串行收口)。
|
|
1714
|
+
*
|
|
1715
|
+
* 为什么不返回 `[]` 就算: 试用记录本来就写在**候选文件本身** (`trial.startedByRunId` = 提出候选的
|
|
1716
|
+
* 那条 Run, 由 `run-closure.ts` 填成收尾的那条 Run), 所以"这条 Run 开过哪些试用"是**可读的事**。
|
|
1717
|
+
* 返回空数组会被读成"这条 Run 没开过试用"—— 那是编态。读不到目录 = 真的一个候选都没有。
|
|
1718
|
+
*/
|
|
1719
|
+
async function trialsOfRun(runId, home) {
|
|
1720
|
+
const dir = path.join(home, SKILL_CANDIDATES_ROOT);
|
|
1721
|
+
let files = [];
|
|
1722
|
+
try {
|
|
1723
|
+
files = await fs.readdir(dir);
|
|
1724
|
+
}
|
|
1725
|
+
catch {
|
|
1726
|
+
return [];
|
|
1727
|
+
}
|
|
1728
|
+
const out = [];
|
|
1729
|
+
for (const f of files.filter((x) => x.endsWith('.json')).sort()) {
|
|
1730
|
+
const rec = await readJson(path.join(dir, f));
|
|
1731
|
+
const trial = (rec?.trial ?? null);
|
|
1732
|
+
if (!rec || String(trial?.startedByRunId ?? '') !== runId)
|
|
1733
|
+
continue;
|
|
1734
|
+
const ch = rec.channel ?? null;
|
|
1735
|
+
const stages = Array.isArray(ch?.stages) ? ch.stages : [];
|
|
1736
|
+
const refusal = ch?.refusal && typeof ch.refusal === 'object' ? ch.refusal : null;
|
|
1737
|
+
out.push({
|
|
1738
|
+
candidateId: String(rec.candidateId ?? trial?.candidateId ?? ''),
|
|
1739
|
+
skillName: String(rec.name ?? trial?.skillName ?? ''),
|
|
1740
|
+
// 准入结论以**盘上记的那一份**为准; 没记过 (老候选) → false, 不假装开过试用
|
|
1741
|
+
ok: ch?.ok === true,
|
|
1742
|
+
stages: stages.map((s) => ({ stage: String(s.stage ?? ''), status: String(s.status ?? ''), reason: String(s.reason ?? '') })),
|
|
1743
|
+
refusal: refusal ? { stage: String(refusal.stage ?? ''), reason: String(refusal.reason ?? '') } : null,
|
|
1744
|
+
path: path.join(dir, f),
|
|
1745
|
+
});
|
|
1746
|
+
}
|
|
1747
|
+
return out;
|
|
1748
|
+
}
|
|
1749
|
+
/**
|
|
1750
|
+
* 一条**已经收过尾的 Run**: 把**那次收尾的事实读回来** (不重跑, 也不编)。
|
|
1751
|
+
*
|
|
1752
|
+
* 依据就是 `closeGoalRun` 自己写下的 closure 决策记录 (它自带 continuation 与用户汇报)。
|
|
1753
|
+
* 读不到就返回 null —— 调用方必须如实说"收过了但事实读不回来", 而不是当作没收过再收一遍。
|
|
1754
|
+
*/
|
|
1755
|
+
export async function readClosureOutcome(goalId, runId, home = bolloonHome()) {
|
|
1756
|
+
const records = await readDecisionRecords(goalId, home).catch(() => []);
|
|
1757
|
+
const rec = records.find((r) => r.phase === 'closure' && r.runId === runId);
|
|
1758
|
+
if (!rec)
|
|
1759
|
+
return null;
|
|
1760
|
+
if (!rec.continuation || !rec.userReport)
|
|
1761
|
+
return null;
|
|
1762
|
+
const run = await readRun(runId).catch(() => null);
|
|
1763
|
+
const reportPath = path.join(home, GOAL_REPORTS_ROOT, `${safeName(goalId)}--${safeName(runId)}.json`);
|
|
1764
|
+
return {
|
|
1765
|
+
runId,
|
|
1766
|
+
goalId,
|
|
1767
|
+
// 步数是**冻结面**的收尾流水线长度: 收尾是固定 9 步, 不随读写路径变化
|
|
1768
|
+
steps: CLOSURE_STEP_ORDER.length,
|
|
1769
|
+
decision: rec.decision,
|
|
1770
|
+
continuation: rec.continuation,
|
|
1771
|
+
userReport: rec.userReport,
|
|
1772
|
+
runStatus: String(run?.status ?? ''),
|
|
1773
|
+
memories: 0,
|
|
1774
|
+
candidates: 0,
|
|
1775
|
+
// 读回路径上的试用: 从候选文件本身读 (不是空数组 —— 见 trialsOfRun 的注释)
|
|
1776
|
+
trials: await trialsOfRun(runId, home),
|
|
1777
|
+
reportPath,
|
|
1778
|
+
decisionRecordPath: decisionFilePath(goalId, runId, 'closure', home),
|
|
1779
|
+
};
|
|
1780
|
+
}
|
|
1781
|
+
/**
|
|
1782
|
+
* 一次继续决策 → Goal 状态 (给**没有 Supervisor 宿主**的 Runner 用)。
|
|
1783
|
+
*
|
|
1784
|
+
* 只是把决策的语义翻译成 Goal 生命周期状态, **不做任何判定**:
|
|
1785
|
+
* complete → completed (仍要过完成门) · fail → failed · pause → paused ·
|
|
1786
|
+
* ask_human → needs_human · delegate → active (派活期间目标还在推进) ·
|
|
1787
|
+
* wait/continue → active。
|
|
1788
|
+
*/
|
|
1789
|
+
export function goalStatusFromDecision(d) {
|
|
1790
|
+
switch (d.decision) {
|
|
1791
|
+
case 'complete': return 'completed';
|
|
1792
|
+
case 'fail': return 'failed';
|
|
1793
|
+
case 'pause': return 'paused';
|
|
1794
|
+
case 'ask_human': return 'needs_human';
|
|
1795
|
+
// ★ M5-⑤ (2026-09-25 真跑验收): decision='wait' 以前落进 default 的 'active' —— 于是
|
|
1796
|
+
// "Run 停在等外部"的 Goal 在盘上写着 active, 界面显示"执行中", 但飞轮判 runnable=false:
|
|
1797
|
+
// 没有任何人会跑它, 直到可信事件到达。状态必须和等待同义 (客户看到的和系统认定的同一份)。
|
|
1798
|
+
case 'wait': return d.state === 'waiting_external' ? 'awaiting_external' : 'retry_wait';
|
|
1799
|
+
default: return 'active';
|
|
1800
|
+
}
|
|
1801
|
+
}
|
|
1802
|
+
/**
|
|
1803
|
+
* 把一条收尾结论经 **Goal reducer** 落进 Goal (唯一漏斗, 规则 ②)。
|
|
1804
|
+
*
|
|
1805
|
+
* 这是"链的下半段": 收尾写了产物 (上半段), 这一步写回"下一步是什么" —— 下一次 continuation。
|
|
1806
|
+
* Supervisor 用它自己的运输层版本 (带退避), 非 Supervisor 宿主 (CLI / 崩溃恢复 / 失速) 用这一份。
|
|
1807
|
+
*/
|
|
1808
|
+
export async function applyClosureToGoal(goalId, view, by = 'system', now = new Date().toISOString()) {
|
|
1809
|
+
const applied = await reduceGoalState({
|
|
1810
|
+
goalId,
|
|
1811
|
+
intent: 'closure_outcome',
|
|
1812
|
+
now,
|
|
1813
|
+
by,
|
|
1814
|
+
outcome: {
|
|
1815
|
+
goalStatus: goalStatusFromDecision(view.decision),
|
|
1816
|
+
continuation: toGoalStoreContinuation(view.continuation),
|
|
1817
|
+
reason: view.decision.reason,
|
|
1818
|
+
},
|
|
1819
|
+
runId: view.runId,
|
|
1820
|
+
});
|
|
1821
|
+
return { status: applied.status, gateRejected: applied.gateRejected };
|
|
1822
|
+
}
|
|
1823
|
+
/**
|
|
1824
|
+
* Run 被**底层状态机**判成终止时 (崩溃恢复 → interrupted / 失速 → stalled) 的回调。
|
|
1825
|
+
*
|
|
1826
|
+
* `run-store` 不认识 Goal, 也不该认识 —— 所以它只暴露一个注册点; 由接线层注册本函数。
|
|
1827
|
+
* 于是"崩溃恢复"与"失速"这两条终止路径也进了唯一责任链 (规则 ④):
|
|
1828
|
+
* 拿到 Run 事实 → 找它的 Goal → 走 `closeRunOnce` → 把下一步经 reducer 写回 Goal。
|
|
1829
|
+
*/
|
|
1830
|
+
export async function onRunTerminal(input) {
|
|
1831
|
+
const run = await readRun(input.runId).catch(() => null);
|
|
1832
|
+
const goalId = input.goalId || run?.goalId || '';
|
|
1833
|
+
if (!goalId)
|
|
1834
|
+
return { closed: false, reason: `Run ${input.runId} 没有绑 Goal → 无从收尾 (不假装收过)`, applied: null };
|
|
1835
|
+
const out = await closeRunOnce({ goalId, runId: input.runId, caller: 'system', finalReview: `底层状态机判为 ${input.status}: ${run?.error || ''}`.trim() });
|
|
1836
|
+
if (isRefusal(out))
|
|
1837
|
+
return { closed: false, reason: out.reason, applied: null };
|
|
1838
|
+
if (out.alreadyClosed)
|
|
1839
|
+
return { closed: false, reason: out.reason, applied: null };
|
|
1840
|
+
if (!out.outcome)
|
|
1841
|
+
return { closed: true, reason: out.reason, applied: null };
|
|
1842
|
+
// 收尾产物有了 → 立刻把"下一步"写回 Goal (否则崩溃恢复这一轮等于白收: 下次还得从零开始)
|
|
1843
|
+
const applied = await applyClosureToGoal(goalId, out.outcome, 'system');
|
|
1844
|
+
return { closed: true, reason: out.reason, applied: applied.status };
|
|
1845
|
+
}
|
|
1846
|
+
/** 把 Run 终止钩子装进 run-store (幂等; 谁宿主谁装) */
|
|
1847
|
+
export async function installRunTerminalHook() {
|
|
1848
|
+
const { setOnRunTerminal } = await import('./run-store.js');
|
|
1849
|
+
setOnRunTerminal(async (rec) => {
|
|
1850
|
+
await onRunTerminal({ runId: rec.runId, status: rec.status, goalId: rec.goalId }).catch(() => null);
|
|
1851
|
+
});
|
|
1852
|
+
}
|
|
1853
|
+
/**
|
|
1854
|
+
* 非 Supervisor 宿主 (CLI `bolloon task` / 独立进程) 的收尾入口。
|
|
1855
|
+
*
|
|
1856
|
+
* 做的事和 Supervisor 完全一样, 只是没有运输层的退避语义 —— 所以它:
|
|
1857
|
+
* ① `closeRunOnce` 收尾 (幂等); ② 把"下一步是什么"经 Goal reducer 落进 Goal。
|
|
1858
|
+
* 于是"CLI 任务跑完"与"Supervisor 推进一轮"在**同一份事实**上收口, 不再各写一套。
|
|
1859
|
+
*/
|
|
1860
|
+
export async function closeTaskRun(runId, goalId, note, by = 'cli') {
|
|
1861
|
+
const now = new Date().toISOString();
|
|
1862
|
+
const out = await closeRunOnce({ goalId, runId, caller: 'runner', now, finalReview: note });
|
|
1863
|
+
if (isRefusal(out))
|
|
1864
|
+
return { closed: false, reason: out.reason, decision: null };
|
|
1865
|
+
if (!out.outcome)
|
|
1866
|
+
return { closed: false, reason: out.reason, decision: null };
|
|
1867
|
+
const view = out.outcome;
|
|
1868
|
+
await applyClosureToGoal(goalId, view, by, now);
|
|
1869
|
+
return { closed: !out.alreadyClosed, reason: out.reason, decision: view.decision.decision };
|
|
1870
|
+
}
|