wezard 2.6.2 → 2.6.4

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 (81) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/config.example.jsonc +3 -1
  3. package/dist/daemon/answer-transfer.js +56 -0
  4. package/dist/daemon/answer-transfer.js.map +1 -0
  5. package/dist/daemon/approval.js +1 -1
  6. package/dist/daemon/approval.js.map +1 -1
  7. package/dist/daemon/bot-mux.js +4 -0
  8. package/dist/daemon/bot-mux.js.map +1 -1
  9. package/dist/daemon/cfg-sync.js +1 -1
  10. package/dist/daemon/cfg-sync.js.map +1 -1
  11. package/dist/daemon/chat-name.js +4 -4
  12. package/dist/daemon/chat-name.js.map +1 -1
  13. package/dist/daemon/config-api.js +2 -2
  14. package/dist/daemon/config-api.js.map +1 -1
  15. package/dist/daemon/image-input.js +54 -0
  16. package/dist/daemon/image-input.js.map +1 -0
  17. package/dist/daemon/inbound.js +7 -13
  18. package/dist/daemon/inbound.js.map +1 -1
  19. package/dist/daemon/index.js +1134 -331
  20. package/dist/daemon/index.js.map +1 -1
  21. package/dist/daemon/jobs.js +39 -15
  22. package/dist/daemon/jobs.js.map +1 -1
  23. package/dist/daemon/last-response.js +37 -7
  24. package/dist/daemon/last-response.js.map +1 -1
  25. package/dist/daemon/message-events.js +361 -0
  26. package/dist/daemon/message-events.js.map +1 -0
  27. package/dist/daemon/message-format.js +40 -0
  28. package/dist/daemon/message-format.js.map +1 -0
  29. package/dist/daemon/mirror-bridge.js +847 -343
  30. package/dist/daemon/mirror-bridge.js.map +1 -1
  31. package/dist/daemon/pane-input.js +48 -0
  32. package/dist/daemon/pane-input.js.map +1 -0
  33. package/dist/daemon/peers.js +17 -16
  34. package/dist/daemon/peers.js.map +1 -1
  35. package/dist/daemon/pending-items.js +8 -0
  36. package/dist/daemon/pending-items.js.map +1 -1
  37. package/dist/daemon/receipts.js +427 -139
  38. package/dist/daemon/receipts.js.map +1 -1
  39. package/dist/daemon/scheduled-delivery.js +52 -0
  40. package/dist/daemon/scheduled-delivery.js.map +1 -0
  41. package/dist/daemon/steward-model.js +10 -10
  42. package/dist/daemon/steward-model.js.map +1 -1
  43. package/dist/daemon/tasks.js +83 -27
  44. package/dist/daemon/tasks.js.map +1 -1
  45. package/dist/daemon/tmux-buffer.js +16 -6
  46. package/dist/daemon/tmux-buffer.js.map +1 -1
  47. package/dist/daemon/weixin-cmd.js +2 -4
  48. package/dist/daemon/weixin-cmd.js.map +1 -1
  49. package/dist/daemon/weixin.js +111 -7
  50. package/dist/daemon/weixin.js.map +1 -1
  51. package/dist/daemon/wizard.js +2 -1
  52. package/dist/daemon/wizard.js.map +1 -1
  53. package/dist/daemon/workflow-nodes.js +217 -0
  54. package/dist/daemon/workflow-nodes.js.map +1 -0
  55. package/dist/daemon/workflow-plan-http.js +83 -0
  56. package/dist/daemon/workflow-plan-http.js.map +1 -0
  57. package/dist/daemon/workflow-plans.js +274 -0
  58. package/dist/daemon/workflow-plans.js.map +1 -0
  59. package/dist/mcp/server.js +27 -1
  60. package/dist/mcp/server.js.map +1 -1
  61. package/dist/shared/cli-backends.js.map +1 -1
  62. package/dist/shared/codex.js +1 -1
  63. package/dist/shared/codex.js.map +1 -1
  64. package/dist/shared/config.js +12 -10
  65. package/dist/shared/config.js.map +1 -1
  66. package/dist/shared/detail-store.js.map +1 -1
  67. package/dist/shared/json-map-store.js +46 -9
  68. package/dist/shared/json-map-store.js.map +1 -1
  69. package/dist/shared/keyed-scheduler.js +51 -0
  70. package/dist/shared/keyed-scheduler.js.map +1 -0
  71. package/dist/shared/message-footer.js +34 -0
  72. package/dist/shared/message-footer.js.map +1 -0
  73. package/dist/shared/model-tiers.js +1 -2
  74. package/dist/shared/model-tiers.js.map +1 -1
  75. package/dist/shared/reminder.js +9 -1
  76. package/dist/shared/reminder.js.map +1 -1
  77. package/dist/shared/session-label.js +19 -1
  78. package/dist/shared/session-label.js.map +1 -1
  79. package/dist/shared/workflow-plan.js +36 -0
  80. package/dist/shared/workflow-plan.js.map +1 -0
  81. package/package.json +1 -1
@@ -26,6 +26,8 @@ const DEADLINE_MIN_SEC = 60;
26
26
  const DEADLINE_MAX_SEC = 12 * 3600;
27
27
  /** 交接把义务转进新会话时, 至少再给它这么久: 不然对方一交接, 这份就提前超时。 */
28
28
  const CARRY_GRACE_MS = 10 * 60_000;
