wezard 2.6.2 → 2.6.3

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 (71) 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/cfg-sync.js +1 -1
  8. package/dist/daemon/cfg-sync.js.map +1 -1
  9. package/dist/daemon/chat-name.js +4 -4
  10. package/dist/daemon/chat-name.js.map +1 -1
  11. package/dist/daemon/config-api.js +2 -2
  12. package/dist/daemon/config-api.js.map +1 -1
  13. package/dist/daemon/image-input.js +34 -0
  14. package/dist/daemon/image-input.js.map +1 -0
  15. package/dist/daemon/inbound.js +4 -11
  16. package/dist/daemon/inbound.js.map +1 -1
  17. package/dist/daemon/index.js +880 -280
  18. package/dist/daemon/index.js.map +1 -1
  19. package/dist/daemon/jobs.js +32 -13
  20. package/dist/daemon/jobs.js.map +1 -1
  21. package/dist/daemon/last-response.js +16 -6
  22. package/dist/daemon/last-response.js.map +1 -1
  23. package/dist/daemon/message-format.js +40 -0
  24. package/dist/daemon/message-format.js.map +1 -0
  25. package/dist/daemon/mirror-bridge.js +673 -295
  26. package/dist/daemon/mirror-bridge.js.map +1 -1
  27. package/dist/daemon/pane-input.js +48 -0
  28. package/dist/daemon/pane-input.js.map +1 -0
  29. package/dist/daemon/peers.js +15 -14
  30. package/dist/daemon/peers.js.map +1 -1
  31. package/dist/daemon/pending-items.js +8 -0
  32. package/dist/daemon/pending-items.js.map +1 -1
  33. package/dist/daemon/receipts.js +346 -120
  34. package/dist/daemon/receipts.js.map +1 -1
  35. package/dist/daemon/scheduled-delivery.js +52 -0
  36. package/dist/daemon/scheduled-delivery.js.map +1 -0
  37. package/dist/daemon/steward-model.js +10 -10
  38. package/dist/daemon/steward-model.js.map +1 -1
  39. package/dist/daemon/tasks.js +83 -27
  40. package/dist/daemon/tasks.js.map +1 -1
  41. package/dist/daemon/tmux-buffer.js +16 -6
  42. package/dist/daemon/tmux-buffer.js.map +1 -1
  43. package/dist/daemon/weixin-cmd.js +2 -4
  44. package/dist/daemon/weixin-cmd.js.map +1 -1
  45. package/dist/daemon/wizard.js +2 -1
  46. package/dist/daemon/wizard.js.map +1 -1
  47. package/dist/daemon/workflow-nodes.js +217 -0
  48. package/dist/daemon/workflow-nodes.js.map +1 -0
  49. package/dist/daemon/workflow-plan-http.js +83 -0
  50. package/dist/daemon/workflow-plan-http.js.map +1 -0
  51. package/dist/daemon/workflow-plans.js +274 -0
  52. package/dist/daemon/workflow-plans.js.map +1 -0
  53. package/dist/mcp/server.js +27 -1
  54. package/dist/mcp/server.js.map +1 -1
  55. package/dist/shared/config.js +12 -10
  56. package/dist/shared/config.js.map +1 -1
  57. package/dist/shared/json-map-store.js +46 -9
  58. package/dist/shared/json-map-store.js.map +1 -1
  59. package/dist/shared/keyed-scheduler.js +51 -0
  60. package/dist/shared/keyed-scheduler.js.map +1 -0
  61. package/dist/shared/message-footer.js +34 -0
  62. package/dist/shared/message-footer.js.map +1 -0
  63. package/dist/shared/model-tiers.js +1 -2
  64. package/dist/shared/model-tiers.js.map +1 -1
  65. package/dist/shared/reminder.js +3 -0
  66. package/dist/shared/reminder.js.map +1 -1
  67. package/dist/shared/session-label.js +19 -1
  68. package/dist/shared/session-label.js.map +1 -1
  69. package/dist/shared/workflow-plan.js +36 -0
  70. package/dist/shared/workflow-plan.js.map +1 -0
  71. 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) => 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,14 +145,6 @@ 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 删掉新登记的那个。
