@bolloon/bolloon-agent 0.4.32 → 0.5.0

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.
Files changed (157) hide show
  1. package/dist/agents/chain/explorer.js +52 -0
  2. package/dist/agents/chain/index.js +2 -0
  3. package/dist/agents/chain/onchain-trade.js +15 -1
  4. package/dist/agents/contacts/chain.js +27 -7
  5. package/dist/agents/execution-supervisor.js +493 -35
  6. package/dist/agents/external-events.js +35 -6
  7. package/dist/agents/gateway-group.js +100 -12
  8. package/dist/agents/goal-criteria.js +10 -3
  9. package/dist/agents/goal-flywheel/block-executor.js +346 -0
  10. package/dist/agents/goal-flywheel/continuation-decision.js +592 -0
  11. package/dist/agents/goal-flywheel/goal-change.js +754 -0
  12. package/dist/agents/goal-flywheel/index.js +26 -0
  13. package/dist/agents/goal-flywheel/memory-layers.js +227 -0
  14. package/dist/agents/goal-flywheel/run-closure.js +809 -0
  15. package/dist/agents/goal-flywheel/skill-candidate.js +628 -0
  16. package/dist/agents/goal-flywheel/types.js +329 -0
  17. package/dist/agents/goal-flywheel/wiring/change.js +288 -0
  18. package/dist/agents/goal-flywheel/wiring/closure.js +697 -0
  19. package/dist/agents/goal-flywheel/wiring/continuation.js +432 -0
  20. package/dist/agents/goal-flywheel/wiring/contract.js +270 -0
  21. package/dist/agents/goal-flywheel/wiring/index.js +26 -0
  22. package/dist/agents/goal-flywheel/wiring/monitor.js +254 -0
  23. package/dist/agents/goal-flywheel/wiring/seams.js +592 -0
  24. package/dist/agents/goal-flywheel/work-contract.js +478 -0
  25. package/dist/agents/goal-flywheel/work-monitor.js +488 -0
  26. package/dist/agents/goal-flywheel-wiring.js +1870 -0
  27. package/dist/agents/goal-state-reducer.js +406 -0
  28. package/dist/agents/goal-store.js +35 -1
  29. package/dist/agents/mobile-flywheel-view.js +144 -0
  30. package/dist/agents/mobile-task-actions-verify.js +74 -0
  31. package/dist/agents/mobile-task-actions.js +178 -0
  32. package/dist/agents/mobile-task-views.js +199 -0
  33. package/dist/agents/network-pulse.js +525 -27
  34. package/dist/agents/pi-sdk.js +75 -20
  35. package/dist/agents/run-store.js +18 -5
  36. package/dist/agents/skill-readiness.js +52 -10
  37. package/dist/agents/subagent-manager.js +209 -3
  38. package/dist/agents/task/task-onchain-runner.js +19 -1
  39. package/dist/agents/task/task-runner.js +36 -6
  40. package/dist/agents/task-board.js +702 -0
  41. package/dist/agents/task-group.js +524 -0
  42. package/dist/agents/task-public-text.js +89 -0
  43. package/dist/cli/commands/index.js +3 -0
  44. package/dist/cli/commands/tasks.js +977 -8
  45. package/dist/cli/identity-command.js +177 -0
  46. package/dist/cli/protocol-envelope.js +4 -0
  47. package/dist/cli/setup-wizard.js +71 -0
  48. package/dist/cli/update-commands.js +150 -24
  49. package/dist/cli-entry.js +12 -2
  50. package/dist/electron-build/agents/chain/explorer.js +59 -0
  51. package/dist/electron-build/agents/chain/explorer.js.map +1 -0
  52. package/dist/electron-build/agents/contacts/chain.js +26 -6
  53. package/dist/electron-build/agents/contacts/chain.js.map +1 -1
  54. package/dist/electron-build/agents/execution-supervisor.js +492 -34
  55. package/dist/electron-build/agents/execution-supervisor.js.map +1 -1
  56. package/dist/electron-build/agents/external-events.js +34 -5
  57. package/dist/electron-build/agents/external-events.js.map +1 -1
  58. package/dist/electron-build/agents/goal-criteria.js +10 -3
  59. package/dist/electron-build/agents/goal-criteria.js.map +1 -1
  60. package/dist/electron-build/agents/goal-flywheel/continuation-decision.js +598 -0
  61. package/dist/electron-build/agents/goal-flywheel/continuation-decision.js.map +1 -0
  62. package/dist/electron-build/agents/goal-flywheel/goal-change.js +773 -0
  63. package/dist/electron-build/agents/goal-flywheel/goal-change.js.map +1 -0
  64. package/dist/electron-build/agents/goal-flywheel/memory-layers.js +269 -0
  65. package/dist/electron-build/agents/goal-flywheel/memory-layers.js.map +1 -0
  66. package/dist/electron-build/agents/goal-flywheel/run-closure.js +818 -0
  67. package/dist/electron-build/agents/goal-flywheel/run-closure.js.map +1 -0
  68. package/dist/electron-build/agents/goal-flywheel/skill-candidate.js +642 -0
  69. package/dist/electron-build/agents/goal-flywheel/skill-candidate.js.map +1 -0
  70. package/dist/electron-build/agents/goal-flywheel/types.js +333 -0
  71. package/dist/electron-build/agents/goal-flywheel/types.js.map +1 -0
  72. package/dist/electron-build/agents/goal-flywheel/wiring/change.js +294 -0
  73. package/dist/electron-build/agents/goal-flywheel/wiring/change.js.map +1 -0
  74. package/dist/electron-build/agents/goal-flywheel/wiring/closure.js +742 -0
  75. package/dist/electron-build/agents/goal-flywheel/wiring/closure.js.map +1 -0
  76. package/dist/electron-build/agents/goal-flywheel/wiring/continuation.js +444 -0
  77. package/dist/electron-build/agents/goal-flywheel/wiring/continuation.js.map +1 -0
  78. package/dist/electron-build/agents/goal-flywheel/wiring/contract.js +279 -0
  79. package/dist/electron-build/agents/goal-flywheel/wiring/contract.js.map +1 -0
  80. package/dist/electron-build/agents/goal-flywheel/wiring/index.js +111 -0
  81. package/dist/electron-build/agents/goal-flywheel/wiring/index.js.map +1 -0
  82. package/dist/electron-build/agents/goal-flywheel/wiring/monitor.js +264 -0
  83. package/dist/electron-build/agents/goal-flywheel/wiring/monitor.js.map +1 -0
  84. package/dist/electron-build/agents/goal-flywheel/wiring/seams.js +612 -0
  85. package/dist/electron-build/agents/goal-flywheel/wiring/seams.js.map +1 -0
  86. package/dist/electron-build/agents/goal-flywheel/work-contract.js +488 -0
  87. package/dist/electron-build/agents/goal-flywheel/work-contract.js.map +1 -0
  88. package/dist/electron-build/agents/goal-flywheel/work-monitor.js +501 -0
  89. package/dist/electron-build/agents/goal-flywheel/work-monitor.js.map +1 -0
  90. package/dist/electron-build/agents/goal-flywheel-wiring.js +1962 -0
  91. package/dist/electron-build/agents/goal-flywheel-wiring.js.map +1 -0
  92. package/dist/electron-build/agents/goal-state-reducer.js +414 -0
  93. package/dist/electron-build/agents/goal-state-reducer.js.map +1 -0
  94. package/dist/electron-build/agents/goal-store.js +35 -1
  95. package/dist/electron-build/agents/goal-store.js.map +1 -1
  96. package/dist/electron-build/agents/local-signer.js +101 -0
  97. package/dist/electron-build/agents/local-signer.js.map +1 -0
  98. package/dist/electron-build/agents/network-pulse.js +531 -28
  99. package/dist/electron-build/agents/network-pulse.js.map +1 -1
  100. package/dist/electron-build/agents/pi-sdk.js +74 -19
  101. package/dist/electron-build/agents/pi-sdk.js.map +1 -1
  102. package/dist/electron-build/agents/run-store.js +20 -5
  103. package/dist/electron-build/agents/run-store.js.map +1 -1
  104. package/dist/electron-build/agents/skill-readiness.js +52 -9
  105. package/dist/electron-build/agents/skill-readiness.js.map +1 -1
  106. package/dist/electron-build/agents/task/task-runner.js +35 -5
  107. package/dist/electron-build/agents/task/task-runner.js.map +1 -1
  108. package/dist/electron-build/agents/task-board.js +761 -0
  109. package/dist/electron-build/agents/task-board.js.map +1 -0
  110. package/dist/electron-build/agents/task-contract.js +382 -0
  111. package/dist/electron-build/agents/task-contract.js.map +1 -0
  112. package/dist/electron-build/orbitdb/cid-database.js +83 -17
  113. package/dist/electron-build/orbitdb/cid-database.js.map +1 -1
  114. package/dist/electron-build/orbitdb/ipfs-node.js +31 -2
  115. package/dist/electron-build/orbitdb/ipfs-node.js.map +1 -1
  116. package/dist/electron-build/utils/dual-source.js +471 -0
  117. package/dist/electron-build/utils/dual-source.js.map +1 -0
  118. package/dist/electron-build/utils/update-manager.js +331 -57
  119. package/dist/electron-build/utils/update-manager.js.map +1 -1
  120. package/dist/electron-build/utils/update-state.js +16 -1
  121. package/dist/electron-build/utils/update-state.js.map +1 -1
  122. package/dist/electron-build/utils/version-info.js +82 -2
  123. package/dist/electron-build/utils/version-info.js.map +1 -1
  124. package/dist/index.js +103 -12
  125. package/dist/ios/chain-explorer.js +617 -0
  126. package/dist/ios/explorer.html +163 -0
  127. package/dist/ios/index.html +96 -0
  128. package/dist/ios/mobile-agent.js +2 -2
  129. package/dist/ios/mobile-contacts.js +329 -0
  130. package/dist/ios/mobile-core.js +1379 -226
  131. package/dist/ios/mobile-privacy.js +185 -0
  132. package/dist/ios/mobile.css +19 -0
  133. package/dist/ios/mobile.html +96 -0
  134. package/dist/ios/mobile.js +706 -7
  135. package/dist/ios/routes-chain-index.js +108 -0
  136. package/dist/ios/routes-contacts.js +420 -0
  137. package/dist/ios/routes-onchain-trade.js +99 -0
  138. package/dist/ios/routes-x402-info.js +1 -0
  139. package/dist/ios/server.js +762 -0
  140. package/dist/ios/task-frame-server.js +211 -0
  141. package/dist/orbitdb/cid-database.js +81 -16
  142. package/dist/orbitdb/ipfs-node.js +31 -2
  143. package/dist/utils/dual-source.js +426 -0
  144. package/dist/utils/update-cli.js +16 -21
  145. package/dist/utils/update-health.js +35 -5
  146. package/dist/utils/update-manager.js +329 -57
  147. package/dist/utils/update-state.js +16 -1
  148. package/dist/utils/version-info.js +75 -1
  149. package/dist/web/mobile-contacts.js +44 -11
  150. package/dist/web/mobile-core.js +1197 -513
  151. package/dist/web/mobile-tasks.js +339 -0
  152. package/dist/web/mobile.css +19 -0
  153. package/dist/web/mobile.html +64 -0
  154. package/dist/web/mobile.js +531 -1
  155. package/dist/web/routes-mobile-tasks.js +353 -0
  156. package/dist/web/server.js +163 -4
  157. package/package.json +3 -1
