@bolloon/bolloon-agent 0.4.26 → 0.4.28

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.
@@ -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
+ }