@@ -253,9 +258,189 @@ 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
+ : deps.lastWords?.(s.to, s.at))?.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);
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
+ s.resolved = isTerminal(out.status);
434
+ s.delivered = false;
435
+ s.inbox = [...(s.inbox ?? []), m];
436
+ save(s); // outcome + queued event in one durable write, before any asker IO
437
+ try {
438
+ account(s, m);
439
+ }
440
+ finally {
441
+ pump(s.from);
442
+ }
443
+ };
259
444
  const watch = async (s) => {
260
445
  const lg = deps.log.child({ mod: "receipt", from: deps.nameOf(s.from), to: deps.nameOf(s.to), ...(s.job ? { job: s.job } : {}) });
261
446
  const anchor = s.at;
@@ -285,7 +470,7 @@ export const createReceipts = (deps) => {
285
470
  }
286
471
  // 工单验收: 交了个空 RESULT (或缺约定的 ARTIFACT) —— 不投、不计数, 以发话方的名义同件号
287
472
  // 打回一次; 再交上来的照收。新登记顶掉这一份 (它随即 stale), 守的是打回那一句的答案。
288
- const why = out.status === "done" && s.job && !s.bounced ? deps.accept?.(s.job, out.body) : undefined;
473
+ const why = out.status === "done" && s.job && !s.bounced && !deps.workflowPrereq?.(s.from, s.turn ?? "") ? deps.accept?.(s.job, out.body) : undefined;
289
474
  if (why && deps.rebound) {
290
475
  const at = Date.now();
291
476
  const legs = (s.legs ?? 1) + 1;
@@ -302,108 +487,43 @@ export const createReceipts = (deps) => {
302
487
  }
303
488
  return;
304
489
  }
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: 发话方已经不在了, 丢弃");
490
+ if (s.answerTransfer) {
491
+ // Human answer transfers have their own durable delivery protocol.
492
+ s.outcome = out;
493
+ s.resolved = isTerminal(out.status);
494
+ save(s);
495
+ deps.onOutcome?.(s, out.status, out.body, false);
496
+ if (s.resolved && s.job)
497
+ deps.settleJob?.(s.job, s.to, out.status, parseClosing(out.body).artifacts);
498
+ if (s.resolved)
499
+ nudgeHolds();
500
+ s.claimed = true;
501
+ save(s);
502
+ s.delivered = await deps.deliverAnswer?.(s.answerTransfer, out) ?? false;
503
+ s.resolved = true;
318
504
  settle(s);
319
- if (final)
320
- release(s);
321
- return;
322
- }
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
505
  return;
380
506
  }
381
- // error / need 只是中途报了一声: 接着守续跑 (或答复之后) 出来的答案。
382
- if (!final && (sent || canceled(s)))
507
+ enqueueOutcome(s, out);
508
+ // Watching the worker continues even if the asker cannot consume NEED/error.
509
+ if (!isTerminal(out.status))
383
510
  return watch(s);
384
- settle(s);
385
- if (!s.delivered)
386
- release(s);
387
511
  };