@@ -29,7 +29,7 @@ import { DeepThinkingEngine, AgentCoordinator } from '@bolloon/constraint-runtim
29
29
  import { WorkflowPivotLoop, createDefaultPivotConfig } from './workflow-pivot-loop.js';
30
30
  import { initDocumentReceiver } from './p2p-document-tools.js';
31
31
  import { startRun, recordStep, finishRun, readRun, budgetVerdict, recordDegradation, recordHarnessEvent, recordRecovery, setRunStatus, prepareResume, markRunRunning, buildResumeInstruction, argsDigestOf, repeatedFailureCount, classifyError as classifyRunError } from './run-store.js';
32
- import { createGoal, readGoal, attachRun, findActiveGoal, completeGoalIfEligible, setUnresolved, addEvidence, evaluateGoalCompletion } from './goal-store.js';
32
+ import { createGoal, attachRun, findActiveGoal } from './goal-store.js';
33
33
  import { PiAgentHarness } from './pi-harness.js';
34
34
  import { DiscoveredAgentsManager, createSocialHeartbeat } from '../social/heartbeat.js';
35
35
  import { SkillRegistry } from '@bolloon/constraint-runtime';
@@ -757,6 +757,30 @@ export class PiAgentSession {
757
757
  status: 'needs_human',
758
758
  error: 'LLM 不可用: provider 未初始化或无 apiKey (fallback 不是执行结果, 需要配置或人工处理)',
759
759
  });
