dsh-expert-group 0.1.2 → 0.1.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.
package/index.js CHANGED
@@ -31,7 +31,7 @@ import {
31
31
  isTransientRequestFailure, recoveryDelayForCount, requestRetryPolicyCoversFailure,
32
32
  appendRecoveryRetryCount, buildRecoveryMessage, classifyTurnOutcome, lastTurnErrorMessage,
33
33
  subagentFeedbackChildId, createAdmissionBatchCoordinator, createHandoffAdmissionCohort,
34
- rootStructuredPathGuard,
34
+ rootStructuredPathGuard, snapshotRunningRootDescendants, collectRuntimeRootDescendants,
35
35
  messageIdsOfTurnWithClaimed, shouldAutoRecover, turnHasNonEmptyText,
36
36
  createDelegationTracker, delegationScopeKey, delegationTaskKey,
37
37
  createRecoveryOp, clearRecoveryOpTimer, scheduleRecoveryOpTimer, findRecoveryOpByMessage, recoveryOpPendingForTurn, recoveryOpIsPending,
@@ -1055,6 +1055,23 @@ export default {
1055
1055
  // pending admission 由 host/handoff 持有;下列调用为兼容投影。
1056
1056
  // Host admission 复用共享 helper 的 token/turn-group 状态;数组只作为本地 owner 索引。
1057
1057
  const handoffAdmissionCohort = createHandoffAdmissionCohort();
1058
+ /**
1059
+ * C:session/event 的**插件内部单调接收序号**。
1060
+ *
1061
+ * 宿主可以在同一个同步批次里连续发布多条 session/event(先 child turn/end、后
1062
+ * agent/inbox/spliced,或相反)。插件对这两类事件的处理都在 queueMicrotask 之后,
1063
+ * 因此「处理时刻看到的状态」不能代表「接收顺序」:notice 先发布、turn/end 随后发布时,
1064
+ * notice 的处理微任务可能已经看到 turnEnded=true,从而错误消费未来的终态证据。
1065
+ *
1066
+ * 唯一可靠的顺序权威是**同步回调第一次进入时的序号**:它随宿主发布顺序单调递增,
1067
+ * 与 event.seq 无关(不同 Session 的 seq 不可比较,seq 仍只作为 delivery 去重身份)。
1068
+ * 序号只是进程内小整数,不持久化、不进入任何快照或日志。
1069
+ */
1070
+ let sessionEventReceiptSeq = 0;
1071
+ function nextSessionEventReceipt() {
1072
+ sessionEventReceiptSeq += 1;
1073
+ return sessionEventReceiptSeq;
1074
+ }
1058
1075
  // resume/CAS 失败只禁止对应 exact root 的自动恢复,不能影响同 id 的新 root。
1059
1076
  const failedHandoffChildren = createHandoffFailureRegistry();
1060
1077
  // WeakSet 不持有 root;迟到 microtask 仍可按 exact 对象识别已销毁代际。
@@ -1384,12 +1401,50 @@ export default {
1384
1401
  * @param {string} rootId - exact root session id。
1385
1402
  * @returns {Promise<{known:boolean,running:object[],runningIds?:Set<string>,reason:string}>}
1386
1403
  */
1404
+ /**
1405
+ * rc.2 的 Agent.status 只有 `idle` / `running`:只有 `idle` 表示「当前未运行」。
1406
+ * `ready` 等历史/非法状态一律走 shared.js 的「状态非法」分支 → `known:false`。
1407
+ */
1408
+ const TURN_WAIT_RUNTIME_STATUS_OPTIONS = Object.freeze({ idleStatuses: ["idle"] });
1409
+
1410
+ /**
1411
+ * runtime agents.list() 的严格 fallback。它只在权威 listDescendants seam 不可用时
1412
+ * 使用,并沿用 shared.js 的完整血缘/状态校验;空数组只有在校验明确成功时才是
1413
+ * known:true,缺失父链、重复 id 或未知状态仍保持 fail-closed。
1414
+ */
1415
+ function snapshotRootTurnDescendantsFromRuntimeAgents(rootId) {
1416
+ let list;
1417
+ try {
1418
+ list = agents?.list;
1419
+ } catch {
1420
+ return void 0;
1421
+ }
1422
+ if (typeof list !== "function") return void 0;
1423
+ const snapshot = snapshotRunningRootDescendants(() => list.call(agents), rootId, TURN_WAIT_RUNTIME_STATUS_OPTIONS);
1424
+ return snapshot.known === true
1425
+ ? {
1426
+ known: true,
1427
+ running: snapshot.runningDescendants,
1428
+ // 复用既有 child settlement 回退:严格 runtime 快照不仅要告诉等待门控
1429
+ // 是否阻塞,还要提供 exact child id 集,才能在缺失完成通知时结算 admission。
1430
+ runningIds: new Set(snapshot.runningDescendants.map((agent) => agent.id)),
1431
+ ...(snapshot.reason === void 0 ? {} : { reason: snapshot.reason }),
1432
+ }
1433
+ : {
1434
+ known: false,
1435
+ running: [],
1436
+ reason: snapshot.reason ?? "runtime-descendant-query-unknown",
1437
+ };
1438
+ }
1439
+
1387
1440
  async function snapshotRootTurnDescendants(rootId) {
1388
1441
  if (typeof rootId !== "string" || rootId.length === 0) {
1389
1442
  return { known: false, running: [], reason: "root-id-unknown" };
1390
1443
  }
1391
1444
  const listed = await snapshotRootTurnDescendantsFromListing(rootId);
1392
1445
  if (listed !== void 0) return listed;
1446
+ const runtime = snapshotRootTurnDescendantsFromRuntimeAgents(rootId);
1447
+ if (runtime !== void 0) return runtime;
1393
1448
  return snapshotRootTurnDescendantsFromActivity(rootId);
1394
1449
  }
1395
1450
 
@@ -1438,6 +1493,40 @@ export default {
1438
1493
  * @returns {Promise<object|undefined>} 明确回答;seam 不可用时返回 undefined
1439
1494
  * 让调用方回退到 waterfall。
1440
1495
  */
1496
+ /**
1497
+ * 读取 runtime 侧的后代索引(rc.2 严格状态:只有 idle 表示「当前未运行」)。
1498
+ *
1499
+ * - `agents.list()` 可用:返回同一份 exact runtime 快照里被 shared.js 严格校验过的
1500
+ * 后代索引(id → { parentSession, status, node }),逐行 status 判定与 live 后代
1501
+ * 覆盖校验都用它;
1502
+ * - 该 seam 缺失:`available:false`。此时**无法**用逐行 `agents.get()` 之类的方式补出
1503
+ * 等价事实(既拿不到同一份快照,也无法验证 runtime live descendants 是否被 catalog
1504
+ * 完整覆盖),调用方必须 fail-closed。真实 rc.2 宿主始终提供 `agents.list()`。
1505
+ *
1506
+ * @returns {{available:boolean, known?:boolean, reason?:string, descendants?:Map<string,object>}}
1507
+ */
1508
+ function readTurnWaitRuntimeDescendants(rootId) {
1509
+ let list;
1510
+ try {
1511
+ list = agents?.list;
1512
+ } catch {
1513
+ return { available: false };
1514
+ }
1515
+ if (typeof list !== "function") return { available: false };
1516
+ let snapshot;
1517
+ try {
1518
+ snapshot = list.call(agents);
1519
+ } catch (error) {
1520
+ // 列表查询失败:未知,绝不当作「没有 running 后代」。
1521
+ logTurnWaitError("runtime-descendant-query", error);
1522
+ return { available: true, known: false, reason: "runtime-descendant-query-unknown" };
1523
+ }
1524
+ const collected = collectRuntimeRootDescendants(snapshot, rootId, TURN_WAIT_RUNTIME_STATUS_OPTIONS);
1525
+ return collected.known === true
1526
+ ? { available: true, known: true, descendants: collected.descendants }
1527
+ : { available: true, known: false, reason: collected.reason ?? "runtime-descendant-identity-unknown" };
1528
+ }
1529
+
1441
1530
  async function snapshotRootTurnDescendantsFromListing(rootId) {
1442
1531
  let listing;
1443
1532
  try {
@@ -1458,8 +1547,8 @@ export default {
1458
1547
  if (!Array.isArray(rows)) {
1459
1548
  return { known: false, running: [], reason: "descendant-listing-malformed" };
1460
1549
  }
1461
- const running = [];
1462
- const runningIds = new Set();
1550
+ // ① 行级公开契约校验 + catalog 树自洽性(重复 id、parentId/depth 链)。
1551
+ const catalog = new Map();
1463
1552
  for (const row of rows) {
1464
1553
  if (row === null || typeof row !== "object" || Array.isArray(row)) {
1465
1554
  return { known: false, running: [], reason: "descendant-listing-malformed" };
@@ -1473,13 +1562,84 @@ export default {
1473
1562
  return { known: false, running: [], reason: "descendant-listing-malformed" };
1474
1563
  }
1475
1564
  // child 行的公开契约校验(含 mode 枚举):任一必填字段不成立 ⟹ 整次 listing
1476
- // 都不可信,绝不能拿其中某行的 activity:"inactive" 当静止证据。
1565
+ // 都不可信,绝不能拿其中某行的 activity 当静止/运行证据。
1477
1566
  if (!isContractChildRow(row)) {
1478
1567
  return { known: false, running: [], reason: "descendant-listing-malformed" };
1479
1568
  }
1569
+ // 同一 child id 出现两次:无法区分代际,整次 listing 不可信。
1570
+ if (catalog.has(row.id)) {
1571
+ return { known: false, running: [], reason: "descendant-listing-duplicate-id" };
1572
+ }
1573
+ catalog.set(row.id, row);
1574
+ }
1575
+ for (const row of catalog.values()) {
1576
+ if (row.parentId === rootId) {
1577
+ if (row.depth !== 1) {
1578
+ return { known: false, running: [], reason: "descendant-listing-lineage-conflict" };
1579
+ }
1580
+ continue;
1581
+ }
1582
+ const parent = catalog.get(row.parentId);
1583
+ // 父级不在本次 listing 里(catalog 不完整)或 depth 与父级不衔接:血缘冲突 → 未知。
1584
+ if (parent === void 0 || parent.depth + 1 !== row.depth) {
1585
+ return { known: false, running: [], reason: "descendant-listing-lineage-conflict" };
1586
+ }
1587
+ }
1588
+ // ② 运行判定交叉校验:同一份 exact runtime 快照(rc.2 严格状态)。
1589
+ // runtime 必须提供**完整**的 agents.list() 快照:listing 只证明 residency,运行判定与
1590
+ // 「live 后代是否被 catalog 完整覆盖」都只能用同一份 exact runtime 快照验证。缺该 seam
1591
+ // 时不存在安全的兼容回退(逐行 agents.get() 拿不到同一份快照、也做不了覆盖校验)⇒ 未知。
1592
+ const runtime = readTurnWaitRuntimeDescendants(rootId);
1593
+ if (runtime.available !== true || runtime.known !== true) {
1594
+ return {
1595
+ known: false,
1596
+ running: [],
1597
+ reason: runtime.available === true
1598
+ ? (runtime.reason ?? "runtime-descendant-identity-unknown")
1599
+ : "runtime-agent-listing-unavailable",
1600
+ };
1601
+ }
1602
+ const running = [];
1603
+ const runningIds = new Set();
1604
+ for (const row of catalog.values()) {
1605
+ const live = runtime.descendants.get(row.id);
1480
1606
  if (row.activity === "running") {
1481
- running.push({ id: row.id });
1482
- runningIds.add(row.id);
1607
+ // 声称驻留:运行判定只认同一份 runtime 快照里的 rc.2 `Agent.status`。
1608
+ if (live === void 0) {
1609
+ return { known: false, running: [], reason: "descendant-listing-runtime-agent-missing" };
1610
+ }
1611
+ // catalog 血缘必须与 runtime 的直接父会话一致(任一侧缺失同样按冲突处理)。
1612
+ if (live.parentSession !== row.parentId) {
1613
+ return { known: false, running: [], reason: "descendant-listing-lineage-conflict" };
1614
+ }
1615
+ if (live.status === "running") {
1616
+ running.push({ id: row.id });
1617
+ runningIds.add(row.id);
1618
+ continue;
1619
+ }
1620
+ if (live.status === "idle") continue;
1621
+ // 缺失 / `ready` / 非字符串等非法状态:既不能当成空闲,也不能当成运行 ⇒ 未知。
1622
+ return { known: false, running: [], reason: "descendant-listing-runtime-status-invalid" };
1623
+ }
1624
+ // 自报非驻留(activity:"inactive"):上游语义是 `sessions.get(id) === undefined`,
1625
+ // 即「没有 live Session」——这种 durable 历史行是**合法**的,按「当前未运行」接受。
1626
+ // 但只要 runtime 里存在任何 exact live Agent(idle 或 running),residency 就自相矛盾
1627
+ // ⇒ 未知(绝不提前放行)。
1628
+ if (live !== void 0) {
1629
+ return { known: false, running: [], reason: "descendant-listing-activity-conflict" };
1630
+ }
1631
+ }
1632
+ // ③ runtime 里仍 live 的后代必须被 catalog 完整覆盖、必须自报驻留,且血缘一致。
1633
+ for (const [id, live] of runtime.descendants) {
1634
+ const row = catalog.get(id);
1635
+ if (row === void 0) {
1636
+ return { known: false, running: [], reason: "descendant-listing-missing-live-descendant" };
1637
+ }
1638
+ if (row.activity !== "running") {
1639
+ return { known: false, running: [], reason: "descendant-listing-activity-conflict" };
1640
+ }
1641
+ if (row.parentId !== live.parentSession) {
1642
+ return { known: false, running: [], reason: "descendant-listing-lineage-conflict" };
1483
1643
  }
1484
1644
  }
1485
1645
  return {
@@ -1589,6 +1749,15 @@ export default {
1589
1749
  onError: (error) => logTurnWaitError("check", error),
1590
1750
  });
1591
1751
 
1752
+ /**
1753
+ * 真实用户输入会打断当前 root 的等待,但不退休 root 记录,也不清除专家工作。
1754
+ * 这样新消息能立即进入下一轮,旧 admission 仍由后续 turn 负责收口。
1755
+ */
1756
+ function cancelRootTurnWaitForUserInput(agent, reason = "user-message") {
1757
+ if (!isRootAgentIdentity(agent) || !isLiveRootAgent(agent)) return false;
1758
+ return rootTurnWaits.cancelWaiters(agent, reason);
1759
+ }
1760
+
1592
1761
  /**
1593
1762
  * 释放一个已收到终态 feedback 但没有成功关联到 turn/messageId 的活动范围。
1594
1763
  *
@@ -1760,7 +1929,7 @@ export default {
1760
1929
  && item.childId === childId,
1761
1930
  );
1762
1931
  }
1763
- function admissionForTurn(record, session, event) {
1932
+ function admissionForTurn(record, session, event, receipt = void 0) {
1764
1933
  const turn = event?.data?.turn;
1765
1934
  if (!Number.isSafeInteger(turn) || record?.session !== session) return void 0;
1766
1935
  const events = Array.isArray(session.events) ? session.events : [];
@@ -1804,6 +1973,7 @@ export default {
1804
1973
  entry.turnEnded = true;
1805
1974
  entry.turn = turn;
1806
1975
  entry.activation = record.agent;
1976
+ recordTurnEndEvidence(entry, { session, activation: record.agent, turn, event, receipt });
1807
1977
  }
1808
1978
  // feedback 可能在 turn/end microtask 之前到达;先记录 exact activation,
1809
1979
  // 再在本 turn 的全部 entry 标记 turnEnded 后一次性标记所有已结束 groups。
@@ -1938,9 +2108,13 @@ export default {
1938
2108
  * 完全跳过消费与排队);unproven 表示没有稳定 message.id 或账本已饱和,调用方只能
1939
2109
  * 使用保守路径。
1940
2110
  */
1941
- function claimFeedbackIdentity(record, feedbackMessageId) {
2111
+ function claimFeedbackIdentity(record, feedbackMessageId, deliveryId) {
1942
2112
  const wasSaturated = record.feedbackIdentityLedger?.saturated === true;
1943
- const identity = feedbackIdentityLedger(record).claim(feedbackMessageId);
2113
+ // session.id + event.seq 只是本次宿主 delivery 的去重身份,不能冒充 admission id。
2114
+ const identityKey = feedbackMessageId !== void 0
2115
+ ? feedbackMessageId
2116
+ : (typeof deliveryId === "string" && deliveryId.length > 0 ? deliveryId : void 0);
2117
+ const identity = feedbackIdentityLedger(record).claim(identityKey);
1944
2118
  if (record.feedbackIdentityLedger.saturated === true && wasSaturated !== true) {
1945
2119
  ctx.logger.warn("dsh-expert-group: feedback identity ledger saturated for child " + record.childId
1946
2120
  + "; further unproven settlements take the conservative queue path");
@@ -1967,6 +2141,149 @@ export default {
1967
2141
  return tokens;
1968
2142
  }
1969
2143
 
2144
+ /**
2145
+ * C:source-less Host 完成通知的**同步接收边界快照**。
2146
+ *
2147
+ * 为什么需要它:通知与 child turn/end 都可能走 session/event 并被延迟到 microtask,
2148
+ * 消费时刻重建的快照可能包含「在本通知之后才到达的终态证据」。因此快照必须在
2149
+ * **同步入口**(第一次进入 session/event 回调时)固化:
2150
+ * - agent/session:接收时的 exact root Agent 与 Session 对象;
2151
+ * - childId/activation/record:接收时该 child 的 exact 恢复记录与 Activation;
2152
+ * - tokens:接收时刻仍未被消费的 admission token 快照(覆盖上界);
2153
+ * - receipt:本条 session/event 的单调接收序号;
2154
+ * - deliveryId:session.id + event.seq 组成的纯 delivery 身份(只用于去重)。
2155
+ *
2156
+ * 后续异步消费只能**收紧**:token 只能取该快照的子集、activation/session/record 必须
2157
+ * 不变、且对应 turn/end 的接收序号必须严格小于本通知的序号。
2158
+ */
2159
+ function captureSourceLessNoticeReceipt(agent, session, message, deliveryId, receipt) {
2160
+ if (message === null || typeof message !== "object" || message.source !== void 0) return void 0;
2161
+ if (typeof deliveryId !== "string" || deliveryId.length === 0) return void 0;
2162
+ if (!Number.isSafeInteger(receipt) || !isLiveRootAgent(agent) || agent.session !== session) return void 0;
2163
+ const childId = subagentFeedbackChildId(message, { allowSourceLess: true });
2164
+ if (childId === void 0) return void 0;
2165
+ const record = recoveryRecords.get(childId);
2166
+ if (record?.parentAgent !== agent || !isCurrentRecoveryFeedbackIdentity(agent, record, childId)) return void 0;
2167
+ return {
2168
+ agent,
2169
+ session,
2170
+ childId,
2171
+ record,
2172
+ activation: record.agent,
2173
+ tokens: arrivalHandoffTokens(agent, childId, record.agent),
2174
+ receipt,
2175
+ deliveryId,
2176
+ };
2177
+ }
2178
+
2179
+ /**
2180
+ * 事件级接收边界快照:只对「当前 live root 的 next-step spliced 注入」建立,返回与
2181
+ * `inserted` 数组同下标对齐的 receipt 列表(不匹配的项为 undefined)。
2182
+ */
2183
+ function captureSourceLessNoticeReceipts(agent, session, event, receipt) {
2184
+ if (event?.type !== "agent/inbox/spliced" || event.data?.target !== "next-step") return void 0;
2185
+ if (!Array.isArray(event.data?.inserted)) return void 0;
2186
+ if (!isLiveRootAgent(agent) || agent.session !== session) return void 0;
2187
+ const deliveryId = Number.isSafeInteger(event.seq)
2188
+ ? "session-event:" + session.id + ":" + String(event.seq) : void 0;
2189
+ if (deliveryId === void 0) return void 0;
2190
+ const receipts = [];
2191
+ for (const inserted of event.data.inserted) {
2192
+ const message = inserted?.message !== undefined ? inserted.message : inserted;
2193
+ receipts.push(captureSourceLessNoticeReceipt(agent, session, message, deliveryId, receipt));
2194
+ }
2195
+ return receipts.some((item) => item !== void 0) ? receipts : void 0;
2196
+ }
2197
+
2198
+ /** C:同步快照在消费时刻是否仍然成立(只允许收紧:activation/session/record 必须不变)。 */
2199
+ function sourceLessNoticeReceiptIsCurrent(agent, childId, record, receipt, deliveryId) {
2200
+ if (receipt === null || typeof receipt !== "object") return false;
2201
+ if (receipt.agent !== agent || receipt.session !== agent.session) return false;
2202
+ if (receipt.childId !== childId || receipt.record !== record) return false;
2203
+ // 快照必须属于**当前这条 delivery**:同一 child 上的其他通知不能借用它的到达位置。
2204
+ if (receipt.deliveryId !== deliveryId) return false;
2205
+ // activation 换代(replacement / cold resume)后旧快照表达的是旧代际事实,必须整体失效。
2206
+ if (receipt.activation !== record.agent) return false;
2207
+ if (!Number.isSafeInteger(receipt.receipt) || !(receipt.tokens instanceof Set)) return false;
2208
+ return true;
2209
+ }
2210
+
2211
+ /**
2212
+ * C:记录 entry 当前终态证据的**首次接收顺序**与 exact 身份。
2213
+ *
2214
+ * 为什么同一终态只保留首次 receipt:宿主可能在同一同步批次里重投递同一条 turn/end
2215
+ * (重放/重入,携带的是同一个 event 对象)。若每次都覆盖 receipt,重投递会把 receipt
2216
+ * 推到合法通知之后,使这条**身份已被 claim** 的合法通知被错误拒绝,且没有新事件能救回
2217
+ * → finalize 永久等待(Oracle P2)。
2218
+ *
2219
+ * 身份判定 = 同一 exact Session 对象 + 同一 exact child Activation + 同一 turn + 同一
2220
+ * 终态证据(同一 event 对象,或在既有 event.seq 契约下同一 seq)。任一维度不同都表示
2221
+ * **新终态**(不同 turn / 不同 terminal event / 换代),必须重置到新 receipt——绝不跨
2222
+ * turn 取全局最早序号,也绝不复用旧终态给未来通知错配。
2223
+ *
2224
+ * 附属身份只占 entry 上的**一个槽位**,随 entry 同生命周期;迁移/replacement 时清除,
2225
+ * 不建立任何历史列表。
2226
+ *
2227
+ * @returns {number|undefined} 该 entry 当前生效的接收序号。
2228
+ */
2229
+ function recordTurnEndEvidence(entry, { session, activation, turn, event, receipt }) {
2230
+ const previous = entry.turnEndEvidence;
2231
+ const sameEvidence = previous !== void 0
2232
+ && previous.session === session
2233
+ && previous.activation === activation
2234
+ && previous.turn === turn
2235
+ && (previous.event === event
2236
+ || (Number.isSafeInteger(previous.seq) && Number.isSafeInteger(event?.seq)
2237
+ && previous.seq === event.seq));
2238
+ // 同一终态的重投递:保留首次 receipt,不推进、不重置。
2239
+ if (sameEvidence) return previous.receipt;
2240
+ if (!Number.isSafeInteger(receipt)) {
2241
+ delete entry.turnEndEvidence;
2242
+ return void 0;
2243
+ }
2244
+ entry.turnEndEvidence = {
2245
+ session,
2246
+ activation,
2247
+ turn,
2248
+ event,
2249
+ seq: Number.isSafeInteger(event?.seq) ? event.seq : void 0,
2250
+ receipt,
2251
+ };
2252
+ return receipt;
2253
+ }
2254
+
2255
+ /**
2256
+ * C:source-less notice 允许消费的 token 集合 =
2257
+ * 同步接收快照 ∩ 「exact turn/end 的接收顺序严格更早」。
2258
+ *
2259
+ * 任一条件不成立都不消费、不排队(fail-closed,等真正的结算):
2260
+ * - 通知之后才登记的 token 不在快照里;
2261
+ * - 通知之后才发布的 turn/end(同一同步批次里的「notice 先、turn/end 后」)序号更大,
2262
+ * 因而属于**未来证据**,本条通知无权消费;
2263
+ * - 终态证据的 receipt 是「该 exact 终态(同一 Session/activation/turn/evidence)**首次**
2264
+ * 被接收」的序号(见 recordTurnEndEvidence):同一终态的重投递不会把它推迟,不同
2265
+ * turn/terminal 或换代后的新终态则各自重置——因此这里比较的永远是**当前终态**的首次
2266
+ * 到达位置,既不会漏掉合法通知,也不会让未来终态借用旧序号。
2267
+ */
2268
+ function sourceLessNoticeEligibleTokens(agent, childId, record, receipt) {
2269
+ const tokens = new Set();
2270
+ for (const token of receipt.tokens) {
2271
+ const item = host.handoff.find((entry) => entry.parent === agent
2272
+ && entry.childId === childId
2273
+ && entry.token === token);
2274
+ if (item === void 0 || item.failed === true || item.feedbackSeen === true) continue;
2275
+ if (item.activation !== void 0 && item.activation !== record.agent) continue;
2276
+ // 终态证据必须属于当前 exact Session/activation,且其接收顺序严格早于本通知。
2277
+ // 不同 turn/terminal 或换代后的新终态各有自己的 receipt,绝不复用最早值。
2278
+ const evidence = item.turnEndEvidence;
2279
+ if (evidence === void 0 || evidence.session !== record.session
2280
+ || evidence.activation !== record.agent) continue;
2281
+ if (!Number.isSafeInteger(evidence.receipt) || evidence.receipt >= receipt.receipt) continue;
2282
+ tokens.add(token);
2283
+ }
2284
+ return tokens;
2285
+ }
2286
+
1970
2287
  /** 该 child/activation 上是否还有「已登记但 turn 尚未结束」的 admission。 */
1971
2288
  function hasUnendedHandoffAdmission(agent, childId, activation) {
1972
2289
  return host.handoff.some((item) =>
@@ -2066,15 +2383,26 @@ export default {
2066
2383
  return marker;
2067
2384
  }
2068
2385
 
2069
- function markPendingHandoffFeedback(agent, childId, message) {
2386
+ function markPendingHandoffFeedback(agent, childId, message, deliveryId, sourceLessHostNotice = false, receipt = void 0) {
2070
2387
  if (!isLiveRootAgent(agent) || typeof childId !== "string" || childId.length === 0) return void 0;
2071
2388
  const record = recoveryRecords.get(childId);
2072
2389
  if (!isCurrentRecoveryFeedbackIdentity(agent, record, childId)) return void 0;
2073
2390
  const feedbackMessageId = typeof message?.id === "string" && message.id.length > 0 ? message.id : void 0;
2074
- const identity = claimFeedbackIdentity(record, feedbackMessageId);
2391
+ const identity = claimFeedbackIdentity(record, feedbackMessageId, deliveryId);
2075
2392
  // 同 id 副本:不消费、不排队。调用方(inbox/inserted microtask)随后仍会走一次
2076
2393
  // 「已就绪 admission 的精确结算」检查,但副本不会让任何 token 就绪,因此是安全空操作。
2077
2394
  if (identity.duplicate) return void 0;
2395
+ // B(Oracle 复现):source-less Host notice 与 unproven 身份的组合必须 fail-closed。
2396
+ // unproven = 账本已饱和(或没有稳定身份),此时协议层无法区分「旧副本」与「下一轮真实
2397
+ // 结算」。下面的通用无身份分支会按「到达时仍有未结束 admission」把通知排队成 wildcard
2398
+ // marker,让这条无法证明属于本轮的通知在**未来** turn/end 消费 future entry,从而凭空
2399
+ // 结算 work 并提前放行 root。因此在认领身份之后、任何 admission/marker 操作之前直接
2400
+ // 拒绝:不消费、不排队、不写账本(结构化通知仍各自按既有语义处理)。
2401
+ if (sourceLessHostNotice && identity.unproven) return void 0;
2402
+ // C:source-less notice 必须携带**同步接收边界**快照(exact activation/session/record
2403
+ // 与接收序号)。快照缺失或已失效时同样 fail-closed:绝不能退化成「消费时刻重建快照」
2404
+ // 的通配消费,那会把晚到的终态证据算进本条通知的覆盖范围。
2405
+ if (sourceLessHostNotice && !sourceLessNoticeReceiptIsCurrent(agent, childId, record, receipt, deliveryId)) return void 0;
2078
2406
  const admissionId = feedbackAdmissionId(message);
2079
2407
  const candidates = host.handoff.filter((item) =>
2080
2408
  item.parent === agent
@@ -2090,7 +2418,8 @@ export default {
2090
2418
  // 这也覆盖 A2 的「首次消费并清理后再次到达」形态:那时没有未消费 admission,
2091
2419
  // 迟到的无身份副本不会重新进入 wildcard union 路径。
2092
2420
  if (entry === void 0) return void 0;
2093
- // 到达快照:本时刻仍未消费的 token(wildcard 覆盖上界,A2)。
2421
+ // 到达快照限制 wildcard 的覆盖上界;shared cohort 仅匹配已结束 turn,
2422
+ // 且 activation snapshot 不会把晚到通知之后新增的 token 纳入消费范围。
2094
2423
  const arrivalTokens = arrivalHandoffTokens(agent, childId, record.agent);
2095
2424
  // 无 id 保守策略(item 1):账本绑定 exact activation,换代即重置。
2096
2425
  const ledger = settlementDeliveryLedger(record, record.agent);
@@ -2130,6 +2459,10 @@ export default {
2130
2459
  // settlement 由 createUserMessage 铸 UUID,因此线上路径始终有稳定身份。
2131
2460
  if (identity.unproven) {
2132
2461
  if (ledger.idlessConsumed === true) return entry;
2462
+ // 走到这里的只剩普通无 id delivery(inbox/inserted 或没有稳定身份的结算)。
2463
+ // source-less Host notice 的 unproven 组合已在上面直接拒绝(它无法证明属于本轮,
2464
+ // 不能进入任何 future 队列)。普通 no-id delivery 保持旧的保守规则:必须先观察到
2465
+ // 未结束 admission,再延迟到该 turn/end 边界消费。
2133
2466
  if (!hasUnendedHandoffAdmission(agent, childId, record.agent)) return entry;
2134
2467
  // 记账点选在「进入投递管线」而不是「真正消费」:即使该 marker 最终没有消费任何
2135
2468
  // token(例如 turn 从未结束),后续无 id delivery 仍无法证明自己是新结算,
@@ -2147,8 +2480,18 @@ export default {
2147
2480
  // 已结束 group 可精确消费(restrict = 到达快照,只覆盖到达时已存在的 token);
2148
2481
  // 尚未建立 group 时先排队,等 turn/end 绑定全部 messageId 后再标记整组,
2149
2482
  // 不能把多个不同 turn 粗暴合并。
2150
- const sharedGroup = handoffAdmissionCohort.markFeedback(agent, childId, void 0, record.agent, arrivalTokens);
2483
+ // C:source-less notice 的候选上界还必须叠加「exact turn/end 的接收顺序更早」这一
2484
+ // 条件(同步快照 ∩ 接收顺序)。其他 delivery 保持原样:到达时刻重建的 token 快照。
2485
+ const wildcardTokens = sourceLessHostNotice
2486
+ ? sourceLessNoticeEligibleTokens(agent, childId, record, receipt)
2487
+ : arrivalTokens;
2488
+ const sharedGroup = handoffAdmissionCohort.markFeedback(agent, childId, void 0, record.agent, wildcardTokens);
2151
2489
  if (sharedGroup !== void 0) {
2490
+ // A(Oracle 复现):这里**不再镜像**标记 entry.feedbackSeen。handoff entry 与 cohort
2491
+ // entry 是同一个对象,sharedGroup 已经把「真正 ended 且命中 restrict 的项」标记完毕;
2492
+ // 如果再按 candidates 首项标记一次,就会把一个尚未结束的 future entry 提前标成
2493
+ // 「已收到反馈」,使它在本轮 turn/end 之后无需任何通知就被准确结算(提前放行)。
2494
+ //
2152
2495
  // 本次消费已经把该 activation 当前全部「到达快照内已结束」的候选标记完毕。
2153
2496
  // 同 activation 的 wildcard marker 再被后续 turn/end 消费只会用**重建的快照**
2154
2497
  // 覆盖当时新登记的 token(提前 release 的直接成因),因此按身份精确摘除:
@@ -2161,6 +2504,9 @@ export default {
2161
2504
  && (feedbackMessageId === void 0 || item.messageId === void 0 || item.messageId === feedbackMessageId));
2162
2505
  return entry;
2163
2506
  }
2507
+ // source-less Host notice 到达早于匹配 turn/end 时不能排队为未来通配证据;
2508
+ // 它只能消费 arrival snapshot 中已经结束的当前 activation cohort。
2509
+ if (sourceLessHostNotice) return entry;
2164
2510
  // 不绑定某一个 turn:marker 记录到达快照,turn/end 消费时以此为候选上界,
2165
2511
  // 因此 T1/T2 等多个已结束 turn 各自按自己的结算消费,future token 不受影响。
2166
2512
  queuePendingHandoffFeedback(record, {
@@ -2677,6 +3023,9 @@ export default {
2677
3023
  entry.feedbackSeen = false;
2678
3024
  entry.feedbackExact = false;
2679
3025
  entry.releaseQueued = false;
3026
+ // C:replacement 迁移后旧代际的终态身份与接收顺序不再成立;新 child 的首个终态
3027
+ // 必须重新登记(receipt 与其附属 exact 身份同生命周期,随迁移一起清除)。
3028
+ delete entry.turnEndEvidence;
2680
3029
  // replacement child 开启新的 turn 归属判断;不能沿用旧 activation 已记录的
2681
3030
  // terminal/recoverable 结果,否则新 child 的首次终态会被提前返回跳过。
2682
3031
  entry.terminalActivityOutcome = void 0;
@@ -2769,22 +3118,30 @@ export default {
2769
3118
  }
2770
3119
 
2771
3120
  /**
2772
- * root-only 显式收口:登记调用方明确列出的、已经返回结果的 admission,然后等待
2773
- * 这些工作真正结束。
3121
+ * root-only 显式收口:登记调用方明确列出的、已经返回结果的 admission,然后**立即**
3122
+ * 返回非终态 waiting 回执(两阶段语义)。
2774
3123
  *
2775
- * 用户确认的 A 方案语义:
2776
3124
  * 1. 校验阶段与旧实现完全一致(token 集完整性、outcome 已 settle、
2777
- * continue_parent_turn / safeParallelWork 等拒绝条件);
2778
- * 2. 把每个 accepted admission 登记进**唯一的** root turn 等待登记表;
2779
- * 3. 等待该 root 精确关联的工作全部结束——**不设任何超时**(子代理可能运行数小时)。
2780
- * 释放条件见 host/turn-wait.js:无未结算工作 + root-scoped 查询明确无 running
2781
- * 后代;unknown / 查询失败一律继续等待;
2782
- * 4. 等待成立后正常返回,工具 execute 的 Promise 收口,root turn 随之结束。
2783
- *
2784
- * 与旧实现的两个关键差异:
2785
- * - **不再调用 exec.concludeTurn()**:turn 由宿主在工具返回后自然关闭,插件不再
2786
- * 主动结束它;保持 turn 打开本身就是"阻止 goal round 推进"的手段;
2787
- * - **不再暂停/恢复任何 goal**:不再有 paused/concluded 之类的回执字段。
3125
+ * continue_parent_turn / safeParallelWork 等拒绝条件、root generation);
3126
+ * 2. 把每个 accepted admission **一次性**登记进唯一的 root turn 等待登记表,并把批次
3127
+ * 标为 pending-close(保存 exact admission 凭据);
3128
+ * 3. **立即**返回 `{kind:"waiting", admissionEpoch, acceptedCount, reason:"experts-running"}`。
3129
+ * 工具调用绝不在 run_code 内等待专家结束,也绝不使用 `exec.signal` 等待——PTC 的
3130
+ * 600000ms 墙钟因此不再能截断收口;
3131
+ * 4. 继续等待由 root turn 的 `agent/turn-stopping` serial 边界承担(见
3132
+ * awaitRootTurnWaitFallback):它用**本次 root turn 的 signal** 等待同一张账本,
3133
+ * 释放条件仍是 host/turn-wait.js 的 fail-closed 静止判定(无未结算工作 + root-scoped
3134
+ * 查询明确无 running 后代;unknown / 查询失败一律继续等待,**不设任何超时**);
3135
+ * 5. 等待明确释放后,插件以同步 `Agent.steer` 向同一 run 的下一 step 投递**一次**
3136
+ * DEG 来源的终态结论,然后一次性关闭批次。
3137
+ *
3138
+ * 与旧实现的关键差异:
3139
+ * - **不再在工具调用内等待**:waiting 是非终态回执,终态结论通过后续模型 step 交付;
3140
+ * - **不再调用 exec.concludeTurn()**:turn 由宿主在工具返回后自然关闭;
3141
+ * - **不再暂停/恢复任何 goal**:不再有 paused/concluded 之类的回执字段;
3142
+ * - 相同批次的重复合法 finalize 返回**稳定** waiting(不重复登记、不关闭、不投递);
3143
+ * 回执里的 acceptedCount 报告该批次当前的完整 accepted 集(含 pending-close 期间
3144
+ * 新接受的 admission)。
2788
3145
  */
2789
3146
  async function finalizeRootAdmissionBatch(parent, exec, args, rootToken) {
2790
3147
  const epoch = args?.admissionEpoch;
@@ -2864,46 +3221,263 @@ export default {
2864
3221
  batch.closed = true;
2865
3222
  return { kind: "noop", ...base, reason: "no-accepted-admission" };
2866
3223
  }
2867
- // 两阶段收口:① 登记等待工作(同步、无副作用);② 等待静止(**不设超时**)。
2868
- // 登记必须在任何 await 之前完成,否则「登记前到达的 child 完成通知」会找不到
2869
- // 工作项而变成空操作。
3224
+ // 同一批次的重复**合法** finalize(同样的 epoch 与完整 token 集)必须得到稳定的
3225
+ // waiting:既不重复登记 work,也不重复关闭或投递。这项判定放在全部校验之后,因此
3226
+ // 错误/陈旧凭据仍会按上面的分支立即 rejected。
3227
+ if (batch.pendingClose !== void 0) {
3228
+ return {
3229
+ kind: "waiting",
3230
+ ...base,
3231
+ // 批次仍开放时可能有 pending-close 期间新接受的 admission;回执报告批次当前的
3232
+ // 完整 accepted 集(初始那次回执是已返回的快照,不追写)。
3233
+ acceptedCount: pendingCloseAcceptedCount(parent),
3234
+ reason: "experts-running",
3235
+ };
3236
+ }
3237
+ // 两阶段收口:① 登记等待工作并标记 pending-close(同步、无任何 await);
3238
+ // ② 立即返回非终态 waiting。登记必须在返回前完成,否则「登记前到达的 child 完成
3239
+ // 通知」会找不到工作项而变成空操作;而工具调用本身绝不等待。
2870
3240
  batch.finalizing = true;
2871
3241
  try {
2872
3242
  if (!host.lineage.isLiveGeneration(parent, rootToken)) {
2873
3243
  batch.closed = true;
2874
3244
  return { kind: "rejected", ...base, acceptedCount: acceptedEntries.length, reason: "root-generation-retired" };
2875
3245
  }
2876
- const registered = registerRootTurnWaitWork(parent, acceptedEntries);
2877
- const waitOutcome = await rootTurnWaits.wait(parent, exec?.signal);
2878
- if (waitOutcome.released !== true) {
2879
- // signal-aborted 只取消本次 waiter,批次与已登记工作仍可由同一凭据重试。
2880
- // root 退休、代际替换或插件卸载等生命周期取消仍按原语义关闭批次。
2881
- if (waitOutcome.reason !== "signal-aborted") batch.closed = true;
2882
- return {
2883
- kind: "rejected",
2884
- ...base,
2885
- acceptedCount: acceptedEntries.length,
2886
- reason: "root-wait-cancelled",
2887
- message: "root turn wait was cancelled (" + waitOutcome.reason + ")",
2888
- };
2889
- }
2890
- batch.closed = true;
3246
+ registerRootTurnWaitWork(parent, acceptedEntries);
3247
+ // pending-close 保存 exact admission 对象:等待账本的记录会在一次静止收口后被
3248
+ // 销毁(此时 isEngaged() 为 false),后续 turn-stopping 只能靠这份凭据重新登记
3249
+ // 尚未结算的 work —— 它绝不重新登记已结算项(登记键会永远等不到结算)。
3250
+ //
3251
+ // 这里**不**缓存 acceptedCount:批次在收口前始终开放(pending-close 期间新接受的
3252
+ // admission 会被原地追加进 admissions),任何数量都必须在使用点现算(见
3253
+ // pendingCloseAcceptedCount),否则旧快照会漏掉新工作。
3254
+ batch.pendingClose = {
3255
+ admissionEpoch: epoch,
3256
+ admissions: acceptedEntries.map((entry) => entry.outcome.handoffAdmission),
3257
+ };
2891
3258
  return {
2892
- kind: "finalized",
3259
+ kind: "waiting",
2893
3260
  ...base,
2894
3261
  acceptedCount: acceptedEntries.length,
2895
- reason: "finalized",
3262
+ reason: "experts-running",
2896
3263
  };
2897
3264
  } finally {
2898
3265
  batch.finalizing = false;
2899
3266
  }
2900
3267
  }
2901
3268
 
3269
+ /**
3270
+ * 取 pending-close 批次的**同步快照**(未关闭、且确实登记过收口)。
3271
+ *
3272
+ * 只返回已保存的状态对象本身:它由 finalize 一次性写入,之后只能**原地**追加
3273
+ * pending-close 期间新接受的 exact admission(见 notePendingCloseAdmission),绝不
3274
+ * 重建或拷贝——拷贝会让投递前的身份校验失去意义。
3275
+ *
3276
+ * @returns {object|undefined} pending-close 状态;无待收口批次时为 undefined。
3277
+ */
3278
+ function pendingCloseBatchFor(parent) {
3279
+ const batch = rootAdmissionBatches.get(parent);
3280
+ if (batch === void 0 || batch.closed === true || batch.pendingClose === void 0) return void 0;
3281
+ return batch.pendingClose;
3282
+ }
3283
+
3284
+ /**
3285
+ * 取开放批次里**当前**已接受的 exact 工作(活视图,不是冻结快照)。
3286
+ *
3287
+ * why 必须是活视图:pending-close 是**批次级** latch,批次在收口前始终开放——用户插话
3288
+ * 后的后续 root turn 仍会通过 beginRootAdmission 把新的 role admission 加进**同一个**
3289
+ * epoch。冻结在 finalize 时刻的 admission 列表会漏掉这些新工作:重新等待会少等一项,
3290
+ * 收口结论的 acceptedCount 也会停在旧值。
3291
+ *
3292
+ * 判定与 finalizeRootAdmissionBatch 的 acceptedEntries 完全一致:reused /
3293
+ * continueParentTurn 不产生「未结算工作」(它们留下的运行中 child 由 root-scoped
3294
+ * 后代查询负责挡住)。
3295
+ *
3296
+ * @param {object} parent - exact root Agent。
3297
+ * @returns {{token:string, admission:object, released:boolean}[]} 已接受工作;
3298
+ * `released` 为 true 表示该 admission 已离开 host.handoff(已结算 / 已释放)。
3299
+ */
3300
+ function acceptedBatchWorks(parent) {
3301
+ const batch = rootAdmissionBatches.get(parent);
3302
+ if (batch === void 0) return [];
3303
+ const works = [];
3304
+ for (const entry of batch.entries.values()) {
3305
+ const outcome = entry?.outcome;
3306
+ if (entry?.settled !== true || outcome === void 0) continue;
3307
+ if (outcome.kind !== "continuable" || outcome.reused === true || outcome.continueParentTurn === true) continue;
3308
+ const admission = outcome.handoffAdmission;
3309
+ if (admission === void 0 || admission.parent !== parent) continue;
3310
+ const token = typeof admission.token === "string" && admission.token.length > 0 ? admission.token : void 0;
3311
+ if (token === void 0) continue;
3312
+ works.push({ token, admission, released: host.handoff.contains(admission) !== true });
3313
+ }
3314
+ return works;
3315
+ }
3316
+
3317
+ /**
3318
+ * 批次当前**完整 accepted 集**的规模。
3319
+ *
3320
+ * 收口结论与重复的 waiting 回执都用它现算:pending-close 期间新接受的 admission 也是
3321
+ * 这个批次的一部分,回放 finalize 时刻的旧快照会让模型看到与实际收口集合不符的数量。
3322
+ * 初始那次 waiting 回执是已返回的快照,不做追写。
3323
+ */
3324
+ function pendingCloseAcceptedCount(parent) {
3325
+ return acceptedBatchWorks(parent).length;
3326
+ }
3327
+
3328
+ /**
3329
+ * 把 pending-close 期间**新接受**的 role admission 立即归入同一次收口的 exact work 集。
3330
+ *
3331
+ * why 需要这个写入口:该批次的收口等待可能正在飞(被打断的 turn 之后,模型在新 step
3332
+ * 里又拉起一个专家)。只依赖 turn-stopping 起点的一次性重新登记时,这段等待里新接受
3333
+ * 的工作就不在等待账本上,静止判定只剩「查不到 running 后代」这条启发式依据——一旦新
3334
+ * child 恰好不在 running 列表里(例如它已经结束 turn、精确结算尚未到达),批次就会在
3335
+ * exact work 未释放时提前收口并投递终态结论。
3336
+ *
3337
+ * 登记键必须与精确结算用的键完全一致(admission 自己的 token);已经离开 host.handoff
3338
+ * 的凭据绝不登记,否则会留下一个永远等不到结算的键,让 root 永久悬挂。
3339
+ *
3340
+ * @param {object} parent - exact root Agent。
3341
+ * @param {object} admission - 新接受的 exact handoff admission。
3342
+ * @returns {boolean} 是否成功进入等待账本(诊断用;判定不依赖它)。
3343
+ */
3344
+ function notePendingCloseAdmission(parent, admission) {
3345
+ const pendingClose = pendingCloseBatchFor(parent);
3346
+ if (pendingClose === void 0 || admission === void 0 || admission.parent !== parent) return false;
3347
+ if (host.handoff.contains(admission) !== true) return false;
3348
+ const token = typeof admission.token === "string" && admission.token.length > 0 ? admission.token : void 0;
3349
+ if (token === void 0) return false;
3350
+ // 归入 exact work 集:只把同一个 exact admission 对象追加进快照数组,绝不重建该对象
3351
+ // ——deliverRootFinalizationConclusion 依赖 pendingClose 的对象身份做重复投递复查。
3352
+ if (!pendingClose.admissions.includes(admission)) pendingClose.admissions.push(admission);
3353
+ const tracked = rootTurnWaits.track(parent, { kind: "admission", token }) !== void 0;
3354
+ // 与 finalize / turn-stopping 的登记路径一致:登记成功即「等待屏障已建立」,
3355
+ // cohort 才会把该 admission 判定为可精确结算。
3356
+ if (tracked) handoffAdmissionCohort.markPauseDone(admission);
3357
+ return tracked;
3358
+ }
3359
+
3360
+ /**
3361
+ * 依据 pending-close 保存的 exact admission 信息**以及开放批次当前的已接受工作活视图**,
3362
+ * 重新登记**尚未结算**的 work 并把该 root engage 进同一张等待账本。
3363
+ *
3364
+ * 为什么必须能「重新登记」:等待账本的一条记录在一次静止收口后立即销毁,此后
3365
+ * `track`/`settle` 都找不到它,`isEngaged()` 也为 false。若只依赖账本现状,
3366
+ * pending-close 批次就会在「child 先静止、turn-stopping 后到」的合法时序里既收不了口、
3367
+ * 也投递不了结论(登记键必须与精确结算用的键完全一致,因此这里用 admission 自己的
3368
+ * token,而不是 batch/ticket token)。
3369
+ *
3370
+ * 只登记仍在 handoff 里的 admission:已结算/已释放的凭据再登记会得到一个永远等不到
3371
+ * 结算的键,让 root 永久悬挂(fail-closed 的另一面)。
3372
+ *
3373
+ * @returns {number} 本次成功登记的 work 数(诊断用;判定不依赖它)。
3374
+ */
3375
+ function reengagePendingCloseWorks(parent, pendingClose) {
3376
+ // 并集:① finalize 快照里保存的 exact admission;② 开放批次当前的已接受工作活视图
3377
+ // (含 pending-close 期间新接受的 admission——它们必须被本次等待一并覆盖)。
3378
+ const candidates = [
3379
+ ...pendingClose.admissions,
3380
+ ...acceptedBatchWorks(parent).map((work) => work.admission),
3381
+ ];
3382
+ const seen = new Set();
3383
+ let tracked = 0;
3384
+ for (const admission of candidates) {
3385
+ if (admission === void 0 || admission.parent !== parent) continue;
3386
+ if (host.handoff.contains(admission) !== true) continue;
3387
+ const token = typeof admission.token === "string" && admission.token.length > 0 ? admission.token : void 0;
3388
+ if (token === void 0 || seen.has(token)) continue;
3389
+ seen.add(token);
3390
+ if (rootTurnWaits.track(parent, { kind: "admission", token }) !== void 0) {
3391
+ handoffAdmissionCohort.markPauseDone(admission);
3392
+ tracked += 1;
3393
+ }
3394
+ }
3395
+ // 即使没有可登记的工作(reused/continue 批次或已全部结算),也必须 engage:静止
3396
+ // 判定要由同一状态机给出**明确**结论,而不是「从未参与」这种无法证明的默认。
3397
+ rootTurnWaits.engage(parent);
3398
+ return tracked;
3399
+ }
3400
+
3401
+ /**
3402
+ * 构造一次收口结论的模型可见正文。
3403
+ *
3404
+ * 只表达「收口已完成 + admissionEpoch + acceptedCount」这三项**插件自己拥有**的事实:
3405
+ * 专家的具体结果由 DSH 的结算通知各自提供,插件消息不复制、不改写任何专家收尾文本,
3406
+ * 也刻意不与 Host 自动完成通知的固定前缀同形(避免被当作 child settlement)。
3407
+ */
3408
+ function rootFinalizationConclusionText(admissionEpoch, acceptedCount) {
3409
+ return "Expert group finalization: the expert batch you finalized is finalized"
3410
+ + " (admissionEpoch " + admissionEpoch
3411
+ + ", acceptedCount " + acceptedCount + ")."
3412
+ + " Every expert task accepted in that batch has actually finished;"
3413
+ + " each expert's own closing output arrived separately as a subagent settlement message,"
3414
+ + " which this notice deliberately does not repeat."
3415
+ + " Do not call dsh_expert_group_finalize again for this batch and do not poll for status;"
3416
+ + " continue the task from here.";
3417
+ }
3418
+
3419
+ /**
3420
+ * 一次性投递某批次的终态收口结论,并关闭该批次。
3421
+ *
3422
+ * 前置条件(缺一不可,全部在**同一同步临界区**内复查):
3423
+ * 1. 该 exact 批次仍是同一个、仍未关闭、且 pendingClose 仍是本次持有的那个对象
3424
+ * (重复 turn-stopping / 竞态下不会二次投递);
3425
+ * 2. 本次 waiter 的 signal 未被取消(取消不是专家完成);
3426
+ * 3. exact root Agent 仍是当前 live root,且 generation token 仍 live(root 退休、
3427
+ * 代际替换或插件卸载期间绝不由旧 root 投递);
3428
+ * 4. runtime 提供 rc.2 的公开 `Agent.steer`。**不**降级为 `inject`:inject 不保证
3429
+ * 唤醒后续模型 step,静默降级会让模型永远收不到收口结论;此处保留 pending-close,
3430
+ * 留给具备该能力的 runtime 或后续 turn(只记一次告警,不刷日志)。
3431
+ *
3432
+ * @returns {boolean} 是否完成投递并关闭。
3433
+ */
3434
+ function deliverRootFinalizationConclusion(parent, pendingClose, signal) {
3435
+ const batch = rootAdmissionBatches.get(parent);
3436
+ if (batch === void 0 || batch.closed === true || batch.pendingClose !== pendingClose) return false;
3437
+ if (signal?.aborted === true) return false;
3438
+ if (!isLiveRootAgent(parent)) return false;
3439
+ const rootToken = host.lineage.tokenFor(parent);
3440
+ if (!host.lineage.isLiveGeneration(parent, rootToken)) return false;
3441
+ if (typeof parent?.steer !== "function") {
3442
+ if (batch.steerUnavailableLogged !== true) {
3443
+ batch.steerUnavailableLogged = true;
3444
+ ctx.logger.warn("dsh-expert-group: root Agent.steer is unavailable; the finalization conclusion was not delivered");
3445
+ }
3446
+ return false;
3447
+ }
3448
+ // 收口时刻的完整 accepted 集:pending-close 期间新接受的 admission 也在其中,
3449
+ // 因此这里必须**现算**,而不是回放 finalize 时的快照值。判定与身份复查处于同一
3450
+ // 同步临界区,结论里的数量与真正被释放的 exact work 集一致。
3451
+ const acceptedCount = pendingCloseAcceptedCount(parent);
3452
+ let message;
3453
+ try {
3454
+ message = createUserMessage({
3455
+ content: [{ type: "text", text: rootFinalizationConclusionText(pendingClose.admissionEpoch, acceptedCount) }],
3456
+ source: { kind: PRODUCER_SOURCE_KIND },
3457
+ });
3458
+ } catch (error) {
3459
+ ctx.logger.warn("dsh-expert-group: root finalization message construction failed: "
3460
+ + (error instanceof Error ? error.name : "Error"));
3461
+ return false;
3462
+ }
3463
+ try {
3464
+ parent.steer(message);
3465
+ } catch (error) {
3466
+ // 投递失败保持 pending-close:结论还没有进入模型上下文,批次不能算收口完成。
3467
+ ctx.logger.warn("dsh-expert-group: root finalization steer failed: "
3468
+ + (error instanceof Error ? error.name : "Error"));
3469
+ return false;
3470
+ }
3471
+ batch.closed = true;
3472
+ batch.pendingClose = void 0;
3473
+ return true;
3474
+ }
3475
+
2902
3476
 
2903
3477
 
2904
3478
  ctx.effect(() => tools.register(defineTool({
2905
3479
  name: "dsh_expert_group_finalize",
2906
- description: "Finalize the current root turn only after every role-tool admission result named by admissionTokens has returned, the root agent has completed its summary and integration/review/verification check, and no safe non-overlapping parallel work remains. This root-only action then waits until every expert child it accepted has actually finished, keeping the root turn open meanwhile; it never waits with a timeout, because an expert may run for hours. Do not call it while safe parallel work remains; a pure continue_parent_turn batch has no accepted admission to finalize, while a later ordinary admission may finalize the complete checked token set.",
3480
+ description: "Finalize the current root turn only after every role-tool admission result named by admissionTokens has returned, the root agent has completed its summary and integration/review/verification check, and no safe non-overlapping parallel work remains. This root-only action validates that batch against the open admission epoch, registers every accepted expert task on the root turn wait ledger, and returns immediately with a non-terminal waiting receipt carrying admissionEpoch and acceptedCount: it never waits inside the tool call, because the root turn lifecycle keeps waiting at the agent/turn-stopping boundary on that same ledger, with no timeout, as an expert may legitimately run for hours. A waiting receipt means the wait is registered, not that any expert finished; it is not an asynchronous task handle and there is no status to poll. Do not call finalize again for that batch and do not poll for status: once every accepted task has actually finished, the plugin closes the batch and steers one DEG-sourced finalization message into your next step automatically. Do not call it while safe parallel work remains; a pure continue_parent_turn batch has no accepted admission to finalize, while a later ordinary admission may finalize the complete checked token set.",
2907
3481
  parameters: {
2908
3482
  admissionEpoch: {
2909
3483
  type: "number",
@@ -2937,7 +3511,8 @@ export default {
2937
3511
  type: "object",
2938
3512
  additionalProperties: false,
2939
3513
  properties: {
2940
- kind: { type: "string", required: true, enum: ["finalized", "noop", "rejected"] },
3514
+ // waiting 是登记完成但非终态;收口结论只通过后续 root 上下文消息表达。
3515
+ kind: { type: "string", required: true, enum: ["waiting", "noop", "rejected"] },
2941
3516
  admissionEpoch: { type: "number", required: true },
2942
3517
  acceptedCount: { type: "number", required: true },
2943
3518
  reason: { type: "string", required: true },
@@ -2947,7 +3522,16 @@ export default {
2947
3522
  render(_args, value) {
2948
3523
  // render 必须带上 message,否则 rejected 的 self-diagnosis 文案在模型可见的
2949
3524
  // 工具输出里被丢弃(reason 是机器码,message 才是可执行提示)。
2950
- return [{ type: "text", text: "root finalization: " + value.reason + (value.message ? " — " + value.message : "") }];
3525
+ const base = "root finalization: " + value.reason + (value.message ? " — " + value.message : "");
3526
+ // waiting 是非终态回执:模型必须看到「等待已登记、不要重试、不要轮询」,
3527
+ // 否则会把 waiting 误当成最终结论,或者反复调用 finalize。
3528
+ if (value.kind === "waiting") {
3529
+ return [{ type: "text", text: base
3530
+ + " — waiting registered for " + value.acceptedCount + " accepted expert task(s) in admissionEpoch "
3531
+ + value.admissionEpoch
3532
+ + "; do not call finalize again and do not poll for status" }];
3533
+ }
3534
+ return [{ type: "text", text: base }];
2951
3535
  },
2952
3536
  },
2953
3537
  isConcurrencySafe: () => false,
@@ -3023,49 +3607,72 @@ export default {
3023
3607
  // finalize 已经在收口(finalizing)或已收口(closed)时一律让路:那是模型必需
3024
3608
  // 路径,等待结论都由它负责,兜底重复介入只会产生竞争。
3025
3609
  if (batch === void 0 || batch.closed === true || batch.finalizing === true) return [];
3026
- const pending = [];
3027
- for (const entry of batch.entries.values()) {
3028
- const outcome = entry?.outcome;
3029
- if (entry?.settled !== true || outcome === void 0) continue;
3030
- if (outcome.kind !== "continuable" || outcome.reused === true || outcome.continueParentTurn === true) continue;
3031
- const admission = outcome.handoffAdmission;
3032
- if (admission === void 0 || host.handoff.contains(admission) !== true) continue;
3033
- const token = typeof entry.token === "string" && entry.token.length > 0 ? entry.token : void 0;
3034
- if (token === void 0) continue;
3035
- pending.push({ token, admission });
3036
- }
3037
- return pending;
3610
+ // 活视图:包含 pending-close 期间新接受的 admission(同一开放批次的 exact work 集)。
3611
+ // 工作键必须与「精确结算」用的键完全一致:结算走 handoff admission 自己的 token
3612
+ // (见 settleAdmissionTurnWait / registerRootTurnWaitWork / acceptedBatchWorks)。
3613
+ // 用 batch/ticket token 登记会得到一个永远不会被结算的键 —— 模型遗漏 finalize 时
3614
+ // 兜底就会永久挂住 turn。
3615
+ return acceptedBatchWorks(parent)
3616
+ .filter((work) => work.released !== true)
3617
+ .map((work) => ({ token: work.token, admission: work.admission }));
3038
3618
  }
3039
3619
 
3040
3620
  /**
3041
3621
  * root turn 即将关闭时的等待兜底(与 finalize 共用**同一个**等待账本/状态机)。
3042
3622
  *
3043
- * 为什么需要它:`dsh_expert_group_finalize` 仍是模型必需路径(prompt 契约、工具
3044
- * 描述都不改),但模型可能遗漏它;纯 `continue_parent_turn` 批次与 fan-out 也可能
3623
+ * 为什么需要它:`dsh_expert_group_finalize` 仍是模型必需路径(prompt 契约要求模型
3624
+ * 调用它登记等待),但模型可能遗漏它;纯 `continue_parent_turn` 批次与 fan-out 也可能
3045
3625
  * 在 turn 结束时仍有子代理在跑。此时若直接关闭 turn,root 会变 idle。
3046
3626
  *
3047
- * 本函数**不**暂停/恢复任何 goal,也不主动结束 turn:它只把「本 turn 已接受、
3048
- * 但还没被 finalize 登记的 admission」补登记进同一张等待表,然后等同一张表的
3049
- * 静止判定成立。DSH 的 `agent/turn-stopping` 是 serial 事件且会 await 监听器
3050
- * 返回的 Promise,因此等待期间 turn 保持打开,其他 agent 事件照常推进。
3051
- *
3052
- * 兜底只做「补登记 + 等」:不清理 admission、不写任何终态结论——那些都属于
3053
- * 正常的 feedback/release 路径。
3627
+ * 本函数**不**暂停/恢复任何 goal,也不主动结束 turn:它把「本 turn 已接受、但还没被
3628
+ * finalize 登记的 admission」补登记进同一张等待表,然后等同一张表的静止判定成立。
3629
+ * DSH 的 `agent/turn-stopping` 是 serial 事件且会 await 监听器返回的 Promise,
3630
+ * 因此等待期间 turn 保持打开,其他 agent 事件照常推进。
3631
+ *
3632
+ * 对**未 finalize** 的批次只做「补登记 + 等」:不清理 admission、不写任何终态结论——
3633
+ * 那些都属于正常的 feedback/release 路径。
3634
+ *
3635
+ * ## pending-close 批次的接续(两阶段收口的后半段)
3636
+ * finalize 已经登记并返回 waiting 的批次由本函数完成收口:
3637
+ * - **重新登记**:等待账本的记录可能已在一次静止收口后销毁(此时 `isEngaged()` 为
3638
+ * false、登记集为空),因此这里按 pending-close 保存的 exact admission **以及开放
3639
+ * 批次当前的已接受工作活视图**重新登记尚未结算的 work 并 engage,保证「child 先静止、
3640
+ * turn-stopping 后到」以及「pending-close 期间又接受了新 admission」都能被本次等待
3641
+ * 完整覆盖;等待在飞期间新接受的 admission 由接受时刻的登记负责(同一个账本);
3642
+ * - **本 turn 的 signal**:等待使用 `payload.signal`(root turn 生命周期),而不是
3643
+ * run_code 子执行的 exec.signal;
3644
+ * - **取消不是完成**:等待被取消(用户插话 / abort / root 退休 / 卸载)时批次保持
3645
+ * pending-close,不关闭、不投递,留给后续 root turn 重建等待;
3646
+ * - **一次性投递**:等待明确 released 且 signal 未取消、exact root/generation 仍 live
3647
+ * 时,才投递一次终态结论并关闭批次。
3054
3648
  *
3055
3649
  * @param {object} parent - exact live root Agent。
3056
- * @returns {Promise<void>} 静止或等待被取消后收口。
3650
+ * @param {AbortSignal} [signal] - 本次 root turn 的 signal。
3651
+ * @returns {Promise<void>} 静止、收口完成或等待被取消后返回。
3057
3652
  */
3058
3653
  async function awaitRootTurnWaitFallback(parent, signal) {
3654
+ // 同步取一次 pending-close 快照:等待期间批次可能被其它边界关闭或代际退休,
3655
+ // 投递前的身份复查(deliverRootFinalizationConclusion)据此拒绝过期投递。
3656
+ const pendingClose = pendingCloseBatchFor(parent);
3059
3657
  for (const candidate of pendingRootAutomaticAdmissions(parent)) {
3060
- rootTurnWaits.track(parent, { kind: "admission", token: candidate.token });
3658
+ // candidate 来自当前开放 batch 且仍在 handoff;只有真实 token 成功进入等待账本后,
3659
+ // 才标记其真实 cohort entry 的 pauseDone,绝不为缺失 admission 伪造完成。
3660
+ const tracked = rootTurnWaits.track(parent, { kind: "admission", token: candidate.token });
3661
+ if (tracked !== void 0) handoffAdmissionCohort.markPauseDone(candidate.admission);
3061
3662
  }
3062
- // 即使没有补登记的工作项,只要该 root 曾经engage过等待体系(例如纯
3663
+ if (pendingClose !== void 0) reengagePendingCloseWorks(parent, pendingClose);
3664
+ // 即使没有补登记的工作项,只要该 root 曾经 engage 过等待体系(例如纯
3063
3665
  // continue_parent_turn 留下的正在运行的 child),也必须让同一状态机判定一次。
3064
3666
  if (!rootTurnWaits.isEngaged(parent)) return;
3065
3667
  const outcome = await rootTurnWaits.wait(parent, signal);
3066
3668
  if (outcome.released !== true) {
3669
+ // 取消(用户插话 / abort / root 退休 / 插件卸载)不是专家完成:pending-close 批次
3670
+ // 保持开放,既不关闭也不投递,后续 root turn 可以重建等待继续收口。
3067
3671
  ctx.logger.warn("dsh-expert-group: root turn wait fallback was cancelled (" + outcome.reason + ")");
3672
+ return;
3068
3673
  }
3674
+ if (pendingClose === void 0) return;
3675
+ deliverRootFinalizationConclusion(parent, pendingClose, signal);
3069
3676
  }
3070
3677
 
3071
3678
  /**
@@ -3076,9 +3683,13 @@ export default {
3076
3683
  ctx.on("agent/turn-stopping", (payload) => {
3077
3684
  const parent = payload?.agent;
3078
3685
  if (turnWaitStopping || !isRootAgentIdentity(parent) || !isLiveRootAgent(parent)) return;
3079
- // 同步前置判定:既没有进入等待体系、也没有可补登记的 admission 时直接放行——
3080
- // 绝不为了「形式上返回 Promise」而在每个 turn 边界插入一次多余的异步查询。
3081
- if (!rootTurnWaits.isEngaged(parent) && pendingRootAutomaticAdmissions(parent).length === 0) return;
3686
+ // 同步前置判定:既没有进入等待体系、也没有可补登记的 admission、也没有待收口的
3687
+ // pending-close 批次时直接放行——绝不为了「形式上返回 Promise」而在每个 turn 边界
3688
+ // 插入一次多余的异步查询。pending-close 必须参与:它的等待账本记录可能已被销毁
3689
+ // (isEngaged() 为 false),只看账本现状会漏掉整条收口链路。
3690
+ if (pendingCloseBatchFor(parent) === void 0
3691
+ && !rootTurnWaits.isEngaged(parent)
3692
+ && pendingRootAutomaticAdmissions(parent).length === 0) return;
3082
3693
  return awaitRootTurnWaitFallback(parent, payload?.signal).catch((error) => {
3083
3694
  ctx.logger.warn("dsh-expert-group: root turn wait fallback failed: " + (error instanceof Error ? error.message : String(error)));
3084
3695
  });
@@ -3314,6 +3925,23 @@ export default {
3314
3925
  sequence: 0,
3315
3926
  closed: false,
3316
3927
  finalizing: false,
3928
+ /**
3929
+ * 两阶段收口的 pending-close 登记(见 finalizeRootAdmissionBatch)。
3930
+ *
3931
+ * finalize 在登记完成后把本批次标为 pending-close:工具立即返回 waiting,等待
3932
+ * 职责移交给 root turn 的 `agent/turn-stopping` serial 边界。这里保存的是
3933
+ * **exact admission 对象**(而不是 token 或 id),因为等待账本的记录会在一次静止
3934
+ * 收口后被销毁,后续 turn 只能靠这份精确凭据重新登记尚未结算的 work;用 id/token
3935
+ * 猜身份会重新登记一个永远不会结算的键。
3936
+ *
3937
+ * `admissions` 是**批次级**闩锁的一部分,而不是冻结的快照:批次在收口前始终开放,
3938
+ * pending-close 期间新接受的 admission 会在接受时刻被追加进来(并同步登记进等待
3939
+ * 账本),因此「重新等待」与「收口结论的 acceptedCount」看到的都是该批次收口
3940
+ * 时刻的完整 accepted 集。
3941
+ */
3942
+ pendingClose: void 0,
3943
+ /** steer 不可用的告警只记一次(不影响批次状态)。 */
3944
+ steerUnavailableLogged: false,
3317
3945
  };
3318
3946
  }
3319
3947
  function beginRootAdmission(parent) {
@@ -3332,6 +3960,9 @@ export default {
3332
3960
  token,
3333
3961
  epoch: batch.epoch,
3334
3962
  entry,
3963
+ // outcome 记录点需要 exact root 才能把「pending-close 期间新接受的 work」写进
3964
+ // 同一批次的等待集(见 recordRootAdmissionOutcome / notePendingCloseAdmission)。
3965
+ parent,
3335
3966
  settle() {
3336
3967
  const settled = ticket.settle();
3337
3968
  if (settled) entry.settled = true;
@@ -3344,6 +3975,15 @@ export default {
3344
3975
  if (admission?.entry === void 0) return;
3345
3976
  admission.entry.outcome = outcome;
3346
3977
  admission.entry.settled = true;
3978
+ // pending-close 批次仍开放时,新接受的 exact work 必须在**接受时刻**就进入
3979
+ // pending-close 的 work 集与等待账本(见 notePendingCloseAdmission):否则批次级
3980
+ // 闩锁与「该批次的 exact 工作集」会脱节,收口等待可能少等这一项、结论里的
3981
+ // acceptedCount 也会停在旧值。判定与 finalize 的 acceptedEntries 完全一致:
3982
+ // reused / continueParentTurn 不产生未结算工作。
3983
+ if (outcome?.kind === "continuable" && outcome.reused !== true && outcome.continueParentTurn !== true
3984
+ && outcome.handoffAdmission !== void 0) {
3985
+ notePendingCloseAdmission(admission.parent, outcome.handoffAdmission);
3986
+ }
3347
3987
  }
3348
3988
  function roleAdmissionFailure(phase, error) {
3349
3989
  const message = error instanceof Error ? error.message : String(error);
@@ -5399,15 +6039,26 @@ export default {
5399
6039
  // correlation id 之前一律 fail-closed:不撤回、不排队、不消费、不释放 handoff,
5400
6040
  // 只留一条按 child 去重的可诊断 warn;消息本身仍作为普通注入消息留在父 inbox
5401
6041
  // (不伪造交接,也不改变正文)。
5402
- ctx.on("agent/inbox/inserted", ({ agent, message }) => {
6042
+ /**
6043
+ * 处理一条父 inbox 反馈。agent/inbox/inserted 与 session/event 的
6044
+ * agent/inbox/spliced 都必须进入同一条路径,避免只覆盖测试夹具能合成的事件。
6045
+ * 文本宿主通知仅作为 child 身份线索;后续仍由当前 root、activation、turn/end
6046
+ * 和 cohort 的既有门控决定是否可以结算,绝不凭正文直接释放等待。
6047
+ */
6048
+ function handleInboxFeedback(agent, message, sourceEvent = "agent/inbox/inserted", deliveryId, receipt = void 0) {
6049
+ // inserted 是 core 唤醒 root 前的同步边界;spliced 在 session/event 中已脱离该
6050
+ // 发布栈,因此仍先做相同的 user / recovery 身份检查,再把 handoff 写入 microtask。
6051
+ // spliced 是已进入 next-step 的注入项,不是新的用户输入边界;其中即使带 user
6052
+ // source 也不能被当作 waiter 取消信号。
6053
+ if (message?.source?.kind === "user" && sourceEvent === "agent/inbox/inserted") cancelRootTurnWaitForUserInput(agent);
5403
6054
  if (message?.source?.kind === "subagent-report") {
5404
6055
  const relayChildId = message.source.senderSessionId;
5405
6056
  warnLegacyRelayFailClosed(typeof relayChildId === "string" ? relayChildId : void 0);
5406
6057
  return;
5407
6058
  }
5408
- // 关键并发边界:core 会在本同步回调之后 wake parent。可恢复链的 settlement
5409
- // 必须先从 inbox 撤回并保存,不能等 queueMicrotask 后才判断,此时 parent 可能已 claim。
5410
- const childId = subagentFeedbackChildId(message);
6059
+ const childId = subagentFeedbackChildId(message, {
6060
+ allowSourceLess: sourceEvent === "agent/inbox/spliced" && typeof deliveryId === "string",
6061
+ });
5411
6062
  if (childId !== void 0) {
5412
6063
  const record = recoveryRecords.get(childId);
5413
6064
  if (record?.parentAgent === agent) {
@@ -5415,41 +6066,61 @@ export default {
5415
6066
  if (gateRecoverableHandoffFeedback(agent, record, childId, message)) return;
5416
6067
  // terminal feedback 可能对应一个已完成但未绑定到 turn/messageId 的 child turn。
5417
6068
  // 只有没有待恢复 op 时才释放,避免把可恢复失败的 activity 提前清掉。
5418
- if (record.pendingRecoveries?.size === 0
6069
+ const sourceLessHostNotice = sourceEvent === "agent/inbox/spliced"
6070
+ && message?.source === void 0 && typeof deliveryId === "string";
6071
+ if (!sourceLessHostNotice && record.pendingRecoveries?.size === 0
5419
6072
  && record.recoveryFeedbackActive !== true
5420
6073
  && (record.gatedRecoveryFeedbacks?.length ?? 0) === 0) {
5421
6074
  releaseActiveExpertScope(agent, { childId, role: record.role });
5422
6075
  }
5423
6076
  } else {
5424
6077
  // 当前 registry/activation 已指向 replacement 时,这是一条无法证明属于
5425
- // 当前 generation 的旧 notice。不能 remove 它,更不能落入普通 handoff
5426
- // release 路径把旧 child 的 settlement 交给新任务。
6078
+ // 当前 generation 的旧 notice;不能进入普通 handoff release 路径。
5427
6079
  return;
5428
6080
  }
5429
6081
  }
5430
6082
  }
5431
- // feedback 的 handoff 状态写入仍延后到发布栈外;agent/message 必须保持 exact 身份。
5432
6083
  queueMicrotask(() => {
5433
6084
  try {
5434
6085
  if (!isLiveRootAgent(agent)) return;
5435
- const gatedChildId = subagentFeedbackChildId(message);
6086
+ const gatedChildId = subagentFeedbackChildId(message, {
6087
+ allowSourceLess: sourceEvent === "agent/inbox/spliced" && typeof deliveryId === "string",
6088
+ });
5436
6089
  if (gatedChildId === void 0 || wasGatedRecoveryFeedback(agent, gatedChildId, message?.id, message)) return;
5437
6090
  const childId = gatedChildId;
5438
- markPendingHandoffFeedback(agent, childId, message);
5439
- // 无论本次 feedback 是否命中 admission,都重新检查一次精确结算:命中时这是
5440
- // release 边界;未命中(重复副本 / 无身份 / 迟到通知)时它是幂等空操作,
5441
- // 绝不会结算别的 token。
6091
+ const sourceLessHostNotice = sourceEvent === "agent/inbox/spliced"
6092
+ && message?.source === void 0 && typeof deliveryId === "string";
6093
+ markPendingHandoffFeedback(agent, childId, message, deliveryId, sourceLessHostNotice, receipt);
6094
+ // 命中与否都重跑精确结算;未命中时只是幂等空操作,不会结算其他 token。
5442
6095
  settleFinishedChildTurnWaits(agent);
5443
6096
  } catch (error) {
5444
- ctx.logger.warn("dsh-expert-group: inbox/inserted handoff handler failed: " + (error instanceof Error ? error.message : String(error)));
6097
+ ctx.logger.warn("dsh-expert-group: " + sourceEvent + " handoff handler failed: " + (error instanceof Error ? error.message : String(error)));
5445
6098
  }
5446
6099
  });
6100
+ }
6101
+
6102
+ ctx.on("agent/inbox/inserted", ({ agent, message }) => {
6103
+ handleInboxFeedback(agent, message);
5447
6104
  });
5448
6105
 
5449
6106
  // 触发通道 2:session/event 的 child turn/end 仍只负责终态结算;root turn/end 才是
5450
6107
  // 终止快照的唯一提交点。microtask 避开 Session.append 发布重入,并在执行前校验
5451
6108
  // exact root Agent/Session/generation 身份。
5452
6109
  ctx.on("session/event", (session, event) => {
6110
+ // C:在**同步入口**取得本次事件的接收序号。它是插件内部唯一的到达顺序权威,
6111
+ // 后续所有 microtask 处理只能使用这个快照(见 nextSessionEventReceipt)。
6112
+ const receipt = nextSessionEventReceipt();
6113
+ // 某些宿主只发布 session/event 而不经过 inbox/inserted;在进入 microtask 前同步
6114
+ // 取消 root waiter,保证 user/message 与 ask_user_question 的 role=user 都能打断旧 turn。
6115
+ const immediateAgent = typeof session?.id === "string" ? agents.get(session.id) : void 0;
6116
+ const isUserInput = (event?.type === "user/message" && event.data?.source?.kind === "user")
6117
+ || (event?.type === "tool/result" && event.data?.message?.role === "user");
6118
+ if (isUserInput && immediateAgent?.session === session) cancelRootTurnWaitForUserInput(immediateAgent);
6119
+ // C:source-less 完成通知的同步接收边界快照(exact root/session、child activation、
6120
+ // 未消费 token 快照、接收序号)。异步消费只允许使用这个快照的交集,且必须满足
6121
+ // 「exact turn/end 接收顺序更早」——后续只能收紧,不能扩大。
6122
+ const noticeReceipts = typeof session?.id === "string"
6123
+ ? captureSourceLessNoticeReceipts(immediateAgent, session, event, receipt) : void 0;
5453
6124
  queueMicrotask(() => {
5454
6125
  try {
5455
6126
  if (typeof session?.id !== "string") return;
@@ -5465,6 +6136,25 @@ export default {
5465
6136
  // 首个 exact event 建立 session 引用;之后旧 Session 事件不得接管 record。
5466
6137
  bindRecoverySession(record, session);
5467
6138
  }
6139
+ if (event?.type === "agent/inbox/spliced"
6140
+ && event.data?.target === "next-step"
6141
+ && liveAgent?.session === session
6142
+ && isLiveRootAgent(liveAgent)
6143
+ && Array.isArray(event.data?.inserted)) {
6144
+ // 真实 Host 将自动完成消息作为 root session event 发布,而不是 guaranteed 的
6145
+ // agent/inbox/inserted。只桥接 next-step 的 inserted 消息,并复用同一 exact
6146
+ // feedback 状态机;固定文本本身绝不构成释放条件。
6147
+ // seq 缺失时 source-less 通知必须 fail-closed;它不能借正文冒充 admission。
6148
+ const deliveryId = Number.isSafeInteger(event.seq)
6149
+ ? "session-event:" + session.id + ":" + String(event.seq) : void 0;
6150
+ for (let index = 0; index < event.data.inserted.length; index += 1) {
6151
+ const inserted = event.data.inserted[index];
6152
+ const message = inserted?.message !== undefined ? inserted.message : inserted;
6153
+ // receipt 与 inserted 同下标对齐:同步入口为每条 source-less 注入固化的到达快照。
6154
+ handleInboxFeedback(liveAgent, message, "agent/inbox/spliced", deliveryId, noticeReceipts?.[index]);
6155
+ }
6156
+ return;
6157
+ }
5468
6158
  if (event?.type === "step/start" && liveAgent?.session === session && isLiveRootAgent(liveAgent)) {
5469
6159
  // turn/end 之后同一 turn 又开始新 step,说明 listener 通过 steer 续接了本次运行:
5470
6160
  // 刚写入的终止快照并不成立,必须撤销,否则会误报"已中断/已完成"。
@@ -5519,7 +6209,7 @@ export default {
5519
6209
  if (event?.type !== "turn/end" || record === void 0) return;
5520
6210
  if (recoveryRecords.get(session.id) !== record
5521
6211
  || !isCurrentRecoverySessionIdentity(record.parentAgent, record, session)) return;
5522
- const admission = admissionForTurn(record, session, event);
6212
+ const admission = admissionForTurn(record, session, event, receipt);
5523
6213
  if (admission?.failed === true) {
5524
6214
  // 失败 admission 只结算自己的 turn,不能被同 child 的其他 token 拦截。
5525
6215
  consumeChildTurnEnd(record.parentAgent, record, session, event, admission);
@@ -5912,9 +6602,9 @@ export default {
5912
6602
  function releaseFanoutGoalHandoff(agent, parentTaskId) {
5913
6603
  if (turnWaitStopping) return false;
5914
6604
  const record = fanoutTurnWaitRecords.get(parentTaskId);
5915
- if (record === void 0 || record.state === "released") return false;
6605
+ // exact Agent 身份必须先于任何状态写入校验;旧代回调不得污染当前记录。
6606
+ if (record === void 0 || record.state === "released" || record.agent !== agent) return false;
5916
6607
  record.state = "released";
5917
- if (record.agent !== agent) return false;
5918
6608
  return rootTurnWaits.settle(agent, { kind: "fanout", parentTaskId, attempt: record.attempt });
5919
6609
  }
5920
6610
 
@@ -9439,10 +10129,21 @@ export default {
9439
10129
  * 宿主能力矩阵。
9440
10130
  *
9441
10131
  * - rootTurnWait 描述 root turn 等待所依赖的宿主 seam:权威档是
9442
- * `subagents.listDescendants`(**唯一**能证明「没有 running 后代」的 seam),
10132
+ * `subagents.listDescendants` **加上**同一份 exact runtime `agents.list()` 快照
10133
+ * (catalog 只证明 session residency;运行判定必须用 runtime Agent.status 交叉校验,
10134
+ * 并用 runtime 快照验证 live 后代已被 catalog 完整覆盖——缺任何一半都不能证明静止),
9443
10135
  * 降级档是 `workspace/session-activity` waterfall(只能报告 activity,不能证明
9444
10136
  * quiet)。两档都无法证明静止时一律继续等待(fail-closed,绝不把未知当成无人
9445
10137
  * 运行);专家派发与设置页继续可用,降级由 status/degradation 字段显式说明。
10138
+ * 因此投影是四档:
10139
+ * 1. listing + 可调用的 `agents.list()` 都在 → `available` / degradation `null`
10140
+ * (完整 catalog 与 runtime 交叉校验,可证明静止);
10141
+ * 2. 只有 listing(缺 runtime 快照,等待实际 fail-closed)→ `degraded` +
10142
+ * `root-turn-wait-runtime-agent-listing-unavailable`;
10143
+ * 3. 缺完整 catalog,但 `agents.list()` 可调用(strict runtime-only fallback,仍能
10144
+ * 给出可证明的状态)或只有 waterfall(只能提供正向 running 证据)→ `degraded` +
10145
+ * `root-turn-wait-descendant-listing-unavailable`;
10146
+ * 4. 三者都不可用 → `unavailable` + `root-turn-wait-descendant-query-unavailable`。
9446
10147
  * - Connection RPC 是**可选能力**:缺失时回落 legacy exact route,无法建立通道时
9447
10148
  * fail-closed(legacy 拒绝并返回 503),状态与失败类别都从固定枚举取值。
9448
10149
  * - storageDomain 是运行期硬依赖;attachments / sessionQuery 是可选能力,缺失时
@@ -9473,6 +10174,19 @@ export default {
9473
10174
  } catch {
9474
10175
  sessionActivityAvailable = false;
9475
10176
  }
10177
+ // 权威静止证明的另一半:**可调用且返回数组**的 agents.list()。只看到 listing 就报
10178
+ // available 会误报——缺 runtime 快照时等待实际 fail-closed(无法校验运行判定与覆盖
10179
+ // 关系);可调用但返回非数组同样不能当快照用(等待侧对此也 fail-closed)。
10180
+ // 这里只做只读可用性探测:读取属性并在 try 内调用一次,绝不触发注册或写入状态。
10181
+ let runtimeAgentListingAvailable = false;
10182
+ try {
10183
+ const listAgents = agents?.list;
10184
+ if (typeof listAgents === "function") {
10185
+ runtimeAgentListingAvailable = Array.isArray(listAgents.call(agents));
10186
+ }
10187
+ } catch {
10188
+ runtimeAgentListingAvailable = false;
10189
+ }
9476
10190
  const connectionStatus = connectionChannelTransport === "handle"
9477
10191
  ? "available"
9478
10192
  : connectionChannelTransport === "route"
@@ -9494,14 +10208,21 @@ export default {
9494
10208
  // rootTurnWait:可选宿主 seam;缺失时等待判定一律 fail-closed(继续等待)。
9495
10209
  rootTurnWait: {
9496
10210
  required: false,
9497
- status: descendantListingAvailable
10211
+ status: descendantListingAvailable && runtimeAgentListingAvailable
9498
10212
  ? "available"
9499
- : sessionActivityAvailable ? "degraded" : "unavailable",
9500
- degradation: descendantListingAvailable
10213
+ : descendantListingAvailable || runtimeAgentListingAvailable || sessionActivityAvailable
10214
+ ? "degraded"
10215
+ : "unavailable",
10216
+ degradation: descendantListingAvailable && runtimeAgentListingAvailable
9501
10217
  ? null
9502
- : sessionActivityAvailable
9503
- ? "root-turn-wait-descendant-listing-unavailable"
9504
- : "root-turn-wait-descendant-query-unavailable",
10218
+ : descendantListingAvailable
10219
+ // 完整 catalog 在、runtime 快照不可用:等待 fail-closed,诊断必须如实降级。
10220
+ ? "root-turn-wait-runtime-agent-listing-unavailable"
10221
+ : runtimeAgentListingAvailable || sessionActivityAvailable
10222
+ // 缺完整 catalog,但仍有可证明的查询路径:strict runtime-only fallback
10223
+ // (agents.list() + 严格血缘/状态校验)或 waterfall-only(只能正向 running)。
10224
+ ? "root-turn-wait-descendant-listing-unavailable"
10225
+ : "root-turn-wait-descendant-query-unavailable",
9505
10226
  },
9506
10227
  // Connection RPC:可选能力,缺失时回落 legacy route。
9507
10228
  connectionRpc: {