388
- const register = (tell, watchIt = true) => {
512
+ const register = (tell, watchIt = true, interim) => {
389
513
  const at = tell.at ?? Date.now();
390
514
  const turn = tell.turn ?? newTurn();
391
515
  // 续问一件停放着的: 接替它在停放处的位置, 不碰这一对眼下那一件。
392
516
  const parked = slots.get(keyOfTurn(tell.from, tell.to, turn));
393
517
  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
- }
518
+ // The pair remains a compatibility index for wait_peer/pending. Every older
519
+ // turn, including completed but unconsumed results, keeps its own slot.
520
+ const park = !parked && !!prev && prev.turn !== turn;
402
521
  // 顶掉一份还没落定的工单活、自己又没带工单: 继承那张工单 —— 旧那份不会再投了,
403
522
  // 这一句的答案就是那个成员的交代; 不继承它就永远不计 done, 工单齐不了。停放的那份还会投, 不让。
404
523
  const job = tell.job || (!park && prev?.job && !prev.resolved ? prev.job : "");
405
524
  const s = {
406
525
  ...tell,
526
+ ...(interim && ["need", "error"].includes(interim.status) ? { outcome: interim } : {}),
407
527
  job,
408
528
  at,
409
529
  turn,
@@ -414,24 +534,57 @@ export const createReceipts = (deps) => {
414
534
  delivered: false,
415
535
  resolved: false,
416
536
  ...(parked ? { parked: true } : {}),
537
+ ...(!park && prev?.inbox ? { inbox: prev.inbox.map((m) => ({ ...m,
538
+ generation: m.generation ?? prev.gen,
539
+ state: m.state === "queued" && !isTerminal(m.outcome.status) ? "superseded" : m.state === "delivering" ? "uncertain" : m.state,
540
+ })) } : {}),
417
541
  };
542
+ // Commit both keys atomically; a crash cannot leave two live aliases for
543
+ // the same turn or erase an old event between parking and registering.
544
+ const oldKey = prev && keyOfTurn(prev.from, prev.to, prev.turn ?? "");
545
+ persist({ ...(park && prev && oldKey ? { [oldKey]: { ...prev, parked: true } } : {}), [keyOf(s)]: s });
546
+ if (park && prev && oldKey) {
547
+ prev.parked = true;
548
+ slots.set(oldKey, prev);
549
+ }
418
550
  slots.set(keyOf(s), s);
419
551
  if (watchIt)
420
552
  arm(s);
421
553
  else {
422
554
  s.claimed = true;
423
555
  s.resolved = true;
424
- s.settled = true;
556
+ settle(s);
425
557
  } // 不守 = 这一份没人会投
426
- save(s);
558
+ pump(s.from);
427
559
  return { at: s.at, turn: s.turn };
428
560
  };
429
561
  // ── 停放 (after) ─────────────────────────────────────────────────────
430
562
  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)); };
563
+ // A failed write pauses all releases, including closures already inside transport.
564
+ // Failed cancellation is not acknowledged: retain the original durable/memory row
565
+ // for operator reconciliation before restarting the store.
566
+ let holdWriteFailed = false;
567
+ const writeHold = (write) => {
568
+ if (holdWriteFailed)
569
+ throw new Error("hold persistence failed; reconcile storage before reload");
570
+ try {
571
+ write();
572
+ }
573
+ catch (e) {
574
+ holdWriteFailed = true;
575
+ throw e;
576
+ }
577
+ };
578
+ const saveHold = (h) => writeHold(() => { deps.holdStore?.set(holdKey(h), h); });
579
+ const dropHold = (h) => {
580
+ writeHold(() => { deps.holdStore?.drop(holdKey(h)); });
581
+ holds.delete(holdKey(h));
582
+ };
433
583
  /** 前置件号 → 此刻的样子。先看回执槽 (按发话方 + 件号找, 不论发给谁), 再看自己也停放着的, 最后看快照。 */