760
+ // ★ M0: "工具/权限不可用"也是一条终止路径 → 同样进收尾漏斗 (规则 ④)。
761
+ const wiring = await import('./goal-flywheel-wiring.js');
762
+ const closed = await wiring.closeRunOnce({
763
+ goalId: boundGoalId,
764
+ runId: rec.runId,
765
+ caller: 'runner',
766
+ now: new Date().toISOString(),
767
+ finalReview: `LLM 不可用 → fallback 收尾 (不是执行结果): ${response}`,
768
+ });
769
+ if (!wiring.isRefusal(closed) && closed.outcome) {
770
+ const { reduceGoalState } = await import('./goal-state-reducer.js');
771
+ await reduceGoalState({
772
+ goalId: boundGoalId,
773
+ intent: 'closure_outcome',
774
+ now: new Date().toISOString(),
775
+ by: 'runner',
776
+ outcome: {
777
+ goalStatus: wiring.goalStatusFromDecision(closed.outcome.decision),
778
+ continuation: wiring.toGoalStoreContinuation(closed.outcome.continuation),
779
+ reason: closed.outcome.decision.reason,
780
+ },
781
+ runId: rec.runId,
782
+ });
783
+ }
760
784
  console.log(`[PiAgent] LLM 不可用 → 本次仍落 Run ${rec.runId} (needs_human), 不伪装成正常执行`);
761
785
  }
762
786
  catch (err) {
@@ -2341,30 +2365,61 @@ ${PiAgentSession.TOOL_SELECTION_GUIDE}
2341
2365
  await recordDegradation({ kind: 'core', op: 'pi-sdk.finishRun', runId: this.currentRunId, message }).catch(() => { });
2342
2366
  onStream?.({ type: 'error', content: `⚠️ ${message}`, tool: 'harness' });
2343
2367
  }
