wezard 2.7.3 → 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,11 +205,13 @@ 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)
@@ -230,8 +233,18 @@ const main = async () => {
230
233
  // (贴进运行中的一轮记成 queued 轮, 终句照样归到这条 event —— 见 brief 的 seal/bindLine)。
231
234
  // 这一句曾是要 idleNow 的, 也就是那个反复 5s 超时的探测源头: 一超时就退避, 把同一 target
232
235
  // 后面排着的每一笔 (含后到的人话) 一起钉住十几分钟。交接期间照旧等 —— 那几秒 pane 真是死的。
233
- 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),
234
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
+ }
235
248
  if (e.payload.peer)
236
249
  return peerEvents.inject(e, boundary);
237
250
  if (!bridge.hasMirrorTarget(e.target)) {
@@ -275,7 +288,8 @@ const main = async () => {
275
288
  }
276
289
  return { consumed: false };
277
290
  },
278
- recovered: (e) => bridge.recoverEventFinal(e),
291
+ recovered: (e) => { if (!handoffFinalSuppressed(e))
292
+ bridge.recoverEventFinal(e); },
279
293
  // T4: 终句 → 路由表。onFinal 连同投递一起做 —— 定论先落盘 (`answer`), 再按 receiver
280
294
  // 投出去 (§9.3/§9.4); 实现在 mirror 大块里 (要 jobs / 信封 / 事件泵)。
281
295
  final: (e, verdict) => deliverFinal?.(e, verdict),
@@ -289,7 +303,7 @@ const main = async () => {
289
303
  uncertain: result.state === "uncertain" ? result.reason ?? "Weixin send ACK unknown" : undefined,
290
304
  failed: result.state === "failed" ? result.reason ?? "Weixin final rejected" : undefined };
291
305
  }
292
- 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;
293
307
  const ack = await responseTracker.sendFrozen(chat, d.chunks[n], stream, events.running);
294
308
  return ack && typeof ack === "object" && "errcode" in ack && ack.errcode === 0 ? { ack: JSON.stringify(ack) }
295
309
  : { ack: JSON.stringify(ack), uncertain: "final transport returned no explicit success ACK" };
@@ -686,24 +700,25 @@ const main = async () => {
686
700
  return;
687
701
  }
688
702
  const foreign = base !== baseOfKey(self);
689
- 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]);
690
704
  if (!slotR.ok) {
691
705
  json(res, slotR.status, slotR.body);
692
706
  return;
693
707
  }
694
- const { target, slot: tag } = slotR;
695
- if (!planStore.allowExternal(target, wizards.get(target)?.name ?? tag)) {
696
- json(res, 409, { ok: false, reason: "workflow plan reserves this executor" });
697
- return;
698
- }
699
- const existed = !!wizards.get(target);
700
- const name = wizards.rename(target, normalizeTag(String(body.name ?? body.tag ?? "")) || tag);
708
+ const { target, name } = slotR;
701
709
  const model = (body.model ?? "").toString().trim();
702
710
  log.child({ mod: "mirror", sub: "sessions-new", target }).info({ self, cwd, foreign, cli: body.cli, model }, "spawning peer session");
703
711
  // silent: 新 wizard 就位不进群 (生命周期事件), 同群的 wizard 从名册增量里得知。
704
- const r = await m.newSession(target, name, body.cli, { cwd, model, silent: true, guard: () => planStore.allowExternal(target, wizards.get(target)?.name ?? name) });
705
- if (!r.ok && !existed)
706
- 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);
707
722
  if (r.ok)
708
723
  postRoster(base, [target, self], { op: "born", name: `.${name}`, by: displayName(self), cwd: r.cwd }, subjectOf(wizards.get(target)));
709
724
  json(res, r.ok ? 200 : 500, r.ok
@@ -746,16 +761,7 @@ const main = async () => {
746
761
  const r = await m.injectText(target, text, undefined, { guard });
747
762
  json(res, r.ok ? 200 : 500, r);
748
763
  });
