@bolloon/bolloon-agent 0.4.25 → 0.4.27

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 (35) hide show
  1. package/dist/agents/execution-supervisor.js +87 -2
  2. package/dist/agents/external-events.js +162 -0
  3. package/dist/agents/goal-criteria.js +124 -0
  4. package/dist/agents/goal-store.js +79 -4
  5. package/dist/agents/p2p-info.js +175 -0
  6. package/dist/agents/pi-sdk.js +39 -0
  7. package/dist/agents/run-store.js +15 -0
  8. package/dist/agents/skill-readiness.js +133 -0
  9. package/dist/agents/skill-supervisor-link.js +70 -0
  10. package/dist/agents/skills-manager.js +282 -0
  11. package/dist/agents/trace-export.js +125 -0
  12. package/dist/agents/write-staging.js +12 -4
  13. package/dist/agents/x402/goal-run-bridge.js +105 -0
  14. package/dist/agents/x402/milestone-settlement.js +150 -0
  15. package/dist/agents/x402/paid-info-store.js +66 -12
  16. package/dist/agents/x402/payment-recovery.js +290 -0
  17. package/dist/agents/x402/resource-contract.js +473 -0
  18. package/dist/agents/x402/settlement-state.js +378 -0
  19. package/dist/agents/x402/trade.js +257 -0
  20. package/dist/agents/x402/transaction-protocol.js +99 -0
  21. package/dist/agents/x402/transaction-store.js +350 -0
  22. package/dist/cli/setup-wizard.js +96 -127
  23. package/dist/cli-entry.js +74 -0
  24. package/dist/electron/first-run.js +33 -2
  25. package/dist/electron-build/electron/first-run.js +35 -2
  26. package/dist/electron-build/electron/first-run.js.map +1 -1
  27. package/dist/index.js +224 -4
  28. package/dist/llm/config-store.js +35 -4
  29. package/dist/network/agent-network.js +10 -0
  30. package/dist/network/goal-event-bridge.js +57 -0
  31. package/dist/setup/onboard.js +549 -0
  32. package/dist/setup/setup-store.js +592 -0
  33. package/dist/web/routes-x402-info.js +1 -0
  34. package/dist/web/server.js +330 -1
  35. package/package.json +1 -1
