wezard 2.7.3 → 2.7.7

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";
@@ -26,8 +26,8 @@ import { applyHot, cfgSeenBy, patchedTop, pinChatKeys, configGet, fromLegacy, le
26
26
  import { computeUsage, costOf, fmtCost, fmtTokens, sumTotal } from "./usage.js";
27
27
  import { installInboundRouter } from "./inbound.js";
28
28
  import { loadMirrorStore } from "./mirror-store.js";
29
- import { cancelOps, deadlineOf, eventRef, isEventRef, makeOutcome, openRouter, ops, pickPair } from "./router.js";
30
- import { createMessageEvents, messageId, finalId } from "./message-events.js";
29
+ import { cancelOps, deadlineOf, eventRef, finalKind, isEventRef, makeOutcome, openRouter, ops, pickPair } from "./router.js";
30
+ import { createMessageEvents, messageId, finalId, reportSource, receiptCoverage, receiptFacts } from "./message-events.js";
31
31
  import { startMirror, installMirrorEventListener } from "./mirror-bridge.js";
32
32
  import { backendFor, runTmux, setTmuxTimeoutReporter, spawnTmuxClaude } from "./spawn-tmux.js";
33
33
  import { bindCardChannel, installApprovalEventListener, makeApproveHandler, requestConfirm } from "./approval.js";
@@ -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,20 @@ 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;
210
+ const ownsReplyEvent = (e) => {
211
+ const row = e.serves ? router.get(e.serves) : undefined;
212
+ if (row && row.owner !== e.target)
213
+ return false;
214
+ const latest = e.serves ? events.latestTransfer(e.serves) : undefined;
215
+ return !latest || latest.id === e.id || latest.at < e.at;
216
+ };
207
217
  const events = createMessageEvents(join(cfg.daemon.stateDir, "message-events.json"), {
208
218
  resource: (target) => bridge.sessionInfo(target)?.tmuxPane || target,
209
219
  transcript: (target) => bridge.inputSessionInfo(target)?.jsonlPath ?? bridge.sessionInfo(target)?.jsonlPath,
210
220
  sessionId: (target) => bridge.inputSessionInfo(target)?.sessionId,
211
- valid: (e) => e.payload.peer ? !!peerEvents?.valid(e) : true,
221
+ valid: (e) => (!(e.payload.handoff || (e.id.startsWith("route:") && e.id.includes(":resume:"))) || handoffAllowed(e.target)) && (e.payload.peer ? !!peerEvents?.valid(e) : true),
212
222
  accepted: (e) => { if (e.payload.peer)
213
223
  peerEvents?.accepted(e); },
214
224
  committed: (e) => { if (e.payload.peer)
@@ -223,15 +233,25 @@ const main = async () => {
223
233
  events.result(e.id, body, finalId(e.id, e.channel, body));
224
234
  // A canceled, unexposed input has a terminal response owned by its original channel.
225
235
  events.delivery({ id: finalId(e.id, e.channel, body), eventId: e.id, target: e.target, turn: e.turn || e.id,
226
- channel: e.channel, revision: finalId(e.id, e.channel, body), body, chunks: bridge.formatFinal(e.target, e.turn || e.id, e.channel, body) });
236
+ channel: e.channel, revision: finalId(e.id, e.channel, body), body, purpose: "cancellation", reportKind: "canceled", chunks: bridge.formatFinal(e.target, e.turn || e.id, e.channel, body) });
227
237
  }
228
238
  },
229
239
  // 人话 (非 peer) 不再问「pane 闲不闲」: 忙就贴进去, 顺序交给 backend CLI 自己的输入队列
230
240
  // (贴进运行中的一轮记成 queued 轮, 终句照样归到这条 event —— 见 brief 的 seal/bindLine)。
231
241
  // 这一句曾是要 idleNow 的, 也就是那个反复 5s 超时的探测源头: 一超时就退避, 把同一 target
232
242
  // 后面排着的每一笔 (含后到的人话) 一起钉住十几分钟。交接期间照旧等 —— 那几秒 pane 真是死的。
233
- ready: async (e) => !handingOff(e.target) && (e.payload.peer ? !!(await peerEvents?.ready(e)) : true),
243
+ 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
244
  inject: async (e, boundary) => {
245
+ if (e.payload.handoff) {
246
+ const expected = e.payload.handoff;
247
+ const same = () => { const info = bridge.sessionInfo(e.target); return info?.tmuxPane === expected.pane && info?.sessionId === expected.sessionId; };
248
+ if (!same())
249
+ return { ok: false, reason: "handoff original pane/session changed; refusing brief injection" };
250
+ return bridge.injectText(e.target, e.payload.text, undefined, {
251
+ 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,
252
+ guard: (phase, identity) => handoffAllowed(e.target) && same() && (!identity?.sessionId || identity.sessionId === expected.sessionId) && boundary(phase, identity),
253
+ });
254
+ }
235
255
  if (e.payload.peer)
236
256
  return peerEvents.inject(e, boundary);
237
257
  if (!bridge.hasMirrorTarget(e.target)) {
@@ -268,20 +288,34 @@ const main = async () => {
268
288
  // 一轮以 CLI 报错收尾: 那段报错不是定论, 但要作为中途态 (error) 报给 receiver ——
269
289
  // 所以照旧不落成 result, 只把原文交给 final (见 §9.4)。
270
290
  return { consumed: true, interjected: !!turns[start].queued,
271
- ...(next >= 0 ? { closedAt: turns[next].ms ?? Date.now() } : {}),
272
- ...(ended && last && !last.mid
291
+ ...(next >= 0 ? { closedAt: turns[next].ms ?? Date.now() }
292
+ : last?.end && !last.text ? { closedAt: last.ms ?? Date.now() } : {}),
293
+ ...(ended && last?.text && !last.mid
273
294
  ? last.apiError ? { body: last.text, error: true } : { body: last.text, revision: finalId(e.id, e.channel, last.text) }
274
295
  : {}) };
275
296
  }
276
297
  return { consumed: false };
277
298
  },
278
- recovered: (e) => bridge.recoverEventFinal(e),
299
+ recovered: (e) => { if (!handoffFinalSuppressed(e))
300
+ bridge.recoverEventFinal(e); },
279
301
  // T4: 终句 → 路由表。onFinal 连同投递一起做 —— 定论先落盘 (`answer`), 再按 receiver
280
302
  // 投出去 (§9.3/§9.4); 实现在 mirror 大块里 (要 jobs / 信封 / 事件泵)。
281
303
  final: (e, verdict) => deliverFinal?.(e, verdict),
282
304
  format: (d) => ({ ...d, chunks: d.chunks.map((c) => responseTracker.freeze(d.channel.replace(/^(user|chat|group):/, ""), c)) }),
283
305
  send: async (d, n) => {
284
306
  await bridge.settleFinalProgress(d.id);
307
+ const allowed = () => {
308
+ const source = d.eventId ? events.authorityOf(d.eventId) : undefined;
309
+ if (!source)
310
+ return !d.eventId || d.purpose === "task";
311
+ const row = source.serves ? router.get(source.serves) : undefined;
312
+ const cancellation = d.purpose === "cancellation" && source.canceled && source.unexposedHuman;
313
+ return source.target === d.target && !source.interjected && !handoffFinalSuppressed(source) && ownsReplyEvent(source)
314
+ && (cancellation || !source.canceled && row?.outcome?.status !== "canceled" && row?.status !== "held")
315
+ && (d.purpose !== "progress" || !!row && ["open", "need", "error"].includes(row.status));
316
+ };
317
+ if (!allowed())
318
+ return { failed: "delivery authority withdrawn before transport" };
285
319
  const chat = d.channel.replace(/^(user|chat|group):/, "");
286
320
  if (wx.owns(chat)) {
287
321
  const result = wx.sendFinal(chat, wxMarkdown(d.chunks[n]), `${d.id}:${n}`);
@@ -289,8 +323,8 @@ const main = async () => {
289
323
  uncertain: result.state === "uncertain" ? result.reason ?? "Weixin send ACK unknown" : undefined,
290
324
  failed: result.state === "failed" ? result.reason ?? "Weixin final rejected" : undefined };
291
325
  }
292
- const stream = d.stream && n === 0 && d.chunks.length === 1 && Date.now() < d.stream.expires ? d.stream : undefined;
293
- const ack = await responseTracker.sendFrozen(chat, d.chunks[n], stream, events.running);
326
+ const stream = d.stream && n === 0 && Date.now() < d.stream.expires ? d.stream : undefined;
327
+ const ack = await responseTracker.sendFrozen(chat, d.chunks[n], stream, () => events.running() && allowed());
294
328
  return ack && typeof ack === "object" && "errcode" in ack && ack.errcode === 0 ? { ack: JSON.stringify(ack) }
295
329
  : { ack: JSON.stringify(ack), uncertain: "final transport returned no explicit success ACK" };
296
330
  },
@@ -686,24 +720,25 @@ const main = async () => {
686
720
  return;
687
721
  }
688
722
  const foreign = base !== baseOfKey(self);
689
- const slotR = await claimSlot(base, String(body.name ?? body.tag ?? ""), tagFromCwd(cwd) || "peer", [self]);
723
+ const slotR = await claimSlot(base, String(body.name ?? body.tag ?? ""), [self]);
690
724
  if (!slotR.ok) {
691
725
  json(res, slotR.status, slotR.body);
692
726
  return;
693
727
  }
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);
728
+ const { target, name } = slotR;
701
729
  const model = (body.model ?? "").toString().trim();
702
730
  log.child({ mod: "mirror", sub: "sessions-new", target }).info({ self, cwd, foreign, cli: body.cli, model }, "spawning peer session");
703
731
  // 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);
732
+ const r = await m.newSession(target, name, body.cli, { cwd, model, silent: true, guard: () => ownsSlot(slotR) && planStore.allowExternal(target, wizards.get(target)?.name ?? name) })
733
+ .catch((e) => ({ ok: false, reason: `spawn threw: ${String(e)}` }));
734
+ if (!ownsSlot(slotR)) {
735
+ json(res, 409, { ok: false, reason: "birth identity replaced during spawn" });
736
+ return;
737
+ }
738
+ if (!r.ok)
739
+ releaseSlot(slotR);
740
+ else
741
+ finishSlot(slotR);
707
742
  if (r.ok)
708
743
  postRoster(base, [target, self], { op: "born", name: `.${name}`, by: displayName(self), cwd: r.cwd }, subjectOf(wizards.get(target)));
709
744
  json(res, r.ok ? 200 : 500, r.ok
@@ -746,16 +781,7 @@ const main = async () => {
746
781
  const r = await m.injectText(target, text, undefined, { guard });
747
782
  json(res, r.ok ? 200 : 500, r);
748
783
  });
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.
784
+ // 工作目录属于 CLI 进程; 活会话只记录 pending, 必须由人显式 /new 应用。
759
785
  http.register("POST /mirror/workspace", async (req, res) => {
760
786
  const body = (await readBody(req));
761
787
  const cwd = (body.cwd ?? "").toString();
@@ -785,12 +811,8 @@ const main = async () => {
785
811
  return;
786
812
  }
787
813
  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 });
814
+ json(res, 409, { ok: false, target, runningCwd: set.runningCwd, pendingCwd: set.pendingCwd,
815
+ reason: "原 CLI 进程不能切换工作目录;请求已保留为 pendingCwd,需要显式 /new 才生效,handoff /clear 不会应用它" });
794
816
  return;
795
817
  }
796
818
  // Same shape as inbound's /new: windowName = tag for tagged sessions,
@@ -977,6 +999,7 @@ const main = async () => {
977
999
  && !a.canceled && a.acceptance !== "rejected" && jobs.get(r.plan.job)?.status === "open" && ["running", "needs-input", "errored"].includes(a.execution)
978
1000
  && (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
1001
  };
1002
+ handoffAllowed = (target) => planContinuationAllowed(target) && !planStore.reservation(target, wizards.get(target)?.name);
980
1003
  /** 一笔活第几个来回 (§3.2): 同一行上已经注入过的续问数 + 1 —— 现算, 不落第二份数据。
981
1004
  * 续问的注入事件 id 是 `route:<event>:r<legs>`, 所以从它自己就能数回来 (reload 也在盘上)。 */
982
1005
  const legsOf = (event) => events.ids().reduce((n, id) => {
@@ -1032,21 +1055,67 @@ const main = async () => {
1032
1055
  // 闸、回执清点与断点续做在那边。
1033
1056
  /** 情景记忆 (memory/episodes/<name>.jsonl) 按名字归档; 默认会话没名字时退回 slot / "default"。 */
1034
1057
  const episodeName = (t) => wizards.get(t)?.name || tagOfKey(t) || "default";
1058
+ const handoffStore = loadJsonMap(cfg.wrc.mirror.handoffsFile, undefined, { strict: true });
1059
+ handoffFinalSuppressed = (event) => {
1060
+ const e = events.get(event.id) ?? event;
1061
+ if (e.handoffRestored)
1062
+ return false;
1063
+ if (e.handoffCut)
1064
+ return true;
1065
+ const p = handoffStore.get(e.target);
1066
+ // 先读在飞事务的明确旧事件集合, 覆盖 result 已写而事件 cut 尚未写的崩溃缝。
1067
+ return !!p && p.mode === "self" && !p.failure?.compensated && !!p.cut?.includes(e.id);
1068
+ };
1035
1069
  const handoffs = createHandoffs({
1036
- isBusy: m.isBusy,
1070
+ currentEvents: (t) => {
1071
+ const info = m.sessionInfo(t);
1072
+ const active = info?.jsonlPath ? openingTurn(expandHome(info.jsonlPath))?.env?.eventId : undefined;
1073
+ return events.ids().filter((id) => {
1074
+ const e = events.get(id);
1075
+ return !!e && e.target === t && !e.payload.handoff && (id === active || (!e.finalized && e.execution !== "canceled" && e.exposure !== "none" && (!e.sessionId || e.sessionId === info?.sessionId)));
1076
+ });
1077
+ },
1078
+ suppress: (ids) => ids.forEach((id) => events.suppressHandoffFinal(id)),
1079
+ isBusy: async (t) => {
1080
+ if (!(await m.idleNow(t)))
1081
+ return true;
1082
+ // TUI 已 idle 不代表旧 final 已被事件泵消费; 先落旧轮结果, 再允许切断上下文。
1083
+ 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));
1084
+ await old.reduce(async (before, e) => { await before; await events.reconcile(e.id); }, Promise.resolve());
1085
+ return old.some((e) => { const now = events.get(e.id); return now && !now.finalized && now.execution !== "canceled"; });
1086
+ },
1037
1087
  sessionId: (t) => m.sessionInfo(t)?.sessionId ?? "",
1038
- restart: (t, p) => restartFresh(t, p),
1088
+ restart: (t, p, submitted) => clearForHandoff(t, p, submitted),
1039
1089
  // 简报是新会话的开场白, 但它不挂任何信封 (服务不着哪一笔) —— 交接前欠着的那几笔由
1040
1090
  // 「继续 <event>」逐笔续答 (见 resume), 别把简报那一轮的回复当成回执。
1041
- inject: async (t, text, channel) => {
1091
+ inject: async (t, text, channel, id, timeoutMs, clear) => {
1042
1092
  if (!planContinuationAllowed(t))
1043
1093
  return { ok: false, reason: "workflow continuation withdrawn" };
1044
1094
  if (planStore.reservation(t, wizards.get(t)?.name))
1045
1095
  return { ok: false, reason: "workflow handoff requires the original attempt" };
1046
- return m.injectText(t, text, undefined, { channel, guard: () => planContinuationAllowed(t) });
1096
+ if (clear && !(await m.verifyClearContext(t, clear)))
1097
+ return { ok: false, reason: "original cleared CLI process/session changed before carry" };
1098
+ // 事件先落盘; 同一 id 重入只恢复状态, 不重贴。内部事件没有 serves, 不替任何欠账收口。
1099
+ if (!events.known(id)) {
1100
+ const info = clear ? { tmuxPane: clear.pane, sessionId: clear.nextSid } : m.sessionInfo(t);
1101
+ if (!info?.tmuxPane || !info.sessionId)
1102
+ return { ok: false, reason: "original handoff pane/session binding is unavailable" };
1103
+ events.accept({ id, identity: JSON.stringify({ pane: info.tmuxPane, sid: info.sessionId }), target: t, speaker: t, channel, priority: "now", deadline: Date.now() + timeoutMs,
1104
+ payload: { text, handoff: { pane: info.tmuxPane, sessionId: info.sessionId, ...(clear ? { clear } : {}) } } });
1105
+ }
1106
+ for (const end = Date.now() + timeoutMs; Date.now() < end; await sleep(200)) {
1107
+ const e = await events.reconcile(id);
1108
+ if (e?.execution === "complete" || (!id.endsWith(":summary") && e?.exposure === "consumed"))
1109
+ return { ok: true };
1110
+ if (e?.execution === "uncertain" || e?.execution === "canceled")
1111
+ return { ok: false, reason: e.reason ?? "handoff event delivery is uncertain; not resubmitting" };
1112
+ }
1113
+ events.cancel((e) => e.id === id && e.exposure === "none", "handoff stopped before confirmed delivery");
1114
+ return { ok: false, reason: "handoff event consumption not confirmed; durable event retained" };
1047
1115
  },
1048
1116
  channelOf: m.currentChannel,
1049
1117
  lastText: m.lastText,
1118
+ summary: (id) => { const e = events.get(id); return e?.execution === "complete" && e.exposure === "consumed" && typeof e.result?.body === "string" ? e.result.body : undefined; },
1050
1119
  // 欠账不落盘在交接里: 路由表按 target 记着它欠谁、欠哪几笔, 换会话不影响 (T8)。
1051
1120
  owed: (t) => router.owedBy(t).filter((r) => r.status === "open").map((r) => r.event),
1052
1121
  resume: async (t, event, at) => injectResume(t, event, at),
@@ -1056,15 +1125,20 @@ const main = async () => {
1056
1125
  // 交接没做成: 切断那一轮扣下的终句按原行补投 —— 走与轮终同一条 onFinal (行已被合成失败落定的, 照追述规则处理)。
1057
1126
  uncut: (t, ids) => ids.forEach((id) => {
1058
1127
  const e = events.get(id);
1059
- if (!e?.result) {
1060
- log.warn({ target: t, event: id }, "handoff: 扣下的终句已不在事件库, 补投不了");
1128
+ if (!e) {
1129
+ log.warn({ target: t, event: id }, "handoff: 扣下的事件已不在事件库, 补投不了");
1061
1130
  return;
1062
1131
  }
1132
+ // 超时可能发生在旧轮尚未产出 final 时; 先释放耐久标记, 让它稍后的真终句正常落定。
1133
+ events.restoreHandoffFinal(id);
1134
+ if (!e.result)
1135
+ return;
1063
1136
  log.info({ target: t, event: id, serves: e.serves }, "handoff: 交接没做成, 补投切断那一轮的终句");
1064
1137
  settleFinal(e, { text: e.result.body });
1138
+ m.recoverEventFinal(events.get(id));
1065
1139
  }),
1066
1140
  log,
1067
- store: loadJsonMap(cfg.wrc.mirror.handoffsFile),
1141
+ store: handoffStore,
1068
1142
  archive: (e) => {
1069
1143
  if (!e.brief.trim())
1070
1144
  return;
@@ -1078,7 +1152,9 @@ const main = async () => {
1078
1152
  * `serves` 指回那一行 (注入事件 id `route:<event>:resume`), 于是它的终句照旧落定、
1079
1153
  * 照旧投给 receiver —— 换会话丢掉的只是 transcript, 不是这笔账。
1080
1154
  * id 按这一场交接的起点 (`at`) 区分: 同一场 reload 重放判重不重投, 换一场才配一条新的。 */
1081
- function injectResume(t, event, at) {
1155
+ async function injectResume(t, event, at) {
1156
+ if (!handoffAllowed(t))
1157
+ return { ok: false, reason: "workflow continuation withdrawn or executor reserved" };
1082
1158
  const row = router.get(event);
1083
1159
  if (!row || row.owner !== t || row.status !== "open")
1084
1160
  return { ok: false, reason: "no open row to resume" };
@@ -1086,7 +1162,7 @@ const main = async () => {
1086
1162
  const waiting = r.kind === "wizard" ? r.to : baseOfKey(r.channel); // 等着它交代的那一方 (信封挂在它名下)
1087
1163
  const channel = r.kind === "chat" ? r.channel : ""; // 结论要落回群里时才带频道 (§9.4)
1088
1164
  const base = `route:${event}:resume`;
1089
- const id = events.known(base) ? `${base}:${at}` : base;
1165
+ const id = `${base}:${at}`;
1090
1166
  try {
1091
1167
  events.accept({
1092
1168
  id, identity: JSON.stringify([t, event, "resume", at]),
@@ -1101,7 +1177,15 @@ const main = async () => {
1101
1177
  envelope: envelopeFor(waiting, channel, { ...(row.asker ? { asker: row.asker } : {}) }),
1102
1178
  } },
1103
1179
  });
1104
- return { ok: true };
1180
+ // accept 仅表示排队; 等该 id 真被消费后才让交接记录收尾。
1181
+ for (const end = Date.now() + 600_000; Date.now() < end; await sleep(200)) {
1182
+ const e = await events.reconcile(id);
1183
+ if (e?.exposure === "consumed" || e?.execution === "complete")
1184
+ return { ok: true };
1185
+ if (e?.execution === "uncertain" || e?.execution === "canceled")
1186
+ return { ok: false, reason: e.reason ?? "resume delivery uncertain" };
1187
+ }
1188
+ return { ok: false, reason: "resume consumption not confirmed; handoff retained" };
1105
1189
  }
1106
1190
  catch (e) {
1107
1191
  return { ok: false, reason: errText(e) };
@@ -1162,11 +1246,8 @@ const main = async () => {
1162
1246
  return "";
1163
1247
  // 计划节点 / 停放放行: 那一笔在 plan 发起 / 停放那一刻就定了; 其余看本轮服务谁。
1164
1248
  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 }));
1249
+ if (p.op === "transfer")
1168
1250
  return f ?? "";
1169
- }
1170
1251
  const parent = p.op === "self" ? f : undefined;
1171
1252
  return openRow({
1172
1253
  ...(parent ? { event: `${parent}.${(router.get(parent)?.seq ?? 0) + 1}` } : {}),
@@ -1176,6 +1257,29 @@ const main = async () => {
1176
1257
  ...(p.job ? { job: p.job } : {}), ...(p.asker ? { asker: p.asker } : {}), ...(p.after?.length ? { after: p.after } : {}),
1177
1258
  });
1178
1259
  };
1260
+ // Acceptance projects ownership only after the input is durable. A rejected
1261
+ // acceptance retains the human's route and bubble for the original owner.
1262
+ const acceptPeerDispatch = (input, transfer = false) => {
1263
+ if (!transfer)
1264
+ return events.accept(input);
1265
+ const row = input.serves ? router.get(input.serves) : undefined;
1266
+ if (!row || row.owner !== input.speaker || row.status === "answered" || row.status === "handed")
1267
+ throw new Error("transfer ownership changed before acceptance");
1268
+ try {
1269
+ const accepted = events.accept(input);
1270
+ m.transferReply(events.get(input.id));
1271
+ return accepted;
1272
+ }
1273
+ catch (error) {
1274
+ const accepted = events.get(input.id);
1275
+ if (!accepted || accepted.exposure === "none") {
1276
+ events.cancel((e) => e.id === input.id, "transfer acceptance failed before exposure");
1277
+ if (router.get(row.event)?.owner === input.target)
1278
+ router.commit(ops.move({ event: row.event, owner: input.speaker }));
1279
+ }
1280
+ throw error;
1281
+ }
1282
+ };
1179
1283
  /** 那一笔行上的接收快照 (挂起账投影用): 结论收给谁。没有行 = 不知道 (还是记 nobody)。 */
1180
1284
  const receiverOfEvent = (event) => (event ? router.get(event)?.receiver : undefined);
1181
1285
  /** 挂起账里那一行的 owner = 派活方 = 这一笔的接收方。收给群的根行 (人话 / 定时) 不进账 ——
@@ -1244,28 +1348,35 @@ const main = async () => {
1244
1348
  return;
1245
1349
  // 挂起账 / 工单账本跟着定论走 (§9.5 / §9.6): 每一份定论都从这儿过 (onFinal 的 answer、
1246
1350
  // 失败级联的 canceled、boot 的重投), 所以放这里而不是 commitRoute。收给群的根行不进账。
1247
- if (row.receiver.kind === "wizard") {
1351
+ const noticeOnly = (out.status === "need" || out.status === "error") && !!row.outcome;
1352
+ if (row.receiver.kind === "wizard" && !noticeOnly) {
1248
1353
  ledger.outcome(row.receiver.to, event, out.status, parseClosing(out.body).text || out.body);
1249
1354
  // 带 workflow 追踪的那件活: 执行态/正文/摘要跟着定论走 (计划节点据此推进) ——
1250
1355
  // 这一份原来只挂在旧回执 watcher 的 onOutcome 上, T10b 随它一起在这里重建。
1251
1356
  workflow.outcome(row.receiver.to, event, out.status, out.body);
1252
1357
  advancePlans();
1253
- // 追述 (row.late) 不是工单成员的第二份结局: 账只记第一次落定。
1254
- if (row.job && isTerminal(out.status) && !row.late)
1358
+ // 追述更新最新结果, 首次落定计数由工单保持。
1359
+ if (row.job && isTerminal(out.status))
1255
1360
  jobs.settle(row.job, row.owner, out.status, parseClosing(out.body).artifacts, { turn: event });
1256
1361
  }
1257
1362
  postRoute(row, out, router.escalate(event, (k) => !!wizards.get(k)), `rcpt:${event}:${out.rev}`);
1258
1363
  };
1259
1364
  /** 一份定论的投递 (账本已记过): 投给升级后的收方 `esc` (没有 = 行上的 receiver)。群收方只落
1260
1365
  * `hand`; wizard 收方 (含 Dead Letter 退到的群默认 wizard) 进收件箱, id = `inbox`。 */
1261
- const postRoute = (row, out, esc, inbox) => {
1366
+ const postRoute = (row, out, esc, inbox, retire) => {
1262
1367
  const event = row.event;
1263
1368
  const receiver = esc?.receiver ?? row.receiver;
1264
1369
  const via = (esc?.via ?? []).map(displayName);
1265
1370
  // 升级落在 chat 根 = Dead Letter: 退到那个群, 由它的默认 wizard 收下这份结论。
1266
1371
  const dead = receiver.kind === "chat" && !!esc;
1267
1372
  if (receiver.kind !== "wizard" && !dead) {
1268
- router.commit(ops.hand({ event, inbox: `chat:${receiver.message}:${out.rev}` }));
1373
+ // A transferred chat reply has no upstream wizard to report failures to.
1374
+ // Persist it through the same final outbox before handing the route off.
1375
+ const transferred = events.latestTransfer(event);
1376
+ if (out.status !== "done" && transferred?.target === row.owner)
1377
+ m.deliverTransferredFinal(transferred, out.body, out.status);
1378
+ if (isTerminal(out.status) && row.outcome?.rev === out.rev)
1379
+ router.commit(ops.hand({ event, inbox: `chat:${receiver.message}:${out.rev}` }));
1269
1380
  return;
1270
1381
  }
1271
1382
  // 回执那一轮服务的就是收方自己欠的那一笔 (`receiver.event`) —— 它的终句又一次落到那一笔上,
@@ -1285,6 +1396,13 @@ const main = async () => {
1285
1396
  : undefined;
1286
1397
  const job = row.job ? jobs.get(row.job) : undefined;
1287
1398
  const tally = row.job ? jobs.tally(row.job) : undefined;
1399
+ const envelope = renderReceiptEnvelope(displayName(row.owner), channel ? chatNameOf(cfg, channel) : undefined, row.job ? { job: row.job, done: tally?.done ?? 0, total: tally?.total ?? 0, closed: job?.status !== "open", ...(job?.kind === "req" ? { req: true } : {}) } : undefined, out.status, event, { ...(row.asker ? { asker: row.asker } : {}),
1400
+ ...(served?.kind === "wizard" ? { up: true } : {}),
1401
+ ...(audienceName(channel, row.asker) ? { audience: audienceName(channel, row.asker) } : {}),
1402
+ ...(via.length ? { via: via.join(", ") } : {}),
1403
+ ...(late ? { late: true } : {}),
1404
+ ...(retellTo ? { retellTo } : {}),
1405
+ ...(row.late ? { retold: true } : {}) });
1288
1406
  events.accept({
1289
1407
  id: inbox, identity: JSON.stringify([to, event, out.status, out.rev]),
1290
1408
  target: to, speaker: row.owner, channel, serves,
@@ -1292,26 +1410,35 @@ const main = async () => {
1292
1410
  priority: "normal", deadline: Date.now() + 12 * 3600_000,
1293
1411
  // 行首 RESULT:/NEED: 只是判定记号, 语义已由信封的 status 携带 —— 投递时剥掉
1294
1412
  // (ARTIFACT 行保留: 路径对收方 wizard 有实际用途)。
1295
- payload: { text: clipMiddle(stripClosingMark(out.body)), peer: {
1413
+ payload: { text: clipMiddle(stripClosingMark(out.body)), receipts: [{ event, version: out.rev, status: out.status,
1414
+ receiver, body: out.body, speaker: row.owner, envelope,
1415
+ source: row.reports?.[out.rev]?.source
1416
+ ?? (row.outcome?.rev === out.rev ? row.outcome.report?.source : row.notice?.rev === out.rev ? row.notice.report?.source : undefined) }], peer: {
1296
1417
  // 回执不是一件新活: `job` 留空 (否则 jobs.account 会把它当成工单的第二份派活数一遍)。
1297
1418
  from: { kind: "peer", from: row.owner, receipt: true, status: out.status, event },
1298
1419
  job: "", kind: "fyi",
1299
- envelope: renderReceiptEnvelope(displayName(row.owner), channel ? chatNameOf(cfg, channel) : undefined, row.job ? { job: row.job, done: tally?.done ?? 0, total: tally?.total ?? 0, closed: job?.status !== "open", ...(job?.kind === "req" ? { req: true } : {}) } : undefined, out.status, event, { ...(row.asker ? { asker: row.asker } : {}),
1300
- ...(served?.kind === "wizard" ? { up: true } : {}),
1301
- ...(audienceName(channel, row.asker) ? { audience: audienceName(channel, row.asker) } : {}),
1302
- ...(via.length ? { via: via.join(", ") } : {}),
1303
- ...(late ? { late: true } : {}),
1304
- ...(retellTo ? { retellTo } : {}),
1305
- ...(row.late ? { retold: true } : {}) }),
1420
+ envelope,
1306
1421
  } },
1307
- });
1308
- router.commit(ops.hand({ event, inbox }));
1422
+ }, retire);
1423
+ if (isTerminal(out.status) && row.outcome?.rev === out.rev)
1424
+ router.commit(ops.hand({ event, inbox }));
1309
1425
  };
1310
1426
  /** 收件箱里那份回执投不到了 (`gone`: 收方没有绑定, 按 store 也恢复不了) —— 投进去时它还在
1311
1427
  * 注册表上, 升级没跳过它。现在把它当不在, 沿 receiver 边重走一次升级: 能收的上层 wizard /
1312
1428
  * 群根 (Dead Letter) 收下。链上无处可升 (根行的收方自己没了): canceled 是收掉它的那一方自己
1313
1429
  * 发起的, 没人在等, 不再投; 别的定论退到收方的家群 (Dead Letter)。账本不再记第二遍。 */
1314
1430
  const rerouteGone = (e) => {
1431
+ const facts = receiptFacts(e);
1432
+ if (facts.length <= 1) {
1433
+ rerouteReceipt(e, e.id);
1434
+ return;
1435
+ }
1436
+ facts.forEach((f, i) => rerouteReceipt({ ...e, id: `rcpt:${f.event}:${f.version}`,
1437
+ speaker: f.speaker, payload: { ...e.payload, text: f.body, receipts: [f],
1438
+ peer: { ...e.payload.peer, from: { kind: "peer", from: f.speaker, event: f.event,
1439
+ receipt: true, status: f.status }, envelope: f.envelope } } }, i === facts.length - 1 ? e.id : undefined));
1440
+ };
1441
+ const rerouteReceipt = (e, retire) => {
1315
1442
  const from = e.payload.peer?.from;
1316
1443
  const event = from?.receipt ? from.event : undefined;
1317
1444
  const row = event ? router.get(event) : undefined;
@@ -1330,10 +1457,10 @@ const main = async () => {
1330
1457
  return;
1331
1458
  }
1332
1459
  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}`;
1460
+ // Replaying a torn reroute must target the same durable inbox entry.
1461
+ const inbox = `${e.id}~${finalId(e.id, JSON.stringify(esc?.receiver ?? row.receiver), rev)}`;
1335
1462
  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);
1463
+ postRoute(row, { status: from.status, body: e.payload.text, rev }, esc, inbox, retire);
1337
1464
  };
1338
1465
  /** 一条路由 op: 先落盘, 再投该投的 (need / error 带正文入箱; answer 按 receiver 投递)。这里
1339
1466
  * 只管 `re` (`mark open`) 把那一行拉回在飞 —— 账本与工单跟着 deliverRoute 走。 */
@@ -1356,6 +1483,8 @@ const main = async () => {
1356
1483
  * 也最多再多打回一次)。**这是「轮终 evidence → onFinal」那条主触发链的落点**: 没有它,
1357
1484
  * answer 就只能靠 sweeper / 级联取消 / boot 重放合成, 谁答完都不会有回执。 */
1358
1485
  deliverFinal = (e, verdict) => {
1486
+ if (handoffFinalSuppressed(e) || !ownsReplyEvent(e))
1487
+ return;
1359
1488
  // 同一轮的终句会递进来两次: result() 当场递一次, 泵见 complete 未 finalized 再补判一次
1360
1489
  // (message-events)。被打回那一轮的重放不是补交 —— 丢掉; 否则 bounced 已记着这一笔,
1361
1490
  // 第二次就把被打回的原文照收成 done (打回的续问还没注入, 定论先落了)。
@@ -1364,16 +1493,16 @@ const main = async () => {
1364
1493
  // 自我交接切断的那一轮: 收尾那句不是欠着的那几笔的答案 —— 行留 open, 交给新会话的「继续 <event>」。
1365
1494
  // 扣下记在这场交接上: 交接没做成 (新会话没起来), 它就是那几笔仅有的交代, 由 uncut 补投。
1366
1495
  if (cuttingTurn(e.target)) {
1496
+ handoffs.cut(e.target, e.id);
1497
+ events.suppressHandoffFinal(e.id);
1367
1498
  log.info({ event: e.id, target: e.target, serves: e.serves }, "handoff: 交接切断的那一轮终句不落定");
1368
- if (!verdict.error)
1369
- handoffs.cut(e.target, e.id);
1370
1499
  return;
1371
1500
  }
1372
1501
  settleFinal(e, verdict);
1373
1502
  };
1374
1503
  /** 一轮的终句落到它服务的那一行上 (定论 / 中途态), 验收不合规格就打回续问。 */
1375
1504
  const settleFinal = (e, verdict) => {
1376
- const plans = router.onFinal(e.target, e.serves, verdict);
1505
+ const plans = router.onFinal(e.target, e.serves, { ...verdict, source: reportSource(e) });
1377
1506
  const answer = plans.flatMap((op) => (op.op === "answer" && op.outcome.status === "done" ? [op] : []))[0];
1378
1507
  const row = answer ? router.get(answer.event) : undefined;
1379
1508
  const why = answer && row ? acceptanceOf(row, answer.outcome.body) : undefined;
@@ -1383,6 +1512,11 @@ const main = async () => {
1383
1512
  rebound(row, why, e.id);
1384
1513
  return;
1385
1514
  }
1515
+ // Error evidence has no durable event result. Queue the chat reply before
1516
+ // mark:error suppresses replay, so a reload between the two cannot lose it.
1517
+ if (verdict.error && plans.some((op) => op.op === "mark" && op.status === "error")
1518
+ && e.serves && router.get(e.serves)?.receiver.kind === "chat" && e.payload.peer?.transferred)
1519
+ m.deliverTransferredFinal(e, verdict.text);
1386
1520
  plans.forEach(commitRoute);
1387
1521
  };
1388
1522
  // ── 新路由表 (T6): 失败级联 ─────────────────────────────────────────────
@@ -1434,9 +1568,12 @@ const main = async () => {
1434
1568
  * 先留这个调用位。 */
1435
1569
  const releaseHeld = (_row) => { };
1436
1570
  /** §4.5 boot: fold 之后 `answered` 还没 `hand` 的行重投一次 —— rcpt 的 id 幂等, 重放不双投。 */
1437
- const replayAnswered = () => router.rows()
1438
- .filter((r) => r.status === "answered" && r.outcome !== undefined)
1439
- .forEach((r) => deliverRoute(r.event, r.outcome));
1571
+ const replayAnswered = () => router.rows().forEach((r) => {
1572
+ if (r.status === "answered" && r.outcome)
1573
+ deliverRoute(r.event, r.outcome);
1574
+ if (r.notice && r.outcome?.status !== "canceled")
1575
+ deliverRoute(r.event, r.notice);
1576
+ });
1440
1577
  const currentAsk = (target) => {
1441
1578
  const env = openingAsk(target);
1442
1579
  if (!env)
@@ -1554,57 +1691,98 @@ const main = async () => {
1554
1691
  * - 撞上的 wizard 静默超过一天 (不在 `keep` 里) → 顶掉它, 名字归新的;
1555
1692
  * - 其余 (还新鲜的) → 409, 附上它的死活, 调用方据此换名或先收掉它。
1556
1693
  * 槽位 (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)) => {
1694
+ const namingClaims = new Set();
1695
+ const ownsSlot = (s) => wizards.get(s.target)?.birthToken === s.token;
1696
+ const releaseSlot = (s) => {
1697
+ if (!ownsSlot(s))
1698
+ return;
1699
+ if (s.existed)
1700
+ wizards.upsert(s.target, { spawning: undefined, spawnReused: undefined });
1701
+ else
1702
+ wizards.drop(s.target);
1703
+ };
1704
+ const finishSlot = (s) => {
1705
+ if (ownsSlot(s))
1706
+ wizards.upsert(s.target, { spawning: undefined, spawnReused: undefined });
1707
+ };
1708
+ const claimSlot = async (base, want, keep = [], allowed = (target, name) => planStore.allowExternal(target, name) && planStore.allowExternal(target, target ? wizards.get(target)?.name : undefined), internalName) => {
1558
1709
  const asked = normalizeTag(want);
1559
1710
  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
- },
1711
+ const claimKey = asked.toLowerCase();
1712
+ if (asked && namingClaims.has(claimKey))
1713
+ return { ok: false, status: 409, body: { ok: false, reason: "wizard name is being claimed" } };
1714
+ if (asked)
1715
+ namingClaims.add(claimKey);
1716
+ try {
1717
+ const candidate = asked || (internalName ? pickName(internalName, wizards.all().map((w) => w.name)) : wizards.nextName());
1718
+ if (!allowed(keyOf(base, candidate), candidate))
1719
+ return denied;
1720
+ const reserve = (target, existed) => {
1721
+ // No await between uniqueness check and durable registration.
1722
+ const token = randomUUID();
1723
+ wizards.upsert(target, { name: candidate, spawning: BOOT_ID, birthToken: token, spawnReused: existed || undefined });
1724
+ return { ok: true, target, name: candidate, existed, token };
1597
1725
  };
1726
+ const clash = asked ? wizards.byName(asked) : undefined;
1727
+ if (clash?.spawning)
1728
+ return { ok: false, status: 409, body: { ok: false, reason: "wizard is being spawned", name: clash.name } };
1729
+ // 名字全机唯一, 撞上别的机器人的 wizard 照样拒 —— 但不顶掉它、不复用它的槽, 也不报它的死活:
1730
+ // 那边的 wizard 在这个机器人眼里不存在, 只剩「名字被占了」这一个事实。
1731
+ if (clash && !sameBot(base, clash.target)) {
1732
+ return { ok: false, status: 409, body: { ok: false, reason: `名字 '.${clash.name}' 已被占用 (名字全局唯一) —— 换个名字`, name: clash.name } };
1733
+ }
1734
+ if (clash && !allowed(clash.target, clash.name))
1735
+ return denied;
1736
+ const bound = !!clash && m.chatTargets(baseOfKey(clash.target)).includes(clash.target);
1737
+ if (clash && !bound && baseOfKey(clash.target) === base && tagOfKey(clash.target)) {
1738
+ return reserve(clash.target, true);
1739
+ }
1740
+ if (clash && !(await displace(asked, keep))) {
1741
+ const info = bound ? (await m.peers(clash.target)).find((p) => p.target === clash.target) : undefined;
1742
+ const alive = info?.paneAlive ?? false;
1743
+ const idleForMs = info?.lastActivity ? Date.now() - info.lastActivity : undefined;
1744
+ const idleDesc = idleForMs !== undefined ? `静默 ${Math.round(idleForMs / 60000)} 分钟` : "从没动过";
1745
+ const advice = alive
1746
+ ? "它的 pane 还活着 —— 除非这就是同一件事的延续, 否则换个名字重新生, 别把不相关的活塞给一个已经有职责的 wizard"
1747
+ : bound
1748
+ ? `pane 已经不在了 (${idleDesc}), 真要复用这个名字就先 stop_wizard({name:"${clash.name}", mode:"end"}) 收掉, 再用同一个名字重新生 —— 拿到干净的上下文; 别直接 send_peer 唤醒它接手, 它会带着上一件事的记忆答你这件`
1749
+ : "这个名字被一个一天内还活动过、眼下没有会话的 wizard 占着 —— 换个名字 (它静默满一天后名字会自动让出)";
1750
+ return {
1751
+ ok: false,
1752
+ status: 409,
1753
+ body: {
1754
+ ok: false,
1755
+ reason: `名字 '.${clash.name}' 已经属于一个${alive ? "活着的" : "不再活跃的"} wizard (名字全局唯一) —— ${advice}`,
1756
+ name: clash.name,
1757
+ address: clash.name,
1758
+ alive,
1759
+ busy: info?.busy ?? false,
1760
+ idleForMs,
1761
+ },
1762
+ };
1763
+ }
1764
+ if (!allowed(keyOf(base, candidate), candidate) || (clash && !allowed(clash.target, clash.name)))
1765
+ return denied;
1766
+ const taken = new Set([
1767
+ ...m.chatTargets(base).map(tagOfKey),
1768
+ ...wizards.all().filter((w) => baseOfKey(w.target) === base).map((w) => tagOfKey(w.target)),
1769
+ ].filter(Boolean));
1770
+ const slot = uniqueTag(candidate, taken);
1771
+ const target = keyOf(base, slot);
1772
+ // An identity rename may have claimed the address while stale eviction awaited IO.
1773
+ if (wizards.byName(candidate))
1774
+ return { ok: false, status: 409, body: { ok: false, reason: "wizard name was claimed during spawn preparation" } };
1775
+ return allowed(target, candidate) ? reserve(target, false) : denied;
1776
+ }
1777
+ catch (e) {
1778
+ if (e instanceof AutoNameExhausted)
1779
+ return { ok: false, status: 409, body: { ok: false, reason: e.message } };
1780
+ throw e;
1781
+ }
1782
+ finally {
1783
+ if (asked)
1784
+ namingClaims.delete(claimKey);
1598
1785
  }
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
1786
  };
1609
1787
  // ── 给人看的消息 ──────────────────────────────────────────────────
1610
1788
  // send_peer 把话塞进另一个 agent 的输入框 (驱动它干活); 这条把话贴进一个聊天
@@ -1657,8 +1835,43 @@ const main = async () => {
1657
1835
  return;
1658
1836
  }
1659
1837
  const asker = askerOf(self);
1838
+ const activeId = openingOfSelf(self)?.env?.eventId;
1839
+ const active = activeId ? events.get(activeId) : undefined;
1840
+ const receipt = !!active?.payload.peer?.from.receipt;
1841
+ const leading = receipt ? /^(RESULT|Result|result|结论|NEED)[ \t]*[::]/.exec(content) : null;
1842
+ const closing = receipt ? parseClosing(content).kind : "none";
1843
+ if (receipt && active) {
1844
+ const row = active.serves ? router.get(active.serves) : undefined;
1845
+ if (active.canceled || active.interjected || handoffFinalSuppressed(active) || !ownsReplyEvent(active) || row?.outcome?.status === "canceled") {
1846
+ json(res, 409, { ok: false, reason: "这笔任务已取消或归属已变更,回执报告不再公开。" });
1847
+ return;
1848
+ }
1849
+ if (closing === "none" && (!row || !["open", "need", "error"].includes(row.status))) {
1850
+ json(res, 409, { ok: false, reason: "这笔任务已收口或无在途任务,回执确认不公开;新结果用 RESULT:,新问题用 NEED:。" });
1851
+ return;
1852
+ }
1853
+ }
1854
+ const shown = receipt && (leading || closing !== "none") ? stripForChat(content) : content;
1855
+ if (!shown.trim()) {
1856
+ json(res, 400, { ok: false, reason: "markdown body required" });
1857
+ return;
1858
+ }
1859
+ const deliveries = [];
1660
1860
  for (const dest of dests) {
1661
- postAs(self, dest, content);
1861
+ if (active?.payload.peer?.from.receipt) {
1862
+ const purpose = closing === "none" ? "progress" : "report";
1863
+ const reportKind = closing === "need" ? "need" : "done";
1864
+ const coverage = purpose === "report" ? receiptCoverage(active, dest, closing === "need", content) : undefined;
1865
+ const revision = finalId(active.id, dest, content);
1866
+ const id = events.delivery({ id: finalId(active.id, dest, revision), eventId: active.id, target: self,
1867
+ turn: active.turn ?? active.id, channel: dest, revision, body: shown, purpose, reportKind,
1868
+ chunks: m.formatFinal(self, active.turn ?? active.id, dest, shown), ...(coverage ? { coverage } : {}) });
1869
+ const claims = events.deliveryClaims(coverage);
1870
+ deliveries.push({ channel: dest, id, states: claims.length ? claims.flatMap((c) => c.states) : events.deliveryStatus(id),
1871
+ ...(claims.length > 1 ? { claims } : {}) });
1872
+ }
1873
+ else
1874
+ postAs(self, dest, content);
1662
1875
  recordPost({ target: self, channel: dest, body: content, ...(asker ? { asker } : {}) });
1663
1876
  }
1664
1877
  // 微信群聊: 人超过 ctxTtlSec 没说话时微信会静默丢弃, 消息只能压着等他下次开口 —— 如实告诉调用方, 别回「已发送」。
@@ -1667,7 +1880,8 @@ const main = async () => {
1667
1880
  const name = (d) => chatNameOf(cfg, d) || d;
1668
1881
  json(res, 200, {
1669
1882
  ok: true,
1670
- sent: dests.filter((d) => !held.includes(d)).map(name),
1883
+ sent: dests.filter((d) => !held.includes(d) && !deliveries.some((x) => x.channel === d)).map(name),
1884
+ ...(deliveries.length ? { deliveries, note: "已持久化;相同事实复用既有投递,实际状态见 states,不确定 ACK 不自动重发。" } : {}),
1671
1885
  ...(held.length ? { held: held.map(name), note: `微信群聊 ${held.map(name).join("、")} 那头超过 ${cfg.weixin.ctxTtlSec}s 没说话: 微信会静默丢弃主动推送, 这条已压着, 对方下次在微信里开口时送达 (定时提醒做不到准点)` } : {}),
1672
1886
  });
1673
1887
  });
@@ -1934,6 +2148,7 @@ const main = async () => {
1934
2148
  const attempt = p.attemptId ? workflow.byTurn(e.speaker, p.from.event ?? "") : undefined;
1935
2149
  // 单已收但这一笔的行还 open = 收工前就派出去的活, 照投 (见 close_job)。
1936
2150
  return planStore.healthy() && !e.canceled && (!p.from.receipt || planContinuationAllowed(e.target))
2151
+ && (!p.transferred || !e.serves || events.latestTransfer(e.serves)?.id === e.id)
1937
2152
  && (!p.job || jobs.get(p.job)?.status === "open" || (!!e.serves && router.get(e.serves)?.status === "open")) && !attempt?.canceled
1938
2153
  && (!p.after?.length || prereqsOf(e.speaker, p.after).every((r) => r.state === "done" || r.state === "pending"));
1939
2154
  },
@@ -1941,6 +2156,15 @@ const main = async () => {
1941
2156
  if (e.execution !== "queued")
1942
2157
  return;
1943
2158
  const p = e.payload.peer;
2159
+ if (p.transferred && e.serves) {
2160
+ const row = router.get(e.serves);
2161
+ if (!row || row.owner !== e.speaker && row.owner !== e.target || events.latestTransfer(e.serves)?.id !== e.id)
2162
+ throw new Error("transfer intent no longer owns this route");
2163
+ // The input is durable before move. A crash here replays this projection
2164
+ // in the injector, without dropping the original human route.
2165
+ if (row.owner !== e.target)
2166
+ router.commit(ops.move({ event: row.event, owner: e.target }));
2167
+ }
1944
2168
  // 旧 watcher 的 continuation 代数已退役 (§9.4): 收口由事件自己的 serves 决定。
1945
2169
  if (p.attemptId)
1946
2170
  accountWorkflow(workflow.byTurn(e.speaker, p.from.event ?? ""));
@@ -1999,7 +2223,7 @@ const main = async () => {
1999
2223
  if (p.attemptId && p.tell)
2000
2224
  workflow.intent(p.attemptId, p.tell);
2001
2225
  const raw = await m.injectText(e.target, e.payload.text, undefined, {
2002
- from: p.from, channel: e.channel, requireIdle: e.priority === "normal",
2226
+ from: p.from, channel: e.channel, requireIdle: e.priority === "normal", noRespawn: e.id.startsWith("route:") && e.id.includes(":resume:"),
2003
2227
  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
2228
  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
2229
  eventId: e.id,
@@ -2015,12 +2239,23 @@ const main = async () => {
2015
2239
  // 没有行 (老事件 / 不带 serves 的注入) → 照旧放行。
2016
2240
  // 已落定的那一笔只放追述 (§4.3): 判据与 onFinal 的 retellOps 同源 (router.finalReleased)。
2017
2241
  // 调用方可能拿着 result 落定前的快照, 正文按事件库现读。
2242
+ m.setEventOwnerGate(ownsReplyEvent);
2243
+ m.setEventProgressGate((e) => {
2244
+ if (handoffFinalSuppressed(e) || e.quiet || e.canceled || e.interjected || !ownsReplyEvent(e))
2245
+ return false;
2246
+ const row = e.serves ? router.get(e.serves) : undefined;
2247
+ if (!row || row.owner !== e.target || row.status !== "open")
2248
+ return false;
2249
+ const kind = finalKind(router.table(), e.serves, e.result?.body ?? "");
2250
+ return !e.result || kind === "progress" || kind === "wait";
2251
+ });
2018
2252
  m.setEventFinalGate((e) => {
2019
- if (e.canceled || e.interjected)
2253
+ if (handoffFinalSuppressed(e))
2254
+ return false;
2255
+ if (e.quiet || e.canceled || e.interjected || !ownsReplyEvent(e))
2020
2256
  return false;
2021
- return router.finalReleased(e.target, e.serves, (events.get(e.id) ?? e).result?.body ?? "");
2257
+ return router.finalReleased(e.target, e.serves, (events.get(e.id) ?? e).result?.body ?? "", reportSource(events.get(e.id) ?? e));
2022
2258
  });
2023
- m.setEventHandedAway((e) => { const row = e.serves ? router.get(e.serves) : undefined; return !!row && row.owner !== e.target; });
2024
2259
  // boot 的路由表侧恢复 (T8): held 重查前置、answered 重投一次; 二者幂等, 与 events.resume
2025
2260
  // 重放的注入/回执撞上无害。
2026
2261
  void m.restored.then(() => { events.resume(); settleHeld(); replayAnswered(); });
@@ -2036,7 +2271,8 @@ const main = async () => {
2036
2271
  // 自己就是计数器, 不另落一份。一次 tick 扫一遍收件箱就够 (这一轮不会再产新事件)。
2037
2272
  const fruitless = new Map();
2038
2273
  events.status().events.forEach((e) => {
2039
- if (!e.serves || e.result || e.canceled || e.execution !== "complete")
2274
+ if (!e.serves || e.result || e.canceled || e.payload.peer?.from.receipt
2275
+ || !(e.execution === "complete" || e.execution === "uncertain" && e.exposure === "consumed" && e.turnClosedAt))
2040
2276
  return;
2041
2277
  fruitless.set(e.serves, (fruitless.get(e.serves) ?? 0) + 1);
2042
2278
  });
@@ -2282,11 +2518,11 @@ const main = async () => {
2282
2518
  ...(afters.length ? { after: afters } : {}), ...(plan.serves ? { serves: plan.serves } : {}) });
2283
2519
  /** 建完行才发现不该派 (计划许可 / 预算在等待期间变了): 把刚开的那一行收掉 —— 不然它一直
2284
2520
  * 挂在 owedBy 上, 名册里也永远「欠着」一件没人干的活。 */
2285
- const dropRow = () => { if (newRow && serves)
2521
+ const dropRow = () => { if (newRow && serves && plan.op !== "transfer")
2286
2522
  router.commit(ops.gc({ event: serves })); };
2287
2523
  // 注入事件的 id 就是对那一笔的引用 (§9.3): 派活 = `route:<event>`, 续问 = `route:<event>:r<legs>`;
2288
2524
  // 不开行 (none) 时没有行可指, 退回一句一号的合成 id。
2289
- const eventId = serves ? `route:${serves}${legs > 1 ? `:r${legs}` : ""}` : `p:${self}:${target}:${at}:${legs}`;
2525
+ const eventId = serves ? plan.op === "transfer" ? `route:${serves}:transfer:${at}:${target}` : `route:${serves}${legs > 1 ? `:r${legs}` : ""}` : `p:${self}:${target}:${at}:${legs}`;
2290
2526
  let attemptId;
2291
2527
  const beginNode = (queued) => {
2292
2528
  if (!planAllowed() || (planned && !planned.alive()))
@@ -2306,7 +2542,8 @@ const main = async () => {
2306
2542
  // urgent 只打断你自己派的那一轮 (或它自己的回执轮): 手上若是别的 wizard / 人 / 定时任务的活,
2307
2543
  // 打断就误伤了那件 —— 降成 normal, 等它这一轮做完再投。
2308
2544
  const others = asked === "urgent" && wasBusy ? await othersTurn(target, self) : undefined;
2309
- const policy = others ? "normal" : asked;
2545
+ // Transfers need a separate input turn, including when the receiver becomes busy.
2546
+ const policy = others || plan.op === "transfer" ? "normal" : asked;
2310
2547
  if (canceledBeforeDispatch() || !actorStillValid()) {
2311
2548
  dropRow();
2312
2549
  return { status: 409, body: { ok: false, reason: "caller authority or dispatch permit changed before acceptance" } };
@@ -2325,7 +2562,7 @@ const main = async () => {
2325
2562
  }
2326
2563
  let accepted;
2327
2564
  try {
2328
- accepted = events.accept({ id: eventId, target, speaker: self, channel, serves, asker, priority: policy, deadline: deadlineAt,
2565
+ accepted = acceptPeerDispatch({ id: eventId, target, speaker: self, channel, serves, asker, priority: policy, deadline: deadlineAt,
2329
2566
  payload: { text, peer: {
2330
2567
  from: { kind: "peer", from: self, event: serves, ...(jobId ? { job: jobId } : {}), ...(isPublic ? { public: true } : {}), ...(handedChat ? { transferred: true } : {}), ...(asker ? { asker } : {}) },
2331
2568
  envelope: envelopeFor(self, channel, { asker, legs, ...(legs > 1 ? { re: true } : {}),
@@ -2333,14 +2570,12 @@ const main = async () => {
2333
2570
  ...(wantReceipt ? { tell } : {}), job: jobId, kind, attemptId, after: afters,
2334
2571
  role: roleParam(body.role), free: freed,
2335
2572
  ...(plan.op === "transfer" ? { transferred: true } : {}),
2336
- } } });
2573
+ } } }, plan.op === "transfer");
2337
2574
  }
2338
2575
  catch (error) {
2576
+ dropRow();
2339
2577
  return { status: 409, body: { ok: false, eventId, reason: errText(error) } };
2340
2578
  }
2341
- // 交出的是人问的那一笔: 那句话的气泡跟着行走, 接手方开轮时接过去续写 (不另开新气泡)。
2342
- if (handedChat && serves)
2343
- m.handBubble(self, serves, eventId, target);
2344
2579
  // `re` 把那一笔从 need / error 拉回 open (§3.2 的 need→open / error→open): 行状态是「这份中途态
2345
2580
  // 已经报过了」的去重记号 —— 不拉回去, owner 下一轮交同一个中途态就被静默吞掉 (往返断在这里)。
2346
2581
  if (reTarget && (reTarget.status === "need" || reTarget.status === "error"))
@@ -2490,22 +2725,29 @@ const main = async () => {
2490
2725
  const results = uniq.map((h, i) => {
2491
2726
  const wr = wrs[i];
2492
2727
  // 同一对并发多笔是允许的 (§4.1): 先取**还在排队**的那份 (那份还没进过你的会话); 都取过了就
2493
- // 报最新那件已交付 —— 绝不把已经 paste 过的那份当成新答案交回来。
2728
+ // 报最新那件的投递状态 —— 绝不把已经 paste 过的那份当成新答案交回来。
2494
2729
  const mine = router.settledTo(self).filter((r) => r.owner === h.target).reverse() // byAt → 新在前
2495
- .map((row) => { const box = receiptOf(row); const execution = events.executionOf(box.id); return { row, box, ev: execution && { execution } }; });
2496
- const pick = mine.find((x) => x.ev?.execution === "queued") ?? mine[0];
2730
+ .map((row) => {
2731
+ const box = receiptOf(row);
2732
+ return { row, box,
2733
+ execution: events.executionOf(box.id), exposure: events.exposureOf(box.id) };
2734
+ });
2735
+ const pick = mine.find((x) => x.execution === "queued" && x.exposure === "none") ?? mine[0];
2497
2736
  if (!pick)
2498
2737
  return { name: h.address, idle: wr.idle, ...(wr.reason ? { reason: wr.reason } : {}),
2499
2738
  ...(wr.idle ? { stale: true, reason: "它停下了, 但路由表上没有交回给你的定论 —— 多半停在一个等人点的弹窗上, 或那句话没被接住; peek_peer 看一眼" } : {}) };
2500
- // 回执早一步到了: 这段话已经作为新的一轮进过你的会话, 别处理第二遍。
2501
- if (pick.ev && pick.ev.execution !== "queued")
2739
+ // Only an unexposed queued input can be claimed. Execution state alone
2740
+ // proves neither consumption nor non-delivery, including old tombstones.
2741
+ if (pick.execution !== "queued" || pick.exposure !== "none")
2502
2742
  return { name: h.address, idle: wr.idle,
2503
- ...(pick.ev.execution === "uncertain"
2504
- ? { delivery: "uncertain", reason: "回执可能已递交,需按 receipt eventId 对账;没有自动重投或再次领取。" }
2505
- : { delivered: true }) };
2743
+ eventId: pick.box.id, delivered: pick.exposure === "consumed", exposure: pick.exposure ?? "unknown",
2744
+ delivery: pick.exposure === "consumed" ? "consumed"
2745
+ : pick.execution === "uncertain" || !pick.exposure || pick.exposure === "possible" ? "uncertain" : pick.execution,
2746
+ reason: pick.exposure === "consumed" ? "回执已由会话消费,不重复领取。"
2747
+ : "结果已保存,尚未确认回执消费;按 eventId 对账,不自动重投或再次领取。" };
2506
2748
  // 取走就撤单: 这一笔在收件箱里排着的**全部**回执一起作废 (need 之后又答完时排着两份, 取走
2507
2749
  // 的那一份就是新结论, 旧的那份不再 paste 第二遍); `hand` 确认已交付 (已 handed 的是 no-op)。
2508
- if (pick.ev) {
2750
+ if (pick.execution === "queued") {
2509
2751
  events.cancel((x) => x.id.startsWith(`rcpt:${pick.row.event}:`) && x.execution === "queued", "taken by wait_peer");
2510
2752
  router.commit(ops.hand({ event: pick.row.event, inbox: pick.box.id }));
2511
2753
  }
@@ -2530,30 +2772,19 @@ const main = async () => {
2530
2772
  ? { ok: true, ...results[0] }
2531
2773
  : { ok: true, need, done, satisfied: done >= need, results });
2532
2774
  });
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) });
2775
+ // handoff 只清空原 pane 的原生上下文; 不更新 CLI 进程、工作目录或出生时的宪章。
2776
+ const clearForHandoff = (target, p, submitted) => {
2777
+ if (!handoffAllowed(target))
2778
+ return Promise.resolve({ ok: false, reason: "workflow continuation withdrawn or executor reserved" });
2779
+ if (!p.clear && m.sessionInfo(target)?.sessionId !== p.sid)
2780
+ return Promise.resolve({ ok: false, reason: "native session changed before the clear attempt; refusing another clear" });
2781
+ return m.clearContext(target, p.clear, submitted, () => handoffAllowed(target));
2547
2782
  };
2548
- // POST /handoff — 交接一个 pane 的会话给一个全新会话。先让目标会话把当前工作
2549
- // 压成一份"零上下文也能接手"的交接简报,等它写完并抓取,再 restartFresh 换一个
2550
- // 全新的进程(同名、同 cwd),把简报作为新会话的首条消息贴进去。拒绝对调用方
2551
- // 自身操作(会 deadlock,同 /peers/send)。
2552
2783
  /** 交接自己: 简报由调用方给, 排在它这一轮说完之后 (见 handoff.ts)。/handoff 点名点到
2553
2784
  * 自己与老路由 /wizard/handoff-self 共用。 */
2554
2785
  const handoffSelf = (self, brief) => {
2555
- if (!planContinuationAllowed(self))
2556
- return { status: 409, body: { ok: false, reason: "workflow continuation withdrawn" } };
2786
+ if (!handoffAllowed(self))
2787
+ return { status: 409, body: { ok: false, reason: "workflow continuation withdrawn or executor reserved" } };
2557
2788
  if (brief.length < 40)
2558
2789
  return { status: 400, body: { ok: false, reason: "brief 太短 —— 要写到零上下文也能接手: 目标 / 已完成 / 当前状态 / 下一步 / 关键文件与坑" } };
2559
2790
  const info = m.sessionInfo(self);
@@ -3431,7 +3662,7 @@ const main = async () => {
3431
3662
  }
3432
3663
  const name = (b.name ?? "").toString().trim();
3433
3664
  const description = (b.description ?? "").toString().trim() || clipLine(task, 120);
3434
- // 空 name 交给 bearAs / claimSlot 按职责自动取名避重, 不当作显式点名复用旧记录。
3665
+ // 空 name 交给 bearAs / claimSlot 按持久序号自动取名避重, 不当作显式点名复用旧记录。
3435
3666
  const tier = taskTier;
3436
3667
  const born = await bearAs(self, { inherit: false, name, description, tier, ...(lead ? { lead: true } : {}) }, undefined, dispatchOrigin?.authority);
3437
3668
  if (born.status !== 200) {
@@ -3555,7 +3786,7 @@ const main = async () => {
3555
3786
  return { status: jc.status, body: { ok: false, reason: jc.reason } };
3556
3787
  }
3557
3788
  }
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());
3789
+ const planBirthAllowed = (target, name = normalizeTag(b.name ?? b.tag)) => planStore.allowSpawn(self, jobId, planned, name, target) && (!planned || planned.alive());
3559
3790
  if (!planBirthAllowed() || (planned && !!b.task))
3560
3791
  return { status: 409, body: { ok: false, reason: "dedicated plan job only permits taskless runner spawn" } };
3561
3792
  // 带 task 派下去 = 派活: 同样过方向闸, 不论带不带 job (新生的目标还不在任何单里, 实际只有 1 / 链深两条会拦)。
@@ -3639,20 +3870,20 @@ const main = async () => {
3639
3870
  } };
3640
3871
  }
3641
3872
  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)));
3873
+ const slotR = await claimSlot(base, askedName, [self, source], (target, name) => planBirthAllowed(target, name) && (!target || planBirthAllowed(target, wizards.get(target)?.name ?? name)));
3645
3874
  if (!slotR.ok) {
3646
3875
  return { status: slotR.status, body: slotR.body };
3647
3876
  }
3648
- const { target, slot: tag } = slotR;
3877
+ const { target, name } = slotR;
3878
+ const releaseBirth = () => releaseSlot(slotR);
3649
3879
  // claimSlot 会复用同名的冷记录 (detached 正是「叫回一个老名字」的场景): 生不出来时
3650
3880
  // 只回滚这一次的新记录, 不能把那个老 wizard 的记忆和家谱一起删掉。
3651
- const existed = !!wizards.get(target);
3652
3881
  const forkOf = inherit && source !== self ? source : undefined;
3653
3882
  // 先落身份再 spawn: 宪章是从注册表渲染出来的, 记录不在就渲染出一个无名分身。
3654
- if (!planBirthAllowed(target, normalizeTag(askedName) || tag) || (jobId && jobs.get(jobId)?.status !== "open"))
3883
+ if (!ownsSlot(slotR) || !planBirthAllowed(target, name) || (jobId && jobs.get(jobId)?.status !== "open")) {
3884
+ releaseBirth();
3655
3885
  return { status: 409, body: { ok: false, reason: "plan canceled/job reserved during spawn preparation" } };
3886
+ }
3656
3887
  wizards.upsert(target, {
3657
3888
  description: (b.description ?? "").toString().trim(),
3658
3889
  // 显式写 undefined: upsert 是合并写, 复用的冷记录里旧的 parent 会留下来。
@@ -3669,15 +3900,16 @@ const main = async () => {
3669
3900
  // 生完才清; 这个进程中途没了, 下次开机由 sweepUnborn 认出并收掉。
3670
3901
  spawning: BOOT_ID,
3671
3902
  });
3672
- const name = wizards.rename(target, normalizeTag(askedName) || tag);
3673
3903
  const charter = charterFor(target, { ...(detached ? {} : { parent: self }), forkOf, inherited: inherit, cli: pick.cli });
3674
3904
  const task = (b.task ?? "").toString().trim();
3675
3905
  // 先盖章再生: 分叉出来的 transcript 里躺着被克隆者的旧回复, 时刻早于这一枚章,
3676
3906
  // wait_peer 才不会把它们当成分身对这件活的答复。
3677
3907
  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())
3908
+ const birthGuard = () => ownsSlot(slotR) && (!authority || controlValid(self, authority)) && planBirthAllowed(target, wizards.get(target)?.name ?? name) && (!jobId || jobs.get(jobId)?.status === "open");
3909
+ if (!birthGuard()) {
3910
+ releaseBirth();
3680
3911
  return { status: 409, body: { ok: false, reason: "executor reservation changed before spawn" } };
3912
+ }
3681
3913
  const r = await m.cloneSession({
3682
3914
  guard: birthGuard,
3683
3915
  parent: source,
@@ -3693,13 +3925,13 @@ const main = async () => {
3693
3925
  }).catch((e) => ({ ok: false, reason: `spawn threw: ${String(e)}`, inherited: false }));
3694
3926
  // 生不出来就回滚身份 —— 否则名字被一个永远没有会话的记录占住。
3695
3927
  if (!r.ok) {
3696
- if (existed)
3697
- wizards.upsert(target, { spawning: undefined });
3698
- else
3699
- wizards.drop(target);
3928
+ releaseBirth();
3700
3929
  return { status: 500, body: { ok: false, reason: r.reason } };
3701
3930
  }
3702
- wizards.upsert(target, { spawning: undefined, clonedFrom: r.inherited ? sourceInfo?.sessionId ?? "" : "", ...(r.inherited ? {} : { forkOf: undefined }) });
3931
+ if (!ownsSlot(slotR))
3932
+ return { status: 409, body: { ok: false, reason: "birth identity replaced during spawn" } };
3933
+ finishSlot(slotR);
3934
+ wizards.upsert(target, { clonedFrom: r.inherited ? sourceInfo?.sessionId ?? "" : "", ...(r.inherited ? {} : { forkOf: undefined }) });
3703
3935
  const kid = briefOf(self, target);
3704
3936
  // 出生不再发群气泡 (人要看的是结论, 不是谁生了谁 —— 过程在 rolepage 里);
3705
3937
  // 同群的 wizard 仍要知道群里多了一个成员。工单里的临时工连这条也省掉。
@@ -3726,18 +3958,21 @@ const main = async () => {
3726
3958
  ? openDispatchRow({ self, target, op: plan.op, deadlineAt, job: jobId, ...(asker ? { asker } : {}), ...(plan.serves ? { serves: plan.serves } : {}) })
3727
3959
  : "";
3728
3960
  dispatchedEvent = serves;
3729
- const eventId = serves ? `route:${serves}` : `p:${self}:${target}:${taskAt}:1`;
3961
+ const eventId = serves ? plan?.ok && plan.op === "transfer" ? `route:${serves}:transfer:${taskAt}:${target}` : `route:${serves}` : `p:${self}:${target}:${taskAt}:1`;
3730
3962
  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);
3963
+ try {
3964
+ acceptPeerDispatch({ id: eventId, target, speaker: self, channel: handedChat, serves, asker, priority: "normal", deadline: deadlineAt,
3965
+ payload: { text: task, peer: {
3966
+ from: { kind: "peer", from: self, event: serves, ...(jobId ? { job: jobId } : {}), ...(handedChat ? { transferred: true } : {}), ...(asker ? { asker } : {}) },
3967
+ envelope: envelopeFor(self, handedChat, { asker, ...(quiet ? { quiet: true } : {}) }) + (b.lead === true ? renderReminder({ wezard: "lead" }, renderLeadPlaybook()) : ""),
3968
+ ...(quiet ? {} : { tell: birthTell }),
3969
+ job: jobId, kind: "task", spawned: true, role: roleParam(body.role),
3970
+ ...(plan?.ok && plan.op === "transfer" ? { transferred: true } : {}),
3971
+ } } }, !!plan?.ok && plan.op === "transfer");
3972
+ }
3973
+ catch (error) {
3974
+ return { status: 409, body: { ok: false, target, name, dispatched: false, reason: errText(error) } };
3975
+ }
3741
3976
  }
3742
3977
  else if (jobId && birthGuard()) {
3743
3978
  jobs.attach(jobId, { target, task: "", spawned: true, ...(roleParam(body.role) ? { role: roleParam(body.role) } : {}) });
@@ -3912,6 +4147,10 @@ const main = async () => {
3912
4147
  json(res, 502, { ok: false, target, reason: done.reason });
3913
4148
  return;
3914
4149
  }
4150
+ if (wizards.get(target)?.birthToken !== victimRec?.birthToken) {
4151
+ json(res, 409, { ok: false, target, reason: "wizard identity changed during stop; identity cleanup withheld" });
4152
+ return;
4153
+ }
3915
4154
  const detached = end && b.forget ? forgetWizard(target) : [];
3916
4155
  // 打断只是停了它这一轮, 它还在; 只有结束才是名册变了。
3917
4156
  if (end)
@@ -4340,7 +4579,7 @@ const main = async () => {
4340
4579
  ];
4341
4580
  // ── 改配置会不会动到宪章 ─────────────────────────────────────────
4342
4581
  // 宪章是 --append-system-prompt 压进去的, 随进程终身: 改了进宪章的配置, 已在跑的 wizard
4343
- // 看不见, 要 handoff 重开 (重新渲染宪章) 才换上。不按字段清单判 —— 宪章读哪些配置随代码变,
4582
+ // 看不见, 需要显式 /new 才重新渲染宪章; handoff /clear 保留启动时的系统提示。不按字段清单判 —— 宪章读哪些配置随代码变,
4344
4583
  // 清单迟早漏; 改前改后各渲染一遍有会话的 wizard 的宪章, 变了的就是受影响的。
4345
4584
  const charterSections = (text) => new Map(text.split(/^(?=## )/m).map((sec) => [sec.split("\n", 1)[0].replace(/^## /, ""), sec]));
4346
4585
  // 「出生时同群的 wizard」是出生快照 (宪章自己声明名册随时可查), 且渲染途中的懒定居
@@ -4377,49 +4616,22 @@ const main = async () => {
4377
4616
  }
4378
4617
  };
4379
4618
  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
- * 自动交接也要等它这一轮说完才动手, 这句话仍成立。 */
4619
+ /** /clear 不重新渲染进程启动时的宪章; 只通知, 不暗中重启会话。 */
4388
4620
  const announceCharterImpact = (c, hits) => {
4389
4621
  if (!hits.length)
4390
4622
  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、自动补名) = 主机器人视角, 单机器人时就是全部。
4623
+ const ask = `${c.reload ? "守护进程 reload 之后, " : ""}需要显式 /new 才能载入新宪章;handoff /clear 保留原进程的系统提示,不会使这次改动生效`;
4624
+ hits.filter((h) => h.target !== c.self).forEach((h) => notices.post([h.target], `${c.what} 改动了你的宪章 (系统提示, 变了的段: ${h.sections.join(" / ")}); ${ask}`));
4398
4625
  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)}`));
4626
+ const mine = hits.find((h) => h.target === c.self);
4414
4627
  return [
4415
4628
  "",
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)}`] : []),
4629
+ `⚠ 这次改动进了宪章 (系统提示)。${ask}。受影响 ${shown.length} 个: ${shown.map((h) => displayName(h.target)).join(" ")}`,
4630
+ ...(mine ? [`**你自己也在其中** (变了的段: ${mine.sections.join(" / ")})`] : []),
4419
4631
  ].join("\n");
4420
4632
  };
4421
4633
  // 所有会动宪章的写入 (config_set、人 `/name`、name_chat、自动补名) 共用这一个守卫: 写前写后各渲染一遍
4422
- // 挂在 pane 上的 wizard 的宪章, 变了的、pane 还活着的自动交接 (hot) 或挂提醒 (reload, 见 announceCharterImpact)。
4634
+ // 挂在 pane 上的 wizard 的宪章, 变了的、pane 还活着的挂提醒 (见 announceCharterImpact)。
4423
4635
  // 快照到写完之间不能有 await —— 别人的写入插进来, 前后就对不上了。已经写了: 比对失败只少一句提醒, 不能让调用方以为没写而重发。
4424
4636
  guardCharter = (write, change) => {
4425
4637
  const targets = attachedWizards();
@@ -4430,7 +4642,7 @@ const main = async () => {
4430
4642
  return { r, note: Promise.resolve("") };
4431
4643
  const fail = (e) => {
4432
4644
  log.warn({ err: errText(e), what: c.what }, "宪章影响比对失败");
4433
- return `\n(宪章影响没比对出来: ${errText(e)} —— 改的若是进宪章的内容, 受影响的 wizard 要 handoff 才换上)`;
4645
+ return `\n(宪章影响没比对出来: ${errText(e)} —— 改的若是进宪章的内容, 受影响的 wizard 需要显式 /new 才载入;handoff /clear 不刷新系统提示)`;
4434
4646
  };
4435
4647
  try {
4436
4648
  const hits = charterImpact(before, (c.after ?? charterSnapshot)(targets));
@@ -4738,7 +4950,7 @@ const main = async () => {
4738
4950
  const eventId = `quiet:${t}:${turn}`;
4739
4951
  const revision = finalId(eventId, base, text);
4740
4952
  events.delivery({ id: finalId(eventId, base, revision), eventId, target: t, turn, channel: base,
4741
- revision, body: text, chunks: m.formatFinal(t, turn, base, text) });
4953
+ revision, body: text, purpose: "task", chunks: m.formatFinal(t, turn, base, text) });
4742
4954
  }
4743
4955
  log.info({ target: t, quiet: isQuiet(reply), len: reply.length }, "quiet task: 一轮结束");
4744
4956
  quietRelays.drop(turn);
@@ -4762,15 +4974,20 @@ const main = async () => {
4762
4974
  const rec = tasks.get(taskId);
4763
4975
  const owner = rec ? ownerOf(rec) : target;
4764
4976
  const ownerName = settleName(wizards, chatNameOf(cfg, owner), owner) || "task";
4765
- const slotR = await claimSlot(base, "", `${ownerName}-task-${taskId.slice(0, 4)}`);
4766
- if (!guard())
4977
+ const slotR = await claimSlot(base, "", [], undefined, `${ownerName}-task-${taskId.slice(0, 4)}`);
4978
+ if (!guard()) {
4979
+ if (slotR.ok)
4980
+ releaseSlot(slotR);
4767
4981
  return { ok: false, reason: "scheduled runner withdrawn during slot selection" };
4982
+ }
4768
4983
  if (!slotR.ok)
4769
4984
  return { ok: false, reason: "起白板 wizard 失败: 挑不出槽位" };
4770
4985
  const runner = slotR.target;
4771
- const allowed = () => guard() && planStore.allowExternal(runner, wizards.get(runner)?.name);
4772
- if (!allowed())
4986
+ const allowed = () => ownsSlot(slotR) && guard() && planStore.allowExternal(runner, wizards.get(runner)?.name);
4987
+ if (!allowed()) {
4988
+ releaseSlot(slotR);
4773
4989
  return { ok: false, reason: "workflow plan reserves task runner" };
4990
+ }
4774
4991
  const info = m.sessionInfo(target);
4775
4992
  const why = busy ? ` (起因: ${displayName(target)} 当时正忙)` : "";
4776
4993
  wizards.upsert(runner, {
@@ -4779,16 +4996,20 @@ const main = async () => {
4779
4996
  voiceOf: owner,
4780
4997
  bornAt: Date.now(),
4781
4998
  });
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 });
4999
+ const runnerName = slotR.name;
5000
+ const spawned = await m.newSession(runner, runnerName, info?.cli, { cwd: info?.cwd, model: info?.model, silent: true, guard: allowed })
5001
+ .catch((e) => ({ ok: false, reason: `spawn threw: ${String(e)}` }));
4784
5002
  if (!spawned.ok) {
4785
- wizards.drop(runner);
5003
+ releaseSlot(slotR);
4786
5004
  return { ok: false, reason: `起白板 wizard 失败: ${spawned.reason ?? "unknown"}` };
4787
5005
  }
5006
+ if (!ownsSlot(slotR))
5007
+ return { ok: false, reason: "task runner identity replaced during spawn" };
5008
+ finishSlot(slotR);
4788
5009
  if (!allowed()) {
4789
- if (m.sessionInfo(runner)?.sessionId === spawned.sessionId) {
5010
+ if (ownsSlot(slotR) && m.sessionInfo(runner)?.sessionId === spawned.sessionId) {
4790
5011
  await m.killPane(runner);
4791
- wizards.drop(runner);
5012
+ releaseSlot(slotR);
4792
5013
  }
4793
5014
  return { ok: false, reason: "scheduled runner withdrawn after spawn" };
4794
5015
  }
@@ -4806,7 +5027,7 @@ const main = async () => {
4806
5027
  ? { guard: allowed, from: { kind: "task", taskId }, channel: "", envelope: renderTaskEnvelope(taskId, { event: turn }) }
4807
5028
  : { guard: allowed, fromChat: true, from: { kind: "task", taskId }, envelope: renderTaskEnvelope(taskId) });
4808
5029
  if (!inj.ok) {
4809
- wizards.drop(runner);
5030
+ releaseSlot(slotR);
4810
5031
  return inj;
4811
5032
  }
4812
5033
  // 没有人会对这个一次性分身喊 stop_wizard, 只能自己等它闲下来再收。30min
@@ -4821,8 +5042,10 @@ const main = async () => {
4821
5042
  log.warn({ runner, reason: idle.reason }, "task runner: 30min 未闲下来, 放弃自动回收");
4822
5043
  return;
4823
5044
  }
5045
+ if (!ownsSlot(slotR) || m.sessionInfo(runner)?.sessionId !== spawned.sessionId)
5046
+ return;
4824
5047
  await m.killPane(runner);
4825
- wizards.drop(runner);
5048
+ releaseSlot(slotR);
4826
5049
  })();
4827
5050
  return inj;
4828
5051
  };
@@ -4894,6 +5117,7 @@ const main = async () => {
4894
5117
  registry: tasks,
4895
5118
  log: log.child({ mod: "tasks" }),
4896
5119
  inject: scheduledTaskInject,
5120
+ ready: bridge.restored,
4897
5121
  // gate 里的 `sh` 默认就在目标 wizard 此刻的工作区跑 —— 任务文件里不必写死绝对路径。
4898
5122
  cwdOf: (t) => { const c = bridge.getCwd(t); return c.runningCwd || c.pendingCwd || c.defaultCwd; },
4899
5123
  // 没写 target 的日程兜底到排班者那个机器人的默认聊天 (副机器人没配就不放枪, 不冒用主机器人)。