wezard 2.7.1 → 2.7.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.
@@ -51,7 +51,6 @@ import { baseOfKey, bindMultiBot, bindTagLinker, botOfKey, headed, ownHead, isIn
51
51
  import { appendEpisode, clipForCharter, cwdOfMd, episodePath, inboxPath, mdsOf, proposedCwds, memoryPath, memoryRoot, proposeMemory, readMemory } from "./wizard-memory.js";
52
52
  import { retireStewardTask, startSteward, stewardEnvelope, STEWARD_ID, STEWARD_RUN_MS, STEWARD_TARGET } from "./memory-steward.js";
53
53
  import { applyChatNames, chatBaseOf, chatNameOf, chatPolicyOf, clearChatName, listChatNames, normChatName, peerAddress, planChatNames, setChatName, stewardTierOf } from "./chat-name.js";
54
- import { createTopOnlyNudge, gcNudge, policyKeyOf, renderNudge } from "./top-only-nudge.js";
55
54
  import { bindWizardStore, loadWizardStore, wizardStore, settleName, settleAll, backfillBorn, sweepUnborn, BOOT_ID, evictStaleName, childrenOf, ancestorsOf, renderCharter, renderLead, renderLeadPlaybook, ROSTER_FRESH_MS, renderRoster, handoffAt, memoryNudgeAt, MEMORY_NUDGE_FLOOR, MEMORY_MAX, NOTE_MAX, } from "./wizard.js";
56
55
  import { activeRows, bindNoticeBox, createNoticeBox, createSeenRing, gcSeen, chatAudience, hears } from "./notices.js";
57
56
  import { createLedger, digest, pendingGc, renderDigest, renderTable, renderAwaiting, renderStalled, renderShelved, ago as agoMs, HEARD_EVERY_MS } from "./pending-items.js";
@@ -67,7 +66,7 @@ import { returnOpOf } from "./dispatch.js";
67
66
  import { isTerminal, renderInFlight, turnState } from "../shared/turn-state.js";
68
67
  import { startIdleNudge } from "./idle-nudge.js";
69
68
  import { askerFromEnvelope, renderReminder, withMsg } from "../shared/reminder.js";
70
- import { createHandoffs, handedOff, handingOff } from "./handoff.js";
69
+ import { createHandoffs, cuttingTurn, handedOff, handingOff } from "./handoff.js";
71
70
  import { coldGateOf, decide, rankCandidates, renderCandidates, routeDutiesOf, tierFor, wakeCostOf } from "./route.js";
72
71
  import { parseWhen, renderChatLog, UNKNOWN_HUMAN } from "./chat-log.js";
73
72
  import { audienceOf } from "../shared/role-view.js";
@@ -214,6 +213,8 @@ const main = async () => {
214
213
  peerEvents?.accepted(e); },
215
214
  committed: (e) => { if (e.payload.peer)
216
215
  peerEvents?.committed(e); },
216
+ gone: (e) => { if (e.payload.peer)
217
+ peerEvents?.gone?.(e); },
217
218
  canceled: (e) => {
218
219
  if (e.payload.peer)
219
220
  peerEvents?.canceled(e);
@@ -521,6 +522,11 @@ const main = async () => {
521
522
  const m = bridge;
522
523
  installMirrorEventListener(ws.client, m, log.child({ mod: "mirror" }));
523
524
  http.register("GET /message-events/status", (_req, res) => json(res, 200, events.status()));
525
+ // 单条 event 的 status 视图 (`?id=`; id 里带冒号, 不走路径段) —— 轮询单条不必把全表算一遍。
526
+ http.register("GET /message-events/event", (_req, res, url) => {
527
+ const e = events.event(url.searchParams.get("id") ?? "");
528
+ json(res, e ? 200 : 404, e ?? { error: "unknown event" });
529
+ });
524
530
  // 路由表的诊断视图 (`?owner=` = 那个 wizard 名下的行, 缺省 = 全表)。
525
531
  http.register("GET /routes", (_req, res, url) => {
526
532
  const owner = url.searchParams.get("owner")?.trim();
@@ -924,17 +930,6 @@ const main = async () => {
924
930
  const relayLabel = (t) => ((h) => (h ? tagLink(t, h) : ""))(tagHead(t));
925
931
  /** 以 `self` 的名义往 `dest` 群发一条: 头独占一行; 头怎么写 (群默认 wizard 报不报名) 归 ownHead。 */
926
932
  const postAs = (self, dest, text) => notifyChat(dest, headed(ownHead(self, dest), text, "\n\n"));
927
- // 没开顶层模式的群里公开气泡多了 → 往那个群的管家 (群默认会话, target 就是 base principal) 信箱挂一行,
928
- // 让它问人要不要开顶层模式 (见 top-only-nudge.ts)。路径里写聊天名: 管家照抄就能 config_set。
929
- const topOnlyNudge = createTopOnlyNudge({
930
- store: loadJsonMap("~/.wezard/top-only-nudge.json", gcNudge),
931
- policy: () => ((p) => ({ windowMs: p.windowMin * 60_000, threshold: p.threshold, cooldownMs: p.cooldownH * 3_600_000 }))(cfg.wrc.mirror.topOnlyNudge),
932
- nudge: (chat, n) => {
933
- const p = cfg.wrc.mirror.topOnlyNudge;
934
- notices.post([chat], renderNudge(policyKeyOf(chatNameOf(cfg, chat), chat), n, p.windowMin, p.cooldownH));
935
- log.info({ chat, n }, "top-only nudge: 已提醒管家问人要不要开顶层模式");
936
- },
937
- });
938
933
  /** 公开的 wizard 间对话: 落在 `channel` 这个群。 */
939
934
  const relayPeer = (from, to, body, channel) => {
940
935
  const text = body.trim();
@@ -945,8 +940,6 @@ const main = async () => {
945
940
  // 头独占一行, 正文自成一个块 —— 只隔一个换行的话, markdown 会把正文首行
946
941
  // 当成头那一段的续行; 表格因此整张塌成一行带竖线的文字 (表格不能打断段落)。
947
942
  notifyChat(channel, `${head}\n\n${clipped}`);
948
- if (!chatPolicyOf(cfg, channel).topOnly)
949
- topOnlyNudge.count(baseOfKey(channel));
950
943
  };
951
944
  /** 调用方这一轮所在的公开频道: 人从哪个群叫的它 / 公开 peer 轮的那个群;
952
945
  * 私聊轮或无记录 → 它的 home 群。notify 与 public send_peer 默认发到这里。 */
@@ -986,8 +979,8 @@ const main = async () => {
986
979
  };
987
980
  /** 一笔活第几个来回 (§3.2): 同一行上已经注入过的续问数 + 1 —— 现算, 不落第二份数据。
988
981
  * 续问的注入事件 id 是 `route:<event>:r<legs>`, 所以从它自己就能数回来 (reload 也在盘上)。 */
989
- const legsOf = (event) => events.status().events.reduce((n, e) => {
990
- const m = e.id.startsWith(`route:${event}:r`) ? Number(e.id.slice(`route:${event}:r`.length)) : NaN;
982
+ const legsOf = (event) => events.ids().reduce((n, id) => {
983
+ const m = id.startsWith(`route:${event}:r`) ? Number(id.slice(`route:${event}:r`.length)) : NaN;
991
984
  return Number.isInteger(m) && m > n ? m : n;
992
985
  }, 1);
993
986
  /** 同一笔活上的一句续问 (idle-nudge 追问 / 工单打回): 不新开行, 注入事件 id
@@ -1008,8 +1001,9 @@ const main = async () => {
1008
1001
  } } });
1009
1002
  return true;
1010
1003
  };
1011
- /** 已经因为不合工单验收被打回过的那些笔 (行状态会随打回留在 `open`, 光看它认不出「打回过」)。 */
1012
- const bounced = new Set();
1004
+ /** 已经因为不合工单验收被打回过的那些笔 (行状态会随打回留在 `open`, 光看它认不出「打回过」)
1005
+ * → 被打回的那一轮的注入事件 id: 同一轮的终句重放 (见 deliverFinal) 认得出不是补交。 */
1006
+ const bounced = new Map();
1013
1007
  /** 工单验收 (§9.6, 从 receipts 的 watcher 重建): 交的活不合规格 → 这一份不算交差。
1014
1008
  * 计划节点有自己的验收 (workflow), 不经这里。 */
1015
1009
  const acceptanceOf = (row, body) => {
@@ -1022,8 +1016,8 @@ const main = async () => {
1022
1016
  return j?.status === "open" ? rejectReason(j.accept ?? "result", parseClosing(body)) : undefined;
1023
1017
  };
1024
1018
  /** 以工单 owner 的名义在同一笔上打回一次: 不新开行, 行留在 `open` 等它按约定再交一份。 */
1025
- const rebound = (row, why) => {
1026
- bounced.add(row.event);
1019
+ const rebound = (row, why, eventId) => {
1020
+ bounced.set(row.event, eventId);
1027
1021
  const from = ledgerOwnerOf(row.event) || row.owner;
1028
1022
  acceptContinuation({
1029
1023
  from, to: row.owner, channel: "", event: row.event, legs: legsOf(row.event) + 1,
@@ -1059,6 +1053,16 @@ const main = async () => {
1059
1053
  // 交接是生命周期事件, 不进群; 失败只捎给它自己 (它调完 handoff_self 拿到的是
1060
1054
  // scheduled:true, 不说的话它不知道上下文其实没换)。
1061
1055
  notify: (t, text) => notices.post([t], text),
1056
+ // 交接没做成: 切断那一轮扣下的终句按原行补投 —— 走与轮终同一条 onFinal (行已被合成失败落定的, 照追述规则处理)。
1057
+ uncut: (t, ids) => ids.forEach((id) => {
1058
+ const e = events.get(id);
1059
+ if (!e?.result) {
1060
+ log.warn({ target: t, event: id }, "handoff: 扣下的终句已不在事件库, 补投不了");
1061
+ return;
1062
+ }
1063
+ log.info({ target: t, event: id, serves: e.serves }, "handoff: 交接没做成, 补投切断那一轮的终句");
1064
+ settleFinal(e, { text: e.result.body });
1065
+ }),
1062
1066
  log,
1063
1067
  store: loadJsonMap(cfg.wrc.mirror.handoffsFile),
1064
1068
  archive: (e) => {
@@ -1082,7 +1086,7 @@ const main = async () => {
1082
1086
  const waiting = r.kind === "wizard" ? r.to : baseOfKey(r.channel); // 等着它交代的那一方 (信封挂在它名下)
1083
1087
  const channel = r.kind === "chat" ? r.channel : ""; // 结论要落回群里时才带频道 (§9.4)
1084
1088
  const base = `route:${event}:resume`;
1085
- const id = events.status().events.some((e) => e.id === base) ? `${base}:${at}` : base;
1089
+ const id = events.known(base) ? `${base}:${at}` : base;
1086
1090
  try {
1087
1091
  events.accept({
1088
1092
  id, identity: JSON.stringify([t, event, "resume", at]),
@@ -1250,7 +1254,12 @@ const main = async () => {
1250
1254
  if (row.job && isTerminal(out.status) && !row.late)
1251
1255
  jobs.settle(row.job, row.owner, out.status, parseClosing(out.body).artifacts, { turn: event });
1252
1256
  }
1253
- const esc = router.escalate(event, (k) => !!wizards.get(k));
1257
+ postRoute(row, out, router.escalate(event, (k) => !!wizards.get(k)), `rcpt:${event}:${out.rev}`);
1258
+ };
1259
+ /** 一份定论的投递 (账本已记过): 投给升级后的收方 `esc` (没有 = 行上的 receiver)。群收方只落
1260
+ * `hand`; wizard 收方 (含 Dead Letter 退到的群默认 wizard) 进收件箱, id = `inbox`。 */
1261
+ const postRoute = (row, out, esc, inbox) => {
1262
+ const event = row.event;
1254
1263
  const receiver = esc?.receiver ?? row.receiver;
1255
1264
  const via = (esc?.via ?? []).map(displayName);
1256
1265
  // 升级落在 chat 根 = Dead Letter: 退到那个群, 由它的默认 wizard 收下这份结论。
@@ -1274,7 +1283,6 @@ const main = async () => {
1274
1283
  const retellTo = late && servedRow && servedRow.owner === to && servedRow.outcome?.status !== "canceled"
1275
1284
  ? servedRow.receiver.kind === "wizard" ? displayName(servedRow.receiver.to) : chatNameOf(cfg, servedRow.receiver.channel) || servedRow.receiver.channel
1276
1285
  : undefined;
1277
- const inbox = `rcpt:${event}:${out.rev}`;
1278
1286
  const job = row.job ? jobs.get(row.job) : undefined;
1279
1287
  const tally = row.job ? jobs.tally(row.job) : undefined;
1280
1288
  events.accept({
@@ -1299,6 +1307,34 @@ const main = async () => {
1299
1307
  });
1300
1308
  router.commit(ops.hand({ event, inbox }));
1301
1309
  };
1310
+ /** 收件箱里那份回执投不到了 (`gone`: 收方没有绑定, 按 store 也恢复不了) —— 投进去时它还在
1311
+ * 注册表上, 升级没跳过它。现在把它当不在, 沿 receiver 边重走一次升级: 能收的上层 wizard /
1312
+ * 群根 (Dead Letter) 收下。链上无处可升 (根行的收方自己没了): canceled 是收掉它的那一方自己
1313
+ * 发起的, 没人在等, 不再投; 别的定论退到收方的家群 (Dead Letter)。账本不再记第二遍。 */
1314
+ const rerouteGone = (e) => {
1315
+ const from = e.payload.peer?.from;
1316
+ const event = from?.receipt ? from.event : undefined;
1317
+ const row = event ? router.get(event) : undefined;
1318
+ if (!event || !row || !from?.status) {
1319
+ log.warn({ id: e.id, target: e.target }, "receipt reroute: 认不出回执的那一行, 投不出去");
1320
+ return;
1321
+ }
1322
+ const reachable = (k) => k !== e.target && !!wizards.get(k) && !!mirrorStore.get(k);
1323
+ const home = baseOfKey(e.target);
1324
+ const esc = router.escalate(event, reachable)
1325
+ ?? (row.receiver.kind === "wizard" && reachable(row.receiver.to) ? undefined
1326
+ : from.status === "canceled" || home === e.target ? null
1327
+ : { receiver: { kind: "chat", channel: home, message: "" }, serves: "", via: [e.target] });
1328
+ if (esc === null) {
1329
+ log.info({ id: e.id, target: e.target, event, status: from.status }, "receipt reroute: 收方已不在且无处可升, 不再投");
1330
+ return;
1331
+ }
1332
+ const rev = e.id.slice(`rcpt:${event}:`.length).split("~")[0];
1333
+ const prefix = `rcpt:${event}:${rev}~`;
1334
+ const inbox = `${prefix}${events.ids().filter((id) => id.startsWith(prefix)).length + 1}`;
1335
+ log.info({ id: e.id, target: e.target, event, to: esc?.receiver ?? row.receiver, inbox }, "receipt reroute: 收方已不在, 改投");
1336
+ postRoute(row, { status: from.status, body: e.payload.text, rev }, esc, inbox);
1337
+ };
1302
1338
  /** 一条路由 op: 先落盘, 再投该投的 (need / error 带正文入箱; answer 按 receiver 投递)。这里
1303
1339
  * 只管 `re` (`mark open`) 把那一行拉回在飞 —— 账本与工单跟着 deliverRoute 走。 */
1304
1340
  const commitRoute = (op) => {
@@ -1320,6 +1356,23 @@ const main = async () => {
1320
1356
  * 也最多再多打回一次)。**这是「轮终 evidence → onFinal」那条主触发链的落点**: 没有它,
1321
1357
  * answer 就只能靠 sweeper / 级联取消 / boot 重放合成, 谁答完都不会有回执。 */
1322
1358
  deliverFinal = (e, verdict) => {
1359
+ // 同一轮的终句会递进来两次: result() 当场递一次, 泵见 complete 未 finalized 再补判一次
1360
+ // (message-events)。被打回那一轮的重放不是补交 —— 丢掉; 否则 bounced 已记着这一笔,
1361
+ // 第二次就把被打回的原文照收成 done (打回的续问还没注入, 定论先落了)。
1362
+ if (e.serves && bounced.get(e.serves) === e.id)
1363
+ return;
1364
+ // 自我交接切断的那一轮: 收尾那句不是欠着的那几笔的答案 —— 行留 open, 交给新会话的「继续 <event>」。
1365
+ // 扣下记在这场交接上: 交接没做成 (新会话没起来), 它就是那几笔仅有的交代, 由 uncut 补投。
1366
+ if (cuttingTurn(e.target)) {
1367
+ log.info({ event: e.id, target: e.target, serves: e.serves }, "handoff: 交接切断的那一轮终句不落定");
1368
+ if (!verdict.error)
1369
+ handoffs.cut(e.target, e.id);
1370
+ return;
1371
+ }
1372
+ settleFinal(e, verdict);
1373
+ };
1374
+ /** 一轮的终句落到它服务的那一行上 (定论 / 中途态), 验收不合规格就打回续问。 */
1375
+ const settleFinal = (e, verdict) => {
1323
1376
  const plans = router.onFinal(e.target, e.serves, verdict);
1324
1377
  const answer = plans.flatMap((op) => (op.op === "answer" && op.outcome.status === "done" ? [op] : []))[0];
1325
1378
  const row = answer ? router.get(answer.event) : undefined;
@@ -1327,7 +1380,7 @@ const main = async () => {
1327
1380
  if (row && why) {
1328
1381
  if (row.status === "need" || row.status === "error")
1329
1382
  commitRoute(ops.mark({ event: row.event, status: "open" }));
1330
- rebound(row, why);
1383
+ rebound(row, why, e.id);
1331
1384
  return;
1332
1385
  }
1333
1386
  plans.forEach(commitRoute);
@@ -1879,7 +1932,9 @@ const main = async () => {
1879
1932
  valid: (e) => {
1880
1933
  const p = e.payload.peer;
1881
1934
  const attempt = p.attemptId ? workflow.byTurn(e.speaker, p.from.event ?? "") : undefined;
1882
- return planStore.healthy() && !e.canceled && (!p.from.receipt || planContinuationAllowed(e.target)) && (!p.job || jobs.get(p.job)?.status === "open") && !attempt?.canceled
1935
+ // 单已收但这一笔的行还 open = 收工前就派出去的活, 照投 (见 close_job)。
1936
+ return planStore.healthy() && !e.canceled && (!p.from.receipt || planContinuationAllowed(e.target))
1937
+ && (!p.job || jobs.get(p.job)?.status === "open" || (!!e.serves && router.get(e.serves)?.status === "open")) && !attempt?.canceled
1883
1938
  && (!p.after?.length || prereqsOf(e.speaker, p.after).every((r) => r.state === "done" || r.state === "pending"));
1884
1939
  },
1885
1940
  accepted: (e) => {
@@ -1918,6 +1973,15 @@ const main = async () => {
1918
1973
  if (p.attemptId)
1919
1974
  workflow.cancel(e.target, e.speaker, p.job, p.from.event);
1920
1975
  },
1976
+ // 收方没了: 回执沿链改投; 派活 / 续问不另做 —— 那一行的 owner 就是它, sweeper 见 pane 不在判 dead 上浮。
1977
+ gone: (e) => {
1978
+ const p = e.payload.peer;
1979
+ log.warn({ id: e.id, target: e.target, receipt: !!p.from.receipt }, "message event: 收方已不在 (无绑定, 恢复失败), 不再重投");
1980
+ if (p.attemptId)
1981
+ workflow.dispatched(p.attemptId, false);
1982
+ if (p.from.receipt)
1983
+ rerouteGone(e);
1984
+ },
1921
1985
  ready: async (e) => prereqsOf(e.speaker, e.payload.peer.after ?? []).every((r) => r.state === "done")
1922
1986
  && (e.priority === "now" || await m.idleNow(e.target) || !(await m.paneLive(e.target)) || (e.priority === "urgent" && !(await othersTurn(e.target, e.speaker)))),
1923
1987
  inject: async (e, boundary) => {
@@ -1956,6 +2020,7 @@ const main = async () => {
1956
2020
  return false;
1957
2021
  return router.finalReleased(e.target, e.serves, (events.get(e.id) ?? e).result?.body ?? "");
1958
2022
  });
2023
+ m.setEventHandedAway((e) => { const row = e.serves ? router.get(e.serves) : undefined; return !!row && row.owner !== e.target; });
1959
2024
  // boot 的路由表侧恢复 (T8): held 重查前置、answered 重投一次; 二者幂等, 与 events.resume
1960
2025
  // 重放的注入/回执撞上无害。
1961
2026
  void m.restored.then(() => { events.resume(); settleHeld(); replayAnswered(); });
@@ -2273,6 +2338,9 @@ const main = async () => {
2273
2338
  catch (error) {
2274
2339
  return { status: 409, body: { ok: false, eventId, reason: errText(error) } };
2275
2340
  }
2341
+ // 交出的是人问的那一笔: 那句话的气泡跟着行走, 接手方开轮时接过去续写 (不另开新气泡)。
2342
+ if (handedChat && serves)
2343
+ m.handBubble(self, serves, eventId, target);
2276
2344
  // `re` 把那一笔从 need / error 拉回 open (§3.2 的 need→open / error→open): 行状态是「这份中途态
2277
2345
  // 已经报过了」的去重记号 —— 不拉回去, owner 下一轮交同一个中途态就被静默吞掉 (往返断在这里)。
2278
2346
  if (reTarget && (reTarget.status === "need" || reTarget.status === "error"))
@@ -2424,7 +2492,7 @@ const main = async () => {
2424
2492
  // 同一对并发多笔是允许的 (§4.1): 先取**还在排队**的那份 (那份还没进过你的会话); 都取过了就
2425
2493
  // 报最新那件已交付 —— 绝不把已经 paste 过的那份当成新答案交回来。
2426
2494
  const mine = router.settledTo(self).filter((r) => r.owner === h.target).reverse() // byAt → 新在前
2427
- .map((row) => { const box = receiptOf(row); return { row, box, ev: events.get(box.id) }; });
2495
+ .map((row) => { const box = receiptOf(row); const execution = events.executionOf(box.id); return { row, box, ev: execution && { execution } }; });
2428
2496
  const pick = mine.find((x) => x.ev?.execution === "queued") ?? mine[0];
2429
2497
  if (!pick)
2430
2498
  return { name: h.address, idle: wr.idle, ...(wr.reason ? { reason: wr.reason } : {}),
@@ -3284,10 +3352,9 @@ const main = async () => {
3284
3352
  json(res, 409, { ok: false, reason: "transfer hands over the row this turn owes; a lead dispatch cannot transfer" });
3285
3353
  return;
3286
3354
  }
3287
- if (transfer && (!plan.serves || router.get(plan.serves)?.receiver.kind !== "wizard")) {
3288
- json(res, 409, { ok: false, reason: "这一轮没有欠着的同伴活可以交出去 (transfer 只用于同伴派给你的那一笔)" });
3289
- return;
3290
- }
3355
+ // 交出人问的那一笔同样合法 (同 tell_peer): 那一行归 chat receiver, 与同伴派的件走同一条
3356
+ // move —— 接手方终句就是那句人话的答复。没得交 (`serves` 落空 / 已不在手上) 由
3357
+ // transferRefusal 在投递前拦, 不在这里按 receiver 种类预判。
3291
3358
  const force = b.force === true;
3292
3359
  const to = (b.to ?? "").toString().trim();
3293
3360
  const now = Date.now();
@@ -3363,12 +3430,8 @@ const main = async () => {
3363
3430
  d = { kind: "spawn", why: `${d.why}; 但它被${t.body.gated === "direction" ? "方向闸" : "冷门控"}退回 (${String(t.body.reason)}) —— 改为白板 spawn` };
3364
3431
  }
3365
3432
  const name = (b.name ?? "").toString().trim();
3366
- const description = (b.description ?? "").toString().trim();
3367
- if (!name || !description) {
3368
- settleRoot(false);
3369
- json(res, 400, { ok: false, decision: "spawn", reason: `${d.why} —— 要新生一个, 带上 name (这件事的短名) 与 description (它的职责) 重发`, ...(shown.length ? { candidates: shown } : {}) });
3370
- return;
3371
- }
3433
+ const description = (b.description ?? "").toString().trim() || clipLine(task, 120);
3434
+ // 空 name 交给 bearAs / claimSlot 按职责自动取名避重, 不当作显式点名复用旧记录。
3372
3435
  const tier = taskTier;
3373
3436
  const born = await bearAs(self, { inherit: false, name, description, tier, ...(lead ? { lead: true } : {}) }, undefined, dispatchOrigin?.authority);
3374
3437
  if (born.status !== 200) {
@@ -3478,9 +3541,8 @@ const main = async () => {
3478
3541
  const plan = b.task?.trim() && !planned ? planReturn(self, body) : undefined;
3479
3542
  if (plan && !plan.ok)
3480
3543
  return { status: plan.status, body: { ok: false, reason: plan.reason } };
3481
- // transfer 只对同伴派给你的那一笔有意义 (要交出去的那一行得真的欠在同伴头上)。
3482
- if (plan?.ok && plan.op === "transfer" && (!plan.serves || router.get(plan.serves)?.receiver.kind !== "wizard"))
3483
- return { status: 409, body: { ok: false, reason: "这一轮没有欠着的同伴活可以交出去 (transfer 只用于同伴派给你的那一笔)" } };
3544
+ // transfer 交出人问的那一笔同样合法 (同 tell_peer, 那一行归 chat receiver) —— 没得交 /
3545
+ // 已不在手上由下面的 transferRefusal 拦, 不在这里按 receiver 种类预判。
3484
3546
  const askedEffort = parseEffort(b.effort);
3485
3547
  if (b.effort !== undefined && !askedEffort) {
3486
3548
  return { status: 400, body: { ok: false, reason: `effort 只有这几档: ${EFFORTS.join(" / ")}` } };
@@ -3654,21 +3716,28 @@ const main = async () => {
3654
3716
  if (dispatched) {
3655
3717
  const deadlineAt = deadlineOf(taskAt);
3656
3718
  const quiet = !!plan?.ok && plan.op === "none";
3719
+ // transfer 交出的是人问的那一笔 (同 tell_peer): 注入事件得带上那个群, 否则频道为空被当
3720
+ // 私聊轮扣下, 刚出生的接手方那句终句人永远收不到 (见 mirror-bridge 的 final 通道)。
3721
+ // 同伴派的件 (receiver 是 wizard) 仍旧私聊 —— handedChat 为空, 行为不变。
3722
+ const handed = plan?.ok && plan.op === "transfer" && plan.serves ? router.get(plan.serves)?.receiver : undefined;
3723
+ const handedChat = handed?.kind === "chat" ? handed.channel : "";
3657
3724
  // 路由行 (§3.1): 生出来的那个领活, 结论回生它的这位。
3658
3725
  const serves = plan?.ok
3659
3726
  ? openDispatchRow({ self, target, op: plan.op, deadlineAt, job: jobId, ...(asker ? { asker } : {}), ...(plan.serves ? { serves: plan.serves } : {}) })
3660
3727
  : "";
3661
3728
  dispatchedEvent = serves;
3662
3729
  const eventId = serves ? `route:${serves}` : `p:${self}:${target}:${taskAt}:1`;
3663
- const birthTell = { from: self, to: target, channel: "", job: jobId, at: taskAt, event: serves, legs: 1, deadlineAt, asker, dispatchKey: eventId };
3664
- events.accept({ id: eventId, target, speaker: self, channel: "", serves, asker, priority: "normal", deadline: deadlineAt,
3730
+ const birthTell = { from: self, to: target, channel: handedChat, job: jobId, at: taskAt, event: serves, legs: 1, deadlineAt, asker, dispatchKey: eventId };
3731
+ events.accept({ id: eventId, target, speaker: self, channel: handedChat, serves, asker, priority: "normal", deadline: deadlineAt,
3665
3732
  payload: { text: task, peer: {
3666
- from: { kind: "peer", from: self, event: serves, ...(jobId ? { job: jobId } : {}), ...(asker ? { asker } : {}) },
3667
- envelope: envelopeFor(self, "", { asker, ...(quiet ? { quiet: true } : {}) }) + (b.lead === true ? renderReminder({ wezard: "lead" }, renderLeadPlaybook()) : ""),
3733
+ from: { kind: "peer", from: self, event: serves, ...(jobId ? { job: jobId } : {}), ...(handedChat ? { transferred: true } : {}), ...(asker ? { asker } : {}) },
3734
+ envelope: envelopeFor(self, handedChat, { asker, ...(quiet ? { quiet: true } : {}) }) + (b.lead === true ? renderReminder({ wezard: "lead" }, renderLeadPlaybook()) : ""),
3668
3735
  ...(quiet ? {} : { tell: birthTell }),
3669
3736
  job: jobId, kind: "task", spawned: true, role: roleParam(body.role),
3670
3737
  ...(plan?.ok && plan.op === "transfer" ? { transferred: true } : {}),
3671
3738
  } } });
3739
+ if (handedChat && serves)
3740
+ m.handBubble(self, serves, eventId, target);
3672
3741
  }
3673
3742
  else if (jobId && birthGuard()) {
3674
3743
  jobs.attach(jobId, { target, task: "", spawned: true, ...(roleParam(body.role) ? { role: roleParam(body.role) } : {}) });
@@ -4018,8 +4087,10 @@ const main = async () => {
4018
4087
  // 这张单名下还排着没投的 (held: 等前置 / 没轮上) 也不投了 —— 单都关了, 它再也轮不到
4019
4088
  // (注入的闸要求单还开着)。receipts.cancelHeld 同一条, 那边只做账 (T6)。
4020
4089
  cancelRoutes(router.rows().filter((r) => r.status === "held" && r.job === j.id).map((r) => r.event), () => `随工单 ${j.id} 收了, 这件排着的活没投。`, j.owner);
4021
- // 这张单名下还排着没投的 (after 停放) 也不投了。
4022
- events.cancel((e) => e.speaker === j.owner && e.payload.peer?.job === j.id);
4090
+ // 这张单名下还排着没投的 (after 停放) 也不投了。行还 open 的那笔是已经派出去的活
4091
+ // (出生事件常在冷 pane 上排着没注入): 收工 (accept) 不收活 —— 照投、照答, 回执标「已经收工」;
4092
+ // 行被上面级联落定 (cancel / held) 的、没开行的才撤。
4093
+ events.cancel((e) => e.speaker === j.owner && e.payload.peer?.job === j.id && router.get(e.serves ?? "")?.status !== "open");
4023
4094
  const closed = jobs.close(j.id, summary, mark);
4024
4095
  ledger.closeJob(j.id);
4025
4096
  // 收工结论留档到开单者名下 (与交接简报同一份 jsonl); 空工单 (没人、没结论) 不记。
@@ -4862,9 +4933,14 @@ const main = async () => {
4862
4933
  }
4863
4934
  setTimeout(() => void sweep().catch((e) => log.warn({ err: e.message }, "pane cap sweep failed")), 15_000).unref();
4864
4935
  }
4936
+ let shuttingDown = false;
4865
4937
  const shutdown = async (signal) => {
4938
+ if (shuttingDown)
4939
+ return;
4940
+ shuttingDown = true;
4866
4941
  log.info({ signal }, "shutdown signal");
4867
- events.stop();
4942
+ // Pause delivery, but persist arrivals while transports/in-flight handlers drain.
4943
+ events.drain();
4868
4944
  stopSweep();
4869
4945
  scheduler.stop();
4870
4946
  steward?.stop();
@@ -4879,6 +4955,7 @@ const main = async () => {
4879
4955
  setTimeout(() => process.exit(0), 1500).unref();
4880
4956
  await new Promise((r) => setTimeout(r, 200));
4881
4957
  await Promise.allSettled([ws.shutdown(), http.close()]);
4958
+ events.stop();
4882
4959
  process.exit(0);
4883
4960
  };
4884
4961
  http.register("POST /shutdown", (_req, res) => {