2344
- // 2026-09-16 (M4): Goal 侧完成门 —— Run 结束 ≠ Goal 完成; 只有判据全满足 + 有证据 + 无未解决项才算
2368
+ // ★ 2026-09-25 (M0 接线冻结, 规则 ③ + ④): Run 结束**必过收尾漏斗**。
2369
+ //
2370
+ // 旧写法在这里另起了一套 Goal 侧收尾 (读证据 → `evaluateGoalCompletion` →
2371
+ // `completeGoalIfEligible` / `setUnresolved`): 它**不收尾** —— 不写 Memory、不生成 Skill 候选、
2372
+ // 不写权威 continuation、不留决策记录。于是同一条 Run 在 Supervisor 那里还会再收一次 (或反过来
2373
+ // 这条路径根本不收), 全仓就有了两套"这一轮跑完意味着什么"。那一套已删除。
2374
+ //
2375
+ // 现在: `closeRunOnce` (幂等) → 9 步收尾 → 产物落盘; 再由 Goal reducer 把"下一步是什么"
2376
+ // 落进 Goal (没有 Supervisor 宿主时 Runner 也要留下 continuation, 否则下一次还是从零开始)。
2345
2377
  if (this.currentGoalId) {
2346
2378
  try {
2347
- const full = await readRun(this.currentRunId).catch(() => null);
2348
- const ev = (full?.evidence || []).slice(-10);
2349
- if (ev.length)
2350
- await addEvidence(this.currentGoalId, ev);
2351
- const goal = await readGoal(this.currentGoalId);
2352
- if (goal) {
2353
- const verdict = evaluateGoalCompletion(goal);
2354
- if (verdict.complete) {
2355
- const done = await completeGoalIfEligible(this.currentGoalId);
2356
- onStream?.({ type: 'status', content: `🎯 目标已达成: ${done.reason} (goal=${this.currentGoalId})`, tool: 'harness' });
2357
- }
2358
- else {
2359
- // 不静默: 未达成的目标留在 active, 未解决项写清楚 (下次 prompt 会继续这个 Goal)
2360
- const items = verdict.missing.length ? verdict.missing : [`run=${this.currentRunId} 结束但目标未达成: ${verdict.reason}`];
2361
- await setUnresolved(this.currentGoalId, items);
2362
- onStream?.({ type: 'status', content: `🎯 目标仍在进行 (未判完成): ${verdict.reason}`, tool: 'harness' });
2363
- }
2379
+ const wiring = await import('./goal-flywheel-wiring.js');
2380
+ const { reduceGoalState } = await import('./goal-state-reducer.js');
2381
+ const nowIso = new Date().toISOString();
2382
+ const closed = await wiring.closeRunOnce({
2383
+ goalId: this.currentGoalId,
2384
+ runId: this.currentRunId,
2385
+ caller: 'runner',
2386
+ now: nowIso,
2387
+ finalReview: finalResponse ? String(finalResponse).slice(0, 2000) : '',
2388
+ });
2389
+ if (wiring.isRefusal(closed)) {
2390
+ onStream?.({ type: 'status', content: `⚠️ 收尾被拒 (不当作收过): ${closed.reason}`, tool: 'harness' });
2391
+ }
2392
+ else if (closed.outcome) {
2393
+ const view = closed.outcome;
2394
+ const applied = await reduceGoalState({
2395
+ goalId: this.currentGoalId,
2396
+ intent: 'closure_outcome',
2397
+ now: nowIso,
2398
+ by: 'runner',
2399
+ outcome: {
2400
+ goalStatus: wiring.goalStatusFromDecision(view.decision),
2401
+ continuation: wiring.toGoalStoreContinuation(view.continuation),
2402
+ reason: view.decision.reason,
2403
+ },
2404
+ runId: view.runId,
2405
+ });
2406
+ const line = view.decision.decision === 'complete'
2407
+ ? `🎯 目标已达成 (仍要过完成门): ${view.decision.reason}`
2408
+ : `🎯 目标仍在进行 (未判完成): ${view.decision.reason}`;
2409
+ onStream?.({
2410
+ type: 'status',
2411
+ content: `${line}${applied.gateRejected ? ` [完成门拒绝: ${applied.gateRejected}]` : ''} `
2412
+ + `(收尾 ${view.steps} 步 · memory ${view.memories} · skill 候选 ${view.candidates} · goal=${this.currentGoalId})`,
2413
+ tool: 'harness',
2414
+ });
2415
+ }
2416
+ else {
2417
+ // 幂等: 别处 (或上一次) 已经收过尾 —— 事实读不回来就如实说, 不重收也不假装
2418
+ onStream?.({ type: 'status', content: `🎯 ${closed.reason}`, tool: 'harness' });
2364
2419
  }
2365
2420
  }
2366
2421
  catch (err) {
2367
- await recordDegradation({ kind: 'observational', op: 'pi-sdk.goalCompletion', runId: this.currentRunId, message: String(err?.message || err).slice(0, 160) }).catch(() => { });
2422
+ await recordDegradation({ kind: 'observational', op: 'pi-sdk.runClosure', runId: this.currentRunId, message: String(err?.message || err).slice(0, 160) }).catch(() => { });
2368
2423
  }
2369
2424
  }
2370
2425
  this.currentRunId = '';
@@ -24,14 +24,14 @@ export const RUN_TRANSITIONS = {
24
24
  queued: ['running', 'aborted', 'interrupted'],
25
25
  running: ['recovering', 'paused', 'awaiting_external', 'done', 'failed', 'aborted', 'interrupted', 'stalled', 'needs_human'],
26
26
  recovering: ['running', 'failed', 'aborted', 'needs_human', 'interrupted', 'stalled'],
27
- paused: ['running', 'aborted', 'interrupted'],
28
- awaiting_external: ['running', 'failed', 'aborted', 'interrupted', 'stalled'],
27
+ paused: ['running', 'aborted', 'interrupted', 'recovering'],
28
+ awaiting_external: ['running', 'failed', 'aborted', 'interrupted', 'stalled', 'recovering'],
29
29
  done: [],
30
30
  failed: [],
31
31
  aborted: [],
32
32
  interrupted: ['recovering', 'aborted'], // 允许"从 checkpoint 恢复"
33
33
  stalled: ['recovering', 'aborted', 'needs_human'],
34
- needs_human: ['running', 'aborted'],
34
+ needs_human: ['running', 'aborted', 'recovering'],
35
35
  };
36
36
  export function canTransition(from, to) {
37
37
  if (from === to)
@@ -572,6 +572,13 @@ function pidAlive(pid) {
572
572
  return e?.code === 'EPERM';
573
573
  }
574
574
  }
575
+ let onRunTerminal = null;
576
+ export function setOnRunTerminal(hook) {
577
+ onRunTerminal = hook;
578
+ }
579
+ export function getOnRunTerminal() {
580
+ return onRunTerminal;
581
+ }
575
582
  /**
576
583
  * 孤儿对账: 启动时把 pid 已死的 running 记录改判 interrupted。
577
584
  * 不做这一层的话, 重载后 UI/CLI 会显示"还在跑"的幽灵运行 —— 那是最典型的假状态。
@@ -588,11 +595,14 @@ export async function reconcileOrphans() {
588
595
  continue;
589
596
  }
590
597
  try {
591
- await finishRun(r.runId, {
598
+ const closed = await finishRun(r.runId, {
592
599
  status: 'interrupted',
593
600
  error: `进程 ${r.pid} 已不在 (刷新/重载/崩溃); 运行到此中断`,
594
601
  });
595
602
  interrupted.push(r.runId);
603
+ // 终止路径进唯一责任链 (拿不到 Goal 就不收, 不假装收过)
604
+ if (onRunTerminal)
605
+ await Promise.resolve(onRunTerminal(closed || r)).catch(() => null);
596
606
  }
597
607
  catch (err) {
598
608
  failed.push(r.runId);
@@ -615,11 +625,14 @@ export async function superviseRuns(now = Date.now()) {
615
625
  continue; // 交给 reconcileOrphans
616
626
  if (now - Date.parse(r.updatedAt) > cfg.staleMs) {
617
627
  try {
618
- await finishRun(r.runId, {
628
+ const closed = await finishRun(r.runId, {
619
629
  status: 'stalled',
620
630
  error: `超过 ${Math.round(cfg.staleMs / 1000)}s 没有新进展 (可能是工具卡住或模型长时间无响应)`,
621
631
  });
622
632
  stalled.push(r.runId);
633
+ // 同上: 失速也是一条终止路径
634
+ if (onRunTerminal)
635
+ await Promise.resolve(onRunTerminal(closed || r)).catch(() => null);
623
636
  }
624
637
  catch (err) {
625
638
  failed.push(r.runId);
@@ -12,7 +12,9 @@
12
12
  */
13
13
  import * as os from 'os';
14
14
  import { SkillsManager } from './skills-manager.js';
15
- import { readGoal, setContinuation, updateGoal, addEvidence, } from './goal-store.js';
15
+ import { readGoal, updateGoal, addEvidence, } from './goal-store.js';
16
+ // 2026-09-25 (M0 接线冻结, 规则 ②): Goal 状态与 continuation 变更只有一个漏斗
17
+ import { reduceGoalState } from './goal-state-reducer.js';
16
18
  import { recordDegradation } from './run-store.js';
17
19
  export function parseSkillSpecs(list = []) {
18
20
  const required = [];
@@ -109,15 +111,45 @@ export async function ensureGoalSkillsReady(goal, opts = {}) {
109
111
  /** 门禁不过 → 写清事实并把 Goal 交给人 (不启动 Run, 不伪造失败) */
110
112
  export async function blockGoalOnSkills(goalId, res) {
111
113
  const reason = res.reason || '技能未就绪';
112
- await setContinuation(goalId, {
113
- wakeReason: 'needs_human', autoContinue: false, needsExternal: undefined,
114
- skillReadiness: { ok: false, at: new Date().toISOString(), reason, missing: res.missing, drift: res.drift, degradations: res.degradations },
115
- });
116
- await updateGoal(goalId, { status: 'needs_human' }).catch(() => { });
117
- await addEvidence(goalId, [`技能门禁拦截: ${reason}`]).catch(() => { });
114
+ // 规则 ②: 状态与 continuation 都经 Goal reducer 写 (单一漏斗)
115
+ await reduceGoalState({
116
+ goalId,
117
+ intent: 'skill_gate_block',
118
+ now: new Date().toISOString(),
119
+ by: 'skill-readiness',
120
+ reason,
121
+ skillReadiness: {
122
+ ok: false, at: new Date().toISOString(), reason,
123
+ missing: res.missing, drift: res.drift, degradations: res.degradations,
124
+ },
125
+ }).catch(() => null);
118
126
  for (const d of res.degradations)
119
127
  await recordDegradation({ kind: 'observational', op: 'skill-readiness', message: d }).catch(() => { });
120
128
  }
129
+ /**
130
+ * 只**记事实**、不改状态: 把本地技能就绪检查的结论写进 `continuation.skillReadiness`。
131
+ *
132
+ * ★ 2026-09-25 (P5 验收修复): 飞轮裁决 = `delegate` 时本地技能门禁**不适用** (能力由子 Agent 凭合同
133
+ * 交付), 所以不能调 `blockGoalOnSkills` (那会把 Goal 打成 needs_human)。但"本地缺什么"必须仍然
134
+ * 可查 —— 原来只有 `blockGoalOnSkills` 写这个字段, 门禁一放行就没有任何记录了。
135
+ */
136
+ export async function recordSkillReadiness(goalId, res) {
137
+ // 规则 ②: continuation 也经 Goal reducer 写 (只记事实, 不改状态)
138
+ await reduceGoalState({
139
+ goalId,
140
+ intent: 'skill_gate_record',
141
+ now: new Date().toISOString(),
142
+ by: 'skill-readiness',
143
+ skillReadiness: {
144
+ ok: res.ok,
145
+ at: new Date().toISOString(),
146
+ reason: res.reason,
147
+ missing: res.missing,
148
+ drift: res.drift,
149
+ degradations: res.degradations,
150
+ },
151
+ }).catch(() => null);
152
+ }
121
153
  /** 人工批准技能升级: 重新冻结快照 (显式动作, 不隐式切换) */
122
154
  export async function approveSkillUpgrade(goalId, opts = {}) {
123
155
  const goal = await readGoal(goalId);
@@ -126,8 +158,18 @@ export async function approveSkillUpgrade(goalId, opts = {}) {
126
158
  const frozen = await freezeGoalSkills(goal, { ...opts, force: true });
127
159
  if (!frozen.ok)
128
160
  return { ok: false, reason: frozen.reason };
129
- await setContinuation(goalId, { skillReadiness: { ok: true, at: new Date().toISOString(), reason: '人工批准技能升级' }, wakeReason: 'active', autoContinue: true });
130
- await updateGoal(goalId, { status: 'active' }).catch(() => { });
131
- await addEvidence(goalId, [`技能升级已被人工批准: ${(frozen.snapshot || []).map((s) => `${s.name}@${s.version}`).join(', ')}`]).catch(() => { });
161
+ // ★ 规则 ⑥ (Skill 不许绕过 验证+快照+可回退 通道): 批准是**人**的动作, 但必须留下通道证据 ——
162
+ // 快照 (skillSnapshot, 上面 freezeGoalSkills 已写) + 版本 + 变更原因 + 可回退 (fromVersion 仍在
163
+ // Goal 上)。这里把"谁批的、为什么"连同新版本一起记进 Goal (走唯一漏斗, 规则 ②)。
164
+ const versions = (frozen.snapshot || []).map((s) => `${s.name}@${s.version}`);
165
+ const fromVersions = (goal.skillSnapshot || []).map((s) => `${s.name}@${s.version}`);
166
+ await reduceGoalState({
167
+ goalId,
168
+ intent: 'skill_upgrade_approved',
169
+ now: new Date().toISOString(),
170
+ by: 'human',
171
+ skillReadiness: { ok: true, at: new Date().toISOString(), reason: '人工批准技能升级' },
172
+ reason: `技能升级已被人工批准 (by=human; from=[${fromVersions.join(', ')}] → to=[${versions.join(', ')}]; 可回退: Goal.skillSnapshot 保留新版本快照, 内容哈希在 skills-manager 侧可核验)`,
173
+ }).catch(() => null);
132
174
  return { ok: true, snapshot: frozen.snapshot };
133
175
  }
@@ -1,5 +1,7 @@
1
1
  import * as fs from 'fs/promises';
2
2
  import * as path from 'path';
3
+ // 2026-09-25 (M3 接缝): 换人必须重签合同的判据只有一份 (纯函数, 零 I/O)
4
+ import { childMatchesContract } from './goal-flywheel/wiring/contract.js';
3
5
  const DEFAULT_STORAGE_PATH = path.join(process.env.HOME || '/tmp', '.bolloon', 'agents');
4
6
  const DEFAULT_TASK_TIMEOUT_MS = 5 * 60 * 1000;
5
7
  const DEFAULT_MAX_CONCURRENT_TASKS = 10;
@@ -160,10 +162,36 @@ export class SubAgentManager {
160
162
  async assignTask(taskId, toAgentId) {
161
163
  const task = this.tasks.get(taskId);
162
164
  if (task) {
165
+ // 2026-09-25 (M3): 挂着工作合同的任务 **不许换人而不换合同**。
166
+ // 合同把 `childAgentId` 钉死了 (回报核验要求 report.childAgentId === contract.childAgentId),
167
+ // 把同一份合同指给另一个子 Agent ⇒ 新子的回报必然对不上合同, 合规判定整个失效。
168
+ // 换人的正确做法是**重新派遣** (父侧 signed 新合同) —— 本方法只维护指派关系, 无权签合同,
169
+ // 所以这里如实拒绝并留痕, 而不是"改了字段就当换成了"。
170
+ if (task.goalId && task.workId) {
171
+ const contract = (task.metadata?.workContract ?? null);
172
+ if (!contract) {
173
+ task.reassignRefused = {
174
+ code: 'contract_unreadable',
175
+ reason: `任务 ${taskId} 挂着 workId=${task.workId} 却读不到合同 → 无法判断新执行者是否与原合同一致, 拒绝换人 (先重签合同)`,
176
+ };
177
+ await this.saveTasks();
178
+ this.notifyTaskListeners(task);
179
+ return;
180
+ }
181
+ const same = childMatchesContract(contract, toAgentId);
182
+ if (!same.ok) {
183
+ task.reassignRefused = { code: 'requires_new_contract', reason: same.reason };
184
+ await this.saveTasks();
185
+ this.notifyTaskListeners(task);
186
+ return;
187
+ }
188
+ task.reassignRefused = null;
189
+ }
163
190
  task.assignedAgentId = toAgentId;
164
191
  task.toAgentId = toAgentId;
165
192
  task.status = 'assigned';
166
193
  task.assignedAt = new Date().toISOString();
194
+ await this.heartbeatContractedTask(task, task.assignedAt);
167
195
  await this.saveTasks();
168
196
  this.notifyTaskListeners(task);
169
197
  }
@@ -171,6 +199,37 @@ export class SubAgentManager {
171
199
  async updateTaskStatus(taskId, status, result, error) {
172
200
  const task = this.tasks.get(taskId);
173
201
  if (task) {
202
+ // 2026-09-25 (M3 接缝): 挂了合同的子任务, 任何状态更新都是"子还活着"的证据 → 记一次心跳。
203
+ // 这是"执行中统一监控"的输入面: 没有心跳, 阻塞巡检只能看见"卡住", 看不见"在动"。
204
+ await this.heartbeatContractedTask(task);
205
+ // 2026-09-25 (飞轮接线 P2): 挂着工作合同的任务, "完成" 必须过回报核验 ——
206
+ // 回一段漂亮话 (没有逐条判据证据) **不算完成**: 任务留在 in_progress, 原因写清楚,
207
+ // 而 Goal 那边的 pendingReports 保持 (父仍然在等这份回报)。
208
+ //
209
+ // 2026-09-25 (M3): 判据从 `task.workId && task.goalId` 收紧成 `task.goalId` ——
210
+ // "派前必须有合同"的**反面**: 目标语境下的任务若**没有**合同 (签发失败 / 旧数据),
211
+ // 它同样不许自称完成 —— 没有合同的回报不核验也不接受 (handleChildReport 的 no_contract)。
212
+ if (status === 'completed' && task.goalId) {
213
+ if (!task.workId) {
214
+ task.status = 'in_progress';
215
+ task.error = '回报不接受为完成: 这条任务在目标语境下**没有工作合同** '
216
+ + '(签合同失败或任务早于合同接线) —— 没有合同就没有判据, 无法核验, 不许当完成记账';
217
+ await this.saveTasks();
218
+ this.notifyTaskListeners(task);
219
+ return;
220
+ }
221
+ const verdict = await this.validateTaskReport(task, result ?? '');
222
+ if (!verdict.accepted) {
223
+ task.status = 'in_progress';
224
+ if (result)
225
+ task.result = result;
226
+ task.error = `回报核验不过, 不接受为完成: ${verdict.reason}`
227
+ + (verdict.missingEvidence.length ? ` [缺证据: ${verdict.missingEvidence.join('; ')}]` : '');
228
+ await this.saveTasks();
229
+ this.notifyTaskListeners(task);
230
+ return;
231
+ }
232
+ }
174
233
  task.status = status;
175
234
  if (result)
176
235
  task.result = result;
@@ -183,6 +242,31 @@ export class SubAgentManager {
183
242
  this.notifyTaskListeners(task);
184
243
  }
185
244
  }
245
+ /**
246
+ * 给挂着合同的任务记一次心跳 (P3 的输入面; 只有真写成盘才算数)。
247
+ *
248
+ * 为什么放在**真实派遣过程**里而不是让子 Agent 自己记得报: 心跳是"任务还活着"的事实,
249
+ * 而子 Agent 是最没有动力报心跳的一方 (它卡住了就不会报)。所以由派遣侧在每一次
250
+ * 真实状态变化 (派出去 / 换人 / 子更新状态) 时落一次 —— 没有这些落点, 阻塞巡检
251
+ * 只能看到"从来没心跳过", 分不清"卡住"与"刚派出"。
252
+ *
253
+ * 失败**不覆盖** `task.error` (那是子 Agent 的事实), 而是记进 `heartbeatError`:
254
+ * 心跳写不进去 = 监控会看到"无进展", 这件事必须留在任务上, 不许静默。
255
+ */
256
+ async heartbeatContractedTask(task, at) {
257
+ if (!task.goalId || !task.workId)
258
+ return;
259
+ const now = at ?? new Date().toISOString();
260
+ try {
261
+ const { recordWorkHeartbeat } = await import('./goal-flywheel-wiring.js');
262
+ await recordWorkHeartbeat(task.goalId, task.workId, now);
263
+ task.lastHeartbeatAt = now;
264
+ task.heartbeatError = null;
265
+ }
266
+ catch (e) {
267
+ task.heartbeatError = `心跳写入失败 (监控会看到"无进展"): ${String(e?.message || e).slice(0, 160)}`;
268
+ }
269
+ }
186
270
  async getTask(taskId) {
187
271
  return this.tasks.get(taskId);
188
272
  }
@@ -300,14 +384,136 @@ export class SubAgentManager {
300
384
  }
301
385
  }
302
386
  }
303
- async delegateTask(fromAgentId, taskDescription, requiredCapabilities, priority = 'normal', input) {
387
+ async delegateTask(fromAgentId, taskDescription, requiredCapabilities, priority = 'normal', input, contractOptions) {
304
388
  const agent = await this.findBestAgentForTask(requiredCapabilities, fromAgentId);
305
389
  if (!agent) {
306
390
  const task = await this.createTask('delegate', taskDescription.substring(0, 50), taskDescription, fromAgentId, undefined, priority, input);
307
- return { task, agent: undefined };
391
+ const workContract = await this.issueWorkContractFor(fromAgentId, task, requiredCapabilities, contractOptions);
392
+ // 派前必须有工作合同 (M3): 目标语境下签不出合同 → **(没有收件人时本来也派不出去)**, 但要如实标注
393
+ if (!workContract && contractOptions?.goalId) {
394
+ this.refuseDispatchWithoutContract(task, contractOptions?.goalId);
395
+ await this.saveTasks();
396
+ return { task, agent: undefined, workContract: undefined };
397
+ }
398
+ if (workContract)
399
+ await this.heartbeatContractedTask(task);
400
+ await this.saveTasks();
401
+ return { task, agent: undefined, workContract };
308
402
  }
309
403
  const task = await this.createTask('delegate', taskDescription.substring(0, 50), taskDescription, fromAgentId, agent.id, priority, input);
310
- return { task, agent };
404
+ // 2026-09-25 (P2): 派遣这件事**本身**就包含签合同 (不是事后再补一份文档);
405
+ // 有没有空闲子 Agent 不影响合同签发 (合同锁的是能力与判据, 不是某个人)。
406
+ //
407
+ // 2026-09-25 (M3): 签不出合同 ⇒ **不派出**。旧形状是"记个 error 照样把任务交出去",
408
+ // 那等于"派前必须有的合同"在失败路径上直接不存在 (子拿到一段任务描述就开始干,
409
+ // 回来只有一段文本, 父连判据都没有)。现在: 任务退回 `pending`、不绑执行者、
410
+ // 返回 `agent: undefined`, 让调用方**没有**可派遣的对象。
411
+ const workContract = await this.issueWorkContractFor(fromAgentId, task, requiredCapabilities, contractOptions);
412
+ if (!workContract && contractOptions?.goalId) {
413
+ this.refuseDispatchWithoutContract(task, contractOptions.goalId);
414
+ await this.saveTasks();
415
+ return { task, agent: undefined, workContract: undefined };
416
+ }
417
+ if (workContract)
418
+ await this.heartbeatContractedTask(task);
419
+ await this.saveTasks();
420
+ return { task, agent, workContract };
421
+ }
422
+ /**
423
+ * 目标语境下没有合同 ⇒ **不派出** (M3 的"派前必须有合同")。
424
+ *
425
+ * 落成事实: 任务退回 `pending` 且**不绑执行者** (没有 `assignedAt`),
426
+ * `contractRefused` 记下原因。于是:
427
+ * · 调用方拿到的 `agent` 是 `undefined` → 没有派遣对象;
428
+ * · 任务不会出现在 `getActiveTasks()` 里 (它没被派出去);
429
+ * · 原因留在任务上, 父能据此重签合同或交人 (不许静默当"没派过")。
430
+ */
431
+ refuseDispatchWithoutContract(task, goalId) {
432
+ const reason = task.error
433
+ ? `派前必须有工作合同: 合同没签成 → 不派出 (${task.error})`
434
+ : '派前必须有工作合同: 合同没签成 → 不派出';
435
+ task.status = 'pending';
436
+ task.assignedAgentId = undefined;
437
+ task.toAgentId = undefined;
438
+ task.assignedAt = undefined;
439
+ // Goal 上下文要留在任务上: ① 它说明这次派遣是**为哪个目标**尝试的;
440
+ // ② 于是完成门 (`updateTaskStatus` 的 `task.goalId` 判据) 也管得到这条没有合同的任务
441
+ // —— 没有合同就不许自称完成, 不留 goalId 的话它就绕过了那道门。
442
+ if (goalId)
443
+ task.goalId = goalId;
444
+ task.contractRefused = { code: 'contract_not_issued', reason };
445
+ }
446
+ /**
447
+ * 给这次派遣签工作合同 (P2)。只有调用方给了 `goalId` 才签 —— 没有长期目标就没有合同上下文,
448
+ * 这时行为与接线前完全一致 (只记任务)。
449
+ */
450
+ async issueWorkContractFor(fromAgentId, task, requiredCapabilities, opts) {
451
+ if (!opts?.goalId)
452
+ return undefined;
453
+ try {
454
+ const { dispatchChildWork } = await import('./goal-flywheel-wiring.js');
455
+ const capability = (requiredCapabilities[0] || 'general').trim();
456
+ const issued = await dispatchChildWork({
457
+ goalId: opts.goalId,
458
+ parentRunId: opts.parentRunId ?? `subagent-task:${task.id}`,
459
+ childAgentId: task.assignedAgentId || task.toAgentId || `(unassigned:${fromAgentId})`,
460
+ capability,
461
+ objective: task.description,
462
+ inputs: { taskId: task.id, input: task.input ?? null },
463
+ allowedTools: opts.allowedTools ?? [],
464
+ budget: opts.budget ?? { maxSteps: null, maxDurationMs: this.config.taskTimeoutMs, maxAmount: null, currency: null },
465
+ deadline: opts.deadline ?? null,
466
+ successCriteria: (opts.successCriteria?.length ? opts.successCriteria : [task.description]).slice(0, 20),
467
+ issuedBy: `subagent-manager:${fromAgentId}`,
468
+ });
469
+ task.goalId = opts.goalId;
470
+ task.workId = issued.workId;
471
+ task.metadata = { ...(task.metadata ?? {}), workContract: issued };
472
+ await this.saveTasks();
473
+ return issued;
474
+ }
475
+ catch (err) {
476
+ // 签合同失败 → 如实记在任务上 (不许悄悄降级成"没合同也能派")
477
+ task.error = `工作合同签发失败: ${String(err?.message || err).slice(0, 200)}`;
478
+ await this.saveTasks();
479
+ return undefined;
480
+ }
481
+ }
482
+ /**
483
+ * 核验挂着合同的子任务回报 (P2)。
484
+ * 报告可以是**结构化 JSON 文本** (推荐: 子按 `bolloon-work-report/1` 回), 也可以是纯文本 ——
485
+ * 纯文本没有逐条证据 → **不接受为完成** (这正是要堵的"回一段漂亮话就算完成")。
486
+ */
487
+ async validateTaskReport(task, result) {
488
+ const { handleChildReport } = await import('./goal-flywheel-wiring.js');
489
+ const contract = (task.metadata?.workContract ?? null);
490
+ const raw = String(result ?? '');
491
+ let report = null;
492
+ try {
493
+ const parsed = JSON.parse(raw);
494
+ if (parsed && typeof parsed === 'object' && typeof parsed.status === 'string') {
495
+ report = parsed;
496
+ }
497
+ }
498
+ catch { /* 不是 JSON → 当纯文本处理 */ }
499
+ if (!report) {
500
+ report = {
501
+ workId: task.workId,
502
+ childAgentId: contract?.childAgentId ?? task.assignedAgentId ?? task.toAgentId ?? '(unknown)',
503
+ status: 'completed',
504
+ summary: raw.slice(0, 400),
505
+ evidence: [],
506
+ artifacts: [],
507
+ checks: [],
508
+ unresolvedItems: [],
509
+ blockReason: null,
510
+ nextRecommendation: '(未提供)',
511
+ durationMs: 0,
512
+ reportedAt: new Date().toISOString(),
513
+ };
514
+ }
515
+ const out = await handleChildReport({ goalId: task.goalId, workId: task.workId, report });
516
+ return { accepted: out.accepted, reason: out.reason, missingEvidence: out.missingEvidence };
311
517
  }
312
518
  async consultAgent(fromAgentId, toAgentId, query, taskId) {
313
519
  return this.sendMessage(fromAgentId, toAgentId, query, 'query', taskId);
@@ -24,7 +24,7 @@ import { resolveTaskBudget } from './task-budget.js';
24
24
  import { adviseResource } from './resource-advisor.js';
25
25
  import { defaultRequestId, installBundle, deriveSkillInput, extractSources, extractConclusion } from './task-runner.js';
26
26
  import { buildReportCard, renderReportCard } from './report-card.js';
27
- import { loadResourceContract, executeContractSkill, validateResourceOutput, verifyInstallFidelity, } from '../x402/resource-contract.js';
27
+ import { loadResourceContract, executeContractSkill, validateResourceOutput, validateResourceInput, verifyInstallFidelity, } from '../x402/resource-contract.js';
28
28
  import { beginTransaction, readTransaction, updateTransaction } from '../x402/transaction-store.js';
29
29
  import { writeDeliveryContent } from '../x402/settlement-state.js';
30
30
  import { computeContentHash } from '../x402/paid-info-protocol.js';
@@ -88,6 +88,23 @@ export async function runTaskOnchain(opts) {
88
88
  };
89
89
  }
90
90
  mark('acquire', `选中 ${chosen.name} (${(chosen.why || []).slice(0, 2).join(' / ') || '名字匹配'})`);
91
+ // ②.5 输入预检 (不花钱的前置门): 输入建不出来/不合 inputSchema → **绝不先注资**
92
+ // 教训: 先注资再执行, 执行不达标 → 钱卡在托管里 (只能 dispute+refund 才拿得回)。
93
+ // 契约的 inputSchema 在这里就能判, 所以把这道门放在任何链上写操作之前。
94
+ const preContract = chosen.contract || (await loadResourceContract(chosen.dir)).contract;
95
+ const preInput = opts.input ?? deriveSkillInput(preContract, opts.task);
96
+ const preChk = preContract ? validateResourceInput(preContract, preInput) : { ok: true, issues: [] };
97
+ if (!preChk.ok) {
98
+ const blocker = `输入不符合 inputSchema (${preChk.issues[0] || '未知'}) → 未开托管, 没花钱`;
99
+ const card = buildReportCard({ task: opts.task, executed: false, paid: false, evidenceRef: {}, blocker });
100
+ mark('report', '输入不完整 → 不开托管, 不花钱 (给 --input 或把数字写进任务文本)');
101
+ return {
102
+ ok: false, taskKey, requestId, transactionId: '', card, text: renderReportCard(card), budget,
103
+ skill: { name: chosen.name, version: chosen.version, dir: chosen.dir },
104
+ chain: { verified: false, reason: blocker, statuses: [] }, outputIssues: [...preChk.issues, blocker],
105
+ recovery: recoverOnchainTrade({ home: opts.home, taskKey }), stages,
106
+ };
107
+ }
91
108
  // ③ 金额 = 单次购买上限 (与 M1 硬约束一致); 转原子单位
92
109
  const amountAtomic = BigInt(Math.round(budget.perPurchase * 10 ** decimals));
93
110
  const amountChk = checkOnchainAmount({ amountAtomic, decimals, budget: { taskBudget: budget.taskBudget, perPurchase: budget.perPurchase } });
@@ -124,6 +141,7 @@ export async function runTaskOnchain(opts) {
124
141
  network: opts.network || 'unknown', tokenDecimals: decimals,
125
142
  buyerSigner: opts.buyerSigner, sellerSigner: opts.sellerSigner,
126
143
  env: opts.env, persistState: opts.persistState, verifyOnChain: opts.verifyOnChain,
144
+ settleRetryAttempts: opts.settleRetryAttempts, settleRetryDelayMs: opts.settleRetryDelayMs,
127
145
  };
128
146
  // ⑤ 幂等/恢复: 已经链上走过的任务**不新开托管** (先读盘重建)
129
147
  const before = recoverOnchainTrade({ home: opts.home, taskKey });