wezard 2.7.2 → 2.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@ import { wxMarkdown } from "../shared/wx-text.js";
2
2
  // Daemon entry. Resident process — exits only on signal or fatal WS auth failure.
3
3
  import { existsSync, readFileSync, renameSync, statSync } from "node:fs";
4
4
  import { join } from "node:path";
5
- import { timingSafeEqual, randomBytes } from "node:crypto";
5
+ import { timingSafeEqual, randomBytes, randomUUID } 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";
@@ -47,11 +47,12 @@ import { openTaskRegistry } from "./task-registry.js";
47
47
  import { deliverScheduled } from "./scheduled-delivery.js";
48
48
  import { describeTrigger, nextFire, parseTrigger, WHEN_HELP } from "../shared/trigger.js";
49
49
  import { isQuiet, slugify, uniqueId } from "../shared/task-file.js";
50
- import { baseOfKey, bindMultiBot, bindTagLinker, botOfKey, headed, ownHead, isInternalKey, keyOf, linkTags, normalizeTag, sameBot, speakerHead, tagFromCwd, tagHead, tagLink, tagOfKey, uniqueTag, withTagHeader } from "../shared/session-label.js";
50
+ import { baseOfKey, bindMultiBot, bindTagLinker, botOfKey, headed, ownHead, isInternalKey, keyOf, linkTags, normalizeTag, sameBot, speakerHead, tagHead, tagLink, tagOfKey, uniqueTag, withTagHeader } from "../shared/session-label.js";
51
51
  import { appendEpisode, clipForCharter, cwdOfMd, episodePath, inboxPath, mdsOf, proposedCwds, memoryPath, memoryRoot, proposeMemory, readMemory } from "./wizard-memory.js";
52
52
  import { retireStewardTask, startSteward, stewardEnvelope, STEWARD_ID, STEWARD_RUN_MS, STEWARD_TARGET } from "./memory-steward.js";