29
+ /** The same accepted quoting/whitespace syntax applies at every turn lookup. */
30
+ export const canonicalTurn = (turn) => turn.replace(/[`\s]/g, "");
29
31
  /** 一件活的编号: 注入之前生成, 写进信封, 回执按它定位答句。 */
30
32
  export const newTurn = () => `t${randomBytes(3).toString("hex")}`;
31
33
  /** `at` 发出、给了 `sec` 秒 (缺省 / 越界取默认或夹到边上) 的期限。 */
@@ -62,21 +64,32 @@ const sameK = (a, b) => kAttr(a) === kAttr(b);
62
64
  /** 落盘的登记留多久。比对方最长允许干的时长宽, 好让续守的那一份仍数得进工单。 */
63
65
  const KEEP_MS = 24 * 3600_000;
64
66
  export const createReceipts = (deps) => {
67
+ let stopped = false;
68
+ let writeFailed = false;
65
69
  const keyOfPair = (from, to) => `${from}\u0000${to}`;
66
70
  const keyOfTurn = (from, to, turn) => `${keyOfPair(from, to)}\u0000${turn}`;
67
71
  const keyOf = (s) => (s.parked ? keyOfTurn(s.from, s.to, s.turn ?? "") : keyOfPair(s.from, s.to));
68
- const live = (s) => Date.now() - s.at < KEEP_MS;
72
+ const live = (s) => !s.settled || Date.now() - s.at < KEEP_MS || !!s.inbox?.some((m) => ["queued", "delivering", "uncertain"].includes(m.state));
69
73
  // 过了保留期的行开机时从盘上抹掉: 同一对的位置会被下一句覆盖, 按件号停放的不会。
70
74
  Object.entries(deps.store?.all() ?? {}).filter(([, s]) => !live(s)).forEach(([key]) => deps.store?.drop(key));
71
- const slots = new Map(Object.values(deps.store?.all() ?? {}).filter(live).map((s) => [keyOf(s), s]));
75
+ const slots = new Map(Object.values(deps.store?.all() ?? {}).filter(live).map((s) => { const row = structuredClone(s); return [keyOf(row), row]; }));
72
76
  const holds = new Map(Object.values(deps.holdStore?.all() ?? {}).map((h) => [keyOfTurn(h.from, h.to, h.turn), h]));
73
77
  /** 写穿到磁盘。被同一对的新一句顶掉的旧 slot 不写 —— 它的 key 已经归新的了。 */
74
- const save = (s) => {
75
- if (!stale(s))
76
- deps.store?.set(keyOf(s), s);
78
+ const persist = (rows) => {
79
+ if (writeFailed)
80
+ throw new Error("receipt persistence failed; consumers paused for reconciliation");
81
+ try {
82
+ deps.store?.merge(structuredClone(rows));
83
+ }
84
+ catch (e) {
85
+ writeFailed = true;
86
+ throw e;
87
+ }
77
88
  };
89
+ const save = (s) => { if (!stale(s))
90
+ persist({ [keyOf(s)]: s }); };
78
91
  const settle = (s) => { s.settled = true; save(s); };
79
- const stale = (s) => slots.get(keyOf(s))?.gen !== s.gen;
92
+ const stale = (s) => stopped || slots.get(keyOf(s)) !== s;
80
93
  /** `to` 答 `k` 那件活的那一份 —— 在 `from→to` 的位置上, 或已按件号停放。 */
81
94
  const upstream = (k, to) => [slots.get(keyOfPair(k.from, to)), slots.get(keyOfTurn(k.from, to, k.turn))].find((p) => p?.turn === k.turn);
82
95
  const ofJob = (from, job) => [...slots.values()].filter((x) => x.from === from && x.job === job);
@@ -132,20 +145,12 @@ export const createReceipts = (deps) => {
132
145
  if (p)
133
146
  wake(p);
134
147
  };
135
- // 同一个 pane 的回执串行投递。两份回执同时等到发话方 idle 时, 两次 paste 会挤进
136
- // 同一个输入框被当成一轮读掉 —— 正是异步化最容易带进来的那个故障。
137
- const chains = new Map();
138
- const serial = (k, job) => {
139
- const next = (chains.get(k) ?? Promise.resolve()).then(job, job);
140
- chains.set(k, next.catch(() => undefined));
141
- return next;
142
- };
143
148
  // 一份 slot 同时只有一个 watcher: reload 续守与 relay 叫醒可能撞在一起, 两个 watcher
144
149
  // 会把同一个答案投两遍。叫醒挂起的那份要 `force`: 挂起那一刻旧 watcher 已经同步返回,
145
150
  // 只是 finally 还没跑; 用 token 免得旧的 finally 删掉新登记的那个。
146
151
  const watching = new Map();
147
152
  const arm = (s, force = false) => {
148
- if (watching.has(s) && !force)
153
+ if (s.queued || (watching.has(s) && !force))
149
154
  return;
150
155
  const token = Symbol();
151
156
  watching.set(s, token);
@@ -253,9 +258,191 @@ export const createReceipts = (deps) => {
253
258
  silent: `它停下了 ${MAX_FRUITLESS} 次, 都没答这一句`,
254
259
  dead: "它的 pane 没了",
255
260
  };
256
- const last = deps.lastWords?.(s.to, s.at).trim();
261
+ // An answer transfer belongs to a specific human turn. The target may have
262
+ // handled another person's message since then, so a time-only transcript
263
+ // tail can disclose that unrelated message in the failure receipt.
264
+ const last = (s.answerTransfer
265
+ ? (s.turn ? deps.replyFor(s.to, deps.nameOf(s.from), s.at, s.turn)?.text : undefined)
266
+ : !s.turn ? deps.lastWords?.(s.to, s.at) : undefined)?.trim();
257
267
  return `(守护进程: ${deps.nameOf(s.to)} 没有给出结论 —— ${why[status] ?? status}。)${last ? `\n它最后说的: ${clipMiddle(last, 600)}` : ""}`;
258
268
  };
269
+ // Inbox consumers are independent of outcome watchers. One consumer per asker
270
+ // waits for that pane; every other asker can consume while it is busy/offline.
271
+ let mailOrder = Math.max(0, ...[...slots.values()].flatMap((s) => (s.inbox ?? []).map((m) => m.order)));
272
+ const consumers = new Set();
273
+ const retryTimers = new Map();
274
+ const mails = (from) => [...slots.values()].filter((s) => !from || s.from === from)
275
+ .flatMap((s) => (s.inbox ?? []).map((mail) => ({ s, mail })));
276
+ const queued = (from) => mails(from).filter(({ mail }) => mail.state === "queued")
277
+ .sort((a, b) => a.mail.order - b.mail.order);
278
+ const activeMail = (s, m) => !writeFailed && !stale(s)
279
+ && !!s.inbox?.includes(m) && ["queued", "delivering"].includes(m.state);
280
+ const retry = (s, m, reason) => {
281
+ m.state = "queued";
282
+ m.reason = reason;
283
+ m.retryAt = Date.now() + Math.min(30_000, (deps.retryMs ?? 1000) * 2 ** Math.min(m.attempts, 5));
284
+ s.undelivered = true;
285
+ save(s);
286
+ };
287
+ const account = (s, m) => {
288
+ if (m.accounted)
289
+ return;
290
+ deps.onOutcome?.(s, m.outcome.status, m.outcome.body, false);
291
+ if (isTerminal(m.outcome.status) && s.job)
292
+ deps.settleJob?.(s.job, s.to, m.outcome.status, parseClosing(m.outcome.body).artifacts, { turn: s.turn, dispatchKey: s.jobDispatchKey ?? s.dispatchKey });
293
+ m.accounted = true;
294
+ save(s);
295
+ if (isTerminal(m.outcome.status))
296
+ nudgeHolds();
297
+ };
298
+ // The ACK is durable before waking a parent. Replay only the local relay after
299
+ // a crash between these writes, never the external paste.
300
+ const finishMail = (s, m) => {
301
+ if (m.state !== "delivered" || m.relayed)
302
+ return;
303
+ const k = m.meta?.replyTo;
304
+ const p = k?.kind === "peer" ? upstream(k, s.from) : undefined;
305
+ if (p && !p.resolved && !p.claimed && m.sentAt !== undefined && (p.deferred || p.at < m.sentAt))
306
+ relay(p, m.sentAt);
307
+ m.relayed = true;
308
+ save(s);
309
+ };
310
+ const consume = async (from) => {
311
+ while (!stopped && !writeFailed) {
312
+ const next = queued(from)[0];
313
+ if (!next)
314
+ return;
315
+ const { s, mail: m } = next;
316
+ if ((m.retryAt ?? 0) > Date.now())
317
+ return;
318
+ if (!activeMail(s, m)) {
319
+ m.state = "superseded";
320
+ save(s);
321
+ continue;
322
+ }
323
+ account(s, m);
324
+ await deps.handedOff?.(from);
325
+ if (!activeMail(s, m))
326
+ continue;
327
+ if (!(await deps.paneLive(from))) {
328
+ retry(s, m, "asker unavailable; retained in inbox");
329
+ return;
330
+ }
331
+ if (!activeMail(s, m))
332
+ continue;
333
+ if (!(await deps.idleNow(from))) {
334
+ await deps.untilIdle(from, IDLE_STEP_MS, { aborted: () => !activeMail(s, m) });
335
+ continue;
336
+ }
337
+ if (!activeMail(s, m) || deps.handingOff?.(from))
338
+ continue;
339
+ const final = isTerminal(m.outcome.status);
340
+ const current = (m.generation ?? s.gen) === s.gen;
341
+ const { parent: _parent, ...route } = routeOf(s, final && current);
342
+ const peers = s.job ? ofJob(from, s.job) : [s];
343
+ const tally = s.job ? deps.jobTally?.(s.job) : undefined;
344
+ m.meta = {
345
+ eventId: m.id, from: s.to, job: s.job, status: m.outcome.status, turn: s.turn ?? "",
346
+ done: Math.max(peers.filter((x) => x.resolved).length, tally?.done ?? 0),
347
+ total: Math.max(peers.length, tally?.total ?? 0),
348
+ ...(s.asker ? { asker: s.asker } : {}), ...route,
349
+ };
350
+ m.state = "delivering";
351
+ m.attempts++;
352
+ m.sentAt = Date.now();
353
+ // Persist the ambiguous boundary before transport. A restarted delivering
354
+ // event is uncertain even if its ACK never made it to disk.
355
+ save(s);
356
+ let result;
357
+ try {
358
+ result = await deps.deliver(from, m.outcome.body, m.meta, () => activeMail(s, m));
359
+ }
360
+ catch (e) {
361
+ result = { ok: false, uncertain: true, reason: e.message };
362
+ }
363
+ if (stale(s))
364
+ continue;
365
+ if (!result.uncertain && result.ok) {
366
+ m.state = "delivered";
367
+ if (current) {
368
+ s.delivered = true;
369
+ delete s.undelivered;
370
+ if (final) {
371
+ s.claimed = true;
372
+ s.settled = true;
373
+ }
374
+ }
375
+ save(s);
376
+ finishMail(s, m);
377
+ }
378
+ else if (!result.uncertain && result.retryable === true) {
379
+ if (!activeMail(s, m)) {
380
+ m.state = "superseded";
381
+ save(s);
382
+ continue;
383
+ }
384
+ retry(s, m, result.reason ?? "transport confirmed no input sent");
385
+ return;
386
+ }
387
+ else {
388
+ m.state = "uncertain";
389
+ m.reason = result.reason ?? "delivery acknowledgement unknown; reconciliation required";
390
+ s.undelivered = true;
391
+ save(s);
392
+ // No settle/release: an uncertain last sibling cannot acquire answer
393
+ // ownership for an unrelated later receipt, nor be sent twice on restart.
394
+ }
395
+ deps.log.info({ from, turn: s.turn, eventId: m.id, state: m.state, attempts: m.attempts }, "receipt inbox: delivery");
396
+ }
397
+ };
398
+ const pump = (from, retryReady = false) => {
399
+ if (stopped || writeFailed || consumers.has(from))
400
+ return;
401
+ const old = retryTimers.get(from);
402
+ if (old) {
403
+ clearTimeout(old);
404
+ retryTimers.delete(from);
405
+ }
406
+ const head = queued(from)[0];
407
+ if (!head)
408
+ return;
409
+ const delay = (head.mail.retryAt ?? 0) - Date.now();
410
+ if (delay > 0 || (head.mail.retryAt !== undefined && !retryReady)) {
411
+ // Even expired backoff yields to the event loop (slow fsync can consume it).
412
+ const timer = setTimeout(() => { retryTimers.delete(from); pump(from, true); }, Math.max(0, delay));
413
+ timer.unref?.();
414
+ retryTimers.set(from, timer);
415
+ return;
416
+ }
417
+ consumers.add(from);
418
+ void consume(from).catch((e) => {
419
+ deps.log.warn({ from, err: e.message }, "receipt inbox: consumer failed");
420
+ const head = queued(from)[0];
421
+ if (head && !writeFailed && !stopped)
422
+ retry(head.s, head.mail, e.message);
423
+ }).finally(() => { consumers.delete(from); pump(from); });
424
+ };
425
+ const enqueueOutcome = (s, out) => {
426
+ // A newer result makes a not-yet-consumed NEED/error obsolete. Anything
427
+ // already exposed retains its delivery evidence in the event history.
428
+ (s.inbox ?? []).filter((m) => m.state === "queued" && !isTerminal(m.outcome.status))
429
+ .forEach((m) => { m.state = "superseded"; });
430
+ const m = { id: `r${randomBytes(12).toString("hex")}`, order: ++mailOrder,
431
+ queuedAt: Date.now(), generation: s.gen, outcome: out, state: "queued", attempts: 0 };
432
+ s.outcome = out;
433
+ if (out.status === "canceled")
434
+ s.cancellationId = m.id;
435
+ s.resolved = isTerminal(out.status);
436
+ s.delivered = false;
437
+ s.inbox = [...(s.inbox ?? []), m];
438
+ save(s); // outcome + queued event in one durable write, before any asker IO
439
+ try {
440
+ account(s, m);
441
+ }
442
+ finally {
443
+ pump(s.from);
444
+ }
445
+ };
259
446
  const watch = async (s) => {
260
447
  const lg = deps.log.child({ mod: "receipt", from: deps.nameOf(s.from), to: deps.nameOf(s.to), ...(s.job ? { job: s.job } : {}) });
261
448
  const anchor = s.at;
@@ -265,11 +452,15 @@ export const createReceipts = (deps) => {
265
452
  const out = canceled(s) ? s.outcome : got;
266
453
  if (stale(s))
267
454
  return;
455
+ if (canceled(s) && s.cancellationId && s.inbox?.some((m) => m.id === s.cancellationId)) {
456
+ pump(s.from);
457
+ return;
458
+ }
268
459
  if (s.claimed || !out) { // wait_peer 抢先取走了
269
460
  s.resolved = true;
270
461
  settle(s);
271
462
  if (s.job)
272
- deps.settleJob?.(s.job, s.to, "done", []);
463
+ deps.settleJob?.(s.job, s.to, "done", [], { turn: s.turn, dispatchKey: s.jobDispatchKey ?? s.dispatchKey });
273
464
  return;
274
465
  }
275
466
  // 下面到 save 之间不能有 await: relay 与这里的判定必须看到同一个状态。
@@ -285,12 +476,18 @@ export const createReceipts = (deps) => {
285
476
  }
286
477
  // 工单验收: 交了个空 RESULT (或缺约定的 ARTIFACT) —— 不投、不计数, 以发话方的名义同件号
287
478
  // 打回一次; 再交上来的照收。新登记顶掉这一份 (它随即 stale), 守的是打回那一句的答案。
288
- const why = out.status === "done" && s.job && !s.bounced ? deps.accept?.(s.job, out.body) : undefined;
479
+ const why = out.status === "done" && s.job && !s.bounced && !deps.workflowPrereq?.(s.from, s.turn ?? "") ? deps.accept?.(s.job, out.body) : undefined;
289
480
  if (why && deps.rebound) {
290
481
  const at = Date.now();
291
482
  const legs = (s.legs ?? 1) + 1;
292
- register({ from: s.from, to: s.to, channel: s.channel, job: s.job, at, turn: s.turn, legs, deadlineAt: deadlineOf(at), ...(s.k ? { k: s.k } : {}), ...(s.asker ? { asker: s.asker } : {}), bounced: out.body });
293
- const ok = await deps.rebound({ from: s.from, to: s.to, turn: s.turn, legs, channel: s.channel, ...(s.asker ? { asker: s.asker } : {}) }, why);
483
+ const tell = { from: s.from, to: s.to, channel: s.channel, job: s.job, at, turn: s.turn, legs, deadlineAt: deadlineOf(at), ...(s.k ? { k: s.k } : {}), ...(s.asker ? { asker: s.asker } : {}), bounced: out.body, jobDispatchKey: s.jobDispatchKey ?? s.dispatchKey };
484
+ if (!deps.durableRebound)
485
+ register(tell);
486
+ const ok = await deps.rebound({ from: s.from, to: s.to, turn: s.turn, legs, channel: s.channel, ...(s.asker ? { asker: s.asker } : {}) }, why, tell);
487
+ if (!ok && deps.durableRebound) {
488
+ enqueueOutcome(s, out);
489
+ return;
490
+ }
294
491
  lg.info({ why, ok }, "receipt: 不合工单验收, 打回一次");
295
492
  // 打回没注入进去: 原来那份照常交上去 (钉成新 slot 的答案, 它的 watcher 直接拿去投)。
296
493
  if (!ok) {
@@ -302,108 +499,43 @@ export const createReceipts = (deps) => {
302
499
  }
303
500
  return;
304
501
  }
305
- const final = isTerminal(out.status);
306
- s.outcome = out;
307
- s.resolved = final;
308
- save(s);
309
- deps.onOutcome?.(s, out.status, out.body, false);
310
- if (final && s.job)
311
- deps.settleJob?.(s.job, s.to, out.status, parseClosing(out.body).artifacts);
312
- if (final)
313
- nudgeHolds();
314
- // 发话方自己在交接: 重开的那几秒里 pane 是死的, 别把这当成"已经不在了"。
315
- await deps.handedOff?.(s.from);
316
- if (!(await deps.paneLive(s.from))) {
317
- lg.info({ status: out.status }, "receipt: 发话方已经不在了, 丢弃");
502
+ if (s.answerTransfer) {
503
+ // Human answer transfers have their own durable delivery protocol.
504
+ s.outcome = out;
505
+ s.resolved = isTerminal(out.status);
506
+ save(s);
507
+ deps.onOutcome?.(s, out.status, out.body, false);
508
+ if (s.resolved && s.job)
509
+ deps.settleJob?.(s.job, s.to, out.status, parseClosing(out.body).artifacts, { turn: s.turn, dispatchKey: s.jobDispatchKey ?? s.dispatchKey });
510
+ if (s.resolved)
511
+ nudgeHolds();
512
+ s.claimed = true;
513
+ save(s);
514
+ s.delivered = await deps.deliverAnswer?.(s.answerTransfer, out) ?? false;
515
+ s.resolved = true;
318
516
  settle(s);
319
- if (final)
320
- release(s);
321
517
  return;
322
518
  }
323
- if (final)
324
- s.claimed = true; // 占位先于投递: 这中间来的 wait_peer 不该把同一段再取一遍
325
- const peers = s.job ? ofJob(s.from, s.job) : [s];
326
- const tally = s.job ? deps.jobTally?.(s.job) : undefined;
327
- const meta = {
328
- from: s.to,
329
- channel: s.channel,
330
- job: s.job,
331
- done: Math.max(peers.filter((x) => x.resolved).length, tally?.done ?? 0),
332
- total: Math.max(peers.length, tally?.total ?? 0),
333
- status: out.status,
334
- turn: s.turn ?? "",
335
- ...(s.asker ? { asker: s.asker } : {}),
336
- };
337
- const sent = await serial(s.from, async () => {
338
- // 发话方正在生成 → 等它这一轮说完, 一结束就投。它的 pane 还活着就一直等 (上限是
339
- // 登记的保留期): 回执到了却因为它在跑长活而扔掉, 它就只能永远等。
340
- // 等的过程中它开始交接 → 等交接完再来一圈: 投进旧会话会被交接一并带走 (简报
341
- // 已经写完了, 不含这一段), 投进还没贴回简报的新会话会抢在简报前面。
342
- // 每圈都查 stale: 等发话方闲下来可能要很久, 同一对的新一句正是在这期间来的。
343
- for (;;) {
344
- await deps.handedOff?.(s.from);
345
- // settled 只可能是 wait_peer 在这期间取走了 (watcher 自己投完才 settle)。
346
- // 排队期间被 cancel 落了终态: 手上这份 (need / error) 已经过时, 改投 canceled。
347
- if (stale(s) || s.settled || (canceled(s) && out !== s.outcome))
348
- return false;
349
- if (Date.now() >= s.at + KEEP_MS || !(await deps.paneLive(s.from))) {
350
- lg.warn({ status: out.status }, "receipt: 发话方一直没空或已不在, 放弃回注");
351
- return false;
352
- }
353
- if (!(await deps.idleNow(s.from))) {
354
- await deps.untilIdle(s.from, Math.min(IDLE_STEP_MS, s.at + KEEP_MS - Date.now()), { aborted: () => stale(s) });
355
- continue;
356
- }
357
- if (!deps.handingOff?.(s.from))
358
- break;
359
- }
360
- // 上面最后一圈的 await 期间可能被 wait_peer 取走; 从这里到 deliver 之间不能有 await。
361
- if (stale(s) || s.settled)
362
- return false;
363
- const { parent, ...route } = routeOf(s, final);
364
- const at = Date.now();
365
- // 先记成已投: 投递途中被 wait_peer 取走的, 要告诉它「这段已经在你会话里了」。
366
- s.delivered = true;
367
- const r = await deps.deliver(s.from, out.body, { ...meta, ...route });
368
- if (!r.ok) {
369
- s.delivered = false;
370
- s.undelivered = true;
371
- }
372
- if (r.ok && parent)
373
- relay(parent, at);
374
- lg.info({ ok: r.ok, reason: r.reason, status: out.status, len: out.body.length, done: meta.done, total: meta.total, ...(route.replyTo ? { replyTo: kAttr(route.replyTo) } : {}), ...(route.pending ? { pending: route.pending } : {}) }, "receipt: 回注");
375
- return true;
376
- });
377
- if (stale(s)) {
378
- lg.info("receipt: 被同一对的新一句顶掉, 不回注");
379
- return;
380
- }
381
- // error / need 只是中途报了一声: 接着守续跑 (或答复之后) 出来的答案。
382
- if (!final && (sent || canceled(s)))
519
+ enqueueOutcome(s, out);
520
+ // Watching the worker continues even if the asker cannot consume NEED/error.
521
+ if (!isTerminal(out.status))
383
522
  return watch(s);
384
- settle(s);
385
- if (!s.delivered)
386
- release(s);
387
523
  };
388
- const register = (tell, watchIt = true) => {
524
+ const register = (tell, watchIt = true, interim) => {
389
525
  const at = tell.at ?? Date.now();
390
526
  const turn = tell.turn ?? newTurn();
391
527
  // 续问一件停放着的: 接替它在停放处的位置, 不碰这一对眼下那一件。
392
528
  const parked = slots.get(keyOfTurn(tell.from, tell.to, turn));
393
529
  const prev = parked ?? slots.get(keyOfPair(tell.from, tell.to));
394
- // 挂起等子活的那份被另一件顶下来: 按件号停放, 不作废 (见 Slot.parked)。还没标上挂起、
395
- // 但它已经为这件派了活的也算 —— watcher 可能还没读到那句「已派」。
396
- const park = !parked && !!prev && !prev.settled && prev.turn !== turn && (!!prev.deferred || children(prev).length > 0);
397
- if (park) {
398
- prev.parked = true;
399
- slots.set(keyOf(prev), prev);
400
- save(prev);
401
- }
530
+ // The pair remains a compatibility index for wait_peer/pending. Every older
531
+ // turn, including completed but unconsumed results, keeps its own slot.
532
+ const park = !parked && !!prev && prev.turn !== turn;
402
533
  // 顶掉一份还没落定的工单活、自己又没带工单: 继承那张工单 —— 旧那份不会再投了,
403
534
  // 这一句的答案就是那个成员的交代; 不继承它就永远不计 done, 工单齐不了。停放的那份还会投, 不让。
404
535
  const job = tell.job || (!park && prev?.job && !prev.resolved ? prev.job : "");
405
536
  const s = {
406
537
  ...tell,
538
+ ...(interim && ["need", "error"].includes(interim.status) ? { outcome: interim } : {}),
407
539
  job,
408
540
  at,
409
541
  turn,
@@ -414,24 +546,58 @@ export const createReceipts = (deps) => {
414
546
  delivered: false,
415
547
  resolved: false,
416
548
  ...(parked ? { parked: true } : {}),
549
+ ...(!park && prev?.inbox ? { inbox: prev.inbox.map((m) => ({ ...m,
550
+ generation: m.generation ?? prev.gen,
551
+ state: m.state === "queued" && !isTerminal(m.outcome.status) ? "superseded" : m.state === "delivering" ? "uncertain" : m.state,
552
+ })) } : {}),
417
553
  };
554
+ // Commit both keys atomically; a crash cannot leave two live aliases for
555
+ // the same turn or erase an old event between parking and registering.
556
+ const oldKey = prev && keyOfTurn(prev.from, prev.to, prev.turn ?? "");
557
+ persist({ ...(park && prev && oldKey ? { [oldKey]: { ...prev, parked: true } } : {}), [keyOf(s)]: s });
558
+ if (park && prev && oldKey) {
559
+ prev.parked = true;
560
+ slots.set(oldKey, prev);
561
+ }
418
562
  slots.set(keyOf(s), s);
419
- if (watchIt)
563
+ if (tell.queued) { /* accepted obligation; no silent/dead watcher until submission */ }
564
+ else if (watchIt)
420
565
  arm(s);
421
566
  else {
422
567
  s.claimed = true;
423
568
  s.resolved = true;
424
- s.settled = true;
569
+ settle(s);
425
570
  } // 不守 = 这一份没人会投
426
- save(s);
571
+ pump(s.from);
427
572
  return { at: s.at, turn: s.turn };
428
573
  };
429
574
  // ── 停放 (after) ─────────────────────────────────────────────────────
430
575
  const holdKey = (h) => keyOfTurn(h.from, h.to, h.turn);
431
- const saveHold = (h) => { deps.holdStore?.set(holdKey(h), h); };
432
- const dropHold = (h) => { holds.delete(holdKey(h)); deps.holdStore?.drop(holdKey(h)); };
576
+ // A failed write pauses all releases, including closures already inside transport.
577
+ // Failed cancellation is not acknowledged: retain the original durable/memory row
578
+ // for operator reconciliation before restarting the store.
579
+ let holdWriteFailed = false;
580
+ const writeHold = (write) => {
581
+ if (holdWriteFailed)
582
+ throw new Error("hold persistence failed; reconcile storage before reload");
583
+ try {
584
+ write();
585
+ }
586
+ catch (e) {
587
+ holdWriteFailed = true;
588
+ throw e;
589
+ }
590
+ };
591
+ const saveHold = (h) => writeHold(() => { deps.holdStore?.set(holdKey(h), h); });
592
+ const dropHold = (h) => {
593
+ writeHold(() => { deps.holdStore?.drop(holdKey(h)); });
594
+ holds.delete(holdKey(h));
595
+ };
433
596
  /** 前置件号 → 此刻的样子。先看回执槽 (按发话方 + 件号找, 不论发给谁), 再看自己也停放着的, 最后看快照。 */
434
597
  const prereqOf = (from, turn, seen = {}) => {
598
+ const tracked = deps.workflowPrereq?.(from, turn);
599
+ if (tracked)
600
+ return tracked;
435
601
  const s = [...slots.values()].find((x) => x.from === from && x.turn === turn);
436
602
  const st = s?.outcome?.status;
437
603
  if (s && s.resolved && st && isTerminal(st))
@@ -452,32 +618,45 @@ export const createReceipts = (deps) => {
452
618
  /** 不投了: 落成一份 canceled 回给发话方, 走与别的回执同一条路 (投递、计数、记账都在 watch 里)。
453
619
  * 按件号停放着放进去 (parked), 绝不占 from→to 的位置 —— 那里可能有在飞的另一件。 */
454
620
  const cancelHold = (h, why, silent = false) => {
455
- dropHold(h);
456
- if (silent)
621
+ if (holds.get(holdKey(h)) !== h)
622
+ return;
623
+ const existing = slots.get(keyOfTurn(h.from, h.to, h.turn));
624
+ if (existing?.outcome?.status === "canceled") {
625
+ dropHold(h);
626
+ if (!existing.claimed)
627
+ arm(existing);
457
628
  return;
629
+ }
458
630
  const at = Date.now();
459
631
  const s = {
460
632
  from: h.from, to: h.to, channel: h.channel, job: h.job, at, turn: h.turn, legs: 1, deadlineAt: at,
461
633
  ...(h.k ? { k: h.k } : {}), ...(h.asker ? { asker: h.asker } : {}),
462
634
  gen: (slots.get(keyOfTurn(h.from, h.to, h.turn))?.gen ?? 0) + 1,
463
- claimed: false, delivered: false, resolved: false, parked: true,
635
+ claimed: silent, delivered: false, resolved: silent, settled: silent, parked: true,
464
636
  outcome: { status: "canceled", body: `(守护进程: ${why})` },
465
637
  };
466
638
  slots.set(keyOf(s), s);
467
639
  save(s);
468
- arm(s);
640
+ if (silent)
641
+ deps.onOutcome?.(s, "canceled", s.outcome.body, true);
642
+ if (!silent)
643
+ enqueueOutcome(s, s.outcome);
644
+ dropHold(h);
469
645
  };
470
646
  const releasing = new Set();
471
647
  /** 逐件看停放着的: 有失败的 → 不投; 都 done 了且这一对没被占着 → 放行; 超过保留期 → 不投。 */
472
648
  const tickHolds = async () => {
649
+ if (stopped || writeFailed || holdWriteFailed)
650
+ return;
473
651
  for (const h of [...holds.values()]) {
474
652
  if (releasing.has(holdKey(h)) || !holds.has(holdKey(h)))
475
653
  continue;
476
654
  const ps = h.after.map((t) => [t, prereqOf(h.from, t, h.seen)]);
477
655
  const fresh = ps.filter(([t, p]) => (p.state === "done" || p.state === "failed") && !h.seen[t]);
478
656
  if (fresh.length) {
479
- fresh.forEach(([t, p]) => { h.seen[t] = p.seen; });
480
- saveHold(h);
657
+ const seen = { ...h.seen, ...Object.fromEntries(fresh.map(([t, p]) => [t, p.seen])) };
658
+ saveHold({ ...h, seen });
659
+ h.seen = seen;
481
660
  }
482
661
  const bad = ps.find(([, p]) => p.state === "failed" || p.state === "unknown");
483
662
  if (bad) {
@@ -497,14 +676,24 @@ export const createReceipts = (deps) => {
497
676
  releasing.add(holdKey(h));
498
677
  void (async () => {
499
678
  try {
500
- const r = (await deps.release?.(h, h.seen)) ?? { ok: false, reason: "没有放行通路" };
679
+ const r = (await deps.release?.(h, h.seen, () => !stopped && !writeFailed && !holdWriteFailed && holds.get(holdKey(h)) === h)) ?? { ok: false, reason: "没有放行通路" };
680
+ if (holds.get(holdKey(h)) !== h)
681
+ return;
501
682
  if (r.ok)
502
683
  dropHold(h);
503
684
  else
504
685
  cancelHold(h, `前置都 done 了, 但这件 (${h.turn}) 投不出去: ${r.reason ?? "未知原因"}, 没投。`);
505
686
  }
506
687
  catch (e) {
507
- cancelHold(h, `放行时出错 (${e.message}), 这件 (${h.turn}) 没投。`);
688
+ try {
689
+ if (!holdWriteFailed)
690
+ cancelHold(h, `放行时出错 (${e.message}), 这件 (${h.turn}) 没投。`);
691
+ }
692
+ catch (writeError) {
693
+ deps.log.error({ err: writeError.message, turn: h.turn }, "receipt: cancellation persistence failed");
694
+ }
695
+ if (holdWriteFailed)
696
+ deps.log.error({ err: e.message, turn: h.turn }, "receipt: hold persistence failed; releases paused for reconciliation");
508
697
  }
509
698
  finally {
510
699
  releasing.delete(holdKey(h));
@@ -523,27 +712,87 @@ export const createReceipts = (deps) => {
523
712
  const nudgeHolds = () => { if (holds.size)
524
713
  void tickHolds().catch(() => undefined); };
525
714
  return {
715
+ stop: () => {
716
+ stopped = true;
717
+ retryTimers.forEach(clearTimeout);
718
+ retryTimers.clear();
719
+ if (holdTimer)
720
+ clearInterval(holdTimer);
721
+ },
722
+ inbox: (from) => mails(from).sort((a, b) => a.mail.order - b.mail.order).map(({ s, mail }) => structuredClone({ ...mail, from: s.from, to: s.to, turn: s.turn ?? "" })),
526
723
  register,
724
+ confirmDelivery: (eventId) => {
725
+ for (const s of slots.values()) {
726
+ const m = s.inbox?.find((m) => m.id === eventId && m.state === "uncertain");
727
+ if (!m)
728
+ continue;
729
+ m.state = "delivered";
730
+ m.reason = "submission confirmed by durable event/transcript evidence";
731
+ if ((m.generation ?? s.gen) === s.gen) {
732
+ s.delivered = true;
733
+ delete s.undelivered;
734
+ if (isTerminal(m.outcome.status)) {
735
+ s.claimed = true;
736
+ s.settled = true;
737
+ }
738
+ }
739
+ save(s);
740
+ finishMail(s, m);
741
+ pump(s.from);
742
+ }
743
+ },
744
+ commit: (tell) => {
745
+ let s = upstream({ from: tell.from, turn: tell.turn ?? "" }, tell.to);
746
+ if (!s) {
747
+ register({ ...tell, queued: true });
748
+ s = upstream({ from: tell.from, turn: tell.turn ?? "" }, tell.to);
749
+ }
750
+ if (!s || s.dispatchKey !== tell.dispatchKey || !s.queued)
751
+ return;
752
+ s.queued = false;
753
+ save(s);
754
+ arm(s);
755
+ },
756
+ watching: (tell) => {
757
+ const s = upstream({ from: tell.from, turn: tell.turn ?? "" }, tell.to);
758
+ return !!s && !s.resolved && !s.claimed && (tell.dispatchKey
759
+ ? s.dispatchKey === tell.dispatchKey
760
+ : s.at === tell.at && (s.legs ?? 1) === (tell.legs ?? 1));
761
+ },
762
+ continuation: (from, to, turn) => {
763
+ const s = upstream({ from, turn }, to);
764
+ return !s || s.resolved || s.claimed ? undefined : { from, to, turn, channel: s.channel, job: s.job,
765
+ at: Date.now(), legs: (s.legs ?? 1) + 1, deadlineAt: s.deadlineAt, k: s.k, asker: s.asker, jobDispatchKey: s.jobDispatchKey ?? s.dispatchKey };
766
+ },
527
767
  reask: (from, to, turn) => {
528
768
  const s = upstream({ from, turn }, to);
529
769
  if (!s || s.resolved || s.claimed)
530
770
  return undefined;
531
771
  const legs = (s.legs ?? 1) + 1;
532
- register({ from, to, channel: s.channel, job: s.job, turn, legs, ...(s.deadlineAt ? { deadlineAt: s.deadlineAt } : {}), ...(s.k ? { k: s.k } : {}), ...(s.asker ? { asker: s.asker } : {}) });
772
+ register({ from, to, channel: s.channel, job: s.job, turn, legs, ...(s.answerTransfer ? { answerTransfer: s.answerTransfer } : {}), ...(s.deadlineAt ? { deadlineAt: s.deadlineAt } : {}), ...(s.k ? { k: s.k } : {}), ...(s.asker ? { asker: s.asker } : {}) });
533
773
  return { legs, channel: s.channel, ...(s.asker ? { asker: s.asker } : {}) };
534
774
  },
535
775
  prepare: (from, to, re) => {
536
- const want = re?.replace(/[`\s]/g, "");
776
+ const want = re ? canonicalTurn(re) : undefined;
537
777
  const s = want ? upstream({ from, turn: want }, to) : undefined;
