wezard 2.6.1 → 2.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/config.example.jsonc +3 -1
  3. package/dist/daemon/answer-transfer.js +56 -0
  4. package/dist/daemon/answer-transfer.js.map +1 -0
  5. package/dist/daemon/approval.js +1 -1
  6. package/dist/daemon/approval.js.map +1 -1
  7. package/dist/daemon/cfg-sync.js +1 -1
  8. package/dist/daemon/cfg-sync.js.map +1 -1
  9. package/dist/daemon/chat-name.js +4 -4
  10. package/dist/daemon/chat-name.js.map +1 -1
  11. package/dist/daemon/config-api.js +2 -2
  12. package/dist/daemon/config-api.js.map +1 -1
  13. package/dist/daemon/image-input.js +34 -0
  14. package/dist/daemon/image-input.js.map +1 -0
  15. package/dist/daemon/inbound.js +4 -11
  16. package/dist/daemon/inbound.js.map +1 -1
  17. package/dist/daemon/index.js +880 -280
  18. package/dist/daemon/index.js.map +1 -1
  19. package/dist/daemon/jobs.js +32 -13
  20. package/dist/daemon/jobs.js.map +1 -1
  21. package/dist/daemon/last-response.js +16 -6
  22. package/dist/daemon/last-response.js.map +1 -1
  23. package/dist/daemon/message-format.js +40 -0
  24. package/dist/daemon/message-format.js.map +1 -0
  25. package/dist/daemon/mirror-bridge.js +673 -295
  26. package/dist/daemon/mirror-bridge.js.map +1 -1
  27. package/dist/daemon/pane-input.js +48 -0
  28. package/dist/daemon/pane-input.js.map +1 -0
  29. package/dist/daemon/peers.js +15 -14
  30. package/dist/daemon/peers.js.map +1 -1
  31. package/dist/daemon/pending-items.js +8 -0
  32. package/dist/daemon/pending-items.js.map +1 -1
  33. package/dist/daemon/receipts.js +346 -120
  34. package/dist/daemon/receipts.js.map +1 -1
  35. package/dist/daemon/scheduled-delivery.js +52 -0
  36. package/dist/daemon/scheduled-delivery.js.map +1 -0
  37. package/dist/daemon/spawn-tmux.js +7 -2
  38. package/dist/daemon/spawn-tmux.js.map +1 -1
  39. package/dist/daemon/steward-model.js +10 -10
  40. package/dist/daemon/steward-model.js.map +1 -1
  41. package/dist/daemon/tasks.js +83 -27
  42. package/dist/daemon/tasks.js.map +1 -1
  43. package/dist/daemon/tmux-buffer.js +44 -9
  44. package/dist/daemon/tmux-buffer.js.map +1 -1
  45. package/dist/daemon/weixin-cmd.js +2 -4
  46. package/dist/daemon/weixin-cmd.js.map +1 -1
  47. package/dist/daemon/wizard.js +2 -1
  48. package/dist/daemon/wizard.js.map +1 -1
  49. package/dist/daemon/workflow-nodes.js +217 -0
  50. package/dist/daemon/workflow-nodes.js.map +1 -0
  51. package/dist/daemon/workflow-plan-http.js +83 -0
  52. package/dist/daemon/workflow-plan-http.js.map +1 -0
  53. package/dist/daemon/workflow-plans.js +274 -0
  54. package/dist/daemon/workflow-plans.js.map +1 -0
  55. package/dist/mcp/server.js +27 -1
  56. package/dist/mcp/server.js.map +1 -1
  57. package/dist/shared/config.js +12 -10
  58. package/dist/shared/config.js.map +1 -1
  59. package/dist/shared/json-map-store.js +46 -9
  60. package/dist/shared/json-map-store.js.map +1 -1
  61. package/dist/shared/keyed-scheduler.js +51 -0
  62. package/dist/shared/keyed-scheduler.js.map +1 -0
  63. package/dist/shared/message-footer.js +34 -0
  64. package/dist/shared/message-footer.js.map +1 -0
  65. package/dist/shared/model-tiers.js +1 -2
  66. package/dist/shared/model-tiers.js.map +1 -1
  67. package/dist/shared/reminder.js +3 -0
  68. package/dist/shared/reminder.js.map +1 -1
  69. package/dist/shared/session-label.js +19 -1
  70. package/dist/shared/session-label.js.map +1 -1
  71. package/dist/shared/workflow-plan.js +36 -0
  72. package/dist/shared/workflow-plan.js.map +1 -0
  73. package/package.json +1 -1
@@ -28,7 +28,7 @@ import { loadMirrorStore } from "./mirror-store.js";
28
28
  import { startMirror, installMirrorEventListener } from "./mirror-bridge.js";
29
29
  import { backendFor, runTmux, setTmuxTimeoutReporter, spawnTmuxClaude } from "./spawn-tmux.js";
30
30
  import { bindCardChannel, installApprovalEventListener, makeApproveHandler, requestConfirm } from "./approval.js";
31
- import { initDetailPersistence, makeDetailHandler, chatHandlers, configureRemoteForward, chatUrlFor, setWorldFactsProvider, currentHumanQueryOf, recordPost, recordCharter, backfillCharter } from "./detail.js";
31
+ import { initDetailPersistence, makeDetailHandler, chatHandlers, configureRemoteForward, chatUrlFor, buildChatUrl, getDetail, setWorldFactsProvider, currentHumanQueryOf, recordPost, recordCharter, backfillCharter } from "./detail.js";
32
32
  import { EMPTY_FACTS } from "../shared/world.js";
33
33
  import { clearAutoWindow, initAutoWindowPersistence, setAutoWindow } from "./session-cache.js";
34
34
  import { makeMessageHandler } from "./outbound.js";
@@ -37,9 +37,11 @@ import { drainForReload, listPending } from "./pending.js";
37
37
  import { makeClaimStartHandler, makeClaimStatusHandler, makeClaimResetHandler, } from "./claim.js";
38
38
  import { makeWedocBridge } from "./wedoc.js";
39
39
  import { errText, installResponseTracker } from "./last-response.js";
40
+ import { footerTarget, responseFormatPolicy } from "./message-format.js";
40
41
  import { scanClaudeSessions } from "./session-scan.js";
41
42
  import { startScheduler, migrateLegacySchedules, promptWantsFreshWizard, renderTask, sinceOf, TASK_POSTPONE_MS, TASK_QUIET_WAIT_MS, } from "./tasks.js";
42
43
  import { openTaskRegistry } from "./task-registry.js";
44
+ import { deliverScheduled } from "./scheduled-delivery.js";
43
45
  import { describeTrigger, nextFire, parseTrigger, WHEN_HELP } from "../shared/trigger.js";
44
46
  import { isQuiet, slugify, uniqueId } from "../shared/task-file.js";
45
47
  import { baseOfKey, bindMultiBot, bindTagLinker, botOfKey, headed, ownHead, isInternalKey, keyOf, linkTags, normalizeTag, sameBot, speakerHead, tagFromCwd, tagHead, tagLink, tagOfKey, uniqueTag, withTagHeader } from "../shared/session-label.js";