@@ -0,0 +1,150 @@
1
+ /**
2
+ * milestone-settlement.ts — 里程碑结算 / 争议 / 责任落地 (Phase 4, 2026-09-18)
3
+ *
4
+ * leo 的 Phase 4:
5
+ * 4.1 **PartiallySettled**: 分阶段服务按里程碑结算 (第一版只支持明确里程碑)
6
+ * 每里程碑: milestoneId / amount / paymentStatus / deliveryStatus / verificationStatus / evidence
7
+ * 规则: 部分成功 → partially_settled; 全部支付且全部交付验证 → verified; 任一交付失败 → disputed 或 delivery_failed
8
+ * ★ `partially_settled` **不许**直接进 Goal 成功证据
9
+ * 4.2 **争议**: disputed / refund_pending / refunded; 争议必须绑定
10
+ * 原始报价 · Payment Header · facilitator response · txHash · 内容哈希 · 签名信封 · Run step · Goal evidence · 失败时点 · 责任候选
11
+ * 三条禁令: 不能自动重付 · 不能标 verified · **不能静默关闭**
12
+ * 4.3 **责任判定**: 机器只给候选 (Phase 0 的 deriveResponsibility), 候选连同证据进交易记录 + Run/Goal 证据
13
+ */
14
+ import { deriveResponsibility } from './settlement-state.js';
15
+ /** 里程碑的不变式: 金额必须是正整数原子单位字符串 (不许浮点/负数/空) */
16
+ export function validateMilestoneSpec(m) {
17
+ if (!m.milestoneId)
18
+ return { ok: false, reason: 'milestoneId 不能为空' };
19
+ if (!/^\d+$/.test(String(m.amount)))
20
+ return { ok: false, reason: `amount 必须是正整数原子单位字符串, 实际: ${m.amount}` };
21
+ if (Number(m.amount) <= 0)
22
+ return { ok: false, reason: 'amount 必须大于 0' };
23
+ return { ok: true };
24
+ }
25
+ export function makeMilestone(m) {
26
+ const chk = validateMilestoneSpec(m);
27
+ if (!chk.ok)
28
+ throw new Error(chk.reason);
29
+ return {
30
+ milestoneId: m.milestoneId, title: m.title, amount: String(m.amount),
31
+ paymentStatus: 'unpaid', deliveryStatus: 'pending', verificationStatus: 'pending',
32
+ evidence: [], updatedAt: new Date().toISOString(),
33
+ };
34
+ }
35
+ /** 里程碑总金额必须等于交易金额 (否则就是账不平) */
36
+ export function milestonesMatchAmount(ms, amount) {
37
+ const sum = ms.reduce((a, m) => a + BigInt(m.amount || '0'), 0n).toString();
38
+ if (amount && String(amount) !== sum)
39
+ return { ok: false, sum, reason: `里程碑金额合计 ${sum} ≠ 交易金额 ${amount}` };
40
+ return { ok: true, sum };
41
+ }
42
+ export function aggregateMilestones(ms) {
43
+ const list = ms || [];
44
+ const paid = list.filter((m) => m.paymentStatus === 'paid').length;
45
+ const delivered = list.filter((m) => m.deliveryStatus === 'delivered').length;
46
+ const verified = list.filter((m) => m.verificationStatus === 'verified').length;
47
+ const failed = list.filter((m) => m.deliveryStatus === 'failed' || m.verificationStatus === 'failed').length;
48
+ const allComplete = list.length > 0 && verified === list.length;
49
+ const anyProgress = paid > 0 || delivered > 0 || verified > 0;
50
+ const partiallyComplete = anyProgress && !allComplete && failed === 0;
51
+ const next = list.find((m) => m.verificationStatus !== 'verified' && m.deliveryStatus !== 'failed');
52
+ let settlementFact = 'unpaid';
53
+ if (allComplete)
54
+ settlementFact = 'fully_settled';
55
+ else if (partiallyComplete)
56
+ settlementFact = 'partially_settled';
57
+ else if (paid > 0 || delivered > 0)
58
+ settlementFact = 'payment_submitted';
59
+ const shouldDispute = failed > 0;
60
+ const reason = shouldDispute
61
+ ? `${failed} 个里程碑交付/验真失败 → 进争议 (不许静默关闭)`
62
+ : allComplete ? '全部里程碑支付+交付+验真完成'
63
+ : partiallyComplete ? `${verified}/${list.length} 个里程碑完成 → partially_settled (不算完成, 也不进 Goal 成功证据)`
64
+ : list.length === 0 ? '没有里程碑 (单笔交易)' : '还没有里程碑完成';
65
+ return { total: list.length, paid, delivered, verified, failed, allComplete, partiallyComplete, nextMilestoneId: next?.milestoneId, settlementFact, shouldDispute, reason };
66
+ }
67
+ /** 应用一个里程碑结果并给出应落的结算事实 (纯函数; 落盘由调用方做, 保证可测) */
68
+ export function applyMilestoneResult(ms, milestoneId, result) {
69
+ const list = (ms || []).map((m) => ({ ...m, evidence: [...(m.evidence || [])] }));
70
+ const target = list.find((m) => m.milestoneId === milestoneId);
71
+ if (!target)
72
+ return { milestones: list, aggregate: aggregateMilestones(list), ok: false, reason: `没有这个里程碑: ${milestoneId}` };
73
+ if (result.paymentStatus)
74
+ target.paymentStatus = result.paymentStatus;
75
+ if (result.deliveryStatus)
76
+ target.deliveryStatus = result.deliveryStatus;
77
+ if (result.verificationStatus)
78
+ target.verificationStatus = result.verificationStatus;
79
+ if (result.evidence?.length)
80
+ target.evidence.push(...result.evidence);
81
+ target.updatedAt = new Date().toISOString();
82
+ return { milestones: list, aggregate: aggregateMilestones(list), ok: true };
83
+ }
84
+ const REQUIRED_EVIDENCE_FIELDS = [
85
+ 'quote', 'txHash', 'contentHash', 'envelopeDigest', 'failurePoint',
86
+ ];
87
+ /** 开争议: 记录绑定的证据 + 缺口; 生命周期进 disputed (自动化到此为止) */
88
+ export function buildDispute(opts) {
89
+ const missing = REQUIRED_EVIDENCE_FIELDS.filter((f) => {
90
+ const v = opts.evidence[f];
91
+ if (v === undefined || v === null)
92
+ return true;
93
+ if (typeof v === 'string')
94
+ return v.trim().length === 0;
95
+ if (Array.isArray(v))
96
+ return v.length === 0;
97
+ if (typeof v === 'object')
98
+ return Object.values(v).every((x) => x === undefined || x === null || x === '');
99
+ return false;
100
+ });
101
+ const responsibility = opts.evidence.responsibility
102
+ || (opts.responsibilityEvidence ? deriveResponsibility(opts.responsibilityEvidence) : undefined);
103
+ return {
104
+ openedAt: new Date().toISOString(),
105
+ reason: opts.reason,
106
+ evidence: { ...opts.evidence, responsibility },
107
+ missingEvidence: missing,
108
+ mustNotRepay: true,
109
+ };
110
+ }
111
+ /** 争议禁令的唯一实现在 settlement-state (`disputeForbids`), 这里只做转发, 避免两份判断漂移 */
112
+ export { disputeForbids } from './settlement-state.js';
113
+ /** 争议收尾: 必须带决定 + 依据 + 证据 (退款走结算层 refund_pending → refunded) */
114
+ export function resolveDispute(rec, opts) {
115
+ if (!rec.dispute)
116
+ throw new Error('这笔交易没有争议记录');
117
+ if (!opts.evidence?.length)
118
+ throw new Error('争议收尾必须带证据 (不许无凭据关闭)');
119
+ return {
120
+ ...rec.dispute,
121
+ resolution: { decision: opts.decision, at: new Date().toISOString(), by: opts.by, reason: opts.reason, evidence: [...opts.evidence] },
122
+ };
123
+ }
124
+ /**
125
+ * 里程碑/争议叠加后的 Goal 成功证据门槛:
126
+ * 交易 verified 或 (全部里程碑完成 + 结算 fully_settled) ∧ 无争议 ∧ 执行成功 ∧ 命中判据
127
+ * —— `partially_settled` 一律不算 (leo: 不要让 partially_settled 直接进 Goal 成功证据)
128
+ */
129
+ export function milestoneGoalEligibility(rec, opts = {}) {
130
+ if (rec.dispute && !rec.dispute.resolution)
131
+ return { eligible: false, reason: '交易在争议中 (未收尾) → 不计入 Goal 成功证据' };
132
+ const agg = aggregateMilestones(rec.milestones);
133
+ if (agg.total > 0) {
134
+ if (agg.shouldDispute)
135
+ return { eligible: false, reason: '里程碑有失败项 → 需争议处理, 不计入成功证据' };
136
+ if (!agg.allComplete)
137
+ return { eligible: false, reason: `里程碑未全部完成 (${agg.verified}/${agg.total}) → partially_settled 不计入成功证据` };
138
+ }
139
+ if (rec.settlementFact === 'partially_settled')
140
+ return { eligible: false, reason: '结算事实是 partially_settled → 不计入成功证据' };
141
+ if (rec.status !== 'verified')
142
+ return { eligible: false, reason: `交易状态是 ${rec.status}, 不是 verified` };
143
+ if (rec.chainSettled !== true)
144
+ return { eligible: false, reason: '链上没有真实结算' };
145
+ if (opts.executionOk !== true)
146
+ return { eligible: false, reason: '资源执行没成功 (买到 ≠ 用上)' };
147
+ if (opts.goalCriteriaHit !== true)
148
+ return { eligible: false, reason: '没有命中 Goal 判据' };
149
+ return { eligible: true, reason: '链上结算 + 全部里程碑完成 + 无争议 + 执行成功 + 命中判据' };
150
+ }
@@ -166,12 +166,23 @@ export async function checkAndSettlePayment(opts) {
166
166
  const req = opts.requirements.accepts[0];
167
167
  const network = String(req.network);
168
168
  if (!opts.paymentHeader)
169
- return { ok: false, mode: 'none', error: '缺少 X-PAYMENT 头 (未付款)' };
169
+ return { ok: false, mode: 'none', error: '缺少 X-PAYMENT 头 (未付款)', attempted: false };
170
170
  const payload = decodePaymentHeader(opts.paymentHeader);
171
171
  if (!payload)
172
- return { ok: false, mode: 'none', error: 'X-PAYMENT 不是合法 base64 JSON' };
172
+ return { ok: false, mode: 'none', error: 'X-PAYMENT 不是合法 base64 JSON', attempted: false };
173
173
  const facilitatorUrl = opts.facilitatorUrl ?? process.env.BOLLOON_X402_FACILITATOR ?? '';
174
174
  const allowLocalDev = opts.allowLocalDev ?? (process.env.BOLLOON_X402_LOCAL_VERIFY === '1');
175
+ // ★ 凭据绑定校验必须在**分模式之前** (两种模式都查): 拿旧回执去换另一条资源 → 一律拒绝。
176
+ // 真跑抓到过: 这段原来只对 local-dev 生效, facilitator 模式提前 return → 跨资源复用没被拦。
177
+ const boundItem = payload?.accepted?.extra?.itemId || payload?.accepted?.itemId || payload?.itemId;
178
+ if (opts.expectedItemId) {
179
+ if (!boundItem) {
180
+ return { ok: false, mode: 'none', attempted: false, error: '支付凭据没有绑定 itemId: 无法证明这笔钱是为这条资源付的' };
181
+ }
182
+ if (String(boundItem) !== String(opts.expectedItemId)) {
183
+ return { ok: false, mode: 'none', attempted: false, error: `支付凭据绑定的资源 (${boundItem}) 与本次请求 (${opts.expectedItemId}) 不一致 — 回执不能跨资源复用` };
184
+ }
185
+ }
175
186
  if (facilitatorUrl) {
176
187
  const f = opts.fetchImpl ?? fetch;
177
188
  const body = { x402Version: 2, paymentPayload: payload, paymentRequirements: req };
@@ -181,26 +192,28 @@ export async function checkAndSettlePayment(opts) {
181
192
  });
182
193
  const v = await vres.json();
183
194
  if (!v?.isValid) {
184
- return { ok: false, mode: 'facilitator', error: `facilitator 校验未通过: ${v?.invalidReason || v?.invalidMessage || 'unknown'}` };
195
+ return { ok: false, mode: 'facilitator', attempted: true, verifyRejected: true, settlementUncertain: false, error: `facilitator 校验未通过: ${v?.invalidReason || v?.invalidMessage || 'unknown'}` };
185
196
  }
186
197
  const sres = await f(`${facilitatorUrl.replace(/\/$/, '')}/settle`, {
187
198
  method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
188
199
  });
189
200
  const s = await sres.json();
190
201
  if (!s?.success) {
191
- return { ok: false, mode: 'facilitator', error: `结算失败: ${s?.errorReason || s?.errorMessage || 'unknown'}` };
202
+ // settle 失败: 链上可能已经动了钱 不确定 (不是"没付过")
203
+ return { ok: false, mode: 'facilitator', attempted: true, verifyRejected: false, settlementUncertain: true, error: `结算失败: ${s?.errorReason || s?.errorMessage || 'unknown'}` };
192
204
  }
193
205
  const receipt = encodePaymentResponse(s);
194
- return { ok: true, mode: 'facilitator', receipt, txHash: s.transaction, payer: s.payer || v.payer, network };
206
+ return { ok: true, mode: 'facilitator', receipt, txHash: s.transaction, payer: s.payer || v.payer, network, attempted: true };
195
207
  }