538
778
  return s?.turn && s.turn === want
539
779
  ? { turn: s.turn, legs: (s.legs ?? 1) + 1, job: s.job, channel: s.channel, asker: s.asker, k: s.k }
540
780
  : { turn: newTurn(), legs: 1, ...(re ? { reUnknown: true } : {}) };
541
781
  },
542
- claim: (from, to) => {
543
- const s = slots.get(keyOfPair(from, to));
782
+ pendingDelivery: (from, k, except) => [...slots.values()].some((s) => s.from === from && !!s.k && sameK(s.k, k)
783
+ && s.inbox?.some((m) => m.id !== except && ["queued", "delivering", "uncertain"].includes(m.state))),
784
+ claim: (from, to, turn) => {
785
+ const s = turn ? upstream({ from, turn }, to) : slots.get(keyOfPair(from, to));
544
786
  if (!s)
545
787
  return false;
788
+ if (s.answerTransfer)
789
+ return s.delivered;
790
+ // Transport may already have exposed this event. Do not let a concurrent
791
+ // wait_peer take a second copy, or turn an unknown ACK into a success.
792
+ if (s.inbox?.some((m) => (m.generation ?? s.gen) === s.gen && ["delivering", "uncertain"].includes(m.state)))
793
+ return "uncertain";
546
794
  const fresh = !s.settled;
795
+ s.inbox?.filter((m) => m.state === "queued" && (m.generation ?? s.gen) === s.gen).forEach((m) => { m.state = "claimed"; });
547
796
  s.claimed = true;
548
797
  s.resolved = true;
549
798
  settle(s);
@@ -553,6 +802,7 @@ export const createReceipts = (deps) => {
553
802
  }
554
803
  return s.delivered;
555
804
  },
805
+ sentTurn: (from, to) => slots.get(keyOfPair(from, to))?.turn,
556
806
  sentAt: (from, to) => slots.get(keyOfPair(from, to))?.at ?? 0,
557
807
  outstanding: (from, job) => [...slots.values()]
558
808
  .filter((s) => s.from === from && !s.resolved && (job === undefined || s.job === job))
@@ -586,47 +836,54 @@ export const createReceipts = (deps) => {
586
836
  .filter((s) => !s.resolved && !s.claimed)
587
837
  .map((s) => ({
588
838
  from: s.from, to: s.to, turn: s.turn ?? "", job: s.job,
589
- state: turnState(s, { parked: !s.outcome && parked(s.to) }),
839
+ state: s.queued ? "queued" : turnState(s, { parked: !s.outcome && parked(s.to) }),
590
840
  ageMs: Date.now() - s.at,
841
+ ...(s.k ? { k: s.k } : {}),
591
842
  }))
592
843
  // 停放着的 (等前置): 还没投出去, 但发话方在等它 —— 同样在飞。
593
- .concat([...holds.values()].map((h) => ({ from: h.from, to: h.to, turn: h.turn, job: h.job, state: "queued", ageMs: Date.now() - h.at })));
844
+ .concat([...holds.values()].map((h) => ({ from: h.from, to: h.to, turn: h.turn, job: h.job, state: "queued", ageMs: Date.now() - h.at, ...(h.k ? { k: h.k } : {}) })));
594
845
  },
595
846
  cancel: (target, by, only = () => true) => {
596
847
  // 排着还没投的 (停放): 不投了; 发起 stop 的那方自己知道, 不回执。
597
848
  const held = [...holds.values()].filter((h) => h.to === target && only({ from: h.from, turn: h.turn }));
598
849
  held.forEach((h) => cancelHold(h, `${deps.nameOf(target)} 被 ${deps.nameOf(by)} 收掉 / 打断了, 这件排着的活 (${h.turn}) 没投。`, h.from === by));
599
- const hit = [...slots.values()].filter((s) => s.to === target && !s.resolved && !s.claimed && only({ from: s.from, turn: s.turn ?? "" }));
850
+ const hit = [...slots.values()].filter((s) => s.to === target && !s.resolved && !s.claimed && !canceled(s) && only({ from: s.from, turn: s.turn ?? "" }));
600
851
  for (const s of hit) {
601
- const last = deps.lastWords?.(s.to, s.at).trim();
852
+ // Keep cancellation consistent with timeout/dead/silent: transferred
853
+ // answers may only quote text tied to their own source turn.
854
+ const last = (s.answerTransfer
855
+ ? (s.turn ? deps.replyFor(s.to, deps.nameOf(s.from), s.at, s.turn)?.text : undefined)
856
+ : !s.turn ? deps.lastWords?.(s.to, s.at) : undefined)?.trim();
602
857
  s.outcome = {
603
858
  status: "canceled",
604
859
  body: `(守护进程: ${deps.nameOf(s.to)} 被 ${deps.nameOf(by)} 收掉 / 打断了, 这件活没有结论。)${last ? `\n它最后说的: ${clipMiddle(last, 600)}` : ""}`,
605
860
  };
606
861
  if (s.job)
607
- deps.settleJob?.(s.job, s.to, "canceled", []);
608
- deps.onOutcome?.(s, "canceled", s.outcome.body, s.from === by);
862
+ deps.settleJob?.(s.job, s.to, "canceled", [], { turn: s.turn, dispatchKey: s.jobDispatchKey ?? s.dispatchKey });
609
863
  // 发起 stop 的那方自己知道, 不投; 守着的 watcher 看到 claimed 就收尾。
610
- if (s.from === by) {
864
+ if (s.from === by && !s.answerTransfer) {
865
+ deps.onOutcome?.(s, "canceled", s.outcome.body, true);
611
866
  s.claimed = true;
612
867
  s.resolved = true;
613
868
  settle(s);
614
869
  continue;
615
870
  }
871
+ s.queued = false;
616
872
  save(s);
617
- // 挂起等子活的没有 watcher 在守: 叫醒它去投这份 canceled。
618
- if (s.deferred)
619
- wake(s);
873
+ if (s.answerTransfer)
874
+ arm(s, true);
875
+ else
876
+ enqueueOutcome(s, s.outcome);
620
877
  }
621
878
  if (hit.length)
622
879
  nudgeHolds();
623
880
  return hit.length + held.length;
624
881
  },
625
- prereqs: (from, turns) => turns.map((t) => prereqOf(from, t.replace(/[`\s]/g, ""))),
882
+ prereqs: (from, turns, seen) => turns.map((t) => prereqOf(from, canonicalTurn(t), seen)),
626
883
  hold: (h) => {
627
884
  const x = { ...h, seen: {} };
628
- holds.set(holdKey(x), x);
629
885
  saveHold(x);
886
+ holds.set(holdKey(x), x);
630
887
  startHolds();
631
888
  nudgeHolds();
632
889
  },
@@ -636,6 +893,8 @@ export const createReceipts = (deps) => {
636
893
  return hit.length;
637
894
  },
638
895
  parentOf: (self, env, channel, opening) => {
896
+ if (env?.quiet)
897
+ return undefined;
639
898
  if (env?.receipt)
640
899
  return env.k ? kOfAttr(env.k) : undefined;
641
900
  if (env?.kind === "peer" && env.private) {
@@ -643,7 +902,7 @@ export const createReceipts = (deps) => {
643
902
  return p && { kind: "peer", from: p.from, turn: p.turn };
644
903
  }
645
904
  // 私聊轮 (频道 "") 派出的活没有群可续: 不能回落成 home, 否则回执轮就进了群。
646
- return channel ? { kind: "chat", channel, turn: opening } : undefined;
905
+ return channel ? { kind: "chat", channel, turn: env?.eventId ?? opening } : undefined;
647
906
  },
648
907
  ancestorsOf: (self, k) => {
649
908
  // 第一跳 (self 此刻答的那件) 按定义在途; 往上每一跳都要那件活还没落定 / 没被 wait_peer 取走 ——
@@ -668,8 +927,37 @@ export const createReceipts = (deps) => {
668
927
  resume: () => {
669
928
  // 挂起且子活还没落定的不守: 叫醒它是 relay / release 的事, 这里再 arm 一个就是两个 watcher。
670
929
  const open = [...slots.values()].filter((s) => !s.settled && !(s.deferred && children(s).length));
671
- // 投到一半被 reload 打断的那份: claimed 是上一个进程的占位, 这里重新来过。
672
- open.forEach((s) => { s.claimed = false; arm(s); });
930
+ // Restore durable inboxes before observing new worker results. The
931
+ // delivering boundary is ambiguous after a crash, never an automatic retry.
932
+ for (const s of slots.values()) {
933
+ for (const m of s.inbox ?? []) {
934
+ if (m.state === "delivering") {
935
+ m.state = "uncertain";
936
+ m.reason = "daemon restarted during delivery; reconciliation required";
937
+ s.undelivered = true;
938
+ save(s);
939
+ }
940
+ if (!m.accounted && !["superseded", "claimed"].includes(m.state))
941
+ account(s, m);
942
+ finishMail(s, m);
943
+ }
944
+ pump(s.from);
945
+ }
946
+ open.forEach((s) => {
947
+ if (s.resolved && s.inbox?.some((m) => (m.generation ?? s.gen) === s.gen))
948
+ return;
949
+ if (!s.answerTransfer && s.outcome && isTerminal(s.outcome.status) && (s.claimed || s.delivered)) {
950
+ // Old-format transport had no intent journal. Preserve it explicitly
951
+ // rather than replaying a possibly delivered legacy result.
952
+ s.inbox = [{ id: `r${randomBytes(12).toString("hex")}`, order: ++mailOrder, queuedAt: Date.now(),
953
+ outcome: s.outcome, state: "uncertain", attempts: 1, reason: "legacy delivery boundary unknown" }];
954
+ s.undelivered = true;
955
+ save(s);
956
+ return;
957
+ }
958
+ s.claimed = false;
959
+ arm(s);
960
+ });
673
961
  if (open.length)
674
962
  deps.log.info({ mod: "receipt", resumed: open.map((s) => `${deps.nameOf(s.from)}←${deps.nameOf(s.to)}`) }, "receipt: reload 后续守");
675
963
  startHolds();