@bolloon/bolloon-agent 0.4.33 → 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 (143) hide show
  1. package/dist/agents/contacts/chain.js +27 -7
  2. package/dist/agents/execution-supervisor.js +493 -35
  3. package/dist/agents/external-events.js +35 -6
  4. package/dist/agents/gateway-group.js +100 -12
  5. package/dist/agents/goal-criteria.js +10 -3
  6. package/dist/agents/goal-flywheel/block-executor.js +346 -0
  7. package/dist/agents/goal-flywheel/continuation-decision.js +592 -0
  8. package/dist/agents/goal-flywheel/goal-change.js +754 -0
  9. package/dist/agents/goal-flywheel/index.js +26 -0
  10. package/dist/agents/goal-flywheel/memory-layers.js +227 -0
  11. package/dist/agents/goal-flywheel/run-closure.js +809 -0
  12. package/dist/agents/goal-flywheel/skill-candidate.js +628 -0
  13. package/dist/agents/goal-flywheel/types.js +329 -0
  14. package/dist/agents/goal-flywheel/wiring/change.js +288 -0
  15. package/dist/agents/goal-flywheel/wiring/closure.js +697 -0
  16. package/dist/agents/goal-flywheel/wiring/continuation.js +432 -0
  17. package/dist/agents/goal-flywheel/wiring/contract.js +270 -0
  18. package/dist/agents/goal-flywheel/wiring/index.js +26 -0
  19. package/dist/agents/goal-flywheel/wiring/monitor.js +254 -0
  20. package/dist/agents/goal-flywheel/wiring/seams.js +592 -0
  21. package/dist/agents/goal-flywheel/work-contract.js +478 -0
  22. package/dist/agents/goal-flywheel/work-monitor.js +488 -0
  23. package/dist/agents/goal-flywheel-wiring.js +1870 -0
  24. package/dist/agents/goal-state-reducer.js +406 -0
  25. package/dist/agents/goal-store.js +35 -1
  26. package/dist/agents/mobile-flywheel-view.js +144 -0
  27. package/dist/agents/mobile-task-actions-verify.js +74 -0
  28. package/dist/agents/mobile-task-actions.js +178 -0
  29. package/dist/agents/mobile-task-views.js +199 -0
  30. package/dist/agents/network-pulse.js +304 -18
  31. package/dist/agents/pi-sdk.js +75 -20
  32. package/dist/agents/run-store.js +18 -5
  33. package/dist/agents/skill-readiness.js +52 -10
  34. package/dist/agents/subagent-manager.js +209 -3
  35. package/dist/agents/task/task-runner.js +9 -3
  36. package/dist/agents/task-group.js +8 -51
  37. package/dist/agents/task-public-text.js +89 -0
  38. package/dist/cli/commands/index.js +1 -0
  39. package/dist/cli/commands/tasks.js +350 -2
  40. package/dist/cli/identity-command.js +177 -0
  41. package/dist/cli/setup-wizard.js +71 -0
  42. package/dist/cli/update-commands.js +150 -24
  43. package/dist/cli-entry.js +12 -2
  44. package/dist/electron-build/agents/contacts/chain.js +26 -6
  45. package/dist/electron-build/agents/contacts/chain.js.map +1 -1
  46. package/dist/electron-build/agents/execution-supervisor.js +492 -34
  47. package/dist/electron-build/agents/execution-supervisor.js.map +1 -1
  48. package/dist/electron-build/agents/external-events.js +34 -5
  49. package/dist/electron-build/agents/external-events.js.map +1 -1
  50. package/dist/electron-build/agents/goal-criteria.js +10 -3
  51. package/dist/electron-build/agents/goal-criteria.js.map +1 -1
  52. package/dist/electron-build/agents/goal-flywheel/continuation-decision.js +598 -0
  53. package/dist/electron-build/agents/goal-flywheel/continuation-decision.js.map +1 -0
  54. package/dist/electron-build/agents/goal-flywheel/goal-change.js +773 -0
  55. package/dist/electron-build/agents/goal-flywheel/goal-change.js.map +1 -0
  56. package/dist/electron-build/agents/goal-flywheel/memory-layers.js +269 -0
  57. package/dist/electron-build/agents/goal-flywheel/memory-layers.js.map +1 -0
  58. package/dist/electron-build/agents/goal-flywheel/run-closure.js +818 -0
  59. package/dist/electron-build/agents/goal-flywheel/run-closure.js.map +1 -0
  60. package/dist/electron-build/agents/goal-flywheel/skill-candidate.js +642 -0
  61. package/dist/electron-build/agents/goal-flywheel/skill-candidate.js.map +1 -0
  62. package/dist/electron-build/agents/goal-flywheel/types.js +333 -0
  63. package/dist/electron-build/agents/goal-flywheel/types.js.map +1 -0
  64. package/dist/electron-build/agents/goal-flywheel/wiring/change.js +294 -0
  65. package/dist/electron-build/agents/goal-flywheel/wiring/change.js.map +1 -0
  66. package/dist/electron-build/agents/goal-flywheel/wiring/closure.js +742 -0
  67. package/dist/electron-build/agents/goal-flywheel/wiring/closure.js.map +1 -0
  68. package/dist/electron-build/agents/goal-flywheel/wiring/continuation.js +444 -0
  69. package/dist/electron-build/agents/goal-flywheel/wiring/continuation.js.map +1 -0
  70. package/dist/electron-build/agents/goal-flywheel/wiring/contract.js +279 -0
  71. package/dist/electron-build/agents/goal-flywheel/wiring/contract.js.map +1 -0
  72. package/dist/electron-build/agents/goal-flywheel/wiring/index.js +111 -0
  73. package/dist/electron-build/agents/goal-flywheel/wiring/index.js.map +1 -0
  74. package/dist/electron-build/agents/goal-flywheel/wiring/monitor.js +264 -0
  75. package/dist/electron-build/agents/goal-flywheel/wiring/monitor.js.map +1 -0
  76. package/dist/electron-build/agents/goal-flywheel/wiring/seams.js +612 -0
  77. package/dist/electron-build/agents/goal-flywheel/wiring/seams.js.map +1 -0
  78. package/dist/electron-build/agents/goal-flywheel/work-contract.js +488 -0
  79. package/dist/electron-build/agents/goal-flywheel/work-contract.js.map +1 -0
  80. package/dist/electron-build/agents/goal-flywheel/work-monitor.js +501 -0
  81. package/dist/electron-build/agents/goal-flywheel/work-monitor.js.map +1 -0
  82. package/dist/electron-build/agents/goal-flywheel-wiring.js +1962 -0
  83. package/dist/electron-build/agents/goal-flywheel-wiring.js.map +1 -0
  84. package/dist/electron-build/agents/goal-state-reducer.js +414 -0
  85. package/dist/electron-build/agents/goal-state-reducer.js.map +1 -0
  86. package/dist/electron-build/agents/goal-store.js +35 -1
  87. package/dist/electron-build/agents/goal-store.js.map +1 -1
  88. package/dist/electron-build/agents/network-pulse.js +307 -19
  89. package/dist/electron-build/agents/network-pulse.js.map +1 -1
  90. package/dist/electron-build/agents/pi-sdk.js +74 -19
  91. package/dist/electron-build/agents/pi-sdk.js.map +1 -1
  92. package/dist/electron-build/agents/run-store.js +20 -5
  93. package/dist/electron-build/agents/run-store.js.map +1 -1
  94. package/dist/electron-build/agents/skill-readiness.js +52 -9
  95. package/dist/electron-build/agents/skill-readiness.js.map +1 -1
  96. package/dist/electron-build/agents/task/task-runner.js +8 -2
  97. package/dist/electron-build/agents/task/task-runner.js.map +1 -1
  98. package/dist/electron-build/orbitdb/cid-database.js +83 -17
  99. package/dist/electron-build/orbitdb/cid-database.js.map +1 -1
  100. package/dist/electron-build/orbitdb/ipfs-node.js +31 -2
  101. package/dist/electron-build/orbitdb/ipfs-node.js.map +1 -1
  102. package/dist/electron-build/utils/dual-source.js +471 -0
  103. package/dist/electron-build/utils/dual-source.js.map +1 -0
  104. package/dist/electron-build/utils/update-manager.js +331 -57
  105. package/dist/electron-build/utils/update-manager.js.map +1 -1
  106. package/dist/electron-build/utils/update-state.js +16 -1
  107. package/dist/electron-build/utils/update-state.js.map +1 -1
  108. package/dist/electron-build/utils/version-info.js +82 -2
  109. package/dist/electron-build/utils/version-info.js.map +1 -1
  110. package/dist/index.js +103 -12
  111. package/dist/ios/chain-explorer.js +617 -0
  112. package/dist/ios/explorer.html +163 -0
  113. package/dist/ios/index.html +96 -0
  114. package/dist/ios/mobile-agent.js +2 -2
  115. package/dist/ios/mobile-contacts.js +329 -0
  116. package/dist/ios/mobile-core.js +1379 -226
  117. package/dist/ios/mobile-privacy.js +185 -0
  118. package/dist/ios/mobile.css +19 -0
  119. package/dist/ios/mobile.html +96 -0
  120. package/dist/ios/mobile.js +706 -7
  121. package/dist/ios/routes-chain-index.js +108 -0
  122. package/dist/ios/routes-contacts.js +420 -0
  123. package/dist/ios/routes-onchain-trade.js +99 -0
  124. package/dist/ios/routes-x402-info.js +1 -0
  125. package/dist/ios/server.js +762 -0
  126. package/dist/ios/task-frame-server.js +211 -0
  127. package/dist/orbitdb/cid-database.js +81 -16
  128. package/dist/orbitdb/ipfs-node.js +31 -2
  129. package/dist/utils/dual-source.js +426 -0
  130. package/dist/utils/update-cli.js +16 -21
  131. package/dist/utils/update-health.js +35 -5
  132. package/dist/utils/update-manager.js +329 -57
  133. package/dist/utils/update-state.js +16 -1
  134. package/dist/utils/version-info.js +75 -1
  135. package/dist/web/mobile-contacts.js +44 -11
  136. package/dist/web/mobile-core.js +1197 -513
  137. package/dist/web/mobile-tasks.js +339 -0
  138. package/dist/web/mobile.css +19 -0
  139. package/dist/web/mobile.html +64 -0
  140. package/dist/web/mobile.js +531 -1
  141. package/dist/web/routes-mobile-tasks.js +353 -0
  142. package/dist/web/server.js +163 -4
  143. package/package.json +3 -1