53
- import { applyChatNames, chatBaseOf, chatNameOf, chatPolicyOf, clearChatName, listChatNames, normChatName, peerAddress, planChatNames, setChatName, stewardTierOf } from "./chat-name.js";
54
- 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";
53
+ import { applyChatNames, chatBaseOf, chatNameOf, chatPolicyOf, clearChatName, listChatNames, normChatName, peerAddress, planChatNames, setChatName } from "./chat-name.js";
54
+ import { bindWizardStore, loadWizardStore, pickName, 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 { AutoNameExhausted } from "./wizard-names.js";
55
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";
@@ -204,15 +205,19 @@ const main = async () => {
204
205
  let peerEvents;
205
206
  /** T4: 路由表的投递层 —— 在 mirror 大块里接线 (要用到 jobs / 回执信封 / 事件泵), 这里先留个口。 */
206
207
  let deliverFinal;
208
+ let handoffAllowed = () => false;
209
+ let handoffFinalSuppressed = (e) => !!e.handoffCut && !e.handoffRestored;
207
210
  const events = createMessageEvents(join(cfg.daemon.stateDir, "message-events.json"), {
208
211
  resource: (target) => bridge.sessionInfo(target)?.tmuxPane || target,
209
212
  transcript: (target) => bridge.inputSessionInfo(target)?.jsonlPath ?? bridge.sessionInfo(target)?.jsonlPath,
210
213
  sessionId: (target) => bridge.inputSessionInfo(target)?.sessionId,
211
- valid: (e) => e.payload.peer ? !!peerEvents?.valid(e) : true,
214
+ valid: (e) => (!(e.payload.handoff || (e.id.startsWith("route:") && e.id.includes(":resume:"))) || handoffAllowed(e.target)) && (e.payload.peer ? !!peerEvents?.valid(e) : true),
212
215
  accepted: (e) => { if (e.payload.peer)
213
216
  peerEvents?.accepted(e); },
214
217
  committed: (e) => { if (e.payload.peer)
215
218
  peerEvents?.committed(e); },
219
+ gone: (e) => { if (e.payload.peer)
220
+ peerEvents?.gone?.(e); },
216
221
  canceled: (e) => {
217
222
  if (e.payload.peer)
218
223
  peerEvents?.canceled(e);
@@ -228,8 +233,18 @@ const main = async () => {
228
233
  // (贴进运行中的一轮记成 queued 轮, 终句照样归到这条 event —— 见 brief 的 seal/bindLine)。
229
234
  // 这一句曾是要 idleNow 的, 也就是那个反复 5s 超时的探测源头: 一超时就退避, 把同一 target
230
235
  // 后面排着的每一笔 (含后到的人话) 一起钉住十几分钟。交接期间照旧等 —— 那几秒 pane 真是死的。
231
- ready: async (e) => !handingOff(e.target) && (e.payload.peer ? !!(await peerEvents?.ready(e)) : true),
236
+ ready: async (e) => e.payload.handoff ? handoffAllowed(e.target) && bridge.idleNow(e.target) : !handingOff(e.target) && (e.payload.peer ? !!(await peerEvents?.ready(e)) : true),
232
237
  inject: async (e, boundary) => {
238
+ if (e.payload.handoff) {
239
+ const expected = e.payload.handoff;
240
+ const same = () => { const info = bridge.sessionInfo(e.target); return info?.tmuxPane === expected.pane && info?.sessionId === expected.sessionId; };
241
+ if (!same())
242
+ return { ok: false, reason: "handoff original pane/session changed; refusing brief injection" };
243
+ return bridge.injectText(e.target, e.payload.text, undefined, {
244
+ channel: e.channel, eventId: e.id, envelope: renderReminder({ wezard: "envelope", kind: "peer", from: wizardStore()?.get(e.target)?.name || e.target, scope: "private", msg: e.id }, []), requireIdle: true, noRespawn: true, handoff: expected.clear,
245
+ guard: (phase, identity) => handoffAllowed(e.target) && same() && (!identity?.sessionId || identity.sessionId === expected.sessionId) && boundary(phase, identity),
246
+ });
247
+ }
233
248
  if (e.payload.peer)
234
249
  return peerEvents.inject(e, boundary);
235
250
  if (!bridge.hasMirrorTarget(e.target)) {
@@ -273,7 +288,8 @@ const main = async () => {
273
288
  }
274
289
  return { consumed: false };
275
290
  },
276
- recovered: (e) => bridge.recoverEventFinal(e),
291
+ recovered: (e) => { if (!handoffFinalSuppressed(e))
292
+ bridge.recoverEventFinal(e); },
277
293
  // T4: 终句 → 路由表。onFinal 连同投递一起做 —— 定论先落盘 (`answer`), 再按 receiver
278
294
  // 投出去 (§9.3/§9.4); 实现在 mirror 大块里 (要 jobs / 信封 / 事件泵)。
279
295
  final: (e, verdict) => deliverFinal?.(e, verdict),
@@ -287,7 +303,7 @@ const main = async () => {
287
303
  uncertain: result.state === "uncertain" ? result.reason ?? "Weixin send ACK unknown" : undefined,
288
304
  failed: result.state === "failed" ? result.reason ?? "Weixin final rejected" : undefined };
289
305
  }
290
- const stream = d.stream && n === 0 && d.chunks.length === 1 && Date.now() < d.stream.expires ? d.stream : undefined;
306
+ const stream = d.stream && n === 0 && Date.now() < d.stream.expires ? d.stream : undefined;
291
307
  const ack = await responseTracker.sendFrozen(chat, d.chunks[n], stream, events.running);
292
308
  return ack && typeof ack === "object" && "errcode" in ack && ack.errcode === 0 ? { ack: JSON.stringify(ack) }
293
309
  : { ack: JSON.stringify(ack), uncertain: "final transport returned no explicit success ACK" };
@@ -684,24 +700,25 @@ const main = async () => {
684
700
  return;
685
701
  }
686
702
  const foreign = base !== baseOfKey(self);
687
- const slotR = await claimSlot(base, String(body.name ?? body.tag ?? ""), tagFromCwd(cwd) || "peer", [self]);
703
+ const slotR = await claimSlot(base, String(body.name ?? body.tag ?? ""), [self]);
688
704
  if (!slotR.ok) {
689
705
  json(res, slotR.status, slotR.body);
690
706
  return;
691
707
  }
692
- const { target, slot: tag } = slotR;
693
- if (!planStore.allowExternal(target, wizards.get(target)?.name ?? tag)) {
694
- json(res, 409, { ok: false, reason: "workflow plan reserves this executor" });
695
- return;
696
- }
697
- const existed = !!wizards.get(target);
698
- const name = wizards.rename(target, normalizeTag(String(body.name ?? body.tag ?? "")) || tag);
708
+ const { target, name } = slotR;
699
709
  const model = (body.model ?? "").toString().trim();
700
710
  log.child({ mod: "mirror", sub: "sessions-new", target }).info({ self, cwd, foreign, cli: body.cli, model }, "spawning peer session");
701
711
  // silent: 新 wizard 就位不进群 (生命周期事件), 同群的 wizard 从名册增量里得知。
702
- const r = await m.newSession(target, name, body.cli, { cwd, model, silent: true, guard: () => planStore.allowExternal(target, wizards.get(target)?.name ?? name) });
703
- if (!r.ok && !existed)
704
- wizards.drop(target);
712
+ const r = await m.newSession(target, name, body.cli, { cwd, model, silent: true, guard: () => ownsSlot(slotR) && planStore.allowExternal(target, wizards.get(target)?.name ?? name) })
713
+ .catch((e) => ({ ok: false, reason: `spawn threw: ${String(e)}` }));
714
+ if (!ownsSlot(slotR)) {
715
+ json(res, 409, { ok: false, reason: "birth identity replaced during spawn" });
716
+ return;
717
+ }
718
+ if (!r.ok)
719
+ releaseSlot(slotR);
720
+ else
721
+ finishSlot(slotR);
705
722
  if (r.ok)
706
723
  postRoster(base, [target, self], { op: "born", name: `.${name}`, by: displayName(self), cwd: r.cwd }, subjectOf(wizards.get(target)));
707
724
  json(res, r.ok ? 200 : 500, r.ok
@@ -744,16 +761,7 @@ const main = async () => {
744
761
  const r = await m.injectText(target, text, undefined, { guard });
745
762
  json(res, r.ok ? 200 : 500, r);
746
763
  });
747
- // One-shot project switch: bind the chat-wide cwd AND apply it right here.
748
- // 活会话走交接 (handoff): 守护进程请它写简报 → 重开 (pendingCwd 在这一步被消费,
749
- // 落进新目录, 宪章随之重渲染) → 简报贴回, 上下文跟着过去, 「📂 当前项目」气泡
750
- // 照发 (announce —— 那是人看得见的回执)。一律 fire-and-forget: 调用方常常就是被换
751
- // 的那个, 它得先拿到这份应答、把这一轮说完, 交接才动得了手 (other 的第一拍就是等它
752
- // 闲)。交接没做成 → 捎一条提醒给它, pendingCwd 仍排着队, 人手 /new 照样完成切换
753
- // (若它本就在交接中, 那场交接的重开同样会消费这笔 pendingCwd)。冷会话 (pane 不在)
754
- // 没有可交接的上下文, 按老路直接新开。Resolved target
755
- // precedence: explicit body.target → sessionId-derived (so MCP can omit
756
- // it) → tmuxPane (stable across /clear) → cfg.defaultChat.
764
+ // 工作目录属于 CLI 进程; 活会话只记录 pending, 必须由人显式 /new 应用。
757
765
  http.register("POST /mirror/workspace", async (req, res) => {
758
766
  const body = (await readBody(req));
759
767
  const cwd = (body.cwd ?? "").toString();
@@ -783,12 +791,8 @@ const main = async () => {
783
791
  return;
784
792
  }
785
793
  if ((await liveOf([target])).length) {
786
- const focus = `工作区切到 ${set.pendingCwd}: 简报里点一句手头活在新目录下怎么接着干`;
787
- void handoffs.other(target, focus, CHARTER_HANDOFF_MS, { announce: true }).then((r) => {
788
- if (!r.ok)
789
- notices.post([target], `换工作区到 ${set.pendingCwd} 的自动交接没做成: ${r.reason} —— 切换仍排着队, 手上这件告一段落后自己 \`handoff({brief})\`, 或让人在群里 /new 一下`);
790
- });
791
- json(res, 200, { ok: true, target, cwd: set.pendingCwd, scheduled: true });
794
+ json(res, 409, { ok: false, target, runningCwd: set.runningCwd, pendingCwd: set.pendingCwd,
795
+ reason: "原 CLI 进程不能切换工作目录;请求已保留为 pendingCwd,需要显式 /new 才生效,handoff /clear 不会应用它" });
792
796
  return;
793
797
  }
794
798
  // Same shape as inbound's /new: windowName = tag for tagged sessions,
@@ -975,10 +979,11 @@ const main = async () => {
975
979
  && !a.canceled && a.acceptance !== "rejected" && jobs.get(r.plan.job)?.status === "open" && ["running", "needs-input", "errored"].includes(a.execution)
976
980
  && (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");
977
981
  };
982
+ handoffAllowed = (target) => planContinuationAllowed(target) && !planStore.reservation(target, wizards.get(target)?.name);
978
983
  /** 一笔活第几个来回 (§3.2): 同一行上已经注入过的续问数 + 1 —— 现算, 不落第二份数据。
979
984
  * 续问的注入事件 id 是 `route:<event>:r<legs>`, 所以从它自己就能数回来 (reload 也在盘上)。 */
980
- const legsOf = (event) => events.status().events.reduce((n, e) => {
981
- const m = e.id.startsWith(`route:${event}:r`) ? Number(e.id.slice(`route:${event}:r`.length)) : NaN;
985
+ const legsOf = (event) => events.ids().reduce((n, id) => {
986
+ const m = id.startsWith(`route:${event}:r`) ? Number(id.slice(`route:${event}:r`.length)) : NaN;
982
987
  return Number.isInteger(m) && m > n ? m : n;
983
988
  }, 1);
984
989
  /** 同一笔活上的一句续问 (idle-nudge 追问 / 工单打回): 不新开行, 注入事件 id
@@ -1030,29 +1035,90 @@ const main = async () => {
1030
1035
  // 闸、回执清点与断点续做在那边。
1031
1036
  /** 情景记忆 (memory/episodes/<name>.jsonl) 按名字归档; 默认会话没名字时退回 slot / "default"。 */
1032
1037
  const episodeName = (t) => wizards.get(t)?.name || tagOfKey(t) || "default";
1038
+ const handoffStore = loadJsonMap(cfg.wrc.mirror.handoffsFile, undefined, { strict: true });
1039
+ handoffFinalSuppressed = (event) => {
1040
+ const e = events.get(event.id) ?? event;
1041
+ if (e.handoffRestored)
1042
+ return false;
1043
+ if (e.handoffCut)
1044
+ return true;
1045
+ const p = handoffStore.get(e.target);
1046
+ // 先读在飞事务的明确旧事件集合, 覆盖 result 已写而事件 cut 尚未写的崩溃缝。
1047
+ return !!p && p.mode === "self" && !p.failure?.compensated && !!p.cut?.includes(e.id);
1048
+ };
1033
1049
  const handoffs = createHandoffs({
1034
- isBusy: m.isBusy,
1050
+ currentEvents: (t) => {
1051
+ const info = m.sessionInfo(t);
1052
+ const active = info?.jsonlPath ? openingTurn(expandHome(info.jsonlPath))?.env?.eventId : undefined;
1053
+ return events.ids().filter((id) => {
1054
+ const e = events.get(id);
1055
+ return !!e && e.target === t && !e.payload.handoff && (id === active || (!e.finalized && e.execution !== "canceled" && e.exposure !== "none" && (!e.sessionId || e.sessionId === info?.sessionId)));
1056
+ });
1057
+ },
1058
+ suppress: (ids) => ids.forEach((id) => events.suppressHandoffFinal(id)),
1059
+ isBusy: async (t) => {
1060
+ if (!(await m.idleNow(t)))
1061
+ return true;
1062
+ // TUI 已 idle 不代表旧 final 已被事件泵消费; 先落旧轮结果, 再允许切断上下文。
1063
+ const old = events.ids().map((id) => events.get(id)).filter((e) => !!e && e.target === t && !e.id.startsWith("handoff:") && e.exposure !== "none" && !e.finalized && !e.releasedAt && e.execution !== "canceled" && (!e.sessionId || e.sessionId === m.sessionInfo(t)?.sessionId));
1064
+ await old.reduce(async (before, e) => { await before; await events.reconcile(e.id); }, Promise.resolve());
1065
+ return old.some((e) => { const now = events.get(e.id); return now && !now.finalized && now.execution !== "canceled"; });
1066
+ },
1035
1067
  sessionId: (t) => m.sessionInfo(t)?.sessionId ?? "",
1036
- restart: (t, p) => restartFresh(t, p),
1068
+ restart: (t, p, submitted) => clearForHandoff(t, p, submitted),
1037
1069
  // 简报是新会话的开场白, 但它不挂任何信封 (服务不着哪一笔) —— 交接前欠着的那几笔由
1038
1070
  // 「继续 <event>」逐笔续答 (见 resume), 别把简报那一轮的回复当成回执。
1039
- inject: async (t, text, channel) => {
1071
+ inject: async (t, text, channel, id, timeoutMs, clear) => {
1040
1072
  if (!planContinuationAllowed(t))
1041
1073
  return { ok: false, reason: "workflow continuation withdrawn" };
1042
1074
  if (planStore.reservation(t, wizards.get(t)?.name))
1043
1075
  return { ok: false, reason: "workflow handoff requires the original attempt" };
1044
- return m.injectText(t, text, undefined, { channel, guard: () => planContinuationAllowed(t) });
1076
+ if (clear && !(await m.verifyClearContext(t, clear)))
1077
+ return { ok: false, reason: "original cleared CLI process/session changed before carry" };
1078
+ // 事件先落盘; 同一 id 重入只恢复状态, 不重贴。内部事件没有 serves, 不替任何欠账收口。
1079
+ if (!events.known(id)) {
1080
+ const info = clear ? { tmuxPane: clear.pane, sessionId: clear.nextSid } : m.sessionInfo(t);
1081
+ if (!info?.tmuxPane || !info.sessionId)
1082
+ return { ok: false, reason: "original handoff pane/session binding is unavailable" };
1083
+ events.accept({ id, identity: JSON.stringify({ pane: info.tmuxPane, sid: info.sessionId }), target: t, speaker: t, channel, priority: "now", deadline: Date.now() + timeoutMs,
1084
+ payload: { text, handoff: { pane: info.tmuxPane, sessionId: info.sessionId, ...(clear ? { clear } : {}) } } });
1085
+ }
1086
+ for (const end = Date.now() + timeoutMs; Date.now() < end; await sleep(200)) {
1087
+ const e = await events.reconcile(id);
1088
+ if (e?.execution === "complete" || (!id.endsWith(":summary") && e?.exposure === "consumed"))
1089
+ return { ok: true };
1090
+ if (e?.execution === "uncertain" || e?.execution === "canceled")
1091
+ return { ok: false, reason: e.reason ?? "handoff event delivery is uncertain; not resubmitting" };
1092
+ }
1093
+ events.cancel((e) => e.id === id && e.exposure === "none", "handoff stopped before confirmed delivery");
1094
+ return { ok: false, reason: "handoff event consumption not confirmed; durable event retained" };
1045
1095
  },
1046
1096
  channelOf: m.currentChannel,
1047
1097
  lastText: m.lastText,
1098
+ summary: (id) => { const e = events.get(id); return e?.execution === "complete" && e.exposure === "consumed" && typeof e.result?.body === "string" ? e.result.body : undefined; },
1048
1099
  // 欠账不落盘在交接里: 路由表按 target 记着它欠谁、欠哪几笔, 换会话不影响 (T8)。
1049
1100
  owed: (t) => router.owedBy(t).filter((r) => r.status === "open").map((r) => r.event),
1050
1101
  resume: async (t, event, at) => injectResume(t, event, at),
1051
1102
  // 交接是生命周期事件, 不进群; 失败只捎给它自己 (它调完 handoff_self 拿到的是
1052
1103
  // scheduled:true, 不说的话它不知道上下文其实没换)。
1053
1104
  notify: (t, text) => notices.post([t], text),
1105
+ // 交接没做成: 切断那一轮扣下的终句按原行补投 —— 走与轮终同一条 onFinal (行已被合成失败落定的, 照追述规则处理)。
1106
+ uncut: (t, ids) => ids.forEach((id) => {
1107
+ const e = events.get(id);
1108
+ if (!e) {
1109
+ log.warn({ target: t, event: id }, "handoff: 扣下的事件已不在事件库, 补投不了");
1110
+ return;
1111
+ }
1112
+ // 超时可能发生在旧轮尚未产出 final 时; 先释放耐久标记, 让它稍后的真终句正常落定。
1113
+ events.restoreHandoffFinal(id);
1114
+ if (!e.result)
1115
+ return;
1116
+ log.info({ target: t, event: id, serves: e.serves }, "handoff: 交接没做成, 补投切断那一轮的终句");
1117
+ settleFinal(e, { text: e.result.body });
1118
+ m.recoverEventFinal(events.get(id));
1119
+ }),
1054
1120
  log,
1055
- store: loadJsonMap(cfg.wrc.mirror.handoffsFile),
1121
+ store: handoffStore,
1056
1122
  archive: (e) => {
1057
1123
  if (!e.brief.trim())
1058
1124
  return;
@@ -1066,7 +1132,9 @@ const main = async () => {
1066
1132
  * `serves` 指回那一行 (注入事件 id `route:<event>:resume`), 于是它的终句照旧落定、
1067
1133
  * 照旧投给 receiver —— 换会话丢掉的只是 transcript, 不是这笔账。
1068
1134
  * id 按这一场交接的起点 (`at`) 区分: 同一场 reload 重放判重不重投, 换一场才配一条新的。 */
1069
- function injectResume(t, event, at) {
1135
+ async function injectResume(t, event, at) {
1136
+ if (!handoffAllowed(t))
1137
+ return { ok: false, reason: "workflow continuation withdrawn or executor reserved" };
1070
1138
  const row = router.get(event);
1071
1139
  if (!row || row.owner !== t || row.status !== "open")
1072
1140
  return { ok: false, reason: "no open row to resume" };
@@ -1074,7 +1142,7 @@ const main = async () => {
1074
1142
  const waiting = r.kind === "wizard" ? r.to : baseOfKey(r.channel); // 等着它交代的那一方 (信封挂在它名下)
1075
1143
  const channel = r.kind === "chat" ? r.channel : ""; // 结论要落回群里时才带频道 (§9.4)
1076
1144
  const base = `route:${event}:resume`;
1077
- const id = events.status().events.some((e) => e.id === base) ? `${base}:${at}` : base;
1145
+ const id = `${base}:${at}`;
1078
1146
  try {
1079
1147
  events.accept({
1080
1148
  id, identity: JSON.stringify([t, event, "resume", at]),
@@ -1089,7 +1157,15 @@ const main = async () => {
1089
1157
  envelope: envelopeFor(waiting, channel, { ...(row.asker ? { asker: row.asker } : {}) }),
1090
1158
  } },
1091
1159
  });
1092
- return { ok: true };
1160
+ // accept 仅表示排队; 等该 id 真被消费后才让交接记录收尾。
1161
+ for (const end = Date.now() + 600_000; Date.now() < end; await sleep(200)) {
1162
+ const e = await events.reconcile(id);
1163
+ if (e?.exposure === "consumed" || e?.execution === "complete")
1164
+ return { ok: true };
1165
+ if (e?.execution === "uncertain" || e?.execution === "canceled")
1166
+ return { ok: false, reason: e.reason ?? "resume delivery uncertain" };
1167
+ }
1168
+ return { ok: false, reason: "resume consumption not confirmed; handoff retained" };
1093
1169
  }
1094
1170
  catch (e) {
1095
1171
  return { ok: false, reason: errText(e) };
@@ -1150,11 +1226,8 @@ const main = async () => {
1150
1226
  return "";
1151
1227
  // 计划节点 / 停放放行: 那一笔在 plan 发起 / 停放那一刻就定了; 其余看本轮服务谁。
1152
1228
  const f = p.serves ?? servesOfTurn(p.self);
1153
- if (p.op === "transfer") {
1154
- if (f && router.get(f)?.owner === p.self)
1155
- router.commit(ops.move({ event: f, owner: p.target }));
1229
+ if (p.op === "transfer")
1156
1230
  return f ?? "";
1157
- }
1158
1231
  const parent = p.op === "self" ? f : undefined;
1159
1232
  return openRow({
1160
1233
  ...(parent ? { event: `${parent}.${(router.get(parent)?.seq ?? 0) + 1}` } : {}),
@@ -1164,6 +1237,36 @@ const main = async () => {
1164
1237
  ...(p.job ? { job: p.job } : {}), ...(p.asker ? { asker: p.asker } : {}), ...(p.after?.length ? { after: p.after } : {}),
1165
1238
  });
1166
1239
  };
1240
+ const ownsReplyEvent = (e) => {
1241
+ const row = e.serves ? router.get(e.serves) : undefined;
1242
+ if (row && row.owner !== e.target)
1243
+ return false;
1244
+ const latest = e.serves ? events.latestTransfer(e.serves) : undefined;
1245
+ return !latest || latest.id === e.id || latest.at < e.at;
1246
+ };
1247
+ // Acceptance projects ownership only after the input is durable. A rejected
1248
+ // acceptance retains the human's route and bubble for the original owner.
1249
+ const acceptPeerDispatch = (input, transfer = false) => {
1250
+ if (!transfer)
1251
+ return events.accept(input);
1252
+ const row = input.serves ? router.get(input.serves) : undefined;
1253
+ if (!row || row.owner !== input.speaker || row.status === "answered" || row.status === "handed")
1254
+ throw new Error("transfer ownership changed before acceptance");
1255
+ try {
1256
+ const accepted = events.accept(input);
1257
+ m.transferReply(events.get(input.id));
1258
+ return accepted;
1259
+ }
1260
+ catch (error) {
1261
+ const accepted = events.get(input.id);
1262
+ if (!accepted || accepted.exposure === "none") {
1263
+ events.cancel((e) => e.id === input.id, "transfer acceptance failed before exposure");
1264
+ if (router.get(row.event)?.owner === input.target)
1265
+ router.commit(ops.move({ event: row.event, owner: input.speaker }));
1266
+ }
1267
+ throw error;
1268
+ }
1269
+ };
1167
1270
  /** 那一笔行上的接收快照 (挂起账投影用): 结论收给谁。没有行 = 不知道 (还是记 nobody)。 */
1168
1271
  const receiverOfEvent = (event) => (event ? router.get(event)?.receiver : undefined);
1169
1272
  /** 挂起账里那一行的 owner = 派活方 = 这一笔的接收方。收给群的根行 (人话 / 定时) 不进账 ——
@@ -1242,12 +1345,22 @@ const main = async () => {
1242
1345
  if (row.job && isTerminal(out.status) && !row.late)
1243
1346
  jobs.settle(row.job, row.owner, out.status, parseClosing(out.body).artifacts, { turn: event });
1244
1347
  }
1245
- const esc = router.escalate(event, (k) => !!wizards.get(k));
1348
+ postRoute(row, out, router.escalate(event, (k) => !!wizards.get(k)), `rcpt:${event}:${out.rev}`);
1349
+ };
1350
+ /** 一份定论的投递 (账本已记过): 投给升级后的收方 `esc` (没有 = 行上的 receiver)。群收方只落
1351
+ * `hand`; wizard 收方 (含 Dead Letter 退到的群默认 wizard) 进收件箱, id = `inbox`。 */
1352
+ const postRoute = (row, out, esc, inbox, retire) => {
1353
+ const event = row.event;
1246
1354
  const receiver = esc?.receiver ?? row.receiver;
1247
1355
  const via = (esc?.via ?? []).map(displayName);
1248
1356
  // 升级落在 chat 根 = Dead Letter: 退到那个群, 由它的默认 wizard 收下这份结论。
1249
1357
  const dead = receiver.kind === "chat" && !!esc;
1250
1358
  if (receiver.kind !== "wizard" && !dead) {
1359
+ // A transferred chat reply has no upstream wizard to report failures to.
1360
+ // Persist it through the same final outbox before handing the route off.
1361
+ const transferred = events.latestTransfer(event);
1362
+ if (out.status !== "done" && transferred?.target === row.owner)
1363
+ m.deliverTransferredFinal(transferred, out.body);
1251
1364
  router.commit(ops.hand({ event, inbox: `chat:${receiver.message}:${out.rev}` }));
1252
1365
  return;
1253
1366
  }
@@ -1266,7 +1379,6 @@ const main = async () => {
1266
1379
  const retellTo = late && servedRow && servedRow.owner === to && servedRow.outcome?.status !== "canceled"
1267
1380
  ? servedRow.receiver.kind === "wizard" ? displayName(servedRow.receiver.to) : chatNameOf(cfg, servedRow.receiver.channel) || servedRow.receiver.channel
1268
1381
  : undefined;
1269
- const inbox = `rcpt:${event}:${out.rev}`;
1270
1382
  const job = row.job ? jobs.get(row.job) : undefined;
1271
1383
  const tally = row.job ? jobs.tally(row.job) : undefined;
1272
1384
  events.accept({
@@ -1288,9 +1400,37 @@ const main = async () => {
1288
1400
  ...(retellTo ? { retellTo } : {}),
1289
1401
  ...(row.late ? { retold: true } : {}) }),
1290
1402
  } },
1291
- });
1403
+ }, retire);
1292
1404
  router.commit(ops.hand({ event, inbox }));
1293
1405
  };
1406
+ /** 收件箱里那份回执投不到了 (`gone`: 收方没有绑定, 按 store 也恢复不了) —— 投进去时它还在
1407
+ * 注册表上, 升级没跳过它。现在把它当不在, 沿 receiver 边重走一次升级: 能收的上层 wizard /
1408
+ * 群根 (Dead Letter) 收下。链上无处可升 (根行的收方自己没了): canceled 是收掉它的那一方自己
1409
+ * 发起的, 没人在等, 不再投; 别的定论退到收方的家群 (Dead Letter)。账本不再记第二遍。 */
1410
+ const rerouteGone = (e) => {
1411
+ const from = e.payload.peer?.from;
1412
+ const event = from?.receipt ? from.event : undefined;
1413
+ const row = event ? router.get(event) : undefined;
1414
+ if (!event || !row || !from?.status) {
1415
+ log.warn({ id: e.id, target: e.target }, "receipt reroute: 认不出回执的那一行, 投不出去");
1416
+ return;
1417
+ }
1418
+ const reachable = (k) => k !== e.target && !!wizards.get(k) && !!mirrorStore.get(k);
1419
+ const home = baseOfKey(e.target);
1420
+ const esc = router.escalate(event, reachable)
1421
+ ?? (row.receiver.kind === "wizard" && reachable(row.receiver.to) ? undefined
1422
+ : from.status === "canceled" || home === e.target ? null
1423
+ : { receiver: { kind: "chat", channel: home, message: "" }, serves: "", via: [e.target] });
1424
+ if (esc === null) {
1425
+ log.info({ id: e.id, target: e.target, event, status: from.status }, "receipt reroute: 收方已不在且无处可升, 不再投");
1426
+ return;
1427
+ }
1428
+ const rev = e.id.slice(`rcpt:${event}:`.length).split("~")[0];
1429
+ // Replaying a torn reroute must target the same durable inbox entry.
1430
+ const inbox = `${e.id}~${finalId(e.id, JSON.stringify(esc?.receiver ?? row.receiver), rev)}`;
1431
+ log.info({ id: e.id, target: e.target, event, to: esc?.receiver ?? row.receiver, inbox }, "receipt reroute: 收方已不在, 改投");
1432
+ postRoute(row, { status: from.status, body: e.payload.text, rev }, esc, inbox, e.id);
1433
+ };
1294
1434
  /** 一条路由 op: 先落盘, 再投该投的 (need / error 带正文入箱; answer 按 receiver 投递)。这里
1295
1435
  * 只管 `re` (`mark open`) 把那一行拉回在飞 —— 账本与工单跟着 deliverRoute 走。 */
1296
1436
  const commitRoute = (op) => {
@@ -1312,16 +1452,25 @@ const main = async () => {
1312
1452
  * 也最多再多打回一次)。**这是「轮终 evidence → onFinal」那条主触发链的落点**: 没有它,
1313
1453
  * answer 就只能靠 sweeper / 级联取消 / boot 重放合成, 谁答完都不会有回执。 */
1314
1454
  deliverFinal = (e, verdict) => {
1455
+ if (handoffFinalSuppressed(e) || !ownsReplyEvent(e))
1456
+ return;
1315
1457
  // 同一轮的终句会递进来两次: result() 当场递一次, 泵见 complete 未 finalized 再补判一次
1316
1458
  // (message-events)。被打回那一轮的重放不是补交 —— 丢掉; 否则 bounced 已记着这一笔,
1317
1459
  // 第二次就把被打回的原文照收成 done (打回的续问还没注入, 定论先落了)。
1318
1460
  if (e.serves && bounced.get(e.serves) === e.id)
1319
1461
  return;
1320
1462
  // 自我交接切断的那一轮: 收尾那句不是欠着的那几笔的答案 —— 行留 open, 交给新会话的「继续 <event>」。
1463
+ // 扣下记在这场交接上: 交接没做成 (新会话没起来), 它就是那几笔仅有的交代, 由 uncut 补投。
1321
1464
  if (cuttingTurn(e.target)) {
1465
+ handoffs.cut(e.target, e.id);
1466
+ events.suppressHandoffFinal(e.id);
1322
1467
  log.info({ event: e.id, target: e.target, serves: e.serves }, "handoff: 交接切断的那一轮终句不落定");
1323
1468
  return;
1324
1469
  }
1470
+ settleFinal(e, verdict);
1471
+ };
1472
+ /** 一轮的终句落到它服务的那一行上 (定论 / 中途态), 验收不合规格就打回续问。 */
1473
+ const settleFinal = (e, verdict) => {
1325
1474
  const plans = router.onFinal(e.target, e.serves, verdict);
1326
1475
  const answer = plans.flatMap((op) => (op.op === "answer" && op.outcome.status === "done" ? [op] : []))[0];
1327
1476
  const row = answer ? router.get(answer.event) : undefined;
@@ -1332,6 +1481,11 @@ const main = async () => {
1332
1481
  rebound(row, why, e.id);
1333
1482
  return;
1334
1483
  }
1484
+ // Error evidence has no durable event result. Queue the chat reply before
1485
+ // mark:error suppresses replay, so a reload between the two cannot lose it.
1486
+ if (verdict.error && plans.some((op) => op.op === "mark" && op.status === "error")
1487
+ && e.serves && router.get(e.serves)?.receiver.kind === "chat" && e.payload.peer?.transferred)
1488
+ m.deliverTransferredFinal(e, verdict.text);
1335
1489
  plans.forEach(commitRoute);
1336
1490
  };
1337
1491
  // ── 新路由表 (T6): 失败级联 ─────────────────────────────────────────────
@@ -1503,57 +1657,98 @@ const main = async () => {
1503
1657
  * - 撞上的 wizard 静默超过一天 (不在 `keep` 里) → 顶掉它, 名字归新的;
1504
1658
  * - 其余 (还新鲜的) → 409, 附上它的死活, 调用方据此换名或先收掉它。
1505
1659
  * 槽位 (key 里的 `#k`) 只是内部 id: 默认取名字本身, 被占了就加序号, 与名字无关。 */
1506
- const claimSlot = async (base, want, fallback, keep = [], allowed = (target, name) => planStore.allowExternal(target, name) && planStore.allowExternal(target, target ? wizards.get(target)?.name : undefined)) => {
1660
+ const namingClaims = new Set();
1661
+ const ownsSlot = (s) => wizards.get(s.target)?.birthToken === s.token;
1662
+ const releaseSlot = (s) => {
1663
+ if (!ownsSlot(s))
1664
+ return;
1665
+ if (s.existed)
1666
+ wizards.upsert(s.target, { spawning: undefined, spawnReused: undefined });
1667
+ else
1668
+ wizards.drop(s.target);
1669
+ };
1670
+ const finishSlot = (s) => {
1671
+ if (ownsSlot(s))
1672
+ wizards.upsert(s.target, { spawning: undefined, spawnReused: undefined });
1673
+ };
1674
+ const claimSlot = async (base, want, keep = [], allowed = (target, name) => planStore.allowExternal(target, name) && planStore.allowExternal(target, target ? wizards.get(target)?.name : undefined), internalName) => {
1507
1675
  const asked = normalizeTag(want);
1508
1676
  const denied = { ok: false, status: 409, body: { ok: false, reason: "workflow plan reserves this executor" } };
1509
- const candidate = asked || normalizeTag(fallback) || "wizard";
1510
- if (!allowed(keyOf(base, candidate), candidate))
1511
- return denied;
1512
- const clash = asked ? wizards.byName(asked) : undefined;
1513
- // 名字全机唯一, 撞上别的机器人的 wizard 照样拒 —— 但不顶掉它、不复用它的槽, 也不报它的死活:
1514
- // 那边的 wizard 在这个机器人眼里不存在, 只剩「名字被占了」这一个事实。
1515
- if (clash && !sameBot(base, clash.target)) {
1516
- return { ok: false, status: 409, body: { ok: false, reason: `名字 '.${clash.name}' 已被占用 (名字全局唯一) —— 换个名字`, name: clash.name } };
1517
- }
1518
- if (clash && !allowed(clash.target, clash.name))
1519
- return denied;
1520
- const bound = !!clash && m.chatTargets(baseOfKey(clash.target)).includes(clash.target);
1521
- if (clash && !bound && baseOfKey(clash.target) === base && tagOfKey(clash.target)) {
1522
- return { ok: true, target: clash.target, slot: tagOfKey(clash.target) };
1523
- }
1524
- if (clash && !(await displace(asked, keep))) {
1525
- const info = bound ? (await m.peers(clash.target)).find((p) => p.target === clash.target) : undefined;
1526
- const alive = info?.paneAlive ?? false;
1527
- const idleForMs = info?.lastActivity ? Date.now() - info.lastActivity : undefined;
1528
- const idleDesc = idleForMs !== undefined ? `静默 ${Math.round(idleForMs / 60000)} 分钟` : "从没动过";
1529
- const advice = alive
1530
- ? "它的 pane 还活着 —— 除非这就是同一件事的延续, 否则换个名字重新生, 别把不相关的活塞给一个已经有职责的 wizard"
1531
- : bound
1532
- ? `pane 已经不在了 (${idleDesc}), 真要复用这个名字就先 stop_wizard({name:"${clash.name}", mode:"end"}) 收掉, 再用同一个名字重新生 —— 拿到干净的上下文; 别直接 send_peer 唤醒它接手, 它会带着上一件事的记忆答你这件`
1533
- : "这个名字被一个一天内还活动过、眼下没有会话的 wizard 占着 —— 换个名字 (它静默满一天后名字会自动让出)";
1534
- return {
1535
- ok: false,
1536
- status: 409,
1537
- body: {
1538
- ok: false,
1539
- reason: `名字 '.${clash.name}' 已经属于一个${alive ? "活着的" : "不再活跃的"} wizard (名字全局唯一) —— ${advice}`,
1540
- name: clash.name,
1541
- address: clash.name,
1542
- alive,
1543
- busy: info?.busy ?? false,
1544
- idleForMs,
1545
- },
1677
+ const claimKey = asked.toLowerCase();
1678
+ if (asked && namingClaims.has(claimKey))
1679
+ return { ok: false, status: 409, body: { ok: false, reason: "wizard name is being claimed" } };
1680
+ if (asked)
1681
+ namingClaims.add(claimKey);
1682
+ try {
1683
+ const candidate = asked || (internalName ? pickName(internalName, wizards.all().map((w) => w.name)) : wizards.nextName());
1684
+ if (!allowed(keyOf(base, candidate), candidate))
1685
+ return denied;
1686
+ const reserve = (target, existed) => {
1687
+ // No await between uniqueness check and durable registration.
1688
+ const token = randomUUID();
1689
+ wizards.upsert(target, { name: candidate, spawning: BOOT_ID, birthToken: token, spawnReused: existed || undefined });
1690
+ return { ok: true, target, name: candidate, existed, token };
1546
1691
  };
1692
+ const clash = asked ? wizards.byName(asked) : undefined;
1693
+ if (clash?.spawning)
1694
+ return { ok: false, status: 409, body: { ok: false, reason: "wizard is being spawned", name: clash.name } };
1695
+ // 名字全机唯一, 撞上别的机器人的 wizard 照样拒 —— 但不顶掉它、不复用它的槽, 也不报它的死活:
1696
+ // 那边的 wizard 在这个机器人眼里不存在, 只剩「名字被占了」这一个事实。
1697
+ if (clash && !sameBot(base, clash.target)) {
1698
+ return { ok: false, status: 409, body: { ok: false, reason: `名字 '.${clash.name}' 已被占用 (名字全局唯一) —— 换个名字`, name: clash.name } };
1699
+ }
1700
+ if (clash && !allowed(clash.target, clash.name))
1701
+ return denied;
1702
+ const bound = !!clash && m.chatTargets(baseOfKey(clash.target)).includes(clash.target);
1703
+ if (clash && !bound && baseOfKey(clash.target) === base && tagOfKey(clash.target)) {
1704
+ return reserve(clash.target, true);
1705
+ }
1706
+ if (clash && !(await displace(asked, keep))) {
1707
+ const info = bound ? (await m.peers(clash.target)).find((p) => p.target === clash.target) : undefined;
1708
+ const alive = info?.paneAlive ?? false;
1709
+ const idleForMs = info?.lastActivity ? Date.now() - info.lastActivity : undefined;
1710
+ const idleDesc = idleForMs !== undefined ? `静默 ${Math.round(idleForMs / 60000)} 分钟` : "从没动过";
1711
+ const advice = alive
1712
+ ? "它的 pane 还活着 —— 除非这就是同一件事的延续, 否则换个名字重新生, 别把不相关的活塞给一个已经有职责的 wizard"
1713
+ : bound
1714
+ ? `pane 已经不在了 (${idleDesc}), 真要复用这个名字就先 stop_wizard({name:"${clash.name}", mode:"end"}) 收掉, 再用同一个名字重新生 —— 拿到干净的上下文; 别直接 send_peer 唤醒它接手, 它会带着上一件事的记忆答你这件`
1715
+ : "这个名字被一个一天内还活动过、眼下没有会话的 wizard 占着 —— 换个名字 (它静默满一天后名字会自动让出)";
1716
+ return {
1717
+ ok: false,
1718
+ status: 409,
1719
+ body: {
1720
+ ok: false,
1721
+ reason: `名字 '.${clash.name}' 已经属于一个${alive ? "活着的" : "不再活跃的"} wizard (名字全局唯一) —— ${advice}`,
1722
+ name: clash.name,
1723
+ address: clash.name,
1724
+ alive,
1725
+ busy: info?.busy ?? false,
1726
+ idleForMs,
1727
+ },
1728
+ };
1729
+ }
1730
+ if (!allowed(keyOf(base, candidate), candidate) || (clash && !allowed(clash.target, clash.name)))
1731
+ return denied;
1732
+ const taken = new Set([
1733
+ ...m.chatTargets(base).map(tagOfKey),
1734
+ ...wizards.all().filter((w) => baseOfKey(w.target) === base).map((w) => tagOfKey(w.target)),
1735
+ ].filter(Boolean));
1736
+ const slot = uniqueTag(candidate, taken);
1737
+ const target = keyOf(base, slot);
1738
+ // An identity rename may have claimed the address while stale eviction awaited IO.
1739
+ if (wizards.byName(candidate))
1740
+ return { ok: false, status: 409, body: { ok: false, reason: "wizard name was claimed during spawn preparation" } };
1741
+ return allowed(target, candidate) ? reserve(target, false) : denied;
1742
+ }
1743
+ catch (e) {
1744
+ if (e instanceof AutoNameExhausted)
1745
+ return { ok: false, status: 409, body: { ok: false, reason: e.message } };
1746
+ throw e;
1747
+ }
1748
+ finally {
1749
+ if (asked)
1750
+ namingClaims.delete(claimKey);
1547
1751
  }
1548
- if (!allowed(keyOf(base, candidate), candidate) || (clash && !allowed(clash.target, clash.name)))
1549
- return denied;
1550
- const taken = new Set([
1551
- ...m.chatTargets(base).map(tagOfKey),
1552
- ...wizards.all().filter((w) => baseOfKey(w.target) === base).map((w) => tagOfKey(w.target)),
1553
- ].filter(Boolean));
1554
- const slot = uniqueTag(asked || normalizeTag(fallback) || "wizard", taken);
1555
- const target = keyOf(base, slot);
1556
- return allowed(target, asked || slot) ? { ok: true, target, slot } : denied;
1557
1752
  };
1558
1753
  // ── 给人看的消息 ──────────────────────────────────────────────────
1559
1754
  // send_peer 把话塞进另一个 agent 的输入框 (驱动它干活); 这条把话贴进一个聊天
@@ -1883,6 +2078,7 @@ const main = async () => {
1883
2078
  const attempt = p.attemptId ? workflow.byTurn(e.speaker, p.from.event ?? "") : undefined;
1884
2079
  // 单已收但这一笔的行还 open = 收工前就派出去的活, 照投 (见 close_job)。
1885
2080
  return planStore.healthy() && !e.canceled && (!p.from.receipt || planContinuationAllowed(e.target))
2081
+ && (!p.transferred || !e.serves || events.latestTransfer(e.serves)?.id === e.id)
1886
2082
  && (!p.job || jobs.get(p.job)?.status === "open" || (!!e.serves && router.get(e.serves)?.status === "open")) && !attempt?.canceled
1887
2083
  && (!p.after?.length || prereqsOf(e.speaker, p.after).every((r) => r.state === "done" || r.state === "pending"));
1888
2084
  },
@@ -1890,6 +2086,15 @@ const main = async () => {
1890
2086
  if (e.execution !== "queued")
1891
2087
  return;
1892
2088
  const p = e.payload.peer;
2089
+ if (p.transferred && e.serves) {
2090
+ const row = router.get(e.serves);
2091
+ if (!row || row.owner !== e.speaker && row.owner !== e.target || events.latestTransfer(e.serves)?.id !== e.id)
2092
+ throw new Error("transfer intent no longer owns this route");
2093
+ // The input is durable before move. A crash here replays this projection
2094
+ // in the injector, without dropping the original human route.
2095
+ if (row.owner !== e.target)
2096
+ router.commit(ops.move({ event: row.event, owner: e.target }));
2097
+ }
1893
2098
  // 旧 watcher 的 continuation 代数已退役 (§9.4): 收口由事件自己的 serves 决定。
1894
2099
  if (p.attemptId)
1895
2100
  accountWorkflow(workflow.byTurn(e.speaker, p.from.event ?? ""));
@@ -1922,6 +2127,15 @@ const main = async () => {
1922
2127
  if (p.attemptId)
1923
2128
  workflow.cancel(e.target, e.speaker, p.job, p.from.event);
1924
2129
  },
2130
+ // 收方没了: 回执沿链改投; 派活 / 续问不另做 —— 那一行的 owner 就是它, sweeper 见 pane 不在判 dead 上浮。
2131
+ gone: (e) => {
2132
+ const p = e.payload.peer;
2133
+ log.warn({ id: e.id, target: e.target, receipt: !!p.from.receipt }, "message event: 收方已不在 (无绑定, 恢复失败), 不再重投");
2134
+ if (p.attemptId)
2135
+ workflow.dispatched(p.attemptId, false);
2136
+ if (p.from.receipt)
2137
+ rerouteGone(e);
2138
+ },
1925
2139
  ready: async (e) => prereqsOf(e.speaker, e.payload.peer.after ?? []).every((r) => r.state === "done")
1926
2140
  && (e.priority === "now" || await m.idleNow(e.target) || !(await m.paneLive(e.target)) || (e.priority === "urgent" && !(await othersTurn(e.target, e.speaker)))),
1927
2141
  inject: async (e, boundary) => {
@@ -1939,7 +2153,7 @@ const main = async () => {
1939
2153
  if (p.attemptId && p.tell)
1940
2154
  workflow.intent(p.attemptId, p.tell);
1941
2155
  const raw = await m.injectText(e.target, e.payload.text, undefined, {
1942
- from: p.from, channel: e.channel, requireIdle: e.priority === "normal",
2156
+ from: p.from, channel: e.channel, requireIdle: e.priority === "normal", noRespawn: e.id.startsWith("route:") && e.id.includes(":resume:"),
1943
2157
  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),
1944
2158
  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))),
1945
2159
  eventId: e.id,
@@ -1955,8 +2169,11 @@ const main = async () => {
1955
2169
  // 没有行 (老事件 / 不带 serves 的注入) → 照旧放行。
1956
2170
  // 已落定的那一笔只放追述 (§4.3): 判据与 onFinal 的 retellOps 同源 (router.finalReleased)。
1957
2171
  // 调用方可能拿着 result 落定前的快照, 正文按事件库现读。
2172
+ m.setEventOwnerGate(ownsReplyEvent);
1958
2173
  m.setEventFinalGate((e) => {
1959
- if (e.canceled || e.interjected)
2174
+ if (handoffFinalSuppressed(e))
2175
+ return false;
2176
+ if (e.canceled || e.interjected || !ownsReplyEvent(e))
1960
2177
  return false;
1961
2178
  return router.finalReleased(e.target, e.serves, (events.get(e.id) ?? e).result?.body ?? "");
1962
2179
  });
@@ -2221,11 +2438,11 @@ const main = async () => {
2221
2438
  ...(afters.length ? { after: afters } : {}), ...(plan.serves ? { serves: plan.serves } : {}) });
2222
2439
  /** 建完行才发现不该派 (计划许可 / 预算在等待期间变了): 把刚开的那一行收掉 —— 不然它一直
2223
2440
  * 挂在 owedBy 上, 名册里也永远「欠着」一件没人干的活。 */
2224
- const dropRow = () => { if (newRow && serves)
2441
+ const dropRow = () => { if (newRow && serves && plan.op !== "transfer")
2225
2442
  router.commit(ops.gc({ event: serves })); };
2226
2443
  // 注入事件的 id 就是对那一笔的引用 (§9.3): 派活 = `route:<event>`, 续问 = `route:<event>:r<legs>`;
2227
2444
  // 不开行 (none) 时没有行可指, 退回一句一号的合成 id。
2228
- const eventId = serves ? `route:${serves}${legs > 1 ? `:r${legs}` : ""}` : `p:${self}:${target}:${at}:${legs}`;
2445
+ const eventId = serves ? plan.op === "transfer" ? `route:${serves}:transfer:${at}:${target}` : `route:${serves}${legs > 1 ? `:r${legs}` : ""}` : `p:${self}:${target}:${at}:${legs}`;
2229
2446
  let attemptId;
2230
2447
  const beginNode = (queued) => {
2231
2448
  if (!planAllowed() || (planned && !planned.alive()))
@@ -2245,7 +2462,8 @@ const main = async () => {
2245
2462
  // urgent 只打断你自己派的那一轮 (或它自己的回执轮): 手上若是别的 wizard / 人 / 定时任务的活,
2246
2463
  // 打断就误伤了那件 —— 降成 normal, 等它这一轮做完再投。
2247
2464
  const others = asked === "urgent" && wasBusy ? await othersTurn(target, self) : undefined;
2248
- const policy = others ? "normal" : asked;
2465
+ // Transfers need a separate input turn, including when the receiver becomes busy.
2466
+ const policy = others || plan.op === "transfer" ? "normal" : asked;
2249
2467
  if (canceledBeforeDispatch() || !actorStillValid()) {
2250
2468
  dropRow();
2251
2469
  return { status: 409, body: { ok: false, reason: "caller authority or dispatch permit changed before acceptance" } };
@@ -2264,7 +2482,7 @@ const main = async () => {
2264
2482
  }
2265
2483
  let accepted;
2266
2484
  try {
2267
- accepted = events.accept({ id: eventId, target, speaker: self, channel, serves, asker, priority: policy, deadline: deadlineAt,
2485
+ accepted = acceptPeerDispatch({ id: eventId, target, speaker: self, channel, serves, asker, priority: policy, deadline: deadlineAt,
2268
2486
  payload: { text, peer: {
2269
2487
  from: { kind: "peer", from: self, event: serves, ...(jobId ? { job: jobId } : {}), ...(isPublic ? { public: true } : {}), ...(handedChat ? { transferred: true } : {}), ...(asker ? { asker } : {}) },
2270
2488
  envelope: envelopeFor(self, channel, { asker, legs, ...(legs > 1 ? { re: true } : {}),
@@ -2272,9 +2490,10 @@ const main = async () => {
2272
2490
  ...(wantReceipt ? { tell } : {}), job: jobId, kind, attemptId, after: afters,
2273
2491
  role: roleParam(body.role), free: freed,
2274
2492
  ...(plan.op === "transfer" ? { transferred: true } : {}),
2275
- } } });
2493
+ } } }, plan.op === "transfer");
2276
2494
  }
2277
2495
  catch (error) {
2496
+ dropRow();
2278
2497
  return { status: 409, body: { ok: false, eventId, reason: errText(error) } };
2279
2498
  }
2280
2499
  // `re` 把那一笔从 need / error 拉回 open (§3.2 的 need→open / error→open): 行状态是「这份中途态
@@ -2428,7 +2647,7 @@ const main = async () => {
2428
2647
  // 同一对并发多笔是允许的 (§4.1): 先取**还在排队**的那份 (那份还没进过你的会话); 都取过了就
2429
2648
  // 报最新那件已交付 —— 绝不把已经 paste 过的那份当成新答案交回来。
2430
2649
  const mine = router.settledTo(self).filter((r) => r.owner === h.target).reverse() // byAt → 新在前
2431
- .map((row) => { const box = receiptOf(row); return { row, box, ev: events.get(box.id) }; });
2650
+ .map((row) => { const box = receiptOf(row); const execution = events.executionOf(box.id); return { row, box, ev: execution && { execution } }; });
2432
2651
  const pick = mine.find((x) => x.ev?.execution === "queued") ?? mine[0];
2433
2652
  if (!pick)
2434
2653
  return { name: h.address, idle: wr.idle, ...(wr.reason ? { reason: wr.reason } : {}),
@@ -2466,30 +2685,19 @@ const main = async () => {
2466
2685
  ? { ok: true, ...results[0] }
2467
2686
  : { ok: true, need, done, satisfied: done >= need, results });
2468
2687
  });
2469
- // 交接的「重开」一步: 走 /new (杀旧 pane、起新进程) 而不是往原 pane 注入 /clear。
2470
- // /clear 只换会话不换进程 —— MCP 子进程、charter 都还是旧的, 新工具拿不到; 镜像
2471
- // 还得靠 sid 轮换探测才跟得上。CLI 使用 cfg 的默认 backend; cwd 照旧沿用
2472
- // (排着队的 pendingCwd —— set_workspace 先落的那笔 —— 在这一步被消费, 重开落进新目录)。
2473
- // 同 backend 时模型也沿用, 管家除外 ——
2474
- // 它换上此刻的 router 档 (newSession 里定), 改了 models.router 后交接一次就是让它生效的那一下。
2475
- // `announce` (换工作区) 时那条「📂 当前项目」气泡照发 —— 那是人看得见的回执; 普通交接静默。
2476
- const restartFresh = (target, p) => {
2477
- const info = m.sessionInfo(target);
2478
- const cli = cfg.wrc.defaultCli ?? primary.name;
2479
- const model = stewardTierOf(cfg, target) || info?.cli !== cli ? undefined : info?.model || undefined;
2480
- if (!planContinuationAllowed(target))
2481
- return Promise.resolve({ ok: false, reason: "workflow continuation withdrawn" });
2482
- return m.newSession(target, displayName(target) || tagOfKey(target) || target, cli, { model, silent: !p?.announce, guard: () => planContinuationAllowed(target) });
2688
+ // handoff 只清空原 pane 的原生上下文; 不更新 CLI 进程、工作目录或出生时的宪章。
2689
+ const clearForHandoff = (target, p, submitted) => {
2690
+ if (!handoffAllowed(target))
2691
+ return Promise.resolve({ ok: false, reason: "workflow continuation withdrawn or executor reserved" });
2692
+ if (!p.clear && m.sessionInfo(target)?.sessionId !== p.sid)
2693
+ return Promise.resolve({ ok: false, reason: "native session changed before the clear attempt; refusing another clear" });
2694
+ return m.clearContext(target, p.clear, submitted, () => handoffAllowed(target));
2483
2695
  };
2484
- // POST /handoff — 交接一个 pane 的会话给一个全新会话。先让目标会话把当前工作
2485
- // 压成一份"零上下文也能接手"的交接简报,等它写完并抓取,再 restartFresh 换一个
2486
- // 全新的进程(同名、同 cwd),把简报作为新会话的首条消息贴进去。拒绝对调用方
2487
- // 自身操作(会 deadlock,同 /peers/send)。
2488
2696
  /** 交接自己: 简报由调用方给, 排在它这一轮说完之后 (见 handoff.ts)。/handoff 点名点到
2489
2697
  * 自己与老路由 /wizard/handoff-self 共用。 */
2490
2698
  const handoffSelf = (self, brief) => {
2491
- if (!planContinuationAllowed(self))
2492
- return { status: 409, body: { ok: false, reason: "workflow continuation withdrawn" } };
2699
+ if (!handoffAllowed(self))
2700
+ return { status: 409, body: { ok: false, reason: "workflow continuation withdrawn or executor reserved" } };
2493
2701
  if (brief.length < 40)
2494
2702
  return { status: 400, body: { ok: false, reason: "brief 太短 —— 要写到零上下文也能接手: 目标 / 已完成 / 当前状态 / 下一步 / 关键文件与坑" } };
2495
2703
  const info = m.sessionInfo(self);
@@ -3366,12 +3574,8 @@ const main = async () => {
3366
3574
  d = { kind: "spawn", why: `${d.why}; 但它被${t.body.gated === "direction" ? "方向闸" : "冷门控"}退回 (${String(t.body.reason)}) —— 改为白板 spawn` };
3367
3575
  }
3368
3576
  const name = (b.name ?? "").toString().trim();
3369
- const description = (b.description ?? "").toString().trim();
3370
- if (!name || !description) {
3371
- settleRoot(false);
3372
- json(res, 400, { ok: false, decision: "spawn", reason: `${d.why} —— 要新生一个, 带上 name (这件事的短名) 与 description (它的职责) 重发`, ...(shown.length ? { candidates: shown } : {}) });
3373
- return;
3374
- }
3577
+ const description = (b.description ?? "").toString().trim() || clipLine(task, 120);
3578
+ // 空 name 交给 bearAs / claimSlot 按持久序号自动取名避重, 不当作显式点名复用旧记录。
3375
3579
  const tier = taskTier;
3376
3580
  const born = await bearAs(self, { inherit: false, name, description, tier, ...(lead ? { lead: true } : {}) }, undefined, dispatchOrigin?.authority);
3377
3581
  if (born.status !== 200) {
@@ -3495,7 +3699,7 @@ const main = async () => {
3495
3699
  return { status: jc.status, body: { ok: false, reason: jc.reason } };
3496
3700
  }
3497
3701
  }
3498
- const planBirthAllowed = (target, name = normalizeTag(b.name ?? b.tag) || (b.detached && b.cwd ? tagFromCwd(b.cwd) : "") || normalizeTag(b.description?.split(/\s+/)[0]) || "clone") => planStore.allowSpawn(self, jobId, planned, name, target) && (!planned || planned.alive());
3702
+ const planBirthAllowed = (target, name = normalizeTag(b.name ?? b.tag)) => planStore.allowSpawn(self, jobId, planned, name, target) && (!planned || planned.alive());
3499
3703
  if (!planBirthAllowed() || (planned && !!b.task))
3500
3704
  return { status: 409, body: { ok: false, reason: "dedicated plan job only permits taskless runner spawn" } };
3501
3705
  // 带 task 派下去 = 派活: 同样过方向闸, 不论带不带 job (新生的目标还不在任何单里, 实际只有 1 / 链深两条会拦)。
@@ -3579,20 +3783,20 @@ const main = async () => {
3579
3783
  } };
3580
3784
  }
3581
3785
  const askedName = String(b.name ?? b.tag ?? "");
3582
- // 没起名: 独立长住的按目录名 (同 /new 与 new_claude_session), 分身按职责的首词。
3583
- const fallback = (detached && b.cwd ? tagFromCwd(b.cwd) : "") || normalizeTag(b.description?.split(/\s+/)[0]) || "clone";
3584
- const slotR = await claimSlot(base, askedName, fallback, [self, source], (target, name) => planBirthAllowed(target, name) && (!target || planBirthAllowed(target, wizards.get(target)?.name ?? name)));
3786
+ const slotR = await claimSlot(base, askedName, [self, source], (target, name) => planBirthAllowed(target, name) && (!target || planBirthAllowed(target, wizards.get(target)?.name ?? name)));
3585
3787
  if (!slotR.ok) {
3586
3788
  return { status: slotR.status, body: slotR.body };
3587
3789
  }
3588
- const { target, slot: tag } = slotR;
3790
+ const { target, name } = slotR;
3791
+ const releaseBirth = () => releaseSlot(slotR);
3589
3792
  // claimSlot 会复用同名的冷记录 (detached 正是「叫回一个老名字」的场景): 生不出来时
3590
3793
  // 只回滚这一次的新记录, 不能把那个老 wizard 的记忆和家谱一起删掉。
3591
- const existed = !!wizards.get(target);
3592
3794
  const forkOf = inherit && source !== self ? source : undefined;
3593
3795
  // 先落身份再 spawn: 宪章是从注册表渲染出来的, 记录不在就渲染出一个无名分身。
3594
- if (!planBirthAllowed(target, normalizeTag(askedName) || tag) || (jobId && jobs.get(jobId)?.status !== "open"))
3796
+ if (!ownsSlot(slotR) || !planBirthAllowed(target, name) || (jobId && jobs.get(jobId)?.status !== "open")) {
3797
+ releaseBirth();
3595
3798
  return { status: 409, body: { ok: false, reason: "plan canceled/job reserved during spawn preparation" } };
3799
+ }
3596
3800
  wizards.upsert(target, {
3597
3801
  description: (b.description ?? "").toString().trim(),
3598
3802
  // 显式写 undefined: upsert 是合并写, 复用的冷记录里旧的 parent 会留下来。
@@ -3609,15 +3813,16 @@ const main = async () => {
3609
3813
  // 生完才清; 这个进程中途没了, 下次开机由 sweepUnborn 认出并收掉。
3610
3814
  spawning: BOOT_ID,
3611
3815
  });
3612
- const name = wizards.rename(target, normalizeTag(askedName) || tag);
3613
3816
  const charter = charterFor(target, { ...(detached ? {} : { parent: self }), forkOf, inherited: inherit, cli: pick.cli });
3614
3817
  const task = (b.task ?? "").toString().trim();
3615
3818
  // 先盖章再生: 分叉出来的 transcript 里躺着被克隆者的旧回复, 时刻早于这一枚章,
3616
3819
  // wait_peer 才不会把它们当成分身对这件活的答复。
3617
3820
  const taskAt = Date.now();
3618
- const birthGuard = () => (!authority || controlValid(self, authority)) && planBirthAllowed(target, wizards.get(target)?.name ?? name) && (!jobId || jobs.get(jobId)?.status === "open");
3619
- if (!birthGuard())
3821
+ const birthGuard = () => ownsSlot(slotR) && (!authority || controlValid(self, authority)) && planBirthAllowed(target, wizards.get(target)?.name ?? name) && (!jobId || jobs.get(jobId)?.status === "open");
3822
+ if (!birthGuard()) {
3823
+ releaseBirth();
3620
3824
  return { status: 409, body: { ok: false, reason: "executor reservation changed before spawn" } };
3825
+ }
3621
3826
  const r = await m.cloneSession({
3622
3827
  guard: birthGuard,
3623
3828
  parent: source,
@@ -3633,13 +3838,13 @@ const main = async () => {
3633
3838
  }).catch((e) => ({ ok: false, reason: `spawn threw: ${String(e)}`, inherited: false }));
3634
3839
  // 生不出来就回滚身份 —— 否则名字被一个永远没有会话的记录占住。
3635
3840
  if (!r.ok) {
3636
- if (existed)
3637
- wizards.upsert(target, { spawning: undefined });
3638
- else
3639
- wizards.drop(target);
3841
+ releaseBirth();
3640
3842
  return { status: 500, body: { ok: false, reason: r.reason } };
3641
3843
  }
3642
- wizards.upsert(target, { spawning: undefined, clonedFrom: r.inherited ? sourceInfo?.sessionId ?? "" : "", ...(r.inherited ? {} : { forkOf: undefined }) });
3844
+ if (!ownsSlot(slotR))
3845
+ return { status: 409, body: { ok: false, reason: "birth identity replaced during spawn" } };
3846
+ finishSlot(slotR);
3847
+ wizards.upsert(target, { clonedFrom: r.inherited ? sourceInfo?.sessionId ?? "" : "", ...(r.inherited ? {} : { forkOf: undefined }) });
3643
3848
  const kid = briefOf(self, target);
3644
3849
  // 出生不再发群气泡 (人要看的是结论, 不是谁生了谁 —— 过程在 rolepage 里);
3645
3850
  // 同群的 wizard 仍要知道群里多了一个成员。工单里的临时工连这条也省掉。
@@ -3666,16 +3871,21 @@ const main = async () => {
3666
3871
  ? openDispatchRow({ self, target, op: plan.op, deadlineAt, job: jobId, ...(asker ? { asker } : {}), ...(plan.serves ? { serves: plan.serves } : {}) })
3667
3872
  : "";
3668
3873
  dispatchedEvent = serves;
3669
- const eventId = serves ? `route:${serves}` : `p:${self}:${target}:${taskAt}:1`;
3874
+ const eventId = serves ? plan?.ok && plan.op === "transfer" ? `route:${serves}:transfer:${taskAt}:${target}` : `route:${serves}` : `p:${self}:${target}:${taskAt}:1`;
3670
3875
  const birthTell = { from: self, to: target, channel: handedChat, job: jobId, at: taskAt, event: serves, legs: 1, deadlineAt, asker, dispatchKey: eventId };
3671
- events.accept({ id: eventId, target, speaker: self, channel: handedChat, serves, asker, priority: "normal", deadline: deadlineAt,
3672
- payload: { text: task, peer: {
3673
- from: { kind: "peer", from: self, event: serves, ...(jobId ? { job: jobId } : {}), ...(handedChat ? { transferred: true } : {}), ...(asker ? { asker } : {}) },
3674
- envelope: envelopeFor(self, handedChat, { asker, ...(quiet ? { quiet: true } : {}) }) + (b.lead === true ? renderReminder({ wezard: "lead" }, renderLeadPlaybook()) : ""),
3675
- ...(quiet ? {} : { tell: birthTell }),
3676
- job: jobId, kind: "task", spawned: true, role: roleParam(body.role),
3677
- ...(plan?.ok && plan.op === "transfer" ? { transferred: true } : {}),
3678
- } } });
3876
+ try {
3877
+ acceptPeerDispatch({ id: eventId, target, speaker: self, channel: handedChat, serves, asker, priority: "normal", deadline: deadlineAt,
3878
+ payload: { text: task, peer: {
3879
+ from: { kind: "peer", from: self, event: serves, ...(jobId ? { job: jobId } : {}), ...(handedChat ? { transferred: true } : {}), ...(asker ? { asker } : {}) },
3880
+ envelope: envelopeFor(self, handedChat, { asker, ...(quiet ? { quiet: true } : {}) }) + (b.lead === true ? renderReminder({ wezard: "lead" }, renderLeadPlaybook()) : ""),
3881
+ ...(quiet ? {} : { tell: birthTell }),
3882
+ job: jobId, kind: "task", spawned: true, role: roleParam(body.role),
3883
+ ...(plan?.ok && plan.op === "transfer" ? { transferred: true } : {}),
3884
+ } } }, !!plan?.ok && plan.op === "transfer");
3885
+ }
3886
+ catch (error) {
3887
+ return { status: 409, body: { ok: false, target, name, dispatched: false, reason: errText(error) } };
3888
+ }
3679
3889
  }
3680
3890
  else if (jobId && birthGuard()) {
3681
3891
  jobs.attach(jobId, { target, task: "", spawned: true, ...(roleParam(body.role) ? { role: roleParam(body.role) } : {}) });
@@ -3850,6 +4060,10 @@ const main = async () => {
3850
4060
  json(res, 502, { ok: false, target, reason: done.reason });
3851
4061
  return;
3852
4062
  }
4063
+ if (wizards.get(target)?.birthToken !== victimRec?.birthToken) {
4064
+ json(res, 409, { ok: false, target, reason: "wizard identity changed during stop; identity cleanup withheld" });
4065
+ return;
4066
+ }
3853
4067
  const detached = end && b.forget ? forgetWizard(target) : [];
3854
4068
  // 打断只是停了它这一轮, 它还在; 只有结束才是名册变了。
3855
4069
  if (end)
@@ -4278,7 +4492,7 @@ const main = async () => {
4278
4492
  ];
4279
4493
  // ── 改配置会不会动到宪章 ─────────────────────────────────────────
4280
4494
  // 宪章是 --append-system-prompt 压进去的, 随进程终身: 改了进宪章的配置, 已在跑的 wizard
4281
- // 看不见, 要 handoff 重开 (重新渲染宪章) 才换上。不按字段清单判 —— 宪章读哪些配置随代码变,
4495
+ // 看不见, 需要显式 /new 才重新渲染宪章; handoff /clear 保留启动时的系统提示。不按字段清单判 —— 宪章读哪些配置随代码变,
4282
4496
  // 清单迟早漏; 改前改后各渲染一遍有会话的 wizard 的宪章, 变了的就是受影响的。
4283
4497
  const charterSections = (text) => new Map(text.split(/^(?=## )/m).map((sec) => [sec.split("\n", 1)[0].replace(/^## /, ""), sec]));
4284
4498
  // 「出生时同群的 wizard」是出生快照 (宪章自己声明名册随时可查), 且渲染途中的懒定居
@@ -4315,49 +4529,22 @@ const main = async () => {
4315
4529
  }
4316
4530
  };
4317
4531
  const charterImpact = (before, after) => [...before].flatMap(([t, a]) => { const secs = changedSections(a, after.get(t) ?? a); return secs.length ? [{ target: t, sections: secs }] : []; });
4318
- /** 宪章改动自动交接的时限 (写简报 + 重开), 与 /handoff 路由同档。 */
4319
- const CHARTER_HANDOFF_MS = 600_000;
4320
- /** 受影响的 wizard 的处理分两档:
4321
- * - hot: 自动交接 —— 守护进程请它写简报、原地重开 (宪章在重开时重渲染)、简报贴回;
4322
- * 交接不成 (一直忙 / 已在交接中 / 重开失败) 才退回老规矩, 挂一条提醒让它自己 handoff。
4323
- * - reload: 此刻重开渲染的还是旧宪章, 自动交接无意义 —— 维持挂提醒, reload 之后它自己交。
4324
- * 给调用方的那段话照旧回; 调用方自己受影响就直说。人发起的改动多一句「交接之前还是旧规矩」:
4325
- * 自动交接也要等它这一轮说完才动手, 这句话仍成立。 */
4532
+ /** /clear 不重新渲染进程启动时的宪章; 只通知, 不暗中重启会话。 */
4326
4533
  const announceCharterImpact = (c, hits) => {
4327
4534
  if (!hits.length)
4328
4535
  return "";
4329
- const ask = `${c.reload ? "等守护进程 reload 之后再" : ""}调 \`handoff({brief})\` 原地重开 (手上这件告一段落就交; 宪章在 handoff 时重新生成)`;
4330
- const lag = (t) => !c.byHuman ? "" : tagOfKey(t)
4331
- ? " —— 这是人发起的改动: 交接之前你仍按旧规矩做事, handoff 之后才按新的"
4332
- : " —— 这是人发起的改动: 交接之前你仍按旧规矩向人汇报, handoff 之后才会按新规矩汇报; 回人时说一句「交接后生效」";
4333
- const mine = hits.find((h) => h.target === c.self);
4334
- // 提醒照旧全机发 (各收各的); 回给调用方的名单与人数只含它看得见的 (sameBot) —— 别的机器人的 wizard
4335
- // 连名字带个数都不报。没有调用方 (人 /name、自动补名) = 主机器人视角, 单机器人时就是全部。
4536
+ const ask = `${c.reload ? "守护进程 reload 之后, " : ""}需要显式 /new 才能载入新宪章;handoff /clear 保留原进程的系统提示,不会使这次改动生效`;
4537
+ hits.filter((h) => h.target !== c.self).forEach((h) => notices.post([h.target], `${c.what} 改动了你的宪章 (系统提示, 变了的段: ${h.sections.join(" / ")}); ${ask}`));
4336
4538
  const shown = hits.filter((h) => sameBot(c.self ?? "", h.target));
4337
- const shownOthers = shown.filter((h) => h.target !== c.self);
4338
- if (!c.reload) {
4339
- hits.forEach((h) => void handoffs.other(h.target, `${c.what} 改动了你的宪章 (变了的段: ${h.sections.join(" / ")}); 简报里点一句手头活在新规矩下怎么接着干`, CHARTER_HANDOFF_MS).then((r) => {
4340
- if (!r.ok)
4341
- notices.post([h.target], `${c.what} 改动了你的宪章 (系统提示, 变了的段: ${h.sections.join(" / ")}); 自动交接没做成 (${r.reason}) —— 已在跑的你看不见新规矩, 手上这件告一段落后自己${ask}${lag(h.target)}`);
4342
- }));
4343
- return [
4344
- "",
4345
- `⚠ 这次改动进了宪章 (系统提示): 已在跑的 wizard 看不见, 要 handoff 才换上。受影响 ${shown.length} 个: ${shown.map((h) => displayName(h.target)).join(" ")}`,
4346
- ...(shown.length ? ["已自动安排它们交接 (各自写简报 → 原地重开 → 接回简报); 交接不成的会收到提醒, 再自己 handoff"] : []),
4347
- ...(mine ? [`**你自己也在其中** (变了的段: ${mine.sections.join(" / ")}): 这一轮说完就会自动交接${lag(mine.target)}`] : []),
4348
- ].join("\n");
4349
- }
4350
- const others = hits.filter((h) => h.target !== c.self);
4351
- others.forEach((h) => notices.post([h.target], `${c.what} 改动了你的宪章 (系统提示, 变了的段: ${h.sections.join(" / ")}); 已在跑的你看不见新规矩 —— ${ask}${lag(h.target)}`));
4539
+ const mine = hits.find((h) => h.target === c.self);
4352
4540
  return [
4353
4541
  "",
4354
- `⚠ 这次改动进了宪章 (系统提示): 已在跑的 wizard 看不见, 要 handoff 才换上。受影响 ${shown.length} 个: ${shown.map((h) => displayName(h.target)).join(" ")}`,
4355
- ...(shownOthers.length ? [`已给其余 ${shownOthers.length} 个各挂一条提醒 (随它们下一条消息到), 让它们${ask}`] : []),
4356
- ...(mine ? [`**你自己也在其中** (变了的段: ${mine.sections.join(" / ")}): 这一轮收尾后${ask}${lag(mine.target)}`] : []),
4542
+ `⚠ 这次改动进了宪章 (系统提示)。${ask}。受影响 ${shown.length} 个: ${shown.map((h) => displayName(h.target)).join(" ")}`,
4543
+ ...(mine ? [`**你自己也在其中** (变了的段: ${mine.sections.join(" / ")})`] : []),
4357
4544
  ].join("\n");
4358
4545
  };
4359
4546
  // 所有会动宪章的写入 (config_set、人 `/name`、name_chat、自动补名) 共用这一个守卫: 写前写后各渲染一遍
4360
- // 挂在 pane 上的 wizard 的宪章, 变了的、pane 还活着的自动交接 (hot) 或挂提醒 (reload, 见 announceCharterImpact)。
4547
+ // 挂在 pane 上的 wizard 的宪章, 变了的、pane 还活着的挂提醒 (见 announceCharterImpact)。
4361
4548
  // 快照到写完之间不能有 await —— 别人的写入插进来, 前后就对不上了。已经写了: 比对失败只少一句提醒, 不能让调用方以为没写而重发。
4362
4549
  guardCharter = (write, change) => {
4363
4550
  const targets = attachedWizards();
@@ -4368,7 +4555,7 @@ const main = async () => {
4368
4555
  return { r, note: Promise.resolve("") };
4369
4556
  const fail = (e) => {
4370
4557
  log.warn({ err: errText(e), what: c.what }, "宪章影响比对失败");
4371
- return `\n(宪章影响没比对出来: ${errText(e)} —— 改的若是进宪章的内容, 受影响的 wizard 要 handoff 才换上)`;
4558
+ return `\n(宪章影响没比对出来: ${errText(e)} —— 改的若是进宪章的内容, 受影响的 wizard 需要显式 /new 才载入;handoff /clear 不刷新系统提示)`;
4372
4559
  };
4373
4560
  try {
4374
4561
  const hits = charterImpact(before, (c.after ?? charterSnapshot)(targets));
@@ -4700,15 +4887,20 @@ const main = async () => {
4700
4887
  const rec = tasks.get(taskId);
4701
4888
  const owner = rec ? ownerOf(rec) : target;
4702
4889
  const ownerName = settleName(wizards, chatNameOf(cfg, owner), owner) || "task";
4703
- const slotR = await claimSlot(base, "", `${ownerName}-task-${taskId.slice(0, 4)}`);
4704
- if (!guard())
4890
+ const slotR = await claimSlot(base, "", [], undefined, `${ownerName}-task-${taskId.slice(0, 4)}`);
4891
+ if (!guard()) {
4892
+ if (slotR.ok)
4893
+ releaseSlot(slotR);
4705
4894
  return { ok: false, reason: "scheduled runner withdrawn during slot selection" };
4895
+ }
4706
4896
  if (!slotR.ok)
4707
4897
  return { ok: false, reason: "起白板 wizard 失败: 挑不出槽位" };
4708
4898
  const runner = slotR.target;
4709
- const allowed = () => guard() && planStore.allowExternal(runner, wizards.get(runner)?.name);
4710
- if (!allowed())
4899
+ const allowed = () => ownsSlot(slotR) && guard() && planStore.allowExternal(runner, wizards.get(runner)?.name);
4900
+ if (!allowed()) {
4901
+ releaseSlot(slotR);
4711
4902
  return { ok: false, reason: "workflow plan reserves task runner" };
4903
+ }
4712
4904
  const info = m.sessionInfo(target);
4713
4905
  const why = busy ? ` (起因: ${displayName(target)} 当时正忙)` : "";
4714
4906
  wizards.upsert(runner, {
@@ -4717,16 +4909,20 @@ const main = async () => {
4717
4909
  voiceOf: owner,
4718
4910
  bornAt: Date.now(),
4719
4911
  });
4720
- const runnerName = wizards.rename(runner, `${ownerName}-task-${taskId.slice(0, 4)}`);
4721
- const spawned = await m.newSession(runner, runnerName, info?.cli, { cwd: info?.cwd, model: info?.model, silent: true, guard: allowed });
4912
+ const runnerName = slotR.name;
4913
+ const spawned = await m.newSession(runner, runnerName, info?.cli, { cwd: info?.cwd, model: info?.model, silent: true, guard: allowed })
4914
+ .catch((e) => ({ ok: false, reason: `spawn threw: ${String(e)}` }));
4722
4915
  if (!spawned.ok) {
4723
- wizards.drop(runner);
4916
+ releaseSlot(slotR);
4724
4917
  return { ok: false, reason: `起白板 wizard 失败: ${spawned.reason ?? "unknown"}` };
4725
4918
  }
4919
+ if (!ownsSlot(slotR))
4920
+ return { ok: false, reason: "task runner identity replaced during spawn" };
4921
+ finishSlot(slotR);
4726
4922
  if (!allowed()) {
4727
- if (m.sessionInfo(runner)?.sessionId === spawned.sessionId) {
4923
+ if (ownsSlot(slotR) && m.sessionInfo(runner)?.sessionId === spawned.sessionId) {
4728
4924
  await m.killPane(runner);
4729
- wizards.drop(runner);
4925
+ releaseSlot(slotR);
4730
4926
  }
4731
4927
  return { ok: false, reason: "scheduled runner withdrawn after spawn" };
4732
4928
  }
@@ -4744,7 +4940,7 @@ const main = async () => {
4744
4940
  ? { guard: allowed, from: { kind: "task", taskId }, channel: "", envelope: renderTaskEnvelope(taskId, { event: turn }) }
4745
4941
  : { guard: allowed, fromChat: true, from: { kind: "task", taskId }, envelope: renderTaskEnvelope(taskId) });
4746
4942
  if (!inj.ok) {
4747
- wizards.drop(runner);
4943
+ releaseSlot(slotR);
4748
4944
  return inj;
4749
4945
  }
4750
4946
  // 没有人会对这个一次性分身喊 stop_wizard, 只能自己等它闲下来再收。30min
@@ -4759,8 +4955,10 @@ const main = async () => {
4759
4955
  log.warn({ runner, reason: idle.reason }, "task runner: 30min 未闲下来, 放弃自动回收");
4760
4956
  return;
4761
4957
  }
4958
+ if (!ownsSlot(slotR) || m.sessionInfo(runner)?.sessionId !== spawned.sessionId)
4959
+ return;
4762
4960
  await m.killPane(runner);
4763
- wizards.drop(runner);
4961
+ releaseSlot(slotR);
4764
4962
  })();
4765
4963
  return inj;
4766
4964
  };
@@ -4832,6 +5030,7 @@ const main = async () => {
4832
5030
  registry: tasks,
4833
5031
  log: log.child({ mod: "tasks" }),
4834
5032
  inject: scheduledTaskInject,
5033
+ ready: bridge.restored,
4835
5034
  // gate 里的 `sh` 默认就在目标 wizard 此刻的工作区跑 —— 任务文件里不必写死绝对路径。
4836
5035
  cwdOf: (t) => { const c = bridge.getCwd(t); return c.runningCwd || c.pendingCwd || c.defaultCwd; },
4837
5036
  // 没写 target 的日程兜底到排班者那个机器人的默认聊天 (副机器人没配就不放枪, 不冒用主机器人)。
@@ -4871,9 +5070,14 @@ const main = async () => {
4871
5070
  }
4872
5071
  setTimeout(() => void sweep().catch((e) => log.warn({ err: e.message }, "pane cap sweep failed")), 15_000).unref();
4873
5072
  }
5073
+ let shuttingDown = false;
4874
5074
  const shutdown = async (signal) => {
5075
+ if (shuttingDown)
5076
+ return;
5077
+ shuttingDown = true;
4875
5078
  log.info({ signal }, "shutdown signal");
4876
- events.stop();
5079
+ // Pause delivery, but persist arrivals while transports/in-flight handlers drain.
5080
+ events.drain();
4877
5081
  stopSweep();
4878
5082
  scheduler.stop();
4879
5083
  steward?.stop();
@@ -4888,6 +5092,7 @@ const main = async () => {
4888
5092
  setTimeout(() => process.exit(0), 1500).unref();
4889
5093
  await new Promise((r) => setTimeout(r, 200));
4890
5094
  await Promise.allSettled([ws.shutdown(), http.close()]);
5095
+ events.stop();
4891
5096
  process.exit(0);
4892
5097
  };
4893
5098
  http.register("POST /shutdown", (_req, res) => {