wezard 2.6.4 → 2.7.1

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 (97) hide show
  1. package/CLAUDE.md +6 -6
  2. package/README.md +1 -15
  3. package/dist/cli/codex-hook.js +5 -1
  4. package/dist/cli/codex-hook.js.map +1 -1
  5. package/dist/cli/sync.js +5 -4
  6. package/dist/cli/sync.js.map +1 -1
  7. package/dist/daemon/approval.js +12 -5
  8. package/dist/daemon/approval.js.map +1 -1
  9. package/dist/daemon/chat-log.js +5 -5
  10. package/dist/daemon/chat-log.js.map +1 -1
  11. package/dist/daemon/chat-name.js +5 -2
  12. package/dist/daemon/chat-name.js.map +1 -1
  13. package/dist/daemon/detail.js +28 -5
  14. package/dist/daemon/detail.js.map +1 -1
  15. package/dist/daemon/direction.js +3 -2
  16. package/dist/daemon/direction.js.map +1 -1
  17. package/dist/daemon/dispatch.js +6 -0
  18. package/dist/daemon/dispatch.js.map +1 -0
  19. package/dist/daemon/handoff.js +66 -15
  20. package/dist/daemon/handoff.js.map +1 -1
  21. package/dist/daemon/inbound.js +3 -5
  22. package/dist/daemon/inbound.js.map +1 -1
  23. package/dist/daemon/index.js +1072 -714
  24. package/dist/daemon/index.js.map +1 -1
  25. package/dist/daemon/jobs.js +7 -2
  26. package/dist/daemon/jobs.js.map +1 -1
  27. package/dist/daemon/last-response.js +8 -3
  28. package/dist/daemon/last-response.js.map +1 -1
  29. package/dist/daemon/message-events.js +232 -59
  30. package/dist/daemon/message-events.js.map +1 -1
  31. package/dist/daemon/mirror-bridge.js +556 -770
  32. package/dist/daemon/mirror-bridge.js.map +1 -1
  33. package/dist/daemon/mirror-store.js.map +1 -1
  34. package/dist/daemon/notices.js +9 -2
  35. package/dist/daemon/notices.js.map +1 -1
  36. package/dist/daemon/peers.js +122 -143
  37. package/dist/daemon/peers.js.map +1 -1
  38. package/dist/daemon/pending-items.js +33 -34
  39. package/dist/daemon/pending-items.js.map +1 -1
  40. package/dist/daemon/route.js +5 -2
  41. package/dist/daemon/route.js.map +1 -1
  42. package/dist/daemon/router.js +643 -0
  43. package/dist/daemon/router.js.map +1 -0
  44. package/dist/daemon/session-name.js +1 -1
  45. package/dist/daemon/session-name.js.map +1 -1
  46. package/dist/daemon/spawn-tmux.js +32 -13
  47. package/dist/daemon/spawn-tmux.js.map +1 -1
  48. package/dist/daemon/subagent-tail.js +3 -3
  49. package/dist/daemon/subagent-tail.js.map +1 -1
  50. package/dist/daemon/{cc-session.js → wezard-session.js} +32 -2
  51. package/dist/daemon/wezard-session.js.map +1 -0
  52. package/dist/daemon/wizard.js +5 -5
  53. package/dist/daemon/wizard.js.map +1 -1
  54. package/dist/daemon/workflow-nodes.js +3 -3
  55. package/dist/daemon/workflow-nodes.js.map +1 -1
  56. package/dist/daemon/workflow-plans.js +9 -6
  57. package/dist/daemon/workflow-plans.js.map +1 -1
  58. package/dist/mcp/server.js +38 -47
  59. package/dist/mcp/server.js.map +1 -1
  60. package/dist/shared/chat-http.js +2 -2
  61. package/dist/shared/chat-http.js.map +1 -1
  62. package/dist/shared/chat-view.js +0 -4
  63. package/dist/shared/chat-view.js.map +1 -1
  64. package/dist/shared/cli-backends.js +11 -1
  65. package/dist/shared/cli-backends.js.map +1 -1
  66. package/dist/shared/config.js +3 -44
  67. package/dist/shared/config.js.map +1 -1
  68. package/dist/shared/detail-render.js +36 -11
  69. package/dist/shared/detail-render.js.map +1 -1
  70. package/dist/shared/detail-store.js.map +1 -1
  71. package/dist/shared/message-footer.js +8 -8
  72. package/dist/shared/message-footer.js.map +1 -1
  73. package/dist/shared/reminder.js +104 -37
  74. package/dist/shared/reminder.js.map +1 -1
  75. package/dist/shared/role-render.js +28 -20
  76. package/dist/shared/role-render.js.map +1 -1
  77. package/dist/shared/role-search.js +3 -3
  78. package/dist/shared/role-search.js.map +1 -1
  79. package/dist/shared/role-view.js +50 -30
  80. package/dist/shared/role-view.js.map +1 -1
  81. package/dist/shared/session-label.js +25 -6
  82. package/dist/shared/session-label.js.map +1 -1
  83. package/dist/shared/turn-state.js +7 -7
  84. package/dist/shared/turn-state.js.map +1 -1
  85. package/dist/shared/world.js +1 -3
  86. package/dist/shared/world.js.map +1 -1
  87. package/hooks/pre-tool-use.sh +5 -25
  88. package/package.json +1 -1
  89. package/web/chat.css +3 -33
  90. package/web/chat.js +11 -62
  91. package/dist/daemon/answer-transfer.js +0 -56
  92. package/dist/daemon/answer-transfer.js.map +0 -1
  93. package/dist/daemon/cc-session.js.map +0 -1
  94. package/dist/daemon/receipts.js +0 -968
  95. package/dist/daemon/receipts.js.map +0 -1
  96. package/dist/shared/keepalive.js +0 -46
  97. package/dist/shared/keepalive.js.map +0 -1