@@ -103,7 +103,34 @@ function writeEvents(list, h) {
103
103
  /* 统计失败绝不影响主路径 */
104
104
  }
105
105
  }
106
- // ── 匿名化 ──────────────────────────────────────────────────────────────────
106
+ export function tallySignatureAudit(h, opts = {}) {
107
+ const now = opts.now ?? Date.now();
108
+ const windowMs = opts.windowMs ?? PULSE_LIMITS.windowMs;
109
+ const file = path.join(home(h), '.bolloon', 'wallet-signatures.jsonl');
110
+ if (!fs.existsSync(file))
111
+ return { available: false, count: 0, window_ms: windowMs };
112
+ let count = 0;
113
+ try {
114
+ for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
115
+ if (!line.trim())
116
+ continue;
117
+ let row = null;
118
+ try {
119
+ row = JSON.parse(line);
120
+ }
121
+ catch {
122
+ continue;
123
+ } // 坏行跳过, 不让一行坏数据毁掉整个计数
124
+ const at = Number(row?.at);
125
+ if (Number.isFinite(at) && at >= now - windowMs)
126
+ count += 1;
127
+ }
128
+ }
129
+ catch {
130
+ return { available: false, count: 0, window_ms: windowMs };
131
+ }
132
+ return { available: true, count, window_ms: windowMs };
133
+ }
107
134
  /** 节点摘要: sha256(值) 前 16 位。不可逆, 不暴露原值。 */