434
584
  const prereqOf = (from, turn, seen = {}) => {
585
+ const tracked = deps.workflowPrereq?.(from, turn);
586
+ if (tracked)
587
+ return tracked;
435
588
  const s = [...slots.values()].find((x) => x.from === from && x.turn === turn);
436
589
  const st = s?.outcome?.status;
437
590
  if (s && s.resolved && st && isTerminal(st))
@@ -452,7 +605,10 @@ export const createReceipts = (deps) => {
452
605
  /** 不投了: 落成一份 canceled 回给发话方, 走与别的回执同一条路 (投递、计数、记账都在 watch 里)。
453
606
  * 按件号停放着放进去 (parked), 绝不占 from→to 的位置 —— 那里可能有在飞的另一件。 */
454
607
  const cancelHold = (h, why, silent = false) => {
608
+ if (holds.get(holdKey(h)) !== h)
609
+ return;
455
610
  dropHold(h);
611
+ deps.onOutcome?.({ ...h, gen: 0, claimed: false, delivered: false, resolved: true }, "canceled", why, silent);
456
612
  if (silent)
457
613
  return;
458
614
  const at = Date.now();
@@ -470,14 +626,17 @@ export const createReceipts = (deps) => {
470
626
  const releasing = new Set();
471
627
  /** 逐件看停放着的: 有失败的 → 不投; 都 done 了且这一对没被占着 → 放行; 超过保留期 → 不投。 */
472
628
  const tickHolds = async () => {
629
+ if (stopped || writeFailed || holdWriteFailed)
630
+ return;
473
631
  for (const h of [...holds.values()]) {
474
632
  if (releasing.has(holdKey(h)) || !holds.has(holdKey(h)))
475
633
  continue;
476
634
  const ps = h.after.map((t) => [t, prereqOf(h.from, t, h.seen)]);
477
635
  const fresh = ps.filter(([t, p]) => (p.state === "done" || p.state === "failed") && !h.seen[t]);
478
636
  if (fresh.length) {
479
- fresh.forEach(([t, p]) => { h.seen[t] = p.seen; });
480
- saveHold(h);
637
+ const seen = { ...h.seen, ...Object.fromEntries(fresh.map(([t, p]) => [t, p.seen])) };
638
+ saveHold({ ...h, seen });
639
+ h.seen = seen;
481
640
  }
482
641
  const bad = ps.find(([, p]) => p.state === "failed" || p.state === "unknown");
483
642
  if (bad) {
@@ -497,14 +656,24 @@ export const createReceipts = (deps) => {
497
656
  releasing.add(holdKey(h));
498
657
  void (async () => {
499
658
  try {
500
- const r = (await deps.release?.(h, h.seen)) ?? { ok: false, reason: "没有放行通路" };
659
+ const r = (await deps.release?.(h, h.seen, () => !stopped && !writeFailed && !holdWriteFailed && holds.get(holdKey(h)) === h)) ?? { ok: false, reason: "没有放行通路" };
660
+ if (holds.get(holdKey(h)) !== h)
661
+ return;
501
662
  if (r.ok)
502
663
  dropHold(h);
503
664
  else
504
665
  cancelHold(h, `前置都 done 了, 但这件 (${h.turn}) 投不出去: ${r.reason ?? "未知原因"}, 没投。`);
505
666
  }
506
667
  catch (e) {
507
- cancelHold(h, `放行时出错 (${e.message}), 这件 (${h.turn}) 没投。`);
668
+ try {
669
+ if (!holdWriteFailed)
670
+ cancelHold(h, `放行时出错 (${e.message}), 这件 (${h.turn}) 没投。`);
671
+ }
672
+ catch (writeError) {
673
+ deps.log.error({ err: writeError.message, turn: h.turn }, "receipt: cancellation persistence failed");
674
+ }
675
+ if (holdWriteFailed)
676
+ deps.log.error({ err: e.message, turn: h.turn }, "receipt: hold persistence failed; releases paused for reconciliation");
508
677
  }
509
678
  finally {
510
679
  releasing.delete(holdKey(h));
@@ -523,17 +692,31 @@ export const createReceipts = (deps) => {
523
692
  const nudgeHolds = () => { if (holds.size)
524
693
  void tickHolds().catch(() => undefined); };
525
694
  return {
695
+ stop: () => {
696
+ stopped = true;
697
+ retryTimers.forEach(clearTimeout);
698
+ retryTimers.clear();
699
+ if (holdTimer)
700
+ clearInterval(holdTimer);
701
+ },
702
+ 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
703
  register,
704
+ watching: (tell) => {
705
+ const s = upstream({ from: tell.from, turn: tell.turn ?? "" }, tell.to);
706
+ return !!s && !s.resolved && !s.claimed && (tell.dispatchKey
707
+ ? s.dispatchKey === tell.dispatchKey
708
+ : s.at === tell.at && (s.legs ?? 1) === (tell.legs ?? 1));
709
+ },
527
710
  reask: (from, to, turn) => {
528
711
  const s = upstream({ from, turn }, to);
529
712
  if (!s || s.resolved || s.claimed)
530
713
  return undefined;
531
714
  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 } : {}) });
715
+ 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
716
  return { legs, channel: s.channel, ...(s.asker ? { asker: s.asker } : {}) };
534
717
  },
535
718
  prepare: (from, to, re) => {
536
- const want = re?.replace(/[`\s]/g, "");
719
+ const want = re ? canonicalTurn(re) : undefined;
537
720
  const s = want ? upstream({ from, turn: want }, to) : undefined;
538
721
  return s?.turn && s.turn === want
539
722
  ? { turn: s.turn, legs: (s.legs ?? 1) + 1, job: s.job, channel: s.channel, asker: s.asker, k: s.k }
@@ -543,7 +726,14 @@ export const createReceipts = (deps) => {
543
726
  const s = slots.get(keyOfPair(from, to));
544
727
  if (!s)
545
728
  return false;
729
+ if (s.answerTransfer)
730
+ return s.delivered;
731
+ // Transport may already have exposed this event. Do not let a concurrent
732
+ // wait_peer take a second copy, or turn an unknown ACK into a success.
733
+ if (s.inbox?.some((m) => (m.generation ?? s.gen) === s.gen && ["delivering", "uncertain"].includes(m.state)))
734
+ return "uncertain";
546
735
  const fresh = !s.settled;
736
+ s.inbox?.filter((m) => m.state === "queued" && (m.generation ?? s.gen) === s.gen).forEach((m) => { m.state = "claimed"; });
547
737
  s.claimed = true;
548
738
  s.resolved = true;
549
739
  settle(s);
@@ -588,9 +778,10 @@ export const createReceipts = (deps) => {
588
778
  from: s.from, to: s.to, turn: s.turn ?? "", job: s.job,
589
779
  state: turnState(s, { parked: !s.outcome && parked(s.to) }),
590
780
  ageMs: Date.now() - s.at,
781
+ ...(s.k ? { k: s.k } : {}),
591
782
  }))
592
783
  // 停放着的 (等前置): 还没投出去, 但发话方在等它 —— 同样在飞。
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 })));
784
+ .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
785
  },
595
786
  cancel: (target, by, only = () => true) => {
596
787
  // 排着还没投的 (停放): 不投了; 发起 stop 的那方自己知道, 不回执。
@@ -598,7 +789,11 @@ export const createReceipts = (deps) => {
598
789
  held.forEach((h) => cancelHold(h, `${deps.nameOf(target)} 被 ${deps.nameOf(by)} 收掉 / 打断了, 这件排着的活 (${h.turn}) 没投。`, h.from === by));
599
790
  const hit = [...slots.values()].filter((s) => s.to === target && !s.resolved && !s.claimed && only({ from: s.from, turn: s.turn ?? "" }));
600
791
  for (const s of hit) {
601
- const last = deps.lastWords?.(s.to, s.at).trim();
792
+ // Keep cancellation consistent with timeout/dead/silent: transferred
793
+ // answers may only quote text tied to their own source turn.
794
+ const last = (s.answerTransfer
795
+ ? (s.turn ? deps.replyFor(s.to, deps.nameOf(s.from), s.at, s.turn)?.text : undefined)
796
+ : deps.lastWords?.(s.to, s.at))?.trim();
602
797
  s.outcome = {
603
798
  status: "canceled",
604
799
  body: `(守护进程: ${deps.nameOf(s.to)} 被 ${deps.nameOf(by)} 收掉 / 打断了, 这件活没有结论。)${last ? `\n它最后说的: ${clipMiddle(last, 600)}` : ""}`,
@@ -607,7 +802,7 @@ export const createReceipts = (deps) => {
607
802
  deps.settleJob?.(s.job, s.to, "canceled", []);
608
803
  deps.onOutcome?.(s, "canceled", s.outcome.body, s.from === by);
609
804
  // 发起 stop 的那方自己知道, 不投; 守着的 watcher 看到 claimed 就收尾。
610
- if (s.from === by) {
805
+ if (s.from === by && !s.answerTransfer) {
611
806
  s.claimed = true;
612
807
  s.resolved = true;
613
808
  settle(s);
@@ -622,11 +817,11 @@ export const createReceipts = (deps) => {
622
817
  nudgeHolds();
623
818
  return hit.length + held.length;
624
819
  },
625
- prereqs: (from, turns) => turns.map((t) => prereqOf(from, t.replace(/[`\s]/g, ""))),
820
+ prereqs: (from, turns, seen) => turns.map((t) => prereqOf(from, canonicalTurn(t), seen)),
626
821
  hold: (h) => {
627
822
  const x = { ...h, seen: {} };
628
- holds.set(holdKey(x), x);
629
823
  saveHold(x);
824
+ holds.set(holdKey(x), x);
630
825
  startHolds();
631
826
  nudgeHolds();
632
827
  },
@@ -636,6 +831,8 @@ export const createReceipts = (deps) => {
636
831
  return hit.length;
637
832
  },
638
833
  parentOf: (self, env, channel, opening) => {
834
+ if (env?.quiet)
835
+ return undefined;
639
836
  if (env?.receipt)
640
837
  return env.k ? kOfAttr(env.k) : undefined;
641
838
  if (env?.kind === "peer" && env.private) {
@@ -668,8 +865,37 @@ export const createReceipts = (deps) => {
668
865
  resume: () => {
669
866
  // 挂起且子活还没落定的不守: 叫醒它是 relay / release 的事, 这里再 arm 一个就是两个 watcher。
670
867
  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); });
868
+ // Restore durable inboxes before observing new worker results. The
869
+ // delivering boundary is ambiguous after a crash, never an automatic retry.
870
+ for (const s of slots.values()) {
871
+ for (const m of s.inbox ?? []) {
872
+ if (m.state === "delivering") {
873
+ m.state = "uncertain";
874
+ m.reason = "daemon restarted during delivery; reconciliation required";
875
+ s.undelivered = true;
876
+ save(s);
877
+ }
878
+ if (!m.accounted && !["superseded", "claimed"].includes(m.state))
879
+ account(s, m);
880
+ finishMail(s, m);
881
+ }
882
+ pump(s.from);
883
+ }
884
+ open.forEach((s) => {
885
+ if (s.resolved && s.inbox?.some((m) => (m.generation ?? s.gen) === s.gen))
886
+ return;
887
+ if (!s.answerTransfer && s.outcome && isTerminal(s.outcome.status) && (s.claimed || s.delivered)) {
888
+ // Old-format transport had no intent journal. Preserve it explicitly
889
+ // rather than replaying a possibly delivered legacy result.
890
+ s.inbox = [{ id: `r${randomBytes(12).toString("hex")}`, order: ++mailOrder, queuedAt: Date.now(),
891
+ outcome: s.outcome, state: "uncertain", attempts: 1, reason: "legacy delivery boundary unknown" }];
892
+ s.undelivered = true;
893
+ save(s);
894
+ return;
895
+ }
896
+ s.claimed = false;
897
+ arm(s);
898
+ });
673
899
  if (open.length)
674
900
  deps.log.info({ mod: "receipt", resumed: open.map((s) => `${deps.nameOf(s.from)}←${deps.nameOf(s.to)}`) }, "receipt: reload 后续守");
675
901
  startHolds();