749
- // One-shot project switch: bind the chat-wide cwd AND apply it right here.
750
- // 活会话走交接 (handoff): 守护进程请它写简报 → 重开 (pendingCwd 在这一步被消费,
751
- // 落进新目录, 宪章随之重渲染) → 简报贴回, 上下文跟着过去, 「📂 当前项目」气泡
752
- // 照发 (announce —— 那是人看得见的回执)。一律 fire-and-forget: 调用方常常就是被换
753
- // 的那个, 它得先拿到这份应答、把这一轮说完, 交接才动得了手 (other 的第一拍就是等它
754
- // 闲)。交接没做成 → 捎一条提醒给它, pendingCwd 仍排着队, 人手 /new 照样完成切换
755
- // (若它本就在交接中, 那场交接的重开同样会消费这笔 pendingCwd)。冷会话 (pane 不在)
756
- // 没有可交接的上下文, 按老路直接新开。Resolved target
757
- // precedence: explicit body.target → sessionId-derived (so MCP can omit
758
- // it) → tmuxPane (stable across /clear) → cfg.defaultChat.
764
+ // 工作目录属于 CLI 进程; 活会话只记录 pending, 必须由人显式 /new 应用。
759
765
  http.register("POST /mirror/workspace", async (req, res) => {
760
766
  const body = (await readBody(req));
761
767
  const cwd = (body.cwd ?? "").toString();
@@ -785,12 +791,8 @@ const main = async () => {
785
791
  return;
786
792
  }
787
793
  if ((await liveOf([target])).length) {
788
- const focus = `工作区切到 ${set.pendingCwd}: 简报里点一句手头活在新目录下怎么接着干`;
789
- void handoffs.other(target, focus, CHARTER_HANDOFF_MS, { announce: true }).then((r) => {
790
- if (!r.ok)
791
- notices.post([target], `换工作区到 ${set.pendingCwd} 的自动交接没做成: ${r.reason} —— 切换仍排着队, 手上这件告一段落后自己 \`handoff({brief})\`, 或让人在群里 /new 一下`);
792
- });
793
- 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 不会应用它" });
794
796
  return;
795
797
  }
796
798
  // Same shape as inbound's /new: windowName = tag for tagged sessions,
@@ -977,6 +979,7 @@ const main = async () => {
977
979
  && !a.canceled && a.acceptance !== "rejected" && jobs.get(r.plan.job)?.status === "open" && ["running", "needs-input", "errored"].includes(a.execution)
978
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");
979
981
  };
982
+ handoffAllowed = (target) => planContinuationAllowed(target) && !planStore.reservation(target, wizards.get(target)?.name);
980
983
  /** 一笔活第几个来回 (§3.2): 同一行上已经注入过的续问数 + 1 —— 现算, 不落第二份数据。
981
984
  * 续问的注入事件 id 是 `route:<event>:r<legs>`, 所以从它自己就能数回来 (reload 也在盘上)。 */
982
985
  const legsOf = (event) => events.ids().reduce((n, id) => {
@@ -1032,21 +1035,67 @@ const main = async () => {
1032
1035
  // 闸、回执清点与断点续做在那边。
1033
1036
  /** 情景记忆 (memory/episodes/<name>.jsonl) 按名字归档; 默认会话没名字时退回 slot / "default"。 */
1034
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
+ };
1035
1049
  const handoffs = createHandoffs({
1036
- 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
+ },
1037
1067
  sessionId: (t) => m.sessionInfo(t)?.sessionId ?? "",
1038
- restart: (t, p) => restartFresh(t, p),
1068
+ restart: (t, p, submitted) => clearForHandoff(t, p, submitted),
1039
1069
  // 简报是新会话的开场白, 但它不挂任何信封 (服务不着哪一笔) —— 交接前欠着的那几笔由
1040
1070
  // 「继续 <event>」逐笔续答 (见 resume), 别把简报那一轮的回复当成回执。
1041
- inject: async (t, text, channel) => {
1071
+ inject: async (t, text, channel, id, timeoutMs, clear) => {
1042
1072
  if (!planContinuationAllowed(t))
1043
1073
  return { ok: false, reason: "workflow continuation withdrawn" };
1044
1074
  if (planStore.reservation(t, wizards.get(t)?.name))
1045
1075
  return { ok: false, reason: "workflow handoff requires the original attempt" };
1046
- 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" };
1047
1095
  },
1048
1096
  channelOf: m.currentChannel,
1049
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; },
1050
1099
  // 欠账不落盘在交接里: 路由表按 target 记着它欠谁、欠哪几笔, 换会话不影响 (T8)。
1051
1100
  owed: (t) => router.owedBy(t).filter((r) => r.status === "open").map((r) => r.event),
1052
1101
  resume: async (t, event, at) => injectResume(t, event, at),
@@ -1056,15 +1105,20 @@ const main = async () => {
1056
1105
  // 交接没做成: 切断那一轮扣下的终句按原行补投 —— 走与轮终同一条 onFinal (行已被合成失败落定的, 照追述规则处理)。
1057
1106
  uncut: (t, ids) => ids.forEach((id) => {
1058
1107
  const e = events.get(id);
1059
- if (!e?.result) {
1060
- log.warn({ target: t, event: id }, "handoff: 扣下的终句已不在事件库, 补投不了");
1108
+ if (!e) {
1109
+ log.warn({ target: t, event: id }, "handoff: 扣下的事件已不在事件库, 补投不了");
1061
1110
  return;
1062
1111
  }
1112
+ // 超时可能发生在旧轮尚未产出 final 时; 先释放耐久标记, 让它稍后的真终句正常落定。
1113
+ events.restoreHandoffFinal(id);
1114
+ if (!e.result)
1115
+ return;
1063
1116
  log.info({ target: t, event: id, serves: e.serves }, "handoff: 交接没做成, 补投切断那一轮的终句");
1064
1117
  settleFinal(e, { text: e.result.body });
1118
+ m.recoverEventFinal(events.get(id));
1065
1119
  }),
1066
1120
  log,
1067
- store: loadJsonMap(cfg.wrc.mirror.handoffsFile),
1121
+ store: handoffStore,
1068
1122
  archive: (e) => {
1069
1123
  if (!e.brief.trim())
1070
1124
  return;
@@ -1078,7 +1132,9 @@ const main = async () => {
1078
1132
  * `serves` 指回那一行 (注入事件 id `route:<event>:resume`), 于是它的终句照旧落定、
1079
1133
  * 照旧投给 receiver —— 换会话丢掉的只是 transcript, 不是这笔账。
1080
1134
  * id 按这一场交接的起点 (`at`) 区分: 同一场 reload 重放判重不重投, 换一场才配一条新的。 */
1081
- 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" };
1082
1138
  const row = router.get(event);
1083
1139
  if (!row || row.owner !== t || row.status !== "open")
1084
1140
  return { ok: false, reason: "no open row to resume" };
@@ -1086,7 +1142,7 @@ const main = async () => {
1086
1142
  const waiting = r.kind === "wizard" ? r.to : baseOfKey(r.channel); // 等着它交代的那一方 (信封挂在它名下)
1087
1143
  const channel = r.kind === "chat" ? r.channel : ""; // 结论要落回群里时才带频道 (§9.4)
1088
1144
  const base = `route:${event}:resume`;
1089
- const id = events.known(base) ? `${base}:${at}` : base;
1145
+ const id = `${base}:${at}`;
1090
1146
  try {
1091
1147
  events.accept({
1092
1148
  id, identity: JSON.stringify([t, event, "resume", at]),
@@ -1101,7 +1157,15 @@ const main = async () => {
1101
1157
  envelope: envelopeFor(waiting, channel, { ...(row.asker ? { asker: row.asker } : {}) }),
1102
1158
  } },
1103
1159
  });
1104
- 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" };
1105
1169
  }
1106
1170
  catch (e) {
1107
1171
  return { ok: false, reason: errText(e) };
@@ -1162,11 +1226,8 @@ const main = async () => {
1162
1226
  return "";
1163
1227
  // 计划节点 / 停放放行: 那一笔在 plan 发起 / 停放那一刻就定了; 其余看本轮服务谁。
1164
1228
  const f = p.serves ?? servesOfTurn(p.self);
1165
- if (p.op === "transfer") {
1166
- if (f && router.get(f)?.owner === p.self)
1167
- router.commit(ops.move({ event: f, owner: p.target }));
1229
+ if (p.op === "transfer")
1168
1230
  return f ?? "";
1169
- }
1170
1231
  const parent = p.op === "self" ? f : undefined;
1171
1232
  return openRow({
1172
1233
  ...(parent ? { event: `${parent}.${(router.get(parent)?.seq ?? 0) + 1}` } : {}),
@@ -1176,6 +1237,36 @@ const main = async () => {
1176
1237
  ...(p.job ? { job: p.job } : {}), ...(p.asker ? { asker: p.asker } : {}), ...(p.after?.length ? { after: p.after } : {}),
1177
1238
  });
1178
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
+ };
1179
1270
  /** 那一笔行上的接收快照 (挂起账投影用): 结论收给谁。没有行 = 不知道 (还是记 nobody)。 */
1180
1271
  const receiverOfEvent = (event) => (event ? router.get(event)?.receiver : undefined);
1181
1272
  /** 挂起账里那一行的 owner = 派活方 = 这一笔的接收方。收给群的根行 (人话 / 定时) 不进账 ——
@@ -1258,13 +1349,18 @@ const main = async () => {
1258
1349
  };
1259
1350
  /** 一份定论的投递 (账本已记过): 投给升级后的收方 `esc` (没有 = 行上的 receiver)。群收方只落
1260
1351
  * `hand`; wizard 收方 (含 Dead Letter 退到的群默认 wizard) 进收件箱, id = `inbox`。 */
1261
- const postRoute = (row, out, esc, inbox) => {
1352
+ const postRoute = (row, out, esc, inbox, retire) => {
1262
1353
  const event = row.event;
1263
1354
  const receiver = esc?.receiver ?? row.receiver;
1264
1355
  const via = (esc?.via ?? []).map(displayName);
1265
1356
  // 升级落在 chat 根 = Dead Letter: 退到那个群, 由它的默认 wizard 收下这份结论。
1266
1357
  const dead = receiver.kind === "chat" && !!esc;
1267
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);
1268
1364
  router.commit(ops.hand({ event, inbox: `chat:${receiver.message}:${out.rev}` }));
1269
1365
  return;
1270
1366
  }
@@ -1304,7 +1400,7 @@ const main = async () => {
1304
1400
  ...(retellTo ? { retellTo } : {}),
1305
1401
  ...(row.late ? { retold: true } : {}) }),
1306
1402
  } },
1307
- });
1403
+ }, retire);
1308
1404
  router.commit(ops.hand({ event, inbox }));
1309
1405
  };
1310
1406
  /** 收件箱里那份回执投不到了 (`gone`: 收方没有绑定, 按 store 也恢复不了) —— 投进去时它还在
@@ -1330,10 +1426,10 @@ const main = async () => {
1330
1426
  return;
1331
1427
  }
1332
1428
  const rev = e.id.slice(`rcpt:${event}:`.length).split("~")[0];
1333
- const prefix = `rcpt:${event}:${rev}~`;
1334
- const inbox = `${prefix}${events.ids().filter((id) => id.startsWith(prefix)).length + 1}`;
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)}`;
1335
1431
  log.info({ id: e.id, target: e.target, event, to: esc?.receiver ?? row.receiver, inbox }, "receipt reroute: 收方已不在, 改投");
1336
- postRoute(row, { status: from.status, body: e.payload.text, rev }, esc, inbox);
1432
+ postRoute(row, { status: from.status, body: e.payload.text, rev }, esc, inbox, e.id);
1337
1433
  };
1338
1434
  /** 一条路由 op: 先落盘, 再投该投的 (need / error 带正文入箱; answer 按 receiver 投递)。这里
1339
1435
  * 只管 `re` (`mark open`) 把那一行拉回在飞 —— 账本与工单跟着 deliverRoute 走。 */
@@ -1356,6 +1452,8 @@ const main = async () => {
1356
1452
  * 也最多再多打回一次)。**这是「轮终 evidence → onFinal」那条主触发链的落点**: 没有它,
1357
1453
  * answer 就只能靠 sweeper / 级联取消 / boot 重放合成, 谁答完都不会有回执。 */
1358
1454
  deliverFinal = (e, verdict) => {
1455
+ if (handoffFinalSuppressed(e) || !ownsReplyEvent(e))
1456
+ return;
1359
1457
  // 同一轮的终句会递进来两次: result() 当场递一次, 泵见 complete 未 finalized 再补判一次
1360
1458
  // (message-events)。被打回那一轮的重放不是补交 —— 丢掉; 否则 bounced 已记着这一笔,
1361
1459
  // 第二次就把被打回的原文照收成 done (打回的续问还没注入, 定论先落了)。
@@ -1364,9 +1462,9 @@ const main = async () => {
1364
1462
  // 自我交接切断的那一轮: 收尾那句不是欠着的那几笔的答案 —— 行留 open, 交给新会话的「继续 <event>」。
1365
1463
  // 扣下记在这场交接上: 交接没做成 (新会话没起来), 它就是那几笔仅有的交代, 由 uncut 补投。
1366
1464
  if (cuttingTurn(e.target)) {
1465
+ handoffs.cut(e.target, e.id);
1466
+ events.suppressHandoffFinal(e.id);
1367
1467
  log.info({ event: e.id, target: e.target, serves: e.serves }, "handoff: 交接切断的那一轮终句不落定");
1368
- if (!verdict.error)
1369
- handoffs.cut(e.target, e.id);
1370
1468
  return;
1371
1469
  }
1372
1470
  settleFinal(e, verdict);
@@ -1383,6 +1481,11 @@ const main = async () => {
1383
1481
  rebound(row, why, e.id);
1384
1482
  return;
1385
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);
1386
1489
  plans.forEach(commitRoute);
1387
1490
  };
1388
1491
  // ── 新路由表 (T6): 失败级联 ─────────────────────────────────────────────
@@ -1554,57 +1657,98 @@ const main = async () => {
1554
1657
  * - 撞上的 wizard 静默超过一天 (不在 `keep` 里) → 顶掉它, 名字归新的;
1555
1658
  * - 其余 (还新鲜的) → 409, 附上它的死活, 调用方据此换名或先收掉它。
1556
1659
  * 槽位 (key 里的 `#k`) 只是内部 id: 默认取名字本身, 被占了就加序号, 与名字无关。 */
1557
- 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) => {
1558
1675
  const asked = normalizeTag(want);
1559
1676
  const denied = { ok: false, status: 409, body: { ok: false, reason: "workflow plan reserves this executor" } };
1560
- const candidate = asked || normalizeTag(fallback) || "wizard";
1561
- if (!allowed(keyOf(base, candidate), candidate))
1562
- return denied;
1563
- const clash = asked ? wizards.byName(asked) : undefined;
1564
- // 名字全机唯一, 撞上别的机器人的 wizard 照样拒 —— 但不顶掉它、不复用它的槽, 也不报它的死活:
1565
- // 那边的 wizard 在这个机器人眼里不存在, 只剩「名字被占了」这一个事实。
1566
- if (clash && !sameBot(base, clash.target)) {
1567
- return { ok: false, status: 409, body: { ok: false, reason: `名字 '.${clash.name}' 已被占用 (名字全局唯一) —— 换个名字`, name: clash.name } };
1568
- }
1569
- if (clash && !allowed(clash.target, clash.name))
1570
- return denied;
1571
- const bound = !!clash && m.chatTargets(baseOfKey(clash.target)).includes(clash.target);
1572
- if (clash && !bound && baseOfKey(clash.target) === base && tagOfKey(clash.target)) {
1573
- return { ok: true, target: clash.target, slot: tagOfKey(clash.target) };
1574
- }
1575
- if (clash && !(await displace(asked, keep))) {
1576
- const info = bound ? (await m.peers(clash.target)).find((p) => p.target === clash.target) : undefined;
1577
- const alive = info?.paneAlive ?? false;
1578
- const idleForMs = info?.lastActivity ? Date.now() - info.lastActivity : undefined;
1579
- const idleDesc = idleForMs !== undefined ? `静默 ${Math.round(idleForMs / 60000)} 分钟` : "从没动过";
1580
- const advice = alive
1581
- ? "它的 pane 还活着 —— 除非这就是同一件事的延续, 否则换个名字重新生, 别把不相关的活塞给一个已经有职责的 wizard"
1582
- : bound
1583
- ? `pane 已经不在了 (${idleDesc}), 真要复用这个名字就先 stop_wizard({name:"${clash.name}", mode:"end"}) 收掉, 再用同一个名字重新生 —— 拿到干净的上下文; 别直接 send_peer 唤醒它接手, 它会带着上一件事的记忆答你这件`
1584
- : "这个名字被一个一天内还活动过、眼下没有会话的 wizard 占着 —— 换个名字 (它静默满一天后名字会自动让出)";
1585
- return {
1586
- ok: false,
1587
- status: 409,
1588
- body: {
1589
- ok: false,
1590
- reason: `名字 '.${clash.name}' 已经属于一个${alive ? "活着的" : "不再活跃的"} wizard (名字全局唯一) —— ${advice}`,
1591
- name: clash.name,
1592
- address: clash.name,
1593
- alive,
1594
- busy: info?.busy ?? false,
1595
- idleForMs,
1596
- },
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 };
1597
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);
1598
1751
  }
1599
- if (!allowed(keyOf(base, candidate), candidate) || (clash && !allowed(clash.target, clash.name)))
1600
- return denied;
1601
- const taken = new Set([
1602
- ...m.chatTargets(base).map(tagOfKey),
1603
- ...wizards.all().filter((w) => baseOfKey(w.target) === base).map((w) => tagOfKey(w.target)),
1604
- ].filter(Boolean));
1605
- const slot = uniqueTag(asked || normalizeTag(fallback) || "wizard", taken);
1606
- const target = keyOf(base, slot);
1607
- return allowed(target, asked || slot) ? { ok: true, target, slot } : denied;
1608
1752
  };
1609
1753
  // ── 给人看的消息 ──────────────────────────────────────────────────
1610
1754
  // send_peer 把话塞进另一个 agent 的输入框 (驱动它干活); 这条把话贴进一个聊天
@@ -1934,6 +2078,7 @@ const main = async () => {
1934
2078
  const attempt = p.attemptId ? workflow.byTurn(e.speaker, p.from.event ?? "") : undefined;
1935
2079
  // 单已收但这一笔的行还 open = 收工前就派出去的活, 照投 (见 close_job)。
1936
2080
  return planStore.healthy() && !e.canceled && (!p.from.receipt || planContinuationAllowed(e.target))
2081
+ && (!p.transferred || !e.serves || events.latestTransfer(e.serves)?.id === e.id)
1937
2082
  && (!p.job || jobs.get(p.job)?.status === "open" || (!!e.serves && router.get(e.serves)?.status === "open")) && !attempt?.canceled
1938
2083
  && (!p.after?.length || prereqsOf(e.speaker, p.after).every((r) => r.state === "done" || r.state === "pending"));
1939
2084
  },
@@ -1941,6 +2086,15 @@ const main = async () => {
1941
2086
  if (e.execution !== "queued")
1942
2087
  return;
1943
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
+ }
1944
2098
  // 旧 watcher 的 continuation 代数已退役 (§9.4): 收口由事件自己的 serves 决定。
1945
2099
  if (p.attemptId)
1946
2100
  accountWorkflow(workflow.byTurn(e.speaker, p.from.event ?? ""));
@@ -1999,7 +2153,7 @@ const main = async () => {
1999
2153
  if (p.attemptId && p.tell)
2000
2154
  workflow.intent(p.attemptId, p.tell);
2001
2155
  const raw = await m.injectText(e.target, e.payload.text, undefined, {
2002
- 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:"),
2003
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),
2004
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))),
2005
2159
  eventId: e.id,
@@ -2015,12 +2169,14 @@ const main = async () => {
2015
2169
  // 没有行 (老事件 / 不带 serves 的注入) → 照旧放行。
2016
2170
  // 已落定的那一笔只放追述 (§4.3): 判据与 onFinal 的 retellOps 同源 (router.finalReleased)。
2017
2171
  // 调用方可能拿着 result 落定前的快照, 正文按事件库现读。
2172
+ m.setEventOwnerGate(ownsReplyEvent);
2018
2173
  m.setEventFinalGate((e) => {
2019
- if (e.canceled || e.interjected)
2174
+ if (handoffFinalSuppressed(e))
2175
+ return false;
2176
+ if (e.canceled || e.interjected || !ownsReplyEvent(e))
2020
2177
  return false;
2021
2178
  return router.finalReleased(e.target, e.serves, (events.get(e.id) ?? e).result?.body ?? "");
2022
2179
  });
2023
- m.setEventHandedAway((e) => { const row = e.serves ? router.get(e.serves) : undefined; return !!row && row.owner !== e.target; });
2024
2180
  // boot 的路由表侧恢复 (T8): held 重查前置、answered 重投一次; 二者幂等, 与 events.resume
2025
2181
  // 重放的注入/回执撞上无害。
2026
2182
  void m.restored.then(() => { events.resume(); settleHeld(); replayAnswered(); });
@@ -2282,11 +2438,11 @@ const main = async () => {
2282
2438
  ...(afters.length ? { after: afters } : {}), ...(plan.serves ? { serves: plan.serves } : {}) });
2283
2439
  /** 建完行才发现不该派 (计划许可 / 预算在等待期间变了): 把刚开的那一行收掉 —— 不然它一直
2284
2440
  * 挂在 owedBy 上, 名册里也永远「欠着」一件没人干的活。 */
2285
- const dropRow = () => { if (newRow && serves)
2441
+ const dropRow = () => { if (newRow && serves && plan.op !== "transfer")
2286
2442
  router.commit(ops.gc({ event: serves })); };
2287
2443
  // 注入事件的 id 就是对那一笔的引用 (§9.3): 派活 = `route:<event>`, 续问 = `route:<event>:r<legs>`;
2288
2444
  // 不开行 (none) 时没有行可指, 退回一句一号的合成 id。
2289
- 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}`;
2290
2446
  let attemptId;
2291
2447
  const beginNode = (queued) => {
2292
2448
  if (!planAllowed() || (planned && !planned.alive()))
@@ -2306,7 +2462,8 @@ const main = async () => {
2306
2462
  // urgent 只打断你自己派的那一轮 (或它自己的回执轮): 手上若是别的 wizard / 人 / 定时任务的活,
2307
2463
  // 打断就误伤了那件 —— 降成 normal, 等它这一轮做完再投。
2308
2464
  const others = asked === "urgent" && wasBusy ? await othersTurn(target, self) : undefined;
2309
- 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;
2310
2467
  if (canceledBeforeDispatch() || !actorStillValid()) {
2311
2468
  dropRow();
2312
2469
  return { status: 409, body: { ok: false, reason: "caller authority or dispatch permit changed before acceptance" } };
@@ -2325,7 +2482,7 @@ const main = async () => {
2325
2482
  }
2326
2483
  let accepted;
2327
2484
  try {
2328
- 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,
2329
2486
  payload: { text, peer: {
2330
2487
  from: { kind: "peer", from: self, event: serves, ...(jobId ? { job: jobId } : {}), ...(isPublic ? { public: true } : {}), ...(handedChat ? { transferred: true } : {}), ...(asker ? { asker } : {}) },
2331
2488
  envelope: envelopeFor(self, channel, { asker, legs, ...(legs > 1 ? { re: true } : {}),
@@ -2333,14 +2490,12 @@ const main = async () => {
2333
2490
  ...(wantReceipt ? { tell } : {}), job: jobId, kind, attemptId, after: afters,
2334
2491
  role: roleParam(body.role), free: freed,
2335
2492
  ...(plan.op === "transfer" ? { transferred: true } : {}),
2336
- } } });
2493
+ } } }, plan.op === "transfer");
2337
2494
  }
2338
2495
  catch (error) {
2496
+ dropRow();
2339
2497
  return { status: 409, body: { ok: false, eventId, reason: errText(error) } };
2340
2498
  }
2341
- // 交出的是人问的那一笔: 那句话的气泡跟着行走, 接手方开轮时接过去续写 (不另开新气泡)。
2342
- if (handedChat && serves)
2343
- m.handBubble(self, serves, eventId, target);
2344
2499
  // `re` 把那一笔从 need / error 拉回 open (§3.2 的 need→open / error→open): 行状态是「这份中途态
2345
2500
  // 已经报过了」的去重记号 —— 不拉回去, owner 下一轮交同一个中途态就被静默吞掉 (往返断在这里)。
2346
2501
  if (reTarget && (reTarget.status === "need" || reTarget.status === "error"))
@@ -2530,30 +2685,19 @@ const main = async () => {
2530
2685
  ? { ok: true, ...results[0] }
2531
2686
  : { ok: true, need, done, satisfied: done >= need, results });
2532
2687
  });
2533
- // 交接的「重开」一步: 走 /new (杀旧 pane、起新进程) 而不是往原 pane 注入 /clear。
2534
- // /clear 只换会话不换进程 —— MCP 子进程、charter 都还是旧的, 新工具拿不到; 镜像
2535
- // 还得靠 sid 轮换探测才跟得上。CLI 使用 cfg 的默认 backend; cwd 照旧沿用
2536
- // (排着队的 pendingCwd —— set_workspace 先落的那笔 —— 在这一步被消费, 重开落进新目录)。
2537
- // 同 backend 时模型也沿用, 管家除外 ——
2538
- // 它换上此刻的 router 档 (newSession 里定), 改了 models.router 后交接一次就是让它生效的那一下。
2539
- // `announce` (换工作区) 时那条「📂 当前项目」气泡照发 —— 那是人看得见的回执; 普通交接静默。
2540
- const restartFresh = (target, p) => {
2541
- const info = m.sessionInfo(target);
2542
- const cli = cfg.wrc.defaultCli ?? primary.name;
2543
- const model = stewardTierOf(cfg, target) || info?.cli !== cli ? undefined : info?.model || undefined;
2544
- if (!planContinuationAllowed(target))
2545
- return Promise.resolve({ ok: false, reason: "workflow continuation withdrawn" });
2546
- 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));
2547
2695
  };
2548
- // POST /handoff — 交接一个 pane 的会话给一个全新会话。先让目标会话把当前工作
2549
- // 压成一份"零上下文也能接手"的交接简报,等它写完并抓取,再 restartFresh 换一个
2550
- // 全新的进程(同名、同 cwd),把简报作为新会话的首条消息贴进去。拒绝对调用方
2551
- // 自身操作(会 deadlock,同 /peers/send)。
2552
2696
  /** 交接自己: 简报由调用方给, 排在它这一轮说完之后 (见 handoff.ts)。/handoff 点名点到
2553
2697
  * 自己与老路由 /wizard/handoff-self 共用。 */
2554
2698
  const handoffSelf = (self, brief) => {
2555
- if (!planContinuationAllowed(self))
2556
- 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" } };
2557
2701
  if (brief.length < 40)
2558
2702
  return { status: 400, body: { ok: false, reason: "brief 太短 —— 要写到零上下文也能接手: 目标 / 已完成 / 当前状态 / 下一步 / 关键文件与坑" } };
2559
2703
  const info = m.sessionInfo(self);
@@ -3431,7 +3575,7 @@ const main = async () => {
3431
3575
  }
3432
3576
  const name = (b.name ?? "").toString().trim();
3433
3577
  const description = (b.description ?? "").toString().trim() || clipLine(task, 120);
3434
- // 空 name 交给 bearAs / claimSlot 按职责自动取名避重, 不当作显式点名复用旧记录。
3578
+ // 空 name 交给 bearAs / claimSlot 按持久序号自动取名避重, 不当作显式点名复用旧记录。
3435
3579
  const tier = taskTier;
3436
3580
  const born = await bearAs(self, { inherit: false, name, description, tier, ...(lead ? { lead: true } : {}) }, undefined, dispatchOrigin?.authority);
3437
3581
  if (born.status !== 200) {
@@ -3555,7 +3699,7 @@ const main = async () => {
3555
3699
  return { status: jc.status, body: { ok: false, reason: jc.reason } };
3556
3700
  }
3557
3701
  }
3558
- 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());
3559
3703
  if (!planBirthAllowed() || (planned && !!b.task))
3560
3704
  return { status: 409, body: { ok: false, reason: "dedicated plan job only permits taskless runner spawn" } };
3561
3705
  // 带 task 派下去 = 派活: 同样过方向闸, 不论带不带 job (新生的目标还不在任何单里, 实际只有 1 / 链深两条会拦)。
@@ -3639,20 +3783,20 @@ const main = async () => {
3639
3783
  } };
3640
3784
  }
3641
3785
  const askedName = String(b.name ?? b.tag ?? "");
3642
- // 没起名: 独立长住的按目录名 (同 /new 与 new_claude_session), 分身按职责的首词。
3643
- const fallback = (detached && b.cwd ? tagFromCwd(b.cwd) : "") || normalizeTag(b.description?.split(/\s+/)[0]) || "clone";
3644
- 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)));
3645
3787
  if (!slotR.ok) {
3646
3788
  return { status: slotR.status, body: slotR.body };
3647
3789
  }
3648
- const { target, slot: tag } = slotR;
3790
+ const { target, name } = slotR;
3791
+ const releaseBirth = () => releaseSlot(slotR);
3649
3792
  // claimSlot 会复用同名的冷记录 (detached 正是「叫回一个老名字」的场景): 生不出来时
3650
3793
  // 只回滚这一次的新记录, 不能把那个老 wizard 的记忆和家谱一起删掉。
3651
- const existed = !!wizards.get(target);
3652
3794
  const forkOf = inherit && source !== self ? source : undefined;
3653
3795
  // 先落身份再 spawn: 宪章是从注册表渲染出来的, 记录不在就渲染出一个无名分身。
3654
- 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();
3655
3798
  return { status: 409, body: { ok: false, reason: "plan canceled/job reserved during spawn preparation" } };
3799
+ }
3656
3800
  wizards.upsert(target, {
3657
3801
  description: (b.description ?? "").toString().trim(),
3658
3802
  // 显式写 undefined: upsert 是合并写, 复用的冷记录里旧的 parent 会留下来。
@@ -3669,15 +3813,16 @@ const main = async () => {
3669
3813
  // 生完才清; 这个进程中途没了, 下次开机由 sweepUnborn 认出并收掉。
3670
3814
  spawning: BOOT_ID,
3671
3815
  });
3672
- const name = wizards.rename(target, normalizeTag(askedName) || tag);
3673
3816
  const charter = charterFor(target, { ...(detached ? {} : { parent: self }), forkOf, inherited: inherit, cli: pick.cli });
3674
3817
  const task = (b.task ?? "").toString().trim();
3675
3818
  // 先盖章再生: 分叉出来的 transcript 里躺着被克隆者的旧回复, 时刻早于这一枚章,
3676
3819
  // wait_peer 才不会把它们当成分身对这件活的答复。
3677
3820
  const taskAt = Date.now();
3678
- const birthGuard = () => (!authority || controlValid(self, authority)) && planBirthAllowed(target, wizards.get(target)?.name ?? name) && (!jobId || jobs.get(jobId)?.status === "open");
3679
- 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();
3680
3824
  return { status: 409, body: { ok: false, reason: "executor reservation changed before spawn" } };
3825
+ }
3681
3826
  const r = await m.cloneSession({
3682
3827
  guard: birthGuard,
3683
3828
  parent: source,
@@ -3693,13 +3838,13 @@ const main = async () => {
3693
3838
  }).catch((e) => ({ ok: false, reason: `spawn threw: ${String(e)}`, inherited: false }));
3694
3839
  // 生不出来就回滚身份 —— 否则名字被一个永远没有会话的记录占住。
3695
3840
  if (!r.ok) {
3696
- if (existed)
3697
- wizards.upsert(target, { spawning: undefined });
3698
- else
3699
- wizards.drop(target);
3841
+ releaseBirth();
3700
3842
  return { status: 500, body: { ok: false, reason: r.reason } };
3701
3843
  }
3702
- 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 }) });
3703
3848
  const kid = briefOf(self, target);
3704
3849
  // 出生不再发群气泡 (人要看的是结论, 不是谁生了谁 —— 过程在 rolepage 里);
3705
3850
  // 同群的 wizard 仍要知道群里多了一个成员。工单里的临时工连这条也省掉。
@@ -3726,18 +3871,21 @@ const main = async () => {
3726
3871
  ? openDispatchRow({ self, target, op: plan.op, deadlineAt, job: jobId, ...(asker ? { asker } : {}), ...(plan.serves ? { serves: plan.serves } : {}) })
3727
3872
  : "";
3728
3873
  dispatchedEvent = serves;
3729
- 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`;
3730
3875
  const birthTell = { from: self, to: target, channel: handedChat, job: jobId, at: taskAt, event: serves, legs: 1, deadlineAt, asker, dispatchKey: eventId };
3731
- events.accept({ id: eventId, target, speaker: self, channel: handedChat, serves, asker, priority: "normal", deadline: deadlineAt,
3732
- payload: { text: task, peer: {
3733
- from: { kind: "peer", from: self, event: serves, ...(jobId ? { job: jobId } : {}), ...(handedChat ? { transferred: true } : {}), ...(asker ? { asker } : {}) },
3734
- envelope: envelopeFor(self, handedChat, { asker, ...(quiet ? { quiet: true } : {}) }) + (b.lead === true ? renderReminder({ wezard: "lead" }, renderLeadPlaybook()) : ""),
3735
- ...(quiet ? {} : { tell: birthTell }),
3736
- job: jobId, kind: "task", spawned: true, role: roleParam(body.role),
3737
- ...(plan?.ok && plan.op === "transfer" ? { transferred: true } : {}),
3738
- } } });
3739
- if (handedChat && serves)
3740
- m.handBubble(self, serves, eventId, target);
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
+ }
3741
3889
  }
