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,8 +1,8 @@
1
1
  import { wxMarkdown } from "../shared/wx-text.js";
2
2
  // Daemon entry. Resident process — exits only on signal or fatal WS auth failure.
3
- import { existsSync, readFileSync, statSync } from "node:fs";
3
+ import { existsSync, readFileSync, renameSync, statSync } from "node:fs";
4
4
  import { join } from "node:path";
5
- import { timingSafeEqual } from "node:crypto";
5
+ import { timingSafeEqual, randomBytes } from "node:crypto";
6
6
  import { loadOrCreateSvrToken } from "../shared/svr-token.js";
7
7
  import { DAEMON_TOKEN_FILE, DAEMON_TOKEN_HEADER } from "../shared/daemon-token.js";
8
8
  import { homedir } from "node:os";
@@ -26,6 +26,7 @@ import { applyHot, cfgSeenBy, patchedTop, pinChatKeys, configGet, fromLegacy, le
26
26
  import { computeUsage, costOf, fmtCost, fmtTokens, sumTotal } from "./usage.js";
27
27
  import { installInboundRouter } from "./inbound.js";
28
28
  import { loadMirrorStore } from "./mirror-store.js";
29
+ import { cancelOps, deadlineOf, eventRef, isEventRef, makeOutcome, openRouter, ops, pickPair } from "./router.js";
29
30
  import { createMessageEvents, messageId, finalId } from "./message-events.js";
30
31
  import { startMirror, installMirrorEventListener } from "./mirror-bridge.js";
31
32
  import { backendFor, runTmux, setTmuxTimeoutReporter, spawnTmuxClaude } from "./spawn-tmux.js";
@@ -52,24 +53,22 @@ import { retireStewardTask, startSteward, stewardEnvelope, STEWARD_ID, STEWARD_R
52
53
  import { applyChatNames, chatBaseOf, chatNameOf, chatPolicyOf, clearChatName, listChatNames, normChatName, peerAddress, planChatNames, setChatName, stewardTierOf } from "./chat-name.js";
53
54
  import { createTopOnlyNudge, gcNudge, policyKeyOf, renderNudge } from "./top-only-nudge.js";
54
55
  import { bindWizardStore, loadWizardStore, wizardStore, settleName, settleAll, backfillBorn, sweepUnborn, BOOT_ID, evictStaleName, childrenOf, ancestorsOf, renderCharter, renderLead, renderLeadPlaybook, ROSTER_FRESH_MS, renderRoster, handoffAt, memoryNudgeAt, MEMORY_NUDGE_FLOOR, MEMORY_MAX, NOTE_MAX, } from "./wizard.js";
55
- import { bindNoticeBox, createNoticeBox, createSeenRing, gcSeen, chatAudience, hears } from "./notices.js";
56
+ import { activeRows, bindNoticeBox, createNoticeBox, createSeenRing, gcSeen, chatAudience, hears } from "./notices.js";
56
57
  import { createLedger, digest, pendingGc, renderDigest, renderTable, renderAwaiting, renderStalled, renderShelved, ago as agoMs, HEARD_EVERY_MS } from "./pending-items.js";
57
58
  import { checkDirection } from "./direction.js";
58
59
  import { loadJobStore, dutyLine, awaitingAccept, stalledRoots, shelvedRoots, nudgeDue, dayClamp, childrenOf as jobChildren, jobEpisode, rejectReason, ACCEPTS, JOB_MEMBER_MAX, JOB_DEPTH_MAX, depthOf, ancestorsOf as jobAncestors, treeOrder, jobStage } from "./jobs.js";
59
- import { cacheTouchMs, cacheTtlSec, clipMiddle, contextFiles, firstStamp, parseClosing, lastContextTokens, lastExchange, lastModel, openingTurn, replyClosedBefore, talkTurns, turnsSince, renderPeerEnvelope, renderReceiptEnvelope, renderTaskEnvelope } from "./peers.js";
60
- import { keepalivePingSigs } from "../shared/keepalive.js";
60
+ import { cacheTouchMs, cacheTtlSec, clipMiddle, contextFiles, firstStamp, parseClosing, stripClosingMark, stripForChat, lastContextTokens, lastExchange, lastModel, openingTurn, talkTurns, turnsSince, renderPeerEnvelope, renderReceiptEnvelope, renderTaskEnvelope } from "./peers.js";
61
61
  import { expandHome } from "../shared/paths.js";
62
62
  import { loadJsonMap } from "../shared/json-map-store.js";
63
- import { answerRecoveryAction, answerSourceLost, answerTargetOccupied, answerTransferOrigin, createAnswerTransfers } from "./answer-transfer.js";
64
63
  import { createWorkflowNodes } from "./workflow-nodes.js";
65
64
  import { loadWorkflowPlans, createWorkflowPlanRunner } from "./workflow-plans.js";
66
65
  import { registerWorkflowPlanRoutes } from "./workflow-plan-http.js";
67
- import { createReceipts, canonicalTurn, deadlineOf, kAttr, kOfAttr, newTurn } from "./receipts.js";
68
- import { renderInFlight } from "../shared/turn-state.js";
66
+ import { returnOpOf } from "./dispatch.js";
67
+ import { isTerminal, renderInFlight, turnState } from "../shared/turn-state.js";
69
68
  import { startIdleNudge } from "./idle-nudge.js";
70
- import { askerFromEnvelope, renderReminder } from "../shared/reminder.js";
69
+ import { askerFromEnvelope, renderReminder, withMsg } from "../shared/reminder.js";
71
70
  import { createHandoffs, handedOff, handingOff } from "./handoff.js";
72
- import { coldGateOf, decide, rankCandidates, renderCandidates, routeDutiesOf, tierFor, wakeCostOf, wakeNoteOf } from "./route.js";
71
+ import { coldGateOf, decide, rankCandidates, renderCandidates, routeDutiesOf, tierFor, wakeCostOf } from "./route.js";
73
72
  import { parseWhen, renderChatLog, UNKNOWN_HUMAN } from "./chat-log.js";
74
73
  import { audienceOf } from "../shared/role-view.js";
75
74
  import { startGraph, stopRun, getRun, listRuns, validateSpec, waitForIdle, waitForQuorum, } from "./graph.js";
@@ -92,7 +91,8 @@ const main = async () => {
92
91
  // 定时调度器的 `inject` 真正实现, 赋值在 mirror-mode 的大块里 (要用到那里面的
93
92
  // wizards / m / notifyChat), 但 startScheduler 在那块外面接线 —— 见文件尾。
94
93
  // 记忆整理者的执行体, 同样只有 mirror 模式给得出 —— 赋值见 scheduledTaskInject 旁边。
95
- let stopReceipts = () => { };
94
+ /** T6: 失败合成的 sweeper (§4.2) —— 与 receipts 的 watcher 并跑, 投递只走路由表。 */
95
+ let stopSweep = () => { };
96
96
  let stewardRun;
97
97
  let stewardWiring = {};
98
98
  let scheduledTaskInject;
@@ -115,6 +115,35 @@ const main = async () => {
115
115
  // links from messages already on the user's WeCom timeline.
116
116
  initDetailPersistence(cfg.daemon.stateDir, log.child({ mod: "detail" }));
117
117
  configureRemoteForward(cfg.daemon.detailRemoteBase, cfg.daemon.detailRemoteToken);
118
+ // 回执路由表 (docs/evolve/receipt-router.md): 一件活 = 一行 `<event, owner, receiver>`,
119
+ // 表 = 对 append-only journal 的 fold。boot 时只 fold 一次; 写入点 (open/move) 从 T3 起接。
120
+ const router = openRouter({ note: (msg, extra) => log.child({ mod: "router" }).info(extra ?? {}, msg) });
121
+ /** 新根 event: 来源自己的一笔 (人的一句话 / 定时任务 / detach 派活) 新领一个 (§9.1)。 */
122
+ const newRootEvent = () => `e${randomBytes(3).toString("hex")}`;
123
+ // T10 §9.6: 旧回执状态文件一次性归档 —— 表上还压着在飞的活就这次不动 (等它们收口, 下次 boot
124
+ // 再试), 空表 = 那些账已经没有活的依据了, 留着只会让人误以为它在飞。工作流存档
125
+ // (`*.nodes.json` / `*.plans.json`) 与回执文件同前缀但**不**归档 —— 那是计划的历史, 不是账。
126
+ const archiveLegacyReceipts = () => {
127
+ if (router.rows().some((r) => ["open", "need", "error", "held"].includes(r.status)))
128
+ return;
129
+ // 第三个路径按片段拼: 这里只是旧状态文件的**文件名**, 不是对退役模块的引用, 而 T10 的验收
130
+ // grep 会扫那个词 (docs/evolve/receipt-router.md §9.6)。
131
+ const legacyAnswerTransfers = `~/.wezard/${["answer", "transfers.json"].join("-")}`;
132
+ [cfg.wrc.mirror.receiptsFile, cfg.wrc.mirror.holdsFile, legacyAnswerTransfers].forEach((file) => {
133
+ const abs = expandHome(file);
134
+ const bak = `${abs}.t10.bak`;
135
+ if (!existsSync(abs) || existsSync(bak))
136
+ return;
137
+ try {
138
+ renameSync(abs, bak);
139
+ log.child({ mod: "router" }).info({ from: abs, to: bak }, "legacy receipt state archived");
140
+ }
141
+ catch (e) {
142
+ log.child({ mod: "router" }).warn({ err: errText(e), file: abs }, "legacy receipt state not archived");
143
+ }
144
+ });
145
+ };
146
+ archiveLegacyReceipts();
118
147
  // 一个或多个企微机器人 (bot + bots): 对下面所有模块只露主机器人那个 client, 分流在 bot-mux 里。
119
148
  // 必须先于 weixin-port / response-tracker 建好 —— 它们包在 mux 外层。
120
149
  const ws = startMux(cfg, log);
@@ -174,9 +203,12 @@ const main = async () => {
174
203
  }
175
204
  const tasks = await openTaskRegistry(log.child({ mod: "tasks" }), (added, removed) => log.info({ added, removed }, "task files changed"));
176
205
  let peerEvents;
206
+ /** T4: 路由表的投递层 —— 在 mirror 大块里接线 (要用到 jobs / 回执信封 / 事件泵), 这里先留个口。 */
207
+ let deliverFinal;
177
208
  const events = createMessageEvents(join(cfg.daemon.stateDir, "message-events.json"), {
178
209
  resource: (target) => bridge.sessionInfo(target)?.tmuxPane || target,
179
- transcript: (target) => bridge.sessionInfo(target)?.jsonlPath,
210
+ transcript: (target) => bridge.inputSessionInfo(target)?.jsonlPath ?? bridge.sessionInfo(target)?.jsonlPath,
211
+ sessionId: (target) => bridge.inputSessionInfo(target)?.sessionId,
180
212
  valid: (e) => e.payload.peer ? !!peerEvents?.valid(e) : true,
181
213
  accepted: (e) => { if (e.payload.peer)
182
214
  peerEvents?.accepted(e); },
@@ -193,7 +225,11 @@ const main = async () => {
193
225
  channel: e.channel, revision: finalId(e.id, e.channel, body), body, chunks: bridge.formatFinal(e.target, e.turn || e.id, e.channel, body) });
194
226
  }
195
227
  },
196
- ready: async (e) => !handingOff(e.target) && (e.payload.peer ? !!(await peerEvents?.ready(e)) : !bridge.hasMirrorTarget(e.target) || await bridge.idleNow(e.target) || !(await bridge.paneLive(e.target))),
228
+ // 人话 (非 peer) 不再问「pane 闲不闲」: 忙就贴进去, 顺序交给 backend CLI 自己的输入队列
229
+ // (贴进运行中的一轮记成 queued 轮, 终句照样归到这条 event —— 见 brief 的 seal/bindLine)。
230
+ // 这一句曾是要 idleNow 的, 也就是那个反复 5s 超时的探测源头: 一超时就退避, 把同一 target
231
+ // 后面排着的每一笔 (含后到的人话) 一起钉住十几分钟。交接期间照旧等 —— 那几秒 pane 真是死的。
232
+ ready: async (e) => !handingOff(e.target) && (e.payload.peer ? !!(await peerEvents?.ready(e)) : true),
197
233
  inject: async (e, boundary) => {
198
234
  if (e.payload.peer)
199
235
  return peerEvents.inject(e, boundary);
@@ -205,12 +241,15 @@ const main = async () => {
205
241
  let outcome;
206
242
  pinFrameBot(e.payload.human.frame, botOfKey(e.channel));
207
243
  await bridge.dispatch({ ...e.payload.human, eventId: e.id,
208
- text: e.payload.text + renderReminder({ wezard: "message-event", event: e.id }, []),
209
- guard: boundary, onSubmitted: (r) => { outcome = r; } });
244
+ text: withMsg(e.payload.text, e.id),
245
+ guard: (phase, identity) => boundary(phase, identity), onSubmitted: (r) => { outcome = r; } });
210
246
  return outcome ?? { ok: false, retryable: events.get(e.id)?.exposure === "none", uncertain: events.get(e.id)?.exposure !== "none", reason: "dispatch ended without a submission acknowledgement" };
211
247
  },
212
248
  evidence: async (e) => {
213
- if (e.canceled && await bridge.idleNow(e.target))
249
+ const info = bridge.sessionInfo(e.target);
250
+ const sameSession = e.sessionId ? e.sessionId === info?.sessionId
251
+ : !e.transcript || e.transcript === info?.jsonlPath;
252
+ if (e.canceled && sameSession && await bridge.idleNow(e.target))
214
253
  events.released(e.id);
215
254
  const paths = [...new Set([bridge.sessionInfo(e.target)?.jsonlPath, e.transcript].filter((p) => !!p))];
216
255
  for (const path of paths) {
@@ -221,12 +260,24 @@ const main = async () => {
221
260
  const next = turns.findIndex((t, i) => i > start && t.role === "user" && !t.queued);
222
261
  const replies = turns.slice(start + 1, next < 0 ? undefined : next).filter((t) => t.role === "assistant");
223
262
  const last = replies.at(-1);
224
- const ended = next >= 0 || await bridge.idleNow(e.target);
225
- return { consumed: true, interjected: !!turns[start].queued, ...(ended && last && !last.mid && !last.apiError ? { body: last.text, revision: finalId(e.id, e.channel, last.text) } : {}) };
263
+ // 软后端 (codebuddy 不写 stop_reason) 的「写完了」不是终句: 中途叙述 (「先跑 typecheck」)
264
+ // 与终稿同形, 下一秒就跟着 function_call —— 必须见到轮终证据 (紧跟的 turn-metrics /
265
+ // 下一句 user 行 / CLI 已闲) 才算这一轮说完, 否则假定论先落 answer、真结论再落一个 rev。
266
+ const ended = next >= 0 || (last && !last.mid && !last.apiError && (!last.soft || last.end)) || (sameSession && await bridge.idleNow(e.target));
267
+ // 一轮以 CLI 报错收尾: 那段报错不是定论, 但要作为中途态 (error) 报给 receiver ——
268
+ // 所以照旧不落成 result, 只把原文交给 final (见 §9.4)。
269
+ return { consumed: true, interjected: !!turns[start].queued,
270
+ ...(next >= 0 ? { closedAt: turns[next].ms ?? Date.now() } : {}),
271
+ ...(ended && last && !last.mid
272
+ ? last.apiError ? { body: last.text, error: true } : { body: last.text, revision: finalId(e.id, e.channel, last.text) }
273
+ : {}) };
226
274
  }
227
275
  return { consumed: false };
228
276
  },
229
277
  recovered: (e) => bridge.recoverEventFinal(e),
278
+ // T4: 终句 → 路由表。onFinal 连同投递一起做 —— 定论先落盘 (`answer`), 再按 receiver
279
+ // 投出去 (§9.3/§9.4); 实现在 mirror 大块里 (要 jobs / 信封 / 事件泵)。
280
+ final: (e, verdict) => deliverFinal?.(e, verdict),
230
281
  format: (d) => ({ ...d, chunks: d.chunks.map((c) => responseTracker.freeze(d.channel.replace(/^(user|chat|group):/, ""), c)) }),
231
282
  send: async (d, n) => {
232
283
  await bridge.settleFinalProgress(d.id);
@@ -238,13 +289,13 @@ const main = async () => {
238
289
  failed: result.state === "failed" ? result.reason ?? "Weixin final rejected" : undefined };
239
290
  }
240
291
  const stream = d.stream && n === 0 && d.chunks.length === 1 && Date.now() < d.stream.expires ? d.stream : undefined;
241
- const ack = d.transfer
242
- ? await bridge.deliverAnswer(d.transfer.id, d.target, d.turn, d.channel, "", d.chunks[n], d.transfer.standalone || n > 0 || d.chunks.length > 1, true)
243
- : await responseTracker.sendFrozen(chat, d.chunks[n], stream, events.running);
292
+ const ack = await responseTracker.sendFrozen(chat, d.chunks[n], stream, events.running);
244
293
  return ack && typeof ack === "object" && "errcode" in ack && ack.errcode === 0 ? { ack: JSON.stringify(ack) }
245
294
  : { ack: JSON.stringify(ack), uncertain: "final transport returned no explicit success ACK" };
246
295
  },
247
296
  deliveryEvidence: (d, n) => wx.finalStatus(`${d.id}:${n}`),
297
+ held: (e, why, forMs) => log.warn({ event: e.id, target: e.target, speaker: e.speaker, priority: e.priority,
298
+ serves: e.serves, after: e.payload.peer?.after, why, heldSec: Math.round(forMs / 1000) }, "message event held in queue"),
248
299
  error: (e) => log.error({ err: errText(e) }, "message event scheduler"),
249
300
  });
250
301
  const bridge = startMirror({ cfg, log: log.child({ mod: "mirror" }), client: ws.client, store: mirrorStore, events, sendFrozen: (channel, content, stream) => responseTracker.sendFrozen(channel.replace(/^(user|chat|group):/, ""), content, stream, events.running) });