196
208
  catch (e) {
197
- return { ok: false, mode: 'facilitator', error: `facilitator 不可达: ${String(e?.message || e).slice(0, 160)}` };
209
+ return { ok: false, mode: 'facilitator', attempted: true, verifyRejected: false, settlementUncertain: true, error: `facilitator 不可达: ${String(e?.message || e).slice(0, 160)}` };
198
210
  }
199
211
  }
200
212
  if (!allowLocalDev) {
201
213
  return {
202
214
  ok: false,
203
215
  mode: 'none',
216
+ attempted: false,
204
217
  error: '未配置 facilitator (BOLLOON_X402_FACILITATOR), 也未开启本机联调模式 (BOLLOON_X402_LOCAL_VERIFY=1) — 无法校验真实付款',
205
218
  };
206
219
  }
@@ -227,9 +240,14 @@ export async function checkAndSettlePayment(opts) {
227
240
  * 购买一条信息: 未付款时服务端回 402, 这里用标准 x402 客户端 (402→签名→重试) 完成支付。
228
241
  * 无钱包私钥时, 只有显式 allowLocalDev 才走本机联调头。
229
242
  */
243
+ function makeTracker(onEvent) {
244
+ return async (e) => { if (onEvent)
245
+ await onEvent(e).catch(() => null); };
246
+ }
230
247
  export async function buyInfo(params) {
231
248
  const { verifyEnvelope } = await import('./paid-info-protocol.js');
232
249
  const doFetch = params.fetchImpl ?? fetch;
250
+ const trackEvent = makeTracker(params.onEvent);
233
251
  // ① 先探一次: 判断是否 402 (以及免费信息直接返回)
234
252
  let res;
235
253
  try {
@@ -248,11 +266,22 @@ export async function buyInfo(params) {
248
266
  }
249
267
  return { ok: false, status: res.status, error: `服务端返回 ${res.status}: ${text.slice(0, 200)}` };
250
268
  }
251
- // ② 402 → 支付 → 重试
269
+ // ② 402 → (策略门) → 支付 → 重试
252
270
  const requirementBody = safeJson(await res.text());
253
271
  const requirements = requirementBody?.accepts?.[0];
254
272
  if (!requirements)
255
273
  return { ok: false, status: 402, error: '402 响应缺少 accepts' };
274
+ const metadata = requirementBody?.metadata || requirementBody?.item || null;
275
+ await trackEvent({ kind: 'payment_required', detail: `amount=${requirements.amount} network=${requirements.network}` });
276
+ // Phase 2: 策略门 —— 必须在解密钱包/签名/付款之前 (顺序不可颠倒)
277
+ if (params.prePayGuard) {
278
+ const gate = await params.prePayGuard({ requirements, url: params.url });
279
+ if (!gate.ok) {
280
+ await trackEvent({ kind: 'policy_denied', detail: gate.reason });
281
+ return { ok: false, status: 402, policyDenied: true, error: gate.reason || '策略拒绝', metadata, raw: JSON.stringify(requirementBody) };
282
+ }
283
+ await trackEvent({ kind: 'policy_allowed', detail: '策略通过, 允许进入付款' });
284
+ }
256
285
  let paymentHeader = '';
257
286
  let mode = '';
258
287
  if (params.privateKey) {
@@ -264,16 +293,32 @@ export async function buyInfo(params) {
264
293
  maxPaymentAmount: params.maxPaymentAmount,
265
294
  rpcUrl: params.rpcUrl,
266
295
  });
296
+ // ★ 真把付款凭据发出去了 → 结算事实 payment_submitted (这一步之后失败都不能当"没付过钱")
297
+ await trackEvent({ kind: 'payment_sending', detail: 'facilitator 模式: 已发出 x402 付款请求', patch: { settlementFact: 'payment_submitted' } });
267
298
  const retry = await paymentFetch(params.url, { method: 'GET' });
268
299
  const text = await retry.text();
269
300
  const parsed = safeJson(text);
270
301
  const receipt = retry.headers.get('x-payment-response') || parsed?.payment?.receipt || '';
271
302
  if (retry.status < 200 || retry.status >= 300) {
272
- return { ok: false, status: retry.status, error: `付款后重试失败 ${retry.status}: ${text.slice(0, 200)}` };
303
+ // 付款这一步已经发出去了 (可能已上链), 只是拿资源失败 绝不许当"没付过钱"
304
+ return {
305
+ ok: false, status: retry.status,
306
+ payment: { mode: 'facilitator', receipt: receipt || undefined, attempted: true, settled: true, settlementUncertain: !receipt },
307
+ error: `付款后重试失败 ${retry.status}: ${text.slice(0, 200)}`,
308
+ };
273
309
  }
274
310
  mode = 'facilitator';
311
+ // ★ 链上事实需要 txHash: facilitator 说成功但没有 txHash → 不能标 chainSettled (真跑抓到过这类"假结算")
312
+ const txHash = parsed?.payment?.txHash || retry.headers.get('x-payment-txhash') || parsed?.txHash || '';
313
+ await trackEvent({
314
+ kind: 'settled',
315
+ detail: `mode=facilitator receipt=${receipt.slice(0, 24)}… txHash=${txHash ? `${String(txHash).slice(0, 16)}…` : '(缺失: 不能认定链上结算完成)'}`,
316
+ patch: { paymentMode: 'facilitator', paymentReceipt: receipt, chainSettled: !!txHash, ...(txHash ? { txHash: String(txHash) } : {}) },
317
+ });
275
318
  const report = parsed?.proof ? await verifyEnvelope(parsed, { resolveDid: params.resolveDid, expectItemId: params.expectItemId }) : undefined;
276
- return { ok: true, status: retry.status, envelope: parsed, verify: report, payment: { mode, receipt }, raw: text };
319
+ if (report)
320
+ await trackEvent({ kind: 'delivered', detail: `trust=${report.trust}`, patch: { verificationTrust: report.trust, contentHash: parsed?.contentHash, protocolVerified: report.trust === 'verified' } });
321
+ return { ok: true, status: retry.status, envelope: parsed, verify: report, payment: { mode, receipt }, metadata, raw: text };
277
322
  }
278
323
  if (!params.allowLocalDev) {
279
324
  return { ok: false, status: 402, error: '需要钱包私钥才能支付 (未提供 privateKey; 本机联调请显式 allowLocalDev)' };
@@ -284,17 +329,26 @@ export async function buyInfo(params) {
284
329
  payload: { localDev: true, at: new Date().toISOString() },
285
330
  payer: 'local-dev',
286
331
  }), 'utf-8').toString('base64');
