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.
- package/.claude-plugin/plugin.json +1 -1
- package/config.example.jsonc +3 -1
- package/dist/daemon/answer-transfer.js +56 -0
- package/dist/daemon/answer-transfer.js.map +1 -0
- package/dist/daemon/approval.js +1 -1
- package/dist/daemon/approval.js.map +1 -1
- package/dist/daemon/bot-mux.js +4 -0
- package/dist/daemon/bot-mux.js.map +1 -1
- package/dist/daemon/cfg-sync.js +1 -1
- package/dist/daemon/cfg-sync.js.map +1 -1
- package/dist/daemon/chat-name.js +4 -4
- package/dist/daemon/chat-name.js.map +1 -1
- package/dist/daemon/config-api.js +2 -2
- package/dist/daemon/config-api.js.map +1 -1
- package/dist/daemon/image-input.js +54 -0
- package/dist/daemon/image-input.js.map +1 -0
- package/dist/daemon/inbound.js +7 -13
- package/dist/daemon/inbound.js.map +1 -1
- package/dist/daemon/index.js +1134 -331
- package/dist/daemon/index.js.map +1 -1
- package/dist/daemon/jobs.js +39 -15
- package/dist/daemon/jobs.js.map +1 -1
- package/dist/daemon/last-response.js +37 -7
- package/dist/daemon/last-response.js.map +1 -1
- package/dist/daemon/message-events.js +361 -0
- package/dist/daemon/message-events.js.map +1 -0
- package/dist/daemon/message-format.js +40 -0
- package/dist/daemon/message-format.js.map +1 -0
- package/dist/daemon/mirror-bridge.js +847 -343
- package/dist/daemon/mirror-bridge.js.map +1 -1
- package/dist/daemon/pane-input.js +48 -0
- package/dist/daemon/pane-input.js.map +1 -0
- package/dist/daemon/peers.js +17 -16
- package/dist/daemon/peers.js.map +1 -1
- package/dist/daemon/pending-items.js +8 -0
- package/dist/daemon/pending-items.js.map +1 -1
- package/dist/daemon/receipts.js +427 -139
- package/dist/daemon/receipts.js.map +1 -1
- package/dist/daemon/scheduled-delivery.js +52 -0
- package/dist/daemon/scheduled-delivery.js.map +1 -0
- package/dist/daemon/steward-model.js +10 -10
- package/dist/daemon/steward-model.js.map +1 -1
- package/dist/daemon/tasks.js +83 -27
- package/dist/daemon/tasks.js.map +1 -1
- package/dist/daemon/tmux-buffer.js +16 -6
- package/dist/daemon/tmux-buffer.js.map +1 -1
- package/dist/daemon/weixin-cmd.js +2 -4
- package/dist/daemon/weixin-cmd.js.map +1 -1
- package/dist/daemon/weixin.js +111 -7
- package/dist/daemon/weixin.js.map +1 -1
- package/dist/daemon/wizard.js +2 -1
- package/dist/daemon/wizard.js.map +1 -1
- package/dist/daemon/workflow-nodes.js +217 -0
- package/dist/daemon/workflow-nodes.js.map +1 -0
- package/dist/daemon/workflow-plan-http.js +83 -0
- package/dist/daemon/workflow-plan-http.js.map +1 -0
- package/dist/daemon/workflow-plans.js +274 -0
- package/dist/daemon/workflow-plans.js.map +1 -0
- package/dist/mcp/server.js +27 -1
- package/dist/mcp/server.js.map +1 -1
- package/dist/shared/cli-backends.js.map +1 -1
- package/dist/shared/codex.js +1 -1
- package/dist/shared/codex.js.map +1 -1
- package/dist/shared/config.js +12 -10
- package/dist/shared/config.js.map +1 -1
- package/dist/shared/detail-store.js.map +1 -1
- package/dist/shared/json-map-store.js +46 -9
- package/dist/shared/json-map-store.js.map +1 -1
- package/dist/shared/keyed-scheduler.js +51 -0
- package/dist/shared/keyed-scheduler.js.map +1 -0
- package/dist/shared/message-footer.js +34 -0
- package/dist/shared/message-footer.js.map +1 -0
- package/dist/shared/model-tiers.js +1 -2
- package/dist/shared/model-tiers.js.map +1 -1
- package/dist/shared/reminder.js +9 -1
- package/dist/shared/reminder.js.map +1 -1
- package/dist/shared/session-label.js +19 -1
- package/dist/shared/session-label.js.map +1 -1
- package/dist/shared/workflow-plan.js +36 -0
- package/dist/shared/workflow-plan.js.map +1 -0
- package/package.json +1 -1
package/dist/daemon/receipts.js
CHANGED
|
@@ -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(
|
|
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
|
|
75
|
-
if (
|
|
76
|
-
|
|
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))
|
|
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
|
-
|
|
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
|
-
|
|
293
|
-
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
//
|
|
395
|
-
//
|
|
396
|
-
const park = !parked && !!prev &&
|
|
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 (
|
|
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
|
|
569
|
+
settle(s);
|
|
425
570
|
} // 不守 = 这一份没人会投
|
|
426
|
-
|
|
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
|
-
|
|
432
|
-
|
|
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
|
-
|
|
456
|
-
|
|
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:
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
543
|
-
|
|
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
|
-
|
|
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
|
-
|
|
618
|
-
|
|
619
|
-
|
|
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
|
|
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
|
-
//
|
|
672
|
-
|
|
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();
|