@@ -56,7 +58,11 @@ import { cacheTouchMs, cacheTtlSec, clipMiddle, contextFiles, firstStamp, parseC
56
58
  import { keepalivePingSigs } from "../shared/keepalive.js";
57
59
  import { expandHome } from "../shared/paths.js";
58
60
  import { loadJsonMap } from "../shared/json-map-store.js";
59
- import { createReceipts, deadlineOf, kAttr, kOfAttr, newTurn } from "./receipts.js";
61
+ import { answerRecoveryAction, answerSourceLost, answerTargetOccupied, answerTransferOrigin, createAnswerTransfers } from "./answer-transfer.js";
62
+ import { createWorkflowNodes } from "./workflow-nodes.js";
63
+ import { loadWorkflowPlans, createWorkflowPlanRunner } from "./workflow-plans.js";
64
+ import { registerWorkflowPlanRoutes } from "./workflow-plan-http.js";
65
+ import { createReceipts, canonicalTurn, deadlineOf, kAttr, kOfAttr, newTurn } from "./receipts.js";
60
66
  import { renderInFlight } from "../shared/turn-state.js";
61
67
  import { startIdleNudge } from "./idle-nudge.js";
62
68
  import { askerFromEnvelope, renderReminder } from "../shared/reminder.js";
@@ -84,6 +90,7 @@ const main = async () => {
84
90
  // 定时调度器的 `inject` 真正实现, 赋值在 mirror-mode 的大块里 (要用到那里面的
85
91
  // wizards / m / notifyChat), 但 startScheduler 在那块外面接线 —— 见文件尾。
86
92
  // 记忆整理者的执行体, 同样只有 mirror 模式给得出 —— 赋值见 scheduledTaskInject 旁边。
93
+ let stopReceipts = () => { };
87
94
  let stewardRun;
88
95
  let stewardWiring = {};
89
96
  let scheduledTaskInject;
@@ -149,7 +156,7 @@ const main = async () => {
149
156
  // last-response tracker enables inbound's `quote` dedup of bot self-replies,
150
157
  // and its chat-gate drops header-only pushes (empty messages) daemon-wide.
151
158
  // 微信出站不走企微长连接, 不必等它连上。
152
- installResponseTracker(ws.client, log.child({ mod: "chat-gate" }), (c) => wx.owns(c));
159
+ installResponseTracker(ws.client, log.child({ mod: "chat-gate" }), (c) => wx.owns(c), (context) => responseFormatPolicy(cfg, context, (target) => chatUrlFor(cfg.daemon, target)));
153
160
  const mirrorStore = loadMirrorStore(cfg.wrc.mirror.attachmentsFile);
154
161
  // 定时任务表 —— 每条任务是 ~/.wezard/tasks/<id>.task.mjs 一份可注入代码的配置
155
162
  // (见 shared/task-file.ts)。目录是热加载的: wizard 改完文件不用 reload 守护进程。
@@ -170,6 +177,7 @@ const main = async () => {
170
177
  const known = (t) => bridge.chatTargets(t).includes(t);
171
178
  // 名册在下面的 mirror 块里才绑 (bindWizardStore) —— 这里全走 wizardStore() 惰性取。
172
179
  bindTagLinker({
180
+ targetOfUrl: (url) => footerTarget(url, new URL(".", buildChatUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, "")).href, (id) => { const r = getDetail(id); return r && "target" in r ? r.target : undefined; }, known),
173
181
  urlOf: (t) => chatUrlFor(cfg.daemon, t),
174
182
  resolve: (name, from) => {
175
183
  const t = wizardStore()?.byName(name)?.target;
@@ -338,12 +346,22 @@ const main = async () => {
338
346
  json(res, 400, { ok: false, reason: "target required (and cfg.defaultChat empty)" });
339
347
  return;
340
348
  }
349
+ if (!planStore.allowExternal(target, wizards.get(target)?.name)) {
350
+ json(res, 409, { ok: false, reason: "workflow plan reserves this executor" });
351
+ return;
352
+ }
341
353
  const spawnLog = log.child({ mod: "mirror", sub: "spawn-init", target });
342
354
  const r = await spawnTmuxClaude({ cfg, log: spawnLog, windowName: target, systemPrompt: charterFor(target, { cli: backendFor(cfg).name }) });
343
355
  if (!r.ok) {
344
356
  json(res, 500, { ok: false, reason: r.reason });
345
357
  return;
346
358
  }
359
+ if (!planStore.allowExternal(target, wizards.get(target)?.name)) {
360
+ if (r.tmuxPane)
361
+ await runTmux(["kill-pane", "-t", r.tmuxPane]);
362
+ json(res, 409, { ok: false, reason: "executor reserved during spawn; binding withheld" });
363
+ return;
364
+ }
347
365
  const att = m.attach({ sessionId: r.sessionId, jsonlPath: r.jsonlPath, target, tmuxPane: r.tmuxPane, tmuxSession: r.tmuxSession });
348
366
  json(res, att.ok ? 200 : 500, att.ok ? { ok: true, sessionId: r.sessionId, tmuxSession: r.tmuxSession, tmuxPane: r.tmuxPane, target } : { ok: false, reason: att.reason });
349
367
  });
@@ -394,6 +412,10 @@ const main = async () => {
394
412
  json(res, 400, { ok: false, reason: "target required (and cfg.defaultChat empty)" });
395
413
  return;
396
414
  }
415
+ if (!planStore.allowExternal(target, wizards.get(target)?.name)) {
416
+ json(res, 409, { ok: false, reason: "workflow plan reserves this executor" });
417
+ return;
418
+ }
397
419
  const sessions = await scanClaudeSessions();
398
420
  // 已绑在别的机器人聊天上的会话与不存在同形。
399
421
  const hit = sessions.find((s) => s.sessionId === sessionId && ((t) => !t || sameBot(target, t))(m.targetForSession(s.sessionId)));
@@ -401,6 +423,10 @@ const main = async () => {
401
423
  json(res, 404, { ok: false, reason: `session ${sessionId} not found among live tmux sessions` });
402
424
  return;
403
425
  }
426
+ if (!planStore.allowExternal(target, wizards.get(target)?.name) || !planStore.allowExternal(m.targetForSession(hit.sessionId))) {
427
+ json(res, 409, { ok: false, reason: "workflow plan reserves this executor" });
428
+ return;
429
+ }
404
430
  const att = m.attach({ sessionId: hit.sessionId, jsonlPath: hit.jsonlPath, target, tmuxPane: hit.tmuxPane, tmuxSession: hit.tmuxSession, cwd: hit.cwd });
405
431
  json(res, att.ok ? 200 : 500, att.ok
406
432
  ? { ok: true, sessionId: hit.sessionId, label: hit.label, target, cwd: hit.cwd, tmuxSession: hit.tmuxSession }
@@ -458,6 +484,10 @@ const main = async () => {
458
484
  return;
459
485
  }
460
486
  const { target, slot: tag } = slotR;
487
+ if (!planStore.allowExternal(target, wizards.get(target)?.name ?? tag)) {
488
+ json(res, 409, { ok: false, reason: "workflow plan reserves this executor" });
489
+ return;
490
+ }
461
491
  const existed = !!wizards.get(target);
462
492
  const name = wizards.rename(target, normalizeTag(String(body.name ?? body.tag ?? "")) || tag);
463
493
  const model = (body.model ?? "").toString().trim();
@@ -465,7 +495,7 @@ const main = async () => {
465
495
  const keepalive = typeof body.keepalive === "boolean" ? body.keepalive : cfg.wrc.mirror.keepalive.spawnDefault;
466
496
  log.child({ mod: "mirror", sub: "sessions-new", target }).info({ self, cwd, foreign, cli: body.cli, model, keepalive }, "spawning peer session");
467
497
  // silent: 新 wizard 就位不进群 (生命周期事件), 同群的 wizard 从名册增量里得知。
468
- const r = await m.newSession(target, name, body.cli, { cwd, model, keepalive, silent: true });
498
+ const r = await m.newSession(target, name, body.cli, { cwd, model, keepalive, silent: true, guard: () => planStore.allowExternal(target, wizards.get(target)?.name ?? name) });
469
499
  if (!r.ok && !existed)
470
500
  wizards.drop(target);
471
501
  if (r.ok)
@@ -503,7 +533,12 @@ const main = async () => {
503
533
  json(res, 400, { ok: false, reason: "text required" });
504
534
  return;
505
535
  }
506
- const r = await m.injectText(target, text);
536
+ const guard = () => planStore.allowExternal(target, wizards.get(target)?.name);
537
+ if (!guard()) {
538
+ json(res, 409, { ok: false, reason: "workflow plan reserves this executor" });
539
+ return;
540
+ }
541
+ const r = await m.injectText(target, text, undefined, { guard });
507
542
  json(res, r.ok ? 200 : 500, r);
508
543
  });
509
544
  // One-shot project switch: bind the chat-wide cwd AND apply it right here
@@ -521,6 +556,10 @@ const main = async () => {
521
556
  json(res, 400, { ok: false, reason: "target required (or pass sessionId of an attached chat)" });
522
557
  return;
523
558
  }
559
+ if (!planStore.allowExternal(target, wizards.get(target)?.name)) {
560
+ json(res, 409, { ok: false, reason: "workflow plan fixes executor cwd" });
561
+ return;
562
+ }
524
563
  // keep = 人回答了「不用换, 就用现在这个」。只落一笔确认, 不碰 pane —— 把
525
564
  // 「保持现状」也走成 setPendingCwd + newSession 的话, 代价是白杀一个会话。
526
565
  if (body.keep === true) {
@@ -540,7 +579,7 @@ const main = async () => {
540
579
  // Same shape as inbound's /new: windowName = tag for tagged sessions,
541
580
  // principal for the default one. No explicit cwd — newSession reads the
542
581
  // pendingCwd just queued and clears it once applied.
543
- const r = await m.newSession(target, tagOfKey(target) || target, undefined, {});
582
+ const r = await m.newSession(target, tagOfKey(target) || target, undefined, { guard: () => planStore.allowExternal(target, wizards.get(target)?.name) });
544
583
  json(res, r.ok ? 200 : 500, r.ok
545
584
  ? { ok: true, target, sessionId: r.sessionId, cwd: r.cwd }
546
585
  : { ok: false, reason: r.reason, target, runningCwd: set.runningCwd, pendingCwd: set.pendingCwd });
@@ -716,15 +755,74 @@ const main = async () => {
716
755
  return id.startsWith("human:") ? id.slice("human:".length) : "";
717
756
  };
718
757
  // 挂起事项表 (见 pending-items.ts): 派活时记一行, 回执落定时由下面的 onOutcome 记账。
719
- const ledger = createLedger(loadJsonMap(cfg.wrc.mirror.pendingFile, pendingGc));
758
+ const ledger = createLedger(loadJsonMap(cfg.wrc.mirror.pendingFile, pendingGc, { strict: true }));
759
+ const workflow = createWorkflowNodes(`${cfg.wrc.mirror.receiptsFile}.nodes.json`);
760
+ const planStore = loadWorkflowPlans(`${cfg.wrc.mirror.receiptsFile}.plans.json`, workflow.prereq);
761
+ const pendingBirthJobs = new Map();
762
+ let advancePlans = () => { };
763
+ // Lifecycle continuations carry the same live attempt, never a new task.
764
+ // Human inbound remains an explicit intervention; agent handoff/receipts
765
+ // must not revive canceled or unconfirmed planned executions.
766
+ const answers = createAnswerTransfers(loadJsonMap(join(cfg.daemon.stateDir, "answer-transfers.json"), undefined, { strict: true }));
767
+ m.setAnswerSilencer(answers.source);
768
+ const answerSends = new Map();
769
+ const deliverTransferredAnswer = (id, out) => {
770
+ const pending = answerSends.get(id);
771
+ if (pending)
772
+ return pending;
773
+ const p = (async () => {
774
+ m.stopAnswerProgress(id);
775
+ const r = answers.outcome(id, out);
776
+ if (!r)
777
+ return true;
778
+ if (!answers.delivering(id))
779
+ return false;
780
+ try {
781
+ await m.deliverAnswer(id, r.target, r.sourceTurn, r.channel, r.prefix, r.outcome.body, !["done", "need"].includes(r.outcome.status));
782
+ answers.delivered(id);
783
+ recordPost({ target: r.target, channel: r.channel, body: r.outcome.body, ...(r.tell.asker ? { asker: r.tell.asker } : {}) });
784
+ return true;
785
+ }
786
+ catch (e) {
787
+ answers.uncertain(id);
788
+ log.error({ id, err: e.message }, "transferred answer delivery acknowledgement uncertain; automatic retry withheld to prevent duplicate answers");
789
+ return false;
790
+ }
791
+ })().finally(() => answerSends.delete(id));
792
+ answerSends.set(id, p);
793
+ return p;
794
+ };
795
+ const planContinuationAllowed = (target) => {
796
+ const r = planStore.reservation(target, wizards.get(target)?.name);
797
+ if (!r)
798
+ return planStore.healthy();
799
+ const n = r.nodes.find((n) => n.to === target);
800
+ const a = n && workflow.byTurn(r.owner, n.turn);
801
+ return !!n && !!a && planStore.healthy() && r.state === "active" && n.phase === "observing"
802
+ && !a.canceled && a.acceptance !== "rejected" && jobs.get(r.plan.job)?.status === "open" && ["running", "needs-input", "errored"].includes(a.execution)
803
+ && (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");
804
+ };
720
805
  const receipts = createReceipts({
806
+ deliverAnswer: deliverTransferredAnswer,
807
+ workflowPrereq: workflow.prereq,
721
808
  idleNow: m.idleNow,
722
- onOutcome: (s, status, bodyText, bySender) => ledger.outcome(s.from, s.turn ?? "", status, parseClosing(bodyText).text || bodyText, bySender),
809
+ onOutcome: (s, status, bodyText, bySender) => {
810
+ // Pin transferred-answer terminal state synchronously with the receipt
811
+ // outcome; delivery may still be waiting for the target to become idle.
812
+ if (s.answerTransfer) {
813
+ answers.outcome(s.answerTransfer, { status, body: bodyText });
814
+ m.stopAnswerProgress(s.answerTransfer);
815
+ }
816
+ workflow.outcome(s.from, s.turn ?? "", status, bodyText);
817
+ advancePlans();
818
+ ledger.outcome(s.from, s.turn ?? "", status, parseClosing(bodyText).text || bodyText, bySender);
819
+ },
723
820
  jobTally: (id) => jobs.tally(id),
724
821
  parkedNow: m.parkedNow,
725
822
  settleJob: (id, target, outcome, artifacts) => { jobs.settle(id, target, outcome, artifacts); },
726
823
  accept: (id, body) => { const j = jobs.get(id); return j?.status === "open" ? rejectReason(j.accept ?? "result", parseClosing(body)) : undefined; },
727
- rebound: async (x, why) => (await m.injectText(x.to, `(守护进程代 ${displayName(x.from)} 打回一次: 这件活是工单里的一份, 你的回复${why}。请按约定收口 —— 末尾一行 \`RESULT: <结论>\`, 交付了文件每个加一行 \`ARTIFACT: <路径> — 一句话\`; 真缺信息就 \`NEED: <问题>\`。)`, undefined, {
824
+ rebound: async (x, why) => workflow.byTurn(x.from, x.turn) || !planStore.allowExternal(x.to, wizards.get(x.to)?.name) ? false : (await m.injectText(x.to, `(守护进程代 ${displayName(x.from)} 打回一次: 这件活是工单里的一份, 你的回复${why}。请按约定收口 —— 末尾一行 \`RESULT: <结论>\`, 交付了文件每个加一行 \`ARTIFACT: <路径> — 一句话\`; 真缺信息就 \`NEED: <问题>\`。)`, undefined, {
825
+ guard: () => planStore.allowExternal(x.to, wizards.get(x.to)?.name),
728
826
  from: { kind: "peer", from: x.from, turn: x.turn, ...(x.asker ? { asker: x.asker } : {}) },
729
827
  channel: x.channel,
730
828
  envelope: envelopeFor(x.from, x.channel, { turn: x.turn, legs: x.legs, re: true, asker: x.asker }),
@@ -733,18 +831,20 @@ const main = async () => {
733
831
  paneLive: m.paneLive,
734
832
  nameOf: displayName,
735
833
  log,
736
- store: loadJsonMap(cfg.wrc.mirror.receiptsFile),
737
- holdStore: loadJsonMap(cfg.wrc.mirror.holdsFile),
834
+ store: loadJsonMap(cfg.wrc.mirror.receiptsFile, undefined, { strict: true }),
835
+ holdStore: loadJsonMap(cfg.wrc.mirror.holdsFile, undefined, { strict: true }),
738
836
  // 放行停放着的 after 件: 走 tell_peer 同一条注入路径, 沿用停放时的件号 / 频道 / 父 k。
739
- release: async (h, seen) => {
740
- const r = await tellPeerAs(h.from, h.body, afterNote(h.after, seen), { turn: h.turn, channel: h.channel, ...(h.k ? { k: h.k } : {}), ...(h.asker ? { asker: h.asker } : {}) });
837
+ release: async (h, seen, alive) => {
838
+ const r = await tellPeerAs(h.from, h.body, afterNote(h.after, seen, h.from), { turn: h.turn, channel: h.channel, after: h.after, seen: h.seen, alive, ...(h.k ? { k: h.k } : {}), ...(h.asker ? { asker: h.asker } : {}) });
741
839
  return r.status === 200 ? { ok: true } : { ok: false, reason: String(r.body.reason ?? `HTTP ${r.status}`) };
742
840
  },
743
841
  handingOff,
744
842
  handedOff,
745
843
  replyFor: (to, fromName, since, turn) => m.replyToPeer(to, fromName, since, turn),
746
844
  lastWords: m.lastReply,
747
- deliver: (to, bodyText, meta) => m.injectText(to, clipMiddle(bodyText), undefined, {
845
+ deliver: (to, bodyText, meta, alive) => m.injectText(to, clipMiddle(bodyText), undefined, {
846
+ requireIdle: true,
847
+ guard: () => (!alive || alive()) && planContinuationAllowed(to),
748
848
  // receipt:true 是「这一轮不该再生回执」的记录 —— 回环在结构上就不成立:
749
849
  // 只有 tell_peer 那条路登记 watcher, 这里是直接注入。
750
850
  from: { kind: "peer", from: meta.from, receipt: true, status: meta.status, ...(meta.turn ? { turn: meta.turn } : {}), ...(meta.job ? { job: meta.job, done: meta.done, total: meta.total } : {}), ...(meta.asker ? { asker: meta.asker } : {}) },
@@ -755,9 +855,10 @@ const main = async () => {
755
855
  ...(meta.asker ? { asker: meta.asker } : {}),
756
856
  ...(meta.pending ? { pending: meta.pending } : {}),
757
857
  ...(audienceName(meta.channel, meta.asker) ? { audience: audienceName(meta.channel, meta.asker) } : {}),
758
- }),
858
+ }) + (meta.eventId ? renderReminder({ wezard: "receipt-delivery", event: meta.eventId }, []) : ""),
759
859
  }),
760
860
  });
861
+ stopReceipts = receipts.stop;
761
862
  const turnsLine = (xs, target) => renderInFlight(xs, target, displayName);
762
863
  // reload 前在飞的回执: 等 mirror 把绑定都恢复了再续守 —— 早一步读 target 会拿不到
763
864
  // transcript, 退回按时刻取就可能把上一件事的结论当回执。
@@ -775,9 +876,13 @@ const main = async () => {
775
876
  // 那一轮就只有一个去向, 别的上游拿到的是答给第一份的话。
776
877
  inject: async (t, text, owe, channel) => {
777
878
  const [first, ...rest] = owe;
879
+ if (!planContinuationAllowed(t))
880
+ return { ok: false, reason: "workflow continuation withdrawn" };
881
+ if (!first && planStore.reservation(t, wizards.get(t)?.name))
882
+ return { ok: false, reason: "workflow handoff requires the original attempt" };
778
883
  if (!first)
779
- return m.injectText(t, text, undefined, { channel });
780
- const r = await m.injectText(t, text, undefined, carryOpts(first));
884
+ return m.injectText(t, text, undefined, { channel, guard: () => planContinuationAllowed(t) });
885
+ const r = await m.injectText(t, text, undefined, { ...carryOpts(first), guard: () => planContinuationAllowed(t) });
781
886
  if (r.ok && rest.length)
782
887
  void carryRest(t, rest);
783
888
  return r;
@@ -817,7 +922,7 @@ const main = async () => {
817
922
  for (const end = Date.now() + 20_000; Date.now() < end && (await m.idleNow(t));)
818
923
  await sleep(1000);
819
924
  await m.untilIdle(t, 3600_000);
820
- const r = await m.injectText(t, `(交接续答)接着上面的简报: 你交接前还欠 ${displayName(o.from)} 一个交代${o.turn ? ` (件 \`${o.turn}\`)` : ""} —— 现在只就这件收口。`, undefined, carryOpts(o));
925
+ const r = await m.injectText(t, `(交接续答)接着上面的简报: 你交接前还欠 ${displayName(o.from)} 一个交代${o.turn ? ` (件 \`${o.turn}\`)` : ""} —— 现在只就这件收口。`, undefined, { ...carryOpts(o), guard: () => planContinuationAllowed(t) });
821
926
  log.info({ mod: "handoff", target: t, from: o.from, turn: o.turn, ok: r.ok, reason: r.reason }, "handoff: 欠账续答");
822
927
  }
823
928
  }
@@ -854,6 +959,17 @@ const main = async () => {
854
959
  const p = m.sessionInfo(self)?.jsonlPath;
855
960
  return p ? openingTurn(expandHome(p), pingSigs) : undefined;
856
961
  };
962
+ const lostAnswer = (self) => {
963
+ return answerSourceLost(answers.all(), self, m.answerContext(self)?.turn, openingOfSelf(self)?.ms);
964
+ };
965
+ const terminalAnswerTurn = (self) => {
966
+ const turn = openingOfSelf(self)?.env?.turn;
967
+ return turn ? answers.terminalTarget(self, turn) : undefined;
968
+ };
969
+ const controlSnapshot = (self) => ({ opening: openingOfSelf(self)?.ms, turn: m.answerContext(self)?.turn });
970
+ const controlValid = (self, snapshot) => openingOfSelf(self)?.ms === snapshot.opening && m.answerContext(self)?.turn === snapshot.turn && !lostAnswer(self) && !terminalAnswerTurn(self);
971
+ const hasAnswerChildren = (self, k) => !!k && receipts.states().some((s) => s.from === self && !!s.k && kAttr(s.k) === kAttr(k));
972
+ m.setAnswerSilencer((target, turn) => answers.source(target, turn) || lostAnswer(target));
857
973
  const parentKOf = (self) => {
858
974
  const opening = openingOfSelf(self);
859
975
  // 不用 channelOf: 它把私聊轮 ("") 回落成 home, 私聊轮派出的活就会续回 home 群。
@@ -873,7 +989,34 @@ const main = async () => {
873
989
  // A private dispatch keeps its human origin even though its output channel is empty.
874
990
  bindCardChannel((key) => askerOf(key)?.chat || m.currentChannel(key));
875
991
  // 交接先续做: 它的闸要在回执续守之前立起来, 否则续守的 watcher 会读到换了一半的会话。
876
- void m.restored.then(() => { handoffs.resume(); receipts.resume(); });
992
+ void m.restored.then(() => {
993
+ handoffs.resume();
994
+ answers.all().filter((r) => r.state !== "delivered").forEach((r) => {
995
+ const action = answerRecoveryAction(r);
996
+ if (action === "cancel-before-send") {
997
+ answers.activate(r.id);
998
+ void deliverTransferredAnswer(r.id, { status: "canceled", body: "daemon 重启时回答移交仍在投递,结果不确定;本轮已收止,请重新发送问题。" });
999
+ }
1000
+ else if (action === "ambiguous") {
1001
+ answers.uncertain(r.id);
1002
+ log.error({ id: r.id }, "answer delivery may have reached the chat before restart; automatic retry withheld, user may resend if absent");
1003
+ }
1004
+ else if (action === "deliver-outcome" && r.outcome)
1005
+ void deliverTransferredAnswer(r.id, r.outcome);
1006
+ else if (action === "watch" && !receipts.watching(r.tell))
1007
+ receipts.register(r.tell);
1008
+ });
1009
+ // Confirmed injections may outlive the legacy receipt-store write. Rebuild watching,
1010
+ // using the original turn and intent; uncertain injections remain manual reconciliation.
1011
+ workflow.all().filter((a) => a.tell && ["running", "needs-input", "errored"].includes(a.execution))
1012
+ .forEach((a) => { try {
1013
+ recoverWorkflow(a);
1014
+ }
1015
+ catch (e) {
1016
+ log.error({ err: e.message, attemptId: a.id }, "workflow recovery accounting failed; watcher not restored");
1017
+ } });
1018
+ receipts.resume();
1019
+ });
877
1020
  // 闲置追问 (见 idle-nudge.ts): 对方持续闲着又没回执 → 代发话方同件号问一句进展, 答复照常回执。
878
1021
  // 注入照抄工单打回 (rebound) 那条: 私聊信封挂发话方的名义与件号, 父 k 等由 reask 沿用原件。
879
1022
  void m.restored.then(() => startIdleNudge({
@@ -883,11 +1026,14 @@ const main = async () => {
883
1026
  jobOpen: (id) => jobs.get(id)?.status === "open",
884
1027
  policy: () => ({ afterMs: cfg.wrc.mirror.idleNudge.afterMin * 60_000, max: cfg.wrc.mirror.idleNudge.max }),
885
1028
  ask: async ({ from, to, turn, text }) => {
1029
+ if (workflow.byTurn(from, turn) || !planStore.allowExternal(to, wizards.get(to)?.name))
1030
+ return false;
886
1031
  const x = receipts.reask(from, to, turn);
887
1032
  if (!x)
888
1033
  return false;
889
1034
  ledger.touch(from, turn);
890
1035
  return (await m.injectText(to, text, undefined, {
1036
+ guard: () => planStore.allowExternal(to, wizards.get(to)?.name),
891
1037
  from: { kind: "peer", from, turn, ...(x.asker ? { asker: x.asker } : {}) },
892
1038
  channel: x.channel,
893
1039
  envelope: envelopeFor(from, x.channel, { turn, legs: x.legs, re: true, asker: x.asker }),
@@ -902,7 +1048,13 @@ const main = async () => {
902
1048
  /** 撞上的名字属于一个静默超过一天的 wizard → 顶掉它 (见 evictStaleName), 同群的
903
1049
  * wizard 收到一行名册变动; 返回是否腾出了名字。 */
904
1050
  const displace = async (name, keep) => {
905
- const gone = await evictStaleName(wizards, name, keep, { lastActivity: m.lastActivity, retire: m.killPane });
1051
+ if (!planStore.allowExternal(undefined, name))
1052
+ return false;
1053
+ const gone = await evictStaleName(wizards, name, keep, { lastActivity: m.lastActivity, retire: async (target) => {
1054
+ if (!planStore.allowExternal(target, wizards.get(target)?.name))
1055
+ throw new Error("workflow plan reserves this identity; eviction withheld");
1056
+ return m.killPane(target);
1057
+ } });
906
1058
  if (!gone)
907
1059
  return false;
908
1060
  const slot = !!tagOfKey(gone.target);
@@ -916,14 +1068,20 @@ const main = async () => {
916
1068
  * - 撞上的 wizard 静默超过一天 (不在 `keep` 里) → 顶掉它, 名字归新的;
917
1069
  * - 其余 (还新鲜的) → 409, 附上它的死活, 调用方据此换名或先收掉它。
918
1070
  * 槽位 (key 里的 `#k`) 只是内部 id: 默认取名字本身, 被占了就加序号, 与名字无关。 */
919
- const claimSlot = async (base, want, fallback, keep = []) => {
1071
+ const claimSlot = async (base, want, fallback, keep = [], allowed = (target, name) => planStore.allowExternal(target, name) && planStore.allowExternal(target, target ? wizards.get(target)?.name : undefined)) => {
920
1072
  const asked = normalizeTag(want);
1073
+ const denied = { ok: false, status: 409, body: { ok: false, reason: "workflow plan reserves this executor" } };
1074
+ const candidate = asked || normalizeTag(fallback) || "wizard";
1075
+ if (!allowed(keyOf(base, candidate), candidate))
1076
+ return denied;
921
1077
  const clash = asked ? wizards.byName(asked) : undefined;
922
1078
  // 名字全机唯一, 撞上别的机器人的 wizard 照样拒 —— 但不顶掉它、不复用它的槽, 也不报它的死活:
923
1079
  // 那边的 wizard 在这个机器人眼里不存在, 只剩「名字被占了」这一个事实。
924
1080
  if (clash && !sameBot(base, clash.target)) {
925
1081
  return { ok: false, status: 409, body: { ok: false, reason: `名字 '.${clash.name}' 已被占用 (名字全局唯一) —— 换个名字`, name: clash.name } };
926
1082
  }
1083
+ if (clash && !allowed(clash.target, clash.name))
1084
+ return denied;
927
1085
  const bound = !!clash && m.chatTargets(baseOfKey(clash.target)).includes(clash.target);
928
1086
  if (clash && !bound && baseOfKey(clash.target) === base && tagOfKey(clash.target)) {
929
1087
  return { ok: true, target: clash.target, slot: tagOfKey(clash.target) };
@@ -952,12 +1110,15 @@ const main = async () => {
952
1110
  },
953
1111
  };
954
1112
  }
1113
+ if (!allowed(keyOf(base, candidate), candidate) || (clash && !allowed(clash.target, clash.name)))
1114
+ return denied;
955
1115
  const taken = new Set([
956
1116
  ...m.chatTargets(base).map(tagOfKey),
957
1117
  ...wizards.all().filter((w) => baseOfKey(w.target) === base).map((w) => tagOfKey(w.target)),
958
1118
  ].filter(Boolean));
959
1119
  const slot = uniqueTag(asked || normalizeTag(fallback) || "wizard", taken);
960
- return { ok: true, target: keyOf(base, slot), slot };
1120
+ const target = keyOf(base, slot);
1121
+ return allowed(target, asked || slot) ? { ok: true, target, slot } : denied;
961
1122
  };
962
1123
  // ── 给人看的消息 ──────────────────────────────────────────────────
963
1124
  // send_peer 把话塞进另一个 agent 的输入框 (驱动它干活); 这条把话贴进一个聊天
@@ -968,7 +1129,12 @@ const main = async () => {
968
1129
  // 回执轮但那件活是为上游同伴派的 (k 是 peer)。人说的、定时任务放的、旁支 (chain:false, 无 k)
969
1130
  // 的回执轮都不拦; 管家只在自己的群里豁免。notify 与 send_file 共用这一道闸。
970
1131
  const topOnlyMuted = (self, dests) => {
1132
+ if (lostAnswer(self))
1133
+ return { ok: false, reason: "本轮回答权和控制权已移交;由目标 wizard 对人交付" };
971
1134
  const env = openingOfSelf(self)?.env;
1135
+ const transferred = env?.turn ? answers.target(self, env.turn) : undefined;
1136
+ if (transferred)
1137
+ return !transferred.outcome && transferred.state !== "delivered" ? undefined : { ok: false, reason: "本轮移交回答已经落定;迟到产出不能再次对人发送" };
972
1138
  const belowTop = env?.kind === "peer" && (env.receipt ? kOfAttr(env.k ?? "")?.kind === "peer" : env.private);
973
1139
  const muted = dests.filter((d) => chatPolicyOf(cfg, d).topOnly && !(!tagOfKey(self) && baseOfKey(self) === d));
974
1140
  return muted.length && belowTop
@@ -998,7 +1164,8 @@ const main = async () => {
998
1164
  json(res, 400, { ok: false, reason: `认不出这些聊天: ${bad.join(", ")} (wizard_roster 每行有 home 聊天名, 人侧 /chats; 没起名的聊天寻址不到)` });
999
1165
  return;
1000
1166
  }
1001
- const dests = [...new Set(refs.length ? refs.map((r) => chatBaseOf(cfg, r)) : [channelOf(self)])];
1167
+ const activeTransfer = (() => { const t = openingOfSelf(self)?.env?.turn; return t ? answers.target(self, t) : undefined; })();
1168
+ const dests = [...new Set(refs.length ? refs.map((r) => chatBaseOf(cfg, r)) : [activeTransfer?.channel ?? channelOf(self)])];
1002
1169
  const muted = topOnlyMuted(self, dests);
1003
1170
  if (muted) {
1004
1171
  json(res, 409, muted);
@@ -1036,7 +1203,9 @@ const main = async () => {
1036
1203
  }
1037
1204
  ensureChatNames(self);
1038
1205
  const ref = (b.to ?? "").trim();
1039
- const dest = ref ? chatBaseOf(cfg, ref) : channelOf(self);
1206
+ const transferTurn = openingOfSelf(self)?.env?.turn;
1207
+ const transfer = transferTurn ? answers.target(self, transferTurn) : undefined;
1208
+ const dest = ref ? chatBaseOf(cfg, ref) : transfer?.channel ?? channelOf(self);
1040
1209
  if (!dest || !sameBot(self, dest)) {
1041
1210
  json(res, 400, { ok: false, reason: `认不出聊天: ${ref} (wizard_roster 每行有 home 聊天名, 人侧 /chats; 没起名的聊天寻址不到)` });
1042
1211
  return;
@@ -1200,23 +1369,65 @@ const main = async () => {
1200
1369
  return [...new Set((Array.isArray(raw) ? raw : []).map((x) => String(x).replace(/[`\s]/g, "")).filter(Boolean))];
1201
1370
  };
1202
1371
  /** 放行时挂在信封后面的前置结论: 谁交的、件号、RESULT 与 ARTIFACT 原样 (只进对方输入框)。 */
1203
- const afterNote = (turns, seen) => renderReminder({ wezard: "after" }, [
1372
+ const afterNote = (turns, seen, owner) => renderReminder({ wezard: "after" }, [
1204
1373
  "这件活排在下面这些前置之后, 它们都已落定; 结论原样附上 (谁交的 · 件号):",
1205
1374
  ...turns.flatMap((t) => {
1206
1375
  const x = seen[t];
1207
1376
  if (!x)
1208
1377
  return [];
1209
1378
  const c = parseClosing(x.body, 1500);
1379
+ const versions = workflow.list(owner).find((a) => a.turn === t && a.acceptance === "accepted")?.files ?? [];
1210
1380
  const clean = (v) => v.replace(/<\/?system-reminder[^>]*>/g, "");
1211
1381
  return [
1212
1382
  `- ${displayName(x.to)} · ${t} · ${x.status}: ${c.kind === "result" ? `RESULT: ${clean(c.text)}` : clipLine(clean(x.body), 600) || "(没有正文)"}`,
1213
- ...c.artifacts.map((a) => ` ARTIFACT: ${a.path}${a.note ? ` — ${a.note}` : ""}`),
1383
+ ...c.artifacts.map((a) => {
1384
+ const f = versions.find((f) => f.path === a.path);
1385
+ return f ? ` ARTIFACT: ${f.snapshot} — accepted version sha256 ${f.sha256}; original source ${f.path}` : ` ARTIFACT: ${a.path}${a.note ? ` — ${a.note}` : ""}`;
1386
+ }),
1387
+ ...versions.map((f) => ` VERIFIED ARTIFACT VERSION: ${f.snapshot} · sha256 ${f.sha256} · source ${f.path}; consume this readonly snapshot, not mutable source`),
1214
1388
  ];
1215
1389
  }),
1216
1390
  ]);
1391
+ // One durable key per tracked physical dispatch leg (held release reuses its reservation).
1392
+ const accountWorkflow = (a, recovery = false) => {
1393
+ if (!a.dispatches?.length)
1394
+ throw new Error("historical tracked attempt lacks dispatch accounting evidence; reconcile stopped before retry (no blind budget import)");
1395
+ for (const d of a.dispatches) {
1396
+ jobs.account(a.job, d.key, { target: a.to, task: d.continuation ? "" : d.text, spawned: false, ...(d.role ? { role: d.role } : {}) }, d.kind, d.free, recovery, !!d.free && !!d.continuation);
1397
+ const t = d.tell ?? a.tell;
1398
+ ledger.dispatch(d.key, { turn: a.turn, owner: a.owner, to: a.to, text: d.text, at: d.at, job: a.job, ...(t?.k ? { for: t.k } : {}), ...(t?.asker ? { asker: t.asker } : {}) });
1399
+ }
1400
+ };
1401
+ const recoverWorkflow = (a) => {
1402
+ if (a.canceled || jobs.get(a.job)?.status !== "open")
1403
+ return;
1404
+ accountWorkflow(a, true);
1405
+ if (a.tell && !receipts.watching(a.tell))
1406
+ receipts.register(a.tell, true, a.status && a.body ? { status: a.status, body: a.body } : undefined);
1407
+ };
1408
+ const cancelPeerWork = (target, self, only = () => true) => {
1409
+ const keys = new Set([
1410
+ ...workflow.all().filter((a) => a.to === target && a.execution !== "terminal" && !a.canceled && only({ from: a.owner, turn: a.turn })).map((a) => `${a.owner}\0${a.turn}`),
1411
+ ...receipts.states().filter((s) => s.to === target && only(s)).map((s) => `${s.from}\0${s.turn}`),
1412
+ ]);
1413
+ workflow.cancel(target, undefined, undefined, undefined, only);
1414
+ receipts.cancel(target, self, only);
1415
+ return keys.size;
1416
+ };
1217
1417
  /** tell_peer 的本体: 以 `self` 的名义说一句 —— HTTP 路由与 dispatch 共用。`extra` = 挂在信封后面的
1218
1418
  * 一段 (只进对方输入框, 不进 `text`: 公开时 text 会成群气泡)。 */
1219
- const tellPeerAs = async (self, body, extra = "", held) => {
1419
+ const peerDispatching = new Map();
1420
+ const tellPeerAsInner = async (self, body, extra = "", held, planned, actor) => {
1421
+ const authority = actor?.authority ?? (!held && !planned ? controlSnapshot(self) : undefined);
1422
+ if (!held && !planned && lostAnswer(self))
1423
+ return { status: 409, body: { ok: false, reason: "this turn transferred answer and control; stop dispatching and conclude silently" } };
1424
+ if (!held && !planned && terminalAnswerTurn(self))
1425
+ return { status: 409, body: { ok: false, reason: "transferred answer is terminal; late control actions refused" } };
1426
+ const answer = body.answer;
1427
+ if (answer !== undefined && answer !== "transfer")
1428
+ return { status: 400, body: { ok: false, reason: "answer must be transfer when supplied" } };
1429
+ if (held?.alive && !held.alive())
1430
+ return { status: 409, body: { ok: false, reason: "held dispatch withdrawn" } };
1220
1431
  const text = (body.text ?? "").toString();
1221
1432
  if (!text.trim()) {
1222
1433
  return { status: 400, body: { ok: false, reason: "text required" } };
@@ -1231,12 +1442,12 @@ const main = async () => {
1231
1442
  }
1232
1443
  const { target, foreign } = r;
1233
1444
  // 件号先于一切: `re` 续问沿用那件活的件号、工单与频道 (见 receipts.prepare)。
1234
- const re = (body.re ?? "").toString().trim();
1445
+ const re = canonicalTurn((body.re ?? "").toString());
1235
1446
  // 放行一件停放着的 (after): 件号 / 频道 / 父 k / 链头都沿用停放那一刻定的 —— 依赖边不改 k。
1236
- const turn = held ? { turn: held.turn, legs: 1, channel: held.channel, asker: held.asker, k: held.k } : receipts.prepare(self, target, re || undefined);
1447
+ const turn = planned ? { turn: planned.turn, legs: 1, channel: "", asker: planned.origin.asker, k: planned.origin.k } : held ? { turn: held.turn, legs: 1, channel: held.channel, asker: held.asker, k: held.k } : receipts.prepare(self, target, re || undefined);
1237
1448
  // Capture the origin before waiting for another pane; our own turn may change meanwhile.
1238
- const asker = held ? held.asker : turn.legs > 1 ? turn.asker : askerOf(self);
1239
- const k = held ? held.k : body.chain === false ? undefined : turn.legs > 1 ? turn.k : parentKOf(self);
1449
+ const asker = planned ? planned.origin.asker : held ? held.asker : actor?.asker ?? (turn.legs > 1 ? turn.asker : askerOf(self));
1450
+ const k = planned ? planned.origin.k : held ? held.k : actor?.k ?? (body.chain === false ? undefined : turn.legs > 1 ? turn.k : parentKOf(self));
1240
1451
  // 续问只在那个工单还开着时沿用它: 收了工的工单不再收成员, 续问照样能发。
1241
1452
  // 没带工单的一句顶掉一份还没落定的工单活, 也算在那张工单上 (receipts.register 同理
1242
1453
  // 继承): 不然换个不带 job 的 tell_peer 就绕开了预算。
@@ -1249,14 +1460,56 @@ const main = async () => {
1249
1460
  // 没带 re 的普通 task / ask / dispatch 一律不并进根单。
1250
1461
  const leadJob = jobs.all().find((j) => j.kind === "req" && j.status === "open" && j.owner === self && j.members.some((x) => x.target === target && x.role === "lead"))?.id ?? "";
1251
1462
  const jobId = kind === "fyi" ? "" : askedJob || isOpen(turn.job) || (open?.deferred ? "" : isOpen(open?.job)) || (re && turn.reUnknown ? leadJob : "");
1463
+ const answerContext = answer === "transfer" ? actor?.answerContext ?? m.answerContext(self) : undefined;
1464
+ const answerSourceOpening = answerContext ? actor?.authority.opening ?? openingOfSelf(self)?.ms ?? 0 : 0;
1465
+ const actorStillValid = () => {
1466
+ if (!authority)
1467
+ return true; // held/planned work carries its captured permit.
1468
+ if (openingOfSelf(self)?.ms !== authority.opening || m.answerContext(self)?.turn !== authority.turn || terminalAnswerTurn(self))
1469
+ return false;
1470
+ if (answer === "transfer")
1471
+ return !answers.all().some((r) => r.source === self && r.sourceTurn === authority.turn && r.id !== turn.turn);
1472
+ return !lostAnswer(self);
1473
+ };
1474
+ if (answer === "transfer" && (!answerContext || !asker?.who.startsWith("user:") || k?.kind !== "chat" || !chatPolicyOf(cfg, k.channel).topOnly || kind === "fyi" || re || jobId || held || planned || body.node || body.after?.length || body.receipt === false || body.chain === false || hasAnswerChildren(self, k))) {
1475
+ return { status: 409, body: { ok: false, reason: "answer transfer requires an active human topOnly turn with no child work, job, re, node, after, chain:false or receipt:false" } };
1476
+ }
1252
1477
  if (jobId) {
1253
1478
  const jc = checkJob(jobId, target, (!!open?.need && open.turn === turn.turn) || !!held);
1254
1479
  if (!jc.ok) {
1255
1480
  return { status: jc.status, body: { ok: false, reason: jc.reason } };
1256
1481
  }
1257
1482
  }
1483
+ const trackedRe = workflow.byTurn(self, turn.turn) ?? (re ? workflow.byTurn(self, re) : undefined);
1484
+ if (trackedRe && !trackedRe.accountingVersion && trackedRe.execution !== "terminal")
1485
+ return { status: 409, body: { ok: false, attemptId: trackedRe.id, reason: "historical tracked attempt lacks dispatch accounting evidence; reconcile stopped before retry" } };
1486
+ const rawNode = body.node;
1487
+ if (rawNode !== undefined && (typeof rawNode !== "string" || !rawNode.trim()))
1488
+ return { status: 400, body: { ok: false, reason: "node must not be blank" } };
1489
+ if (trackedRe && rawNode !== undefined && rawNode.trim() !== trackedRe.node)
1490
+ return { status: 409, body: { ok: false, reason: "tracked turn cannot change node" } };
1491
+ const node = rawNode?.trim() ?? trackedRe?.node ?? "";
1492
+ const planAllowed = () => planStore.allowTell(self, jobId, node, target, re, planned, wizards.get(target)?.name);
1493
+ if (!planAllowed())
1494
+ return { status: 409, body: { ok: false, reason: "dedicated plan job/executor only allows runner dispatch or exact owner re" } };
1495
+ if (node && jobId && workflow.list(self, jobId).some((a) => !a.accountingVersion))
1496
+ return { status: 409, body: { ok: false, reason: "historical job has incomplete dispatch accounting evidence; reconcile stopped, close the old job, and open a new job (no guessed budget migration)" } };
1497
+ if (node && (!jobId || jobs.get(jobId)?.owner !== self || kind !== "task" || body.receipt === false)) {
1498
+ return { status: 400, body: { ok: false, reason: "node requires owner job, task, and receipt" } };
1499
+ }
1500
+ if (re && trackedRe && (turn.reUnknown || trackedRe.to !== target))
1501
+ return { status: 409, body: { ok: false, reason: "tracked re cannot start a new attempt; use node after reconciliation" } };
1502
+ // A released hold can survive a crash after confirmed injection but before dropHold.
1503
+ // Recover the watcher from the durable intent; never paste that attempt again.
1504
+ const heldAttempt = held && node ? workflow.byTurn(self, held.turn) : undefined;
1505
+ if (heldAttempt?.tell && ["running", "needs-input", "errored"].includes(heldAttempt.execution)) {
1506
+ recoverWorkflow(heldAttempt);
1507
+ return { status: 200, body: { ok: true, recovered: true, attemptId: heldAttempt.id, turn: held.turn } };
1508
+ }
1509
+ if (heldAttempt?.execution === "terminal" && heldAttempt.status === "done")
1510
+ return { status: 200, body: { ok: true, recovered: true, attemptId: heldAttempt.id } };
1258
1511
  // 派活方向闸 (见 direction.ts): 只拒不改投递路径, 不动回执 k。
1259
- const dr = held ? undefined : checkDirection({ self, target, kind, re: turn.legs > 1, jobId, jobs: jobs.all(), chain: receipts.ancestorsOf(self, parentKOf(self)), nameOf: displayName });
1512
+ const dr = held ? undefined : checkDirection({ self, target, kind, re: turn.legs > 1, jobId, jobs: jobs.all(), chain: planned?.origin.chain ?? receipts.ancestorsOf(self, parentKOf(self)), nameOf: displayName });
1260
1513
  if (dr) {
1261
1514
  return { status: 409, body: { ok: false, gated: "direction", reason: dr } };
1262
1515
  }
@@ -1283,7 +1536,7 @@ const main = async () => {
1283
1536
  const parkIt = prereqs.some((p) => p.state === "pending");
1284
1537
  // 前置都已落定: 不停放, 结论附在信封后面照常投。
1285
1538
  if (afters.length && !parkIt)
1286
- extra += afterNote(afters, Object.fromEntries(afters.map((t, i) => [t, prereqs[i].seen])));
1539
+ extra += afterNote(afters, Object.fromEntries(afters.map((t, i) => [t, prereqs[i].seen])), self);
1287
1540
  // 公开与否由发话方 (LLM) 判断: 公开 = 在它这一轮的公开频道里说, 气泡进群、对方
1288
1541
  // 那一轮的回复也发进这个群; 私聊 (默认) = 只落双方的 rolepage, 回复靠 wait_peer 取。
1289
1542
  // 只有两端都是登记在册的 wizard 才能当着人说: 裸 target 冒充的发话方、forget
@@ -1303,7 +1556,11 @@ const main = async () => {
1303
1556
  }
1304
1557
  // 它正在交接: 这句话等它换完会话再进 —— 投进旧会话会被交接一并带走, 投进重开中的
1305
1558
  // 空 pane 会 resume 回旧 sid (见 handoff.ts)。发话时刻取在这之后。
1559
+ if (answer === "transfer" && handingOff(target))
1560
+ return { status: 409, body: { ok: false, reason: "answer target is handing off; source retains answer and control" } };
1306
1561
  await handedOff(target);
1562
+ if (!actorStillValid())
1563
+ return { status: 409, body: { ok: false, reason: "caller turn/control changed while waiting; dispatch refused" } };
1307
1564
  // 投递策略。对方闲着三者一样 —— 立刻投; 只在它正忙时分岔:
1308
1565
  // normal (默认) 等它这一轮结束再投 —— 派新活的常态: 两个 wizard 同时找第三个时,
1309
1566
  // 两段文本会挤进同一个输入框、被当成一轮读掉, 这在协同网络里不是边角情况而是常态;
@@ -1337,23 +1594,53 @@ const main = async () => {
1337
1594
  }
1338
1595
  // 前置还没落定: 不注入, 登记一份停放 (见 receipts.Hold) —— 件号、频道、父 k、链头定在这一刻,
1339
1596
  // 放行时原样沿用; 工单成员与预算也现在就记, 「齐了吗」照常数。
1597
+ let attemptId;
1598
+ const beginNode = (queued) => {
1599
+ if (!planAllowed() || (planned && !planned.alive()))
1600
+ throw new Error("plan dispatch canceled or job reserved");
1601
+ return node ? workflow.begin(self, jobId, node, turn.turn, target, queued).id : undefined;
1602
+ };
1340
1603
  if (parkIt) {
1604
+ try {
1605
+ attemptId = beginNode(true);
1606
+ }
1607
+ catch (e) {
1608
+ return { status: 409, body: { ok: false, reason: e.message } };
1609
+ }
1341
1610
  const bp = body;
1611
+ if (attemptId) {
1612
+ try {
1613
+ workflow.recordLeg(attemptId, { text, at: Date.now(), tell: { from: self, to: target, channel, job: jobId, turn: turn.turn, at: Date.now(), legs: 1, ...(k ? { k } : {}), ...(asker ? { asker } : {}) }, kind: "task", ...(roleParam(bp.role) ? { role: roleParam(bp.role) } : {}) });
1614
+ accountWorkflow(workflow.byTurn(self, turn.turn));
1615
+ }
1616
+ catch (e) {
1617
+ return { status: 409, body: { ok: false, attemptId, reason: e.message } };
1618
+ }
1619
+ }
1342
1620
  receipts.hold({
1343
1621
  from: self, to: target, turn: turn.turn, at: Date.now(), after: afters, channel, job: jobId,
1344
1622
  ...(k ? { k } : {}), ...(asker ? { asker } : {}),
1345
1623
  // force: 冷门控上面已过一遍, 放行时缓存又凉了也不再退回。
1346
- body: { name: peerAddress(cfg, self, target), text, kind, force: true, ...(jobId ? { job: jobId } : {}), ...(bp.role ? { role: bp.role } : {}), ...(bp.priority ? { priority: bp.priority } : {}), ...(bp.waitSec ? { waitSec: bp.waitSec } : {}), ...(bp.deadline !== undefined ? { deadline: bp.deadline } : {}), ...(bp.receipt === false ? { receipt: false } : {}) },
1624
+ body: { name: peerAddress(cfg, self, target), text, kind, force: true, ...(node ? { node } : {}), ...(jobId ? { job: jobId } : {}), ...(bp.role ? { role: bp.role } : {}), ...(bp.priority ? { priority: bp.priority } : {}), ...(bp.waitSec ? { waitSec: bp.waitSec } : {}), ...(bp.deadline !== undefined ? { deadline: bp.deadline } : {}), ...(bp.receipt === false ? { receipt: false } : {}) },
1347
1625
  });
1348
- if (jobId) {
1626
+ if (jobId && !attemptId) {
1349
1627
  const role = roleParam(bp.role);
1350
1628
  jobs.attach(jobId, { target, task: text, spawned: false, ...(role ? { role } : {}) }, kind === "ask" ? "ask" : "task");
1351
1629
  jobs.spend(jobId);
1352
1630
  }
1353
- return { status: 200, body: { ok: true, parked: true, after: afters, turn: turn.turn, name: peerAddress(cfg, self, target), ...(jobId ? { job: jobId } : {}), receipt: `排在 ${afters.join(" ")} 之后: 前置都 done 才投 (带上它们的结论), 任一失败这件不投、按 canceled 回给你; 不要挂在 wait_peer 上等, 接着干你自己的事` } };
1631
+ return { status: 200, body: { ok: true, parked: true, after: afters, turn: turn.turn, ...(attemptId ? { node, attemptId } : {}), name: peerAddress(cfg, self, target), ...(jobId ? { job: jobId } : {}), receipt: `排在 ${afters.join(" ")} 之后: 前置满足放行条件才投(tracked 要 accepted,legacy 要 done;带上结论), 任一失败这件不投、按 canceled 回给你; 不要挂在 wait_peer 上等, 接着干你自己的事` } };
1632
+ }
1633
+ try {
1634
+ attemptId = beginNode(true);
1354
1635
  }
1636
+ catch (e) {
1637
+ return { status: 409, body: { ok: false, reason: e.message } };
1638
+ }
1639
+ const canceledBeforeDispatch = () => !planAllowed() || (!!planned && !planned.alive()) || (!!held?.alive && !held.alive()) || !!workflow.byTurn(self, turn.turn)?.canceled || (!!jobId && jobs.get(jobId)?.status !== "open");
1355
1640
  // 「忙」= 这一轮还没结束, 或停在审批上等人 —— 都不是能接新活的时候。
1356
1641
  const wasBusy = !(await m.idleNow(target));
1642
+ if (answer === "transfer" && (wasBusy || receipts.states().some((s) => s.to === target)))
1643
+ return { status: 409, body: { ok: false, wasBusy: true, reason: "answer target is busy or has pending work; source retains answer and control" } };
1357
1644
  // urgent 只打断你自己派的那一轮 (或它自己的回执轮): 手上若是别的 wizard / 人 / 定时任务的活,
1358
1645
  // 打断就误伤了那件 —— 降成 normal, 等它这一轮做完再投。
1359
1646
  const others = asked === "urgent" && wasBusy ? await othersTurn(target, self) : undefined;
@@ -1363,12 +1650,20 @@ const main = async () => {
1363
1650
  if (policy === "normal" && wasBusy) {
1364
1651
  const t0 = Date.now();
1365
1652
  // 它那一轮一结束就投: 注册表 status 翻 idle 是事件, 不抽样也不等稳定期。
1366
- const wr = await m.untilIdle(target, waitSec * 1000);
1653
+ const wr = await m.untilIdle(target, waitSec * 1000, { aborted: canceledBeforeDispatch });
1367
1654
  waitedMs = Date.now() - t0;
1655
+ if (!actorStillValid())
1656
+ return { status: 409, body: { ok: false, attemptId, reason: "caller turn/control changed while waiting for idle" } };
1368
1657
  if (!wr.idle) {
1658
+ if (canceledBeforeDispatch())
1659
+ return { status: 409, body: { ok: false, attemptId, reason: "dispatch canceled while waiting for idle" } };
1369
1660
  return { status: 409, body: { ok: false, target, foreign, wasBusy, waitedMs, reason: `它一直在忙, ${waitSec}s 内没闲下来 —— peek_peer 看看它卡在哪; 真急就 priority:"urgent" 打断它` } };
1370
1661
  }
1371
1662
  }
1663
+ if (canceledBeforeDispatch())
1664
+ return { status: 409, body: { ok: false, attemptId, reason: "dispatch canceled while waiting for idle" } };
1665
+ if (!actorStillValid())
1666
+ return { status: 409, body: { ok: false, attemptId, reason: "caller turn/control changed before target control" } };
1372
1667
  if (policy === "urgent" && wasBusy) {
1373
1668
  // 只按 Esc, 不走 /stop 的 teardown: 那会清掉它的注入队列 (别人排着的话一起丢)
1374
1669
  // 并暂停 keepalive —— 这里打断它是为了马上接着干, 不是让它安静。
@@ -1377,6 +1672,8 @@ const main = async () => {
1377
1672
  // 等 Esc 生效、这一轮真收住再投; 收不住也照投 —— 落进输入框排在后面, 退化成 now。
1378
1673
  interrupted = esc.ok && (await m.untilIdle(target, 15_000)).idle;
1379
1674
  waitedMs = Date.now() - t0;
1675
+ if (!actorStillValid())
1676
+ return { status: 409, body: { ok: false, attemptId, reason: "caller turn/control changed during interrupt wait" } };
1380
1677
  }
1381
1678
  // 时刻取在注入**之前**: 晚于那一句落盘的话, 回执定位的下界就偏了。
1382
1679
  const at = Date.now();
@@ -1390,7 +1687,64 @@ const main = async () => {
1390
1687
  // 是"放出去就不管了"的那种派活; fyi 一定不要。对方的信封据此改口, 不再让它收口。
1391
1688
  const wantReceipt = kind !== "fyi" && body.receipt !== false;
1392
1689
  const deadlineAt = deadlineOf(at, deadlineSec === undefined ? undefined : Number(deadlineSec));
1690
+ const blocking = workflow.list(self).find((a) => a.to === target && a.turn !== turn.turn && !["queued", "terminal"].includes(a.execution));
1691
+ if (blocking && kind !== "fyi")
1692
+ return { status: 409, body: { ok: false, reason: `tracked attempt ${blocking.id} still in flight` } };
1693
+ if (receipts.prereqs(self, held?.after ?? afters, held?.seen).some((p) => p.state !== "done"))
1694
+ return { status: 409, body: { ok: false, reason: "prerequisite acceptance/version changed before injection" } };
1695
+ let physicalExposure = false;
1696
+ const dispatchGuard = (phase) => {
1697
+ if (!actorStillValid())
1698
+ return false;
1699
+ if (answers.all().some((r) => r.target === target && answerTargetOccupied(r) && r.id !== turn.turn))
1700
+ return false;
1701
+ if (answerContext && m.answerContext(self)?.turn !== answerContext.turn)
1702
+ return false;
1703
+ if (!planAllowed() || (planned && !planned.alive()) || (held?.alive && !held.alive()))
1704
+ return false;
1705
+ if (jobId && jobs.get(jobId)?.status !== "open") {
1706
+ if (attemptId)
1707
+ workflow.cancel(target, self, jobId, turn.turn);
1708
+ return false;
1709
+ }
1710
+ const a = workflow.byTurn(self, turn.turn);
1711
+ if (a?.canceled || a?.execution === "dispatch-unknown" || a?.execution === "terminal")
1712
+ return false;
1713
+ if (receipts.prereqs(self, held?.after ?? afters, held?.seen).some((p) => p.state !== "done"))
1714
+ return false;
1715
+ const allowed = !phase || !attemptId || workflow.boundary(attemptId);
1716
+ if (allowed && phase)
1717
+ physicalExposure = true;
1718
+ return allowed;
1719
+ };
1720
+ if (!dispatchGuard())
1721
+ return { status: 409, body: { ok: false, attemptId, reason: "dispatch canceled or prerequisites invalidated" } };
1722
+ const trackedTell = { from: self, to: target, channel, job: jobId, at, turn: turn.turn, legs: turn.legs, deadlineAt, ...(answer === "transfer" ? { answerTransfer: turn.turn } : {}), ...(k ? { k } : {}), ...(asker ? { asker } : {}) };
1723
+ if (node) {
1724
+ try {
1725
+ attemptId = beginNode(false);
1726
+ workflow.intent(attemptId, trackedTell);
1727
+ workflow.recordLeg(attemptId, { text, tell: trackedTell, continuation: turn.legs > 1, at, kind: "task", ...(roleParam(body.role) ? { role: roleParam(body.role) } : {}), free: !!open?.need && open.turn === turn.turn }, !!held);
1728
+ accountWorkflow(workflow.byTurn(self, turn.turn));
1729
+ }
1730
+ catch (e) {
1731
+ return { status: 409, body: { ok: false, reason: e.message } };
1732
+ }
1733
+ }
1734
+ if (answerContext && k?.kind === "chat") {
1735
+ try {
1736
+ answers.reserve({ id: turn.turn, source: self, target, sourceTurn: answerContext.turn, sourceOpening: answerSourceOpening, origin: answerTransferOrigin(self, answerContext.turn), channel: k.channel, prefix: answerContext.prefix, tell: trackedTell, state: "reserved" });
1737
+ }
1738
+ catch (e) {
1739
+ return { status: 409, body: { ok: false, reason: e.message } };
1740
+ }
1741
+ if (!m.prepareAnswer(turn.turn, self, answerContext.turn, target, answerContext.channel)) {
1742
+ answers.rollback(turn.turn);
1743
+ return { status: 409, body: { ok: false, reason: "answer stream is no longer writable; source retains answer and control" } };
1744
+ }
1745
+ }
1393
1746
  const inj = await m.injectText(target, text, undefined, {
1747
+ guard: dispatchGuard,
1394
1748
  from: { kind: "peer", from: self, turn: turn.turn, ...(jobId ? { job: jobId } : {}), ...(isPublic ? { public: true } : {}), ...(asker ? { asker } : {}) },
1395
1749
  channel,
1396
1750
  envelope: envelopeFor(self, channel, {
@@ -1402,15 +1756,39 @@ const main = async () => {
1402
1756
  ...(deadlineSec !== undefined ? { deadline: deadlineAt } : {}),
1403
1757
  ...(kind !== "task" ? { act: kind } : {}),
1404
1758
  ...(wantReceipt ? {} : { quiet: true }),
1405
- }) + extra,
1759
+ }) + extra + (answerContext ? renderReminder({ wezard: "answer-transfer" }, ["你已接手这轮人的回答权与控制权。判断为简单问题,直接答给人;原气泡内容会清空,只显示你的进度,终句覆盖气泡并由 daemon 唯一交付,不回给派活方。不要另行 notify 相同终稿;需要追问可直接以自然语言收口。派活方这轮后续产出不再对人发送。"]) : ""),
1760
+ }).catch((e) => {
1761
+ if (attemptId)
1762
+ workflow.dispatched(attemptId, false);
1763
+ if (answerContext)
1764
+ return { ok: false, uncertain: true, reason: e.message };
1765
+ throw e;
1406
1766
  });
1767
+ if (answerContext) {
1768
+ if (inj.ok || inj.uncertain || physicalExposure) {
1769
+ answers.activate(turn.turn);
1770
+ const bubble = await m.takeAnswer(turn.turn, self, answerContext.turn, target, answerContext.channel);
1771
+ receipts.register(trackedTell);
1772
+ if (!inj.ok)
1773
+ void deliverTransferredAnswer(turn.turn, { status: "canceled", body: "回答移交的投递结果不确定,本轮已收止;若未收到回答,请重新发送问题。" });
1774
+ if (!bubble.cleared)
1775
+ return { status: 202, body: { ...inj, ok: false, answer: "transfer", control: "transfer", ownershipTransferred: true, bubbleCleared: false, retryable: false, name: peerAddress(cfg, self, target), turn: turn.turn, reason: "回答权已转交,但原气泡清空未确认(已结束、不可用或 ACK 失败);不能重试移交。目标仍负责最终答复,必要时另发终稿;本轮请停止派活与公开回答。" } };
1776
+ return { status: 200, body: { ...inj, ok: true, answer: "transfer", control: "transfer", name: peerAddress(cfg, self, target), turn: turn.turn, bubbleCleared: bubble.cleared, receipt: "回答权已转交;可用气泡只显示目标进度与终稿;本轮请停止派活与公开回答,静默收口" } };
1777
+ }
1778
+ m.cancelAnswerPreparation(turn.turn);
1779
+ answers.rollback(turn.turn);
1780
+ }
1781
+ if (attemptId)
1782
+ workflow.dispatched(attemptId, inj.ok && !inj.uncertain);
1783
+ if (!dispatchGuard())
1784
+ return { status: 409, body: { ok: false, attemptId, reason: `canceled/invalidated during transport; reconcile possible delivery${inj.reason ? ": " + inj.reason : ""}`, uncertain: !!inj.uncertain || physicalExposure || workflow.byTurn(self, turn.turn)?.execution === "dispatch-unknown" } };
1407
1785
  // 注入失败就不守 —— 没有问话, 也不会有回答。不要回执 (receipt:false / fyi) 就不登记:
1408
1786
  // 一份不守的槽会以同一对的 key 顶掉发话方还在等它答的那件活, 那件的回执就再也来不了。
1409
1787
  // 父 k: 默认这件活算发话方此刻在答的那件的子活 (链式续回); `chain:false` = 旁支,
1410
1788
  // 回执照常回来, 但不挂住它给上游的交代 —— 只有发话方知道这件活是不是为上游派的。
1411
1789
  if (inj.ok && wantReceipt)
1412
- receipts.register({ from: self, to: target, channel, job: jobId, at, turn: turn.turn, legs: turn.legs, deadlineAt, ...(k ? { k } : {}), ...(asker ? { asker } : {}) });
1413
- if (inj.ok && wantReceipt) {
1790
+ receipts.register(attemptId ? workflow.byTurn(self, turn.turn).tell : trackedTell);
1791
+ if (inj.ok && wantReceipt && !attemptId) {
1414
1792
  if (turn.legs > 1)
1415
1793
  ledger.touch(self, turn.turn);
1416
1794
  else
@@ -1418,7 +1796,7 @@ const main = async () => {
1418
1796
  }
1419
1797
  // 工单成员照旧记账 (收工那一条会列出各自那段活); 公开的那一句在群里成气泡。
1420
1798
  // 续问 (re) 不是一段新活: 工单页与留档里该列的仍是当初派的那段。
1421
- if (inj.ok && jobId) {
1799
+ if (inj.ok && jobId && !attemptId) {
1422
1800
  const role = roleParam(body.role);
1423
1801
  // G2: 需求根单的 owner 答了 lead 的 NEED —— 方案闸过了 (只记第一次)。
1424
1802
  const rj = jobs.get(jobId);
@@ -1427,7 +1805,8 @@ const main = async () => {
1427
1805
  jobs.attach(jobId, { target, task: turn.legs > 1 ? "" : text, spawned: false, ...(role ? { role } : {}) }, kind === "ask" ? "ask" : "task");
1428
1806
  }
1429
1807
  // 停放时已记过预算, 放行不再算一次。
1430
- const spent = inj.ok && jobId && !held ? jobs.spend(jobId) : undefined;
1808
+ const trackedJob = inj.ok && attemptId ? jobs.get(jobId) : undefined;
1809
+ const spent = trackedJob ? { turns: trackedJob.turns ?? 0, maxTurns: trackedJob.maxTurns } : inj.ok && jobId && !held ? jobs.spend(jobId) : undefined;
1431
1810
  if (inj.ok && isPublic)
1432
1811
  relayPeer(self, target, text, channel);
1433
1812
  // `wasBusy` 是给调用方的判断依据: 立刻投给一个正在生成的会话, 这句话会排在
@@ -1446,6 +1825,7 @@ const main = async () => {
1446
1825
  ...(waitedMs ? { waitedMs } : {}),
1447
1826
  ...(jobId ? { job: jobId } : {}),
1448
1827
  ...(inj.ok ? { turn: turn.turn } : {}),
1828
+ ...(attemptId ? { node, attemptId, acceptance: "pending" } : {}),
1449
1829
  ...(spent?.maxTurns ? { budget: `${spent.turns}/${spent.maxTurns}${spent.turns >= spent.maxTurns ? " —— 用完了, 这是最后一次派活, 下一步收口" : ""}` } : {}),
1450
1830
  ...(inj.ok && wake ? { wakeCost: wake } : {}),
1451
1831
  ...(turn.reUnknown ? { reUnknown: true, reNote: `re "${re}" 不是你正在等 ${displayName(target)} 答的那件, 按新活发出` } : {}),
@@ -1458,6 +1838,24 @@ const main = async () => {
1458
1838
  : {}),
1459
1839
  } };
1460
1840
  };
1841
+ const tellPeerAs = async (...args) => {
1842
+ const [self, body] = args;
1843
+ const transfer = body.answer === "transfer";
1844
+ const current = peerDispatching.get(self);
1845
+ if (current?.transfer || (transfer && current?.count))
1846
+ return { status: 409, body: { ok: false, reason: "another dispatch is in flight for this caller; source retains its current answer ownership" } };
1847
+ peerDispatching.set(self, { count: (current?.count ?? 0) + 1, transfer });
1848
+ try {
1849
+ return await tellPeerAsInner(...args);
1850
+ }
1851
+ finally {
1852
+ const n = peerDispatching.get(self);
1853
+ if (n && n.count > 1)
1854
+ peerDispatching.set(self, { count: n.count - 1, transfer: n.transfer });
1855
+ else
1856
+ peerDispatching.delete(self);
1857
+ }
1858
+ };
1461
1859
  const tellPeer = async (req, res) => {
1462
1860
  const { self, body } = await readPeerBody(req);
1463
1861
  if (!self) {
@@ -1467,6 +1865,58 @@ const main = async () => {
1467
1865
  const r = await tellPeerAs(self, body);
1468
1866
  json(res, r.status, r.body);
1469
1867
  };
1868
+ http.register("POST /peers/inbox", async (req, res) => {
1869
+ const { self } = await readPeerBody(req);
1870
+ if (!self) {
1871
+ json(res, 400, { ok: false, reason: "cannot resolve caller" });
1872
+ return;
1873
+ }
1874
+ json(res, 200, { ok: true, events: receipts.inbox(self) });
1875
+ });
1876
+ http.register("POST /workflow/nodes", async (req, res) => {
1877
+ const { self, body } = await readPeerBody(req);
1878
+ if (!self) {
1879
+ json(res, 400, { ok: false, reason: "cannot resolve caller" });
1880
+ return;
1881
+ }
1882
+ const attempts = workflow.list(self, body.job);
1883
+ json(res, 200, { ok: true, nodes: attempts, tally: {
1884
+ attempts: attempts.length, nodes: new Set(attempts.map((a) => `${a.job}\0${a.node}`)).size,
1885
+ accepted: attempts.filter((a) => workflow.prereq(self, a.turn)?.state === "done").length,
1886
+ awaitingAcceptance: attempts.filter((a) => a.execution === "terminal" && a.status === "done" && a.acceptance === "pending").length,
1887
+ } });
1888
+ });
1889
+ http.register("POST /workflow/verdict", async (req, res) => {
1890
+ const { self, body } = await readPeerBody(req);
1891
+ if (!self) {
1892
+ json(res, 400, { ok: false, reason: "cannot resolve caller" });
1893
+ return;
1894
+ }
1895
+ const b = body;
1896
+ if (!["accepted", "rejected", "sent", "stopped"].includes(b.as ?? "")) {
1897
+ json(res, 400, { ok: false, reason: "invalid verdict" });
1898
+ return;
1899
+ }
1900
+ try {
1901
+ const old = workflow.list(self).find((a) => a.id === b.attemptId);
1902
+ if (!old)
1903
+ throw new Error("attempt not found for owner");
1904
+ if (b.as !== "stopped" && (jobs.get(old.job)?.status !== "open" || jobs.get(old.job)?.owner !== self))
1905
+ throw new Error("owner job must be open for acceptance or sent watching");
1906
+ if (b.as === "sent" && !old.dispatches?.length)
1907
+ throw new Error("historical tracked attempt lacks dispatch accounting evidence; reconcile stopped before retry");
1908
+ const a = workflow.verdict(self, b.attemptId ?? "", b.as, b.digest, b.reason ?? "", b.files);
1909
+ if (b.as === "sent" && a.tell)
1910
+ recoverWorkflow(a);
1911
+ if (b.as === "stopped")
1912
+ receipts.cancel(a.to, self, (x) => x.from === self && x.turn === a.turn);
1913
+ advancePlans();
1914
+ json(res, 200, { ok: true, attempt: workflow.byTurn(self, a.turn) });
1915
+ }
1916
+ catch (e) {
1917
+ json(res, 409, { ok: false, reason: e.message });
1918
+ }
1919
+ });
1470
1920
  http.register("POST /peers/tell", tellPeer);
1471
1921
  http.register("POST /peers/send", tellPeer); // 老 MCP 进程还在调这个名字
1472
1922
  // 等一个 wizard, 或者等一**组**。fan-out 之后 join 必须是并行的: 串行地等五个
@@ -1515,19 +1965,20 @@ const main = async () => {
1515
1965
  // 取走就占位: 同一段话不会再作为回执 paste 进来一遍 (见 receipts.claim)。
1516
1966
  const full = wr.idle ? m.lastReply(h.target, receipts.sentAt(self, h.target)) : "";
1517
1967
  const already = full ? receipts.claim(self, h.target) : false;
1518
- const closing = parseClosing(full);
1968
+ const closing = parseClosing(already === "uncertain" ? "" : full);
1519
1969
  const result = closing.kind === "result" ? closing.text : "";
1520
1970
  return {
1521
1971
  name: h.address,
1522
1972
  idle: wr.idle,
1523
1973
  // 回执早一步到了: 这段话已经作为新的一轮进过你的会话, 别再处理第二遍。
1524
- ...(already ? { delivered: true } : {}),
1974
+ ...(already === true ? { delivered: true } : {}),
1975
+ ...(already === "uncertain" ? { delivery: "uncertain", reason: "回执可能已递交,需按 receipt eventId 对账;没有自动重投或再次领取。" } : {}),
1525
1976
  ...(wr.reason ? { reason: wr.reason } : {}),
1526
1977
  ...(closing.kind === "need" ? { need: closing.text } : {}),
1527
1978
  ...(closing.artifacts.length ? { artifacts: closing.artifacts } : {}),
1528
1979
  ...(result
1529
1980
  ? { result, ...(full.length > result.length + 200 ? { omitted: full.length - result.length } : {}) }
1530
- : full
1981
+ : already === "uncertain" ? {} : full
1531
1982
  ? { lastText: clipMiddle(full) }
1532
1983
  : wr.idle
1533
1984
  ? { stale: true, reason: "它停下了, 但自你上次 send_peer 以来没有新回复 —— 多半停在一个等人点的弹窗上, 或那句话没被接住; peek_peer 看一眼" }
@@ -1544,12 +1995,14 @@ const main = async () => {
1544
1995
  // /clear 只换会话不换进程 —— MCP 子进程、charter 都还是旧的, 新工具拿不到; 镜像
1545
1996
  // 还得靠 sid 轮换探测才跟得上。CLI 使用 cfg 的默认 backend; cwd / keepalive 照旧沿用。
1546
1997
  // 同 backend 时模型也沿用, 管家除外 ——
1547
- // 它换上此刻的管家档 (newSession 里定), 改了 models.steward 后交接一次就是让它生效的那一下。
1998
+ // 它换上此刻的 router 档 (newSession 里定), 改了 models.router 后交接一次就是让它生效的那一下。
1548
1999
  const restartFresh = (target) => {
1549
2000
  const info = m.sessionInfo(target);
1550
2001
  const cli = cfg.wrc.defaultCli ?? primary.name;
1551
2002
  const model = stewardTierOf(cfg, target) || info?.cli !== cli ? undefined : info?.model || undefined;
1552
- return m.newSession(target, displayName(target) || tagOfKey(target) || target, cli, { model, silent: true, warm: true });
2003
+ if (!planContinuationAllowed(target))
2004
+ return Promise.resolve({ ok: false, reason: "workflow continuation withdrawn" });
2005
+ return m.newSession(target, displayName(target) || tagOfKey(target) || target, cli, { model, silent: true, warm: true, guard: () => planContinuationAllowed(target) });
1553
2006
  };
1554
2007
  // POST /handoff — 交接一个 pane 的会话给一个全新会话。先让目标会话把当前工作
1555
2008
  // 压成一份"零上下文也能接手"的交接简报,等它写完并抓取,再 restartFresh 换一个
@@ -1558,6 +2011,8 @@ const main = async () => {
1558
2011
  /** 交接自己: 简报由调用方给, 排在它这一轮说完之后 (见 handoff.ts)。/handoff 点名点到
1559
2012
  * 自己与老路由 /wizard/handoff-self 共用。 */
1560
2013
  const handoffSelf = (self, brief) => {
2014
+ if (!planContinuationAllowed(self))
2015
+ return { status: 409, body: { ok: false, reason: "workflow continuation withdrawn" } };
1561
2016
  if (brief.length < 40)
1562
2017
  return { status: 400, body: { ok: false, reason: "brief 太短 —— 要写到零上下文也能接手: 目标 / 已完成 / 当前状态 / 下一步 / 关键文件与坑" } };
1563
2018
  const info = m.sessionInfo(self);
@@ -1608,6 +2063,10 @@ const main = async () => {
1608
2063
  json(res, r.status, r.body);
1609
2064
  return;
1610
2065
  }
2066
+ if (!planStore.allowExternal(target, wizards.get(target)?.name)) {
2067
+ json(res, 409, { ok: false, reason: "workflow executor may only hand off its own live attempt" });
2068
+ return;
2069
+ }
1611
2070
  const focus = (body.focus ?? "").toString().trim();
1612
2071
  const timeoutMs = Math.min(Math.max(Number(body.timeoutSec ?? 600) || 600, 30), 7200) * 1000;
1613
2072
  const r = await handoffs.other(target, focus, timeoutMs);
@@ -2196,6 +2655,10 @@ const main = async () => {
2196
2655
  json(res, 400, { ok: false, reason: "名字只能是 1-32 位字母/数字/`_`/`-`" });
2197
2656
  return;
2198
2657
  }
2658
+ if (asked && (!planStore.allowExternal(self, wizards.get(self)?.name) || !planStore.allowExternal(undefined, asked))) {
2659
+ json(res, 409, { ok: false, reason: "workflow plan reserves this identity" });
2660
+ return;
2661
+ }
2199
2662
  const before = settleName(wizards, chatNameOf(cfg, self), self);
2200
2663
  const name = asked ? wizards.rename(self, asked) : before;
2201
2664
  if (description)
@@ -2327,7 +2790,23 @@ const main = async () => {
2327
2790
  json(res, 400, { ok: false, reason: "task is required" });
2328
2791
  return;
2329
2792
  }
2793
+ const dispatchOrigin = {
2794
+ authority: controlSnapshot(self), answerContext: m.answerContext(self), asker: askerOf(self), k: parentKOf(self),
2795
+ };
2330
2796
  const lead = b.lead === true;
2797
+ if (b.answer !== undefined && b.answer !== "transfer") {
2798
+ json(res, 400, { ok: false, reason: "answer must be transfer when supplied" });
2799
+ return;
2800
+ }
2801
+ if (b.answer === "transfer" && lead) {
2802
+ json(res, 409, { ok: false, reason: "answer transfer is for simple direct answers; lead dispatch cannot transfer" });
2803
+ return;
2804
+ }
2805
+ const answerParent = dispatchOrigin?.k;
2806
+ if (b.answer === "transfer" && (lostAnswer(self) || !dispatchOrigin?.answerContext || answerParent?.kind !== "chat" || !dispatchOrigin.asker?.who.startsWith("user:") || !chatPolicyOf(cfg, answerParent.channel).topOnly || hasAnswerChildren(self, answerParent) || peerDispatching.has(self))) {
2807
+ json(res, 409, { ok: false, reason: "no exclusive active human topOnly answer available to transfer" });
2808
+ return;
2809
+ }
2331
2810
  const force = b.force === true;
2332
2811
  const to = (b.to ?? "").toString().trim();
2333
2812
  const now = Date.now();
@@ -2335,6 +2814,10 @@ const main = async () => {
2335
2814
  // 占用保护由 decide 统一判断, 留在候选表里说明为什么不能自动复用。
2336
2815
  // 显式 to 保留人为选定; force 只影响成本门, 不绕 lead/工单/连续性保护。
2337
2816
  const pool = to || b.spawn || lead ? [] : (await routeRowsOf(self)).filter((r) => r.alive && !!tagOfKey(r.target));
2817
+ if (!controlValid(self, dispatchOrigin.authority)) {
2818
+ json(res, 409, { ok: false, reason: "dispatch source turn/control changed while routing; original task remains with source" });
2819
+ return;
2820
+ }
2338
2821
  const tierArg = (b.tier ?? "").toString().trim();
2339
2822
  if (tierArg && !TIERS.includes(tierArg)) {
2340
2823
  json(res, 400, { ok: false, reason: `unknown tier: ${tierArg}` });
@@ -2352,10 +2835,11 @@ const main = async () => {
2352
2835
  let rootId = "";
2353
2836
  const tell = (name, text, extra = "") => tellPeerAs(self, {
2354
2837
  name, text, public: b.public !== false, force,
2838
+ ...(b.answer ? { answer: b.answer } : {}),
2355
2839
  // lead 的活远超默认的 1h: 不给 deadline 就按 12h (回执的上限)。
2356
2840
  ...(b.deadline !== undefined ? { deadline: b.deadline } : lead ? { deadline: 43200 } : {}),
2357
2841
  ...(rootId ? { job: rootId } : {}),
2358
- }, extra);
2842
+ }, extra, undefined, undefined, dispatchOrigin);
2359
2843
  const shown = cands.slice(0, 3).map((e) => `.${e.row.address || e.row.name}${e.row.busy ? " 忙" : ""}`);
2360
2844
  if (b.dryRun) {
2361
2845
  json(res, 200, { ok: true, dryRun: true, decision: d.kind, reason: d.why, ...(d.kind === "existing" ? { name: to || (d.kind === "existing" && d.row ? d.row.address || d.row.name : "") } : { tier: taskTier }), ...(shown.length ? { candidates: shown } : {}) });
@@ -2405,12 +2889,17 @@ const main = async () => {
2405
2889
  return;
2406
2890
  }
2407
2891
  const tier = taskTier;
2408
- const born = await bearAs(self, { inherit: false, name, description, tier, ...(lead ? { lead: true } : {}) });
2892
+ const born = await bearAs(self, { inherit: false, name, description, tier, ...(lead ? { lead: true } : {}) }, undefined, dispatchOrigin?.authority);
2409
2893
  if (born.status !== 200) {
2410
2894
  settleRoot(false);
2411
2895
  json(res, born.status, { ...born.body, decision: "spawn", reason: d.why });
2412
2896
  return;
2413
2897
  }
2898
+ if (!controlValid(self, dispatchOrigin.authority)) {
2899
+ settleRoot(false);
2900
+ json(res, 409, { ok: false, decision: "spawn", reason: "dispatch source turn changed during spawn; task remains with original source turn" });
2901
+ return;
2902
+ }
2414
2903
  // 宪章里只有层级规矩, 打法随信封带 (宪章每轮都在上下文里, 打法只这一件活用得上)。
2415
2904
  const t = await tell(String(born.body.name), task, lead ? renderReminder({ wezard: "lead" }, renderLeadPlaybook()) : "");
2416
2905
  settleRoot(t.status === 200);
@@ -2492,193 +2981,249 @@ const main = async () => {
2492
2981
  // 生分身。默认 fork 调用方此刻的上下文 —— 这是"先把公共材料读进来, 再分出 N
2493
2982
  // 个干活的"之所以省事的原因: 材料只读一遍, 却进了 N 份上下文。
2494
2983
  /** spawn_wizard / clone_wizard 的本体 —— HTTP 路由与 dispatch 共用。 */
2495
- const bearAs = async (self, body) => {
2496
- const b = body;
2497
- const asker = b.task?.trim() ? askerOf(self) : undefined;
2498
- const k = body.chain === false ? undefined : parentKOf(self);
2499
- const askedEffort = parseEffort(b.effort);
2500
- if (b.effort !== undefined && !askedEffort) {
2501
- return { status: 400, body: { ok: false, reason: `effort 只有这几档: ${EFFORTS.join(" / ")}` } };
2502
- }
2503
- // 工单先验: 生完分身才发现工单号打错了, 那个分身就成了没人认领的孤儿。
2504
- const jobId = (b.job ?? "").trim();
2505
- if (jobId) {
2506
- const jc = checkJob(jobId, undefined, !(b.task ?? "").toString().trim());
2507
- if (!jc.ok) {
2508
- return { status: jc.status, body: { ok: false, reason: jc.reason } };
2984
+ const bearAs = async (self, body, planned, capturedAuthority) => {
2985
+ const authority = planned ? undefined : capturedAuthority ?? controlSnapshot(self);
2986
+ if (authority && !controlValid(self, authority))
2987
+ return { status: 409, body: { ok: false, reason: "this turn no longer owns control; cannot spawn more work" } };
2988
+ // Legacy bootstrap can dispatch before attaching its member. An empty job
2989
+ // is not available for plan ownership while that birth is still in flight.
2990
+ const birthJob = (body.job ?? "").trim();
2991
+ if (birthJob)
2992
+ pendingBirthJobs.set(birthJob, (pendingBirthJobs.get(birthJob) ?? 0) + 1);
2993
+ try {
2994
+ const b = body;
2995
+ const asker = b.task?.trim() ? askerOf(self) : undefined;
2996
+ const k = body.chain === false ? undefined : parentKOf(self);
2997
+ const askedEffort = parseEffort(b.effort);
2998
+ if (b.effort !== undefined && !askedEffort) {
2999
+ return { status: 400, body: { ok: false, reason: `effort 只有这几档: ${EFFORTS.join(" / ")}` } };
2509
3000
  }
2510
- }
2511
- // 带 task 派下去 = 派活: 同样过方向闸, 不论带不带 job (新生的目标还不在任何单里, 实际只有 1 / 链深两条会拦)。
2512
- const dr = (b.task ?? "").toString().trim() ? checkDirection({ self, target: "", kind: "task", re: false, jobId, jobs: jobs.all(), chain: receipts.ancestorsOf(self, parentKOf(self)), nameOf: displayName }) : undefined;
2513
- if (dr) {
2514
- return { status: 409, body: { ok: false, gated: "direction", reason: dr } };
2515
- }
2516
- // `inherit` 没有默认值 —— 继承与否是两种完全不同的分身 (一种开局就带着你
2517
- // 读过的一切, 另一种白纸一张), 猜错了要么白烧一份上下文, 要么让它从零重读。
2518
- // 逼调用方每次自己说。
2519
- if (typeof b.inherit !== "boolean") {
2520
- return { status: 400, body: { ok: false, reason: "inherit 必填: true = fork 你此刻的上下文 (它开局就有你读过的材料, 必须留在同一个 cwd); false = 空白分身, 只继承身份" } };
2521
- }
2522
- const inherit = b.inherit;
2523
- // detached = 独立长住 (原 new_claude_session / 人在群里 `/new .x`): 不挂家谱、不占
2524
- // 调用方的分身名额、不随工单回收。它和「fork 我的上下文」「归某张工单」都互斥。
2525
- const detached = b.detached === true;
2526
- if (detached && (inherit || (b.job ?? "").trim())) {
2527
- return { status: 400, body: { ok: false, reason: "detached 只用于白板 (inherit:false) 且不带 job —— 独立长住的 wizard 不归你管, 也不随工单回收" } };
2528
- }
2529
- // 克隆的源头: 默认是调用方自己; `from` 点名就 fork 那个 wizard 此刻的上下文。
2530
- // 分身仍归调用方管 (parent = self: 占它的预算、随它的工单回收), 只有上下文来自别处。
2531
- // 空串不能交给 resolvePeer —— 那是「本聊天的默认 wizard」, 不是「我自己」。
2532
- const fromAddr = (b.from ?? "").toString().trim();
2533
- if (fromAddr && !inherit) {
2534
- return { status: 400, body: { ok: false, reason: "from 只对克隆有意义 —— 白板生出来的 wizard 不带任何人的上下文" } };
2535
- }
2536
- const fromR = fromAddr ? resolvePeer(self, fromAddr) : { ok: true, target: self };
2537
- if (!fromR.ok) {
2538
- return { status: fromR.status, body: { ok: false, reason: fromR.reason, ...(fromR.candidates ? { candidates: fromR.candidates } : {}) } };
2539
- }
2540
- const source = fromR.target;
2541
- const sourceInfo = m.sessionInfo(source);
2542
- const forkBackend = inherit && sourceInfo && existsSync(sourceInfo.jsonlPath)
2543
- && (!b.cwd?.trim() || expandHome(b.cwd.trim()) === sourceInfo.cwd) ? sourceInfo.cli : undefined;
2544
- const pick = pickTier(cfg, b.tier, { cli: b.cli, model: b.model, effort: askedEffort }, inherit, forkBackend);
2545
- if (!pick.ok)
2546
- return { status: 400, body: pick };
2547
- const effort = pick.effort;
2548
- // 别人的会话 fork 不出来就是失败, 不能像克隆自己那样悄悄退化成白板 —— 调用方
2549
- // 要的恰恰是它的上下文, 给一张白纸等于答非所问。
2550
- if (source !== self && !sourceInfo?.sessionId) {
2551
- return { status: 409, body: { ok: false, reason: `${displayName(source)} 没有可 fork 的会话 (还没说过话, 或绑定已失效)` } };
2552
- }
2553
- const alivePeers = detached ? [] : await m.peers(self);
2554
- // 预算。撞到上限由守护进程腾位, 不叫调用方去收: 最久没动的闲分身只关 pane、绑定留着
2555
- // (同 maxPanes 的 reap), 再找它 `--resume` 带全上下文复活。忙的、停在审批上的、手上
2556
- // 还有没落定的活的不动; 全都动不了才拒。只数**本聊天里**活着的 ——
2557
- // 生到别的聊天去的归那边管, 为了数它们再探一遍 tmux 不值当。
2558
- const aliveKids = alivePeers.filter((pp) => pp.paneAlive && childrenOf(wizards.all(), self).some((k) => k.target === pp.target));
2559
- const over = detached ? 0 : aliveKids.length - cfg.wrc.mirror.cloneMax + 1;
2560
- if (over > 0) {
2561
- const parked = new Set(listPending().map((p) => p.meta.sessionId).filter(Boolean));
2562
- const owing = new Set(receipts.states().map((x) => x.to));
2563
- const idle = aliveKids
2564
- .filter((pp) => !pp.busy && !owing.has(pp.target) && !parked.has(m.sessionInfo(pp.target)?.sessionId ?? ""))
2565
- .sort((x, y) => x.lastActivity - y.lastActivity)
2566
- .slice(0, over);
2567
- const slept = (await Promise.all(idle.map((pp) => m.sleepPane(pp.target)))).filter((r) => r.ok).length;
2568
- if (slept < over) {
2569
- return { status: 429, body: {
3001
+ // 工单先验: 生完分身才发现工单号打错了, 那个分身就成了没人认领的孤儿。
3002
+ const jobId = (b.job ?? "").trim();
3003
+ if (jobId) {
3004
+ const jc = checkJob(jobId, undefined, !(b.task ?? "").toString().trim());
3005
+ if (!jc.ok) {
3006
+ return { status: jc.status, body: { ok: false, reason: jc.reason } };
3007
+ }
3008
+ }
3009
+ 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());
3010
+ if (!planBirthAllowed() || (planned && !!b.task))
3011
+ return { status: 409, body: { ok: false, reason: "dedicated plan job only permits taskless runner spawn" } };
3012
+ // 带 task 派下去 = 派活: 同样过方向闸, 不论带不带 job (新生的目标还不在任何单里, 实际只有 1 / 链深两条会拦)。
3013
+ const dr = planned || (b.task ?? "").toString().trim() ? checkDirection({ self, target: "", kind: "task", re: false, jobId, jobs: jobs.all(), chain: planned?.origin.chain ?? receipts.ancestorsOf(self, parentKOf(self)), nameOf: displayName }) : undefined;
3014
+ if (dr) {
3015
+ return { status: 409, body: { ok: false, gated: "direction", reason: dr } };
3016
+ }
3017
+ // `inherit` 没有默认值 —— 继承与否是两种完全不同的分身 (一种开局就带着你
3018
+ // 读过的一切, 另一种白纸一张), 猜错了要么白烧一份上下文, 要么让它从零重读。
3019
+ // 逼调用方每次自己说。
3020
+ if (typeof b.inherit !== "boolean") {
3021
+ return { status: 400, body: { ok: false, reason: "inherit 必填: true = fork 你此刻的上下文 (它开局就有你读过的材料, 必须留在同一个 cwd); false = 空白分身, 只继承身份" } };
3022
+ }
3023
+ const inherit = b.inherit;
3024
+ // detached = 独立长住 (原 new_claude_session / 人在群里 `/new .x`): 不挂家谱、不占
3025
+ // 调用方的分身名额、不随工单回收。它和「fork 我的上下文」「归某张工单」都互斥。
3026
+ const detached = b.detached === true;
3027
+ if (detached && (inherit || (b.job ?? "").trim())) {
3028
+ return { status: 400, body: { ok: false, reason: "detached 只用于白板 (inherit:false) 且不带 job —— 独立长住的 wizard 不归你管, 也不随工单回收" } };
3029
+ }
3030
+ // 克隆的源头: 默认是调用方自己; `from` 点名就 fork 那个 wizard 此刻的上下文。
3031
+ // 分身仍归调用方管 (parent = self: 占它的预算、随它的工单回收), 只有上下文来自别处。
3032
+ // 空串不能交给 resolvePeer —— 那是「本聊天的默认 wizard」, 不是「我自己」。
3033
+ const fromAddr = (b.from ?? "").toString().trim();
3034
+ if (fromAddr && !inherit) {
3035
+ return { status: 400, body: { ok: false, reason: "from 只对克隆有意义 —— 白板生出来的 wizard 不带任何人的上下文" } };
3036
+ }
3037
+ const fromR = fromAddr ? resolvePeer(self, fromAddr) : { ok: true, target: self };
3038
+ if (!fromR.ok) {
3039
+ return { status: fromR.status, body: { ok: false, reason: fromR.reason, ...(fromR.candidates ? { candidates: fromR.candidates } : {}) } };
3040
+ }
3041
+ const source = fromR.target;
3042
+ const sourceInfo = m.sessionInfo(source);
3043
+ const forkBackend = inherit && sourceInfo && existsSync(sourceInfo.jsonlPath)
3044
+ && (!b.cwd?.trim() || expandHome(b.cwd.trim()) === sourceInfo.cwd) ? sourceInfo.cli : undefined;
3045
+ const pick = pickTier(cfg, b.tier, { cli: b.cli, model: b.model, effort: askedEffort }, inherit, forkBackend);
3046
+ if (!pick.ok)
3047
+ return { status: 400, body: pick };
3048
+ const effort = pick.effort;
3049
+ // 别人的会话 fork 不出来就是失败, 不能像克隆自己那样悄悄退化成白板 —— 调用方
3050
+ // 要的恰恰是它的上下文, 给一张白纸等于答非所问。
3051
+ if (source !== self && !sourceInfo?.sessionId) {
3052
+ return { status: 409, body: { ok: false, reason: `${displayName(source)} 没有可 fork 的会话 (还没说过话, 或绑定已失效)` } };
3053
+ }
3054
+ const alivePeers = detached ? [] : await m.peers(self);
3055
+ if (authority && !controlValid(self, authority))
3056
+ return { status: 409, body: { ok: false, reason: "caller turn/control changed while preparing spawn" } };
3057
+ // 预算。撞到上限由守护进程腾位, 不叫调用方去收: 最久没动的闲分身只关 pane、绑定留着
3058
+ // (同 maxPanes 的 reap), 再找它 `--resume` 带全上下文复活。忙的、停在审批上的、手上
3059
+ // 还有没落定的活的不动; 全都动不了才拒。只数**本聊天里**活着的 ——
3060
+ // 生到别的聊天去的归那边管, 为了数它们再探一遍 tmux 不值当。
3061
+ const aliveKids = alivePeers.filter((pp) => pp.paneAlive && childrenOf(wizards.all(), self).some((k) => k.target === pp.target));
3062
+ const over = detached ? 0 : aliveKids.length - cfg.wrc.mirror.cloneMax + 1;
3063
+ if (over > 0) {
3064
+ const parked = new Set(listPending().map((p) => p.meta.sessionId).filter(Boolean));
3065
+ const owing = new Set(receipts.states().map((x) => x.to));
3066
+ const idle = aliveKids
3067
+ .filter((pp) => planStore.allowExternal(pp.target, wizards.get(pp.target)?.name) && !pp.busy && !owing.has(pp.target) && !parked.has(m.sessionInfo(pp.target)?.sessionId ?? ""))
3068
+ .sort((x, y) => x.lastActivity - y.lastActivity)
3069
+ .slice(0, over);
3070
+ const slept = (await Promise.all(idle.map((pp) => m.sleepPane(pp.target)))).filter((r) => r.ok).length;
3071
+ if (slept < over) {
3072
+ return { status: 429, body: {
3073
+ ok: false,
3074
+ reason: `你名下 ${aliveKids.length} 个活着的分身 (上限 ${cfg.wrc.mirror.cloneMax}) 都在干活, 腾不出位置 —— 等其中一个交回结论再分`,
3075
+ clones: aliveKids.map((k) => peerAddress(cfg, self, k.target)),
3076
+ } };
3077
+ }
3078
+ log.info({ self, slept: idle.map((pp) => pp.target) }, "clone cap: idle clones put to sleep");
3079
+ }
3080
+ const wantChat = (b.chat ?? "").toString().trim();
3081
+ const base = ((b) => (b && sameBot(self, b) ? b : ""))(wantChat ? chatBaseOf(cfg, wantChat) : baseOfKey(self));
3082
+ if (!base) {
3083
+ return { status: 404, body: {
2570
3084
  ok: false,
2571
- reason: `你名下 ${aliveKids.length} 个活着的分身 (上限 ${cfg.wrc.mirror.cloneMax}) 都在干活, 腾不出位置 —— 等其中一个交回结论再分`,
2572
- clones: aliveKids.map((k) => peerAddress(cfg, self, k.target)),
3085
+ reason: `unknown chat '${wantChat}' — 只有起过名字的聊天能被指名; 让那边先 /name`,
3086
+ candidates: chatNamesFor(self),
2573
3087
  } };
2574
3088
  }
2575
- log.info({ self, slept: idle.map((pp) => pp.target) }, "clone cap: idle clones put to sleep");
2576
- }
2577
- const wantChat = (b.chat ?? "").toString().trim();
2578
- const base = ((b) => (b && sameBot(self, b) ? b : ""))(wantChat ? chatBaseOf(cfg, wantChat) : baseOfKey(self));
2579
- if (!base) {
2580
- return { status: 404, body: {
2581
- ok: false,
2582
- reason: `unknown chat '${wantChat}' — 只有起过名字的聊天能被指名; 让那边先 /name`,
2583
- candidates: chatNamesFor(self),
2584
- } };
2585
- }
2586
- const askedName = String(b.name ?? b.tag ?? "");
2587
- // 没起名: 独立长住的按目录名 (同 /new 与 new_claude_session), 分身按职责的首词。
2588
- const fallback = (detached && b.cwd ? tagFromCwd(b.cwd) : "") || normalizeTag(b.description?.split(/\s+/)[0]) || "clone";
2589
- const slotR = await claimSlot(base, askedName, fallback, [self, source]);
2590
- if (!slotR.ok) {
2591
- return { status: slotR.status, body: slotR.body };
3089
+ const askedName = String(b.name ?? b.tag ?? "");
3090
+ // 没起名: 独立长住的按目录名 (同 /new 与 new_claude_session), 分身按职责的首词。
3091
+ const fallback = (detached && b.cwd ? tagFromCwd(b.cwd) : "") || normalizeTag(b.description?.split(/\s+/)[0]) || "clone";
3092
+ const slotR = await claimSlot(base, askedName, fallback, [self, source], (target, name) => planBirthAllowed(target, name) && (!target || planBirthAllowed(target, wizards.get(target)?.name ?? name)));
3093
+ if (!slotR.ok) {
3094
+ return { status: slotR.status, body: slotR.body };
3095
+ }
3096
+ const { target, slot: tag } = slotR;
3097
+ // claimSlot 会复用同名的冷记录 (detached 正是「叫回一个老名字」的场景): 生不出来时
3098
+ // 只回滚这一次的新记录, 不能把那个老 wizard 的记忆和家谱一起删掉。
3099
+ const existed = !!wizards.get(target);
3100
+ const forkOf = inherit && source !== self ? source : undefined;
3101
+ // 先落身份再 spawn: 宪章是从注册表渲染出来的, 记录不在就渲染出一个无名分身。
3102
+ if (!planBirthAllowed(target, normalizeTag(askedName) || tag) || (jobId && jobs.get(jobId)?.status !== "open"))
3103
+ return { status: 409, body: { ok: false, reason: "plan canceled/job reserved during spawn preparation" } };
3104
+ wizards.upsert(target, {
3105
+ description: (b.description ?? "").toString().trim(),
3106
+ // 显式写 undefined: upsert 是合并写, 复用的冷记录里旧的 parent 会留下来。
3107
+ parent: detached ? undefined : self,
3108
+ bornAt: Date.now(),
3109
+ clonedFrom: inherit ? sourceInfo?.sessionId ?? "" : "",
3110
+ ...(forkOf ? { forkOf } : {}),
3111
+ // fork 了上下文就一并继承 self 记忆: 系统提示不随 fork 走 (分身拿的是自己的宪章),
3112
+ // 不拷的话它开局就没有, /clear 或交接重开后更是彻底没了。标上来源; 已经是继承来的不再套一层。
3113
+ ...(inherit ? { memory: (wizards.get(source)?.memory ?? []).map((x) => x.startsWith("(继承自 ") ? x : `(继承自 ${displayName(source)}) ${x}`) } : {}),
3114
+ tier: pick.tier,
3115
+ // 显式写 undefined: 复用的冷记录可能当过 lead。
3116
+ lead: b.lead === true ? true : undefined,
3117
+ // 生完才清; 这个进程中途没了, 下次开机由 sweepUnborn 认出并收掉。
3118
+ spawning: BOOT_ID,
3119
+ });
3120
+ const name = wizards.rename(target, normalizeTag(askedName) || tag);
3121
+ const charter = charterFor(target, { ...(detached ? {} : { parent: self }), forkOf, inherited: inherit, cli: pick.cli });
3122
+ const task = (b.task ?? "").toString().trim();
3123
+ // 没点名就按配置的 keepalive.spawnDefault 来 —— 调用方明说的永远优先。
3124
+ const keepalive = typeof b.keepalive === "boolean" ? b.keepalive : cfg.wrc.mirror.keepalive.spawnDefault;
3125
+ // 先盖章再生: 分叉出来的 transcript 里躺着被克隆者的旧回复, 时刻早于这一枚章,
3126
+ // wait_peer 才不会把它们当成分身对这件活的答复。
3127
+ const taskAt = Date.now();
3128
+ const taskTurn = newTurn();
3129
+ const birthGuard = () => (!authority || controlValid(self, authority)) && planBirthAllowed(target, wizards.get(target)?.name ?? name) && (!jobId || jobs.get(jobId)?.status === "open");
3130
+ if (!birthGuard())
3131
+ return { status: 409, body: { ok: false, reason: "executor reservation changed before spawn" } };
3132
+ const r = await m.cloneSession({
3133
+ guard: birthGuard,
3134
+ parent: source,
3135
+ target,
3136
+ windowName: name,
3137
+ cli: pick.cli,
3138
+ model: pick.model,
3139
+ effort,
3140
+ cwd: b.cwd,
3141
+ systemPrompt: charter,
3142
+ inherit,
3143
+ // 继承路径上第一句话是分叉的触发器, 所以直接把活当开场白 —— 少一次往返,
3144
+ // 也少一次"就位了但没事干"的空转。
3145
+ // 子 lead 的宪章只有层级规矩, 打法跟第一句的信封走 (同 dispatch 对已有 wizard 的做法)。
3146
+ bootstrap: task ? task + envelopeFor(self, "", { turn: taskTurn, asker }) + (b.lead === true ? renderReminder({ wezard: "lead" }, renderLeadPlaybook()) : "") : undefined,
3147
+ ...(task ? { bootstrapFrom: { from: { kind: "peer", from: self, turn: taskTurn, ...(jobId ? { job: jobId } : {}), ...(asker ? { asker } : {}) }, query: task } } : {}),
3148
+ keepalive,
3149
+ }).catch((e) => ({ ok: false, reason: `spawn threw: ${String(e)}`, inherited: false }));
3150
+ // 生不出来就回滚身份 —— 否则名字被一个永远没有会话的记录占住。
3151
+ if (!r.ok) {
3152
+ if (existed)
3153
+ wizards.upsert(target, { spawning: undefined });
3154
+ else
3155
+ wizards.drop(target);
3156
+ return { status: 500, body: { ok: false, reason: r.reason } };
3157
+ }
3158
+ wizards.upsert(target, { spawning: undefined, clonedFrom: r.inherited ? sourceInfo?.sessionId ?? "" : "", ...(r.inherited ? {} : { forkOf: undefined }) });
3159
+ const kid = briefOf(self, target);
3160
+ // 出生不再发群气泡 (人要看的是结论, 不是谁生了谁 —— 过程在 rolepage 里);
3161
+ // 同群的 wizard 仍要知道群里多了一个成员。工单里的临时工连这条也省掉。
3162
+ if (!jobId) {
3163
+ // 模型 / keepalive 不报: 那是生它的那位的事, 旁人要时 wizard_roster 有。
3164
+ postRoster(base, [target, self], {
3165
+ op: "born", name: `.${name}`, by: displayName(self), clone: !detached,
3166
+ ...(r.inherited ? { ctx: forkOf ? displayName(forkOf) : displayName(self) } : {}),
3167
+ ...(kid.description ? { job: kid.description } : {}), cwd: r.cwd,
3168
+ }, subjectOf(wizards.get(target)));
3169
+ }
3170
+ // 继承路径上活已经随开场白进去了, 空白分身才需要在这里补一次注入 (私聊)。
3171
+ let dispatched = r.inherited && !!task;
3172
+ if (task && !r.inherited && birthGuard()) {
3173
+ const inj = await m.injectText(target, task, undefined, { from: { kind: "peer", from: self, turn: taskTurn, ...(jobId ? { job: jobId } : {}), ...(asker ? { asker } : {}) }, channel: "", envelope: envelopeFor(self, "", { turn: taskTurn, asker }), guard: birthGuard });
3174
+ dispatched = inj.ok;
3175
+ }
3176
+ if (jobId && birthGuard()) {
3177
+ const role = roleParam(body.role);
3178
+ const from = inherit ? source : undefined;
3179
+ jobs.attach(jobId, { target, task, spawned: true, ...(role ? { role } : {}), ...(from ? { forkOf: from } : {}) });
3180
+ }
3181
+ if (jobId && dispatched)
3182
+ jobs.spend(jobId);
3183
+ // 分身的第一件活也守回执: fan-out 最常见的形状就是 clone_wizard({task}) × N,
3184
+ // 让它们干完自己把结论送回来, 发起方不必挂在 wait_peer 上。
3185
+ if (dispatched)
3186
+ receipts.register({ from: self, to: target, channel: "", job: jobId, at: taskAt, turn: taskTurn, ...(k ? { k } : {}), ...(asker ? { asker } : {}) });
3187
+ if (dispatched)
3188
+ ledger.open({ turn: taskTurn, owner: self, to: target, text: task, at: taskAt, ...(jobId ? { job: jobId } : {}), ...(k ? { for: k } : {}), ...(asker ? { asker } : {}) });
3189
+ return { status: 200, body: { ok: true, target, name, address: name, inherited: r.inherited, sessionId: r.sessionId, cwd: r.cwd, dispatched, ...(dispatched ? { turn: taskTurn } : {}), keepalive, ...(r.model ? { model: r.model } : {}), ...(r.modelWarning ? { modelWarning: r.modelWarning } : {}), ...(r.effort ? { effort: r.effort } : {}), ...(pick.tier ? { tier: pick.tier } : {}), via: pick.via, ...(pick.note ? { tierNote: pick.note } : {}), ...(jobId ? { job: jobId } : {}), ...(detached ? { detached: true } : {}) } };
2592
3190
  }
2593
- const { target, slot: tag } = slotR;
2594
- // claimSlot 会复用同名的冷记录 (detached 正是「叫回一个老名字」的场景): 生不出来时
2595
- // 只回滚这一次的新记录, 不能把那个老 wizard 的记忆和家谱一起删掉。
2596
- const existed = !!wizards.get(target);
2597
- const forkOf = inherit && source !== self ? source : undefined;
2598
- // 先落身份再 spawn: 宪章是从注册表渲染出来的, 记录不在就渲染出一个无名分身。
2599
- wizards.upsert(target, {
2600
- description: (b.description ?? "").toString().trim(),
2601
- // 显式写 undefined: upsert 是合并写, 复用的冷记录里旧的 parent 会留下来。
2602
- parent: detached ? undefined : self,
2603
- bornAt: Date.now(),
2604
- clonedFrom: inherit ? sourceInfo?.sessionId ?? "" : "",
2605
- ...(forkOf ? { forkOf } : {}),
2606
- // fork 了上下文就一并继承 self 记忆: 系统提示不随 fork 走 (分身拿的是自己的宪章),
2607
- // 不拷的话它开局就没有, /clear 或交接重开后更是彻底没了。标上来源; 已经是继承来的不再套一层。
2608
- ...(inherit ? { memory: (wizards.get(source)?.memory ?? []).map((x) => x.startsWith("(继承自 ") ? x : `(继承自 ${displayName(source)}) ${x}`) } : {}),
2609
- tier: pick.tier,
2610
- // 显式写 undefined: 复用的冷记录可能当过 lead。
2611
- lead: b.lead === true ? true : undefined,
2612
- // 生完才清; 这个进程中途没了, 下次开机由 sweepUnborn 认出并收掉。
2613
- spawning: BOOT_ID,
2614
- });
2615
- const name = wizards.rename(target, normalizeTag(askedName) || tag);
2616
- const charter = charterFor(target, { ...(detached ? {} : { parent: self }), forkOf, inherited: inherit, cli: pick.cli });
2617
- const task = (b.task ?? "").toString().trim();
2618
- // 没点名就按配置的 keepalive.spawnDefault 来 —— 调用方明说的永远优先。
2619
- const keepalive = typeof b.keepalive === "boolean" ? b.keepalive : cfg.wrc.mirror.keepalive.spawnDefault;
2620
- // 先盖章再生: 分叉出来的 transcript 里躺着被克隆者的旧回复, 时刻早于这一枚章,
2621
- // wait_peer 才不会把它们当成分身对这件活的答复。
2622
- const taskAt = Date.now();
2623
- const taskTurn = newTurn();
2624
- const r = await m.cloneSession({
2625
- parent: source,
2626
- target,
2627
- windowName: name,
2628
- cli: pick.cli,
2629
- model: pick.model,
2630
- effort,
2631
- cwd: b.cwd,
2632
- systemPrompt: charter,
2633
- inherit,
2634
- // 继承路径上第一句话是分叉的触发器, 所以直接把活当开场白 —— 少一次往返,
2635
- // 也少一次"就位了但没事干"的空转。
2636
- // 子 lead 的宪章只有层级规矩, 打法跟第一句的信封走 (同 dispatch 对已有 wizard 的做法)。
2637
- bootstrap: task ? task + envelopeFor(self, "", { turn: taskTurn, asker }) + (b.lead === true ? renderReminder({ wezard: "lead" }, renderLeadPlaybook()) : "") : undefined,
2638
- ...(task ? { bootstrapFrom: { from: { kind: "peer", from: self, turn: taskTurn, ...(jobId ? { job: jobId } : {}), ...(asker ? { asker } : {}) }, query: task } } : {}),
2639
- keepalive,
2640
- }).catch((e) => ({ ok: false, reason: `spawn threw: ${String(e)}`, inherited: false }));
2641
- // 生不出来就回滚身份 —— 否则名字被一个永远没有会话的记录占住。
2642
- if (!r.ok) {
2643
- if (existed)
2644
- wizards.upsert(target, { spawning: undefined });
2645
- else
2646
- wizards.drop(target);
2647
- return { status: 500, body: { ok: false, reason: r.reason } };
2648
- }
2649
- wizards.upsert(target, { spawning: undefined, clonedFrom: r.inherited ? sourceInfo?.sessionId ?? "" : "", ...(r.inherited ? {} : { forkOf: undefined }) });
2650
- const kid = briefOf(self, target);
2651
- // 出生不再发群气泡 (人要看的是结论, 不是谁生了谁 —— 过程在 rolepage 里);
2652
- // 同群的 wizard 仍要知道群里多了一个成员。工单里的临时工连这条也省掉。
2653
- if (!jobId) {
2654
- // 模型 / keepalive 不报: 那是生它的那位的事, 旁人要时 wizard_roster 有。
2655
- postRoster(base, [target, self], {
2656
- op: "born", name: `.${name}`, by: displayName(self), clone: !detached,
2657
- ...(r.inherited ? { ctx: forkOf ? displayName(forkOf) : displayName(self) } : {}),
2658
- ...(kid.description ? { job: kid.description } : {}), cwd: r.cwd,
2659
- }, subjectOf(wizards.get(target)));
2660
- }
2661
- // 继承路径上活已经随开场白进去了, 空白分身才需要在这里补一次注入 (私聊)。
2662
- let dispatched = r.inherited && !!task;
2663
- if (task && !r.inherited) {
2664
- const inj = await m.injectText(target, task, undefined, { from: { kind: "peer", from: self, turn: taskTurn, ...(jobId ? { job: jobId } : {}), ...(asker ? { asker } : {}) }, channel: "", envelope: envelopeFor(self, "", { turn: taskTurn, asker }) });
2665
- dispatched = inj.ok;
3191
+ finally {
3192
+ if (birthJob) {
3193
+ const remaining = (pendingBirthJobs.get(birthJob) ?? 1) - 1;
3194
+ if (remaining)
3195
+ pendingBirthJobs.set(birthJob, remaining);
3196
+ else
3197
+ pendingBirthJobs.delete(birthJob);
3198
+ }
2666
3199
  }
2667
- if (jobId) {
2668
- const role = roleParam(body.role);
2669
- const from = inherit ? source : undefined;
2670
- jobs.attach(jobId, { target, task, spawned: true, ...(role ? { role } : {}), ...(from ? { forkOf: from } : {}) });
2671
- }
2672
- if (jobId && dispatched)
2673
- jobs.spend(jobId);
2674
- // 分身的第一件活也守回执: fan-out 最常见的形状就是 clone_wizard({task}) × N,
2675
- // 让它们干完自己把结论送回来, 发起方不必挂在 wait_peer 上。
2676
- if (dispatched)
2677
- receipts.register({ from: self, to: target, channel: "", job: jobId, at: taskAt, turn: taskTurn, ...(k ? { k } : {}), ...(asker ? { asker } : {}) });
2678
- if (dispatched)
2679
- ledger.open({ turn: taskTurn, owner: self, to: target, text: task, at: taskAt, ...(jobId ? { job: jobId } : {}), ...(k ? { for: k } : {}), ...(asker ? { asker } : {}) });
2680
- return { status: 200, body: { ok: true, target, name, address: name, inherited: r.inherited, sessionId: r.sessionId, cwd: r.cwd, dispatched, ...(dispatched ? { turn: taskTurn } : {}), keepalive, ...(r.model ? { model: r.model } : {}), ...(r.modelWarning ? { modelWarning: r.modelWarning } : {}), ...(r.effort ? { effort: r.effort } : {}), ...(pick.tier ? { tier: pick.tier } : {}), via: pick.via, ...(pick.note ? { tierNote: pick.note } : {}), ...(jobId ? { job: jobId } : {}), ...(detached ? { detached: true } : {}) } };
2681
3200
  };
3201
+ const planRunner = createWorkflowPlanRunner(planStore, {
3202
+ job: jobs.get, attempt: workflow.byTurn, prereq: workflow.prereq,
3203
+ spawn: (run, node, state, permit) => bearAs(run.owner, {
3204
+ inherit: false, name: state.name, description: node.criteria || `workflow node ${node.id}`,
3205
+ tier: node.tier, cwd: run.plan.cwd, job: run.plan.job, keepalive: false,
3206
+ }, permit),
3207
+ tell: (run, node, state, after, permit) => tellPeerAs(run.owner, {
3208
+ name: state.name, text: node.task + (node.criteria ? `\n\n验收标准: ${node.criteria}` : ""),
3209
+ node: node.id, job: run.plan.job, ...(after.length ? { after } : {}),
3210
+ }, "", undefined, permit),
3211
+ cancel: (run) => {
3212
+ workflow.cancel(undefined, run.owner, run.plan.job);
3213
+ run.nodes.filter((n) => n.to).forEach((n) => receipts.cancel(n.to, run.owner, (x) => x.from === run.owner && x.turn === n.turn));
3214
+ receipts.cancelHeld(run.plan.job, run.owner);
3215
+ },
3216
+ error: (e) => log.error({ err: errText(e) }, "workflow plan advancement paused"),
3217
+ });
3218
+ advancePlans = planRunner.kick;
3219
+ registerWorkflowPlanRoutes(http, {
3220
+ read: readPeerBody, store: planStore, runner: planRunner,
3221
+ job: jobs.get, attempts: workflow.list,
3222
+ occupied: (self, job) => pendingBirthJobs.has(job) || receipts.states().some((s) => s.from === self && s.job === job),
3223
+ origin: (self) => { const k = parentKOf(self); return { ...(k ? { k } : {}), ...(askerOf(self) ? { asker: askerOf(self) } : {}), chain: receipts.ancestorsOf(self, k) }; },
3224
+ direction: (self, jobId, origin) => checkDirection({ self, target: "", kind: "task", re: false, jobId, jobs: jobs.all(), chain: origin.chain, nameOf: displayName }),
3225
+ });
3226
+ void m.restored.then(planRunner.start);
2682
3227
  http.register("POST /wizard/clone", async (req, res) => {
2683
3228
  const { self, body } = await readPeerBody(req);
2684
3229
  if (!self) {
@@ -2702,6 +3247,11 @@ const main = async () => {
2702
3247
  json(res, 400, { ok: false, reason: "cannot resolve caller session" });
2703
3248
  return;
2704
3249
  }
3250
+ const authority = controlSnapshot(self);
3251
+ if (!controlValid(self, authority)) {
3252
+ json(res, 409, { ok: false, reason: "本轮回答权和控制权已移交或失效;不能打断或结束目标 wizard" });
3253
+ return;
3254
+ }
2705
3255
  const b = body;
2706
3256
  const r = resolvePeer(self, addrOf(b));
2707
3257
  // 有身份没会话 (冷记录 / 生到一半的僵尸): resolvePeer 够不着它, 但收它不需要
@@ -2709,6 +3259,11 @@ const main = async () => {
2709
3259
  const found = r.ok || r.candidates?.length ? undefined : ((w) => (w && sameBot(self, w.target) ? w : undefined))(wizards.byName(addrOf(b)));
2710
3260
  const cold = found && !m.chatTargets(baseOfKey(found.target)).includes(found.target) ? found : undefined;
2711
3261
  if (cold && (b.mode ?? "end") === "end") {
3262
+ if (!controlValid(self, authority)) {
3263
+ json(res, 409, { ok: false, reason: "本轮控制权已变化;拒绝结束目标 wizard" });
3264
+ return;
3265
+ }
3266
+ workflow.cancel(cold.target);
2712
3267
  const detached = b.forget ? forgetWizard(cold.target) : [];
2713
3268
  json(res, 200, { ok: true, target: cold.target, name: cold.name, mode: "end", forgotten: !!b.forget, note: "它本来就没有会话在跑", ...(detached.length ? { canceledIn: detached } : {}) });
2714
3269
  return;
@@ -2731,19 +3286,27 @@ const main = async () => {
2731
3286
  const turn = (b.turn ?? "").replace(/[`\s]/g, "");
2732
3287
  if (!end && turn) {
2733
3288
  const mine = receipts.pending(self, target);
2734
- if (mine?.turn !== turn) {
3289
+ if (mine?.turn !== turn && workflow.byTurn(self, turn)?.to !== target && !receipts.states().some((s) => s.from === self && s.to === target && s.turn === turn && s.state === "queued")) {
2735
3290
  json(res, 404, { ok: false, reason: `你没有在等 ${victim.name ? `.${victim.name}` : target} 答 '${turn}' 这件 (已落定、件号写错, 或不是你派的)` });
2736
3291
  return;
2737
3292
  }
2738
3293
  // 先落 canceled 再按 Esc: 被打断那一轮的半截话不该被 watcher 当成答案投回来。
2739
3294
  const busy = !(await m.idleNow(target));
2740
- const canceled = receipts.cancel(target, self, (x) => x.from === self && x.turn === turn);
3295
+ if (!controlValid(self, authority)) {
3296
+ json(res, 409, { ok: false, reason: "等待期间本轮控制权已变化;未撤回或打断目标" });
3297
+ return;
3298
+ }
3299
+ const canceled = cancelPeerWork(target, self, (x) => x.from === self && x.turn === turn);
2741
3300
  // 按 Esc 之前再认一次手上这一轮: 上面那次 await 期间它可能已经做完这件、接了别人的下一件。
2742
3301
  const current = busy && openingAsk(target)?.turn === turn;
2743
3302
  // 还没轮到 / 排在后面: 捎一句给它, 轮到那句话时别再做了 (notices 挂在下一条注入的尾巴上)。
2744
3303
  if (!current)
2745
3304
  notices.post([target], `${displayName(self)} 撤回了它派给你的 ${turn} —— 那件活不用做了; 读到它时直接略过, 不必回复。`);
2746
3305
  // 只按 Esc, 不 teardown: teardown 会清掉它的注入队列, 别人排着的话一起丢。
3306
+ if (current && !controlValid(self, authority)) {
3307
+ json(res, 409, { ok: false, reason: "物理操作前本轮控制权已变化;已登记撤回但未发送 Esc" });
3308
+ return;
3309
+ }
2747
3310
  const esc = current ? await m.interruptPane(target) : undefined;
2748
3311
  // Esc 没按成也不回滚撤回: 这件已经不等了, 只是它手上那一轮还在跑 —— 如实说。
2749
3312
  json(res, 200, {
@@ -2755,13 +3318,26 @@ const main = async () => {
2755
3318
  }
2756
3319
  // 不带件号的打断会停掉它手上整轮: 那一轮若是别的 wizard 派的, 先拒 —— 误伤的是别人的活。
2757
3320
  const others = !end && !b.force ? await othersTurn(target, self) : undefined;
3321
+ if (!controlValid(self, authority)) {
3322
+ json(res, 409, { ok: false, reason: "等待期间本轮控制权已变化;未取消或打断目标" });
3323
+ return;
3324
+ }
2758
3325
  if (others) {
2759
3326
  json(res, 409, { ok: false, target, reason: `它此刻在做 ${others.from} 派的活${others.turn ? ` (${others.turn})` : ""}, 打断会连那件一起停掉 —— 只想撤回你自己派的那件, 传 turn (件号); 确要打断它这一轮, 加 force:true` });
2760
3327
  return;
2761
3328
  }
2762
3329
  // 先落 canceled 再动 pane: pane 一死, 守着的 watcher 会把同一份报成 dead。
2763
3330
  // 打断只停它手上这一轮: 只掐这一轮开头那句派来的活 (人 / 定时 / 回执轮不掐任何一份)。
2764
- const canceled = receipts.cancel(target, self, end ? undefined : currentAsk(target));
3331
+ const only = end ? undefined : currentAsk(target);
3332
+ if (!controlValid(self, authority)) {
3333
+ json(res, 409, { ok: false, reason: "物理操作前本轮控制权已变化;未取消或打断目标" });
3334
+ return;
3335
+ }
3336
+ const canceled = cancelPeerWork(target, self, only);
3337
+ if (!controlValid(self, authority)) {
3338
+ json(res, 409, { ok: false, reason: "物理操作前本轮控制权已变化;未打断或结束目标" });
3339
+ return;
3340
+ }
2765
3341
  const done = end ? await m.killPane(target) : await m.interruptPane(target, { teardown: true });
2766
3342
  if (!done.ok) {
2767
3343
  json(res, 502, { ok: false, target, reason: done.reason });
@@ -2933,6 +3509,8 @@ const main = async () => {
2933
3509
  const closeOne = async (j, summary, mark) => {
2934
3510
  const sids = new Map([j.owner, ...j.members.map((mm) => mm.target)].map((t) => [t, m.sessionInfo(t)?.sessionId ?? ""]));
2935
3511
  // 取消: 还没落定的成员手上这张单派的活记成 canceled, 发起者不会再收到一串回执。
3512
+ planRunner.cancelJob(j.owner, j.id);
3513
+ workflow.cancel(undefined, j.owner, j.id);
2936
3514
  if (how === "cancel")
2937
3515
  j.members.filter((mm) => !mm.outcome).forEach((mm) => receipts.cancel(mm.target, j.owner, (x) => x.from === j.owner));
2938
3516
  // 这张单名下还排着没投的 (after 停放) 也不投了。
@@ -3390,6 +3968,11 @@ const main = async () => {
3390
3968
  json(res, 400, { ok: false, reason: `node.tag \`${xnode.tag}\` 不是一个合法的 tag` });
3391
3969
  return;
3392
3970
  }
3971
+ const allowed = (target, name) => planStore.allowExternal(target, name) && planStore.allowExternal(target, wizards.get(target)?.name);
3972
+ if (spec.nodes.some((n) => !allowed(keyOf(spec.base, n.tag), n.tag))) {
3973
+ json(res, 409, { ok: false, reason: "workflow plan reserves a graph executor" });
3974
+ return;
3975
+ }
3393
3976
  const graphLog = log.child({ mod: "graph", base: spec.base });
3394
3977
  // 每一步、每条进度都在发起这次 run 的那一轮的频道里: 人在群里叫它发起的才进群,
3395
3978
  // 私聊轮里发起的整场只进 rolepage —— 步骤不带频道就等于在节点的 home 群里开口。
@@ -3399,16 +3982,27 @@ const main = async () => {
3399
3982
  // (or its pane died). Re-spawning a healthy node would throw away the
3400
3983
  // context that makes a multi-round loop worth running.
3401
3984
  ensureNode: async (target, node) => {
3985
+ if (!allowed(target, node.tag))
3986
+ return { ok: false, reason: "workflow plan reserves this executor" };
3402
3987
  const existing = (await m.peers(target)).find((p) => p.target === target);
3988
+ if (!allowed(target, node.tag))
3989
+ return { ok: false, reason: "workflow plan reserves this executor" };
3403
3990
  if (existing?.paneAlive)
3404
3991
  return { ok: true };
3405
3992
  // No broadcast plumbing needed: a `base#tag` target strips down to the
3406
3993
  // same WeCom chatid as `base`, so the node's own pushes already land
3407
3994
  // in this chat — mirroring them again was pure duplication.
3408
- const r = await m.newSession(target, node.tag, node.cli, { model: node.model, cwd: node.cwd, silent: true });
3995
+ const r = await m.newSession(target, node.tag, node.cli, { model: node.model, cwd: node.cwd, silent: true, guard: () => allowed(target, node.tag) });
3409
3996
  return { ok: r.ok, reason: r.reason };
3410
3997
  },
3411
- send: async (target, text, origin) => (await handedOff(target), m.injectText(target, text, origin, { channel })),
3998
+ send: async (target, text, origin) => {
3999
+ if (!allowed(target))
4000
+ return { ok: false, reason: "workflow plan reserves this executor" };
4001
+ await handedOff(target);
4002
+ if (!allowed(target))
4003
+ return { ok: false, reason: "workflow plan reserves this executor" };
4004
+ return m.injectText(target, text, origin, { channel, guard: () => allowed(target) });
4005
+ },
3412
4006
  isBusy: m.isBusy,
3413
4007
  lastText: async (target) => m.lastText(target),
3414
4008
  notify: (_base, markdown) => { if (channel)
@@ -3473,50 +4067,37 @@ const main = async () => {
3473
4067
  // 还要连触发时间点一起被它那一轮拖走。所以到点起一个白板 wizard 单独执行,
3474
4068
  // 跑完自动收掉。只有排班时点名了某个 wizard (fresh=false) 且它此刻闲着,
3475
4069
  // 才把这句话直接投进它那一轮 —— 「在已有会话里继续」是要求出来的, 不是默认。
3476
- scheduledTaskInject = async (target, text, { taskId, fresh, quiet }) => {
3477
- // 日程文件是可手改的源码: target 与排班者不同机器人就不放枪 (schedule_task 那头已拦, 这里拦手改的)。
4070
+ scheduledTaskInject = async (target, text, opts) => {
4071
+ const { taskId, fresh, quiet } = opts;
3478
4072
  const owner = ((r) => (r ? ownerOf(r) : target))(tasks.get(taskId));
3479
4073
  if (!sameBot(owner, target))
3480
4074
  return { ok: false, reason: `日程 ${taskId} 的目标与排班者归不同的企微机器人, 不跨机器人放枪` };
3481
- // 安静任务私下跑 (channel ""): 镜像不推它的任何一句, 终句由 relayUnlessQuiet 判断要不要进群。
3482
- const inNamed = async () => {
3483
- const at = Date.now();
3484
- const turn = newTurn();
3485
- const inj = await m.injectText(target, text, undefined, quiet
3486
- ? { from: { kind: "task", taskId }, channel: "", envelope: renderTaskEnvelope(taskId, { turn }) }
3487
- : { fromChat: true, from: { kind: "task", taskId }, envelope: renderTaskEnvelope(taskId) });
3488
- if (inj.ok && quiet)
3489
- void relayUnlessQuiet(target, at, turn, baseOfKey(target));
3490
- return inj;
3491
- };
3492
- if (!fresh)
3493
- await handedOff(target); // 交接中途 pane 是空的, 别当成闲着投进去
3494
- const busy = fresh ? false : await m.isBusy(target);
3495
- if (!fresh && !busy)
3496
- return inNamed();
3497
- // 点名的目标正忙: 顺延到它这一轮结束再投 (与 tell_peer 默认的 normal 同一口径) ——
3498
- // 「在它那儿继续」是排班时要求的, 不该因为撞上它在干活就换成白板。后台等, 别拖住
3499
- // 调度器的其它任务; 等满 30 分钟仍忙才退回起白板。
3500
- const inRunner = (wasBusy) => runTaskInRunner(target, text, taskId, quiet, wasBusy);
3501
- if (!fresh && busy) {
3502
- // 同一条任务已有一枪在等这个目标: 这一枪并进去 —— 等它闲下来时几枪一起投, 会挤进同一个输入框。
3503
- if (postponed.has(taskId))
3504
- return { ok: true, reason: "上一枪还在等它闲下来, 这一枪并入" };
3505
- postponed.add(taskId);
3506
- void (async () => {
3507
- const wr = await m.untilIdle(target, TASK_POSTPONE_MS);
3508
- postponed.delete(taskId);
3509
- const r = wr.idle ? await inNamed() : await inRunner(true);
3510
- log.info({ taskId, target, postponed: true, idle: wr.idle, ok: r.ok, reason: r.reason }, "scheduled task: 目标正忙, 顺延");
3511
- if (!r.ok)
3512
- notifyChat(baseOfKey(target), withTagHeader(target, `⏰ 定时任务 \`${taskId}\` 顺延后注入失败: ${r.reason ?? "unknown"}`));
3513
- })();
3514
- return { ok: true, reason: "目标正忙, 顺延到它闲下来" };
3515
- }
3516
- return inRunner(busy);
3517
- };
3518
- /** 顺延中的任务 (内存即可: reload 丢了只是少放一枪, 下一次到点照常)。 */
3519
- const postponed = new Set();
4075
+ const allowed = () => opts.guard() && planStore.allowExternal(target, wizards.get(target)?.name);
4076
+ return deliverScheduled({ ...opts, guard: () => opts.guard() && (fresh || allowed()) }, {
4077
+ postponeMs: TASK_POSTPONE_MS,
4078
+ handedOff: () => handedOff(target),
4079
+ idleNow: () => m.idleNow(target),
4080
+ untilIdle: (timeoutMs, aborted) => m.untilIdle(target, timeoutMs, { aborted }),
4081
+ named: async (live) => {
4082
+ const guard = () => live() && allowed();
4083
+ if (!guard())
4084
+ return { ok: false, reason: "scheduled target permission withdrawn" };
4085
+ const at = Date.now();
4086
+ const turn = newTurn();
4087
+ // Confirm idle inside the physical pane transaction; a race is unsent
4088
+ // and returns to the target consumer, without releasing task dedup.
4089
+ const inj = await m.injectText(target, text, undefined, quiet
4090
+ ? { guard, requireIdle: true, from: { kind: "task", taskId }, channel: "", envelope: renderTaskEnvelope(taskId, { turn }) }
4091
+ : { guard, requireIdle: true, fromChat: true, from: { kind: "task", taskId }, envelope: renderTaskEnvelope(taskId) });
4092
+ if (inj.ok && quiet)
4093
+ void relayUnlessQuiet(target, at, turn, baseOfKey(target));
4094
+ return inj;
4095
+ },
4096
+ runner: (busy, live) => !fresh && !allowed()
4097
+ ? Promise.resolve({ ok: false, reason: "scheduled target permission withdrawn" })
4098
+ : runTaskInRunner(target, text, taskId, quiet, busy, live),
4099
+ });
4100
+ };
3520
4101
  /** 安静任务那一轮的终句: 它私下跑, 镜像不推; 等它这一轮跑完, 终句不是 `QUIET` 就替它
3521
4102
  * 发进群 (头写它的名字, 人知道是谁说的)。按信封件号定位答句 (同回执): 按时刻取会把紧随
3522
4103
  * 其后的私聊 / 人话那一轮的答案当成它的, 把私下内容发进群。 */
@@ -3544,7 +4125,9 @@ const main = async () => {
3544
4125
  const quietRelays = loadJsonMap(join(cfg.daemon.stateDir, "quiet-relays.json"), (rows) => Object.fromEntries(Object.entries(rows).filter(([, r]) => Date.now() - r.at < TASK_QUIET_WAIT_MS)));
3545
4126
  void m.restored.then(() => Object.entries(quietRelays.all()).forEach(([turn, r]) => void relayUnlessQuiet(r.target, r.at, turn, r.base)));
3546
4127
  /** 到点起一个白板 wizard 执行这一枪, 跑完自动收掉。 */
3547
- const runTaskInRunner = async (target, text, taskId, quiet, busy) => {
4128
+ const runTaskInRunner = async (target, text, taskId, quiet, busy, guard) => {
4129
+ if (!guard())
4130
+ return { ok: false, reason: "scheduled runner withdrawn" };
3548
4131
  // 执行体挂在日程的主人名下: 名字 `<主人>-task-xxxx`, parent = 主人 —— 它的
3549
4132
  // rolepage 里看得见这一枪是谁的日程放的。落在执行目标的群/目录里。
3550
4133
  const base = baseOfKey(target);
@@ -3552,9 +4135,14 @@ const main = async () => {
3552
4135
  const owner = rec ? ownerOf(rec) : target;
3553
4136
  const ownerName = settleName(wizards, chatNameOf(cfg, owner), owner) || "task";
3554
4137
  const slotR = await claimSlot(base, "", `${ownerName}-task-${taskId.slice(0, 4)}`);
4138
+ if (!guard())
4139
+ return { ok: false, reason: "scheduled runner withdrawn during slot selection" };
3555
4140
  if (!slotR.ok)
3556
4141
  return { ok: false, reason: "起白板 wizard 失败: 挑不出槽位" };
3557
4142
  const runner = slotR.target;
4143
+ const allowed = () => guard() && planStore.allowExternal(runner, wizards.get(runner)?.name);
4144
+ if (!allowed())
4145
+ return { ok: false, reason: "workflow plan reserves task runner" };
3558
4146
  const info = m.sessionInfo(target);
3559
4147
  const why = busy ? ` (起因: ${displayName(target)} 当时正忙)` : "";
3560
4148
  wizards.upsert(runner, {
@@ -3564,11 +4152,18 @@ const main = async () => {
3564
4152
  bornAt: Date.now(),
3565
4153
  });
3566
4154
  const runnerName = wizards.rename(runner, `${ownerName}-task-${taskId.slice(0, 4)}`);
3567
- const spawned = await m.newSession(runner, runnerName, info?.cli, { cwd: info?.cwd, model: info?.model, silent: true });
4155
+ const spawned = await m.newSession(runner, runnerName, info?.cli, { cwd: info?.cwd, model: info?.model, silent: true, guard: allowed });
3568
4156
  if (!spawned.ok) {
3569
4157
  wizards.drop(runner);
3570
4158
  return { ok: false, reason: `起白板 wizard 失败: ${spawned.reason ?? "unknown"}` };
3571
4159
  }
4160
+ if (!allowed()) {
4161
+ if (m.sessionInfo(runner)?.sessionId === spawned.sessionId) {
4162
+ await m.killPane(runner);
4163
+ wizards.drop(runner);
4164
+ }
4165
+ return { ok: false, reason: "scheduled runner withdrawn after spawn" };
4166
+ }
3572
4167
  if (!quiet) {
3573
4168
  notifyChat(base, withTagHeader(target, busy
3574
4169
  ? `⏰ 定时任务到点时正忙, 已起白板 wizard .${runnerName} 单独执行, 完成后自动收掉`
@@ -3577,8 +4172,8 @@ const main = async () => {
3577
4172
  const at = Date.now();
3578
4173
  const turn = newTurn();
3579
4174
  const inj = await m.injectText(runner, text, undefined, quiet
3580
- ? { from: { kind: "task", taskId }, channel: "", envelope: renderTaskEnvelope(taskId, { turn }) }
3581
- : { fromChat: true, from: { kind: "task", taskId }, envelope: renderTaskEnvelope(taskId) });
4175
+ ? { guard: allowed, from: { kind: "task", taskId }, channel: "", envelope: renderTaskEnvelope(taskId, { turn }) }
4176
+ : { guard: allowed, fromChat: true, from: { kind: "task", taskId }, envelope: renderTaskEnvelope(taskId) });
3582
4177
  if (!inj.ok) {
3583
4178
  wizards.drop(runner);
3584
4179
  return inj;
@@ -3687,7 +4282,7 @@ const main = async () => {
3687
4282
  const sweep = async () => {
3688
4283
  const taskOwners = new Set(tasks.list()
3689
4284
  .filter((t) => t.enabled)
3690
- .map((t) => wizardStore()?.byName(t.owner)?.target)
4285
+ .map((t) => wizardStore()?.get(t.owner)?.target ?? wizardStore()?.byName(t.owner)?.target)
3691
4286
  .filter((t) => !!t));
3692
4287
  const pendingSids = new Set(listPending().map((p) => p.meta.sessionId).filter((s) => !!s));
3693
4288
  const r = await bridge.reapOverflow(max, (target, sid) => taskOwners.has(target) ? "owns scheduled task"
@@ -3709,6 +4304,7 @@ const main = async () => {
3709
4304
  }
3710
4305
  const shutdown = async (signal) => {
3711
4306
  log.info({ signal }, "shutdown signal");
4307
+ stopReceipts();
3712
4308
  scheduler.stop();
3713
4309
  steward?.stop();
3714
4310
  tasks.stop();
@@ -3724,6 +4320,10 @@ const main = async () => {
3724
4320
  await Promise.allSettled([ws.shutdown(), http.close()]);
3725
4321
  process.exit(0);
3726
4322
  };
4323
+ http.register("POST /shutdown", (_req, res) => {
4324
+ json(res, 200, { ok: true });
4325
+ return shutdown("HTTP");
4326
+ });
3727
4327
  process.on("SIGINT", () => void shutdown("SIGINT"));
3728
4328
  process.on("SIGTERM", () => void shutdown("SIGTERM"));
3729
4329
  try {