332
+ await trackEvent({ kind: 'payment_sending', detail: 'local-dev 模式: 已发出 X-PAYMENT 请求 (非链上)', patch: { settlementFact: 'payment_submitted' } });
287
333
  const retry = await doFetch(params.url, { method: 'GET', headers: { 'X-PAYMENT': paymentHeader } });
288
334
  const text = await retry.text();
289
335
  const parsed = safeJson(text);
290
336
  if (retry.status < 200 || retry.status >= 300) {
291
- return { ok: false, status: retry.status, error: `本机联调付款被拒 ${retry.status}: ${text.slice(0, 200)}` };
337
+ return {
338
+ ok: false, status: retry.status,
339
+ payment: { mode: 'local-dev', attempted: true, settled: false, settlementUncertain: false },
340
+ error: `本机联调付款被拒 ${retry.status}: ${text.slice(0, 200)}`,
341
+ };
292
342
  }
293
343
  mode = 'local-dev';
344
+ const receiptLd = retry.headers.get('x-payment-response') || parsed?.payment?.receipt || '';
345
+ await trackEvent({ kind: 'settled', detail: 'mode=local-dev (非链上)', patch: { paymentMode: 'local-dev', paymentReceipt: receiptLd, chainSettled: false } });
294
346
  const report = parsed?.proof ? await verifyEnvelope(parsed, { resolveDid: params.resolveDid, expectItemId: params.expectItemId }) : undefined;