@@ -1,968 +0,0 @@
1
- // 回执: 一次 tell_peer 之后, 对方干完那一轮, 守护进程把它的结论**自动**送回发话方
2
- // 的输入框。
3
- //
4
- // 在这之前, 发话方只有两条路: 挂在 wait_peer 上阻塞等 (把一个能干活的 pane 锁死在
5
- // 轮询里, 而且一次只能等一批、等完才醒), 或者自己记得回头 peek —— 后者实际上没人做。
6
- // 异步回执把「问」和「收」拆开: 问完就接着干自己的事, 答案到了作为新的一轮进来。
7
- //
8
- // 这不是第二个编排器, 是一条投递通路。它只做四件事, 每一件都复用现成的判定:
9
- // 等对方停下 (mirror.untilIdle, 注册表判闲) → 取它答**这一句**的那段话
10
- // (peers.replyToPeer, 按信封定位而不是按时刻) → 等发话方停下 → paste 进去。
11
- // 控制流仍在发话方自己的上下文里: 它没有被阻塞, 也没有被谁代理。
12
- //
13
- // 登记落盘 (`store`), boot 时把没收尾的重新守起来。不能纯内存: 这个仓库里被派活的
14
- // wizard 自己就常以 `build + reload` 收尾 —— reload 恰好落在它那一轮里, 内存里的
15
- // watcher 跟着进程一起没了, 发话方永远等不到回执。答案本来就在对方的 transcript 里
16
- // (按信封定位), 重启后接着守, 取到的还是同一段。
17
- import { randomBytes } from "node:crypto";
18
- import { clipMiddle, parseClosing } from "./peers.js";
19
- import { isTerminal, turnState } from "../shared/turn-state.js";
20
- import { sleep } from "../shared/std.js";
21
- /** 对方最长允许干多久 (超过就投一份 timeout 回执, 不占着内存等到天亮)。默认值;
22
- * tell_peer 的 `deadline` 可在 [MIN, MAX] 里改 —— 上限留在 KEEP_MS 之内, 超时那份
23
- * 回执还得数得进工单。 */
24
- const TARGET_WAIT_SEC = 3600;
25
- const DEADLINE_MIN_SEC = 60;
26
- const DEADLINE_MAX_SEC = 12 * 3600;
27
- /** 交接把义务转进新会话时, 至少再给它这么久: 不然对方一交接, 这份就提前超时。 */
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, "");
31
- /** 一件活的编号: 注入之前生成, 写进信封, 回执按它定位答句。 */
32
- export const newTurn = () => `t${randomBytes(3).toString("hex")}`;
33
- /** `at` 发出、给了 `sec` 秒 (缺省 / 越界取默认或夹到边上) 的期限。 */
34
- export const deadlineOf = (at, sec) => at + 1000 * (sec === undefined || !Number.isFinite(sec) ? TARGET_WAIT_SEC : Math.min(Math.max(sec, DEADLINE_MIN_SEC), DEADLINE_MAX_SEC));
35
- /** 对方 pane 不在了, 隔这么久再看一眼才判 dead: 非交接的 pane 换新 (respawn) 也有
36
- * 几秒是死的。 */
37
- const DEAD_GRACE_MS = 5000;
38
- /** 停放件隔多久看一次前置落定了没有 (落定时另有事件触发, 这是兜底)。 */
39
- const HOLD_POLL_MS = 3000;
40
- /** 对方读进了我们这一句、停下了却没答 —— 再等下一次停下。连着这么多次都没答就认定
41
- * 它不会答了, 别守到天亮。我们那一句还没被读进 (排在别的轮后面) 的那几次不算: 那是
42
- * 在等前面的轮, 不是它不答; 只有整段 ramp 都闲着、问话仍不在 transcript 里, 才算一次
43
- * (那一句多半丢了)。 */
44
- const MAX_FRUITLESS = 3;
45
- /** 等对方「动起来」的窗口: 注册表在它读进这一句之前仍报 idle, 不等这一下就会在它开工
46
- * 之前读 transcript, 读到的还是上一件事。轮询放慢到 2s: idleNow 与 answered 都同步读
47
- * tail, 十几路 fan-out 同时 ramp 时 1s 一次会压住事件循环。 */
48
- const RAMP_MS = 20_000;
49
- const RAMP_POLL_MS = 2000;
50
- /** 每次最多等对方这么久就回来重读 deadline: 交接 transfer 会把 s.at 往后推, 一次定死的
51
- * 超时会早于新的 deadline 放弃。 */
52
- const IDLE_STEP_MS = 5 * 60_000;
53
- /** codebuddy 没有注册表, 判闲靠 pane 采样 —— 它停在本地对话框上时 spinner 也会消失,
54
- * 仍会被算作闲 (P1 在那边没修), 多采一次压低误判。发话方投递路径保持 2。 */
55
- const IDLE_CONFIRM = 3;
56
- /** 信封属性上的 k: `chat:<turn>:<base>` / `peer:<turn>:<key>` —— 只给机器读。chat 的
57
- * `turn` 是开那一轮的那句人话的时刻: 人先后问的两件事各派了活, 不能被当成兄弟。 */
58
- export const kAttr = (k) => `${k.kind}:${k.turn}:${k.kind === "chat" ? k.channel : k.from}`;
59
- export const kOfAttr = (a) => {
60
- const m = a.match(/^(chat|peer):([^:]+):(.+)$/);
61
- return !m ? undefined : m[1] === "chat" ? { kind: "chat", turn: m[2], channel: m[3] } : { kind: "peer", turn: m[2], from: m[3] };
62
- };
63
- const sameK = (a, b) => kAttr(a) === kAttr(b);
64
- /** 落盘的登记留多久。比对方最长允许干的时长宽, 好让续守的那一份仍数得进工单。 */
65
- const KEEP_MS = 24 * 3600_000;
66
- export const createReceipts = (deps) => {
67
- let stopped = false;
68
- let writeFailed = false;
69
- const keyOfPair = (from, to) => `${from}\u0000${to}`;
70
- const keyOfTurn = (from, to, turn) => `${keyOfPair(from, to)}\u0000${turn}`;
71
- const keyOf = (s) => (s.parked ? keyOfTurn(s.from, s.to, s.turn ?? "") : keyOfPair(s.from, s.to));
72
- const live = (s) => !s.settled || Date.now() - s.at < KEEP_MS || !!s.inbox?.some((m) => ["queued", "delivering", "uncertain"].includes(m.state));
73
- // 过了保留期的行开机时从盘上抹掉: 同一对的位置会被下一句覆盖, 按件号停放的不会。
74
- Object.entries(deps.store?.all() ?? {}).filter(([, s]) => !live(s)).forEach(([key]) => deps.store?.drop(key));
75
- const slots = new Map(Object.values(deps.store?.all() ?? {}).filter(live).map((s) => { const row = structuredClone(s); return [keyOf(row), row]; }));
76
- const holds = new Map(Object.values(deps.holdStore?.all() ?? {}).map((h) => [keyOfTurn(h.from, h.to, h.turn), h]));
77
- /** 写穿到磁盘。被同一对的新一句顶掉的旧 slot 不写 —— 它的 key 已经归新的了。 */
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
- }
88
- };
89
- const save = (s) => { if (!stale(s))
90
- persist({ [keyOf(s)]: s }); };
91
- const settle = (s) => { s.settled = true; save(s); };
92
- const stale = (s) => stopped || slots.get(keyOf(s)) !== s;
93
- /** `to` 答 `k` 那件活的那一份 —— 在 `from→to` 的位置上, 或已按件号停放。 */
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);
95
- const ofJob = (from, job) => [...slots.values()].filter((x) => x.from === from && x.job === job);
96
- /** P 的答话方在答 P 的那一轮里又派出去、还没落定的活。 */
97
- const children = (p) => [
98
- ...[...slots.values()].filter((c) => c.from === p.to && c.k?.kind === "peer" && c.k.from === p.from && c.k.turn === p.turn && !c.settled),
99
- // 停放着的子活同样挂住上游: 它投出去、落定之前, 发话方给上游的交代不能先走。
100
- ...[...holds.values()].filter((c) => c.from === p.to && c.k?.kind === "peer" && c.k.from === p.from && c.k.turn === p.turn),
101
- ];
102
- /** 与 c 同一个发话方、同一个父 k、定论还没送进去的那几份 (不含 c)。送进去的只是一份
103
- * error 的那种还没落定: 续跑出来的真答案还要来。 */
104
- const siblings = (c) => [
105
- ...[...slots.values()].filter((x) => x !== c && x.from === c.from && !!x.k && sameK(x.k, c.k) && !x.settled && !(x.delivered && x.resolved)),
106
- ...[...holds.values()].filter((x) => x.from === c.from && !!x.k && sameK(x.k, c.k)),
107
- ];
108
- /** 这份回执送进去之后, 发话方那一轮的终句去哪 (§3.3)。只在投递那一刻算: 兄弟们是
109
- * 陆续回来的, 早算一步就会两份都以为自己不是最后一份。
110
- * 频道只由去向定: 续回进群 (父 k 是人在群里开的那一轮、兄弟都落定了) 才有频道, 其余
111
- * 一律 "" —— 回执轮不是谁在群里问的话。那件子活当初 public:true 派的也不例外: 公开的是
112
- * 派活那一句和对方的答复, 不是发话方收到回执之后那一轮; 沿用它的频道, 私聊 ask 底下的
113
- * 一轮就会因为没有父 k (chain:false)、只是中途回执 (NEED)、或上游那份已被取走而进群。 */
114
- const routeOf = (s, final) => {
115
- const k = s.k;
116
- if (!k || !final)
117
- return { channel: "", ...(k ? { k } : {}) };
118
- const pending = siblings(s).length;
119
- if (pending)
120
- return { channel: "", k, pending };
121
- if (k.kind === "chat")
122
- return { channel: k.channel, k, replyTo: k };
123
- const p = upstream(k, s.from);
124
- return p && !p.resolved && !p.claimed ? { channel: "", k, replyTo: k, parent: p } : { channel: "", k };
125
- };
126
- /** 续回: 上游那一份改锚到刚送进去的那份回执 (answerOf 按 reply-to 认它), 重新守。
127
- * 正在守的那个 watcher 自己会发现锚变了; 挂起 (deferred) 的才要重新 arm。 */
128
- const relay = (p, at) => {
129
- p.at = at;
130
- p.deadlineAt = Math.max(p.deadlineAt ?? 0, deadlineOf(at));
131
- wake(p);
132
- };
133
- const wake = (p) => {
134
- const was = p.deferred;
135
- delete p.deferred;
136
- save(p);
137
- if (was)
138
- arm(p, true);
139
- };
140
- /** 子活落定了却没有一份回执送进发话方 (发话方不在了 / 等不到它闲 / wait_peer 取走):
141
- * 它若是挂起的上游等着的最后一份, 照样叫醒上游 —— 不然上游连一份 dead 都收不到。
142
- * 锚不动: 上游按原来那一句重新守, 答话方不在了就落成 dead。 */
143
- const release = (s) => {
144
- const p = routeOf(s, true).parent;
145
- if (p)
146
- wake(p);
147
- };
148
- // 一份 slot 同时只有一个 watcher: reload 续守与 relay 叫醒可能撞在一起, 两个 watcher
149
- // 会把同一个答案投两遍。叫醒挂起的那份要 `force`: 挂起那一刻旧 watcher 已经同步返回,
150
- // 只是 finally 还没跑; 用 token 免得旧的 finally 删掉新登记的那个。
151
- const watching = new Map();
152
- const arm = (s, force = false) => {
153
- if (s.queued || (watching.has(s) && !force))
154
- return;
155
- const token = Symbol();
156
- watching.set(s, token);
157
- void watch(s)
158
- .catch((e) => deps.log.warn({ err: e.message }, "receipt watch failed"))
159
- .finally(() => { if (watching.get(s) === token)
160
- watching.delete(s); });
161
- };
162
- /** 对方这一轮结束了。判闲以注册表为准 (`untilIdle`): pane 的 spinner 在本地对话框上
163
- * (hook 回落成 ask / AskUserQuestion) 和长工具调用的间隙里都会消失, 按它判闲会把
164
- * 一个停在对话框上的 wizard 当成「答完了却没答」, 扑空三次就把回执扔了。
165
- * 先等它动起来 (`rampEnd` 之前): 还没读进这一句的会话注册表照样报 idle; 已经答出
166
- * 来了 (`answered`, 太快的一轮可能在两次采样之间就跑完) 就不必等满。
167
- * `quiet` = 整段 ramp 它都闲着、什么也没答 —— 调用方据此判断那一句是不是丢了。 */
168
- const settled = async (to, rampEnd, deadline, aborted, answered) => {
169
- for (;;) {
170
- if (aborted())
171
- return { idle: false, reason: "stopped" };
172
- if (Date.now() >= rampEnd)
173
- return { idle: true, quiet: true };
174
- if (!(await deps.idleNow(to)) || answered())
175
- break;
176
- await sleep(RAMP_POLL_MS);
177
- }
178
- for (;;) {
179
- const left = deadline() - Date.now();
180
- if (left <= 0)
181
- return { idle: false, reason: "timeout" };
182
- const r = await deps.untilIdle(to, Math.min(left, IDLE_STEP_MS), { aborted, confirm: IDLE_CONFIRM });
183
- if (r.idle || aborted())
184
- return r;
185
- }
186
- };
187
- const canceled = (s) => s.outcome?.status === "canceled";
188
- /** 守到对方答出**我们这一句**为止。信封是比发话时刻更硬的锚 (见 peers.replyToPeer):
189
- * 对方正忙时我们那一句是排队的, 它先吐出来的是上一件事的结论 —— 按时刻取就会把
190
- * 旧结论当成这一次的回执。所以"停下了但还没答我们"要接着等, 不能将就; 定位不到
191
- * 问话 (undefined) 也只说明它还没读进, 绝不退回按时刻取。
192
- * 那一轮以 CLI 报错收尾 → `error`; 报过一次之后只等续跑出来的真答案, 不再计扑空。
193
- * `undefined` = 被 wait_peer 取走或被新的一句顶掉, 调用方自己知道。 */
194
- const awaitReply = async (s) => {
195
- // 锚在发话时刻而不是此刻: reload 后续守的那一份不该重新领一整份时长。交接转过来
196
- // 的那份改锚到贴回简报的时刻, 所以每圈重算。
197
- const deadline = () => s.deadlineAt ?? s.at + TARGET_WAIT_SEC * 1000;
198
- // cancel 落的终态也算: 守到一半被别人收掉, 不再等它的答案。
199
- const aborted = () => s.claimed || stale(s) || canceled(s);
200
- const reported = () => s.outcome?.status === "error";
201
- // 反问过了: 同一段 NEED 不再投第二遍, 等发话方 tell_peer({re}) 答 (那会顶掉这份)。
202
- const asked = () => s.outcome?.status === "need";
203
- const reply = () => deps.replyFor(s.to, deps.nameOf(s.from), s.at, s.turn);
204
- const news = (r) => !!r?.text.trim() && !(r.error && reported()) && !(asked() && r.text === s.outcome.body) && r.text !== s.bounced;
205
- const failed = (status) => ({ status, body: failure(s, status) });
206
- for (let fruitless = 0; fruitless < MAX_FRUITLESS;) {
207
- if (Date.now() >= deadline())
208
- return failed("timeout");
209
- // 对方 pane 没了 (被收掉 / 崩了): 不会再答, 别空转三次 ramp。交接重开的那几秒
210
- // pane 也是死的, 先等交接收尾再看; 别的换新 (respawn) 给一小段 grace。
211
- await deps.handedOff?.(s.to);
212
- if (s.answer !== undefined)
213
- return { status: "done", body: s.answer };
214
- if (!(await deps.paneLive(s.to))) {
215
- await sleep(DEAD_GRACE_MS);
216
- await deps.handedOff?.(s.to);
217
- if (s.answer !== undefined)
218
- return { status: "done", body: s.answer };
219
- if (!(await deps.paneLive(s.to)))
220
- return failed("dead");
221
- }
222
- const wr = await settled(s.to, Math.min(deadline(), Date.now() + RAMP_MS), deadline, aborted, () => news(reply()));
223
- if (aborted())
224
- return undefined;
225
- if (!wr.idle) {
226
- if (wr.reason === "timeout")
227
- return failed("timeout");
228
- continue;
229
- }
230
- // 它停下是因为在交接: 等交接收尾, 答案要么已钉在 s.answer, 要么义务转进了新会话
231
- // (那就接着守新会话)。这一段与下面的 replyFor 之间不能有 await —— 交接一旦
232
- // 开始就在旧会话还在时把 transfer 做完, 同步读到的必然还是旧 transcript。
233
- const handing = deps.handingOff?.(s.to) ?? false;
234
- if (handing)
235
- await deps.handedOff?.(s.to);
236
- if (s.answer !== undefined)
237
- return { status: "done", body: s.answer };
238
- if (handing)
239
- continue;
240
- const got = reply();
241
- if (got && news(got))
242
- return { status: got.error ? "error" : parseClosing(got.text).kind === "need" ? "need" : "done", body: got.text };
243
- // 读进了没答 = 扑空一次; 还没读进 = 前面有别的轮, 接着等 —— 除非整段 ramp 都静着。
244
- // 报过 error / NEED 之后它停着是在等人续跑或答复, 不算扑空, 守到期限。
245
- if (!reported() && !asked() && (got !== undefined || wr.quiet))
246
- fruitless++;
247
- }
248
- // 打回过的那份: 它原样再交一遍 (按原文排除, 看不出是新的), 或打回那句因 reload 没注入
249
- // 进去 —— 都扑空到这里。手上的原文就是它的交代, 照收, 别报 silent。
250
- return s.bounced ? { status: "done", body: s.bounced } : failed("silent");
251
- };
252
- /** 失败回执的正文: 守护进程的一句说明 + 对方最后一句 (给发话方一点线索)。 */
253
- const failure = (s, status) => {
254
- const why = {
255
- timeout: s.outcome?.status === "need"
256
- ? `它的反问 (NEED) 等了 ${Math.round((Date.now() - s.at) / 60_000)} 分钟没人答`
257
- : `等了 ${Math.round((Date.now() - s.at) / 60_000)} 分钟它还没答完`,
258
- silent: `它停下了 ${MAX_FRUITLESS} 次, 都没答这一句`,
259
- dead: "它的 pane 没了",
260
- };
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();
267
- return `(守护进程: ${deps.nameOf(s.to)} 没有给出结论 —— ${why[status] ?? status}。)${last ? `\n它最后说的: ${clipMiddle(last, 600)}` : ""}`;
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
- };
446
- const watch = async (s) => {
447
- const lg = deps.log.child({ mod: "receipt", from: deps.nameOf(s.from), to: deps.nameOf(s.to), ...(s.job ? { job: s.job } : {}) });
448
- const anchor = s.at;
449
- // 终态写一次不再改: reload 打断投递后续守的那一份直接重投同一个结果, 不再重等。
450
- const got = s.outcome && isTerminal(s.outcome.status) ? s.outcome : await awaitReply(s);
451
- // 等的过程中被 cancel 落了终态: 以它为准 (pane 随后被杀, awaitReply 可能报的是 dead)。
452
- const out = canceled(s) ? s.outcome : got;
453
- if (stale(s))
454
- return;
455
- if (canceled(s) && s.cancellationId && s.inbox?.some((m) => m.id === s.cancellationId)) {
456
- pump(s.from);
457
- return;
458
- }
459
- if (s.claimed || !out) { // wait_peer 抢先取走了
460
- s.resolved = true;
461
- settle(s);
462
- if (s.job)
463
- deps.settleJob?.(s.job, s.to, "done", [], { turn: s.turn, dispatchKey: s.jobDispatchKey ?? s.dispatchKey });
464
- return;
465
- }
466
- // 下面到 save 之间不能有 await: relay 与这里的判定必须看到同一个状态。
467
- // 锚被 relay 挪到了回执那一句: 刚取到的是旧锚的答案 (那句「已派」), 重来。
468
- if (s.at !== anchor)
469
- return watch(s);
470
- // 它这一轮又派了活, 结论要等那些回来 —— 这一句不是定论: 不投、不计数 (§3.2)。
471
- if (out.status === "done" && children(s).length) {
472
- s.deferred = true;
473
- save(s);
474
- lg.info({ children: children(s).map((c) => deps.nameOf(c.to)) }, "receipt: 答话方又派了活, 等子回执回来再续");
475
- return;
476
- }
477
- // 工单验收: 交了个空 RESULT (或缺约定的 ARTIFACT) —— 不投、不计数, 以发话方的名义同件号
478
- // 打回一次; 再交上来的照收。新登记顶掉这一份 (它随即 stale), 守的是打回那一句的答案。
479
- const why = out.status === "done" && s.job && !s.bounced && !deps.workflowPrereq?.(s.from, s.turn ?? "") ? deps.accept?.(s.job, out.body) : undefined;
480
- if (why && deps.rebound) {
481
- const at = Date.now();
482
- const legs = (s.legs ?? 1) + 1;
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
- }
491
- lg.info({ why, ok }, "receipt: 不合工单验收, 打回一次");
492
- // 打回没注入进去: 原来那份照常交上去 (钉成新 slot 的答案, 它的 watcher 直接拿去投)。
493
- if (!ok) {
494
- const n = upstream({ from: s.from, turn: s.turn }, s.to);
495
- if (n?.bounced) {
496
- n.answer = out.body;
497
- save(n);
498
- }
499
- }
500
- return;
501
- }
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;
516
- settle(s);
517
- return;
518
- }
519
- enqueueOutcome(s, out);
520
- // Watching the worker continues even if the asker cannot consume NEED/error.
521
- if (!isTerminal(out.status))
522
- return watch(s);
523
- };
524
- const register = (tell, watchIt = true, interim) => {
525
- const at = tell.at ?? Date.now();
526
- const turn = tell.turn ?? newTurn();
527
- // 续问一件停放着的: 接替它在停放处的位置, 不碰这一对眼下那一件。
528
- const parked = slots.get(keyOfTurn(tell.from, tell.to, turn));
529
- const prev = parked ?? slots.get(keyOfPair(tell.from, tell.to));
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;
533
- // 顶掉一份还没落定的工单活、自己又没带工单: 继承那张工单 —— 旧那份不会再投了,
534
- // 这一句的答案就是那个成员的交代; 不继承它就永远不计 done, 工单齐不了。停放的那份还会投, 不让。
535
- const job = tell.job || (!park && prev?.job && !prev.resolved ? prev.job : "");
536
- const s = {
537
- ...tell,
538
- ...(interim && ["need", "error"].includes(interim.status) ? { outcome: interim } : {}),
539
- job,
540
- at,
541
- turn,
542
- legs: tell.legs ?? 1,
543
- deadlineAt: tell.deadlineAt ?? deadlineOf(at),
544
- gen: (prev?.gen ?? 0) + 1,
545
- claimed: false,
546
- delivered: false,
547
- resolved: false,
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
- })) } : {}),
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
- }
562
- slots.set(keyOf(s), s);
563
- if (tell.queued) { /* accepted obligation; no silent/dead watcher until submission */ }
564
- else if (watchIt)
565
- arm(s);
566
- else {
567
- s.claimed = true;
568
- s.resolved = true;
569
- settle(s);
570
- } // 不守 = 这一份没人会投
571
- pump(s.from);
572
- return { at: s.at, turn: s.turn };
573
- };
574
- // ── 停放 (after) ─────────────────────────────────────────────────────
575
- const holdKey = (h) => keyOfTurn(h.from, h.to, h.turn);
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
- };
596
- /** 前置件号 → 此刻的样子。先看回执槽 (按发话方 + 件号找, 不论发给谁), 再看自己也停放着的, 最后看快照。 */
597
- const prereqOf = (from, turn, seen = {}) => {
598
- const tracked = deps.workflowPrereq?.(from, turn);
599
- if (tracked)
600
- return tracked;
601
- const s = [...slots.values()].find((x) => x.from === from && x.turn === turn);
602
- const st = s?.outcome?.status;
603
- if (s && s.resolved && st && isTerminal(st))
604
- return { state: st === "done" ? "done" : "failed", seen: { to: s.to, status: st, body: s.outcome.body } };
605
- // wait_peer 取走的: resolved 而没有 outcome —— 结论已在发话方会话里, 算 done。
606
- if (s && s.resolved && !st)
607
- return { state: "done", seen: { to: s.to, status: "done", body: "" } };
608
- if (s || [...holds.values()].some((h) => h.from === from && h.turn === turn))
609
- return { state: "pending" };
610
- const old = seen[turn];
611
- return old ? { state: old.status === "done" ? "done" : "failed", seen: old } : { state: "unknown" };
612
- };
613
- /** 同一对眼下有一件别的、没落定的活: 现在投会顶掉它的回执槽 —— 等它落定。 */
614
- const pairBusy = (h) => {
615
- const cur = slots.get(keyOfPair(h.from, h.to));
616
- return !!cur && !cur.resolved && !cur.claimed && cur.turn !== h.turn;
617
- };
618
- /** 不投了: 落成一份 canceled 回给发话方, 走与别的回执同一条路 (投递、计数、记账都在 watch 里)。
619
- * 按件号停放着放进去 (parked), 绝不占 from→to 的位置 —— 那里可能有在飞的另一件。 */
620
- const cancelHold = (h, why, silent = false) => {
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);
628
- return;
629
- }
630
- const at = Date.now();
631
- const s = {
632
- from: h.from, to: h.to, channel: h.channel, job: h.job, at, turn: h.turn, legs: 1, deadlineAt: at,
633
- ...(h.k ? { k: h.k } : {}), ...(h.asker ? { asker: h.asker } : {}),
634
- gen: (slots.get(keyOfTurn(h.from, h.to, h.turn))?.gen ?? 0) + 1,
635
- claimed: silent, delivered: false, resolved: silent, settled: silent, parked: true,
636
- outcome: { status: "canceled", body: `(守护进程: ${why})` },
637
- };
638
- slots.set(keyOf(s), s);
639
- save(s);
640
- if (silent)
641
- deps.onOutcome?.(s, "canceled", s.outcome.body, true);
642
- if (!silent)
643
- enqueueOutcome(s, s.outcome);
644
- dropHold(h);
645
- };
646
- const releasing = new Set();
647
- /** 逐件看停放着的: 有失败的 → 不投; 都 done 了且这一对没被占着 → 放行; 超过保留期 → 不投。 */
648
- const tickHolds = async () => {
649
- if (stopped || writeFailed || holdWriteFailed)
650
- return;
651
- for (const h of [...holds.values()]) {
652
- if (releasing.has(holdKey(h)) || !holds.has(holdKey(h)))
653
- continue;
654
- const ps = h.after.map((t) => [t, prereqOf(h.from, t, h.seen)]);
655
- const fresh = ps.filter(([t, p]) => (p.state === "done" || p.state === "failed") && !h.seen[t]);
656
- if (fresh.length) {
657
- const seen = { ...h.seen, ...Object.fromEntries(fresh.map(([t, p]) => [t, p.seen])) };
658
- saveHold({ ...h, seen });
659
- h.seen = seen;
660
- }
661
- const bad = ps.find(([, p]) => p.state === "failed" || p.state === "unknown");
662
- if (bad) {
663
- const [t, p] = bad;
664
- cancelHold(h, p.state === "unknown"
665
- ? `前置 ${t} 的登记找不到了 (被同一对的新一句顶掉或已过期), 这件 (${h.turn}) 没投。`
666
- : `前置 ${t} (${deps.nameOf(p.seen.to)}) 以 ${p.seen.status} 收场, 这件 (${h.turn}) 没投。`);
667
- continue;
668
- }
669
- if (Date.now() - h.at >= KEEP_MS) {
670
- cancelHold(h, `这件 (${h.turn}) 排了超过 24 小时, 前置始终没落定, 不投了。`);
671
- continue;
672
- }
673
- if (ps.some(([, p]) => p.state === "pending") || pairBusy(h))
674
- continue;
675
- // 放行可能要等目标闲下来 (几分钟): 不堵住巡检里别的停放件。
676
- releasing.add(holdKey(h));
677
- void (async () => {
678
- try {
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;
682
- if (r.ok)
683
- dropHold(h);
684
- else
685
- cancelHold(h, `前置都 done 了, 但这件 (${h.turn}) 投不出去: ${r.reason ?? "未知原因"}, 没投。`);
686
- }
687
- catch (e) {
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");
697
- }
698
- finally {
699
- releasing.delete(holdKey(h));
700
- }
701
- })();
702
- }
703
- };
704
- let holdTimer;
705
- const startHolds = () => {
706
- if (holdTimer)
707
- return;
708
- holdTimer = setInterval(() => { void tickHolds().catch((e) => deps.log.warn({ err: e.message }, "receipt: 停放巡检出错")); }, HOLD_POLL_MS);
709
- holdTimer.unref?.();
710
- };
711
- /** 事件驱动再补一刀: 前置一落定就立刻判, 不等下一个巡检周期。 */
712
- const nudgeHolds = () => { if (holds.size)
713
- void tickHolds().catch(() => undefined); };
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 ?? "" })),
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
- },
767
- reask: (from, to, turn) => {
768
- const s = upstream({ from, turn }, to);
769
- if (!s || s.resolved || s.claimed)
770
- return undefined;
771
- const legs = (s.legs ?? 1) + 1;
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 } : {}) });
773
- return { legs, channel: s.channel, ...(s.asker ? { asker: s.asker } : {}) };
774
- },
775
- prepare: (from, to, re) => {
776
- const want = re ? canonicalTurn(re) : undefined;
777
- const s = want ? upstream({ from, turn: want }, to) : undefined;
778
- return s?.turn && s.turn === want
779
- ? { turn: s.turn, legs: (s.legs ?? 1) + 1, job: s.job, channel: s.channel, asker: s.asker, k: s.k }
780
- : { turn: newTurn(), legs: 1, ...(re ? { reUnknown: true } : {}) };
781
- },
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));
786
- if (!s)
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";
794
- const fresh = !s.settled;
795
- s.inbox?.filter((m) => m.state === "queued" && (m.generation ?? s.gen) === s.gen).forEach((m) => { m.state = "claimed"; });
796
- s.claimed = true;
797
- s.resolved = true;
798
- settle(s);
799
- if (fresh) {
800
- release(s);
801
- deps.onOutcome?.(s, "done", "", true);
802
- }
803
- return s.delivered;
804
- },
805
- sentTurn: (from, to) => slots.get(keyOfPair(from, to))?.turn,
806
- sentAt: (from, to) => slots.get(keyOfPair(from, to))?.at ?? 0,
807
- outstanding: (from, job) => [...slots.values()]
808
- .filter((s) => s.from === from && !s.resolved && (job === undefined || s.job === job))
809
- .map((s) => deps.nameOf(s.to)),
810
- transfer: (to, answered, carryAt) => {
811
- const owed = [...slots.values()].filter((s) => s.to === to && !s.resolved && !s.claimed);
812
- const carried = owed.filter((s) => {
813
- const got = answered(s);
814
- if (got.trim())
815
- s.answer = got;
816
- else {
817
- s.at = carryAt;
818
- s.deadlineAt = Math.max(s.deadlineAt ?? 0, carryAt + CARRY_GRACE_MS);
819
- }
820
- save(s);
821
- return !got.trim();
822
- });
823
- if (owed.length)
824
- deps.log.info({ mod: "receipt", to: deps.nameOf(to), answered: owed.length - carried.length, carried: carried.map((s) => deps.nameOf(s.from)) }, "receipt: 交接时转交");
825
- return carried;
826
- },
827
- pending: (from, to) => {
828
- const s = slots.get(keyOfPair(from, to));
829
- return s && !s.resolved && !s.claimed ? { job: s.job, turn: s.turn ?? "", need: s.outcome?.status === "need", ...(s.deferred ? { deferred: true } : {}) } : undefined;
830
- },
831
- states: () => {
832
- // 一个 target 欠好几份时只探一次 (探针要读它 transcript 的尾巴)。
833
- const probed = new Map();
834
- const parked = (t) => probed.get(t) ?? probed.set(t, deps.parkedNow?.(t) ?? false).get(t);
835
- return [...slots.values()]
836
- .filter((s) => !s.resolved && !s.claimed)
837
- .map((s) => ({
838
- from: s.from, to: s.to, turn: s.turn ?? "", job: s.job,
839
- state: s.queued ? "queued" : turnState(s, { parked: !s.outcome && parked(s.to) }),
840
- ageMs: Date.now() - s.at,
841
- ...(s.k ? { k: s.k } : {}),
842
- }))
843
- // 停放着的 (等前置): 还没投出去, 但发话方在等它 —— 同样在飞。
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 } : {}) })));
845
- },
846
- cancel: (target, by, only = () => true) => {
847
- // 排着还没投的 (停放): 不投了; 发起 stop 的那方自己知道, 不回执。
848
- const held = [...holds.values()].filter((h) => h.to === target && only({ from: h.from, turn: h.turn }));
849
- held.forEach((h) => cancelHold(h, `${deps.nameOf(target)} 被 ${deps.nameOf(by)} 收掉 / 打断了, 这件排着的活 (${h.turn}) 没投。`, h.from === by));
850
- const hit = [...slots.values()].filter((s) => s.to === target && !s.resolved && !s.claimed && !canceled(s) && only({ from: s.from, turn: s.turn ?? "" }));
851
- for (const s of hit) {
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();
857
- s.outcome = {
858
- status: "canceled",
859
- body: `(守护进程: ${deps.nameOf(s.to)} 被 ${deps.nameOf(by)} 收掉 / 打断了, 这件活没有结论。)${last ? `\n它最后说的: ${clipMiddle(last, 600)}` : ""}`,
860
- };
861
- if (s.job)
862
- deps.settleJob?.(s.job, s.to, "canceled", [], { turn: s.turn, dispatchKey: s.jobDispatchKey ?? s.dispatchKey });
863
- // 发起 stop 的那方自己知道, 不投; 守着的 watcher 看到 claimed 就收尾。
864
- if (s.from === by && !s.answerTransfer) {
865
- deps.onOutcome?.(s, "canceled", s.outcome.body, true);
866
- s.claimed = true;
867
- s.resolved = true;
868
- settle(s);
869
- continue;
870
- }
871
- s.queued = false;
872
- save(s);
873
- if (s.answerTransfer)
874
- arm(s, true);
875
- else
876
- enqueueOutcome(s, s.outcome);
877
- }
878
- if (hit.length)
879
- nudgeHolds();
880
- return hit.length + held.length;
881
- },
882
- prereqs: (from, turns, seen) => turns.map((t) => prereqOf(from, canonicalTurn(t), seen)),
883
- hold: (h) => {
884
- const x = { ...h, seen: {} };
885
- saveHold(x);
886
- holds.set(holdKey(x), x);
887
- startHolds();
888
- nudgeHolds();
889
- },
890
- cancelHeld: (job, by) => {
891
- const hit = [...holds.values()].filter((h) => h.job === job);
892
- hit.forEach((h) => cancelHold(h, `工单 ${job} 收了, 这件排着的活 (${h.turn}) 没投。`, h.from === by));
893
- return hit.length;
894
- },
895
- parentOf: (self, env, channel, opening) => {
896
- if (env?.quiet)
897
- return undefined;
898
- if (env?.receipt)
899
- return env.k ? kOfAttr(env.k) : undefined;
900
- if (env?.kind === "peer" && env.private) {
901
- const p = [...slots.values()].find((x) => x.to === self && !x.resolved && !x.claimed && (env.turn ? x.turn === env.turn : deps.nameOf(x.from) === env.from));
902
- return p && { kind: "peer", from: p.from, turn: p.turn };
903
- }
904
- // 私聊轮 (频道 "") 派出的活没有群可续: 不能回落成 home, 否则回执轮就进了群。
905
- return channel ? { kind: "chat", channel, turn: env?.eventId ?? opening } : undefined;
906
- },
907
- ancestorsOf: (self, k) => {
908
- // 第一跳 (self 此刻答的那件) 按定义在途; 往上每一跳都要那件活还没落定 / 没被 wait_peer 取走 ——
909
- // 已结束的那一跳不再是在等, 链到此为止 (宁可少拦)。
910
- const go = (who, cur, seen, first) => {
911
- if (cur?.kind !== "peer" || seen.has(cur.from))
912
- return [];
913
- const up = upstream(cur, who);
914
- if (!first && (!up || up.resolved || up.claimed))
915
- return [];
916
- return [cur.from, ...go(cur.from, up?.k, new Set([...seen, cur.from]), false)];
917
- };
918
- return go(self, k, new Set([self]), true);
919
- },
920
- askerOf: (self, env) => {
921
- // 回执轮: 那件活是 self 派出去的; 派来的活: 那件活是派给 self 的。同一对后来又说了一句,
922
- // 槽被顶掉 —— 件号对不上就认不出, 宁可不知道。
923
- const mine = (x) => (env.receipt ? x.from === self : x.to === self);
924
- const hit = [...slots.values()].find((x) => mine(x) && (env.turn ? x.turn === env.turn : deps.nameOf(env.receipt ? x.to : x.from) === env.from));
925
- return hit?.asker;
926
- },
927
- resume: () => {
928
- // 挂起且子活还没落定的不守: 叫醒它是 relay / release 的事, 这里再 arm 一个就是两个 watcher。
929
- const open = [...slots.values()].filter((s) => !s.settled && !(s.deferred && children(s).length));
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
- });
961
- if (open.length)
962
- deps.log.info({ mod: "receipt", resumed: open.map((s) => `${deps.nameOf(s.from)}←${deps.nameOf(s.to)}`) }, "receipt: reload 后续守");
963
- startHolds();
964
- return open.length;
965
- },
966
- };
967
- };
968
- //# sourceMappingURL=receipts.js.map