wezard 2.7.4 → 2.7.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -26,8 +26,8 @@ import { applyHot, cfgSeenBy, patchedTop, pinChatKeys, configGet, fromLegacy, le
26
26
  import { computeUsage, costOf, fmtCost, fmtTokens, sumTotal } from "./usage.js";
27
27
  import { installInboundRouter } from "./inbound.js";
28
28
  import { loadMirrorStore } from "./mirror-store.js";
29
- import { cancelOps, deadlineOf, eventRef, isEventRef, makeOutcome, openRouter, ops, pickPair } from "./router.js";
30
- import { createMessageEvents, messageId, finalId } from "./message-events.js";
29
+ import { cancelOps, deadlineOf, eventRef, finalKind, isEventRef, makeOutcome, openRouter, ops, pickPair } from "./router.js";
30
+ import { createMessageEvents, messageId, finalId, reportSource, receiptCoverage, receiptFacts } from "./message-events.js";
31
31
  import { startMirror, installMirrorEventListener } from "./mirror-bridge.js";
32
32
  import { backendFor, runTmux, setTmuxTimeoutReporter, spawnTmuxClaude } from "./spawn-tmux.js";
33
33
  import { bindCardChannel, installApprovalEventListener, makeApproveHandler, requestConfirm } from "./approval.js";
@@ -207,6 +207,13 @@ const main = async () => {
207
207
  let deliverFinal;
208
208
  let handoffAllowed = () => false;
209
209
  let handoffFinalSuppressed = (e) => !!e.handoffCut && !e.handoffRestored;
210
+ const ownsReplyEvent = (e) => {
211
+ const row = e.serves ? router.get(e.serves) : undefined;
212
+ if (row && row.owner !== e.target)
213
+ return false;
214
+ const latest = e.serves ? events.latestTransfer(e.serves) : undefined;
215
+ return !latest || latest.id === e.id || latest.at < e.at;
216
+ };
210
217
  const events = createMessageEvents(join(cfg.daemon.stateDir, "message-events.json"), {
211
218
  resource: (target) => bridge.sessionInfo(target)?.tmuxPane || target,
212
219
  transcript: (target) => bridge.inputSessionInfo(target)?.jsonlPath ?? bridge.sessionInfo(target)?.jsonlPath,
@@ -226,7 +233,7 @@ const main = async () => {
226
233
  events.result(e.id, body, finalId(e.id, e.channel, body));
227
234
  // A canceled, unexposed input has a terminal response owned by its original channel.
228
235
  events.delivery({ id: finalId(e.id, e.channel, body), eventId: e.id, target: e.target, turn: e.turn || e.id,
229
- channel: e.channel, revision: finalId(e.id, e.channel, body), body, chunks: bridge.formatFinal(e.target, e.turn || e.id, e.channel, body) });
236
+ channel: e.channel, revision: finalId(e.id, e.channel, body), body, purpose: "cancellation", reportKind: "canceled", chunks: bridge.formatFinal(e.target, e.turn || e.id, e.channel, body) });
230
237
  }
231
238
  },
232
239
  // 人话 (非 peer) 不再问「pane 闲不闲」: 忙就贴进去, 顺序交给 backend CLI 自己的输入队列
@@ -281,8 +288,9 @@ const main = async () => {
281
288
  // 一轮以 CLI 报错收尾: 那段报错不是定论, 但要作为中途态 (error) 报给 receiver ——
282
289
  // 所以照旧不落成 result, 只把原文交给 final (见 §9.4)。
283
290
  return { consumed: true, interjected: !!turns[start].queued,
284
- ...(next >= 0 ? { closedAt: turns[next].ms ?? Date.now() } : {}),
285
- ...(ended && last && !last.mid
291
+ ...(next >= 0 ? { closedAt: turns[next].ms ?? Date.now() }
292
+ : last?.end && !last.text ? { closedAt: last.ms ?? Date.now() } : {}),
293
+ ...(ended && last?.text && !last.mid
286
294
  ? last.apiError ? { body: last.text, error: true } : { body: last.text, revision: finalId(e.id, e.channel, last.text) }
287
295
  : {}) };
288
296
  }
@@ -296,6 +304,18 @@ const main = async () => {
296
304
  format: (d) => ({ ...d, chunks: d.chunks.map((c) => responseTracker.freeze(d.channel.replace(/^(user|chat|group):/, ""), c)) }),
297
305
  send: async (d, n) => {
298
306
  await bridge.settleFinalProgress(d.id);
307
+ const allowed = () => {
308
+ const source = d.eventId ? events.authorityOf(d.eventId) : undefined;
309
+ if (!source)
310
+ return !d.eventId || d.purpose === "task";
311
+ const row = source.serves ? router.get(source.serves) : undefined;
312
+ const cancellation = d.purpose === "cancellation" && source.canceled && source.unexposedHuman;
313
+ return source.target === d.target && !source.interjected && !handoffFinalSuppressed(source) && ownsReplyEvent(source)
314
+ && (cancellation || !source.canceled && row?.outcome?.status !== "canceled" && row?.status !== "held")
315
+ && (d.purpose !== "progress" || !!row && ["open", "need", "error"].includes(row.status));
316
+ };
317
+ if (!allowed())
318
+ return { failed: "delivery authority withdrawn before transport" };
299
319
  const chat = d.channel.replace(/^(user|chat|group):/, "");
300
320
  if (wx.owns(chat)) {
301
321
  const result = wx.sendFinal(chat, wxMarkdown(d.chunks[n]), `${d.id}:${n}`);
@@ -304,7 +324,7 @@ const main = async () => {
304
324
  failed: result.state === "failed" ? result.reason ?? "Weixin final rejected" : undefined };
305
325
  }
306
326
  const stream = d.stream && n === 0 && Date.now() < d.stream.expires ? d.stream : undefined;
307
- const ack = await responseTracker.sendFrozen(chat, d.chunks[n], stream, events.running);
327
+ const ack = await responseTracker.sendFrozen(chat, d.chunks[n], stream, () => events.running() && allowed());
308
328
  return ack && typeof ack === "object" && "errcode" in ack && ack.errcode === 0 ? { ack: JSON.stringify(ack) }
309
329
  : { ack: JSON.stringify(ack), uncertain: "final transport returned no explicit success ACK" };
310
330
  },
@@ -1237,13 +1257,6 @@ const main = async () => {
1237
1257
  ...(p.job ? { job: p.job } : {}), ...(p.asker ? { asker: p.asker } : {}), ...(p.after?.length ? { after: p.after } : {}),
1238
1258
  });
1239
1259
  };
1240
- const ownsReplyEvent = (e) => {
1241
- const row = e.serves ? router.get(e.serves) : undefined;
1242
- if (row && row.owner !== e.target)
1243
- return false;
1244
- const latest = e.serves ? events.latestTransfer(e.serves) : undefined;
1245
- return !latest || latest.id === e.id || latest.at < e.at;
1246
- };
1247
1260
  // Acceptance projects ownership only after the input is durable. A rejected
1248
1261
  // acceptance retains the human's route and bubble for the original owner.
1249
1262
  const acceptPeerDispatch = (input, transfer = false) => {
@@ -1335,14 +1348,15 @@ const main = async () => {
1335
1348
  return;
1336
1349
  // 挂起账 / 工单账本跟着定论走 (§9.5 / §9.6): 每一份定论都从这儿过 (onFinal 的 answer、
1337
1350
  // 失败级联的 canceled、boot 的重投), 所以放这里而不是 commitRoute。收给群的根行不进账。
1338
- if (row.receiver.kind === "wizard") {
1351
+ const noticeOnly = (out.status === "need" || out.status === "error") && !!row.outcome;
1352
+ if (row.receiver.kind === "wizard" && !noticeOnly) {
1339
1353
  ledger.outcome(row.receiver.to, event, out.status, parseClosing(out.body).text || out.body);
1340
1354
  // 带 workflow 追踪的那件活: 执行态/正文/摘要跟着定论走 (计划节点据此推进) ——
1341
1355
  // 这一份原来只挂在旧回执 watcher 的 onOutcome 上, T10b 随它一起在这里重建。
1342
1356
  workflow.outcome(row.receiver.to, event, out.status, out.body);
1343
1357
  advancePlans();
1344
- // 追述 (row.late) 不是工单成员的第二份结局: 账只记第一次落定。
1345
- if (row.job && isTerminal(out.status) && !row.late)
1358
+ // 追述更新最新结果, 首次落定计数由工单保持。
1359
+ if (row.job && isTerminal(out.status))
1346
1360
  jobs.settle(row.job, row.owner, out.status, parseClosing(out.body).artifacts, { turn: event });
1347
1361
  }
1348
1362
  postRoute(row, out, router.escalate(event, (k) => !!wizards.get(k)), `rcpt:${event}:${out.rev}`);
@@ -1360,8 +1374,9 @@ const main = async () => {
1360
1374
  // Persist it through the same final outbox before handing the route off.
1361
1375
  const transferred = events.latestTransfer(event);
1362
1376
  if (out.status !== "done" && transferred?.target === row.owner)
1363
- m.deliverTransferredFinal(transferred, out.body);
1364
- router.commit(ops.hand({ event, inbox: `chat:${receiver.message}:${out.rev}` }));
1377
+ m.deliverTransferredFinal(transferred, out.body, out.status);
1378
+ if (isTerminal(out.status) && row.outcome?.rev === out.rev)
1379
+ router.commit(ops.hand({ event, inbox: `chat:${receiver.message}:${out.rev}` }));
1365
1380
  return;
1366
1381
  }
1367
1382
  // 回执那一轮服务的就是收方自己欠的那一笔 (`receiver.event`) —— 它的终句又一次落到那一笔上,
@@ -1381,6 +1396,13 @@ const main = async () => {
1381
1396
  : undefined;
1382
1397
  const job = row.job ? jobs.get(row.job) : undefined;
1383
1398
  const tally = row.job ? jobs.tally(row.job) : undefined;
1399
+ const envelope = renderReceiptEnvelope(displayName(row.owner), channel ? chatNameOf(cfg, channel) : undefined, row.job ? { job: row.job, done: tally?.done ?? 0, total: tally?.total ?? 0, closed: job?.status !== "open", ...(job?.kind === "req" ? { req: true } : {}) } : undefined, out.status, event, { ...(row.asker ? { asker: row.asker } : {}),
1400
+ ...(served?.kind === "wizard" ? { up: true } : {}),
1401
+ ...(audienceName(channel, row.asker) ? { audience: audienceName(channel, row.asker) } : {}),
1402
+ ...(via.length ? { via: via.join(", ") } : {}),
1403
+ ...(late ? { late: true } : {}),
1404
+ ...(retellTo ? { retellTo } : {}),
1405
+ ...(row.late ? { retold: true } : {}) });
1384
1406
  events.accept({
1385
1407
  id: inbox, identity: JSON.stringify([to, event, out.status, out.rev]),
1386
1408
  target: to, speaker: row.owner, channel, serves,
@@ -1388,26 +1410,35 @@ const main = async () => {
1388
1410
  priority: "normal", deadline: Date.now() + 12 * 3600_000,
1389
1411
  // 行首 RESULT:/NEED: 只是判定记号, 语义已由信封的 status 携带 —— 投递时剥掉
1390
1412
  // (ARTIFACT 行保留: 路径对收方 wizard 有实际用途)。
1391
- payload: { text: clipMiddle(stripClosingMark(out.body)), peer: {
1413
+ payload: { text: clipMiddle(stripClosingMark(out.body)), receipts: [{ event, version: out.rev, status: out.status,
1414
+ receiver, body: out.body, speaker: row.owner, envelope,
1415
+ source: row.reports?.[out.rev]?.source
1416
+ ?? (row.outcome?.rev === out.rev ? row.outcome.report?.source : row.notice?.rev === out.rev ? row.notice.report?.source : undefined) }], peer: {
1392
1417
  // 回执不是一件新活: `job` 留空 (否则 jobs.account 会把它当成工单的第二份派活数一遍)。
1393
1418
  from: { kind: "peer", from: row.owner, receipt: true, status: out.status, event },
1394
1419
  job: "", kind: "fyi",
1395
- envelope: renderReceiptEnvelope(displayName(row.owner), channel ? chatNameOf(cfg, channel) : undefined, row.job ? { job: row.job, done: tally?.done ?? 0, total: tally?.total ?? 0, closed: job?.status !== "open", ...(job?.kind === "req" ? { req: true } : {}) } : undefined, out.status, event, { ...(row.asker ? { asker: row.asker } : {}),
1396
- ...(served?.kind === "wizard" ? { up: true } : {}),
1397
- ...(audienceName(channel, row.asker) ? { audience: audienceName(channel, row.asker) } : {}),
1398
- ...(via.length ? { via: via.join(", ") } : {}),
1399
- ...(late ? { late: true } : {}),
1400
- ...(retellTo ? { retellTo } : {}),
1401
- ...(row.late ? { retold: true } : {}) }),
1420
+ envelope,
1402
1421
  } },
1403
1422
  }, retire);
1404
- router.commit(ops.hand({ event, inbox }));
1423
+ if (isTerminal(out.status) && row.outcome?.rev === out.rev)
1424
+ router.commit(ops.hand({ event, inbox }));
1405
1425
  };
1406
1426
  /** 收件箱里那份回执投不到了 (`gone`: 收方没有绑定, 按 store 也恢复不了) —— 投进去时它还在
1407
1427
  * 注册表上, 升级没跳过它。现在把它当不在, 沿 receiver 边重走一次升级: 能收的上层 wizard /
1408
1428
  * 群根 (Dead Letter) 收下。链上无处可升 (根行的收方自己没了): canceled 是收掉它的那一方自己
1409
1429
  * 发起的, 没人在等, 不再投; 别的定论退到收方的家群 (Dead Letter)。账本不再记第二遍。 */
1410
1430
  const rerouteGone = (e) => {
1431
+ const facts = receiptFacts(e);
1432
+ if (facts.length <= 1) {
1433
+ rerouteReceipt(e, e.id);
1434
+ return;
1435
+ }
1436
+ facts.forEach((f, i) => rerouteReceipt({ ...e, id: `rcpt:${f.event}:${f.version}`,
1437
+ speaker: f.speaker, payload: { ...e.payload, text: f.body, receipts: [f],
1438
+ peer: { ...e.payload.peer, from: { kind: "peer", from: f.speaker, event: f.event,
1439
+ receipt: true, status: f.status }, envelope: f.envelope } } }, i === facts.length - 1 ? e.id : undefined));
1440
+ };
1441
+ const rerouteReceipt = (e, retire) => {
1411
1442
  const from = e.payload.peer?.from;
1412
1443
  const event = from?.receipt ? from.event : undefined;
1413
1444
  const row = event ? router.get(event) : undefined;
@@ -1429,7 +1460,7 @@ const main = async () => {
1429
1460
  // Replaying a torn reroute must target the same durable inbox entry.
1430
1461
  const inbox = `${e.id}~${finalId(e.id, JSON.stringify(esc?.receiver ?? row.receiver), rev)}`;
1431
1462
  log.info({ id: e.id, target: e.target, event, to: esc?.receiver ?? row.receiver, inbox }, "receipt reroute: 收方已不在, 改投");
1432
- postRoute(row, { status: from.status, body: e.payload.text, rev }, esc, inbox, e.id);
1463
+ postRoute(row, { status: from.status, body: e.payload.text, rev }, esc, inbox, retire);
1433
1464
  };
1434
1465
  /** 一条路由 op: 先落盘, 再投该投的 (need / error 带正文入箱; answer 按 receiver 投递)。这里
1435
1466
  * 只管 `re` (`mark open`) 把那一行拉回在飞 —— 账本与工单跟着 deliverRoute 走。 */
@@ -1471,7 +1502,7 @@ const main = async () => {
1471
1502
  };
1472
1503
  /** 一轮的终句落到它服务的那一行上 (定论 / 中途态), 验收不合规格就打回续问。 */
1473
1504
  const settleFinal = (e, verdict) => {
1474
- const plans = router.onFinal(e.target, e.serves, verdict);
1505
+ const plans = router.onFinal(e.target, e.serves, { ...verdict, source: reportSource(e) });
1475
1506
  const answer = plans.flatMap((op) => (op.op === "answer" && op.outcome.status === "done" ? [op] : []))[0];
1476
1507
  const row = answer ? router.get(answer.event) : undefined;
1477
1508
  const why = answer && row ? acceptanceOf(row, answer.outcome.body) : undefined;
@@ -1537,9 +1568,12 @@ const main = async () => {
1537
1568
  * 先留这个调用位。 */
1538
1569
  const releaseHeld = (_row) => { };
1539
1570
  /** §4.5 boot: fold 之后 `answered` 还没 `hand` 的行重投一次 —— rcpt 的 id 幂等, 重放不双投。 */
1540
- const replayAnswered = () => router.rows()
1541
- .filter((r) => r.status === "answered" && r.outcome !== undefined)
1542
- .forEach((r) => deliverRoute(r.event, r.outcome));
1571
+ const replayAnswered = () => router.rows().forEach((r) => {
1572
+ if (r.status === "answered" && r.outcome)
1573
+ deliverRoute(r.event, r.outcome);
1574
+ if (r.notice && r.outcome?.status !== "canceled")
1575
+ deliverRoute(r.event, r.notice);
1576
+ });
1543
1577
  const currentAsk = (target) => {
1544
1578
  const env = openingAsk(target);
1545
1579
  if (!env)
@@ -1801,8 +1835,43 @@ const main = async () => {
1801
1835
  return;
1802
1836
  }
1803
1837
  const asker = askerOf(self);
1838
+ const activeId = openingOfSelf(self)?.env?.eventId;
1839
+ const active = activeId ? events.get(activeId) : undefined;
1840
+ const receipt = !!active?.payload.peer?.from.receipt;
1841
+ const leading = receipt ? /^(RESULT|Result|result|结论|NEED)[ \t]*[::]/.exec(content) : null;
1842
+ const closing = receipt ? parseClosing(content).kind : "none";
1843
+ if (receipt && active) {
1844
+ const row = active.serves ? router.get(active.serves) : undefined;
1845
+ if (active.canceled || active.interjected || handoffFinalSuppressed(active) || !ownsReplyEvent(active) || row?.outcome?.status === "canceled") {
1846
+ json(res, 409, { ok: false, reason: "这笔任务已取消或归属已变更,回执报告不再公开。" });
1847
+ return;
1848
+ }
1849
+ if (closing === "none" && (!row || !["open", "need", "error"].includes(row.status))) {
1850
+ json(res, 409, { ok: false, reason: "这笔任务已收口或无在途任务,回执确认不公开;新结果用 RESULT:,新问题用 NEED:。" });
1851
+ return;
1852
+ }
1853
+ }
1854
+ const shown = receipt && (leading || closing !== "none") ? stripForChat(content) : content;
1855
+ if (!shown.trim()) {
1856
+ json(res, 400, { ok: false, reason: "markdown body required" });
1857
+ return;
1858
+ }
1859
+ const deliveries = [];
1804
1860
  for (const dest of dests) {
1805
- postAs(self, dest, content);
1861
+ if (active?.payload.peer?.from.receipt) {
1862
+ const purpose = closing === "none" ? "progress" : "report";
1863
+ const reportKind = closing === "need" ? "need" : "done";
1864
+ const coverage = purpose === "report" ? receiptCoverage(active, dest, closing === "need", content) : undefined;
1865
+ const revision = finalId(active.id, dest, content);
1866
+ const id = events.delivery({ id: finalId(active.id, dest, revision), eventId: active.id, target: self,
1867
+ turn: active.turn ?? active.id, channel: dest, revision, body: shown, purpose, reportKind,
1868
+ chunks: m.formatFinal(self, active.turn ?? active.id, dest, shown), ...(coverage ? { coverage } : {}) });
1869
+ const claims = events.deliveryClaims(coverage);
1870
+ deliveries.push({ channel: dest, id, states: claims.length ? claims.flatMap((c) => c.states) : events.deliveryStatus(id),
1871
+ ...(claims.length > 1 ? { claims } : {}) });
1872
+ }
1873
+ else
1874
+ postAs(self, dest, content);
1806
1875
  recordPost({ target: self, channel: dest, body: content, ...(asker ? { asker } : {}) });
1807
1876
  }
1808
1877
  // 微信群聊: 人超过 ctxTtlSec 没说话时微信会静默丢弃, 消息只能压着等他下次开口 —— 如实告诉调用方, 别回「已发送」。
@@ -1811,7 +1880,8 @@ const main = async () => {
1811
1880
  const name = (d) => chatNameOf(cfg, d) || d;
1812
1881
  json(res, 200, {
1813
1882
  ok: true,
1814
- sent: dests.filter((d) => !held.includes(d)).map(name),
1883
+ sent: dests.filter((d) => !held.includes(d) && !deliveries.some((x) => x.channel === d)).map(name),
1884
+ ...(deliveries.length ? { deliveries, note: "已持久化;相同事实复用既有投递,实际状态见 states,不确定 ACK 不自动重发。" } : {}),
1815
1885
  ...(held.length ? { held: held.map(name), note: `微信群聊 ${held.map(name).join("、")} 那头超过 ${cfg.weixin.ctxTtlSec}s 没说话: 微信会静默丢弃主动推送, 这条已压着, 对方下次在微信里开口时送达 (定时提醒做不到准点)` } : {}),
1816
1886
  });
1817
1887
  });
@@ -2170,12 +2240,21 @@ const main = async () => {
2170
2240
  // 已落定的那一笔只放追述 (§4.3): 判据与 onFinal 的 retellOps 同源 (router.finalReleased)。
2171
2241
  // 调用方可能拿着 result 落定前的快照, 正文按事件库现读。
2172
2242
  m.setEventOwnerGate(ownsReplyEvent);
2243
+ m.setEventProgressGate((e) => {
2244
+ if (handoffFinalSuppressed(e) || e.quiet || e.canceled || e.interjected || !ownsReplyEvent(e))
2245
+ return false;
2246
+ const row = e.serves ? router.get(e.serves) : undefined;
2247
+ if (!row || row.owner !== e.target || row.status !== "open")
2248
+ return false;
2249
+ const kind = finalKind(router.table(), e.serves, e.result?.body ?? "");
2250
+ return !e.result || kind === "progress" || kind === "wait";
2251
+ });
2173
2252
  m.setEventFinalGate((e) => {
2174
2253
  if (handoffFinalSuppressed(e))
2175
2254
  return false;
2176
- if (e.canceled || e.interjected || !ownsReplyEvent(e))
2255
+ if (e.quiet || e.canceled || e.interjected || !ownsReplyEvent(e))
2177
2256
  return false;
2178
- return router.finalReleased(e.target, e.serves, (events.get(e.id) ?? e).result?.body ?? "");
2257
+ return router.finalReleased(e.target, e.serves, (events.get(e.id) ?? e).result?.body ?? "", reportSource(events.get(e.id) ?? e));
2179
2258
  });
2180
2259
  // boot 的路由表侧恢复 (T8): held 重查前置、answered 重投一次; 二者幂等, 与 events.resume
2181
2260
  // 重放的注入/回执撞上无害。
@@ -2192,7 +2271,8 @@ const main = async () => {
2192
2271
  // 自己就是计数器, 不另落一份。一次 tick 扫一遍收件箱就够 (这一轮不会再产新事件)。
2193
2272
  const fruitless = new Map();
2194
2273
  events.status().events.forEach((e) => {
2195
- if (!e.serves || e.result || e.canceled || e.execution !== "complete")
2274
+ if (!e.serves || e.result || e.canceled || e.payload.peer?.from.receipt
2275
+ || !(e.execution === "complete" || e.execution === "uncertain" && e.exposure === "consumed" && e.turnClosedAt))
2196
2276
  return;
2197
2277
  fruitless.set(e.serves, (fruitless.get(e.serves) ?? 0) + 1);
2198
2278
  });
@@ -2645,22 +2725,29 @@ const main = async () => {
2645
2725
  const results = uniq.map((h, i) => {
2646
2726
  const wr = wrs[i];
2647
2727
  // 同一对并发多笔是允许的 (§4.1): 先取**还在排队**的那份 (那份还没进过你的会话); 都取过了就
2648
- // 报最新那件已交付 —— 绝不把已经 paste 过的那份当成新答案交回来。
2728
+ // 报最新那件的投递状态 —— 绝不把已经 paste 过的那份当成新答案交回来。
2649
2729
  const mine = router.settledTo(self).filter((r) => r.owner === h.target).reverse() // byAt → 新在前
2650
- .map((row) => { const box = receiptOf(row); const execution = events.executionOf(box.id); return { row, box, ev: execution && { execution } }; });
2651
- const pick = mine.find((x) => x.ev?.execution === "queued") ?? mine[0];
2730
+ .map((row) => {
2731
+ const box = receiptOf(row);
2732
+ return { row, box,
2733
+ execution: events.executionOf(box.id), exposure: events.exposureOf(box.id) };
2734
+ });
2735
+ const pick = mine.find((x) => x.execution === "queued" && x.exposure === "none") ?? mine[0];
2652
2736
  if (!pick)
2653
2737
  return { name: h.address, idle: wr.idle, ...(wr.reason ? { reason: wr.reason } : {}),
2654
2738
  ...(wr.idle ? { stale: true, reason: "它停下了, 但路由表上没有交回给你的定论 —— 多半停在一个等人点的弹窗上, 或那句话没被接住; peek_peer 看一眼" } : {}) };
2655
- // 回执早一步到了: 这段话已经作为新的一轮进过你的会话, 别处理第二遍。
2656
- if (pick.ev && pick.ev.execution !== "queued")
2739
+ // Only an unexposed queued input can be claimed. Execution state alone
2740
+ // proves neither consumption nor non-delivery, including old tombstones.
2741
+ if (pick.execution !== "queued" || pick.exposure !== "none")
2657
2742
  return { name: h.address, idle: wr.idle,
2658
- ...(pick.ev.execution === "uncertain"
2659
- ? { delivery: "uncertain", reason: "回执可能已递交,需按 receipt eventId 对账;没有自动重投或再次领取。" }
2660
- : { delivered: true }) };
2743
+ eventId: pick.box.id, delivered: pick.exposure === "consumed", exposure: pick.exposure ?? "unknown",
2744
+ delivery: pick.exposure === "consumed" ? "consumed"
2745
+ : pick.execution === "uncertain" || !pick.exposure || pick.exposure === "possible" ? "uncertain" : pick.execution,
2746
+ reason: pick.exposure === "consumed" ? "回执已由会话消费,不重复领取。"
2747
+ : "结果已保存,尚未确认回执消费;按 eventId 对账,不自动重投或再次领取。" };
2661
2748
  // 取走就撤单: 这一笔在收件箱里排着的**全部**回执一起作废 (need 之后又答完时排着两份, 取走
2662
2749
  // 的那一份就是新结论, 旧的那份不再 paste 第二遍); `hand` 确认已交付 (已 handed 的是 no-op)。
2663
- if (pick.ev) {
2750
+ if (pick.execution === "queued") {
2664
2751
  events.cancel((x) => x.id.startsWith(`rcpt:${pick.row.event}:`) && x.execution === "queued", "taken by wait_peer");
2665
2752
  router.commit(ops.hand({ event: pick.row.event, inbox: pick.box.id }));
2666
2753
  }
@@ -4863,7 +4950,7 @@ const main = async () => {
4863
4950
  const eventId = `quiet:${t}:${turn}`;
4864
4951
  const revision = finalId(eventId, base, text);
4865
4952
  events.delivery({ id: finalId(eventId, base, revision), eventId, target: t, turn, channel: base,
4866
- revision, body: text, chunks: m.formatFinal(t, turn, base, text) });
4953
+ revision, body: text, purpose: "task", chunks: m.formatFinal(t, turn, base, text) });
4867
4954
  }
4868
4955
  log.info({ target: t, quiet: isQuiet(reply), len: reply.length }, "quiet task: 一轮结束");
4869
4956
  quietRelays.drop(turn);