347
+ if (report)
348
+ await trackEvent({ kind: 'delivered', detail: `trust=${report.trust} (本机联调)`, patch: { verificationTrust: report.trust, contentHash: parsed?.contentHash, protocolVerified: report.trust !== 'unverified' } });
295
349
  return {
296
- ok: true, status: retry.status, envelope: parsed, verify: report,
297
- payment: { mode, receipt: retry.headers.get('x-payment-response') || parsed?.payment?.receipt }, raw: text,
350
+ ok: true, status: retry.status, envelope: parsed, verify: report, metadata,
351
+ payment: { mode, receipt: receiptLd, attempted: true }, raw: text,
298
352
  };
299
353
  }
300
354
  function safeJson(text) {
@@ -0,0 +1,290 @@
1
+ /**
2
+ * payment-recovery.ts — 支付中断恢复 (Phase 3, 2026-09-18)
3
+ *
4
+ * leo 的硬规则, 决定了这一层的全部逻辑:
5
+ * `payment uncertain ≠ payment failed` —— 不确定时不许当失败处理
6
+ * `payment failed ≠ safe to retry` —— 失败也不等于可以重付
7
+ * 先 reconcile, 再决定 retry
8
+ *
9
+ * 五个 SIGKILL 时点 (恢复后必须各自走对路):
10
+ * ① 付款前 → 状态仍是 quoted/payment_required, 可安全付款, 不重复付
11
+ * ② 拿到付款权后 → 旧 claim 因进程死亡可回收, 新 worker 接管, 不会两个 worker 同时付
12
+ * ③ facilitator settle 后→ 重启用**真实 txHash/结算事实**续上, 禁止重付, 继续交付与验真
13
+ * ④ 支付成功、交付前 → 只执行 delivery; 正文缺失 → delivery_failed, 不重付
14
+ * ⑤ 交付后、验真前 → 只执行 verification; 成功 → verified, 失败 → verification_failed, 不重付
15
+ *
16
+ * 两个附加场景:
17
+ * · 支付状态未知 → 先进 unknown, 先对账, **不自动重付**
18
+ * · facilitator 返回成功但**没有 txHash** → 不能认定链上结算完成 (链上事实需要 txHash)
19
+ */
20
+ import { deriveSettlementFact, hasPaymentEvidence, isSettlementFact } from './settlement-state.js';
21
+ /** 只有这些状态才允许自动重试付款 */
22
+ const RETRYABLE_STATUSES = ['quoted', 'payment_required'];
23
+ /**
24
+ * 纯函数: 从交易记录的两层状态推出"下一步该干什么"。
25
+ * 这是整个恢复逻辑的唯一决策点 —— Supervisor / CLI / 恢复脚本都走它, 不许各自 if-else。
26
+ */
27
+ export function planTransactionRecovery(rec, opts = {}) {
28
+ const fact = isSettlementFact(rec.settlementFact) ? rec.settlementFact : deriveSettlementFact(rec);
29
+ const status = String(rec.status);
30
+ const evidence = hasPaymentEvidence({ ...rec, settlementFact: fact });
31
+ const base = { transactionId: rec.transactionId, settlementFact: fact };
32
+ // 终态: 不再动手 (钱的事已经定了)
33
+ if (['verified', 'policy_denied', 'failed'].includes(status)) {
34
+ return { ...base, action: status === 'verified' ? 'complete' : 'closed', mustNotRepay: evidence, needsResponsibility: false, reason: `终态 ${status}, 不再变化` };
35
+ }
36
+ if (status === 'delivery_failed' || status === 'verification_failed') {
37
+ return {
38
+ ...base, action: 'closed', mustNotRepay: true, needsResponsibility: true,
39
+ reason: `${status}: 钱 ${fact === 'unpaid' ? '可能没付' : '已付/待确认'} 但交付/验真失败 → 不自动重付, 进追责/人工处理`,
40
+ };
41
+ }
42
+ // 有别的 worker 拿着付款权 → 等 (同一个 requestId 只能有一个付款者)
43
+ if (opts.claimHeldByOther) {
44
+ return { ...base, action: 'wait', mustNotRepay: true, needsResponsibility: false, reason: '付款权被另一个进程持有 → 复用同一交易, 等它走完' };
45
+ }
46
+ // 支付状态未知 / 付款中但没有证据 → 先对账
47
+ if (fact === 'unknown' || (status === 'paying' && !evidence)) {
48
+ if (!evidence && status === 'paying') {
49
+ // 付款中没有**任何**支付凭据 → 判定"没真发出去", 允许安全重试 (由 reconcile 落结论)
50
+ return { ...base, action: 'reconcile', mustNotRepay: false, needsResponsibility: false, reason: '付款中但没有任何支付凭据 → 先对账确认(可安全重试)' };
51
+ }
52
+ return { ...base, action: 'reconcile', mustNotRepay: true, needsResponsibility: false, reason: `结算事实 ${fact} → 先对账, 绝不自动重付` };
53
+ }
54
+ // 明确没付过 + 还没走完 → 可以安全付款
55
+ if (!evidence && RETRYABLE_STATUSES.includes(status)) {
56
+ return { ...base, action: 'retry_payment', mustNotRepay: false, needsResponsibility: false, reason: `状态 ${status} 且结算事实 unpaid (确认没付过) → 可安全付款` };
57
+ }
58
+ // 有支付证据 → 继续推进交付/验真, 绝不重付
59
+ if (evidence) {
60
+ if (status === 'delivered') {
61
+ return { ...base, action: 'verify', mustNotRepay: true, needsResponsibility: false, reason: '已交付 → 继续验真' };
62
+ }
63
+ if (['paying', 'settled', 'payment_required'].includes(status)) {
64
+ return { ...base, action: 'deliver', mustNotRepay: true, needsResponsibility: false, reason: `结算事实 ${fact} → 付款事实在手, 继续交付 (不重付)` };
65
+ }
66
+ }
67
+ return { ...base, action: 'closed', mustNotRepay: evidence, needsResponsibility: false, reason: `状态 ${status} + 结算事实 ${fact}: 没有可自动推进的动作 → 交人` };
68
+ }
69
+ /**
70
+ * 按计划执行一步恢复。**幂等**: 任何一步重复执行都不会造成第二次付款
71
+ * (付款只有 plan.action === 'retry_payment' 时才可能发生)。
72
+ */
73
+ export async function runTransactionRecovery(rec, deps) {
74
+ const plan = planTransactionRecovery(rec);
75
+ const steps = [];
76
+ let paid = false;
77
+ if (plan.action === 'retry_payment') {
78
+ const res = await deps.pay(rec);
79
+ paid = true;
80
+ if (res.ok) {
81
+ await deps.persist(rec.transactionId, { settlementFact: 'payment_submitted', paymentReceipt: res.receipt, ...(res.txHash ? { txHash: res.txHash } : {}) }, {
82
+ kind: 'recovery_paid', detail: `恢复后重新付款 (对账结论: 没付过) receipt=${String(res.receipt || '').slice(0, 20)}…`,
83
+ });
84
+ steps.push('retry_payment:已付');
85
+ }
86
+ else {
87
+ await deps.persist(rec.transactionId, { failureReason: res.error }, { kind: 'recovery_pay_failed', detail: String(res.error || '').slice(0, 120) });
88
+ steps.push('retry_payment:失败');
89
+ }
90
+ return { action: plan.action, plan, paid, steps };
91
+ }
92
+ if (plan.action === 'reconcile') {
93
+ const r = await deps.reconcile(rec);
94
+ const patch = { settlementFact: r.fact };
95
+ if (r.txHash) {
96
+ patch.txHash = r.txHash;
97
+ patch.chainSettled = true;
98
+ }
99
+ // 对账确认"没付过"时, 状态也要从 paying 退回 payment_required —— 否则下一步永远还是"先对账", 推不动
100
+ const backToRetry = r.fact === 'unpaid' && String(rec.status) === 'paying';
101
+ if (backToRetry)
102
+ patch.status = 'payment_required';
103
+ await deps.persist(rec.transactionId, patch, { kind: 'recovery_reconciled', detail: r.note || `对账结论: ${r.fact}${r.txHash ? ` (txHash=${r.txHash.slice(0, 12)}…)` : ''}${backToRetry ? ' → 状态退回 payment_required (可安全重试)' : ''}` });
104
+ steps.push(`reconcile:${r.fact}`);
105
+ // 对账结论若是"明确没付过" → 下一步可以安全付款; 若是链上事实 → 继续交付
106
+ // 本地视图必须与刚落盘的一致 (少带字段会让下一步判断回到旧状态 —— 真跑抓到过)
107
+ const after = { ...rec, ...patch };
108
+ const nextPlan = planTransactionRecovery(after);
109
+ if (nextPlan.action === 'deliver') {
110
+ const d = await deps.deliver(after);
111
+ steps.push(`deliver:${d.ok ? 'ok' : 'fail'}`);
112
+ if (!d.ok) {
113
+ await deps.persist(rec.transactionId, {}, { kind: 'recovery_delivery_failed', detail: String(d.reason || '').slice(0, 120) });
114
+ }
115
+ else {
116
+ const persisted = (deps.read ? await deps.read(rec.transactionId) : null) ?? after;
117
+ const v = await deps.verify(persisted);
118
+ steps.push(`verify:${v.ok ? 'ok' : 'fail'}`);
119
+ }
120
+ }
121
+ else if (nextPlan.action === 'retry_payment' && r.fact === 'unpaid') {
122
+ const pay = await deps.pay(after);
123
+ paid = true;
124
+ if (pay.ok)
125
+ await deps.persist(rec.transactionId, { settlementFact: 'payment_submitted', paymentReceipt: pay.receipt }, { kind: 'recovery_paid_after_reconcile', detail: '对账确认没付过 → 重新付款' });
126
+ steps.push(`retry_after_reconcile:${pay.ok ? '已付' : '失败'}`);
127
+ }
128
+ return { action: plan.action, plan, paid, steps };
129
+ }
130
+ if (plan.action === 'deliver') {
131
+ // ★ 先对账: 结算事实还停在 payment_submitted/unknown 时, 不许直接往下走 (要拿到 txHash 或确认无链上事实)
132
+ if (!['payment_verified', 'partially_settled', 'fully_settled'].includes(plan.settlementFact)) {
133
+ const r = await deps.reconcile(rec);
134
+ const patch = { settlementFact: r.fact };
135
+ if (r.txHash) {
136
+ patch.txHash = r.txHash;
137
+ patch.chainSettled = true;
138
+ }
139
+ await deps.persist(rec.transactionId, patch, { kind: 'recovery_reconciled', detail: `交付前对账: ${r.note || r.fact}${r.txHash ? ` (txHash=${r.txHash.slice(0, 12)}…)` : ' (无 txHash: 不能认定链上结算)'}` });
140
+ steps.push(`reconcile:${r.fact}`);
141
+ rec = { ...rec, ...patch };
142
+ const recheck = planTransactionRecovery(rec);
143
+ if (recheck.mustNotRepay)
144
+ plan.mustNotRepay = true;
145
+ }
146
+ const d = await deps.deliver(rec);
147
+ steps.push(`deliver:${d.ok ? 'ok' : 'fail'}`);
148
+ if (d.ok) {
149
+ const persisted = (deps.read ? await deps.read(rec.transactionId) : null) ?? { ...rec, status: 'delivered' };
150
+ const v = await deps.verify(persisted);
151
+ steps.push(`verify:${v.ok ? 'ok' : 'fail'}`);
152
+ }
153
+ else {
154
+ await deps.persist(rec.transactionId, {}, { kind: 'recovery_delivery_failed', detail: String(d.reason || '').slice(0, 120) });
155
+ }
156
+ return { action: plan.action, plan, paid, steps };
157
+ }
158
+ if (plan.action === 'verify') {
159
+ const v = await deps.verify(rec);
160
+ steps.push(`verify:${v.ok ? 'ok' : 'fail'}`);
161
+ return { action: plan.action, plan, paid, steps };
162
+ }
163
+ // complete / closed / wait: 什么都不做 (尤其是绝不付款)
164
+ steps.push(`${plan.action}:noop`);
165
+ return { action: plan.action, plan, paid, steps };
166
+ }
167
+ /** 对账结论是否可以升级为链上事实 (必须有 txHash —— facilitator 说成功不算) */
168
+ export function reconciliationIsChainBacked(r) {
169
+ if (!r.txHash)
170
+ return false;
171
+ return ['payment_verified', 'partially_settled', 'fully_settled'].includes(r.fact);
172
+ }
173
+ /**
174
+ * 供 Supervisor 的 tick 调用: 扫描未完结交易, **只做对账** (安全、无副作用),
175
+ * 把结论钉在结算事实上, 并把"该继续做的事"交回**对应的 Goal** (挂在 `rec.goalId` 上的长期目标)。
176
+ *
177
+ * 为什么这里**不付款**: Supervisor 不持有钱包/私钥。让它替人花钱是把"恢复"变成"自己决定花第二笔钱",
178
+ * 违反 `payment failed ≠ safe to retry` 的边界 —— 付款必须由能对账、能签名的那一方显式执行。
179
+ *
180
+ * 2026-09-18 补: `awaitingPayment` 不再只是"列出来", 而是**唤醒对应的 Goal** (写 continuation.nextAction +
181
+ * 立即可跑), 由 Goal 的执行器 (持有钱包/上下文的那一方) 去走幂等付款路径; 有争议或必须人等的, 一律不唤醒。
182
+ */
183
+ export async function reconcileInterruptedPayments(opts) {
184
+ const report = { scanned: 0, reconciled: [], awaitingPayment: [], mustNotRepay: [], closed: [], errors: [], goalsWoken: [], goalsFlagged: [] };
185
+ const { pendingTransactions, listTransactions } = await import('./transaction-store.js');
186
+ let all = [];
187
+ try {
188
+ const pending = await pendingTransactions(opts.home);
189
+ // 中途被杀的交易可能停在**任何非终态**: discovered(刚建) / quoted(拿到报价还没付) / delivered(该验真)…
190
+ // 只扫 pending 会漏掉场景 ①(付款前被杀) 与 ⑤ 之后的推进 (真跑抓到过: quoted 的交易根本没被扫到)
191
+ const IN_FLIGHT = ['discovered', 'quoted', 'payment_required', 'paying', 'settled', 'delivered', 'disputed'];
192
+ const inFlight = (await listTransactions(opts.home)).filter((t) => IN_FLIGHT.includes(String(t.status)) || t.settlementFact === 'unknown');
193
+ const seen = new Set();
194
+ all = [...pending, ...inFlight].filter((t) => { if (seen.has(t.transactionId))
195
+ return false; seen.add(t.transactionId); return true; });
196
+ }
197
+ catch (err) {
198
+ report.errors.push(`读取未完结交易失败: ${String(err?.message || err).slice(0, 120)}`);
199
+ return report;
200
+ }
201
+ const wakeGoal = opts.wakeGoal || (async (goalId, nextAction, why) => {
202
+ const { setContinuation } = await import('../goal-store.js');
203
+ await setContinuation(goalId, { nextAction, wakeReason: 'active', autoContinue: true, wakeAt: undefined, needsExternal: undefined });
204
+ return why;
205
+ });
206
+ const humanNeeded = opts.needsHuman || (async (goalId, why) => {
207
+ const { setContinuation } = await import('../goal-store.js');
208
+ await setContinuation(goalId, { nextAction: undefined, wakeReason: 'needs_human', autoContinue: false, needsExternal: why });
209
+ return why;
210
+ });
211
+ /** 有 Goal 且允许自动继续 → 唤醒它; 否则 (无 Goal / 已终态) 只报告 */
212
+ const wakeOrFlag = async (before, after, plan) => {
213
+ const goalId = before.goalId || after.goalId;
214
+ if (!goalId)
215
+ return;
216
+ if (after.dispute && !after.dispute.resolution) {
217
+ report.goalsFlagged.push({ goalId, why: `交易 ${before.transactionId} 在争议中 → 交人` });
218
+ try {
219
+ await humanNeeded(goalId, `交易争议待处理: ${before.transactionId}`);
220
+ }
221
+ catch (e) {
222
+ report.errors.push(`唤醒 Goal 失败: ${String(e?.message || e).slice(0, 100)}`);
223
+ }
224
+ return;
225
+ }
226
+ const nextAction = plan.action === 'retry_payment'
227
+ ? `x402_payment_retry:${before.transactionId} (对账确认可安全付款, 走同一 requestId 的幂等路径)`
228
+ : `x402_continue:${before.transactionId} (${plan.action}: ${plan.reason})`;
229
+ try {
230
+ await wakeGoal(goalId, nextAction, plan.reason);
231
+ report.goalsWoken.push({ goalId, transactionId: before.transactionId, action: plan.action, nextAction });
232
+ }
233
+ catch (e) {
234
+ report.errors.push(`唤醒 Goal ${goalId} 失败: ${String(e?.message || e).slice(0, 100)}`);
235
+ }
236
+ };
237
+ for (const rec of all.slice(0, opts.limit ?? 50)) {
238
+ report.scanned++;
239
+ const plan = planTransactionRecovery(rec);
240
+ try {
241
+ if (plan.action === 'reconcile') {
242
+ const r = await opts.reconcile(rec);
243
+ const patch = { settlementFact: r.fact };
244
+ if (r.txHash) {
245
+ patch.txHash = r.txHash;
246
+ patch.chainSettled = true;
247
+ }
248
+ const backToRetry = r.fact === 'unpaid' && String(rec.status) === 'paying';
249
+ if (backToRetry)
250
+ patch.status = 'payment_required';
251
+ await opts.persist(rec.transactionId, patch, {
252
+ kind: 'supervisor_payment_reconciled',
253
+ detail: `${r.note || r.fact}${r.txHash ? ` (txHash=${r.txHash.slice(0, 12)}…)` : ''}${backToRetry ? ' → payment_required (可安全重试)' : ''}`,
254
+ });
255
+ report.reconciled.push(rec.transactionId);
256
+ const after = { ...rec, ...patch };
257
+ const nextPlan = planTransactionRecovery(after);
258
+ if (nextPlan.action === 'retry_payment') {
259
+ report.awaitingPayment.push({ transactionId: rec.transactionId, reason: nextPlan.reason });
260
+ await wakeOrFlag(rec, after, nextPlan);
261
+ }
262
+ else if (nextPlan.mustNotRepay)
263
+ report.mustNotRepay.push(rec.transactionId);
264
+ continue;
265
+ }
266
+ if (plan.action === 'retry_payment') {
267
+ // 不需要对账就能确认"没付过" → 列出来 + 唤醒对应 Goal 去走幂等付款路径 (这里不动钱)
268
+ report.awaitingPayment.push({ transactionId: rec.transactionId, reason: plan.reason });
269
+ await wakeOrFlag(rec, rec, plan);
270
+ continue;
271
+ }
272
+ if (plan.action === 'deliver' || plan.action === 'verify') {
273
+ // 付款事实在手 → 唤醒 Goal 继续推进交付/验真 (同样不在这里动钱)
274
+ await wakeOrFlag(rec, rec, plan);
275
+ continue;
276
+ }
277
+ if (plan.mustNotRepay)
278
+ report.mustNotRepay.push(rec.transactionId);
279
+ if (plan.action === 'closed' || plan.action === 'complete' || plan.action === 'wait') {
280
+ report.closed.push({ transactionId: rec.transactionId, reason: plan.reason });
281
+ // 争议中的交易也要让对应 Goal **转人工** (不是让它继续跑, 而是明确交人)
282
+ await wakeOrFlag(rec, rec, plan);
283
+ }
284
+ }
285
+ catch (err) {
286
+ report.errors.push(`${rec.transactionId}: ${String(err?.reason || err?.message || err).slice(0, 120)}`);
287
+ }
288
+ }
289
+ return report;
290
+ }