3742
3890
  else if (jobId && birthGuard()) {
3743
3891
  jobs.attach(jobId, { target, task: "", spawned: true, ...(roleParam(body.role) ? { role: roleParam(body.role) } : {}) });
@@ -3912,6 +4060,10 @@ const main = async () => {
3912
4060
  json(res, 502, { ok: false, target, reason: done.reason });
3913
4061
  return;
3914
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
+ }
3915
4067
  const detached = end && b.forget ? forgetWizard(target) : [];
3916
4068
  // 打断只是停了它这一轮, 它还在; 只有结束才是名册变了。
3917
4069
  if (end)
@@ -4340,7 +4492,7 @@ const main = async () => {
4340
4492
  ];
4341
4493
  // ── 改配置会不会动到宪章 ─────────────────────────────────────────
4342
4494
  // 宪章是 --append-system-prompt 压进去的, 随进程终身: 改了进宪章的配置, 已在跑的 wizard
4343
- // 看不见, 要 handoff 重开 (重新渲染宪章) 才换上。不按字段清单判 —— 宪章读哪些配置随代码变,
4495
+ // 看不见, 需要显式 /new 才重新渲染宪章; handoff /clear 保留启动时的系统提示。不按字段清单判 —— 宪章读哪些配置随代码变,
4344
4496
  // 清单迟早漏; 改前改后各渲染一遍有会话的 wizard 的宪章, 变了的就是受影响的。
4345
4497
  const charterSections = (text) => new Map(text.split(/^(?=## )/m).map((sec) => [sec.split("\n", 1)[0].replace(/^## /, ""), sec]));
4346
4498
  // 「出生时同群的 wizard」是出生快照 (宪章自己声明名册随时可查), 且渲染途中的懒定居
@@ -4377,49 +4529,22 @@ const main = async () => {
4377
4529
  }
4378
4530
  };
4379
4531
  const charterImpact = (before, after) => [...before].flatMap(([t, a]) => { const secs = changedSections(a, after.get(t) ?? a); return secs.length ? [{ target: t, sections: secs }] : []; });
4380
- /** 宪章改动自动交接的时限 (写简报 + 重开), 与 /handoff 路由同档。 */
4381
- const CHARTER_HANDOFF_MS = 600_000;
4382
- /** 受影响的 wizard 的处理分两档:
4383
- * - hot: 自动交接 —— 守护进程请它写简报、原地重开 (宪章在重开时重渲染)、简报贴回;
4384
- * 交接不成 (一直忙 / 已在交接中 / 重开失败) 才退回老规矩, 挂一条提醒让它自己 handoff。
4385
- * - reload: 此刻重开渲染的还是旧宪章, 自动交接无意义 —— 维持挂提醒, reload 之后它自己交。
4386
- * 给调用方的那段话照旧回; 调用方自己受影响就直说。人发起的改动多一句「交接之前还是旧规矩」:
4387
- * 自动交接也要等它这一轮说完才动手, 这句话仍成立。 */
4532
+ /** /clear 不重新渲染进程启动时的宪章; 只通知, 不暗中重启会话。 */
4388
4533
  const announceCharterImpact = (c, hits) => {
4389
4534
  if (!hits.length)
4390
4535
  return "";
4391
- const ask = `${c.reload ? "等守护进程 reload 之后再" : ""}调 \`handoff({brief})\` 原地重开 (手上这件告一段落就交; 宪章在 handoff 时重新生成)`;
4392
- const lag = (t) => !c.byHuman ? "" : tagOfKey(t)
4393
- ? " —— 这是人发起的改动: 交接之前你仍按旧规矩做事, handoff 之后才按新的"
4394
- : " —— 这是人发起的改动: 交接之前你仍按旧规矩向人汇报, handoff 之后才会按新规矩汇报; 回人时说一句「交接后生效」";
4395
- const mine = hits.find((h) => h.target === c.self);
4396
- // 提醒照旧全机发 (各收各的); 回给调用方的名单与人数只含它看得见的 (sameBot) —— 别的机器人的 wizard
4397
- // 连名字带个数都不报。没有调用方 (人 /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}`));
4398
4538
  const shown = hits.filter((h) => sameBot(c.self ?? "", h.target));
4399
- const shownOthers = shown.filter((h) => h.target !== c.self);
4400
- if (!c.reload) {
4401
- hits.forEach((h) => void handoffs.other(h.target, `${c.what} 改动了你的宪章 (变了的段: ${h.sections.join(" / ")}); 简报里点一句手头活在新规矩下怎么接着干`, CHARTER_HANDOFF_MS).then((r) => {
4402
- if (!r.ok)
4403
- notices.post([h.target], `${c.what} 改动了你的宪章 (系统提示, 变了的段: ${h.sections.join(" / ")}); 自动交接没做成 (${r.reason}) —— 已在跑的你看不见新规矩, 手上这件告一段落后自己${ask}${lag(h.target)}`);
4404
- }));
4405
- return [
4406
- "",
4407
- `⚠ 这次改动进了宪章 (系统提示): 已在跑的 wizard 看不见, 要 handoff 才换上。受影响 ${shown.length} 个: ${shown.map((h) => displayName(h.target)).join(" ")}`,
4408
- ...(shown.length ? ["已自动安排它们交接 (各自写简报 → 原地重开 → 接回简报); 交接不成的会收到提醒, 再自己 handoff"] : []),
4409
- ...(mine ? [`**你自己也在其中** (变了的段: ${mine.sections.join(" / ")}): 这一轮说完就会自动交接${lag(mine.target)}`] : []),
4410
- ].join("\n");
4411
- }
4412
- const others = hits.filter((h) => h.target !== c.self);
4413
- 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);
4414
4540
  return [
4415
4541
  "",
4416
- `⚠ 这次改动进了宪章 (系统提示): 已在跑的 wizard 看不见, 要 handoff 才换上。受影响 ${shown.length} 个: ${shown.map((h) => displayName(h.target)).join(" ")}`,
4417
- ...(shownOthers.length ? [`已给其余 ${shownOthers.length} 个各挂一条提醒 (随它们下一条消息到), 让它们${ask}`] : []),
4418
- ...(mine ? [`**你自己也在其中** (变了的段: ${mine.sections.join(" / ")}): 这一轮收尾后${ask}${lag(mine.target)}`] : []),
4542
+ `⚠ 这次改动进了宪章 (系统提示)。${ask}。受影响 ${shown.length} 个: ${shown.map((h) => displayName(h.target)).join(" ")}`,
4543
+ ...(mine ? [`**你自己也在其中** (变了的段: ${mine.sections.join(" / ")})`] : []),
4419
4544
  ].join("\n");
4420
4545
  };
4421
4546
  // 所有会动宪章的写入 (config_set、人 `/name`、name_chat、自动补名) 共用这一个守卫: 写前写后各渲染一遍
4422
- // 挂在 pane 上的 wizard 的宪章, 变了的、pane 还活着的自动交接 (hot) 或挂提醒 (reload, 见 announceCharterImpact)。
4547
+ // 挂在 pane 上的 wizard 的宪章, 变了的、pane 还活着的挂提醒 (见 announceCharterImpact)。
4423
4548
  // 快照到写完之间不能有 await —— 别人的写入插进来, 前后就对不上了。已经写了: 比对失败只少一句提醒, 不能让调用方以为没写而重发。
4424
4549
  guardCharter = (write, change) => {
4425
4550
  const targets = attachedWizards();
@@ -4430,7 +4555,7 @@ const main = async () => {
4430
4555
  return { r, note: Promise.resolve("") };
4431
4556
  const fail = (e) => {
4432
4557
  log.warn({ err: errText(e), what: c.what }, "宪章影响比对失败");
4433
- return `\n(宪章影响没比对出来: ${errText(e)} —— 改的若是进宪章的内容, 受影响的 wizard 要 handoff 才换上)`;
4558
+ return `\n(宪章影响没比对出来: ${errText(e)} —— 改的若是进宪章的内容, 受影响的 wizard 需要显式 /new 才载入;handoff /clear 不刷新系统提示)`;
4434
4559
  };
4435
4560
  try {
4436
4561
  const hits = charterImpact(before, (c.after ?? charterSnapshot)(targets));
@@ -4762,15 +4887,20 @@ const main = async () => {
4762
4887
  const rec = tasks.get(taskId);
4763
4888
  const owner = rec ? ownerOf(rec) : target;
4764
4889
  const ownerName = settleName(wizards, chatNameOf(cfg, owner), owner) || "task";
4765
- const slotR = await claimSlot(base, "", `${ownerName}-task-${taskId.slice(0, 4)}`);
4766
- 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);
4767
4894
  return { ok: false, reason: "scheduled runner withdrawn during slot selection" };
4895
+ }
4768
4896
  if (!slotR.ok)
4769
4897
  return { ok: false, reason: "起白板 wizard 失败: 挑不出槽位" };
4770
4898
  const runner = slotR.target;
4771
- const allowed = () => guard() && planStore.allowExternal(runner, wizards.get(runner)?.name);
4772
- if (!allowed())
4899
+ const allowed = () => ownsSlot(slotR) && guard() && planStore.allowExternal(runner, wizards.get(runner)?.name);
4900
+ if (!allowed()) {
4901
+ releaseSlot(slotR);
4773
4902
  return { ok: false, reason: "workflow plan reserves task runner" };
4903
+ }
4774
4904
  const info = m.sessionInfo(target);
4775
4905
  const why = busy ? ` (起因: ${displayName(target)} 当时正忙)` : "";
4776
4906
  wizards.upsert(runner, {
@@ -4779,16 +4909,20 @@ const main = async () => {
4779
4909
  voiceOf: owner,
4780
4910
  bornAt: Date.now(),
4781
4911
  });
4782
- const runnerName = wizards.rename(runner, `${ownerName}-task-${taskId.slice(0, 4)}`);
4783
- 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)}` }));
4784
4915
  if (!spawned.ok) {
4785
- wizards.drop(runner);
4916
+ releaseSlot(slotR);
4786
4917
  return { ok: false, reason: `起白板 wizard 失败: ${spawned.reason ?? "unknown"}` };
4787
4918
  }
4919
+ if (!ownsSlot(slotR))
4920
+ return { ok: false, reason: "task runner identity replaced during spawn" };
4921
+ finishSlot(slotR);
4788
4922
  if (!allowed()) {
4789
- if (m.sessionInfo(runner)?.sessionId === spawned.sessionId) {
4923
+ if (ownsSlot(slotR) && m.sessionInfo(runner)?.sessionId === spawned.sessionId) {
4790
4924
  await m.killPane(runner);
4791
- wizards.drop(runner);
4925
+ releaseSlot(slotR);
4792
4926
  }
4793
4927
  return { ok: false, reason: "scheduled runner withdrawn after spawn" };
4794
4928
  }
@@ -4806,7 +4940,7 @@ const main = async () => {
4806
4940
  ? { guard: allowed, from: { kind: "task", taskId }, channel: "", envelope: renderTaskEnvelope(taskId, { event: turn }) }
4807
4941
  : { guard: allowed, fromChat: true, from: { kind: "task", taskId }, envelope: renderTaskEnvelope(taskId) });
4808
4942
  if (!inj.ok) {
4809
- wizards.drop(runner);
4943
+ releaseSlot(slotR);
4810
4944
  return inj;
4811
4945
  }
4812
4946
  // 没有人会对这个一次性分身喊 stop_wizard, 只能自己等它闲下来再收。30min
@@ -4821,8 +4955,10 @@ const main = async () => {
4821
4955
  log.warn({ runner, reason: idle.reason }, "task runner: 30min 未闲下来, 放弃自动回收");
4822
4956
  return;
4823
4957
  }
4958
+ if (!ownsSlot(slotR) || m.sessionInfo(runner)?.sessionId !== spawned.sessionId)
4959
+ return;
4824
4960
  await m.killPane(runner);
4825
- wizards.drop(runner);
4961
+ releaseSlot(slotR);
4826
4962
  })();
4827
4963
  return inj;
4828
4964
  };
@@ -4894,6 +5030,7 @@ const main = async () => {
4894
5030
  registry: tasks,
4895
5031
  log: log.child({ mod: "tasks" }),
4896
5032
  inject: scheduledTaskInject,
5033
+ ready: bridge.restored,
4897
5034
  // gate 里的 `sh` 默认就在目标 wizard 此刻的工作区跑 —— 任务文件里不必写死绝对路径。
4898
5035
  cwdOf: (t) => { const c = bridge.getCwd(t); return c.runningCwd || c.pendingCwd || c.defaultCwd; },
4899
5036
  // 没写 target 的日程兜底到排班者那个机器人的默认聊天 (副机器人没配就不放枪, 不冒用主机器人)。