108
135
  export function nodeDigest(value) {
109
136
  return crypto.createHash('sha256').update(`bolloon-pulse|${String(value)}`).digest('hex').slice(0, 16);
@@ -728,6 +755,8 @@ export function buildChainIdScope(rows) {
728
755
  * (页面不许出现「0 个任务」与「N 行任务」并存而**不解释**)
729
756
  * ⑥ 0x 长 hex 越界 (2026-09-23): 只许出现在 tx_hash/contract/explorer_* 四个白名单键下 ——
730
757
  * EOA 地址跑到别的键 (或白名单键形状不对) 就拒绝导出
758
+ * ⑧ ★ **同一概念不许并排两个数** (2026-09-24, 见 `totalsScopeIssues`): 顶部计数与表格行数/
759
+ * 同源计数矛盾, 或者某个数没有来源却不标「未接入」→ 拒绝导出 (这道门在 UI 侧还有一份镜像)
731
760
  */
732
761
  export function snapshotConsistencyIssues(snap) {
733
762
  const issues = [];
@@ -756,8 +785,137 @@ export function snapshotConsistencyIssues(snap) {
756
785
  }
757
786
  issues.push(...auditPublicHexLeaks(snap)); // ⑥ 0x 长 hex 越界 → 拒绝导出 (不静默放行)
758
787
  issues.push(...openTasksIssues(snap)); // ⑦ 待接单任务的字段白名单与「只含未认领」不变式
788
+ issues.push(...totalsScopeIssues(snap)); // ⑧ ★ 同一概念不得并排两个数 (顶部 vs 表格)
759
789
  return issues;
760
790
  }
791
+ /** 顶部逐字段口径的键 (与 `TotalsFieldKey` 逐字一致; 少一个 = 那个数没有来源说明) */
792
+ export const TOTALS_FIELD_KEYS = [
793
+ 'nodes', 'agents', 'active_agents', 'seen_last_24h',
794
+ 'tasks', 'tasks_completed', 'tasks_verified', 'tasks_settled', 'signatures',
795
+ ];
796
+ const TOTALS_FIELD_SOURCES = ['pulse-events', 'chain-index', 'signature-audit', 'none'];
797
+ const TOTALS_FIELD_WINDOWS = ['window-24h', 'full', 'unknown'];
798
+ /** 「未接入」类措辞 (无源时必须出现, 否则等于没说清为什么没有数) */
799
+ const UNAVAILABLE_WORDS = /未接入|not connected|无可用源|no available source/;
800
+ /**
801
+ * ★ 新不变量门 (2026-09-24 leo:「数量怎么对不上」): **同一概念不许并排两个数**。
802
+ *
803
+ * 检查的是「页面会把它们放在同一屏里」的那几个数 —— 它们的口径必须各自挂得住:
804
+ * ① 逐字段口径 `totals_scope.fields` 九个键必须齐 (source/window/short/label 双语都在) ——
805
+ * 缺了就只能靠 notes 辩解, 而那正是当初「顶部 0 个任务 vs 表格 25 行」能上线的路径;
806
+ * ② 值 `null` ⇔ 口径标 `none` + 明说「未接入」; **有源却不给数** 或 **无源却拿 0 冒充** 都算矛盾;
807
+ * ③ 同一概念对账 (表里有 5 个任务而顶部写 0 任务 → 判红):
808
+ * · 链上索引可用时, 顶部 tasks/tasks_completed/tasks_settled **必须逐字等于** activity_totals
809
+ * 的同名字段 (同源即恒等, 任何解释都救不了);
810
+ * · 表格行数/同源计数非零而顶部为 0 或缺值 → 必须有口径说明 (note 里写清) 才放行;
811
+ * · 顶部说有 N 个任务而表格一行都没有 (反向矛盾) → 同样判红。
812
+ * `status === 'unavailable'` 的整份快照不发布任何计数 (页面整块显示「快照读不到」), 故只查形状。
813
+ */
814
+ export function totalsScopeIssues(snap) {
815
+ const out = [];
816
+ const t = snap?.totals || {};
817
+ const ts = snap?.totals_scope;
818
+ const at = snap?.activity_totals || {};
819
+ const rows = Array.isArray(snap?.confirmed_activity) ? snap.confirmed_activity : [];
820
+ const unavailableSnap = String(snap?.status || '') === 'unavailable';
821
+ if (!ts || typeof ts !== 'object')
822
+ return ['totals_scope 缺失 (顶部计数的口径必须随快照一起给)'];
823
+ if (!ts.fields || typeof ts.fields !== 'object') {
824
+ return ['totals_scope.fields 缺失 (顶部每个数都要能就地说明来自哪 —— 不许只靠 notes 辩解)'];
825
+ }
826
+ // ① 形状: 九个键齐 + 每个键的 source/window/short/label 都合法
827
+ for (const k of TOTALS_FIELD_KEYS) {
828
+ const f = ts.fields[k];
829
+ if (!f || typeof f !== 'object') {
830
+ out.push(`totals_scope.fields.${k} 缺失`);
831
+ continue;
832
+ }
833
+ if (!TOTALS_FIELD_SOURCES.includes(f.source))
834
+ out.push(`totals_scope.fields.${k}.source=${JSON.stringify(f.source)} 不在白名单`);
835
+ if (!TOTALS_FIELD_WINDOWS.includes(f.window))
836
+ out.push(`totals_scope.fields.${k}.window=${JSON.stringify(f.window)} 不在白名单`);
837
+ if (!f.label?.zh || !f.label?.en)
838
+ out.push(`totals_scope.fields.${k}.label 必须双语都给`);
839
+ if (!f.short?.zh || !f.short?.en)
840
+ out.push(`totals_scope.fields.${k}.short 必须双语都给 (页面要就地贴在数字旁)`);
841
+ }
842
+ if (out.length)
843
+ return out; // 形状都不对就不往下算数
844
+ if (!unavailableSnap) {
845
+ // ② 值 ⇔ 口径 (null = 未接入; 有源必须有数; 无源不许给 0)
846
+ for (const k of TOTALS_FIELD_KEYS) {
847
+ const f = ts.fields[k];
848
+ const v = t[k];
849
+ const isNull = v === null || v === undefined;
850
+ if (isNull) {
851
+ if (f.source !== 'none') {
852
+ out.push(`totals.${k} 没有值 (未接入) 却标了源 ${f.source} —— 有源就必须给真值, 没源就标 none`);
853
+ }
854
+ if (!UNAVAILABLE_WORDS.test(`${f.short?.zh}${f.short?.en}${f.label?.zh}${f.label?.en}`)) {
855
+ out.push(`totals.${k} 没有值却没有一句「未接入」说明 (读者会把空白读成 0)`);
856
+ }
857
+ }
858
+ else {
859
+ if (typeof v !== 'number' || !Number.isFinite(v) || v < 0) {
860
+ out.push(`totals.${k}=${JSON.stringify(v)} 不是非负有限数`);
861
+ }
862
+ else if (f.source === 'none') {
863
+ out.push(`totals.${k}=${v} 却标「无源」 —— 不许拿裸 0 冒充「没有接入」(那会被读成"没发生过")`);
864
+ }
865
+ }
866
+ }
867
+ // ③ 同一概念对账 (顶部计数 vs 表格行数/同源计数)
868
+ const notes = Array.isArray(snap.notes) ? snap.notes.join(' ') : '';
869
+ const pairs = [
870
+ ['tasks', t.tasks, at.tasks],
871
+ ['tasks_completed', t.tasks_completed, at.tasks_completed],
872
+ ['tasks_settled', t.tasks_settled, at.tasks_settled],
873
+ ];
874
+ const tableRows = Number(at.rows) || rows.length;
875
+ const sameSourceChain = String(at.source) === 'chain-index';
876
+ // ⑨ 声明与能力不符: 某字段自称「链上索引」口径, 而这一份快照的链上索引根本不可用 ——
877
+ // 那就是替一个不存在的源背书 (页面会写「链上索引·全量」, 而索引这一份没读出来)。
878
+ // 注: 本块必须放在 pairs / sameSourceChain 之后 (先声明后使用, 否则 TDZ 崩)。
879
+ if (!sameSourceChain) {
880
+ for (const [k] of pairs) {
881
+ const f = ts.fields[k];
882
+ if (f.source === 'chain-index') {
883
+ out.push(`totals_scope.fields.${k}.source='chain-index' 而这一份快照的链上索引不可用 ` +
884
+ `(activity_totals.source=${JSON.stringify(at.source)}) —— 声明与能力不符`);
885
+ }
886
+ }
887
+ }
888
+ for (const [k, top, src] of pairs) {
889
+ const n = Number(src) || 0;
890
+ const topN = (top === null || top === undefined) ? null : Number(top);
891
+ const topScope = ts.fields[k];
892
+ if (sameSourceChain && topScope.source === 'chain-index') {
893
+ // 同源: 必须逐字相等, 不需要任何"解释"
894
+ if (topN !== n) {
895
+ out.push(`同一概念两个数: 顶部 totals.${k}=${topN === null ? '未接入' : topN} ≠ 链上索引同源计数 activity_totals.${k}=${n}` +
896
+ ` (表格 ${tableRows} 行) —— 同源即恒等, 页面并排出现两个数就是矛盾`);
897
+ }
898
+ continue;
899
+ }
900
+ if (n > 0 && (topN === null || topN === 0)) {
901
+ const explained = notes.includes('口径') && notes.includes(String(tableRows)) && notes.includes('脉冲事件');
902
+ if (!explained) {
903
+ out.push(`顶部 totals.${k}=${topN === null ? '未接入' : 0} 与表格里的 ${n} 个同类 (${tableRows} 行) 矛盾, 且没有任何口径说明`);
904
+ }
905
+ }
906
+ if (topN !== null && n === 0 && tableRows === 0 && topN > 0) {
907
+ out.push(`顶部 totals.${k}=${topN} 而表格一行都没有 (${tableRows} 行) —— 反向矛盾: 数不出行却报了数`);
908
+ }
909
+ }
910
+ }
911
+ // ④ 明说"数字不一样"时必须有解释 (老规矩保留; 不许只靠一句 label)
912
+ if (ts.differs_from_activity === true) {
913
+ const notes = Array.isArray(snap.notes) ? snap.notes.join(' ') : '';
914
+ if (!notes.includes('口径'))
915
+ out.push('totals_scope.differs_from_activity=true 但 notes 里没有任何口径说明');
916
+ }
917
+ return out;
918
+ }
761
919
  /** open_tasks 行的字段白名单 (与 `OpenTaskRow` 逐字一致; 多一个键 = 可能把正文/身份带出去了) */
762
920
  export const OPEN_TASK_KEYS = ['capability', 'budget', 'currency', 'network', 'deadline', 'claimed', 'announcementId'];
763
921
  /**
@@ -798,26 +956,85 @@ export function openTasksIssues(snap) {
798
956
  });
799
957
  return out;
800
958
  }
959
+ // ── 顶部计数的逐字段口径 (2026-09-24) ────────────────────────────────────────
960
+ // 页面把 8~9 个数字并排放在**一行**里, 一行一个总口径是解释不了它们的 —— 每个数必须自带来源。
961
+ const WINDOW_HOURS = Math.round(PULSE_LIMITS.windowMs / 3600000);
962
+ /** 「本节点 N h 脉冲事件」口径 (节点/智能体类计数, 以及链上索引不可用时的降级口径) */
963
+ function pulseField(what) {
964
+ return {
965
+ source: 'pulse-events',
966
+ window: 'window-24h',
967
+ short: { zh: `${WINDOW_HOURS}h 脉冲`, en: `${WINDOW_HOURS}h pulse` },
968
+ label: {
969
+ zh: `本节点 ${WINDOW_HOURS}h 观察窗口内收到的脉冲事件 · ${what.zh}`,
970
+ en: `pulse events received by this node within the ${WINDOW_HOURS}h window · ${what.en}`,
971
+ },
972
+ };
973
+ }
974
+ /** 「链上索引 · 全量」口径 (与下方活动表**同源**: 同源即恒等, 页面不许并排两个数) */
975
+ function chainField(what) {
976
+ return {
977
+ source: 'chain-index',
978
+ window: 'full',
979
+ short: { zh: '链上索引·全量', en: 'chain index · whole' },
980
+ label: {
981
+ zh: `链上索引全量 (与下方活动表同源) · ${what.zh}`,
982
+ en: `chain index, whole (same source as the activity table below) · ${what.en}`,
983
+ },
984
+ };
985
+ }
986
+ /** 「本机签名审计账」口径 (真源: `<home>/.bolloon/wallet-signatures.jsonl` 窗口内条数) */
987
+ function signatureAuditField() {
988
+ return {
989
+ source: 'signature-audit',
990
+ window: 'window-24h',
991
+ short: { zh: `${WINDOW_HOURS}h 签名审计`, en: `${WINDOW_HOURS}h signature audit` },
992
+ label: {
993
+ zh: `本机签名审计账 (wallet-signatures.jsonl) ${WINDOW_HOURS}h 内条数 —— 只数条数, 不含签名内容`,
994
+ en: `rows in this node's signature audit log (wallet-signatures.jsonl) within ${WINDOW_HOURS}h — count only, no content`,
995
+ },
996
+ };
997
+ }
998
+ /** 「没有可用源」= 未接入 (值必须是 null; 页面必须写「未接入」, 不许拿 0 冒充「没发生过」) */
999
+ function noneField(why) {
1000
+ return {
1001
+ source: 'none',
1002
+ window: 'unknown',
1003
+ short: { zh: '未接入', en: 'not connected' },
1004
+ label: { zh: `没有可用源 → 报「未接入」而不是 0 · ${why.zh}`, en: `no available source → reported as "not connected", not 0 · ${why.en}` },
1005
+ unavailable: true,
1006
+ };
1007
+ }
801
1008
  export function computeSnapshot(events, opts) {
802
1009
  const now = opts.now;
803
1010
  const fresh_until = now + PULSE_LIMITS.snapshotTtlMs;
804
1011
  if (opts.unavailable) {
805
1012
  const emptyRows = [];
806
1013
  const gates = normalizeActivityGates(DEFAULT_CONFIRMATIONS);
1014
+ // 观察层不可用 = **没有数** (不是 0): 计数一律 null/0 占位由页面整块隐藏, 口径逐字段标「未接入」。
1015
+ const downField = noneField({ zh: '观察层不可用', en: 'observation layer unavailable' });
807
1016
  return {
808
1017
  status: 'unavailable',
809
1018
  generated_at: now,
810
1019
  fresh_until,
811
1020
  scope: 'observed',
812
1021
  scope_label: { zh: '当前节点观察到', en: 'Observed by this node' },
813
- totals: { nodes: 0, agents: 0, active_agents: 0, seen_last_24h: 0, tasks: 0, tasks_completed: 0, tasks_verified: 0, signatures: 0 },
1022
+ totals: {
1023
+ nodes: 0, agents: 0, active_agents: 0, seen_last_24h: 0,
1024
+ tasks: null, tasks_completed: null, tasks_verified: null, signatures: null, tasks_settled: null,
1025
+ },
814
1026
  totals_scope: {
815
1027
  source: 'pulse-events', window_ms: PULSE_LIMITS.windowMs,
816
1028
  label: {
817
- zh: `只统计本节点 ${PULSE_LIMITS.windowMs / 3600000}h 观察窗口内收到的脉冲事件 (本节点自己上报的)`,
818
- en: `Only pulse events received by this node within the ${PULSE_LIMITS.windowMs / 3600000}h observation window (reported by this node itself)`,
1029
+ zh: `本节点 ${WINDOW_HOURS}h 观察窗口的脉冲事件层不可用 → 没有数 (不是 0)`,
1030
+ en: `This node's ${WINDOW_HOURS}h pulse layer is unavailable → no counts (not zero)`,
819
1031
  },
820
1032
  differs_from_activity: false,
1033
+ fields: {
1034
+ nodes: downField, agents: downField, active_agents: downField, seen_last_24h: downField,
1035
+ tasks: downField, tasks_completed: downField, tasks_verified: downField,
1036
+ tasks_settled: downField, signatures: downField,
1037
+ },
821
1038
  },
822
1039
  capabilities: [],
823
1040
  recent_activity: [],
@@ -859,10 +1076,13 @@ export function computeSnapshot(events, opts) {
859
1076
  capabilities.push({ key: 'other', count: otherCount.size });
860
1077
  capabilities.sort((a, b) => b.count - a.count || (a.key < b.key ? -1 : 1));
861
1078
  }
862
- // 经济计数: 按**不同任务**去重 (同一任务重复事件不虚增), 且只给聚合数不给内容
1079
+ // 经济计数 (**脉冲口径**) —— 注意: 顶部「任务/已完成/已结算」的**权威源是链上索引**
1080
+ // (见下面 chainAuthoritative 那一段), 脉冲口径只在索引不可用时才顶上去。
1081
+ // 按**不同任务**去重 (同一任务重复事件不虚增), 且只给聚合数不给内容
863
1082
  const tasks = new Set();
864
1083
  const tasksCompleted = new Set();
865
1084
  const tasksVerified = new Set();
1085
+ const tasksSettledPulse = new Set();
866
1086
  for (const e of window) {
867
1087
  const t = e.taskProof;
868
1088
  if (!t)
@@ -871,6 +1091,8 @@ export function computeSnapshot(events, opts) {
871
1091
  tasks.add(t);
872
1092
  if (e.type === 'task_completed' || e.type === 'trade_settled')
873
1093
  tasksCompleted.add(t);
1094
+ if (e.type === 'trade_settled')
1095
+ tasksSettledPulse.add(t);
874
1096
  if (e.type === 'trade_verified')
875
1097
  tasksVerified.add(t);
876
1098
  }
@@ -895,15 +1117,62 @@ export function computeSnapshot(events, opts) {
895
1117
  // 同源计数 + 链归属: **输入就是上表那批行**, 所以不可能与行数/链 id 打架
896
1118
  const activity_totals = summarizeActivityRows(activity.rows, { source: activity.source, gates: activity.gates });
897
1119
  const chain_id_scope = buildChainIdScope(activity.rows);
1120
+ // ★ 2026-09-24 (leo: 「数量怎么对不上, 尤其是后面的任务和钱包」):
1121
+ // 顶部任务类计数的**权威源改成链上索引** —— 它与下面那张表**同源**, 同源即恒等,
1122
+ // 从根上消灭「顶部 0 个任务 / 表格 15 行任务」这种并排矛盾 (不再靠 notes 辩解)。
1123
+ // 链上索引不可用 (降级 pulse-events) 时才退回旧的脉冲口径, 并在 fields 里如实标出降级。
1124
+ const chainAuthoritative = activity.source === 'chain-index';
1125
+ const totalsTasks = chainAuthoritative ? activity_totals.tasks : tasks.size;
1126
+ const totalsCompleted = chainAuthoritative ? activity_totals.tasks_completed : tasksCompleted.size;
1127
+ const totalsSettled = chainAuthoritative ? activity_totals.tasks_settled : tasksSettledPulse.size;
1128
+ // 「验真」在链上索引里**没有**对应事件 → 链上口径下这个数没有源 (null + 未接入), 不拿结算数冒充已验证
1129
+ const totalsVerified = chainAuthoritative ? null : tasksVerified.size;
1130
+ // 钱包签名: 真源 = 本机签名审计账 (窗口内条数, 只计数); 没有审计账 → 退回脉冲事件上报数;
1131
+ // 两个都没有 → null (未接入)。**绝不**在没有源的时候报 0 (那是「没发生过」, 是另一句话)。
1132
+ const sigTally = opts.signatureAudit ?? null;
1133
+ const auditWired = !!sigTally && sigTally.available === true;
1134
+ const totalsSignatures = auditWired
1135
+ ? Number(sigTally.count)
1136
+ : (signatureKeys.size > 0 ? signatureKeys.size : null);
1137
+ const fields = {
1138
+ nodes: pulseField({ zh: '不同节点数', en: 'distinct nodes' }),
1139
+ agents: pulseField({ zh: '不同 Agent 数', en: 'distinct agents' }),
1140
+ active_agents: pulseField({ zh: '活跃 Agent 数', en: 'active agents' }),
1141
+ seen_last_24h: pulseField({ zh: '24h 内出现过的不同 Agent', en: 'distinct agents seen within 24h' }),
1142
+ tasks: chainAuthoritative
1143
+ ? chainField({ zh: '有链上活动的不同任务', en: 'distinct tasks with on-chain activity' })
1144
+ : pulseField({ zh: '观察到发起的任务', en: 'tasks observed as created' }),
1145
+ tasks_completed: chainAuthoritative
1146
+ ? chainField({ zh: '交付完成 (ProofSubmittedV2) 的不同任务', en: 'distinct tasks with ProofSubmittedV2' })
1147
+ : pulseField({ zh: '交付完成的任务', en: 'tasks observed as completed' }),
1148
+ tasks_settled: chainAuthoritative
1149
+ ? chainField({ zh: '托管结算 (Released/Refunded/Expired/Disputed) 的不同任务', en: 'distinct tasks with an on-chain escrow settlement' })
1150
+ : pulseField({ zh: '链上口径结算的任务', en: 'tasks with on-chain settlement' }),
1151
+ tasks_verified: chainAuthoritative
1152
+ ? noneField({ zh: '链上索引没有「验真」事件', en: 'the chain index has no verification event' })
1153
+ : pulseField({ zh: '真验真 (trade_verified) 的任务', en: 'tasks truly verified (trade_verified)' }),
1154
+ signatures: auditWired
1155
+ ? signatureAuditField()
1156
+ : (signatureKeys.size > 0
1157
+ ? pulseField({ zh: '上报过的钱包签名 (按来源+时刻去重)', en: 'reported wallet signatures (deduped by source+time)' })
1158
+ : noneField({ zh: '本节点既没有签名审计账, 也没有签名脉冲事件', en: 'this node has neither a signature audit log nor signature pulse events' })),
1159
+ };
898
1160
  const totals_scope = {
899
- source: 'pulse-events',
1161
+ source: chainAuthoritative ? 'mixed' : 'pulse-events',
900
1162
  window_ms: PULSE_LIMITS.windowMs,
901
1163
  label: {
902
- zh: `只统计本节点 ${PULSE_LIMITS.windowMs / 3600000}h 观察窗口内收到的脉冲事件 (本节点自己上报的)`,
903
- en: `Only pulse events received by this node within the ${PULSE_LIMITS.windowMs / 3600000}h observation window (reported by this node itself)`,
1164
+ zh: chainAuthoritative
1165
+ ? `节点/智能体 = 本节点 ${WINDOW_HOURS}h 脉冲事件; 任务/已完成/已结算 = 链上索引全量 (与下表同源); 钱包签名 = 本机签名审计`
1166
+ : `只统计本节点 ${WINDOW_HOURS}h 观察窗口内收到的脉冲事件 (本节点自己上报的)`,
1167
+ en: chainAuthoritative
1168
+ ? `nodes/agents = this node's ${WINDOW_HOURS}h pulse events; tasks/completed/settled = chain index, whole (same source as the table below); wallet signatures = this node's signature audit log`
1169
+ : `Only pulse events received by this node within the ${WINDOW_HOURS}h observation window (reported by this node itself)`,
904
1170
  },
905
1171
  differs_from_activity: activity_totals.rows > 0 &&
906
- (tasks.size !== activity_totals.tasks || tasksCompleted.size !== activity_totals.tasks_completed),
1172
+ (totalsTasks !== activity_totals.tasks ||
1173
+ totalsCompleted !== activity_totals.tasks_completed ||
1174
+ totalsSettled !== activity_totals.tasks_settled),
1175
+ fields,
907
1176
  };
908
1177
  if (activity.source === 'chain-index') {
909
1178
  notes.push(`confirmed_activity 来自链上索引 (chain-index): ${activity_totals.rows} 行 · ${activity_totals.tasks} 个不同任务 · ` +
@@ -916,10 +1185,21 @@ export function computeSnapshot(events, opts) {
916
1185
  }
917
1186
  // 两套计数口径不同时**必须**解释 —— 页面不许出现「0 个任务」与「N 行任务」并存而不解释
918
1187
  if (totals_scope.differs_from_activity) {
919
- notes.push(`口径不同, 不是数据丢失: totals.tasks/tasks_completed/tasks_verified/signatures 只数本节点 ` +
920
- `${PULSE_LIMITS.windowMs / 3600000}h 窗口内的脉冲事件 (本快照 tasks=${tasks.size} · tasks_completed=${tasksCompleted.size} · ` +
921
- `tasks_verified=${tasksVerified.size} · signatures=${signatureKeys.size}); 上表 ${activity_totals.rows} 行来自链上索引 ` +
922
- `(全量, 不是 ${PULSE_LIMITS.windowMs / 3600000}h 窗口) —— 同源计数见 activity_totals`);
1188
+ notes.push(`口径不同, 不是数据丢失: 链上索引不可用 → 顶部任务类计数**降级为脉冲口径** (本快照 tasks=${tasks.size} · ` +
1189
+ `tasks_completed=${tasksCompleted.size} · tasks_verified=${tasksVerified.size} · tasks_settled=${tasksSettledPulse.size} · ` +
1190
+ `signatures=${signatureKeys.size}); 上表 ${activity_totals.rows} 行来自脉冲事件 (同源计数见 activity_totals, ` +
1191
+ `不是链上索引全量) —— 每个数的来源见 totals_scope.fields`);
1192
+ }
1193
+ if (chainAuthoritative) {
1194
+ notes.push(`顶部「任务/已完成/已结算」= 链上索引的**同源计数** (= activity_totals, 与下表同源, 同源即恒等): ` +
1195
+ `tasks=${totalsTasks} · tasks_completed=${totalsCompleted} · tasks_settled=${totalsSettled}; ` +
1196
+ `「节点/智能体」= 本节点 ${WINDOW_HOURS}h 脉冲事件; 「已验证」在链上索引里没有对应事件 → ` +
1197
+ `该口径无对应事件源, 不下发该字段 (不是 0); 「钱包签名」= ${auditWired
1198
+ ? `本机签名审计账 ${totalsSignatures} 条 (${WINDOW_HOURS}h 窗口, 只计数)`
1199
+ : (totalsSignatures === null
1200
+ ? '无可用源 (本节点既没有签名审计账, 也没有签名脉冲事件), 不下发该字段'
1201
+ : `脉冲事件上报 ${totalsSignatures} 条`)} — ` +
1202
+ `每个数的来源见 totals_scope.fields`);
923
1203
  }
924
1204
  if (activity_totals.rows > 0)
925
1205
  notes.push(chain_id_scope.note.zh);
@@ -936,10 +1216,12 @@ export function computeSnapshot(events, opts) {
936
1216
  agents: agents.size,
937
1217
  active_agents: active.size,
938
1218
  seen_last_24h: agents.size, // 24h 窗口内的不同 Agent
939
- tasks: tasks.size, // 观察到发起的任务数 (聚合, 无内容)
940
- tasks_completed: tasksCompleted.size,
941
- tasks_verified: tasksVerified.size,
942
- signatures: signatureKeys.size, // 真实签名次数 (只计数)
1219
+ // ★ 任务类计数 = 链上索引同源值 (与下表恒等); 索引不可用才退回脉冲口径 (见 fields.tasks)
1220
+ tasks: totalsTasks,
1221
+ tasks_completed: totalsCompleted,
1222
+ tasks_verified: totalsVerified, // 链上口径下 = null (未接入), 不冒充 0
1223
+ signatures: totalsSignatures, // 真源 = 本机签名审计账; 无源 = null (未接入)
1224
+ tasks_settled: totalsSettled, // 新增字段一律排最后 (老 8 个字段顺序逐字不变)
943
1225
  },
944
1226
  totals_scope,
945
1227
  capabilities,
@@ -966,6 +1248,7 @@ export async function getNetworkPulse(opts = {}) {
966
1248
  const shapeOk = Array.isArray(cached?.confirmed_activity)
967
1249
  && typeof cached?.confirmed_activity_source === 'string'
968
1250
  && !!cached?.totals_scope
1251
+ && !!cached?.totals_scope?.fields // 老缓存没有逐字段口径 → 过期形状, 重算
969
1252
  && !!cached?.activity_totals
970
1253
  && !!cached?.chain_id_scope
971
1254
  && Array.isArray(cached?.open_tasks); // 老缓存没有待接单任务 → 过期形状, 重算
@@ -983,8 +1266,11 @@ export async function getNetworkPulse(opts = {}) {
983
1266
  }
984
1267
  // 活动行优先取 P5 链上索引 (真链上事实); 索引不可用 → 退回脉冲事件, 并在快照里标出来源
985
1268
  const confirmedActivity = await resolveConfirmedActivity({ home: opts.home, events, now });
1269
+ // 钱包签名的**真源**: 本机签名审计账 (窗口内条数)。没有账 → 注入 available:false → 快照标「未接入」
1270
+ // (不拿 0 冒充「没发生过」: 本机明明签过而脉冲事件丢了的那次真事故就是这么来的)
1271
+ const signatureAudit = tallySignatureAudit(opts.home, { now, windowMs: PULSE_LIMITS.windowMs });
986
1272
  // 待接单任务: 只读本机公告板目录 (未认领且未过期), 投影成白名单 7 字段 —— 与脉冲事件层无关
987
- const snap = computeSnapshot(events, { now, confirmedActivity, openTasks: readOpenTasks(opts.home, now) });
1273
+ const snap = computeSnapshot(events, { now, confirmedActivity, openTasks: readOpenTasks(opts.home, now), signatureAudit });
988
1274
  try {
989
1275
  fs.mkdirSync(pulseDir(opts.home), { recursive: true });
990
1276
  fs.writeFileSync(snapshotFile(opts.home), JSON.stringify(snap), 'utf8');
@@ -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);