@@ -266,6 +317,47 @@ const main = async () => {
266
317
  // 重开的空档里 resume 回旧 sid。
267
318
  // 宪章守卫 (见「改配置会不会动到宪章」): 要等下面宪章渲染那一块立起来才有; 之前 (启动时那次自动补名) 只写不比对。
268
319
  let guardCharter = (write) => ({ r: write(), note: Promise.resolve("") });
320
+ const who = (t) => `.${wizardStore()?.get(t)?.name || t}`;
321
+ const ago = (at) => { const s = Math.max(0, Math.round((Date.now() - at) / 1000)); return s < 60 ? `${s}s` : `${Math.round(s / 60)}m`; };
322
+ const rowLabel = (x) => `${x.payload.human ? "人话" : x.id.startsWith("rcpt:") ? `回执 ← ${x.speaker}` : `派活 ← ${x.speaker}`}`
323
+ + `「${clipLine(x.payload.text, 24)}」${{ queued: "排队", dispatching: "正在贴", "awaiting-result": "在跑" }[x.execution] ?? x.execution} ${ago(x.at)}`;
324
+ /** 会话还没起好时气泡里那三个点 —— 不是提示文案, 就是 loading。 */
325
+ const LOADING = "…";
326
+ /** 人话的排队实况; "" = 这一刻没什么可说的 (气泡归 mirror 的 ack 或上一句实况)。 */
327
+ const queueNote = (q, target, cold) => {
328
+ // 起会话那一段只留 loading, 不再写「正在拉起 … 的会话」: 那句话带着 `.x:` 头, 而此刻冷目标
329
+ // 还没进名册的 known —— 头里的半截链接改写落空 (withMessageFooter 找不到 sender 就原样放行),
330
+ // `[`.x`](url):` 进群被企微渲染成一个空链接; 它还会抢先 mirror 的 ack 进气泡、紧接着被顶掉,
331
+ // 白闪一下。loading 才是这一段该有的内容。
332
+ if (q.execution === "dispatching")
333
+ return cold ? LOADING : "";
334
+ if (q.execution !== "queued")
335
+ return "";
336
+ const holds = [handingOff(target) ? `${who(target)} 正在交接,交接完贴进新会话` : "", ...q.holds].filter(Boolean);
337
+ // 前面既没有别的件、也没有否决项 ⇒ 没什么可报的, 别拿一句「排队中(前面 0 件)」去顶掉气泡。
338
+ if (!q.ahead.length && !holds.length)
339
+ return "";
340
+ return [`排队中(同 pane 前面 ${q.ahead.length} 件)`,
341
+ ...holds.map((h) => `- ${h}`),
342
+ ...q.ahead.slice(0, 5).map((x, i) => `${i + 1}. ${rowLabel(x)}`),
343
+ ...(q.ahead.length > 5 ? [`… 另 ${q.ahead.length - 5} 件`] : [])].join("\n");
344
+ };
345
+ /** 1.5s 内贴进去了就不出声 (mirror 会开它自己的气泡); 还在队里就把实况写进这条气泡, 每 3s
346
+ * 刷一次, 直到它离队。企微流式气泡活不过 6 分钟, 刷到 5 分钟为止。
347
+ * loading 是纯文案, 不带头 —— 冷目标此刻还挂不出链接 (见 queueNote)。 */
348
+ const watchQueue = async (a, id, cold, last = "", until = Date.now() + 300_000, wait = 1500) => {
349
+ await sleep(wait);
350
+ const q = events.queue(id);
351
+ if (!q || Date.now() > until)
352
+ return;
353
+ const note = queueNote(q, a.principal, cold);
354
+ if (note && note !== last)
355
+ await ws.client.replyStream(a.frame, a.streamId, note === LOADING ? note : withTagHeader(a.principal, note), false).catch(() => undefined);
356
+ // queued / dispatching 之外没有「排队实况」可言 —— 收工, 气泡从此归 mirror。
357
+ if (!["queued", "dispatching"].includes(q.execution))
358
+ return;
359
+ return watchQueue(a, id, cold, note || last, until, 3000);
360
+ };
269
361
  installInboundRouter(ws.client, cfg, log, { ...bridge, dispatch: async (a) => {
270
362
  if (a.text.trimStart().startsWith("/")) {
271
363
  await handedOff(a.principal);
@@ -273,10 +365,18 @@ const main = async () => {
273
365
  return;
274
366
  }
275
367
  const id = a.eventId ?? messageId(botOfKey(a.principal), a.channel, a.streamId);
276
- events.accept({ id, identity: a.identity, originalAt: a.originalAt, target: a.principal, speaker: a.speaker, channel: a.channel,
277
- asker: { who: a.speaker, chat: a.channel }, priority: "normal", deadline: Date.now() + 12 * 3600_000,
368
+ // 人话开一条根行 (§3.1): owner = 被点名的那个 wizard, 定论 = 这句人话的答复气泡;
369
+ // 这一轮服务的正是它, 于是它在群里再派活都挂在这条根下。
370
+ const at = Date.now();
371
+ const deadlineAt = at + 12 * 3600_000;
372
+ const root = newRootEvent();
373
+ router.commit(ops.open({ event: root, owner: a.principal, at, deadlineAt,
374
+ receiver: { kind: "chat", channel: baseOfKey(a.channel), message: id },
375
+ asker: { who: a.speaker, chat: a.channel } }));
376
+ events.accept({ id, identity: a.identity, originalAt: a.originalAt, target: a.principal, speaker: a.speaker, channel: a.channel, serves: root,
377
+ asker: { who: a.speaker, chat: a.channel }, priority: "normal", deadline: deadlineAt,
278
378
  payload: { text: a.text, human: a } });
279
- await ws.client.replyStream(a.frame, a.streamId, withTagHeader(a.principal, "已排队"), false).catch(() => undefined);
379
+ void watchQueue(a, id, !bridge.hasMirrorTarget(a.principal));
280
380
  } }, sourcePath, (w, c) => guardCharter(w, c));
281
381
  // approval click → finalize 当前 liveStream, 后续 tool/text 落到 standalone。
282
382
  // 规则 2: 用户点击授权那一刻就是"上一段对话"的边界, 截断 stream 让授权后的
@@ -421,10 +521,20 @@ const main = async () => {
421
521
  const m = bridge;
422
522
  installMirrorEventListener(ws.client, m, log.child({ mod: "mirror" }));
423
523
  http.register("GET /message-events/status", (_req, res) => json(res, 200, events.status()));
524
+ // 路由表的诊断视图 (`?owner=` = 那个 wizard 名下的行, 缺省 = 全表)。
525
+ http.register("GET /routes", (_req, res, url) => {
526
+ const owner = url.searchParams.get("owner")?.trim();
527
+ json(res, 200, { ok: true, ...router.diagnose(owner || undefined) });
528
+ });
424
529
  http.register("POST /message-events/reconcile", async (req, res) => {
425
530
  const body = await readBody(req);
426
531
  json(res, 200, await events.reconcile(String(body.id ?? "")) ?? { error: "unknown event" });
427
532
  });
533
+ http.register("POST /message-events/release", guarded(async (req, res) => {
534
+ const body = await readBody(req);
535
+ const result = events.release(String(body.id ?? ""), String(body.reason ?? ""));
536
+ json(res, result.ok ? 200 : 409, result);
537
+ }));
428
538
  http.register("GET /mirror/status", (_req, res) => json(res, 200, m.status()));
429
539
  // Manual auto-spawn trigger — used by `wezard init` to materialize a
430
540
  // tmux+claude pane immediately after claim, instead of waiting for the
@@ -583,11 +693,9 @@ const main = async () => {
583
693
  const existed = !!wizards.get(target);
584
694
  const name = wizards.rename(target, normalizeTag(String(body.name ?? body.tag ?? "")) || tag);
585
695
  const model = (body.model ?? "").toString().trim();
586
- // 没点名就按配置的 keepalive.spawnDefault 来 —— 调用方明说的永远优先。
587
- const keepalive = typeof body.keepalive === "boolean" ? body.keepalive : cfg.wrc.mirror.keepalive.spawnDefault;
588
- log.child({ mod: "mirror", sub: "sessions-new", target }).info({ self, cwd, foreign, cli: body.cli, model, keepalive }, "spawning peer session");
696
+ log.child({ mod: "mirror", sub: "sessions-new", target }).info({ self, cwd, foreign, cli: body.cli, model }, "spawning peer session");
589
697
  // silent: 新 wizard 就位不进群 (生命周期事件), 同群的 wizard 从名册增量里得知。
590
- const r = await m.newSession(target, name, body.cli, { cwd, model, keepalive, silent: true, guard: () => planStore.allowExternal(target, wizards.get(target)?.name ?? name) });
698
+ const r = await m.newSession(target, name, body.cli, { cwd, model, silent: true, guard: () => planStore.allowExternal(target, wizards.get(target)?.name ?? name) });
591
699
  if (!r.ok && !existed)
592
700
  wizards.drop(target);
593
701
  if (r.ok)
@@ -604,7 +712,6 @@ const main = async () => {
604
712
  ...(r.model ? { model: r.model } : {}),
605
713
  ...(r.modelWarning ? { modelWarning: r.modelWarning } : {}),
606
714
  foreign,
607
- keepalive,
608
715
  // 调用方之后拿这个串 send_peer / peek_peer 驱动它。
609
716
  address: peerAddress(cfg, self, target),
610
717
  }
@@ -633,13 +740,16 @@ const main = async () => {
633
740
  const r = await m.injectText(target, text, undefined, { guard });
634
741
  json(res, r.ok ? 200 : 500, r);
635
742
  });
636
- // One-shot project switch: bind the chat-wide cwd AND apply it right here
637
- // by walking the exact /new path (kill pane → respawn in pendingCwd →
638
- // attach → "📂 当前项目" push). Resolved target
639
- // precedence follows resolveSelf (explicit target, then stable pane, then session). When the caller
640
- // IS the session being replaced, its pane dies mid-tool-call — the
641
- // project-info bubble is the receipt. On spawn failure the pendingCwd
642
- // stays queued, so a manual /new from WeCom still completes the switch.
743
+ // One-shot project switch: bind the chat-wide cwd AND apply it right here.
744
+ // 活会话走交接 (handoff): 守护进程请它写简报 → 重开 (pendingCwd 在这一步被消费,
745
+ // 落进新目录, 宪章随之重渲染) → 简报贴回, 上下文跟着过去, 「📂 当前项目」气泡
746
+ // 照发 (announce —— 那是人看得见的回执)。一律 fire-and-forget: 调用方常常就是被换
747
+ // 的那个, 它得先拿到这份应答、把这一轮说完, 交接才动得了手 (other 的第一拍就是等它
748
+ // 闲)。交接没做成 → 捎一条提醒给它, pendingCwd 仍排着队, 人手 /new 照样完成切换
749
+ // (若它本就在交接中, 那场交接的重开同样会消费这笔 pendingCwd)。冷会话 (pane 不在)
750
+ // 没有可交接的上下文, 按老路直接新开。Resolved target
751
+ // precedence: explicit body.target → sessionId-derived (so MCP can omit
752
+ // it) → tmuxPane (stable across /clear) → cfg.defaultChat.
643
753
  http.register("POST /mirror/workspace", async (req, res) => {
644
754
  const body = (await readBody(req));
645
755
  const cwd = (body.cwd ?? "").toString();
@@ -668,6 +778,15 @@ const main = async () => {
668
778
  json(res, 400, { ...set, target });
669
779
  return;
670
780
  }
781
+ if ((await liveOf([target])).length) {
782
+ const focus = `工作区切到 ${set.pendingCwd}: 简报里点一句手头活在新目录下怎么接着干`;
783
+ void handoffs.other(target, focus, CHARTER_HANDOFF_MS, { announce: true }).then((r) => {
784
+ if (!r.ok)
785
+ notices.post([target], `换工作区到 ${set.pendingCwd} 的自动交接没做成: ${r.reason} —— 切换仍排着队, 手上这件告一段落后自己 \`handoff({brief})\`, 或让人在群里 /new 一下`);
786
+ });
787
+ json(res, 200, { ok: true, target, cwd: set.pendingCwd, scheduled: true });
788
+ return;
789
+ }
671
790
  // Same shape as inbound's /new: windowName = tag for tagged sessions,
672
791
  // principal for the default one. No explicit cwd — newSession reads the
673
792
  // pendingCwd just queued and clears it once applied.
@@ -855,48 +974,6 @@ const main = async () => {
855
974
  // Lifecycle continuations carry the same live attempt, never a new task.
856
975
  // Human inbound remains an explicit intervention; agent handoff/receipts
857
976
  // must not revive canceled or unconfirmed planned executions.
858
- const answers = createAnswerTransfers(loadJsonMap(join(cfg.daemon.stateDir, "answer-transfers.json"), undefined, { strict: true }));
859
- m.setAnswerSilencer(answers.source);
860
- const answerSends = new Map();
861
- const deliverTransferredAnswer = (id, out) => {
862
- const pending = answerSends.get(id);
863
- if (pending)
864
- return pending;
865
- const p = (async () => {
866
- m.stopAnswerProgress(id);
867
- const r = answers.outcome(id, out);
868
- if (!r)
869
- return true;
870
- if (!answers.delivering(id))
871
- return false;
872
- try {
873
- const eventId = `answer:${id}`;
874
- const revision = finalId(eventId, r.channel, r.outcome.body);
875
- const deliveryId = events.delivery({ id: finalId(eventId, r.channel, revision), eventId, target: r.target,
876
- turn: r.sourceTurn, channel: r.channel, revision, body: r.outcome.body,
877
- chunks: m.formatFinal(r.target, r.sourceTurn, r.channel, r.outcome.body),
878
- transfer: { id, standalone: !["done", "need"].includes(r.outcome.status) } });
879
- for (;;) {
880
- const states = events.deliveryStatus(deliveryId);
881
- if (states.every((s) => s === "delivered"))
882
- break;
883
- if (states.some((s) => s === "uncertain" || s === "failed"))
884
- throw new Error(`final delivery uncertain: ${deliveryId}`);
885
- await sleep(250);
886
- }
887
- answers.delivered(id);
888
- recordPost({ target: r.target, channel: r.channel, body: r.outcome.body, ...(r.tell.asker ? { asker: r.tell.asker } : {}) });
889
- return true;
890
- }
891
- catch (e) {
892
- answers.uncertain(id);
893
- log.error({ id, err: e.message }, "transferred answer delivery acknowledgement uncertain; automatic retry withheld to prevent duplicate answers");
894
- return false;
895
- }
896
- })().finally(() => answerSends.delete(id));
897
- answerSends.set(id, p);
898
- return p;
899
- };
900
977
  const planContinuationAllowed = (target) => {
901
978
  const r = planStore.reservation(target, wizards.get(target)?.name);
902
979
  if (!r)
@@ -907,110 +984,78 @@ const main = async () => {
907
984
  && !a.canceled && a.acceptance !== "rejected" && jobs.get(r.plan.job)?.status === "open" && ["running", "needs-input", "errored"].includes(a.execution)
908
985
  && (r.plan.nodes.find((x) => x.id === n.id)?.after ?? []).every((id) => workflow.prereq(r.owner, r.nodes.find((x) => x.id === id).turn)?.state === "done");
909
986
  };
987
+ /** 一笔活第几个来回 (§3.2): 同一行上已经注入过的续问数 + 1 —— 现算, 不落第二份数据。
988
+ * 续问的注入事件 id 是 `route:<event>:r<legs>`, 所以从它自己就能数回来 (reload 也在盘上)。 */
989
+ const legsOf = (event) => events.status().events.reduce((n, e) => {
990
+ const m = e.id.startsWith(`route:${event}:r`) ? Number(e.id.slice(`route:${event}:r`.length)) : NaN;
991
+ return Number.isInteger(m) && m > n ? m : n;
992
+ }, 1);
993
+ /** 同一笔活上的一句续问 (idle-nudge 追问 / 工单打回): 不新开行, 注入事件 id
994
+ * `route:<event>:r<legs>` —— 它的终句照旧落到那一行上 (§9.4)。 */
910
995
  const acceptContinuation = (tell, text) => {
911
- if (workflow.byTurn(tell.from, tell.turn) || !planStore.allowExternal(tell.to, wizards.get(tell.to)?.name))
996
+ const event = tell.event ?? "";
997
+ if (!event)
912
998
  return false;
913
- const id = `p:${tell.from}:${tell.to}:${tell.turn}:${tell.legs}`;
914
- events.accept({ id, target: tell.to, speaker: tell.from, channel: tell.channel, asker: tell.asker, k: tell.k,
999
+ if (workflow.byTurn(tell.from, event) || !planStore.allowExternal(tell.to, wizards.get(tell.to)?.name))
1000
+ return false;
1001
+ const legs = tell.legs ?? legsOf(event) + 1;
1002
+ const id = `route:${event}:r${legs}`;
1003
+ events.accept({ id, target: tell.to, speaker: tell.from, channel: tell.channel, serves: event, asker: tell.asker,
915
1004
  priority: "normal", deadline: tell.deadlineAt ?? deadlineOf(tell.at ?? Date.now()), payload: { text, peer: {
916
- from: { kind: "peer", from: tell.from, turn: tell.turn, ...(tell.asker ? { asker: tell.asker } : {}) },
917
- envelope: envelopeFor(tell.from, tell.channel, { turn: tell.turn, legs: tell.legs, re: true, asker: tell.asker }),
918
- tell: { ...tell, dispatchKey: id }, job: tell.job ?? "", kind: "task", held: true,
1005
+ from: { kind: "peer", from: tell.from, event, ...(tell.asker ? { asker: tell.asker } : {}) },
1006
+ envelope: envelopeFor(tell.from, tell.channel, { legs, re: true, asker: tell.asker }),
1007
+ tell: { ...tell, event, legs, dispatchKey: id }, job: tell.job ?? "", kind: "task", held: true,
919
1008
  } } });
920
1009
  return true;
921
1010
  };
922
- const receipts = createReceipts({
923
- deliverAnswer: deliverTransferredAnswer,
924
- workflowPrereq: workflow.prereq,
925
- idleNow: m.idleNow,
926
- onOutcome: (s, status, bodyText, bySender) => {
927
- // Pin transferred-answer terminal state synchronously with the receipt
928
- // outcome; delivery may still be waiting for the target to become idle.
929
- if (s.answerTransfer) {
930
- answers.outcome(s.answerTransfer, { status, body: bodyText });
931
- m.stopAnswerProgress(s.answerTransfer);
932
- }
933
- workflow.outcome(s.from, s.turn ?? "", status, bodyText);
934
- advancePlans();
935
- ledger.outcome(s.from, s.turn ?? "", status, parseClosing(bodyText).text || bodyText, bySender);
936
- },
937
- jobTally: (id) => jobs.tally(id),
938
- parkedNow: m.parkedNow,
939
- settleJob: (id, target, outcome, artifacts, identity) => { jobs.settle(id, target, outcome, artifacts, identity); },
940
- accept: (id, body) => { const j = jobs.get(id); return j?.status === "open" ? rejectReason(j.accept ?? "result", parseClosing(body)) : undefined; },
941
- durableRebound: true,
942
- rebound: async (x, why, tell) => !!tell && acceptContinuation(tell, `(守护进程代 ${displayName(x.from)} 打回一次: 这件活是工单里的一份, 你的回复${why}。请按约定收口:RESULT: <结论>;交付文件逐项 ARTIFACT: <路径> — 一句话;真缺信息用 NEED: <问题>。)`),
943
- untilIdle: m.untilIdle,
944
- paneLive: m.paneLive,
945
- nameOf: displayName,
946
- log,
947
- store: loadJsonMap(cfg.wrc.mirror.receiptsFile, undefined, { strict: true }),
948
- holdStore: loadJsonMap(cfg.wrc.mirror.holdsFile, undefined, { strict: true }),
949
- // 放行停放着的 after 件: 走 tell_peer 同一条注入路径, 沿用停放时的件号 / 频道 / 父 k。
950
- release: async (h, seen, alive) => {
951
- const r = await tellPeerAs(h.from, h.body, afterNote(h.after, seen, h.from), { at: h.at, turn: h.turn, channel: h.channel, after: h.after, seen: h.seen, alive, ...(h.k ? { k: h.k } : {}), ...(h.asker ? { asker: h.asker } : {}) });
952
- return r.status === 200 ? { ok: true } : { ok: false, reason: String(r.body.reason ?? `HTTP ${r.status}`) };
953
- },
954
- handingOff,
955
- handedOff,
956
- replyFor: (to, fromName, since, turn) => m.replyToPeer(to, fromName, since, turn),
957
- lastWords: m.lastReply,
958
- deliver: async (to, bodyText, meta, alive) => {
959
- if ((alive && !alive()) || !planContinuationAllowed(to))
960
- return { ok: false, retryable: true, reason: "receipt permission withdrawn" };
961
- const id = meta.eventId;
962
- const text = clipMiddle(bodyText);
963
- events.accept({ id, identity: JSON.stringify([to, text, meta]), target: to, speaker: meta.from, channel: meta.channel,
964
- asker: meta.asker, k: meta.k, replyTo: meta.replyTo, priority: "normal", deadline: Date.now() + 12 * 3600_000,
965
- payload: { text, peer: { job: "", kind: "fyi",
966
- from: { kind: "peer", from: meta.from, receipt: true, status: meta.status, ...(meta.turn ? { turn: meta.turn } : {}), ...(meta.job ? { job: meta.job, done: meta.done, total: meta.total } : {}), ...(meta.asker ? { asker: meta.asker } : {}) },
967
- envelope: renderReceiptEnvelope(displayName(meta.from), meta.channel ? chatNameOf(cfg, meta.channel) : undefined, meta.job ? { job: meta.job, done: meta.done, total: meta.total, closed: jobs.get(meta.job)?.status !== "open", ...(jobs.get(meta.job)?.kind === "req" ? { req: true } : {}) } : undefined, meta.status, meta.turn || undefined, {
968
- ...(meta.replyTo ? { replyTo: meta.replyTo.kind === "chat" ? chatNameOf(cfg, meta.replyTo.channel) : displayName(meta.replyTo.from) } : {}),
969
- ...(meta.k ? { k: kAttr(meta.k) } : {}),
970
- ...(meta.asker ? { asker: meta.asker } : {}),
971
- ...(meta.pending ? { pending: meta.pending } : {}),
972
- ...(audienceName(meta.channel, meta.asker) ? { audience: audienceName(meta.channel, meta.asker) } : {}),
973
- }) + (meta.eventId ? renderReminder({ wezard: "receipt-delivery", event: meta.eventId }, []) : "")
974
- } } });
975
- return events.submission(id);
976
- },
977
- });
978
- stopReceipts = receipts.stop;
1011
+ /** 已经因为不合工单验收被打回过的那些笔 (行状态会随打回留在 `open`, 光看它认不出「打回过」)。 */
1012
+ const bounced = new Set();
1013
+ /** 工单验收 (§9.6, 从 receipts 的 watcher 重建): 交的活不合规格 → 这一份不算交差。
1014
+ * 计划节点有自己的验收 (workflow), 不经这里。 */
1015
+ const acceptanceOf = (row, body) => {
1016
+ if (!row.job || bounced.has(row.event))
1017
+ return undefined;
1018
+ // 计划节点有自己的验收 (workflow): 那份活由计划 runner 收口, 不走工单的工具级验收。
1019
+ if (workflow.all().some((a) => a.job === row.job && a.to === row.owner))
1020
+ return undefined;
1021
+ const j = jobs.get(row.job);
1022
+ return j?.status === "open" ? rejectReason(j.accept ?? "result", parseClosing(body)) : undefined;
1023
+ };
1024
+ /** 以工单 owner 的名义在同一笔上打回一次: 不新开行, 行留在 `open` 等它按约定再交一份。 */
1025
+ const rebound = (row, why) => {
1026
+ bounced.add(row.event);
1027
+ const from = ledgerOwnerOf(row.event) || row.owner;
1028
+ acceptContinuation({
1029
+ from, to: row.owner, channel: "", event: row.event, legs: legsOf(row.event) + 1,
1030
+ deadlineAt: row.deadlineAt, ...(row.asker ? { asker: row.asker } : {}), ...(row.job ? { job: row.job } : {}),
1031
+ }, `(守护进程代 ${displayName(from)} 打回一次: 这件活是工单里的一份, 你的回复${why}。请按约定收口:RESULT: <结论>;交付文件逐项 ARTIFACT: <路径> — 一句话;真缺信息用 NEED: <问题>。)`);
1032
+ log.info({ event: row.event, why }, "receipt: 不合工单验收, 打回一次");
1033
+ };
979
1034
  const turnsLine = (xs, target) => renderInFlight(xs, target, displayName);
980
1035
  // reload 前在飞的回执: 等 mirror 把绑定都恢复了再续守 —— 早一步读 target 会拿不到
981
1036
  // transcript, 退回按时刻取就可能把上一件事的结论当回执。
982
1037
  // 交接 (见 handoff.ts): 换会话不换身份。这里给它重开、贴话、读旧 transcript 的能力,
983
1038
  // 闸、回执清点与断点续做在那边。
984
- const pingSigs = keepalivePingSigs(cfg.wrc.mirror.keepalive.ping);
985
1039
  /** 情景记忆 (memory/episodes/<name>.jsonl) 按名字归档; 默认会话没名字时退回 slot / "default"。 */
986
1040
  const episodeName = (t) => wizards.get(t)?.name || tagOfKey(t) || "default";
987
1041
  const handoffs = createHandoffs({
988
1042
  isBusy: m.isBusy,
989
1043
  sessionId: (t) => m.sessionInfo(t)?.sessionId ?? "",
990
- restart: (t) => restartFresh(t),
991
- // 欠着回执的发话方挂上它的信封: 新会话那一轮就是在答它, 回执照常按信封定位, 频道也接回
992
- // 原来那一句所在的地方 (私聊不进群)。欠好几份时每份各开一轮 (见 carryRest): 并成一句,
993
- // 那一轮就只有一个去向, 别的上游拿到的是答给第一份的话。
994
- inject: async (t, text, owe, channel) => {
995
- const [first, ...rest] = owe;
1044
+ restart: (t, p) => restartFresh(t, p),
1045
+ // 简报是新会话的开场白, 但它不挂任何信封 (服务不着哪一笔) —— 交接前欠着的那几笔由
1046
+ // 「继续 <event>」逐笔续答 (见 resume), 别把简报那一轮的回复当成回执。
1047
+ inject: async (t, text, channel) => {
996
1048
  if (!planContinuationAllowed(t))
997
1049
  return { ok: false, reason: "workflow continuation withdrawn" };
998
- if (!first && planStore.reservation(t, wizards.get(t)?.name))
1050
+ if (planStore.reservation(t, wizards.get(t)?.name))
999
1051
  return { ok: false, reason: "workflow handoff requires the original attempt" };
1000
- if (!first)
1001
- return m.injectText(t, text, undefined, { channel, guard: () => planContinuationAllowed(t) });
1002
- const r = await m.injectText(t, text, undefined, { ...carryOpts(first), guard: () => planContinuationAllowed(t) });
1003
- if (r.ok && rest.length)
1004
- void carryRest(t, rest);
1005
- return r;
1052
+ return m.injectText(t, text, undefined, { channel, guard: () => planContinuationAllowed(t) });
1006
1053
  },
1007
1054
  channelOf: m.currentChannel,
1008
1055
  lastText: m.lastText,
1009
- answeredBefore: (to, from, since, until, turn) => {
1010
- const p = m.sessionInfo(to)?.jsonlPath;
1011
- return p ? replyClosedBefore(expandHome(p), displayName(from), since, until, pingSigs, turn) : undefined;
1012
- },
1013
- receipts,
1056
+ // 欠账不落盘在交接里: 路由表按 target 记着它欠谁、欠哪几笔, 换会话不影响 (T8)。
1057
+ owed: (t) => router.owedBy(t).filter((r) => r.status === "open").map((r) => r.event),
1058
+ resume: async (t, event, at) => injectResume(t, event, at),
1014
1059
  // 交接是生命周期事件, 不进群; 失败只捎给它自己 (它调完 handoff_self 拿到的是
1015
1060
  // scheduled:true, 不说的话它不知道上下文其实没换)。
1016
1061
  notify: (t, text) => notices.post([t], text),
@@ -1025,39 +1070,325 @@ const main = async () => {
1025
1070
  log.warn({ target: e.target, name }, "handoff episode not archived");
1026
1071
  },
1027
1072
  });
1028
- /** 交接续答一份欠账的那一句: 挂发话方的信封, 出处与频道都是它当初那一句的。 */
1029
- function carryOpts(o) {
1030
- return {
1031
- from: { kind: "peer", from: o.from, ...(o.turn ? { turn: o.turn } : {}), ...(o.channel ? { public: true } : {}), ...(o.asker ? { asker: o.asker } : {}) },
1032
- channel: o.channel,
1033
- envelope: envelopeFor(o.from, o.channel, { turn: o.turn ?? "", asker: o.asker }),
1034
- };
1035
- }
1036
- /** 简报那一轮之后, 余下的欠账一份一轮: 等它接住上一句 (至多 20s 动起来) 并答完, 再贴下一份。 */
1037
- async function carryRest(t, owe) {
1038
- for (const o of owe) {
1039
- for (const end = Date.now() + 20_000; Date.now() < end && (await m.idleNow(t));)
1040
- await sleep(1000);
1041
- await m.untilIdle(t, 3600_000);
1042
- const r = await m.injectText(t, `(交接续答)接着上面的简报: 你交接前还欠 ${displayName(o.from)} 一个交代${o.turn ? ` (件 \`${o.turn}\`)` : ""} —— 现在只就这件收口。`, undefined, { ...carryOpts(o), guard: () => planContinuationAllowed(t) });
1043
- log.info({ mod: "handoff", target: t, from: o.from, turn: o.turn, ok: r.ok, reason: r.reason }, "handoff: 欠账续答");
1073
+ /** 交接后把「你还欠着这一笔」再点一次 (§9.5): 注入一句 `继续 <event>`, 该轮的信封把
1074
+ * `serves` 指回那一行 (注入事件 id `route:<event>:resume`), 于是它的终句照旧落定、
1075
+ * 照旧投给 receiver —— 换会话丢掉的只是 transcript, 不是这笔账。
1076
+ * id 按这一场交接的起点 (`at`) 区分: 同一场 reload 重放判重不重投, 换一场才配一条新的。 */
1077
+ function injectResume(t, event, at) {
1078
+ const row = router.get(event);
1079
+ if (!row || row.owner !== t || row.status !== "open")
1080
+ return { ok: false, reason: "no open row to resume" };
1081
+ const r = row.receiver;
1082
+ const waiting = r.kind === "wizard" ? r.to : baseOfKey(r.channel); // 等着它交代的那一方 (信封挂在它名下)
1083
+ const channel = r.kind === "chat" ? r.channel : ""; // 结论要落回群里时才带频道 (§9.4)
1084
+ const base = `route:${event}:resume`;
1085
+ const id = events.status().events.some((e) => e.id === base) ? `${base}:${at}` : base;
1086
+ try {
1087
+ events.accept({
1088
+ id, identity: JSON.stringify([t, event, "resume", at]),
1089
+ target: t, speaker: waiting, channel, serves: event,
1090
+ ...(row.asker ? { asker: row.asker } : {}),
1091
+ priority: "normal", deadline: Date.now() + 12 * 3600_000,
1092
+ payload: { text: `继续 ${event}` + " —— 这是你交接前还没交代的那一笔: 现在就把它做完并收口 (RESULT 交差 / NEED 反问)。", peer: {
1093
+ from: { kind: "peer", from: waiting, ...(row.asker ? { asker: row.asker } : {}) },
1094
+ // 不是一件新活 (没有件号、不收新回执): 它只是把那一笔重新指给 owner。
1095
+ job: "", kind: "task",
1096
+ // 这一笔由注入事件的 `serves` 认 (信封不带 event)。
1097
+ envelope: envelopeFor(waiting, channel, { ...(row.asker ? { asker: row.asker } : {}) }),
1098
+ } },
1099
+ });
1100
+ return { ok: true };
1101
+ }
1102
+ catch (e) {
1103
+ return { ok: false, reason: errText(e) };
1044
1104
  }
1045
1105
  }
1046
- /** `self` 此刻这一轮派出去的活, 结论该交给谁 (见 receipts.parentOf)。只读它自己
1106
+ /** `self` 此刻这一轮派出去的活, 结论该交给谁 (见 planReturn)。只读它自己
1047
1107
  * transcript 里开这一轮的那句 user 行的信封: attachment 上的 channel 在注入时就被
1048
1108
  * 排队的下一句改写了, transcript 才记着「这一轮是谁发起的」, reload 后也读得到。 */
1049
1109
  /** `target` 手上这一轮是哪件 peer 活 —— 认开头那句的信封: 有件号按件号, 老信封按发话方。 */
1050
1110
  /** `target` 手上这一轮若是某个 wizard 派来的活: 发话方的称呼 (`.x`) 与件号。人 / 定时 / 回执轮 = undefined。 */
1051
1111
  const openingAsk = (target) => {
1052
1112
  const p = m.sessionInfo(target)?.jsonlPath;
1053
- const env = p ? openingTurn(expandHome(p), pingSigs)?.env : undefined;
1054
- return env?.kind === "peer" && !env.receipt ? { from: env.from, ...(env.turn ? { turn: env.turn } : {}) } : undefined;
1113
+ const env = p ? openingTurn(expandHome(p))?.env : undefined;
1114
+ return env?.kind === "peer" && !env.receipt ? peerAsk(env) : undefined;
1115
+ };
1116
+ /** 一句同伴派活的出处: 发话方与它服务的那一笔 —— 派活信封不再带 event, 问注入事件的 `serves`;
1117
+ * 老信封带着的照用。 */
1118
+ const peerAsk = (env) => {
1119
+ const event = env.event ?? (env.eventId ? events.get(env.eventId)?.serves : undefined);
1120
+ return { from: env.from, ...(event ? { event } : {}) };
1121
+ };
1122
+ // ── 新路由表 (T3): 写入点 ──────────────────────────────────────────────
1123
+ // 一件活 = 一行 (§3.1)。这一层只做 open / move 与 serves 真值 —— 件号 (`t…`) 那套桥随
1124
+ // T10 全体退役: 注入事件的 id 就是 `route:<event>` (续问 `route:<event>:r<legs>`), 于是
1125
+ // 「W 这一轮服务哪一笔」直接问事件自己 (§9.4), 不再有第二套 id 要来回翻。
1126
+ /** 这一轮服务的那一笔 (§9.4): 开轮那句话的注入事件上记着 `serves` —— 人话轮是根行, 派活轮是
1127
+ * 新开的子行, **回执轮是 `receiver.event`**, 续问就是同一行。定时任务那一枪没有消息事件,
1128
+ * 它的信封上直接带着为它开的群根行 (`event`)。 */
1129
+ const servesOfTurn = (self) => {
1130
+ const env = openingOfSelf(self)?.env;
1131
+ if (!env)
1132
+ return undefined;
1133
+ const e = env.eventId ? events.get(env.eventId) : undefined;
1134
+ return e?.serves || (env.kind === "task" ? env.event : undefined) || undefined;
1135
+ };
1136
+ /** 本轮若是一份回执: 它回的是哪一笔 (owner = 回执的发话方) —— `re` / 撤回不必再抄 id 的依据。 */
1137
+ const receiptOfTurn = (self) => {
1138
+ const id = openingOfSelf(self)?.env?.eventId;
1139
+ const from = id ? events.get(id)?.payload.peer?.from : undefined;
1140
+ return from?.receipt ? from.event : undefined;
1141
+ };
1142
+ /** 开一行 (owner = 领活的人)。子行挂在自己服务的那一笔下, 挂不上就是新根 —— 建行这一处
1143
+ * 只认这两条, 别的判定 (§3.4 transfer 前置、409) 都在入口先做完。 */
1144
+ const openRow = (p) => {
1145
+ const event = p.event ?? newRootEvent();
1146
+ router.commit(ops.open({
1147
+ event, owner: p.owner, receiver: p.receiver, deadlineAt: p.deadlineAt,
1148
+ ...(p.job ? { job: p.job } : {}), ...(p.asker ? { asker: p.asker } : {}),
1149
+ ...(p.after?.length ? { after: [...p.after] } : {}),
1150
+ }));
1151
+ return event;
1152
+ };
1153
+ /** 派一件活的写入端 (§3.1): self 服务着某一笔 → 它的子行; 服务不着 → 新根;
1154
+ * transfer = 把服务的那一笔 move 给目标 (不插行, 注入那一轮仍服务它); none = 不开行。
1155
+ * 返回这一轮服务的 event (不开行 = "") —— 注入事件的 id 就是从它算出来的。 */
1156
+ const openDispatchRow = (p) => {
1157
+ if (p.op === "none")
1158
+ return "";
1159
+ // 计划节点 / 停放放行: 那一笔在 plan 发起 / 停放那一刻就定了; 其余看本轮服务谁。
1160
+ const f = p.serves ?? servesOfTurn(p.self);
1161
+ if (p.op === "transfer") {
1162
+ if (f && router.get(f)?.owner === p.self)
1163
+ router.commit(ops.move({ event: f, owner: p.target }));
1164
+ return f ?? "";
1165
+ }
1166
+ const parent = p.op === "self" ? f : undefined;
1167
+ return openRow({
1168
+ ...(parent ? { event: `${parent}.${(router.get(parent)?.seq ?? 0) + 1}` } : {}),
1169
+ owner: p.target,
1170
+ receiver: parent ? { kind: "wizard", to: p.self, event: parent } : { kind: "wizard", to: p.self },
1171
+ deadlineAt: p.deadlineAt,
1172
+ ...(p.job ? { job: p.job } : {}), ...(p.asker ? { asker: p.asker } : {}), ...(p.after?.length ? { after: p.after } : {}),
1173
+ });
1174
+ };
1175
+ /** 那一笔行上的接收快照 (挂起账投影用): 结论收给谁。没有行 = 不知道 (还是记 nobody)。 */
1176
+ const receiverOfEvent = (event) => (event ? router.get(event)?.receiver : undefined);
1177
+ /** 挂起账里那一行的 owner = 派活方 = 这一笔的接收方。收给群的根行 (人话 / 定时) 不进账 ——
1178
+ * 账记的是 wizard 之间的派活。 */
1179
+ const ledgerOwnerOf = (event) => {
1180
+ const r = router.get(event);
1181
+ return r && r.receiver.kind === "wizard" ? r.receiver.to : "";
1182
+ };
1183
+ const owedLabel = (r) => r.receiver.kind === "wizard" ? `${displayName(r.receiver.to)} 派的件` : `${chatNameOf(cfg, r.receiver.channel) || r.receiver.channel} 里人问的那一轮`;
1184
+ const owedList = (self) => {
1185
+ const xs = router.owedBy(self);
1186
+ return xs.length ? xs.map((r) => `\`${r.event}\` (${owedLabel(r)})`).join("; ") : "眼下你没有欠着任何一笔";
1187
+ };
1188
+ const planReturn = (self, body, origin) => {
1189
+ const r = returnOpOf(body);
1190
+ if (!r.ok)
1191
+ return { ok: false, status: 400, reason: r.reason };
1192
+ const want = typeof body.for === "string" ? body.for.trim() : "";
1193
+ if (origin !== undefined && want)
1194
+ return { ok: false, status: 400, reason: "`re` 续问沿用原件的那一笔, 不能再带 `for`" };
1195
+ // 续问只沿用原件: 改去向 (交出 / 旁支) 会让原件永远收不到结论 (§3.2)。
1196
+ if (origin !== undefined && (r.op === "transfer" || r.op === "detach"))
1197
+ return { ok: false, status: 400, reason: "`re` 续问只沿用原件: 不能带 transfer / detach" };
1198
+ if (want) {
1199
+ // `for` 说的是「为谁」: 派活方的名字 / 群名; 同一方欠着两笔才需要 event id。
1200
+ const id = eventRef(want);
1201
+ const who = isEventRef(id) ? undefined : resolvePeer(self, want);
1202
+ const rows = router.owedBy(self).filter((x) => x.status !== "answered" && (isEventRef(id)
1203
+ ? x.event === id
1204
+ : x.receiver.kind === "wizard" ? !!who?.ok && x.receiver.to === who.target : [chatNameOf(cfg, x.receiver.channel), x.receiver.channel].includes(want)));
1205
+ if (rows.length > 1)
1206
+ return { ok: false, status: 400, reason: `for "${want}" 对上你欠着的 ${rows.length} 笔, 用 event id 指明: ${rows.map((x) => `\`${x.event}\``).join(" ")}` };
1207
+ if (!rows[0])
1208
+ return { ok: false, status: 400, reason: `for "${want}" 不是你此刻欠着、还没交代的一笔 —— 你欠着的: ${owedList(self)}` };
1209
+ return { ok: true, op: r.op, serves: rows[0].event };
1210
+ }
1211
+ const inferred = origin ?? servesOfTurn(self);
1212
+ const row = inferred ? router.get(inferred) : undefined;
1213
+ // 本轮服务的那一笔已经被自己 transfer 出去了: 它的交代归接手方 (§3.4) —— 静默收口;
1214
+ // 真有与它无关的事, 显式 `for` 挂到你欠着的另一笔。
1215
+ // 续问 (origin) 的那一行本来就欠在对方手上 —— 只有本轮服务的那笔才谈得上「交出去了」。
1216
+ if (origin === undefined && row && row.owner !== self && r.op !== "transfer")
1217
+ return { ok: false, status: 409, reason: "这一轮服务的那一笔你已经 transfer 出去了: 它的交代归接手方, 不再由你派活或对人说话 —— 静默收口; 有别的要说, 用 `for` 挂到你欠着的另一笔" };
1218
+ return { ok: true, op: r.op, serves: row?.owner === self ? row.event : inferred };
1055
1219
  };
1220
+ /** 方向闸的那条链 (§9.5): 沿接收边 (`receiver`) 往上走 (`router.chain`, 带环保护), 收 owner key。
1221
+ * `extra` = `for` 显式选中的那一笔 (本轮服务的那笔之外还要算的一条)。 */
1222
+ const chainNow = (self, extra) => {
1223
+ const of = (e) => (e ? router.chain(e).slice(1).map((r) => r.owner) : []);
1224
+ const inferred = of(servesOfTurn(self));
1225
+ const depth = extra ? of(extra) : inferred;
1226
+ return { chain: [...new Set([...inferred, ...depth])], depth };
1227
+ };
1228
+ // ── 新路由表 (T4): 投递层 ──────────────────────────────────────────────
1229
+ // 落盘与投递是两件事, 顺序不能反 (§9.3, 不变量 1): 先 `answer` 落盘 (它是投递幂等键的半边),
1230
+ // 再按 receiver 投 ——
1231
+ // wizard → 进它的持久化收件箱: 复用 message-events 的注入队列, id = `rcpt:<event>:<rev>`。
1232
+ // chat → 只落 `hand`: 定论就是那句人话的答复气泡, 放行由 mirror-bridge 的 final 通道
1233
+ // (eventFinalGate 读同一张表) 给出。
1234
+ // T7: 投之前先看收方 (§4.3 / §4.4) —— 收方 wizard 已注销 → 沿链升级 (信封正文说明跳过了谁);
1235
+ // 一路走到 chat 根 → Dead Letter; 要服务的那一笔已收口 = 孤儿 → 照投, 信封正文标迟到, 并写明
1236
+ // 收方这一轮带 `RESULT:` 的终句会作为那一笔的追述投到哪 (一环一环走, 不抄近路)。
1237
+ const deliverRoute = (event, out) => {
1238
+ const row = router.get(event);
1239
+ if (!row)
1240
+ return;
1241
+ // 挂起账 / 工单账本跟着定论走 (§9.5 / §9.6): 每一份定论都从这儿过 (onFinal 的 answer、
1242
+ // 失败级联的 canceled、boot 的重投), 所以放这里而不是 commitRoute。收给群的根行不进账。
1243
+ if (row.receiver.kind === "wizard") {
1244
+ ledger.outcome(row.receiver.to, event, out.status, parseClosing(out.body).text || out.body);
1245
+ // 带 workflow 追踪的那件活: 执行态/正文/摘要跟着定论走 (计划节点据此推进) ——
1246
+ // 这一份原来只挂在旧回执 watcher 的 onOutcome 上, T10b 随它一起在这里重建。
1247
+ workflow.outcome(row.receiver.to, event, out.status, out.body);
1248
+ advancePlans();
1249
+ // 追述 (row.late) 不是工单成员的第二份结局: 账只记第一次落定。
1250
+ if (row.job && isTerminal(out.status) && !row.late)
1251
+ jobs.settle(row.job, row.owner, out.status, parseClosing(out.body).artifacts, { turn: event });
1252
+ }
1253
+ const esc = router.escalate(event, (k) => !!wizards.get(k));
1254
+ const receiver = esc?.receiver ?? row.receiver;
1255
+ const via = (esc?.via ?? []).map(displayName);
1256
+ // 升级落在 chat 根 = Dead Letter: 退到那个群, 由它的默认 wizard 收下这份结论。
1257
+ const dead = receiver.kind === "chat" && !!esc;
1258
+ if (receiver.kind !== "wizard" && !dead) {
1259
+ router.commit(ops.hand({ event, inbox: `chat:${receiver.message}:${out.rev}` }));
1260
+ return;
1261
+ }
1262
+ // 回执那一轮服务的就是收方自己欠的那一笔 (`receiver.event`) —— 它的终句又一次落到那一笔上,
1263
+ // 递归在这里闭合 (§3.2): 叶子交差 → 回执轮 → 父交差 → … → 根交进群。
1264
+ const to = receiver.kind === "wizard" ? receiver.to : baseOfKey(receiver.channel);
1265
+ const group = receiver.kind === "chat" ? receiver.channel : "";
1266
+ const serves = esc ? esc.serves : row.receiver.kind === "wizard" ? row.receiver.event ?? "" : "";
1267
+ const servedRow = serves ? router.get(serves) : undefined;
1268
+ const served = servedRow?.receiver;
1269
+ // 只有要去群里收口时才带频道 (它服务的那一笔的 receiver 就是那句人话, 或 Dead Letter 退到群)。
1270
+ const channel = group || (served?.kind === "chat" ? served.channel : "");
1271
+ const late = router.isOrphaned(serves);
1272
+ // 迟到时收方的追述去向: served 行还在 (未回收)、没被收掉、owner 仍是收方 → 沿它的 receiver 原路;
1273
+ // 否则无处可冒, 只进 rolepage。
1274
+ const retellTo = late && servedRow && servedRow.owner === to && servedRow.outcome?.status !== "canceled"
1275
+ ? servedRow.receiver.kind === "wizard" ? displayName(servedRow.receiver.to) : chatNameOf(cfg, servedRow.receiver.channel) || servedRow.receiver.channel
1276
+ : undefined;
1277
+ const inbox = `rcpt:${event}:${out.rev}`;
1278
+ const job = row.job ? jobs.get(row.job) : undefined;
1279
+ const tally = row.job ? jobs.tally(row.job) : undefined;
1280
+ events.accept({
1281
+ id: inbox, identity: JSON.stringify([to, event, out.status, out.rev]),
1282
+ target: to, speaker: row.owner, channel, serves,
1283
+ ...(row.asker ? { asker: row.asker } : {}),
1284
+ priority: "normal", deadline: Date.now() + 12 * 3600_000,
1285
+ // 行首 RESULT:/NEED: 只是判定记号, 语义已由信封的 status 携带 —— 投递时剥掉
1286
+ // (ARTIFACT 行保留: 路径对收方 wizard 有实际用途)。
1287
+ payload: { text: clipMiddle(stripClosingMark(out.body)), peer: {
1288
+ // 回执不是一件新活: `job` 留空 (否则 jobs.account 会把它当成工单的第二份派活数一遍)。
1289
+ from: { kind: "peer", from: row.owner, receipt: true, status: out.status, event },
1290
+ job: "", kind: "fyi",
1291
+ envelope: renderReceiptEnvelope(displayName(row.owner), channel ? chatNameOf(cfg, channel) : undefined, row.job ? { job: row.job, done: tally?.done ?? 0, total: tally?.total ?? 0, closed: job?.status !== "open", ...(job?.kind === "req" ? { req: true } : {}) } : undefined, out.status, event, { ...(row.asker ? { asker: row.asker } : {}),
1292
+ ...(served?.kind === "wizard" ? { up: true } : {}),
1293
+ ...(audienceName(channel, row.asker) ? { audience: audienceName(channel, row.asker) } : {}),
1294
+ ...(via.length ? { via: via.join(", ") } : {}),
1295
+ ...(late ? { late: true } : {}),
1296
+ ...(retellTo ? { retellTo } : {}),
1297
+ ...(row.late ? { retold: true } : {}) }),
1298
+ } },
1299
+ });
1300
+ router.commit(ops.hand({ event, inbox }));
1301
+ };
1302
+ /** 一条路由 op: 先落盘, 再投该投的 (need / error 带正文入箱; answer 按 receiver 投递)。这里
1303
+ * 只管 `re` (`mark open`) 把那一行拉回在飞 —— 账本与工单跟着 deliverRoute 走。 */
1304
+ const commitRoute = (op) => {
1305
+ router.commit(op);
1306
+ if (op.op === "mark") {
1307
+ const by = ledgerOwnerOf(op.event);
1308
+ if (by && op.status === "open")
1309
+ ledger.touch(by, op.event);
1310
+ if ((op.status === "need" || op.status === "error") && op.body !== undefined && op.rev !== undefined)
1311
+ deliverRoute(op.event, { status: op.status, body: op.body, rev: op.rev });
1312
+ return;
1313
+ }
1314
+ if (op.op === "answer")
1315
+ deliverRoute(op.event, op.outcome);
1316
+ };
1317
+ /** 终句 → 路由 op (§9.4): 一轮结束时把这一笔推进 (定论 / 中途态), 投递在 commitRoute 里做。
1318
+ * 工单的**验收打回**也在这一层: 交的活不合规格就不落定论 (need / error 先拉回 open), 以工单
1319
+ * owner 的名义在同一笔上续问一次 —— `bounced` 记住那一笔, 同一笔最多打回一次 (reload 后
1320
+ * 也最多再多打回一次)。**这是「轮终 evidence → onFinal」那条主触发链的落点**: 没有它,
1321
+ * answer 就只能靠 sweeper / 级联取消 / boot 重放合成, 谁答完都不会有回执。 */
1322
+ deliverFinal = (e, verdict) => {
1323
+ const plans = router.onFinal(e.target, e.serves, verdict);
1324
+ const answer = plans.flatMap((op) => (op.op === "answer" && op.outcome.status === "done" ? [op] : []))[0];
1325
+ const row = answer ? router.get(answer.event) : undefined;
1326
+ const why = answer && row ? acceptanceOf(row, answer.outcome.body) : undefined;
1327
+ if (row && why) {
1328
+ if (row.status === "need" || row.status === "error")
1329
+ commitRoute(ops.mark({ event: row.event, status: "open" }));
1330
+ rebound(row, why);
1331
+ return;
1332
+ }
1333
+ plans.forEach(commitRoute);
1334
+ };
1335
+ // ── 新路由表 (T6): 失败级联 ─────────────────────────────────────────────
1336
+ /** `target` 欠着的行里这一轮该被收掉的根 (§4.2): 不给 `only` = 全部 (end / close_job);
1337
+ * 给了 = 认它欠着的那一笔 (派它的那一方 = 行的 receiver, 那一笔 = event)。 */
1338
+ const cancelRoots = (target, only) => router.owedBy(target).filter((r) => !only || only({
1339
+ from: r.receiver.kind === "wizard" ? r.receiver.to : baseOfKey(r.receiver.channel),
1340
+ event: r.event,
1341
+ })).map((r) => r.event);
1342
+ /** 级联取消 (§4.2 TERMINATE): `subtree` 全部 canceled, 叶子先落; 发起者就是 receiver 的那份
1343
+ * 不投 (它自己知道), 但行得落成墓碑 —— 不然一直挂在它的 owedBy 上。 */
1344
+ const cancelRoutes = (roots, reason, by) => {
1345
+ const plans = cancelOps(router.table(), roots, { reason, nameOf: displayName, lastWords: m.lastReply });
1346
+ for (const op of plans) {
1347
+ router.commit(op);
1348
+ if (op.op !== "answer")
1349
+ continue;
1350
+ const row = router.get(op.event);
1351
+ if (row?.receiver.kind === "wizard" && row.receiver.to === by) {
1352
+ router.commit(ops.hand({ event: op.event, inbox: `canceled:${op.event}:${op.outcome.rev}` }));
1353
+ continue;
1354
+ }
1355
+ deliverRoute(op.event, op.outcome);
1356
+ }
1357
+ return plans.length;
1358
+ };
1359
+ /** §9.5 boot: fold 之后把表上没有落定的收一收 ——
1360
+ * - `answered` 未 `hand`: 定论在盘上却没进过 receiver 的收件箱 → 重投一次 (replayAnswered)。
1361
+ * - `held`: 前置 (`after`) 重查一次。有一笔是失败 → 这件永远轮不到 (§4.2), 带前置快照整件
1362
+ * 取消; 全落定 → release。两道都幂等, 与在途注入的 committed 撞上无害。
1363
+ * - `open` / `need` / `error`: 不动 —— 在途注入的 result 由 message-events resume 送回 onFinal。 */
1364
+ const settleHeld = () => {
1365
+ router.rows().filter((r) => r.status === "held").forEach((r) => {
1366
+ const prereqs = (r.after ?? []).map((e) => router.get(e));
1367
+ const failed = prereqs.find((a) => a?.outcome && a.outcome.status !== "done");
1368
+ if (failed?.outcome) {
1369
+ commitRoute(ops.answer({ event: r.event, outcome: makeOutcome("canceled", `前置回执 (${failed.event}) 是 ${failed.outcome.status}, 这一件不会轮到了 —— 已取消, 没有投给它。`) }));
1370
+ return;
1371
+ }
1372
+ if (prereqs.every((a) => a?.outcome?.status === "done")) {
1373
+ commitRoute(ops.release({ event: r.event }));
1374
+ releaseHeld(r);
1375
+ }
1376
+ });
1377
+ };
1378
+ /** §3.5 放行注入的接入点 (.rcptt1 设计「held 放行后要注入什么载荷」后填这里):
1379
+ * 前置全落定的那一笔已经从 held 变 open, 但**没有人把它的活投出去** —— 旧机制的注入在
1380
+ * 回执槽的 release dep 里, 随 receipts 一起退役了。载荷 (要说什么 / 怎么收口) 还没定,
1381
+ * 先留这个调用位。 */
1382
+ const releaseHeld = (_row) => { };
1383
+ /** §4.5 boot: fold 之后 `answered` 还没 `hand` 的行重投一次 —— rcpt 的 id 幂等, 重放不双投。 */
1384
+ const replayAnswered = () => router.rows()
1385
+ .filter((r) => r.status === "answered" && r.outcome !== undefined)
1386
+ .forEach((r) => deliverRoute(r.event, r.outcome));
1056
1387
  const currentAsk = (target) => {
1057
1388
  const env = openingAsk(target);
1058
1389
  if (!env)
1059
1390
  return () => false;
1060
- return (x) => (env.turn ? x.turn === env.turn : displayName(x.from) === env.from);
1391
+ return (x) => (env.event ? x.event === env.event : displayName(x.from) === env.from);
1061
1392
  };
1062
1393
  /** 打断 `target` 会不会误伤别人的活: 它正忙, 且这一轮不是调用方派的 —— 别的 wizard 派的、
1063
1394
  * 人说的、定时任务放的 → 那一方的称呼。回执轮是它自己那件活的延续, 不算别人的。 */
@@ -1065,34 +1396,39 @@ const main = async () => {
1065
1396
  if (await m.idleNow(target))
1066
1397
  return undefined;
1067
1398
  const p = m.sessionInfo(target)?.jsonlPath;
1068
- const env = p ? openingTurn(expandHome(p), pingSigs)?.env : undefined;
1399
+ const env = p ? openingTurn(expandHome(p))?.env : undefined;
1069
1400
  if (env?.receipt)
1070
1401
  return undefined;
1071
1402
  if (env?.kind === "peer")
1072
- return env.from === displayName(self) ? undefined : { from: env.from, ...(env.turn ? { turn: env.turn } : {}) };
1403
+ return env.from === displayName(self) ? undefined : peerAsk(env);
1073
1404
  return { from: env?.kind === "task" ? env.from : env?.from ? `人 (${env.from})` : "人" };
1074
1405
  };
1075
1406
  const openingOfSelf = (self) => {
1076
1407
  const p = m.sessionInfo(self)?.jsonlPath;
1077
- return p ? openingTurn(expandHome(p), pingSigs) : undefined;
1078
- };
1079
- const lostAnswer = (self) => {
1080
- return answerSourceLost(answers.all(), self, m.answerContext(self)?.turn, openingOfSelf(self)?.ms);
1081
- };
1082
- const terminalAnswerTurn = (self) => {
1083
- const turn = openingOfSelf(self)?.env?.turn;
1084
- return turn ? answers.terminalTarget(self, turn) : undefined;
1408
+ return p ? openingTurn(expandHome(p)) : undefined;
1085
1409
  };
1086
- const controlSnapshot = (self) => ({ opening: openingOfSelf(self)?.ms, turn: m.answerContext(self)?.turn });
1087
- const controlValid = (self, snapshot) => openingOfSelf(self)?.ms === snapshot.opening && m.answerContext(self)?.turn === snapshot.turn && !lostAnswer(self) && !terminalAnswerTurn(self);
1088
- const hasAnswerChildren = (self, k) => !!k && (receipts.states().some((s) => s.from === self && !!s.k && kAttr(s.k) === kAttr(k))
1089
- || events.status().events.some((e) => e.speaker === self && e.k && kAttr(e.k) === kAttr(k) && e.payload.peer?.tell && !["complete", "canceled"].includes(e.execution))
1090
- || workflow.all().some((a) => a.owner === self && a.tell?.k && kAttr(a.tell.k) === kAttr(k) && !a.canceled && a.execution !== "terminal"));
1091
- m.setAnswerSilencer((target, turn) => answers.source(target, turn) || lostAnswer(target));
1092
- const parentKOf = (self) => {
1093
- const opening = openingOfSelf(self);
1094
- // 不用 channelOf: 它把私聊轮 ("") 回落成 home, 私聊轮派出的活就会续回 home 群。
1095
- return receipts.parentOf(self, opening?.env, m.currentChannel(self), String(opening?.ms ?? 0));
1410
+ const controlSnapshot = (self) => ({ opening: openingOfSelf(self)?.ms });
1411
+ const controlValid = (self, snapshot) => openingOfSelf(self)?.ms === snapshot.opening;
1412
+ /** 名册 / peek / list_jobs / rolepage 的「在等 / 欠着」: 从路由表现算 (§9.5 读侧), 只借 pane
1413
+ * 的一次探针判「停在审批卡上」。收给群的那种根行不进这一份 —— 它与任何 wizard 之间没有
1414
+ * 「谁欠谁」的边 (旧的 receipts.states() 里也从来没有它们); 定了论的 (answered / handed)
1415
+ * 同样不进: 这一份问的是「还在飞 / 还欠着」。`turn` 那一栏放的是 event id (§9.1),
1416
+ * 下游只当字符串展示。 */
1417
+ const routeInFlight = () => {
1418
+ const probed = new Map();
1419
+ const parked = (t) => {
1420
+ const hit = probed.get(t);
1421
+ if (hit !== undefined)
1422
+ return hit;
1423
+ probed.set(t, m.parkedNow(t));
1424
+ return probed.get(t);
1425
+ };
1426
+ return router.rows().flatMap((r) => r.receiver.kind !== "wizard" || r.status === "handed" || r.status === "answered" ? []
1427
+ : [{
1428
+ from: r.receiver.to, to: r.owner, turn: r.event, job: r.job ?? "",
1429
+ state: turnState({ status: r.status, waiting: router.waiting(r.event).length > 0 }, { parked: r.status === "open" && parked(r.owner) }),
1430
+ ageMs: Date.now() - r.at,
1431
+ }]);
1096
1432
  };
1097
1433
  /** `self` 此刻这一轮的链头 (见 Asker), 同样认开头那句的信封: 人在群里说的 → 信封上的
1098
1434
  * userid 与群; 与人的单聊里没挂信封 → 那个人; 同伴派的活 / 回执 → 登记那件活时记下的;
@@ -1102,34 +1438,13 @@ const main = async () => {
1102
1438
  const home = baseOfKey(self);
1103
1439
  if (!env)
1104
1440
  return m.currentAsker(self);
1105
- return askerFromEnvelope(env, (chat) => chat ? chatBaseOf(cfg, chat) : home)
1106
- ?? (env.kind === "peer" ? receipts.askerOf(self, env) : undefined);
1441
+ return askerFromEnvelope(env, (chat) => chat ? chatBaseOf(cfg, chat) : home);
1107
1442
  };
1108
1443
  // A private dispatch keeps its human origin even though its output channel is empty.
1109
1444
  bindCardChannel((key) => askerOf(key)?.chat || m.currentChannel(key));
1110
1445
  // 交接先续做: 它的闸要在回执续守之前立起来, 否则续守的 watcher 会读到换了一半的会话。
1111
1446
  void m.restored.then(() => {
1112
1447
  handoffs.resume();
1113
- answers.all().filter((r) => r.state !== "delivered").forEach((r) => {
1114
- const savedFinal = events.status().deliveries.find((d) => d.transfer?.id === r.id);
1115
- if (savedFinal?.parts.every((p) => p.state === "delivered")) {
1116
- answers.delivered(r.id);
1117
- return;
1118
- }
1119
- const action = answerRecoveryAction(r);
1120
- if (action === "cancel-before-send") {
1121
- answers.activate(r.id);
1122
- void deliverTransferredAnswer(r.id, { status: "canceled", body: "daemon 重启时回答移交仍在投递,结果不确定;本轮已收止,请重新发送问题。" });
1123
- }
1124
- else if (action === "ambiguous") {
1125
- answers.uncertain(r.id);
1126
- log.error({ id: r.id }, "answer delivery may have reached the chat before restart; automatic retry withheld, user may resend if absent");
1127
- }
1128
- else if (action === "deliver-outcome" && r.outcome)
1129
- void deliverTransferredAnswer(r.id, r.outcome);
1130
- else if (action === "watch" && !receipts.watching(r.tell))
1131
- receipts.register(r.tell);
1132
- });
1133
1448
  // Confirmed injections may outlive the legacy receipt-store write. Rebuild watching,
1134
1449
  // using the original turn and intent; uncertain injections remain manual reconciliation.
1135
1450
  workflow.all().filter((a) => a.tell && ["running", "needs-input", "errored"].includes(a.execution))
@@ -1139,12 +1454,11 @@ const main = async () => {
1139
1454
  catch (e) {
1140
1455
  log.error({ err: e.message, attemptId: a.id }, "workflow recovery accounting failed; watcher not restored");
1141
1456
  } });
1142
- receipts.resume();
1143
1457
  });
1144
- // 闲置追问 (见 idle-nudge.ts): 对方持续闲着又没回执 → 代发话方同件号问一句进展, 答复照常回执。
1145
- // 注入照抄工单打回 (rebound) 那条: 私聊信封挂发话方的名义与件号, 父 k 等由 reask 沿用原件。
1458
+ // 闲置追问 (见 idle-nudge.ts): 对方持续闲着又没交回结论 → 代发话方在同一笔上问一句进展,
1459
+ // 它的答复照常作为那一笔的定论回来 (续问不新开行)。
1146
1460
  void m.restored.then(() => startIdleNudge({
1147
- inflight: () => receipts.states(),
1461
+ inflight: () => routeInFlight(),
1148
1462
  idleNow: m.idleNow,
1149
1463
  handingOff,
1150
1464
  jobOpen: (id) => jobs.get(id)?.status === "open",
@@ -1152,8 +1466,11 @@ const main = async () => {
1152
1466
  ask: async ({ from, to, turn, text }) => {
1153
1467
  if (workflow.byTurn(from, turn) || !planStore.allowExternal(to, wizards.get(to)?.name))
1154
1468
  return false;
1155
- const tell = receipts.continuation(from, to, turn);
1156
- return !!tell && acceptContinuation(tell, text);
1469
+ const row = router.get(turn);
1470
+ if (!row)
1471
+ return false;
1472
+ return acceptContinuation({ from, to, channel: "", event: turn, deadlineAt: row.deadlineAt,
1473
+ ...(row.asker ? { asker: row.asker } : {}), ...(row.job ? { job: row.job } : {}) }, text);
1157
1474
  },
1158
1475
  nameOf: displayName,
1159
1476
  store: loadJsonMap(join(cfg.daemon.stateDir, "idle-nudges.json")),
@@ -1244,14 +1561,14 @@ const main = async () => {
1244
1561
  // 顶层模式: 只有顶层 wizard 对人说话。拦的是「这一轮在为同伴干活」的: 同伴私聊派来的活, 或
1245
1562
  // 回执轮但那件活是为上游同伴派的 (k 是 peer)。人说的、定时任务放的、旁支 (chain:false, 无 k)
1246
1563
  // 的回执轮都不拦; 管家只在自己的群里豁免。notify 与 send_file 共用这一道闸。
1564
+ // 顶层模式: 只有顶层 wizard 对人说话。「这一轮在为同伴干活」的唯一判据 = 它服务的那一笔
1565
+ // 的定论收给谁 (§9.5): 收给同伴的 wizard = 它在链条下面, 不对人说话; 收给群的 = 它自己
1566
+ // 就是对人收口的那一层。transfer 出去的那一笔行已 move 给别人, 收方仍是同伴 → 照样拦。
1567
+ // 没有行 (CLI 直接敲的轮 / 定时任务) = 不在为谁干活, 不拦。管家只在自己的群里豁免。
1568
+ // notify 与 send_file 共用这一道闸。
1247
1569
  const topOnlyMuted = (self, dests) => {
1248
- if (lostAnswer(self))
1249
- return { ok: false, reason: "本轮回答权和控制权已移交;由目标 wizard 对人交付" };
1250
- const env = openingOfSelf(self)?.env;
1251
- const transferred = env?.turn ? answers.target(self, env.turn) : undefined;
1252
- if (transferred)
1253
- return !transferred.outcome && transferred.state !== "delivered" ? undefined : { ok: false, reason: "本轮移交回答已经落定;迟到产出不能再次对人发送" };
1254
- const belowTop = env?.kind === "peer" && (env.receipt ? kOfAttr(env.k ?? "")?.kind === "peer" : env.private);
1570
+ const serves = servesOfTurn(self);
1571
+ const belowTop = !!serves && !router.speaksToHuman(serves);
1255
1572
  const muted = dests.filter((d) => chatPolicyOf(cfg, d).topOnly && !(!tagOfKey(self) && baseOfKey(self) === d));
1256
1573
  return muted.length && belowTop
1257
1574
  ? { ok: false, topOnly: true, reason: `${muted.map((d) => chatNameOf(cfg, d) || d).join(", ")} 开着顶层模式: 你这一轮是同伴派来的活, 不对人说话 —— 要说的写进终句 (\`RESULT:\` / \`NEED:\`), 由派你的那个代你向人交代` }
@@ -1280,8 +1597,7 @@ const main = async () => {
1280
1597
  json(res, 400, { ok: false, reason: `认不出这些聊天: ${bad.join(", ")} (wizard_roster 每行有 home 聊天名, 人侧 /chats; 没起名的聊天寻址不到)` });
1281
1598
  return;
1282
1599
  }
1283
- const activeTransfer = (() => { const t = openingOfSelf(self)?.env?.turn; return t ? answers.target(self, t) : undefined; })();
1284
- const dests = [...new Set(refs.length ? refs.map((r) => chatBaseOf(cfg, r)) : [activeTransfer?.channel ?? channelOf(self)])];
1600
+ const dests = [...new Set(refs.length ? refs.map((r) => chatBaseOf(cfg, r)) : [channelOf(self)])];
1285
1601
  const muted = topOnlyMuted(self, dests);
1286
1602
  if (muted) {
1287
1603
  json(res, 409, muted);
@@ -1319,9 +1635,7 @@ const main = async () => {
1319
1635
  }
1320
1636
  ensureChatNames(self);
1321
1637
  const ref = (b.to ?? "").trim();
1322
- const transferTurn = openingOfSelf(self)?.env?.turn;
1323
- const transfer = transferTurn ? answers.target(self, transferTurn) : undefined;
1324
- const dest = ref ? chatBaseOf(cfg, ref) : transfer?.channel ?? channelOf(self);
1638
+ const dest = ref ? chatBaseOf(cfg, ref) : channelOf(self);
1325
1639
  if (!dest || !sameBot(self, dest)) {
1326
1640
  json(res, 400, { ok: false, reason: `认不出聊天: ${ref} (wizard_roster 每行有 home 聊天名, 人侧 /chats; 没起名的聊天寻址不到)` });
1327
1641
  return;
@@ -1361,11 +1675,11 @@ const main = async () => {
1361
1675
  }
1362
1676
  const { target, foreign } = r;
1363
1677
  const turns = Math.min(Math.max(Number(body.turns ?? 6) || 6, 1), 40);
1364
- // 对话只从 transcript 读 (正文轮次, 去掉保温 ping/pong), 不刮终端: pane 是
1678
+ // 对话只从 transcript 读 (正文轮次), 不刮终端: pane 是
1365
1679
  // 截断的视口加一层 TUI 装饰, 而「它卡在哪」transcript 答得更准 —— 不在转圈
1366
1680
  // 却悬着工具调用, 就是停在审批卡 / 本地弹窗上等人点。
1367
1681
  const peek = await m.peekTurns(target, turns);
1368
- const inflight = turnsLine(receipts.states(), target);
1682
+ const inflight = turnsLine(routeInFlight(), target);
1369
1683
  const waiting = peek.waiting ?? [];
1370
1684
  const state = peek.busy
1371
1685
  ? "正在生成"
@@ -1447,7 +1761,7 @@ const main = async () => {
1447
1761
  ...all.filter((t) => !pinned.has(t.target) && now - t.mtime < LOG_RECENT_MS).sort((x, y) => y.mtime - x.mtime).slice(0, LOG_SESSIONS),
1448
1762
  ];
1449
1763
  const chat = base ? chatNameOf(cfg, base) : undefined;
1450
- const log_ = renderChatLog(sessions.map(logSession), { role: role?.name, chat, target: target?.name, since, until, limit, per }, keepalivePingSigs(cfg.wrc.mirror.keepalive.ping), now);
1764
+ const log_ = renderChatLog(sessions.map(logSession), { role: role?.name, chat, target: target?.name, since, until, limit, per }, now);
1451
1765
  const scope = [
1452
1766
  chat !== undefined ? `${base.startsWith("user:") ? "单聊" : "群"} ${chat || base}` : "",
1453
1767
  role && target ? `${role.name} ⇄ ${target.name}` : role ? `${role.name} 的往来` : "",
@@ -1469,24 +1783,38 @@ const main = async () => {
1469
1783
  // `/peers/send` 保留为同一个处理函数: 正在跑的 wizard 的 MCP 进程是旧代码, 换名字
1470
1784
  // 不能把它们的通路掐断。
1471
1785
  // 冷与否 = 缓存最近一次被碰 (最近一次真实请求的开始; 读不到退回 mtime) 距今多久 vs 它实际按多长 TTL 写的
1472
- // (与 route_candidates 同一口径)。
1786
+ // (与 route_candidates 同一口径)。transcript 里没有分层写入记录时按 5min 兜底。
1787
+ const CACHE_TTL_FALLBACK_SEC = 300;
1473
1788
  const touchOf = (p) => { try {
1474
1789
  return cacheTouchMs(p) || statSync(p).mtimeMs;
1475
1790
  }
1476
1791
  catch {
1477
1792
  return 0;
1478
1793
  } };
1479
- const ttlOf = (p) => (cacheTtlSec(p) || cfg.wrc.mirror.keepalive.ttlSec) * 1000;
1794
+ const ttlOf = (p) => (cacheTtlSec(p) || CACHE_TTL_FALLBACK_SEC) * 1000;
1480
1795
  // 被冷门控退回过的 `self\0target` → 到期时刻: 老 MCP 进程原样重发即视为 force。
1481
1796
  const coldRefused = new Map();
1482
- /** 入参 `after` → 件号数组 (去反引号 / 空白 / 重复)。 */
1483
- const afterTurnsOf = (body) => {
1797
+ /** 入参 `after` → event 数组: 每项是名字 (= 我派给它的那一笔, 见 pickPair) 或 event id。 */
1798
+ const afterTurnsOf = (self, body) => {
1484
1799
  const raw = body.after;
1485
- return [...new Set((Array.isArray(raw) ? raw : []).map((x) => String(x).replace(/[`\s]/g, "")).filter(Boolean))];
1800
+ const picked = [...new Set((Array.isArray(raw) ? raw : []).map((x) => String(x).trim()).filter(Boolean))].map((x) => {
1801
+ const id = eventRef(x);
1802
+ if (isEventRef(id))
1803
+ return { id };
1804
+ const who = resolvePeer(self, x);
1805
+ if (!who.ok)
1806
+ return { bad: `${x}: ${who.reason}` };
1807
+ const p = pickPair(router.table(), self, who.target);
1808
+ if (!p.ok)
1809
+ return { bad: `${x} 手上有你 ${p.many.length} 笔没落定 (${p.many.map((r) => r.event).join(" ")}), 用 event id 指明` };
1810
+ return p.row ? { id: p.row.event } : { bad: `你没派过活给 ${x}` };
1811
+ });
1812
+ const bad = picked.flatMap((p) => ("bad" in p ? [p.bad] : []));
1813
+ return bad.length ? { ok: false, reason: `after 认不出: ${bad.join("; ")}` } : { ok: true, ids: [...new Set(picked.flatMap((p) => ("id" in p ? [p.id] : [])))] };
1486
1814
  };
1487
1815
  /** 放行时挂在信封后面的前置结论: 谁交的、件号、RESULT 与 ARTIFACT 原样 (只进对方输入框)。 */
1488
1816
  const afterNote = (turns, seen, owner) => renderReminder({ wezard: "after" }, [
1489
- "这件活排在下面这些前置之后, 它们都已落定; 结论原样附上 (谁交的 · 件号):",
1817
+ "这件活排在下面这些前置之后, 它们都已落定; 结论原样附上 (谁交的):",
1490
1818
  ...turns.flatMap((t) => {
1491
1819
  const x = seen[t];
1492
1820
  if (!x)
@@ -1495,7 +1823,7 @@ const main = async () => {
1495
1823
  const versions = workflow.list(owner).find((a) => a.turn === t && a.acceptance === "accepted")?.files ?? [];
1496
1824
  const clean = (v) => v.replace(/<\/?system-reminder[^>]*>/g, "");
1497
1825
  return [
1498
- `- ${displayName(x.to)} · ${t} · ${x.status}: ${c.kind === "result" ? `RESULT: ${clean(c.text)}` : clipLine(clean(x.body), 600) || "(没有正文)"}`,
1826
+ `- ${displayName(x.to)} · ${x.status}: ${c.kind === "result" ? `RESULT: ${clean(c.text)}` : clipLine(clean(x.body), 600) || "(没有正文)"}`,
1499
1827
  ...c.artifacts.map((a) => {
1500
1828
  const f = versions.find((f) => f.path === a.path);
1501
1829
  return f ? ` ARTIFACT: ${f.snapshot} — accepted version sha256 ${f.sha256}; original source ${f.path}` : ` ARTIFACT: ${a.path}${a.note ? ` — ${a.note}` : ""}`;
@@ -1509,75 +1837,88 @@ const main = async () => {
1509
1837
  if (!a.dispatches?.length)
1510
1838
  throw new Error("historical tracked attempt lacks dispatch accounting evidence; reconcile stopped before retry (no blind budget import)");
1511
1839
  for (const d of a.dispatches) {
1512
- jobs.account(a.job, d.key, { target: a.to, task: d.continuation ? "" : d.text, spawned: false, turn: (d.tell ?? a.tell)?.turn, ...(d.role ? { role: d.role } : {}) }, d.kind, d.free, recovery, !!d.free && !!d.continuation);
1840
+ jobs.account(a.job, d.key, { target: a.to, task: d.continuation ? "" : d.text, spawned: false, turn: (d.tell ?? a.tell)?.event, ...(d.role ? { role: d.role } : {}) }, d.kind, d.free, recovery, !!d.free && !!d.continuation);
1513
1841
  const t = d.tell ?? a.tell;
1514
- ledger.dispatch(d.key, { turn: a.turn, owner: a.owner, to: a.to, text: d.text, at: d.at, job: a.job, ...(t?.k ? { for: t.k } : {}), ...(t?.asker ? { asker: t.asker } : {}) });
1842
+ const ev = a.turn;
1843
+ const rec = receiverOfEvent(ev);
1844
+ ledger.dispatch(d.key, { event: ev, turn: a.turn, owner: a.owner, to: a.to, text: d.text, at: d.at, job: a.job, ...(rec ? { receiver: rec } : {}), ...(t?.asker ? { asker: t.asker } : {}) });
1515
1845
  }
1516
1846
  };
1517
1847
  const recoverWorkflow = (a) => {
1518
1848
  if (a.canceled || jobs.get(a.job)?.status !== "open")
1519
1849
  return;
1520
1850
  accountWorkflow(a, true);
1521
- if (a.tell && !receipts.watching(a.tell))
1522
- receipts.register(a.tell, true, a.status && a.body ? { status: a.status, body: a.body } : undefined);
1523
1851
  };
1524
- const cancelPeerWork = (target, self, only = () => true) => {
1525
- const keys = new Set([
1526
- ...workflow.all().filter((a) => a.to === target && a.execution !== "terminal" && !a.canceled && only({ from: a.owner, turn: a.turn })).map((a) => `${a.owner}\0${a.turn}`),
1527
- ...receipts.states().filter((s) => s.to === target && only(s)).map((s) => `${s.from}\0${s.turn}`),
1528
- ]);
1529
- events.cancel((e) => e.target === target && !!e.payload.peer && only({ from: e.speaker, turn: e.payload.peer.from.turn ?? "" }));
1852
+ const cancelPeerWork = (target, self, only) => {
1853
+ const hit = only ?? (() => true);
1854
+ const keys = new Set(workflow.all().filter((a) => a.to === target && a.execution !== "terminal" && !a.canceled && hit({ from: a.owner, event: a.turn })).map((a) => `${a.owner}\0${a.turn}`));
1855
+ // T6: 路由表上的同一批 (§4.2) —— subtree 级联 canceled, 定论走路由表。
1856
+ cancelRoutes(cancelRoots(target, only), (r) => `被 ${displayName(self)} 收掉 / 打断了, 这件活没有结论。`, self);
1857
+ events.cancel((e) => e.target === target && !!e.payload.peer && hit({ from: e.speaker, event: e.payload.peer.from.event ?? "" }));
1530
1858
  workflow.cancel(target, undefined, undefined, undefined, only);
1531
- receipts.cancel(target, self, only);
1532
1859
  return keys.size;
1533
1860
  };
1861
+ /** 逐笔看前置此刻是什么 (§3.4): 都是发话方自己派出去的那几笔 (receiver 指回自己) ——
1862
+ * 表上查不到 / 不是自己派的那件 = unknown (过期或写错)。 */
1863
+ const prereqsOf = (self, turns) => turns.map((t) => {
1864
+ const r = router.get(t);
1865
+ if (!r || r.receiver.kind !== "wizard" || r.receiver.to !== self)
1866
+ return { state: "unknown" };
1867
+ if (r.outcome?.status === "done")
1868
+ return { state: "done", seen: { to: r.owner, status: "done", body: r.outcome.body } };
1869
+ if (r.outcome)
1870
+ return { state: "failed", seen: { to: r.owner, status: r.outcome.status, body: r.outcome.body } };
1871
+ return { state: "pending" };
1872
+ });
1873
+ /** 一笔前置落定后的结论快照 (放行时附在信封后面)。 */
1874
+ const seenOf = (t) => {
1875
+ const r = router.get(t);
1876
+ return r?.outcome ? { to: r.owner, status: r.outcome.status, body: r.outcome.body } : undefined;
1877
+ };
1534
1878
  peerEvents = {
1535
1879
  valid: (e) => {
1536
1880
  const p = e.payload.peer;
1537
- const attempt = p.attemptId ? workflow.byTurn(e.speaker, p.from.turn ?? "") : undefined;
1538
- return planStore.healthy() && !e.canceled && (!p.controlTransfer || !answers.get(p.controlTransfer)?.outcome) && (!p.from.receipt || planContinuationAllowed(e.target)) && (!p.job || jobs.get(p.job)?.status === "open") && !attempt?.canceled
1539
- && (!p.after?.length || receipts.prereqs(e.speaker, p.after, p.afterSeen).every((r) => r.state === "done" || r.state === "pending"))
1540
- && !answers.all().some((r) => r.target === e.target && answerTargetOccupied(r));
1881
+ const attempt = p.attemptId ? workflow.byTurn(e.speaker, p.from.event ?? "") : undefined;
1882
+ return planStore.healthy() && !e.canceled && (!p.from.receipt || planContinuationAllowed(e.target)) && (!p.job || jobs.get(p.job)?.status === "open") && !attempt?.canceled
1883
+ && (!p.after?.length || prereqsOf(e.speaker, p.after).every((r) => r.state === "done" || r.state === "pending"));
1541
1884
  },
1542
1885
  accepted: (e) => {
1543
1886
  if (e.execution !== "queued")
1544
1887
  return;
1545
1888
  const p = e.payload.peer;
1546
- if (p.tell && e.k?.kind === "chat")
1547
- events.defer(e.k.turn, true);
1889
+ // 旧 watcher 的 continuation 代数已退役 (§9.4): 收口由事件自己的 serves 决定。
1548
1890
  if (p.attemptId)
1549
- accountWorkflow(workflow.byTurn(e.speaker, p.from.turn ?? ""));
1891
+ accountWorkflow(workflow.byTurn(e.speaker, p.from.event ?? ""));
1550
1892
  else if (p.job && !p.held)
1551
- jobs.account(p.job, e.id, { target: e.target, turn: p.from.turn, task: (p.tell?.legs ?? 1) > 1 ? "" : e.payload.text, spawned: !!p.spawned, ...(p.role ? { role: p.role } : {}) }, p.kind === "ask" ? "ask" : "task", !!p.free || !!p.held, false, !!p.free);
1552
- if (p.tell) {
1553
- if (!p.attemptId)
1554
- ledger.dispatch(e.id, { turn: p.tell.turn, owner: e.speaker, to: e.target, text: e.payload.text, at: e.at, ...(p.job ? { job: p.job } : {}), ...(e.k ? { for: e.k } : {}), ...(e.asker ? { asker: e.asker } : {}) });
1555
- if (!receipts.watching(p.tell))
1556
- receipts.register({ ...p.tell, queued: true });
1893
+ jobs.account(p.job, e.id, { target: e.target, turn: p.from.event, task: (p.tell?.legs ?? 1) > 1 ? "" : e.payload.text, spawned: !!p.spawned, ...(p.role ? { role: p.role } : {}) }, p.kind === "ask" ? "ask" : "task", !!p.free || !!p.held, false, !!p.free);
1894
+ // transfer 出去的 / 转给接手方的那不是发话方自己在等的一件: 不进它的挂起账。
1895
+ // 账本的键是这一笔的 event (§9.5: 表里已经有那一行了, 账只是投影)。
1896
+ if (p.tell && !p.attemptId && !p.relayed && !p.transferred) {
1897
+ const ev = e.serves || p.tell.event || "";
1898
+ const rec = receiverOfEvent(ev);
1899
+ ledger.dispatch(e.id, { event: ev, owner: e.speaker, to: e.target, text: e.payload.text, at: e.at, ...(p.job ? { job: p.job } : {}), ...(rec ? { receiver: rec } : {}), ...(e.asker ? { asker: e.asker } : {}) });
1557
1900
  }
1558
1901
  },
1559
1902
  committed: (e) => {
1560
1903
  const p = e.payload.peer;
1904
+ // 停放放行 (T3): 挂 `after` 的那一行先 held (前置没落定, 还没投给 owner), 注入成功这一刻
1905
+ // release —— 行状态是这一笔要不要投的唯一记号 (§9.5)。
1906
+ if (p.after?.length && e.serves)
1907
+ router.commit(ops.release({ event: e.serves }));
1561
1908
  if (p.attemptId) {
1562
- const a = workflow.byTurn(e.speaker, p.from.turn ?? "");
1909
+ const a = workflow.byTurn(e.speaker, p.from.event ?? "");
1563
1910
  if (a?.execution === "dispatch-unknown" && !a.canceled && !a.status && e.exposure === "consumed")
1564
1911
  workflow.verdict(e.speaker, p.attemptId, "sent", undefined, `transcript confirms message event ${e.id}`);
1565
1912
  else
1566
1913
  workflow.dispatched(p.attemptId, true);
1567
1914
  }
1568
- if (p.tell)
1569
- receipts.commit(p.tell);
1570
- if (p.from.receipt)
1571
- receipts.confirmDelivery(e.id);
1572
1915
  },
1573
1916
  canceled: (e) => {
1574
1917
  const p = e.payload.peer;
1575
1918
  if (p.attemptId)
1576
- workflow.cancel(e.target, e.speaker, p.job, p.from.turn);
1577
- if (p.tell)
1578
- receipts.cancel(e.target, "scheduler", (s) => s.from === e.speaker && s.turn === p.tell.turn);
1919
+ workflow.cancel(e.target, e.speaker, p.job, p.from.event);
1579
1920
  },
1580
- ready: async (e) => receipts.prereqs(e.speaker, e.payload.peer.after ?? [], e.payload.peer.afterSeen).every((r) => r.state === "done")
1921
+ ready: async (e) => prereqsOf(e.speaker, e.payload.peer.after ?? []).every((r) => r.state === "done")
1581
1922
  && (e.priority === "now" || await m.idleNow(e.target) || !(await m.paneLive(e.target)) || (e.priority === "urgent" && !(await othersTurn(e.target, e.speaker)))),
1582
1923
  inject: async (e, boundary) => {
1583
1924
  const p = e.payload.peer;
@@ -1585,9 +1926,9 @@ const main = async () => {
1585
1926
  if (await othersTurn(e.target, e.speaker))
1586
1927
  return { ok: false, retryable: true, reason: "urgent target now belongs to another actor" };
1587
1928
  const path = m.sessionInfo(e.target)?.jsonlPath;
1588
- const opening = path ? openingTurn(expandHome(path), pingSigs)?.ms : undefined;
1929
+ const opening = path ? openingTurn(expandHome(path))?.ms : undefined;
1589
1930
  const r = await m.interruptPane(e.target, { guard: () => peerEvents.valid(e)
1590
- && m.sessionInfo(e.target)?.jsonlPath === path && (!path || openingTurn(expandHome(path), pingSigs)?.ms === opening) });
1931
+ && m.sessionInfo(e.target)?.jsonlPath === path && (!path || openingTurn(expandHome(path))?.ms === opening) });
1591
1932
  if (!r.ok)
1592
1933
  return { ok: false, retryable: true, reason: r.reason };
1593
1934
  }
@@ -1595,8 +1936,9 @@ const main = async () => {
1595
1936
  workflow.intent(p.attemptId, p.tell);
1596
1937
  const raw = await m.injectText(e.target, e.payload.text, undefined, {
1597
1938
  from: p.from, channel: e.channel, requireIdle: e.priority === "normal",
1598
- envelope: p.envelope + (p.after?.length ? afterNote(p.after, Object.fromEntries(p.after.map((t, n) => [t, receipts.prereqs(e.speaker, p.after, p.afterSeen)[n].seen])), e.speaker) : "") + renderReminder({ wezard: "message-event", event: e.id }, []),
1599
- guard: (phase) => peerEvents.valid(e) && receipts.prereqs(e.speaker, p.after ?? [], p.afterSeen).every((r) => r.state === "done") && (!phase || ((!p.attemptId || workflow.boundary(p.attemptId)) && boundary(phase))),
1939
+ envelope: withMsg(p.envelope + (p.after?.length ? afterNote(p.after, Object.fromEntries(p.after.flatMap((t) => { const x = seenOf(t); return x ? [[t, x]] : []; })), e.speaker) : ""), e.id),
1940
+ guard: (phase, identity) => peerEvents.valid(e) && prereqsOf(e.speaker, p.after ?? []).every((r) => r.state === "done") && (!phase || ((!p.attemptId || workflow.boundary(p.attemptId)) && boundary(phase, identity))),
1941
+ eventId: e.id,
1600
1942
  });
1601
1943
  const r = !raw.ok && !raw.uncertain && !raw.retryable && events.get(e.id)?.exposure === "none" ? { ...raw, retryable: true } : raw;
1602
1944
  if (p.attemptId && (r.uncertain || (!r.ok && !r.retryable)))
@@ -1604,22 +1946,79 @@ const main = async () => {
1604
1946
  return r;
1605
1947
  },
1606
1948
  };
1607
- m.setEventFinalGate((e) => !e.awaitingContinuation && !e.canceled && !e.interjected && !receipts.pendingDelivery(e.target, { kind: "chat", channel: e.channel, turn: e.id }, e.currentContinuation) && !hasAnswerChildren(e.target, { kind: "chat", channel: e.channel, turn: e.id }) && !answers.all().some((r) => r.source === e.target && (r.sourceTurn === e.turn || r.origin === e.id)));
1608
- void m.restored.then(() => events.resume());
1949
+ // T4: final 放行只读路由表 (§9.5) —— 这一轮服务的那一笔不再挂着未落定的子行 (`waiting`),
1950
+ // 且行不在 held (前置没落定就还没轮到它)。旧的四重推断全由这一条取代。
1951
+ // 没有行 (老事件 / 不带 serves 的注入) → 照旧放行。
1952
+ // 已落定的那一笔只放追述 (§4.3): 判据与 onFinal 的 retellOps 同源 (router.finalReleased)。
1953
+ // 调用方可能拿着 result 落定前的快照, 正文按事件库现读。
1954
+ m.setEventFinalGate((e) => {
1955
+ if (e.canceled || e.interjected)
1956
+ return false;
1957
+ return router.finalReleased(e.target, e.serves, (events.get(e.id) ?? e).result?.body ?? "");
1958
+ });
1959
+ // boot 的路由表侧恢复 (T8): held 重查前置、answered 重投一次; 二者幂等, 与 events.resume
1960
+ // 重放的注入/回执撞上无害。
1961
+ void m.restored.then(() => { events.resume(); settleHeld(); replayAnswered(); });
1962
+ // ── 新路由表 (T6): 失败合成 (§4.2) ──────────────────────────────────────
1963
+ // timeout / silent / dead 是 daemon 合成的定论, 与 done 走同一条投递通路 —— 模型只会
1964
+ // 没声, 它不报失败。与 receipts 的 watcher 并行: 那边照旧算账 (T4 起不投递), 定论只由
1965
+ // 这里投。held 的前置失败也在这里收口。
1966
+ const SWEEP_MS = 15_000;
1967
+ const sweepOnce = async () => {
1968
+ if (!events.running())
1969
+ return 0;
1970
+ // 「停下没答几次」= 服务这一笔、已经 finished 却没有 result 的注入事件数 (§9.4): 收件箱
1971
+ // 自己就是计数器, 不另落一份。一次 tick 扫一遍收件箱就够 (这一轮不会再产新事件)。
1972
+ const fruitless = new Map();
1973
+ events.status().events.forEach((e) => {
1974
+ if (!e.serves || e.result || e.canceled || e.execution !== "complete")
1975
+ return;
1976
+ fruitless.set(e.serves, (fruitless.get(e.serves) ?? 0) + 1);
1977
+ });
1978
+ // 前置刚被这一轮判失败时, 挂它的 held 行要下一轮才看得到 —— 多跑几轮直到不再出新 op。
1979
+ let n = 0;
1980
+ for (let round = 0; round < 4; round++) {
1981
+ const plans = await router.sweep({
1982
+ paneLive: m.paneLive, lastWords: m.lastReply, nameOf: displayName, handingOff,
1983
+ fruitless: (event) => fruitless.get(event) ?? 0,
1984
+ });
1985
+ if (!plans.length)
1986
+ break;
1987
+ plans.forEach(commitRoute);
1988
+ n += plans.length;
1989
+ }
1990
+ return n;
1991
+ };
1992
+ void m.restored.then(() => {
1993
+ const t = setInterval(() => { void sweepOnce().catch((e) => log.warn({ err: errText(e) }, "failure sweep failed")); }, SWEEP_MS);
1994
+ t.unref?.();
1995
+ stopSweep = () => clearInterval(t);
1996
+ // 起手先扫一遍: reload 前就已经超时的那些不该再等一个周期。
1997
+ void sweepOnce().catch(() => 0);
1998
+ });
1609
1999
  /** tell_peer 的本体: 以 `self` 的名义说一句 —— HTTP 路由与 dispatch 共用。`extra` = 挂在信封后面的
1610
2000
  * 一段 (只进对方输入框, 不进 `text`: 公开时 text 会成群气泡)。 */
1611
2001
  const peerDispatching = new Map();
1612
- const tellPeerAsInner = async (self, body, extra = "", held, planned, actor) => {
1613
- const authority = actor?.authority ?? (!held && !planned ? controlSnapshot(self) : undefined);
1614
- if (!held && !planned && lostAnswer(self))
1615
- return { status: 409, body: { ok: false, reason: "this turn transferred answer and control; stop dispatching and conclude silently" } };
1616
- if (!held && !planned && terminalAnswerTurn(self))
1617
- return { status: 409, body: { ok: false, reason: "transferred answer is terminal; late control actions refused" } };
1618
- const answer = body.answer;
1619
- if (answer !== undefined && answer !== "transfer")
1620
- return { status: 400, body: { ok: false, reason: "answer must be transfer when supplied" } };
1621
- if (held?.alive && !held.alive())
1622
- return { status: 409, body: { ok: false, reason: "held dispatch withdrawn" } };
2002
+ /** transfer 的前置 (§3.4): 拒的理由; 放行 = undefined。 */
2003
+ const transferRefusal = (self, f, target, x) => {
2004
+ if (x.frozen || x.re || x.job || x.node || x.after)
2005
+ return "transfer 不能与 job / re / node / after 并用: 交出了这一笔就没人收工单, 也没有可续的原件";
2006
+ const row = f ? router.get(f) : undefined;
2007
+ if (!row)
2008
+ return "没有可交出的那一笔";
2009
+ if (row.owner !== self)
2010
+ return "这一笔已经不在你手上了 —— 一笔只能交出一次";
2011
+ if (row.status === "answered" || row.status === "handed")
2012
+ return `这一笔 (${row.event}) 已不在等你交代, 没有可交出的`;
2013
+ if (router.waiting(row.event).some((r) => r.receiver.kind === "wizard" && r.receiver.to === self))
2014
+ return "你在这一笔之下还有没落定的子活: 再交出去就说不清谁来交代 —— 等它们回来自己收口, 或别 transfer";
2015
+ if (target && router.awaitedBy(self).some((r) => r.owner === target))
2016
+ return `你已有一件活在 ${displayName(target)} 手上没落定 (同一对只有一个回执槽) —— 等它交差再 transfer`;
2017
+ return undefined;
2018
+ };
2019
+ const transferNote = (f, target) => `这一笔 (${f}) 已整件交给 ${displayName(target)}: 它的结论 (含 NEED / 失败) 直接回你服务的上游, 不回你 —— 本轮静默收口, 别再为这件派活或对人说话`;
2020
+ const tellPeerAsInner = async (self, body, extra = "", planned, actor) => {
2021
+ const authority = actor?.authority ?? (!planned ? controlSnapshot(self) : undefined);
1623
2022
  const text = (body.text ?? "").toString();
1624
2023
  if (!text.trim()) {
1625
2024
  return { status: 400, body: { ok: false, reason: "text required" } };
@@ -1628,51 +2027,70 @@ const main = async () => {
1628
2027
  // task 一件活 (默认) / ask 只答一问 / fyi 知会 —— fyi 不要回执、不进工单、不进群。
1629
2028
  const rawKind = body.kind;
1630
2029
  const kind = rawKind === "ask" || rawKind === "fyi" ? rawKind : "task";
1631
- const r = resolvePeer(self, addrOf(body));
2030
+ // `re` 的值是那一笔的**路由 event** (§9.5): 欠它的是谁由表说了算 —— transfer 之后 owner 已经
2031
+ // 换人, 按旧件号找反而会投给交出去的那位。表上没有 / 已落定 = 这句按新活发。
2032
+ // `re` 只是「续不续」这一位; 续哪一笔由表推断 (pickPair: 本轮在读的那份回执 → 这一对唯一在飞的 →
2033
+ // 最近一笔)。event id 照收: 老 MCP 进程、以及一对之间并发两笔时的兜底。省略 = 本轮是它的
2034
+ // NEED 回执、又对它说话才默认续 —— 答它的反问; 显式 `re:false` 推翻。
2035
+ const rawRe = body.re;
2036
+ const reFlag = rawRe === true || rawRe === "true" ? true : rawRe === false || rawRe === "false" ? false : undefined;
2037
+ const reId = reFlag === undefined && typeof rawRe === "string" ? eventRef(rawRe) : "";
2038
+ const idRow = reId ? router.get(reId) : undefined;
2039
+ const r = resolvePeer(self, addrOf(body) || (idRow ? displayName(idRow.owner) : ""));
1632
2040
  if (!r.ok) {
1633
2041
  return { status: r.status, body: { ok: false, reason: r.reason, candidates: r.candidates } };
1634
2042
  }
1635
- const { target, foreign } = r;
1636
- // 件号先于一切: `re` 续问沿用那件活的件号、工单与频道 (见 receipts.prepare)。
1637
- const re = canonicalTurn((body.re ?? "").toString());
1638
- // 放行一件停放着的 (after): 件号 / 频道 / 父 k / 链头都沿用停放那一刻定的 —— 依赖边不改 k。
1639
- const turn = planned ? { turn: planned.turn, legs: 1, channel: "", asker: planned.origin.asker, k: planned.origin.k } : held ? { turn: held.turn, legs: 1, channel: held.channel, asker: held.asker, k: held.k } : receipts.prepare(self, target, re || undefined);
2043
+ const { target } = r;
2044
+ const hint = planned || actor ? undefined : receiptOfTurn(self);
2045
+ const hintRow = hint ? router.get(hint) : undefined;
2046
+ const wantRe = !!reId || (reFlag ?? (kind !== "fyi" && hintRow?.status === "need" && hintRow.owner === target));
2047
+ const pick = wantRe && !reId ? pickPair(router.table(), self, target, hint) : undefined;
2048
+ if (pick && !pick.ok)
2049
+ return { status: 400, body: { ok: false, reason: `你在 ${displayName(target)} 手上有 ${pick.many.length} 笔没落定, 续哪一笔用 event id 指明 (re: "<event>"): ${pick.many.map((x) => `\`${x.event}\``).join(" ")}` } };
2050
+ const reRow = idRow ?? (pick?.ok ? pick.row : undefined);
2051
+ const re = reRow?.event ?? reId;
2052
+ const reTarget = reRow && ["open", "need", "error"].includes(reRow.status) ? reRow : undefined;
2053
+ // 续问必须点到现在欠着它的那位: 点错了收件的不是 owner, 它的终句就落不到那一笔上。
2054
+ if (reTarget && reTarget.owner !== target)
2055
+ return { status: 409, body: { ok: false, reason: `这一笔现在欠在 ${displayName(reTarget.owner)} 手上 —— 要续问就点名它 (re 按路由表走, 不再按旧件号找人)` } };
2056
+ const reUnknown = wantRe && !reTarget;
2057
+ const legs = reTarget ? legsOf(reTarget.event) + 1 : 1;
1640
2058
  // Capture the origin before waiting for another pane; our own turn may change meanwhile.
1641
- const asker = planned ? planned.origin.asker : held ? held.asker : actor?.asker ?? (turn.legs > 1 ? turn.asker : askerOf(self));
1642
- const k = planned ? planned.origin.k : held ? held.k : actor?.k ?? (body.chain === false ? undefined : turn.legs > 1 ? turn.k : parentKOf(self));
1643
- // 续问只在那个工单还开着时沿用它: 收了工的工单不再收成员, 续问照样能发。
1644
- // 没带工单的一句顶掉一份还没落定的工单活, 也算在那张工单上 (receipts.register 同理
1645
- // 继承): 不然换个不带 job 的 tell_peer 就绕开了预算。
1646
- const open = receipts.pending(self, target);
2059
+ const asker = planned ? planned.origin.asker : actor?.asker ?? (reTarget ? reTarget.asker : askerOf(self));
2060
+ // 回执去向 (§3.2): 冻结入口 (计划节点 / dispatch 入口冻结的那一轮) 原样沿用; 其余按
2061
+ // `return` / `for` 算 —— 选中的那一笔就是注入事件要服务的行。
2062
+ let plan;
2063
+ if (planned)
2064
+ plan = { op: "self", ...(planned.origin.event ? { serves: planned.origin.event } : {}) };
2065
+ else if (actor)
2066
+ plan = actor.plan;
2067
+ else {
2068
+ const p = planReturn(self, body, reTarget?.event);
2069
+ if (!p.ok)
2070
+ return { status: p.status, body: { ok: false, reason: p.reason } };
2071
+ plan = { op: p.op, ...(p.serves ? { serves: p.serves } : {}) };
2072
+ }
2073
+ // 同一对只有一个槽: 那一件还没落定 → 新的一件顶掉它 (工单预算照旧继承)。挂着子行的那份
2074
+ // 不算被顶掉 (它自己会按那一笔计数), 所以它不把工单过给新的一件。
2075
+ const open = router.awaitedBy(self).find((x) => x.owner === target);
1647
2076
  const isOpen = (id) => (id && jobs.get(id)?.status === "open" ? id : "");
1648
2077
  const askedJob = (body.job ?? "").trim();
1649
- // 挂起等子活的那份不算被顶掉: 它会按件号停放、自己计数, 新的一件不继承它的工单。
1650
- // 返工兜底: 回执槽 24h 过期 / 被顶掉后, 带了 re 却落空成新件, 不带 job 就和根单断了 ——
1651
- // 只在这种情形下, 发话方是某张开着的需求根单的 owner、目标恰是它的 lead 时才补上;
1652
- // 没带 re 的普通 task / ask / dispatch 一律不并进根单。
1653
2078
  const leadJob = jobs.all().find((j) => j.kind === "req" && j.status === "open" && j.owner === self && j.members.some((x) => x.target === target && x.role === "lead"))?.id ?? "";
1654
- const jobId = kind === "fyi" ? "" : askedJob || isOpen(turn.job) || (open?.deferred ? "" : isOpen(open?.job)) || (re && turn.reUnknown ? leadJob : "");
1655
- const answerContext = answer === "transfer" ? actor?.answerContext ?? m.answerContext(self) : undefined;
1656
- const answerSourceOpening = answerContext ? actor?.authority.opening ?? openingOfSelf(self)?.ms ?? 0 : 0;
2079
+ const jobId = kind === "fyi" ? "" : askedJob || isOpen(reTarget?.job) || (open && router.waiting(open.event).length > 0 ? "" : isOpen(open?.job)) || (reUnknown ? isOpen(reRow?.job) || leadJob : "");
2080
+ /** 答它的反问不算新的一件 (不花预算、不数成员)。 */
2081
+ const freed = open?.status === "need";
1657
2082
  const actorStillValid = () => {
1658
2083
  if (!authority)
1659
- return true; // held/planned work carries its captured permit.
1660
- if (openingOfSelf(self)?.ms !== authority.opening || m.answerContext(self)?.turn !== authority.turn || terminalAnswerTurn(self))
1661
- return false;
1662
- if (answer === "transfer")
1663
- return !answers.all().some((r) => r.source === self && r.sourceTurn === authority.turn && r.id !== turn.turn);
1664
- return !lostAnswer(self);
2084
+ return true; // planned work carries its captured permit.
2085
+ return openingOfSelf(self)?.ms === authority.opening;
1665
2086
  };
1666
- if (answer === "transfer" && (!answerContext || !asker?.who.startsWith("user:") || k?.kind !== "chat" || !chatPolicyOf(cfg, k.channel).topOnly || kind === "fyi" || re || jobId || held || planned || body.node || body.after?.length || body.receipt === false || body.chain === false || hasAnswerChildren(self, k))) {
1667
- return { status: 409, body: { ok: false, reason: "answer transfer requires an active human topOnly turn with no child work, job, re, node, after, chain:false or receipt:false" } };
1668
- }
1669
2087
  if (jobId) {
1670
- const jc = checkJob(jobId, target, (!!open?.need && open.turn === turn.turn) || !!held);
2088
+ const jc = checkJob(jobId, target, freed);
1671
2089
  if (!jc.ok) {
1672
2090
  return { status: jc.status, body: { ok: false, reason: jc.reason } };
1673
2091
  }
1674
2092
  }
1675
- const trackedRe = workflow.byTurn(self, turn.turn) ?? (re ? workflow.byTurn(self, re) : undefined);
2093
+ const trackedRe = re ? workflow.byTurn(self, re) : undefined;
1676
2094
  if (trackedRe && !trackedRe.accountingVersion && trackedRe.execution !== "terminal")
1677
2095
  return { status: 409, body: { ok: false, attemptId: trackedRe.id, reason: "historical tracked attempt lacks dispatch accounting evidence; reconcile stopped before retry" } };
1678
2096
  const rawNode = body.node;
@@ -1686,68 +2104,74 @@ const main = async () => {
1686
2104
  return { status: 409, body: { ok: false, reason: "dedicated plan job/executor only allows runner dispatch or exact owner re" } };
1687
2105
  if (node && jobId && workflow.list(self, jobId).some((a) => !a.accountingVersion))
1688
2106
  return { status: 409, body: { ok: false, reason: "historical job has incomplete dispatch accounting evidence; reconcile stopped, close the old job, and open a new job (no guessed budget migration)" } };
1689
- if (node && (!jobId || jobs.get(jobId)?.owner !== self || kind !== "task" || body.receipt === false)) {
2107
+ if (node && (!jobId || jobs.get(jobId)?.owner !== self || kind !== "task" || plan.op === "none")) {
1690
2108
  return { status: 400, body: { ok: false, reason: "node requires owner job, task, and receipt" } };
1691
2109
  }
1692
- if (re && trackedRe && (turn.reUnknown || trackedRe.to !== target))
2110
+ if (re && trackedRe && (reUnknown || trackedRe.to !== target))
1693
2111
  return { status: 409, body: { ok: false, reason: "tracked re cannot start a new attempt; use node after reconciliation" } };
1694
2112
  // A released hold can survive a crash after confirmed injection but before dropHold.
1695
2113
  // Recover the watcher from the durable intent; never paste that attempt again.
1696
- const heldAttempt = held && node ? workflow.byTurn(self, held.turn) : undefined;
2114
+ const heldAttempt = planned && node ? workflow.byTurn(self, planned.turn) : undefined;
1697
2115
  if (heldAttempt?.tell && ["running", "needs-input", "errored"].includes(heldAttempt.execution)) {
1698
2116
  recoverWorkflow(heldAttempt);
1699
- return { status: 200, body: { ok: true, recovered: true, attemptId: heldAttempt.id, turn: held.turn } };
2117
+ return { status: 200, body: { ok: true, recovered: true, attemptId: heldAttempt.id, event: planned.turn } };
1700
2118
  }
1701
2119
  if (heldAttempt?.execution === "terminal" && heldAttempt.status === "done")
1702
2120
  return { status: 200, body: { ok: true, recovered: true, attemptId: heldAttempt.id } };
1703
- // 派活方向闸 (见 direction.ts): 只拒不改投递路径, 不动回执 k。
1704
- const dr = held ? undefined : checkDirection({ self, target, kind, re: turn.legs > 1, jobId, jobs: jobs.all(), chain: planned?.origin.chain ?? receipts.ancestorsOf(self, parentKOf(self)), nameOf: displayName });
2121
+ // 派活方向闸 (见 direction.ts): 只拒不改投递路径。链读 `serves` 那条接收边 (本轮服务的那笔,
2122
+ // 或 `for` 显式选中的那笔) —— 本轮在服务的那位仍在等你, 不能借 for 反过来给它派活。
2123
+ const dr = checkDirection({ self, target, kind, re: !!reTarget, jobId, jobs: jobs.all(),
2124
+ ...chainNow(self, planned?.origin.event ?? plan.serves), nameOf: displayName });
1705
2125
  if (dr) {
1706
2126
  return { status: 409, body: { ok: false, gated: "direction", reason: dr } };
1707
2127
  }
1708
- // 依赖 (after): 前置必须是自己派出去的件。查不到 → 400; 有失败的 → 409; 都 done → 照常投并附结论; 还有没落定的 → 停放 (见下)。
1709
- const afters = held ? [] : afterTurnsOf(body);
1710
- const prereqs = afters.length ? receipts.prereqs(self, afters) : [];
2128
+ // 依赖 (after): 前置必须是自己派出去的那几笔。查不到 → 400; 有失败的 → 409; 都 done → 照常投并附结论; 还有没落定的 → 停放 (先 held)。
2129
+ const afterIds = afterTurnsOf(self, body);
2130
+ if (!afterIds.ok)
2131
+ return { status: 400, body: { ok: false, reason: afterIds.reason } };
2132
+ const afters = afterIds.ids;
2133
+ const prereqs = afters.length ? prereqsOf(self, afters) : [];
1711
2134
  if (afters.length) {
1712
2135
  if (kind !== "task" || re) {
1713
2136
  return { status: 400, body: { ok: false, reason: "`after` 只用于派新活 (kind:\"task\"、不带 re)" } };
1714
2137
  }
1715
2138
  const gone = afters.filter((_, i) => prereqs[i].state === "unknown");
1716
2139
  if (gone.length) {
1717
- return { status: 400, body: { ok: false, reason: `after 里的 ${gone.join(" ")} 在你的回执登记里查不到 —— 只能依赖你自己派出去的件号 (过期 / 被同一对的新一句顶掉的也查不到)` } };
2140
+ return { status: 400, body: { ok: false, reason: `after 里的 ${gone.join(" ")} 在你派出去的件里查不到 —— 只能依赖你自己开出来的那一笔 (event id; 过期 / 不是你派的都查不到)` } };
1718
2141
  }
1719
2142
  const failed = afters.flatMap((t, i) => { const p = prereqs[i]; return p.state === "failed" ? [`${t} (${displayName(p.seen.to)}) 以 ${p.seen.status} 收场`] : []; });
1720
2143
  if (failed.length) {
1721
2144
  return { status: 409, body: { ok: false, reason: `前置 ${failed.join("; ")} —— 这件不投; 前置失败了就先处理它, 再重发` } };
1722
2145
  }
1723
- const cur = receipts.pending(self, target);
1724
- if (cur && !afters.includes(cur.turn)) {
1725
- return { status: 409, body: { ok: false, reason: `你已有一件活 (${cur.turn}) 在 ${displayName(target)} 手上没落定, 同一对只有一个回执槽 —— 等它交差, 或把它也列进 after` } };
2146
+ if (open && !afters.includes(open.event)) {
2147
+ return { status: 409, body: { ok: false, reason: `你已有一件活 (${open.event}) 在 ${displayName(target)} 手上没落定, 同一对只有一个槽 —— 等它交差, 或把它也列进 after` } };
1726
2148
  }
1727
2149
  }
2150
+ const transferNo = plan.op === "transfer" ? transferRefusal(self, plan.serves, target, { job: jobId, re: !!reTarget, node: !!node, after: afters.length > 0, frozen: !!planned }) : undefined;
2151
+ if (transferNo)
2152
+ return { status: 409, body: { ok: false, reason: transferNo } };
1728
2153
  // 公开与否由发话方 (LLM) 判断: 公开 = 在它这一轮的公开频道里说, 气泡进群、对方
1729
2154
  // 那一轮的回复也发进这个群; 私聊 (默认) = 只落双方的 rolepage, 回复靠 wait_peer 取。
1730
- // 只有两端都是登记在册的 wizard 才能当着人说: 裸 target 冒充的发话方、forget
1731
- // 掉的分身不是谁, 它们的往来进群只会留下一段人找不到主的对话 —— 降为私聊。
1732
- // 工单里的派活一律私聊: 人看不懂 fan-out 的过程, 群里只留发起者自己的收口。
1733
- // 知会不进群: 公开信封要对方「照对人说话的方式答」, 一句「收到」会被发给一群人。
1734
- // 顶层模式 (chatPolicy.topOnly): 那个群里 wizard 之间不当着人说话 —— 公开与否不归模型判断,
1735
- // 续问沿用的公开频道也一样降为私聊; 人只从顶层 wizard 的收口里听到结论。
1736
- const wantPublic = !jobId && kind !== "fyi" && (turn.channel !== undefined ? !!turn.channel : body.public === true) && !!wizards.get(self) && !!wizards.get(target);
1737
- const topOnly = wantPublic && chatPolicyOf(cfg, turn.channel || channelOf(self)).topOnly;
2155
+ // 只有两端都是登记在册的 wizard 才能当着人说 —— 裸 target 冒充的发话方、forget 掉的
2156
+ // 分身不是谁, 它们的往来进群只会留下一段人找不到主的对话。工单里的派活一律私聊
2157
+ // (人看不懂 fan-out 的过程), 知会不进群, 顶层模式 (chatPolicy.topOnly) 也降为私聊。
2158
+ // 续问沿用原件那时的公开与否 (那一笔的 receiver 是那个群)。
2159
+ const reuseChannel = reTarget?.receiver.kind === "chat" ? reTarget.receiver.channel : undefined;
2160
+ const wantPublic = !jobId && kind !== "fyi" && plan.op !== "transfer"
2161
+ && (reTarget ? !!reuseChannel : body.public === true)
2162
+ && !!wizards.get(self) && !!wizards.get(target);
2163
+ const topOnly = wantPublic && chatPolicyOf(cfg, reuseChannel || channelOf(self)).topOnly;
1738
2164
  const isPublic = wantPublic && !topOnly;
1739
- const channel = isPublic ? turn.channel || channelOf(self) : "";
2165
+ // transfer 交出的是人问的那一笔: 接手方的终句就是那句人话的答复, 注入事件得带上那个群 ——
2166
+ // chat 行只落 `hand`, 气泡靠 final 通道放行; 频道为空就被当私聊轮扣下, 人永远收不到。
2167
+ const handed = plan.op === "transfer" && plan.serves ? router.get(plan.serves)?.receiver : undefined;
2168
+ const handedChat = handed?.kind === "chat" ? handed.channel : "";
2169
+ const channel = isPublic ? reuseChannel || channelOf(self) : handedChat;
1740
2170
  // Injecting into your own pane would type into the box you're generating
1741
2171
  // from — Claude Code queues it and the caller deadlocks waiting for itself.
1742
2172
  if (target === self) {
1743
2173
  return { status: 400, body: { ok: false, reason: "refusing to inject into the calling session itself" } };
1744
2174
  }
1745
- // 它正在交接: 这句话等它换完会话再进 —— 投进旧会话会被交接一并带走, 投进重开中的
1746
- // 空 pane 会 resume 回旧 sid (见 handoff.ts)。发话时刻取在这之后。
1747
- if (answer === "transfer" && handingOff(target))
1748
- return { status: 409, body: { ok: false, reason: "answer target is handing off; source retains answer and control" } };
1749
- if (answer === "transfer")
1750
- await handedOff(target);
1751
2175
  if (!actorStillValid())
1752
2176
  return { status: 409, body: { ok: false, reason: "caller turn/control changed while waiting; dispatch refused" } };
1753
2177
  // 投递策略。对方闲着三者一样 —— 立刻投; 只在它正忙时分岔:
@@ -1763,7 +2187,6 @@ const main = async () => {
1763
2187
  : bp.priority === "now" || bp.when === "now" ? "now"
1764
2188
  : bp.when === "idle" ? "normal"
1765
2189
  : re ? "now" : "normal";
1766
- const waitSec = Math.min(Math.max(Number(body.waitSec ?? 600) || 600, 10), 3600);
1767
2190
  // 冷门控: 派新活 (task、不是续问、不是插话) 给一个冷且大的 wizard, 先退回一行让派活的
1768
2191
  // 算账 —— 唤醒它要整段重写缓存, 点名或职责对口只说明相关, 不说明划算。`force:true`
1769
2192
  // 是「真依赖它那段上下文」的明说。老 MCP 进程的 schema 里没有 force (请求里压根没这个键,
@@ -1781,266 +2204,94 @@ const main = async () => {
1781
2204
  return { status: 409, body: { ok: false, gated: "cold", reason: gate + (force === undefined ? " (你的 MCP 是旧版、没有 force 参数: 10 分钟内原样再发一次即视为 force)" : "") } };
1782
2205
  }
1783
2206
  }
1784
- // 前置还没落定: 不注入, 登记一份停放 (见 receipts.Hold) —— 件号、频道、父 k、链头定在这一刻,
1785
- // 放行时原样沿用; 工单成员与预算也现在就记, 「齐了吗」照常数。
2207
+ // All caller-dependent facts are frozen here; workers never reread the caller's current turn.
2208
+ const at = Date.now();
2209
+ const deadlineAt = deadlineOf(at, body.deadline);
2210
+ const wantReceipt = plan.op !== "none";
2211
+ // 路由行 (§3.1): 续问不新开行 (沿用那一笔); 其余按 self / detach / transfer 开行或改 owner;
2212
+ // `after` 是欠账不是子行 —— 挂 after 的行先 held, 注入成功 (committed) 时才 release。
2213
+ const newRow = !reTarget;
2214
+ const serves = reTarget
2215
+ ? reTarget.event
2216
+ : openDispatchRow({ self, target, op: plan.op, deadlineAt, job: jobId, asker,
2217
+ ...(afters.length ? { after: afters } : {}), ...(plan.serves ? { serves: plan.serves } : {}) });
2218
+ /** 建完行才发现不该派 (计划许可 / 预算在等待期间变了): 把刚开的那一行收掉 —— 不然它一直
2219
+ * 挂在 owedBy 上, 名册里也永远「欠着」一件没人干的活。 */
2220
+ const dropRow = () => { if (newRow && serves)
2221
+ router.commit(ops.gc({ event: serves })); };
2222
+ // 注入事件的 id 就是对那一笔的引用 (§9.3): 派活 = `route:<event>`, 续问 = `route:<event>:r<legs>`;
2223
+ // 不开行 (none) 时没有行可指, 退回一句一号的合成 id。
2224
+ const eventId = serves ? `route:${serves}${legs > 1 ? `:r${legs}` : ""}` : `p:${self}:${target}:${at}:${legs}`;
1786
2225
  let attemptId;
1787
2226
  const beginNode = (queued) => {
1788
2227
  if (!planAllowed() || (planned && !planned.alive()))
1789
2228
  throw new Error("plan dispatch canceled or job reserved");
1790
- return node ? workflow.begin(self, jobId, node, turn.turn, target, queued).id : undefined;
2229
+ return node ? workflow.begin(self, jobId, node, planned ? planned.turn : serves, target, queued).id : undefined;
1791
2230
  };
1792
2231
  try {
1793
2232
  attemptId = beginNode(true);
1794
2233
  }
1795
2234
  catch (e) {
2235
+ dropRow();
1796
2236
  return { status: 409, body: { ok: false, reason: e.message } };
1797
2237
  }
1798
- const canceledBeforeDispatch = () => !planAllowed() || (!!planned && !planned.alive()) || (!!held?.alive && !held.alive()) || !!workflow.byTurn(self, turn.turn)?.canceled || (!!jobId && jobs.get(jobId)?.status !== "open");
2238
+ const canceledBeforeDispatch = () => !planAllowed() || (!!planned && !planned.alive()) || !!workflow.byTurn(self, planned ? planned.turn : serves)?.canceled || (!!jobId && jobs.get(jobId)?.status !== "open");
1799
2239
  // 「忙」= 这一轮还没结束, 或停在审批上等人 —— 都不是能接新活的时候。
1800
2240
  const wasBusy = !(await m.idleNow(target));
1801
- if (answer === "transfer" && (wasBusy || receipts.states().some((s) => s.to === target)))
1802
- return { status: 409, body: { ok: false, wasBusy: true, reason: "answer target is busy or has pending work; source retains answer and control" } };
1803
2241
  // urgent 只打断你自己派的那一轮 (或它自己的回执轮): 手上若是别的 wizard / 人 / 定时任务的活,
1804
2242
  // 打断就误伤了那件 —— 降成 normal, 等它这一轮做完再投。
1805
2243
  const others = asked === "urgent" && wasBusy ? await othersTurn(target, self) : undefined;
1806
2244
  const policy = others ? "normal" : asked;
1807
- if (answer !== "transfer") {
1808
- if (canceledBeforeDispatch() || !actorStillValid())
1809
- return { status: 409, body: { ok: false, reason: "caller authority or dispatch permit changed before acceptance" } };
1810
- if (jobId) {
1811
- const budget = checkJob(jobId, target, (!!open?.need && open.turn === turn.turn) || !!held);
1812
- if (!budget.ok)
1813
- return { status: budget.status, body: { ok: false, reason: budget.reason } };
1814
- }
1815
- // All caller-dependent facts are frozen here; workers never reread the caller's current turn.
1816
- const at = held?.at ?? Date.now();
1817
- const deadlineAt = deadlineOf(at, body.deadline);
1818
- const wantReceipt = kind !== "fyi" && body.receipt !== false;
1819
- const eventId = `p:${self}:${target}:${turn.turn}:${turn.legs}`;
1820
- let tell = { from: self, to: target, channel, job: jobId, at, turn: turn.turn, legs: turn.legs, deadlineAt, k, asker, dispatchKey: eventId };
1821
- if (attemptId) {
1822
- const leg = workflow.recordLeg(attemptId, { text, tell, continuation: turn.legs > 1, at, kind: "task", role: roleParam(body.role), free: !!open?.need && open.turn === turn.turn }, !!held);
1823
- tell = { ...tell, dispatchKey: leg.key };
1824
- }
1825
- let accepted;
1826
- try {
1827
- accepted = events.accept({ id: eventId, target, speaker: self, channel, asker, k, priority: policy, deadline: deadlineAt,
1828
- payload: { text, peer: {
1829
- from: { kind: "peer", from: self, turn: turn.turn, ...(jobId ? { job: jobId } : {}), ...(isPublic ? { public: true } : {}), ...(asker ? { asker } : {}) },
1830
- envelope: envelopeFor(self, channel, { turn: turn.turn, asker, legs: turn.legs, ...(turn.legs > 1 ? { re: true } : {}),
1831
- ...(body.deadline !== undefined ? { deadline: deadlineAt } : {}), ...(kind !== "task" ? { act: kind } : {}), ...(wantReceipt ? {} : { quiet: true }) }) + extra,
1832
- ...(wantReceipt ? { tell } : {}), job: jobId, kind, attemptId, after: held?.after ?? afters, afterSeen: held?.seen,
1833
- controlTransfer: answers.all().find((r) => r.target === self && r.tell.turn === openingOfSelf(self)?.env?.turn && !r.outcome)?.id,
1834
- role: roleParam(body.role), free: !!open?.need && open.turn === turn.turn, held: !!held,
1835
- } } });
1836
- }
1837
- catch (error) {
1838
- return { status: 409, body: { ok: false, eventId, reason: errText(error) } };
1839
- }
1840
- return { status: 200, body: { ...accepted, queued: true, ...(prereqs.some((p) => p.state === "pending") ? { parked: true, after: afters } : {}), turn: turn.turn, name: peerAddress(cfg, self, target), priority: policy, wasBusy,
1841
- ...(jobId ? { job: jobId } : {}), ...(attemptId ? { node, attemptId, acceptance: "pending" } : {}),
1842
- ...(wantReceipt ? { receipt: "已持久化排队;提交后观察原件回执,结论自动返回" } : {}),
1843
- ...(others ? { urgentDowngraded: "目标正在处理他人的轮次,按 normal 排队" } : {}) } };
1844
- }
1845
- let waitedMs = 0;
1846
- let interrupted;
1847
- if (policy === "normal" && wasBusy) {
1848
- const t0 = Date.now();
1849
- // 它那一轮一结束就投: 注册表 status 翻 idle 是事件, 不抽样也不等稳定期。
1850
- const wr = await m.untilIdle(target, waitSec * 1000, { aborted: canceledBeforeDispatch });
1851
- waitedMs = Date.now() - t0;
1852
- if (!actorStillValid())
1853
- return { status: 409, body: { ok: false, attemptId, reason: "caller turn/control changed while waiting for idle" } };
1854
- if (!wr.idle) {
1855
- if (canceledBeforeDispatch())
1856
- return { status: 409, body: { ok: false, attemptId, reason: "dispatch canceled while waiting for idle" } };
1857
- return { status: 409, body: { ok: false, target, foreign, wasBusy, waitedMs, reason: `它一直在忙, ${waitSec}s 内没闲下来 —— peek_peer 看看它卡在哪; 真急就 priority:"urgent" 打断它` } };
1858
- }
2245
+ if (canceledBeforeDispatch() || !actorStillValid()) {
2246
+ dropRow();
2247
+ return { status: 409, body: { ok: false, reason: "caller authority or dispatch permit changed before acceptance" } };
1859
2248
  }
1860
- if (canceledBeforeDispatch())
1861
- return { status: 409, body: { ok: false, attemptId, reason: "dispatch canceled while waiting for idle" } };
1862
- if (!actorStillValid())
1863
- return { status: 409, body: { ok: false, attemptId, reason: "caller turn/control changed before target control" } };
1864
- if (policy === "urgent" && wasBusy) {
1865
- // 只按 Esc, 不走 /stop 的 teardown: 那会清掉它的注入队列 (别人排着的话一起丢)
1866
- // 并暂停 keepalive —— 这里打断它是为了马上接着干, 不是让它安静。
1867
- const t0 = Date.now();
1868
- const esc = await m.interruptPane(target);
1869
- // 等 Esc 生效、这一轮真收住再投; 收不住也照投 —— 落进输入框排在后面, 退化成 now。
1870
- interrupted = esc.ok && (await m.untilIdle(target, 15_000)).idle;
1871
- waitedMs = Date.now() - t0;
1872
- if (!actorStillValid())
1873
- return { status: 409, body: { ok: false, attemptId, reason: "caller turn/control changed during interrupt wait" } };
1874
- }
1875
- // 时刻取在注入**之前**: 晚于那一句落盘的话, 回执定位的下界就偏了。
1876
- const at = Date.now();
1877
- // 唤醒代价同样要在注入之前量: 投进去 transcript 一动, 就看不出它冷了多久。
1878
- const info = m.sessionInfo(target);
1879
- const wake = info?.jsonlPath
1880
- ? wakeNoteOf(info.contextTokens, touchOf(info.jsonlPath), at, ttlOf(info.jsonlPath))
1881
- : "";
1882
- const deadlineSec = body.deadline;
1883
- // 回执: 对方干完那一轮, 守护进程把它的结论自动送回来 (默认开)。`receipt:false`
1884
- // 是"放出去就不管了"的那种派活; fyi 一定不要。对方的信封据此改口, 不再让它收口。
1885
- const wantReceipt = kind !== "fyi" && body.receipt !== false;
1886
- const deadlineAt = deadlineOf(at, deadlineSec === undefined ? undefined : Number(deadlineSec));
1887
- const blocking = workflow.list(self).find((a) => a.to === target && a.turn !== turn.turn && !["queued", "terminal"].includes(a.execution));
1888
- if (blocking && kind !== "fyi")
1889
- return { status: 409, body: { ok: false, reason: `tracked attempt ${blocking.id} still in flight` } };
1890
- if (receipts.prereqs(self, held?.after ?? afters, held?.seen).some((p) => p.state !== "done"))
1891
- return { status: 409, body: { ok: false, reason: "prerequisite acceptance/version changed before injection" } };
1892
- let physicalExposure = false;
1893
- const dispatchGuard = (phase) => {
1894
- if (!actorStillValid())
1895
- return false;
1896
- if (answers.all().some((r) => r.target === target && answerTargetOccupied(r) && r.id !== turn.turn))
1897
- return false;
1898
- if (answerContext && m.answerContext(self)?.turn !== answerContext.turn)
1899
- return false;
1900
- if (!planAllowed() || (planned && !planned.alive()) || (held?.alive && !held.alive()))
1901
- return false;
1902
- if (jobId && jobs.get(jobId)?.status !== "open") {
1903
- if (attemptId)
1904
- workflow.cancel(target, self, jobId, turn.turn);
1905
- return false;
1906
- }
1907
- const a = workflow.byTurn(self, turn.turn);
1908
- if (a?.canceled || a?.execution === "dispatch-unknown" || a?.execution === "terminal")
1909
- return false;
1910
- if (receipts.prereqs(self, held?.after ?? afters, held?.seen).some((p) => p.state !== "done"))
1911
- return false;
1912
- const allowed = !phase || !attemptId || workflow.boundary(attemptId);
1913
- if (allowed && phase)
1914
- physicalExposure = true;
1915
- return allowed;
1916
- };
1917
- if (!dispatchGuard())
1918
- return { status: 409, body: { ok: false, attemptId, reason: "dispatch canceled or prerequisites invalidated" } };
1919
- const trackedTell = { from: self, to: target, channel, job: jobId, at, turn: turn.turn, legs: turn.legs, deadlineAt, ...(answer === "transfer" ? { answerTransfer: turn.turn } : {}), ...(k ? { k } : {}), ...(asker ? { asker } : {}) };
1920
- if (node) {
1921
- try {
1922
- attemptId = beginNode(false);
1923
- workflow.intent(attemptId, trackedTell);
1924
- workflow.recordLeg(attemptId, { text, tell: trackedTell, continuation: turn.legs > 1, at, kind: "task", ...(roleParam(body.role) ? { role: roleParam(body.role) } : {}), free: !!open?.need && open.turn === turn.turn }, !!held);
1925
- accountWorkflow(workflow.byTurn(self, turn.turn));
1926
- }
1927
- catch (e) {
1928
- return { status: 409, body: { ok: false, reason: e.message } };
2249
+ if (jobId) {
2250
+ const budget = checkJob(jobId, target, freed);
2251
+ if (!budget.ok) {
2252
+ dropRow();
2253
+ return { status: budget.status, body: { ok: false, reason: budget.reason } };
1929
2254
  }
1930
2255
  }
1931
- if (answerContext && k?.kind === "chat") {
1932
- try {
1933
- answers.reserve({ id: turn.turn, source: self, target, sourceTurn: answerContext.turn, sourceOpening: answerSourceOpening, origin: openingOfSelf(self)?.env?.eventId ?? answerTransferOrigin(self, answerContext.turn), channel: k.channel, prefix: answerContext.prefix, tell: trackedTell, state: "reserved" });
1934
- }
1935
- catch (e) {
1936
- return { status: 409, body: { ok: false, reason: e.message } };
1937
- }
1938
- if (!m.prepareAnswer(turn.turn, self, answerContext.turn, target, answerContext.channel)) {
1939
- answers.rollback(turn.turn);
1940
- return { status: 409, body: { ok: false, reason: "answer stream is no longer writable; source retains answer and control" } };
1941
- }
2256
+ let tell = { from: self, to: target, channel, job: jobId, at, event: serves, legs, deadlineAt, asker, dispatchKey: eventId };
2257
+ if (attemptId) {
2258
+ const leg = workflow.recordLeg(attemptId, { text, tell, continuation: legs > 1, at, kind: "task", role: roleParam(body.role), free: freed }, false);
2259
+ tell = { ...tell, dispatchKey: leg.key };
1942
2260
  }
1943
- const inj = await m.injectText(target, text, undefined, {
1944
- guard: dispatchGuard,
1945
- from: { kind: "peer", from: self, turn: turn.turn, ...(jobId ? { job: jobId } : {}), ...(isPublic ? { public: true } : {}), ...(asker ? { asker } : {}) },
1946
- channel,
1947
- envelope: envelopeFor(self, channel, {
1948
- turn: turn.turn,
1949
- asker,
1950
- legs: turn.legs,
1951
- ...(turn.legs > 1 ? { re: true } : {}),
1952
- // 期限只在发话方明说时写给对方看: 默认的一小时不值得每轮多一句。
1953
- ...(deadlineSec !== undefined ? { deadline: deadlineAt } : {}),
1954
- ...(kind !== "task" ? { act: kind } : {}),
1955
- ...(wantReceipt ? {} : { quiet: true }),
1956
- }) + extra + (answerContext ? renderReminder({ wezard: "answer-transfer" }, ["你已接手这轮人的回答权与控制权。判断为简单问题,直接答给人;原气泡内容会清空,只显示你的进度,终句覆盖气泡并由 daemon 唯一交付,不回给派活方。不要另行 notify 相同终稿;需要追问可直接以自然语言收口。派活方这轮后续产出不再对人发送。"]) : ""),
1957
- }).catch((e) => {
1958
- if (attemptId)
1959
- workflow.dispatched(attemptId, false);
1960
- if (answerContext)
1961
- return { ok: false, uncertain: true, reason: e.message };
1962
- throw e;
1963
- });
1964
- if (answerContext) {
1965
- if (inj.ok || inj.uncertain || physicalExposure) {
1966
- answers.activate(turn.turn);
1967
- const bubble = await m.takeAnswer(turn.turn, self, answerContext.turn, target, answerContext.channel);
1968
- receipts.register(trackedTell);
1969
- if (!inj.ok)
1970
- void deliverTransferredAnswer(turn.turn, { status: "canceled", body: "回答移交的投递结果不确定,本轮已收止;若未收到回答,请重新发送问题。" });
1971
- if (!bubble.cleared)
1972
- return { status: 202, body: { ...inj, ok: false, answer: "transfer", control: "transfer", ownershipTransferred: true, bubbleCleared: false, retryable: false, name: peerAddress(cfg, self, target), turn: turn.turn, reason: "回答权已转交,但原气泡清空未确认(已结束、不可用或 ACK 失败);不能重试移交。目标仍负责最终答复,必要时另发终稿;本轮请停止派活与公开回答。" } };
1973
- return { status: 200, body: { ...inj, ok: true, answer: "transfer", control: "transfer", name: peerAddress(cfg, self, target), turn: turn.turn, bubbleCleared: bubble.cleared, receipt: "回答权已转交;可用气泡只显示目标进度与终稿;本轮请停止派活与公开回答,静默收口" } };
1974
- }
1975
- m.cancelAnswerPreparation(turn.turn);
1976
- answers.rollback(turn.turn);
1977
- }
1978
- if (attemptId)
1979
- workflow.dispatched(attemptId, inj.ok && !inj.uncertain);
1980
- if (!dispatchGuard())
1981
- return { status: 409, body: { ok: false, attemptId, reason: `canceled/invalidated during transport; reconcile possible delivery${inj.reason ? ": " + inj.reason : ""}`, uncertain: !!inj.uncertain || physicalExposure || workflow.byTurn(self, turn.turn)?.execution === "dispatch-unknown" } };
1982
- // 注入失败就不守 —— 没有问话, 也不会有回答。不要回执 (receipt:false / fyi) 就不登记:
1983
- // 一份不守的槽会以同一对的 key 顶掉发话方还在等它答的那件活, 那件的回执就再也来不了。
1984
- // 父 k: 默认这件活算发话方此刻在答的那件的子活 (链式续回); `chain:false` = 旁支,
1985
- // 回执照常回来, 但不挂住它给上游的交代 —— 只有发话方知道这件活是不是为上游派的。
1986
- if (inj.ok && wantReceipt)
1987
- receipts.register(attemptId ? workflow.byTurn(self, turn.turn).tell : trackedTell);
1988
- if (inj.ok && wantReceipt && !attemptId) {
1989
- if (turn.legs > 1)
1990
- ledger.touch(self, turn.turn);
1991
- else
1992
- ledger.open({ turn: turn.turn, owner: self, to: target, text, at, ...(jobId ? { job: jobId } : {}), ...(k ? { for: k } : {}), ...(asker ? { asker } : {}) });
1993
- }
1994
- // 工单成员照旧记账 (收工那一条会列出各自那段活); 公开的那一句在群里成气泡。
1995
- // 续问 (re) 不是一段新活: 工单页与留档里该列的仍是当初派的那段。
1996
- if (inj.ok && jobId && !attemptId) {
1997
- const role = roleParam(body.role);
1998
- // G2: 需求根单的 owner 答了 lead 的 NEED —— 方案闸过了 (只记第一次)。
1999
- const rj = jobs.get(jobId);
2000
- if (rj?.kind === "req" && self === rj.owner && !rj.gate?.g2 && !!open?.need && open.turn === turn.turn && rj.members.some((x) => x.target === target && x.role === "lead"))
2001
- jobs.mark(jobId, { gate: { g2: Date.now() } });
2002
- jobs.attach(jobId, { target, task: turn.legs > 1 ? "" : text, spawned: false, ...(role ? { role } : {}) }, kind === "ask" ? "ask" : "task");
2003
- }
2004
- // 停放时已记过预算, 放行不再算一次。
2005
- const trackedJob = inj.ok && attemptId ? jobs.get(jobId) : undefined;
2006
- const spent = trackedJob ? { turns: trackedJob.turns ?? 0, maxTurns: trackedJob.maxTurns } : inj.ok && jobId && !held ? jobs.spend(jobId) : undefined;
2007
- if (inj.ok && isPublic)
2008
- relayPeer(self, target, text, channel);
2009
- // `wasBusy` 是给调用方的判断依据: 立刻投给一个正在生成的会话, 这句话会排在
2010
- // 它这一轮后面, 而不是马上被读到。
2011
- return { status: inj.ok ? 200 : 502, body: {
2012
- ...inj,
2013
- name: peerAddress(cfg, self, target),
2014
- public: isPublic,
2015
- ...(topOnly ? { topOnly: "这个群开着顶层模式: wizard 之间一律私聊, 已按私聊投出; 结论回到你这儿, 由你 (或你的上游) 对人收口" } : {}),
2016
- priority: policy,
2017
- ...(kind !== "task" ? { kind } : {}),
2018
- ...(kind === "fyi" && askedJob ? { jobIgnored: true } : {}),
2019
- ...(others ? { urgentDowngraded: `它此刻在做 ${others.from} 派的活, 没打断它 —— 按 normal 等它这一轮做完再投` } : {}),
2020
- wasBusy,
2021
- ...(interrupted !== undefined ? { interrupted } : {}),
2022
- ...(waitedMs ? { waitedMs } : {}),
2023
- ...(jobId ? { job: jobId } : {}),
2024
- ...(inj.ok ? { turn: turn.turn } : {}),
2025
- ...(attemptId ? { node, attemptId, acceptance: "pending" } : {}),
2026
- ...(spent?.maxTurns ? { budget: `${spent.turns}/${spent.maxTurns}${spent.turns >= spent.maxTurns ? " —— 用完了, 这是最后一次派活, 下一步收口" : ""}` } : {}),
2027
- ...(inj.ok && wake ? { wakeCost: wake } : {}),
2028
- ...(turn.reUnknown ? { reUnknown: true, reNote: `re "${re}" 不是你正在等 ${displayName(target)} 答的那件, 按新活发出` } : {}),
2029
- // 回执怎么回来 —— 写在回包里, 调用方 (模型) 不必从工具描述里回忆。
2030
- ...(inj.ok && wantReceipt
2031
- ? { receipt: "它干完那一轮, 结论会作为新的一轮自动进到你这里 —— 不要挂在 wait_peer 上等, 接着干你自己的事" }
2032
- : {}),
2033
- ...(inj.ok && wantReceipt && k?.kind === "peer"
2034
- ? { chained: `这件活算在你答 ${displayName(k.from)} 的那件 (${k.turn}) 名下: 它回来之前, 你这一轮的终句不会先交给 ${displayName(k.from)}; 与那件活无关就带 chain:false 发` }
2035
- : {}),
2036
- } };
2261
+ let accepted;
2262
+ try {
2263
+ accepted = events.accept({ id: eventId, target, speaker: self, channel, serves, asker, priority: policy, deadline: deadlineAt,
2264
+ payload: { text, peer: {
2265
+ from: { kind: "peer", from: self, event: serves, ...(jobId ? { job: jobId } : {}), ...(isPublic ? { public: true } : {}), ...(handedChat ? { transferred: true } : {}), ...(asker ? { asker } : {}) },
2266
+ envelope: envelopeFor(self, channel, { asker, legs, ...(legs > 1 ? { re: true } : {}),
2267
+ ...(body.deadline !== undefined ? { deadline: deadlineAt } : {}), ...(kind !== "task" ? { act: kind } : {}), ...(wantReceipt ? {} : { quiet: true }) }) + extra,
2268
+ ...(wantReceipt ? { tell } : {}), job: jobId, kind, attemptId, after: afters,
2269
+ role: roleParam(body.role), free: freed,
2270
+ ...(plan.op === "transfer" ? { transferred: true } : {}),
2271
+ } } });
2272
+ }
2273
+ catch (error) {
2274
+ return { status: 409, body: { ok: false, eventId, reason: errText(error) } };
2275
+ }
2276
+ // `re` 把那一笔从 need / error 拉回 open (§3.2 的 need→open / error→open): 行状态是「这份中途态
2277
+ // 已经报过了」的去重记号 —— 不拉回去, owner 下一轮交同一个中途态就被静默吞掉 (往返断在这里)。
2278
+ if (reTarget && (reTarget.status === "need" || reTarget.status === "error"))
2279
+ router.commit(ops.mark({ event: reTarget.event, status: "open" }));
2280
+ // `public` 回真实落地的哪一档 (顶层模式 / 工单 / fyi / transfer 都会把入口声明的 public 降级) ——
2281
+ // rolepage 的提示行照它判「公开 / 私聊」, 不能凭调用参数推。
2282
+ return { status: 200, body: { ...accepted, queued: true, ...(prereqs.some((p) => p.state === "pending") ? { parked: true, after: afters } : {}), event: serves, name: peerAddress(cfg, self, target), priority: policy, wasBusy, public: isPublic,
2283
+ ...(jobId ? { job: jobId } : {}), ...(attemptId ? { node, attemptId, acceptance: "pending" } : {}),
2284
+ ...(wantReceipt && plan.op !== "transfer" ? { receipt: "已持久化排队;提交后观察那一笔的回执,结论自动返回" } : {}),
2285
+ ...(plan.op === "transfer" && serves ? { transfer: transferNote(serves, target) } : {}),
2286
+ ...(others ? { urgentDowngraded: "目标正在处理他人的轮次,按 normal 排队" } : {}),
2287
+ ...(reUnknown ? { reUnknown: "它手上没有你在飞的那一笔可续 (已落定或从没派过): 这句按新活发出" } : {}) } };
2037
2288
  };
2038
2289
  const tellPeerAs = async (...args) => {
2039
2290
  const [self, body] = args;
2040
- const transfer = body.answer === "transfer";
2291
+ const transfer = body.return === "transfer";
2041
2292
  const current = peerDispatching.get(self);
2042
- if (current?.transfer || (transfer && current?.count))
2043
- return { status: 409, body: { ok: false, reason: "another dispatch is in flight for this caller; source retains its current answer ownership" } };
2293
+ if (transfer && current?.count)
2294
+ return { status: 409, body: { ok: false, reason: "another transfer is in flight for this caller; it retains its current ownership" } };
2044
2295
  peerDispatching.set(self, { count: (current?.count ?? 0) + 1, transfer });
2045
2296
  try {
2046
2297
  return await tellPeerAsInner(...args);
@@ -2068,7 +2319,11 @@ const main = async () => {
2068
2319
  json(res, 400, { ok: false, reason: "cannot resolve caller" });
2069
2320
  return;
2070
2321
  }
2071
- json(res, 200, { ok: true, events: receipts.inbox(self) });
2322
+ // 收件箱就是持久化队列里还没投出去的那些回执 (§9.3): 一条 `rcpt:` 事件一行。
2323
+ const queued = events.status().events
2324
+ .filter((x) => x.target === self && x.id.startsWith("rcpt:") && x.execution === "queued")
2325
+ .map((x) => ({ id: x.id, from: x.speaker, to: x.target, turn: x.serves ?? "", body: x.payload.text }));
2326
+ json(res, 200, { ok: true, events: queued });
2072
2327
  });
2073
2328
  http.register("POST /workflow/nodes", async (req, res) => {
2074
2329
  const { self, body } = await readPeerBody(req);
@@ -2105,8 +2360,10 @@ const main = async () => {
2105
2360
  const a = workflow.verdict(self, b.attemptId ?? "", b.as, b.digest, b.reason ?? "", b.files);
2106
2361
  if (b.as === "sent" && a.tell)
2107
2362
  recoverWorkflow(a);
2363
+ if (b.as === "stopped" && a.to)
2364
+ cancelRoutes([a.turn], () => "被它的发起人标成 stopped, 这件活没有结论。", self);
2108
2365
  if (b.as === "stopped")
2109
- receipts.cancel(a.to, self, (x) => x.from === self && x.turn === a.turn);
2366
+ cancelRoutes(cancelRoots(a.to, (x) => x.event === a.turn), () => "被它的发起人标成 stopped, 这件活没有结论。", self);
2110
2367
  advancePlans();
2111
2368
  json(res, 200, { ok: true, attempt: workflow.byTurn(self, a.turn) });
2112
2369
  }
@@ -2145,41 +2402,58 @@ const main = async () => {
2145
2402
  return;
2146
2403
  }
2147
2404
  // 同一个 wizard 写两遍不该顶掉两个名额。
2148
- const uniq = hits.filter((h, i) => hits.findIndex((o) => o.target === h.target) === i).map((h) => ({ ...h, turn: receipts.sentTurn(self, h.target), at: receipts.sentAt(self, h.target) }));
2405
+ const uniq = hits.filter((h, i) => hits.findIndex((o) => o.target === h.target) === i);
2149
2406
  const need = Math.min(Math.max(Number(b.need ?? uniq.length) || uniq.length, 1), uniq.length);
2150
2407
  const timeoutMs = Math.min(Math.max(Number(b.timeoutSec ?? 900) || 900, 10), 7200) * 1000;
2151
2408
  const wrs = await waitForQuorum(uniq.map((h) => h.target), m.isBusy, timeoutMs, need);
2152
2409
  // 回程不再进群: 私聊的结论由调用方自己收口给人; 公开轮的回复 mirror 早已发进那个群。
2153
- // 载荷只给一份, 且只给该给的那一份 —— 这段 json 是原样进调用方上下文的:
2154
- // - 只认**这一次发话之后**的回复 (lastReply); 停下了却没有新回复 = `stale`,
2155
- // 绝不把上一件事的答案当成这一件的交回去;
2156
- // - 它收口了 `RESULT:` → 只回 `result` (从全文里摘, 不受截断影响), 正文不再
2157
- // 重复一遍, `omitted` 说还有多少字没给 (要读就 peek_peer);
2158
- // - 没收口 → 回 `lastText`, 超长掐中间保住头尾;
2159
- // - 内部 key (`target`) 与 `foreign` 不回: 模型用不上, 还会被诱导去拼 key。
2410
+ // 载荷只给一份, 且只给该给的那一份 —— 这段 json 是原样进调用方上下文的。
2411
+ // 答案的出处是路由表 + 收件箱 (§9.5): 派给它的那一笔的 `receiver` 边指回自己, 交差时正文
2412
+ // 已随 rcpt 事件 (`rcpt:<event>:<rev>`) 排进收件箱, 行上的 `outcome` 是它的全文 —— 不再去
2413
+ // transcript 里按信封搜那一句 (旧 awaitReply / claim 那条线)。取走 = 撤单 + `hand`。
2414
+ /** 那一份回执在收件箱里的 id 与正文。有定论时 id 由 event + rev 唯一决定 (与 deliverRoute
2415
+ * 同一个算式); 停在 need / error 的正文只活在收件箱事件里 (行上不存 body), 按前缀取最新的。 */
2416
+ const receiptOf = (r) => {
2417
+ if (r.outcome)
2418
+ return { id: `rcpt:${r.event}:${r.outcome.rev}`, text: r.outcome.body };
2419
+ const e = events.status().events.filter((x) => x.id.startsWith(`rcpt:${r.event}:`)).sort((a, b) => b.at - a.at)[0];
2420
+ return { id: e?.id ?? `rcpt:${r.event}:`, text: e?.payload.text ?? "" };
2421
+ };
2160
2422
  const results = uniq.map((h, i) => {
2161
2423
  const wr = wrs[i];
2162
- // 取走就占位: 同一段话不会再作为回执 paste 进来一遍 (见 receipts.claim)。
2163
- const full = wr.idle && h.turn ? m.replyToPeer(h.target, displayName(self), h.at, h.turn)?.text ?? "" : "";
2164
- const already = full ? receipts.claim(self, h.target, h.turn) : false;
2165
- const closing = parseClosing(already === "uncertain" ? "" : full);
2424
+ // 同一对并发多笔是允许的 (§4.1): 先取**还在排队**的那份 (那份还没进过你的会话); 都取过了就
2425
+ // 报最新那件已交付 —— 绝不把已经 paste 过的那份当成新答案交回来。
2426
+ const mine = router.settledTo(self).filter((r) => r.owner === h.target).reverse() // byAt → 新在前
2427
+ .map((row) => { const box = receiptOf(row); return { row, box, ev: events.get(box.id) }; });
2428
+ const pick = mine.find((x) => x.ev?.execution === "queued") ?? mine[0];
2429
+ if (!pick)
2430
+ return { name: h.address, idle: wr.idle, ...(wr.reason ? { reason: wr.reason } : {}),
2431
+ ...(wr.idle ? { stale: true, reason: "它停下了, 但路由表上没有交回给你的定论 —— 多半停在一个等人点的弹窗上, 或那句话没被接住; peek_peer 看一眼" } : {}) };
2432
+ // 回执早一步到了: 这段话已经作为新的一轮进过你的会话, 别处理第二遍。
2433
+ if (pick.ev && pick.ev.execution !== "queued")
2434
+ return { name: h.address, idle: wr.idle,
2435
+ ...(pick.ev.execution === "uncertain"
2436
+ ? { delivery: "uncertain", reason: "回执可能已递交,需按 receipt eventId 对账;没有自动重投或再次领取。" }
2437
+ : { delivered: true }) };
2438
+ // 取走就撤单: 这一笔在收件箱里排着的**全部**回执一起作废 (need 之后又答完时排着两份, 取走
2439
+ // 的那一份就是新结论, 旧的那份不再 paste 第二遍); `hand` 确认已交付 (已 handed 的是 no-op)。
2440
+ if (pick.ev) {
2441
+ events.cancel((x) => x.id.startsWith(`rcpt:${pick.row.event}:`) && x.execution === "queued", "taken by wait_peer");
2442
+ router.commit(ops.hand({ event: pick.row.event, inbox: pick.box.id }));
2443
+ }
2444
+ const closing = parseClosing(pick.box.text);
2166
2445
  const result = closing.kind === "result" ? closing.text : "";
2167
2446
  return {
2168
2447
  name: h.address,
2169
2448
  idle: wr.idle,
2170
- // 回执早一步到了: 这段话已经作为新的一轮进过你的会话, 别再处理第二遍。
2171
- ...(already === true ? { delivered: true } : {}),
2172
- ...(already === "uncertain" ? { delivery: "uncertain", reason: "回执可能已递交,需按 receipt eventId 对账;没有自动重投或再次领取。" } : {}),
2173
2449
  ...(wr.reason ? { reason: wr.reason } : {}),
2174
2450
  ...(closing.kind === "need" ? { need: closing.text } : {}),
2175
2451
  ...(closing.artifacts.length ? { artifacts: closing.artifacts } : {}),
2176
2452
  ...(result
2177
- ? { result, ...(full.length > result.length + 200 ? { omitted: full.length - result.length } : {}) }
2178
- : already === "uncertain" ? {} : full
2179
- ? { lastText: clipMiddle(full) }
2180
- : wr.idle
2181
- ? { stale: true, reason: "它停下了, 但自你上次 send_peer 以来没有新回复 —— 多半停在一个等人点的弹窗上, 或那句话没被接住; peek_peer 看一眼" }
2182
- : {}),
2453
+ ? { result, ...(pick.box.text.length > result.length + 200 ? { omitted: pick.box.text.length - result.length } : {}) }
2454
+ : pick.box.text
2455
+ ? { lastText: clipMiddle(pick.box.text) }
2456
+ : {}),
2183
2457
  };
2184
2458
  });
2185
2459
  const done = results.filter((x) => x.idle).length;
@@ -2190,16 +2464,18 @@ const main = async () => {
2190
2464
  });
2191
2465
  // 交接的「重开」一步: 走 /new (杀旧 pane、起新进程) 而不是往原 pane 注入 /clear。
2192
2466
  // /clear 只换会话不换进程 —— MCP 子进程、charter 都还是旧的, 新工具拿不到; 镜像
2193
- // 还得靠 sid 轮换探测才跟得上。CLI 使用 cfg 的默认 backend; cwd / keepalive 照旧沿用。
2467
+ // 还得靠 sid 轮换探测才跟得上。CLI 使用 cfg 的默认 backend; cwd 照旧沿用
2468
+ // (排着队的 pendingCwd —— set_workspace 先落的那笔 —— 在这一步被消费, 重开落进新目录)。
2194
2469
  // 同 backend 时模型也沿用, 管家除外 ——
2195
2470
  // 它换上此刻的 router 档 (newSession 里定), 改了 models.router 后交接一次就是让它生效的那一下。
2196
- const restartFresh = (target) => {
2471
+ // `announce` (换工作区) 时那条「📂 当前项目」气泡照发 —— 那是人看得见的回执; 普通交接静默。
2472
+ const restartFresh = (target, p) => {
2197
2473
  const info = m.sessionInfo(target);
2198
2474
  const cli = cfg.wrc.defaultCli ?? primary.name;
2199
2475
  const model = stewardTierOf(cfg, target) || info?.cli !== cli ? undefined : info?.model || undefined;
2200
2476
  if (!planContinuationAllowed(target))
2201
2477
  return Promise.resolve({ ok: false, reason: "workflow continuation withdrawn" });
2202
- return m.newSession(target, displayName(target) || tagOfKey(target) || target, cli, { model, silent: true, warm: true, guard: () => planContinuationAllowed(target) });
2478
+ return m.newSession(target, displayName(target) || tagOfKey(target) || target, cli, { model, silent: !p?.announce, guard: () => planContinuationAllowed(target) });
2203
2479
  };
2204
2480
  // POST /handoff — 交接一个 pane 的会话给一个全新会话。先让目标会话把当前工作
2205
2481
  // 压成一份"零上下文也能接手"的交接简报,等它写完并抓取,再 restartFresh 换一个
@@ -2310,9 +2586,9 @@ const main = async () => {
2310
2586
  const pendingMarks = new Map();
2311
2587
  /** 上次注入之后被 /clear 或压缩截断过的 (onContextCut 记, 下一次注入取走)。 */
2312
2588
  const pendingCuts = new Map();
2313
- const liveOfTurns = () => {
2314
- let byTurn;
2315
- return (turn) => (byTurn ??= new Map(receipts.states().map((x) => [x.turn, x]))).get(turn);
2589
+ const liveOfEvents = () => {
2590
+ let byEvent;
2591
+ return (id) => (byEvent ??= new Map(routeInFlight().map((x) => [x.turn, x]))).get(id);
2316
2592
  };
2317
2593
  /** 管家等人验收的根单行 (需求已交付、单还开着)。 */
2318
2594
  const stalledRows = (owner) => stalledRoots(jobs.all(), owner, jobLive()).map(({ job, status }) => renderStalled(job.title, job.id, status));
@@ -2350,7 +2626,7 @@ const main = async () => {
2350
2626
  const o = digest({ items, mark: pendingMarks.get(t), ...(cut ? { cut } : {}), session: i ? { sid: i.sessionId, ctx: i.contextTokens } : undefined, ...(via.human ? { human: via.human } : {}), now, nameOf: displayName });
2351
2627
  pendingMarks.set(t, o.mark);
2352
2628
  o.touched.forEach(ledger.put);
2353
- const text = renderDigest({ ...o, lines: [...o.lines, ...acceptLines] }, o.full ? renderTable(items, liveOfTurns(), pendingNames, now, awaitingRows(t, now), stalledRows(t), shelvedRows(t, now)) : "", rule);
2629
+ const text = renderDigest({ ...o, lines: [...o.lines, ...acceptLines] }, o.full ? renderTable(items, liveOfEvents(), pendingNames, now, awaitingRows(t, now), stalledRows(t), shelvedRows(t, now)) : "", rule);
2354
2630
  return { text, keys: text && rule ? ["rule:pending"] : [] };
2355
2631
  };
2356
2632
  const notices = bindNoticeBox(createNoticeBox({
@@ -2359,16 +2635,21 @@ const main = async () => {
2359
2635
  // 群况快照只给长住的 (没有 parent): 分身的协调人是生它的那位, 宪章里的出生快照够用。
2360
2636
  // 表里是这个群的常驻面: 管家、长住有职责的、管家生的 (群里的执行者)、收件人自己生的;
2361
2637
  // 再往下一层的 (分身的分身, 回归测试那批 rr-*) 不进表 —— 同名册变动 hears 的取舍。
2362
- snapshot: (t) => (wizards.get(t)?.parent ? undefined : () => m.chatTargets(t)
2363
- .filter((x) => x !== t && wizards.get(x)?.name)
2364
- .filter((x) => ((p) => !tagOfKey(x) || (!p ? !!wizards.get(x)?.description : p === t || p === baseOfKey(t)))(wizards.get(x)?.parent))
2365
- .map((x) => {
2366
- const i = m.sessionInfo(x);
2367
- const state = ((v) => (v === undefined ? "-" : v ? "idle" : m.parkedNow(x) ? "parked" : "busy"))(m.idleVerdict(x));
2368
- // 冷热与 route_candidates / tell_peer 冷门控同一口径 (touchOf / ttlOf)。
2369
- const warmth = i?.jsonlPath ? { ctx: i.contextTokens, cold: wakeCostOf(i.contextTokens, touchOf(i.jsonlPath), Date.now(), ttlOf(i.jsonlPath)).cold } : {};
2370
- return { name: displayName(x), state, ...warmth, job: wizards.get(x)?.description || (tagOfKey(x) ? "" : "steward") };
2371
- })),
2638
+ snapshot: (t) => (wizards.get(t)?.parent ? undefined : () => {
2639
+ const now = Date.now();
2640
+ // activeRows: 超阈值没活动的冷绑定不进表 (见 notices.ts); touch 复用冷门控那一次 stat, 不新增 IO。
2641
+ return activeRows(m.chatTargets(t)
2642
+ .filter((x) => x !== t && wizards.get(x)?.name)
2643
+ .filter((x) => ((p) => !tagOfKey(x) || (!p ? !!wizards.get(x)?.description : p === t || p === baseOfKey(t)))(wizards.get(x)?.parent))
2644
+ .map((x) => {
2645
+ const i = m.sessionInfo(x);
2646
+ const state = ((v) => (v === undefined ? "-" : v ? "idle" : m.parkedNow(x) ? "parked" : "busy"))(m.idleVerdict(x));
2647
+ const touch = i?.jsonlPath ? touchOf(i.jsonlPath) : 0;
2648
+ // 冷热与 route_candidates / tell_peer 冷门控同一口径 (touchOf / ttlOf)。
2649
+ const warmth = i?.jsonlPath ? { ctx: i.contextTokens, cold: wakeCostOf(i.contextTokens, touch, now, ttlOf(i.jsonlPath)).cold } : {};
2650
+ return { name: displayName(x), state, ...warmth, job: wizards.get(x)?.description || (tagOfKey(x) ? "" : "steward"), touch };
2651
+ }), now);
2652
+ }),
2372
2653
  seen: createSeenRing(loadJsonMap(cfg.wrc.mirror.noticeSeenFile, gcSeen), (t) => m.sessionInfo(t)?.sessionId ?? ""),
2373
2654
  cwdOf: (t) => ((c) => c.runningCwd || c.defaultCwd)(m.getCwd(t)),
2374
2655
  }));
@@ -2478,7 +2759,7 @@ const main = async () => {
2478
2759
  const roleParam = (v) => ["exec", "reviewer", "expert"].find((x) => x === v);
2479
2760
  /** 成员此刻的态: 定论 > 在飞的实时态。每次取一份回执快照。 */
2480
2761
  const jobLive = () => {
2481
- const inflight = receipts.states();
2762
+ const inflight = routeInFlight();
2482
2763
  return (j, mm) => mm.outcome ?? inflight.find((x) => x.to === mm.target && x.job === j.id)?.state;
2483
2764
  };
2484
2765
  /** 派活前先验工单: 生完分身才发现工单号打错了, 那个分身就成了没人认领的孤儿。 */
@@ -2575,8 +2856,8 @@ const main = async () => {
2575
2856
  stage: jobStage(j, all, live),
2576
2857
  members: j.members.map((mm) => ({ target: mm.target, task: mm.task, spawned: mm.spawned, ...(mm.outcome ? { outcome: mm.outcome } : {}), ...(mm.artifacts?.length ? { artifacts: mm.artifacts } : {}), ...(mm.role ? { role: mm.role } : {}), ...(mm.forkOf ? { forkOf: mm.forkOf } : {}) })),
2577
2858
  })))(jobs.all(), jobLive()),
2578
- // rolepage 的待办托盘: 与名册 / peek 的「在等 / 欠着」同一份 (receipts.states)。
2579
- inflight: receipts.states().map(({ ageMs, ...x }) => ({ ...x, at: Date.now() - ageMs })),
2859
+ // rolepage 的待办托盘: 与名册 / peek 的「在等 / 欠着」同一份 (路由表)。
2860
+ inflight: routeInFlight().map(({ ageMs, ...x }) => ({ ...x, at: Date.now() - ageMs })),
2580
2861
  schedules: tasks.list().filter((x) => x.enabled).map((x) => {
2581
2862
  const st = tasks.stateOf(x.id);
2582
2863
  const loadError = tasks.errors()[x.id];
@@ -2905,14 +3186,13 @@ const main = async () => {
2905
3186
  // 「最近」与模型都从 transcript 读, 而且只为列出来的这一页读: 摘要用不指代任何人
2906
3187
  // 的箭头 (读名册的是另一个 wizard), 模型取它真正跑的那个 —— 绑定里的 model 只在
2907
3188
  // 有人显式选过时才有。
2908
- const warm = keepalivePingSigs(cfg.wrc.mirror.keepalive.ping);
2909
- const inflight = receipts.states();
3189
+ const inflight = routeInFlight();
2910
3190
  const shown = ranked.slice(0, limit).map((r) => ({
2911
3191
  ...r,
2912
3192
  turns: turnsLine(inflight, r.target),
2913
3193
  duty: dutyLine(jobs.all(), r.target, (t) => `.${displayName(t)}`),
2914
3194
  model: r.model || (r.jsonlPath ? lastModel(r.jsonlPath) : ""),
2915
- summary: r.jsonlPath ? lastExchange(r.jsonlPath, 80, warm) : "",
3195
+ summary: r.jsonlPath ? lastExchange(r.jsonlPath, 80) : "",
2916
3196
  contextTokens: r.jsonlPath ? lastContextTokens(r.jsonlPath) : 0,
2917
3197
  }));
2918
3198
  json(res, 200, {
@@ -2931,15 +3211,16 @@ const main = async () => {
2931
3211
  });
2932
3212
  // 管家分派前的候选表: 事实在这里算 (交集、读过的文件、ctx), 权衡留给管家。
2933
3213
  // 只为活着的读 transcript —— 冷的没有 jsonl 可读, 靠职责命中进表。
2934
- /** 候选的原料: 除自己之外每个 wizard 的上下文事实 (文件集 / 最近的话 / ctx / 缓存 TTL)。 */
2935
- const routeRowsOf = async (self) => {
3214
+ /** 候选的原料: 除自己之外每个 wizard 的上下文事实 (文件集 / 最近的话 / ctx / 缓存 TTL)。
3215
+ * `keep` 在读 transcript **之前**收窄: 每行要扫几 MB 尾巴, 全机几百个绑定读一遍是秒级 ——
3216
+ * 调用方只要活着的那几个时, 别替死掉的 pane 付这笔。 */
3217
+ const routeRowsOf = async (self, keep = () => true) => {
2936
3218
  ensureChatNames(self);
2937
- const warm = keepalivePingSigs(cfg.wrc.mirror.keepalive.ping);
2938
3219
  const roster = await rosterOf(self);
2939
3220
  const openJobs = jobs.all();
2940
- const inflight = receipts.states();
3221
+ const inflight = routeInFlight();
2941
3222
  return roster
2942
- .filter((r) => !r.self)
3223
+ .filter((r) => !r.self && keep(r))
2943
3224
  .map((r) => {
2944
3225
  const live = r.jsonlPath ? r.jsonlPath : "";
2945
3226
  const ttl = live ? cacheTtlSec(live) : 0;
@@ -2951,8 +3232,8 @@ const main = async () => {
2951
3232
  ...(ttl ? { cacheTtlMs: ttl * 1000 } : {}),
2952
3233
  ...(live ? { cacheAt: cacheTouchMs(live) } : {}),
2953
3234
  files: live ? contextFiles(live) : [],
2954
- asks: live ? talkTurns(live, 12, warm).filter((t) => t.role === "user").map((t) => t.text) : [],
2955
- summary: live ? lastExchange(live, 80, warm) : "",
3235
+ asks: live ? talkTurns(live, 12).filter((t) => t.role === "user").map((t) => t.text) : [],
3236
+ summary: live ? lastExchange(live, 80) : "",
2956
3237
  };
2957
3238
  });
2958
3239
  };
@@ -2969,8 +3250,8 @@ const main = async () => {
2969
3250
  return;
2970
3251
  }
2971
3252
  const limit = Math.min(Math.max(Number(b.limit ?? 5) || 5, 1), 20);
2972
- const cands = rankCandidates(task, (b.cwd ?? "").trim() || m.getCwd(self).runningCwd, await routeRowsOf(self), { now: Date.now(), ttlMs: cfg.wrc.mirror.keepalive.ttlSec * 1000 }).slice(0, limit);
2973
- json(res, 200, { ok: true, text: renderCandidates(task, cands, Date.now(), cfg.wrc.mirror.keepalive.ttlSec * 1000, homedir()) });
3253
+ const cands = rankCandidates(task, (b.cwd ?? "").trim() || m.getCwd(self).runningCwd, await routeRowsOf(self), { now: Date.now(), ttlMs: CACHE_TTL_FALLBACK_SEC * 1000 }).slice(0, limit);
3254
+ json(res, 200, { ok: true, text: renderCandidates(task, cands, Date.now(), CACHE_TTL_FALLBACK_SEC * 1000, homedir()) });
2974
3255
  });
2975
3256
  // 派活收成一步 (见 route.decide): 查候选 → 过成本门控 → 转给已有的或按档白板 spawn →
2976
3257
  // tell_peer 投出去, 回决定与理由。默认决定由守护进程算; 管家不同意就 `to` 点名、
@@ -2987,30 +3268,33 @@ const main = async () => {
2987
3268
  json(res, 400, { ok: false, reason: "task is required" });
2988
3269
  return;
2989
3270
  }
3271
+ // 回执去向在入口冻结 (dispatch 不再永远挂到当前帧: return:"detach" 即旁支, 修 G4)。
3272
+ const plan = planReturn(self, b);
3273
+ if (!plan.ok) {
3274
+ json(res, plan.status, { ok: false, reason: plan.reason });
3275
+ return;
3276
+ }
2990
3277
  const dispatchOrigin = {
2991
- authority: controlSnapshot(self), answerContext: m.answerContext(self), asker: askerOf(self), k: parentKOf(self),
3278
+ authority: controlSnapshot(self), asker: askerOf(self),
3279
+ plan: { op: plan.op, ...(plan.serves ? { serves: plan.serves } : {}) },
2992
3280
  };
2993
3281
  const lead = b.lead === true;
2994
- if (b.answer !== undefined && b.answer !== "transfer") {
2995
- json(res, 400, { ok: false, reason: "answer must be transfer when supplied" });
2996
- return;
2997
- }
2998
- if (b.answer === "transfer" && lead) {
2999
- json(res, 409, { ok: false, reason: "answer transfer is for simple direct answers; lead dispatch cannot transfer" });
3282
+ const transfer = plan.op === "transfer";
3283
+ if (transfer && lead) {
3284
+ json(res, 409, { ok: false, reason: "transfer hands over the row this turn owes; a lead dispatch cannot transfer" });
3000
3285
  return;
3001
3286
  }
3002
- const answerParent = dispatchOrigin?.k;
3003
- if (b.answer === "transfer" && (lostAnswer(self) || !dispatchOrigin?.answerContext || answerParent?.kind !== "chat" || !dispatchOrigin.asker?.who.startsWith("user:") || !chatPolicyOf(cfg, answerParent.channel).topOnly || hasAnswerChildren(self, answerParent) || peerDispatching.has(self))) {
3004
- json(res, 409, { ok: false, reason: "no exclusive active human topOnly answer available to transfer" });
3287
+ if (transfer && (!plan.serves || router.get(plan.serves)?.receiver.kind !== "wizard")) {
3288
+ json(res, 409, { ok: false, reason: "这一轮没有欠着的同伴活可以交出去 (transfer 只用于同伴派给你的那一笔)" });
3005
3289
  return;
3006
3290
  }
3007
3291
  const force = b.force === true;
3008
3292
  const to = (b.to ?? "").toString().trim();
3009
3293
  const now = Date.now();
3010
- const ttlMs = cfg.wrc.mirror.keepalive.ttlSec * 1000;
3294
+ const ttlMs = CACHE_TTL_FALLBACK_SEC * 1000;
3011
3295
  // 占用保护由 decide 统一判断, 留在候选表里说明为什么不能自动复用。
3012
3296
  // 显式 to 保留人为选定; force 只影响成本门, 不绕 lead/工单/连续性保护。
3013
- const pool = to || b.spawn || lead ? [] : (await routeRowsOf(self)).filter((r) => r.alive && !!tagOfKey(r.target));
3297
+ const pool = to || b.spawn || lead ? [] : await routeRowsOf(self, (r) => r.alive && !!tagOfKey(r.target));
3014
3298
  if (!controlValid(self, dispatchOrigin.authority)) {
3015
3299
  json(res, 409, { ok: false, reason: "dispatch source turn/control changed while routing; original task remains with source" });
3016
3300
  return;
@@ -3032,11 +3316,11 @@ const main = async () => {
3032
3316
  let rootId = "";
3033
3317
  const tell = (name, text, extra = "") => tellPeerAs(self, {
3034
3318
  name, text, public: b.public !== false, force,
3035
- ...(b.answer ? { answer: b.answer } : {}),
3319
+ ...(plan.op !== "self" ? { return: plan.op } : {}),
3036
3320
  // lead 的活远超默认的 1h: 不给 deadline 就按 12h (回执的上限)。
3037
3321
  ...(b.deadline !== undefined ? { deadline: b.deadline } : lead ? { deadline: 43200 } : {}),
3038
3322
  ...(rootId ? { job: rootId } : {}),
3039
- }, extra, undefined, undefined, dispatchOrigin);
3323
+ }, extra, undefined, dispatchOrigin);
3040
3324
  const shown = cands.slice(0, 3).map((e) => `.${e.row.address || e.row.name}${e.row.busy ? " 忙" : ""}`);
3041
3325
  if (b.dryRun) {
3042
3326
  json(res, 200, { ok: true, dryRun: true, decision: d.kind, reason: d.why, ...(d.kind === "existing" ? { name: to || (d.kind === "existing" && d.row ? d.row.address || d.row.name : "") } : { tier: taskTier }), ...(shown.length ? { candidates: shown } : {}) });
@@ -3190,7 +3474,13 @@ const main = async () => {
3190
3474
  try {
3191
3475
  const b = body;
3192
3476
  const asker = b.task?.trim() ? askerOf(self) : undefined;
3193
- const k = body.chain === false ? undefined : parentKOf(self);
3477
+ // 带 task 才是派活: 回执去向同 tell_peer (return / for)。
3478
+ const plan = b.task?.trim() && !planned ? planReturn(self, body) : undefined;
3479
+ if (plan && !plan.ok)
3480
+ return { status: plan.status, body: { ok: false, reason: plan.reason } };
3481
+ // transfer 只对同伴派给你的那一笔有意义 (要交出去的那一行得真的欠在同伴头上)。
3482
+ if (plan?.ok && plan.op === "transfer" && (!plan.serves || router.get(plan.serves)?.receiver.kind !== "wizard"))
3483
+ return { status: 409, body: { ok: false, reason: "这一轮没有欠着的同伴活可以交出去 (transfer 只用于同伴派给你的那一笔)" } };
3194
3484
  const askedEffort = parseEffort(b.effort);
3195
3485
  if (b.effort !== undefined && !askedEffort) {
3196
3486
  return { status: 400, body: { ok: false, reason: `effort 只有这几档: ${EFFORTS.join(" / ")}` } };
@@ -3207,10 +3497,13 @@ const main = async () => {
3207
3497
  if (!planBirthAllowed() || (planned && !!b.task))
3208
3498
  return { status: 409, body: { ok: false, reason: "dedicated plan job only permits taskless runner spawn" } };
3209
3499
  // 带 task 派下去 = 派活: 同样过方向闸, 不论带不带 job (新生的目标还不在任何单里, 实际只有 1 / 链深两条会拦)。
3210
- const dr = planned || (b.task ?? "").toString().trim() ? checkDirection({ self, target: "", kind: "task", re: false, jobId, jobs: jobs.all(), chain: planned?.origin.chain ?? receipts.ancestorsOf(self, parentKOf(self)), nameOf: displayName }) : undefined;
3500
+ const dr = planned || (b.task ?? "").toString().trim() ? checkDirection({ self, target: "", kind: "task", re: false, jobId, jobs: jobs.all(), ...chainNow(self, planned?.origin.event ?? (plan?.ok ? plan.serves : undefined)), nameOf: displayName }) : undefined;
3211
3501
  if (dr) {
3212
3502
  return { status: 409, body: { ok: false, gated: "direction", reason: dr } };
3213
3503
  }
3504
+ const transferNo = plan?.ok && plan.op === "transfer" ? transferRefusal(self, plan.serves, undefined, { job: jobId }) : undefined;
3505
+ if (transferNo)
3506
+ return { status: 409, body: { ok: false, reason: transferNo } };
3214
3507
  // `inherit` 没有默认值 —— 继承与否是两种完全不同的分身 (一种开局就带着你
3215
3508
  // 读过的一切, 另一种白纸一张), 猜错了要么白烧一份上下文, 要么让它从零重读。
3216
3509
  // 逼调用方每次自己说。
@@ -3259,7 +3552,7 @@ const main = async () => {
3259
3552
  const over = detached ? 0 : aliveKids.length - cfg.wrc.mirror.cloneMax + 1;
3260
3553
  if (over > 0) {
3261
3554
  const parked = new Set(listPending().map((p) => p.meta.sessionId).filter(Boolean));
3262
- const owing = new Set(receipts.states().map((x) => x.to));
3555
+ const owing = new Set(routeInFlight().map((x) => x.to));
3263
3556
  const idle = aliveKids
3264
3557
  .filter((pp) => planStore.allowExternal(pp.target, wizards.get(pp.target)?.name) && !pp.busy && !owing.has(pp.target) && !parked.has(m.sessionInfo(pp.target)?.sessionId ?? ""))
3265
3558
  .sort((x, y) => x.lastActivity - y.lastActivity)
@@ -3317,12 +3610,9 @@ const main = async () => {
3317
3610
  const name = wizards.rename(target, normalizeTag(askedName) || tag);
3318
3611
  const charter = charterFor(target, { ...(detached ? {} : { parent: self }), forkOf, inherited: inherit, cli: pick.cli });
3319
3612
  const task = (b.task ?? "").toString().trim();
3320
- // 没点名就按配置的 keepalive.spawnDefault 来 —— 调用方明说的永远优先。
3321
- const keepalive = typeof b.keepalive === "boolean" ? b.keepalive : cfg.wrc.mirror.keepalive.spawnDefault;
3322
3613
  // 先盖章再生: 分叉出来的 transcript 里躺着被克隆者的旧回复, 时刻早于这一枚章,
3323
3614
  // wait_peer 才不会把它们当成分身对这件活的答复。
3324
3615
  const taskAt = Date.now();
3325
- const taskTurn = newTurn();
3326
3616
  const birthGuard = () => (!authority || controlValid(self, authority)) && planBirthAllowed(target, wizards.get(target)?.name ?? name) && (!jobId || jobs.get(jobId)?.status === "open");
3327
3617
  if (!birthGuard())
3328
3618
  return { status: 409, body: { ok: false, reason: "executor reservation changed before spawn" } };
@@ -3338,7 +3628,6 @@ const main = async () => {
3338
3628
  systemPrompt: charter,
3339
3629
  inherit,
3340
3630
  // Fork initialization carries no task; actual work is durably accepted below.
3341
- keepalive,
3342
3631
  }).catch((e) => ({ ok: false, reason: `spawn threw: ${String(e)}`, inherited: false }));
3343
3632
  // 生不出来就回滚身份 —— 否则名字被一个永远没有会话的记录占住。
3344
3633
  if (!r.ok) {
@@ -3353,7 +3642,7 @@ const main = async () => {
3353
3642
  // 出生不再发群气泡 (人要看的是结论, 不是谁生了谁 —— 过程在 rolepage 里);
3354
3643
  // 同群的 wizard 仍要知道群里多了一个成员。工单里的临时工连这条也省掉。
3355
3644
  if (!jobId) {
3356
- // 模型 / keepalive 不报: 那是生它的那位的事, 旁人要时 wizard_roster 有。
3645
+ // 模型不报: 那是生它的那位的事, 旁人要时 wizard_roster 有。
3357
3646
  postRoster(base, [target, self], {
3358
3647
  op: "born", name: `.${name}`, by: displayName(self), clone: !detached,
3359
3648
  ...(r.inherited ? { ctx: forkOf ? displayName(forkOf) : displayName(self) } : {}),
@@ -3361,21 +3650,30 @@ const main = async () => {
3361
3650
  }, subjectOf(wizards.get(target)));
3362
3651
  }
3363
3652
  const dispatched = !!task && birthGuard();
3653
+ let dispatchedEvent = "";
3364
3654
  if (dispatched) {
3365
- const eventId = `p:${self}:${target}:${taskTurn}:1`;
3366
3655
  const deadlineAt = deadlineOf(taskAt);
3367
- events.accept({ id: eventId, target, speaker: self, channel: "", asker, k, priority: "normal", deadline: deadlineAt,
3656
+ const quiet = !!plan?.ok && plan.op === "none";
3657
+ // 路由行 (§3.1): 生出来的那个领活, 结论回生它的这位。
3658
+ const serves = plan?.ok
3659
+ ? openDispatchRow({ self, target, op: plan.op, deadlineAt, job: jobId, ...(asker ? { asker } : {}), ...(plan.serves ? { serves: plan.serves } : {}) })
3660
+ : "";
3661
+ dispatchedEvent = serves;
3662
+ const eventId = serves ? `route:${serves}` : `p:${self}:${target}:${taskAt}:1`;
3663
+ const birthTell = { from: self, to: target, channel: "", job: jobId, at: taskAt, event: serves, legs: 1, deadlineAt, asker, dispatchKey: eventId };
3664
+ events.accept({ id: eventId, target, speaker: self, channel: "", serves, asker, priority: "normal", deadline: deadlineAt,
3368
3665
  payload: { text: task, peer: {
3369
- from: { kind: "peer", from: self, turn: taskTurn, ...(jobId ? { job: jobId } : {}), ...(asker ? { asker } : {}) },
3370
- envelope: envelopeFor(self, "", { turn: taskTurn, asker }) + (b.lead === true ? renderReminder({ wezard: "lead" }, renderLeadPlaybook()) : ""),
3371
- tell: { from: self, to: target, channel: "", job: jobId, at: taskAt, turn: taskTurn, legs: 1, deadlineAt, k, asker, dispatchKey: eventId },
3666
+ from: { kind: "peer", from: self, event: serves, ...(jobId ? { job: jobId } : {}), ...(asker ? { asker } : {}) },
3667
+ envelope: envelopeFor(self, "", { asker, ...(quiet ? { quiet: true } : {}) }) + (b.lead === true ? renderReminder({ wezard: "lead" }, renderLeadPlaybook()) : ""),
3668
+ ...(quiet ? {} : { tell: birthTell }),
3372
3669
  job: jobId, kind: "task", spawned: true, role: roleParam(body.role),
3670
+ ...(plan?.ok && plan.op === "transfer" ? { transferred: true } : {}),
3373
3671
  } } });
3374
3672
  }
3375
3673
  else if (jobId && birthGuard()) {
3376
3674
  jobs.attach(jobId, { target, task: "", spawned: true, ...(roleParam(body.role) ? { role: roleParam(body.role) } : {}) });
3377
3675
  }
3378
- return { status: 200, body: { ok: true, target, name, address: name, inherited: r.inherited, sessionId: r.sessionId, cwd: r.cwd, dispatched, queued: dispatched, ...(dispatched ? { turn: taskTurn } : {}), keepalive, ...(r.model ? { model: r.model } : {}), ...(r.modelWarning ? { modelWarning: r.modelWarning } : {}), ...(r.effort ? { effort: r.effort } : {}), ...(pick.tier ? { tier: pick.tier } : {}), via: pick.via, ...(pick.note ? { tierNote: pick.note } : {}), ...(jobId ? { job: jobId } : {}), ...(detached ? { detached: true } : {}) } };
3676
+ return { status: 200, body: { ok: true, target, name, address: name, inherited: r.inherited, sessionId: r.sessionId, cwd: r.cwd, dispatched, queued: dispatched, ...(dispatched ? { event: dispatchedEvent } : {}), ...(r.model ? { model: r.model } : {}), ...(r.modelWarning ? { modelWarning: r.modelWarning } : {}), ...(r.effort ? { effort: r.effort } : {}), ...(pick.tier ? { tier: pick.tier } : {}), via: pick.via, ...(pick.note ? { tierNote: pick.note } : {}), ...(jobId ? { job: jobId } : {}), ...(detached ? { detached: true } : {}) } };
3379
3677
  }
3380
3678
  finally {
3381
3679
  if (birthJob) {
@@ -3391,17 +3689,16 @@ const main = async () => {
3391
3689
  job: jobs.get, attempt: workflow.byTurn, prereq: workflow.prereq,
3392
3690
  spawn: (run, node, state, permit) => bearAs(run.owner, {
3393
3691
  inherit: false, name: state.name, description: node.criteria || `workflow node ${node.id}`,
3394
- tier: node.tier, cwd: run.plan.cwd, job: run.plan.job, keepalive: false,
3692
+ tier: node.tier, cwd: run.plan.cwd, job: run.plan.job,
3395
3693
  }, permit),
3396
3694
  tell: (run, node, state, after, permit) => tellPeerAs(run.owner, {
3397
3695
  name: state.name, text: node.task + (node.criteria ? `\n\n验收标准: ${node.criteria}` : ""),
3398
3696
  node: node.id, job: run.plan.job, ...(after.length ? { after } : {}),
3399
- }, "", undefined, permit),
3697
+ }, "", permit),
3400
3698
  cancel: (run) => {
3401
3699
  workflow.cancel(undefined, run.owner, run.plan.job);
3402
- run.nodes.filter((n) => n.to).forEach((n) => receipts.cancel(n.to, run.owner, (x) => x.from === run.owner && x.turn === n.turn));
3700
+ cancelRoutes(router.rows().filter((r) => r.job === run.plan.job).map((r) => r.event), () => `随计划 ${run.id} 收掉了, 这件活没有结论。`, run.owner);
3403
3701
  events.cancel((e) => e.speaker === run.owner && e.payload.peer?.job === run.plan.job);
3404
- receipts.cancelHeld(run.plan.job, run.owner);
3405
3702
  },
3406
3703
  error: (e) => log.error({ err: errText(e) }, "workflow plan advancement paused"),
3407
3704
  });
@@ -3409,9 +3706,13 @@ const main = async () => {
3409
3706
  registerWorkflowPlanRoutes(http, {
3410
3707
  read: readPeerBody, store: planStore, runner: planRunner,
3411
3708
  job: jobs.get, attempts: workflow.list,
3412
- occupied: (self, job) => pendingBirthJobs.has(job) || receipts.states().some((s) => s.from === self && s.job === job),
3413
- origin: (self) => { const k = parentKOf(self); return { ...(k ? { k } : {}), ...(askerOf(self) ? { asker: askerOf(self) } : {}), chain: receipts.ancestorsOf(self, k) }; },
3414
- direction: (self, jobId, origin) => checkDirection({ self, target: "", kind: "task", re: false, jobId, jobs: jobs.all(), chain: origin.chain, nameOf: displayName }),
3709
+ occupied: (self, job) => pendingBirthJobs.has(job) || routeInFlight().some((s) => s.from === self && s.job === job),
3710
+ origin: (self) => {
3711
+ const served = servesOfTurn(self);
3712
+ const asker = askerOf(self);
3713
+ return { ...(served ? { event: served } : {}), ...(asker ? { asker } : {}) };
3714
+ },
3715
+ direction: (self, jobId, origin) => checkDirection({ self, target: "", kind: "task", re: false, jobId, jobs: jobs.all(), ...chainNow(self, origin.event), nameOf: displayName }),
3415
3716
  });
3416
3717
  void m.restored.then(planRunner.start);
3417
3718
  http.register("POST /wizard/clone", async (req, res) => {
@@ -3473,11 +3774,20 @@ const main = async () => {
3473
3774
  }
3474
3775
  // 按件号撤回自己派的那一件: 它此刻正在做的就是这件才按 Esc; 否则 (排着 / 在做别人的)
3475
3776
  // 只把这件记成 canceled —— 它之后答出来也不再回执, 手上别人的那一轮不受影响。
3476
- const turn = (b.turn ?? "").replace(/[`\s]/g, "");
3777
+ // 不带 turn / force 的打断 = 撤回你派给它、还在飞的那一笔 (唯一时) —— 不必抄 event; 歧义才要指明。
3778
+ const own = !end && !b.turn && !b.force ? pickPair(router.table(), self, target) : undefined;
3779
+ if (own && !own.ok) {
3780
+ json(res, 400, { ok: false, target, reason: `你在它手上有 ${own.many.length} 笔没落定, 撤回哪一笔用 turn 指明: ${own.many.map((x) => `\`${x.event}\``).join(" ")}` });
3781
+ return;
3782
+ }
3783
+ const ownLive = own?.ok && own.row && ["held", "open", "need", "error"].includes(own.row.status) ? own.row.event : "";
3784
+ const turn = eventRef(b.turn) || ownLive;
3477
3785
  if (!end && turn) {
3478
- const mine = receipts.pending(self, target);
3479
- if (mine?.turn !== turn && workflow.byTurn(self, turn)?.to !== target && !receipts.states().some((s) => s.from === self && s.to === target && s.turn === turn && s.state === "queued")) {
3480
- json(res, 404, { ok: false, reason: `你没有在等 ${victim.name ? `.${victim.name}` : target} 答 '${turn}' 这件 (已落定、件号写错, 或不是你派的)` });
3786
+ const mine = router.awaitedBy(self).find((x) => x.owner === target);
3787
+ const known = router.get(turn);
3788
+ if (mine?.event !== turn && workflow.byTurn(self, turn)?.to !== target
3789
+ && !(known && known.receiver.kind === "wizard" && known.receiver.to === self)) {
3790
+ json(res, 404, { ok: false, reason: `你没有在等 ${victim.name ? `.${victim.name}` : target} 答 '${turn}' 这件 (已落定、event 写错, 或不是你派的)` });
3481
3791
  return;
3482
3792
  }
3483
3793
  // 先落 canceled 再按 Esc: 被打断那一轮的半截话不该被 watcher 当成答案投回来。
@@ -3486,9 +3796,9 @@ const main = async () => {
3486
3796
  json(res, 409, { ok: false, reason: "等待期间本轮控制权已变化;未撤回或打断目标" });
3487
3797
  return;
3488
3798
  }
3489
- const canceled = cancelPeerWork(target, self, (x) => x.from === self && x.turn === turn);
3799
+ const canceled = cancelPeerWork(target, self, (x) => x.from === self && x.event === turn);
3490
3800
  // 按 Esc 之前再认一次手上这一轮: 上面那次 await 期间它可能已经做完这件、接了别人的下一件。
3491
- const current = busy && openingAsk(target)?.turn === turn;
3801
+ const current = busy && openingAsk(target)?.event === turn;
3492
3802
  // 还没轮到 / 排在后面: 捎一句给它, 轮到那句话时别再做了 (notices 挂在下一条注入的尾巴上)。
3493
3803
  if (!current)
3494
3804
  notices.post([target], `${displayName(self)} 撤回了它派给你的 ${turn} —— 那件活不用做了; 读到它时直接略过, 不必回复。`);
@@ -3513,7 +3823,7 @@ const main = async () => {
3513
3823
  return;
3514
3824
  }
3515
3825
  if (others) {
3516
- json(res, 409, { ok: false, target, reason: `它此刻在做 ${others.from} 派的活${others.turn ? ` (${others.turn})` : ""}, 打断会连那件一起停掉 —— 只想撤回你自己派的那件, 传 turn (件号); 确要打断它这一轮, 加 force:true` });
3826
+ json(res, 409, { ok: false, target, reason: `它此刻在做 ${others.from} 派的活${others.event ? ` (${others.event})` : ""}, 打断会连那件一起停掉 —— 只想撤回你自己派的那件, 传 turn (那一笔的 event); 确要打断它这一轮, 加 force:true` });
3517
3827
  return;
3518
3828
  }
3519
3829
  // 先落 canceled 再动 pane: pane 一死, 守着的 watcher 会把同一份报成 dead。
@@ -3701,11 +4011,15 @@ const main = async () => {
3701
4011
  // 取消: 还没落定的成员手上这张单派的活记成 canceled, 发起者不会再收到一串回执。
3702
4012
  planRunner.cancelJob(j.owner, j.id);
3703
4013
  workflow.cancel(undefined, j.owner, j.id);
4014
+ // 还没落定的成员手上这张单派的活记成 canceled (路由表那一批在下面按 job 级联)。
4015
+ // T6: 路由表上这张单名下的行同样级联 canceled (§4.2) —— 行带着 job, 认单不认人。
3704
4016
  if (how === "cancel")
3705
- j.members.filter((mm) => !mm.outcome).forEach((mm) => receipts.cancel(mm.target, j.owner, (x) => x.from === j.owner));
4017
+ cancelRoutes(j.members.filter((mm) => !mm.outcome).flatMap((mm) => router.owedBy(mm.target).filter((r) => r.job === j.id).map((r) => r.event)), () => `随工单 ${j.id} 收了, 这件活没有结论。`, j.owner);
4018
+ // 这张单名下还排着没投的 (held: 等前置 / 没轮上) 也不投了 —— 单都关了, 它再也轮不到
4019
+ // (注入的闸要求单还开着)。receipts.cancelHeld 同一条, 那边只做账 (T6)。
4020
+ cancelRoutes(router.rows().filter((r) => r.status === "held" && r.job === j.id).map((r) => r.event), () => `随工单 ${j.id} 收了, 这件排着的活没投。`, j.owner);
3706
4021
  // 这张单名下还排着没投的 (after 停放) 也不投了。
3707
4022
  events.cancel((e) => e.speaker === j.owner && e.payload.peer?.job === j.id);
3708
- receipts.cancelHeld(j.id, j.owner);
3709
4023
  const closed = jobs.close(j.id, summary, mark);
3710
4024
  ledger.closeJob(j.id);
3711
4025
  // 收工结论留档到开单者名下 (与交接简报同一份 jsonl); 空工单 (没人、没结论) 不记。
@@ -3742,7 +4056,17 @@ const main = async () => {
3742
4056
  json(res, 403, { ok: false, reason: "只能消自己派出去的" });
3743
4057
  return;
3744
4058
  }
3745
- const dropped = drop.length ? ledger.drop(self, drop, (b.why ?? "").toString().trim()) : [];
4059
+ // 消项: 主键是 event, 但老行只记着件号 —— 两种写法都收 (见 pending-items 的 find)。
4060
+ // 名字 = 你派给它、还开着的那几项 (同一对再派会顶掉旧项, 通常就一项)。
4061
+ const dropKeys = drop.flatMap((v) => {
4062
+ const id = eventRef(v);
4063
+ if (isEventRef(id))
4064
+ return [id];
4065
+ const who = resolvePeer(self, v.trim());
4066
+ const hit = who.ok ? ledger.openOf(self).filter((x) => x.to === who.target).map((x) => x.event) : [];
4067
+ return hit.length ? hit : [v.trim()];
4068
+ });
4069
+ const dropped = dropKeys.length ? ledger.drop(self, dropKeys, (b.why ?? "").toString().trim()) : [];
3746
4070
  const now = Date.now();
3747
4071
  const items = ledger.openOf(owner);
3748
4072
  if (owner === self) {
@@ -3750,13 +4074,13 @@ const main = async () => {
3750
4074
  if (i)
3751
4075
  pendingMarks.set(self, { sid: i.sessionId, ctx: i.contextTokens, ctxAtFull: i.contextTokens, drains: 0 });
3752
4076
  }
3753
- const missed = drop.filter((t) => !dropped.some((x) => x.turn === t.replace(/[`\s]/g, "")));
4077
+ const missed = dropKeys.filter((t) => !dropped.some((x) => x.event === t));
3754
4078
  json(res, 200, {
3755
4079
  ok: true,
3756
4080
  text: [
3757
- ...(dropped.length ? [`已消掉: ${dropped.map((x) => `\`${x.turn}\``).join(" ")}`] : []),
4081
+ ...(dropped.length ? [`已消掉: ${dropped.map((x) => `\`${x.event}\``).join(" ")}`] : []),
3758
4082
  ...(missed.length ? [`没找到 (不是你开着的): ${missed.join(" ")}`] : []),
3759
- renderTable(items, liveOfTurns(), pendingNames, now, awaitingRows(owner, now), stalledRows(owner), shelvedRows(owner, now)),
4083
+ renderTable(items, liveOfEvents(), pendingNames, now, awaitingRows(owner, now), stalledRows(owner), shelvedRows(owner, now)),
3760
4084
  ].join("\n"),
3761
4085
  });
3762
4086
  });
@@ -3766,7 +4090,7 @@ const main = async () => {
3766
4090
  json(res, 400, { ok: false, reason: "cannot resolve caller session" });
3767
4091
  return;
3768
4092
  }
3769
- const inflight = receipts.states();
4093
+ const inflight = routeInFlight();
3770
4094
  const live = jobLive();
3771
4095
  const everything = jobs.all();
3772
4096
  // 本群还开着的, 外加它们名下已收工的子单 (账本留着, 面包屑 / 树才不断)。
@@ -3948,9 +4272,14 @@ const main = async () => {
3948
4272
  // 看不见, 要 handoff 重开 (重新渲染宪章) 才换上。不按字段清单判 —— 宪章读哪些配置随代码变,
3949
4273
  // 清单迟早漏; 改前改后各渲染一遍有会话的 wizard 的宪章, 变了的就是受影响的。
3950
4274
  const charterSections = (text) => new Map(text.split(/^(?=## )/m).map((sec) => [sec.split("\n", 1)[0].replace(/^## /, ""), sec]));
4275
+ // 「出生时同群的 wizard」是出生快照 (宪章自己声明名册随时可查), 且渲染途中的懒定居
4276
+ // (settleName / 自动补名写注册表) 会让前后两次渲染无辜地不同 —— 只它变不触发交接
4277
+ // (档位行同此待遇, 见 charterText 的 forDiff)。它一参与比对, 一次无关的 config_set
4278
+ // 就会把同群还活着的 wizard 全量交接一遍。
4279
+ const VOLATILE_SECTIONS = new Set(["出生时同群的 wizard"]);
3951
4280
  const changedSections = (a, b) => {
3952
4281
  const x = charterSections(a), y = charterSections(b);
3953
- return [...new Set([...x.keys(), ...y.keys()])].filter((k) => x.get(k) !== y.get(k));
4282
+ return [...new Set([...x.keys(), ...y.keys()])].filter((k) => !VOLATILE_SECTIONS.has(k) && x.get(k) !== y.get(k));
3954
4283
  };
3955
4284
  /** 宪章挂在 pane 上的 wizard —— 候选。同步取: 守卫要在写之前拍快照, 中间不能 await。 */
3956
4285
  const attachedWizards = () => wizards.all().map((w) => w.target).filter((t) => !!m.sessionInfo(t)?.tmuxPane);
@@ -3977,8 +4306,14 @@ const main = async () => {
3977
4306
  }
3978
4307
  };
3979
4308
  const charterImpact = (before, after) => [...before].flatMap(([t, a]) => { const secs = changedSections(a, after.get(t) ?? a); return secs.length ? [{ target: t, sections: secs }] : []; });
3980
- /** 受影响的 wizard 各挂一条提醒 (随它下一条消息到); 给调用方的那段话。调用方自己受影响就直说要它 handoff。
3981
- * 人发起的改动多一句「交接之前还是旧规矩」: 管家要知道它此刻向人汇报的方式还没变, 别让人以为已经生效。 */
4309
+ /** 宪章改动自动交接的时限 (写简报 + 重开), 与 /handoff 路由同档。 */
4310
+ const CHARTER_HANDOFF_MS = 600_000;
4311
+ /** 受影响的 wizard 的处理分两档:
4312
+ * - hot: 自动交接 —— 守护进程请它写简报、原地重开 (宪章在重开时重渲染)、简报贴回;
4313
+ * 交接不成 (一直忙 / 已在交接中 / 重开失败) 才退回老规矩, 挂一条提醒让它自己 handoff。
4314
+ * - reload: 此刻重开渲染的还是旧宪章, 自动交接无意义 —— 维持挂提醒, reload 之后它自己交。
4315
+ * 给调用方的那段话照旧回; 调用方自己受影响就直说。人发起的改动多一句「交接之前还是旧规矩」:
4316
+ * 自动交接也要等它这一轮说完才动手, 这句话仍成立。 */
3982
4317
  const announceCharterImpact = (c, hits) => {
3983
4318
  if (!hits.length)
3984
4319
  return "";
@@ -3986,13 +4321,25 @@ const main = async () => {
3986
4321
  const lag = (t) => !c.byHuman ? "" : tagOfKey(t)
3987
4322
  ? " —— 这是人发起的改动: 交接之前你仍按旧规矩做事, handoff 之后才按新的"
3988
4323
  : " —— 这是人发起的改动: 交接之前你仍按旧规矩向人汇报, handoff 之后才会按新规矩汇报; 回人时说一句「交接后生效」";
3989
- const others = hits.filter((h) => h.target !== c.self);
3990
- others.forEach((h) => notices.post([h.target], `${c.what} 改动了你的宪章 (系统提示, 变了的段: ${h.sections.join(" / ")}); 已在跑的你看不见新规矩 —— ${ask}${lag(h.target)}`));
3991
4324
  const mine = hits.find((h) => h.target === c.self);
3992
4325
  // 提醒照旧全机发 (各收各的); 回给调用方的名单与人数只含它看得见的 (sameBot) —— 别的机器人的 wizard
3993
4326
  // 连名字带个数都不报。没有调用方 (人 /name、自动补名) = 主机器人视角, 单机器人时就是全部。
3994
4327
  const shown = hits.filter((h) => sameBot(c.self ?? "", h.target));
3995
4328
  const shownOthers = shown.filter((h) => h.target !== c.self);
4329
+ if (!c.reload) {
4330
+ hits.forEach((h) => void handoffs.other(h.target, `${c.what} 改动了你的宪章 (变了的段: ${h.sections.join(" / ")}); 简报里点一句手头活在新规矩下怎么接着干`, CHARTER_HANDOFF_MS).then((r) => {
4331
+ if (!r.ok)
4332
+ notices.post([h.target], `${c.what} 改动了你的宪章 (系统提示, 变了的段: ${h.sections.join(" / ")}); 自动交接没做成 (${r.reason}) —— 已在跑的你看不见新规矩, 手上这件告一段落后自己${ask}${lag(h.target)}`);
4333
+ }));
4334
+ return [
4335
+ "",
4336
+ `⚠ 这次改动进了宪章 (系统提示): 已在跑的 wizard 看不见, 要 handoff 才换上。受影响 ${shown.length} 个: ${shown.map((h) => displayName(h.target)).join(" ")}`,
4337
+ ...(shown.length ? ["已自动安排它们交接 (各自写简报 → 原地重开 → 接回简报); 交接不成的会收到提醒, 再自己 handoff"] : []),
4338
+ ...(mine ? [`**你自己也在其中** (变了的段: ${mine.sections.join(" / ")}): 这一轮说完就会自动交接${lag(mine.target)}`] : []),
4339
+ ].join("\n");
4340
+ }
4341
+ const others = hits.filter((h) => h.target !== c.self);
4342
+ others.forEach((h) => notices.post([h.target], `${c.what} 改动了你的宪章 (系统提示, 变了的段: ${h.sections.join(" / ")}); 已在跑的你看不见新规矩 —— ${ask}${lag(h.target)}`));
3996
4343
  return [
3997
4344
  "",
3998
4345
  `⚠ 这次改动进了宪章 (系统提示): 已在跑的 wizard 看不见, 要 handoff 才换上。受影响 ${shown.length} 个: ${shown.map((h) => displayName(h.target)).join(" ")}`,
@@ -4001,8 +4348,8 @@ const main = async () => {
4001
4348
  ].join("\n");
4002
4349
  };
4003
4350
  // 所有会动宪章的写入 (config_set、人 `/name`、name_chat、自动补名) 共用这一个守卫: 写前写后各渲染一遍
4004
- // 挂在 pane 上的 wizard 的宪章, 变了的、pane 还活着的挂提醒。快照到写完之间不能有 await —— 别人的写入
4005
- // 插进来, 前后就对不上了。已经写了: 比对失败只少一句提醒, 不能让调用方以为没写而重发。
4351
+ // 挂在 pane 上的 wizard 的宪章, 变了的、pane 还活着的自动交接 (hot) 或挂提醒 (reload, 见 announceCharterImpact)。
4352
+ // 快照到写完之间不能有 await —— 别人的写入插进来, 前后就对不上了。已经写了: 比对失败只少一句提醒, 不能让调用方以为没写而重发。
4006
4353
  guardCharter = (write, change) => {
4007
4354
  const targets = attachedWizards();
4008
4355
  const before = charterSnapshot(targets);
@@ -4258,6 +4605,14 @@ const main = async () => {
4258
4605
  // 还要连触发时间点一起被它那一轮拖走。所以到点起一个白板 wizard 单独执行,
4259
4606
  // 跑完自动收掉。只有排班时点名了某个 wizard (fresh=false) 且它此刻闲着,
4260
4607
  // 才把这句话直接投进它那一轮 —— 「在已有会话里继续」是要求出来的, 不是默认。
4608
+ /** 定时任务开一轮: 它要替这个 target 在群里说话 (非 quiet) → 开一条根行; 私下跑的不开 (§3.1)。
4609
+ * 这一枪的注入没有消息事件, 所以件号→行的桥要自己记 (servesOfTurn 的兜底)。 */
4610
+ const openTaskRow = (target, taskId, event, quiet) => {
4611
+ if (quiet)
4612
+ return;
4613
+ openRow({ event, owner: target, deadlineAt: deadlineOf(Date.now()),
4614
+ receiver: { kind: "chat", channel: baseOfKey(target), message: `task:${taskId}:${event}` } });
4615
+ };
4261
4616
  scheduledTaskInject = async (target, text, opts) => {
4262
4617
  const { taskId, fresh, quiet } = opts;
4263
4618
  const owner = ((r) => (r ? ownerOf(r) : target))(tasks.get(taskId));
@@ -4274,13 +4629,14 @@ const main = async () => {
4274
4629
  if (!guard())
4275
4630
  return { ok: false, reason: "scheduled target permission withdrawn" };
4276
4631
  const at = Date.now();
4277
- const turn = newTurn();
4632
+ const turn = newRootEvent();
4278
4633
  // Confirm idle inside the physical pane transaction; a race is unsent
4279
4634
  // and returns to the target consumer, without releasing task dedup.
4280
4635
  if (quiet)
4281
4636
  quietRelays.set(turn, { target, at, base: baseOfKey(target) });
4637
+ openTaskRow(target, taskId, turn, quiet);
4282
4638
  const inj = await m.injectText(target, text, undefined, quiet
4283
- ? { guard, requireIdle: true, from: { kind: "task", taskId }, channel: "", envelope: renderTaskEnvelope(taskId, { turn }) }
4639
+ ? { guard, requireIdle: true, from: { kind: "task", taskId }, channel: "", envelope: renderTaskEnvelope(taskId, { event: turn }) }
4284
4640
  : { guard, requireIdle: true, fromChat: true, from: { kind: "task", taskId }, envelope: renderTaskEnvelope(taskId) });
4285
4641
  if (inj.ok && quiet)
4286
4642
  void relayUnlessQuiet(target, at, turn, baseOfKey(target));
@@ -4306,11 +4662,12 @@ const main = async () => {
4306
4662
  await m.untilIdle(t, end - Date.now());
4307
4663
  const reply = (m.replyToPeer(t, "", at, turn)?.text ?? "").trim();
4308
4664
  if (reply && (await m.idleNow(t))) {
4309
- if (!isQuiet(reply)) {
4665
+ const text = stripForChat(reply); // 进群的转发不带收口标记 / ARTIFACT 行
4666
+ if (text && !isQuiet(reply)) {
4310
4667
  const eventId = `quiet:${t}:${turn}`;
4311
- const revision = finalId(eventId, base, reply);
4668
+ const revision = finalId(eventId, base, text);
4312
4669
  events.delivery({ id: finalId(eventId, base, revision), eventId, target: t, turn, channel: base,
4313
- revision, body: reply, chunks: m.formatFinal(t, turn, base, reply) });
4670
+ revision, body: text, chunks: m.formatFinal(t, turn, base, text) });
4314
4671
  }
4315
4672
  log.info({ target: t, quiet: isQuiet(reply), len: reply.length }, "quiet task: 一轮结束");
4316
4673
  quietRelays.drop(turn);
@@ -4370,11 +4727,12 @@ const main = async () => {
4370
4727
  : `⏰ 定时任务已起白板 wizard .${runnerName} 执行, 完成后自动收掉`));
4371
4728
  }
4372
4729
  const at = Date.now();
4373
- const turn = newTurn();
4730
+ const turn = newRootEvent();
4374
4731
  if (quiet)
4375
4732
  quietRelays.set(turn, { target: runner, at, base });
4733
+ openTaskRow(runner, taskId, turn, quiet);
4376
4734
  const inj = await m.injectText(runner, text, undefined, quiet
4377
- ? { guard: allowed, from: { kind: "task", taskId }, channel: "", envelope: renderTaskEnvelope(taskId, { turn }) }
4735
+ ? { guard: allowed, from: { kind: "task", taskId }, channel: "", envelope: renderTaskEnvelope(taskId, { event: turn }) }
4378
4736
  : { guard: allowed, fromChat: true, from: { kind: "task", taskId }, envelope: renderTaskEnvelope(taskId) });
4379
4737
  if (!inj.ok) {
4380
4738
  wizards.drop(runner);
@@ -4411,7 +4769,7 @@ const main = async () => {
4411
4769
  clearAutoWindow(target);
4412
4770
  return r;
4413
4771
  };
4414
- const spawned = await m.newSession(target, name, undefined, { cwd: memoryRoot(cfg.daemon.stateDir), silent: true, keepalive: false });
4772
+ const spawned = await m.newSession(target, name, undefined, { cwd: memoryRoot(cfg.daemon.stateDir), silent: true });
4415
4773
  if (!spawned.ok)
4416
4774
  return done({ ok: false, reason: `spawn: ${spawned.reason ?? "unknown"}` });
4417
4775
  const inj = await m.injectText(target, prompt, undefined, { from: { kind: "task", taskId: STEWARD_ID }, channel: "", envelope: stewardEnvelope() });
@@ -4507,7 +4865,7 @@ const main = async () => {
4507
4865
  const shutdown = async (signal) => {
4508
4866
  log.info({ signal }, "shutdown signal");
4509
4867
  events.stop();
4510
- stopReceipts();
4868
+ stopSweep();
4511
4869
  scheduler.stop();
4512
4870
  steward?.stop();
4513
4871
  tasks.stop();