@sema-agent/client-core 0.43.1 → 0.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/CHANGELOG.md +528 -0
  2. package/README.md +1 -1
  3. package/dist/adapt/arms.js +33 -0
  4. package/dist/adapt.d.ts +1 -1
  5. package/dist/adapt.js +2 -0
  6. package/dist/adapter/downstream/eventToSdkMessage.js +82 -0
  7. package/dist/engineCapsCache.d.ts +14 -0
  8. package/dist/engineCapsCache.js +21 -0
  9. package/dist/engineSessionParam.d.ts +27 -1
  10. package/dist/engineSessionParam.js +37 -6
  11. package/dist/fleet/fleetLedger.d.ts +174 -18
  12. package/dist/fleet/fleetLedger.js +321 -26
  13. package/dist/hitl/planReviewWire.d.ts +1 -1
  14. package/dist/hitl/planReviewWire.js +44 -1
  15. package/dist/hitl/toolApprovalWire.d.ts +58 -11
  16. package/dist/hitl/toolApprovalWire.js +138 -6
  17. package/dist/hooksWireCaps.d.ts +37 -0
  18. package/dist/hooksWireCaps.js +49 -0
  19. package/dist/notifications.d.ts +62 -1
  20. package/dist/notifications.js +68 -0
  21. package/dist/seam.d.ts +41 -1
  22. package/dist/seam.js +4 -0
  23. package/dist/subagent/engineCompactWire.d.ts +32 -5
  24. package/dist/subagent/engineCompactWire.js +129 -48
  25. package/dist/subagent/engineDelegatedPrompt.js +9 -4
  26. package/dist/subagent/engineRowStopGate.d.ts +10 -3
  27. package/dist/subagent/engineRowStopGate.js +15 -6
  28. package/dist/subagent/engineSubagentOutput.js +12 -6
  29. package/dist/subagent/engineSubagentResume.d.ts +53 -3
  30. package/dist/subagent/engineSubagentResume.js +57 -14
  31. package/dist/subagent/engineSubagentSteer.js +20 -11
  32. package/dist/subagent/engineSubagentTail.js +18 -6
  33. package/dist/subagent/engineTaskHandleWire.js +33 -10
  34. package/dist/subagentContentStore.d.ts +57 -4
  35. package/dist/subagentContentStore.js +152 -13
  36. package/docs/INTEGRATION-CLIENTS.md +156 -25
  37. package/package.json +1 -1
@@ -26,10 +26,9 @@
26
26
  */
27
27
  import { hostLog } from '../host.js';
28
28
  import { makeEngineWireClient } from '../engineWireSdk.js';
29
- import { engineWireTarget } from '../engineWireTarget.js';
30
- import { engineSessionParamSpread } from '../engineSessionParam.js';
31
- import { getBgParentRunOwner } from '../subagentContentStore.js';
32
- import { resolveOwnerRunId } from './engineSubagentResume.js';
29
+ import { engineWireTargetFor } from '../engineWireTarget.js';
30
+ import { engineSessionParamFor } from '../engineSessionParam.js';
31
+ import { resolveOwnerContext, resolveOwnerRunId } from './engineSubagentResume.js';
33
32
  import { noteBgOwnerAbsence } from './subagentOwnerAbsence.js';
34
33
  /**
35
34
  * Steer a running engine subagent (target = its parentToolCallId, or agentName).
@@ -58,12 +57,16 @@ import { noteBgOwnerAbsence } from './subagentOwnerAbsence.js';
58
57
  * @param childTaskId 这一行子代的 taskId(fleet 行 id 尾段;端有行上下文时**应当**传)。
59
58
  */
60
59
  export async function steerEngineSubagent(target, text, childTaskId) {
61
- const cfg = engineWireTarget();
62
- if (!cfg)
63
- return { ok: false, reason: 'no-wire' };
64
60
  // 空串/非串按「没指名行」处理(与 resolveOwnerRunId 的 named 判据同口径);窄成 string|undefined
65
61
  // 而不是 boolean —— 布尔位不给 TS 窄化,留痕那一行会被迫 cast。
66
62
  const named = typeof childTaskId === 'string' && childTaskId !== '' ? childTaskId : undefined;
63
+ // 🔴 design/285 批 3(D7b):台账记录**先取**(纯读)—— wire target 按**这一行的会话槽**取,
64
+ // 零参 `engineWireTarget()` 写死默认槽(会话对了而 baseUrl/token/principal 是别人的)。
65
+ // 这也顺带把本腿原有的「同一张表读两次」收成一次读(取址判据仍在 resolveOwnerRunId 一处)。
66
+ const { record: owner, key } = resolveOwnerContext(named);
67
+ const cfg = engineWireTargetFor(key);
68
+ if (!cfg)
69
+ return { ok: false, reason: 'no-wire' };
67
70
  const runId = resolveOwnerRunId(named);
68
71
  if (!runId) {
69
72
  // 指名了行才算「台账缺席」——不指名的通用调用只是没有在飞 run,不是宿主映射缺席(两者出路
@@ -79,10 +82,16 @@ export async function steerEngineSubagent(target, text, childTaskId) {
79
82
  });
80
83
  if (!client)
81
84
  return { ok: false, reason: 'no-wire' };
82
- // 会话二态(见头注):行登记的会话优先,缺则退现势会话。这里是同一张表的第二次同步读
83
- // (与上面 `resolveOwnerRunId` 之间无 await ⇒ 同一拍),取址判据本身仍只有那一处,不另铸。
84
- const rowSession = named !== undefined ? getBgParentRunOwner(named)?.sessionId : undefined;
85
- const session = rowSession !== undefined ? { session: rowSession } : engineSessionParamSpread();
85
+ // 会话二态(见头注):行登记的会话优先,缺则退现势会话。记录来自上面那一次读(`resolveOwnerContext`),
86
+ // 不再第二次查表;取址判据本身仍只有 `resolveOwnerRunId` 那一处,不另铸。
87
+ // 🔴 design/285 3:现势会话回落也按 `key` (没指名行时 key 恒是默认槽 ⇒ 与 0.44.0 逐字同)。
88
+ const rowSession = owner?.sessionId;
89
+ const fallbackSession = rowSession === undefined ? engineSessionParamFor(key) : undefined;
90
+ const session = rowSession !== undefined
91
+ ? { session: rowSession }
92
+ : fallbackSession !== undefined
93
+ ? { session: fallbackSession }
94
+ : {};
86
95
  try {
87
96
  // service 1.89 receipt shape: { note: "Message queued for delivery to <name> …", delivery, … }
88
97
  const body = await client.runs.steerSubagent(runId, target, { content: text }, session);
@@ -49,9 +49,10 @@
49
49
  */
50
50
  import { hostLog } from '../host.js';
51
51
  import { makeEngineWireClient } from '../engineWireSdk.js';
52
- import { engineWireTarget } from '../engineWireTarget.js';
53
- import { engineSessionParam } from '../engineSessionParam.js';
54
- import { coerceOutput, getBgParentRun, parentToolCallIdOf, publishSubagentContentEvent, settleSubagentContent, } from '../subagentContentStore.js';
52
+ import { engineWireTargetFor } from '../engineWireTarget.js';
53
+ import { engineSessionParamFor } from '../engineSessionParam.js';
54
+ import { resolveOwnerContext } from './engineSubagentResume.js';
55
+ import { coerceOutput, parentToolCallIdOf, publishSubagentContentEvent, settleSubagentContent, } from '../subagentContentStore.js';
55
56
  import { noteBgOwnerAbsence } from './subagentOwnerAbsence.js';
56
57
  // ── caps 探测(true 固化 / false TTL / 失败不缓存)────────────────────────────────────────────
57
58
  const capByBase = new Map();
@@ -113,7 +114,12 @@ export function isEngineSubagentTailActive(taskId) {
113
114
  export function tailEngineSubagent(taskId) {
114
115
  if (activeTails.has(taskId))
115
116
  return;
116
- const cfg = engineWireTarget();
117
+ // 🔴 design/285 批 3(D7b):台账记录**先取**(纯读),因为 wire target 要按**这一行的会话槽**取 ——
118
+ // 零参 `engineWireTarget()` 写死默认槽,keyed 宿主上会拿 A 槽的 baseUrl/token/principal 去
119
+ // 读 B 槽的子代。`sessionKey` 缺席 ⇒ 回落默认槽 ⇒ 与 0.44.0 逐字同。
120
+ // ⚠️ 两道闸的**次序一字未动**(无 wire 仍先于「台账缺席」返回,缺席留痕不会在离线宿主上刷屏)。
121
+ const { record: owner, runId, key } = resolveOwnerContext(taskId);
122
+ const cfg = engineWireTargetFor(key);
117
123
  if (!cfg)
118
124
  return;
119
125
  // 🔴 #242 批 3([4000] Q3=B 裁定执行,行为翻面):台账缺席即**诚实缺席,绝不回落**
@@ -122,7 +128,6 @@ export function tailEngineSubagent(taskId) {
122
128
  // 供给面:本表由 fleet 行帧 parentId / bg 通知 parentTaskId / task_progress tick 三腿喂
123
129
  // (fleetLedger.ts:394/:496 + recordSubagentOwnerFromProgress),正常 bg 子代出生帧即有值;
124
130
  // 缺席 = 真不知道宿主(壳重启且行帧未重播等),此时连「当前活跃 run」纯属赌。
125
- const runId = getBgParentRun(taskId);
126
131
  if (!runId) {
127
132
  noteBgOwnerAbsence('subagent-tail', taskId);
128
133
  return;
@@ -146,7 +151,14 @@ export function tailEngineSubagent(taskId) {
146
151
  try {
147
152
  if (!(await subagentStreamCapable(client, cfg.baseUrl, ac.signal)))
148
153
  return;
149
- const session = engineSessionParam();
154
+ // 🔴 design/285 批 3(D6,会话二态;与 steer 腿 `:88-91` 同一口径):
155
+ // · 行登记时**捕到了会话** ⇒ 用**行的**会话(与行的 runId 同源同拍,唯一自洽的组合);
156
+ // · 行没有随行会话 ⇒ 退**本槽的现势会话**(遵 [1498]③ 无条件带 session 的双版本兼容纪律;
157
+ // runId 已由台账钉死,配错的组合在 session-bound run 上是 server 干净的 404,落既有静默出路)。
158
+ // 🔴 **BEHAVIOR CHANGE(默认槽也变)**:tick 腿今天就在默认槽写 `sessionId`,所以「会话轮换后
159
+ // 对旧行开 tail」此前用**现势**会话打一条 session-bound 的旧 run = 确定性 404;现在用行登记的
160
+ // 那一个,与 steer/resume 已成立的口径统一。现势会话回落也按 `key` 取,不再写死默认槽。
161
+ const session = owner?.sessionId ?? engineSessionParamFor(key);
150
162
  // SDK 0.0.77 起 runs.subagentStream 有真型([1547]① 候发版即消费兑现)——宽形 cast 已撤。
151
163
  const stream = client.runs.subagentStream(runId, taskId, {
152
164
  signal: ac.signal,
@@ -40,9 +40,9 @@
40
40
  import { TaskStopConflictError } from '@sema-agent/sdk';
41
41
  import { hostLog } from '../host.js';
42
42
  import { makeEngineWireClient } from '../engineWireSdk.js';
43
- import { engineWireDebugEnabled, engineWireTarget } from '../engineWireTarget.js';
44
- import { engineSessionParamSpread } from '../engineSessionParam.js';
45
- import { getBgParentRun } from '../subagentContentStore.js';
43
+ import { engineWireDebugEnabled, engineWireTargetFor } from '../engineWireTarget.js';
44
+ import { engineSessionParamFor } from '../engineSessionParam.js';
45
+ import { resolveOwnerContext } from './engineSubagentResume.js';
46
46
  import { engineTaskHandlesCapable } from './engineRowStopGate.js';
47
47
  import { noteBgOwnerAbsence } from './subagentOwnerAbsence.js';
48
48
  // REF-CC-域词表-06 提单源:spool 全量/增量判读的单一真源现在在 toolResult.ts(它也消费同一份
@@ -63,13 +63,30 @@ import { STOP_CONFLICT_CODES, STOP_NOT_LANDED, STOP_NOT_LOCAL, STOP_PARKED, STOP
63
63
  * 缺席不静默(noteBgOwnerAbsence warn 留痕);两个消费口各自 fail-soft(output→null 回落
64
64
  * receipt/outputFile 渲染,stop→unavailable + detail,UI 不翻行)。 */
65
65
  function resolveHostRun(handle) {
66
- const runId = getBgParentRun(handle);
66
+ const runId = resolveOwnerContext(handle).runId;
67
67
  if (!runId) {
68
68
  noteBgOwnerAbsence('task-handle', handle);
69
69
  return null;
70
70
  }
71
71
  return runId;
72
72
  }
73
+ /**
74
+ * 🔴 会话**二态**(design/285 批 3,与 tail / output / steer 三腿同一口径):
75
+ * · 句柄登记时**捕到了会话**(批 2 起 fleet 行帧腿经 ingress 三值同写)⇒ 用**行登记的**会话 ——
76
+ * 它与本腿寻址用的 `runId` 同源同拍,是唯一自洽的组合;
77
+ * · 没有随行会话 ⇒ 退**本槽的**现势会话(遵 [1498]③ 无条件带 session 的双版本兼容纪律)。
78
+ *
79
+ * 🔴 **为什么本腿也改**(设计稿 D6 原本只点名 tail/output,施工轮扩到本腿,同形存量族扫):
80
+ * 病形 = 「台账取的 runId 配现势 session」—— 两面出生即强制带 `?session=`(见文件头注),
81
+ * 对 session-bound 的旧 run 是确定性 404。批 2 落地之前本腿的行**没有**随行会话,这一格不可达;
82
+ * 批 2 让 fleet 行帧腿开始写 session 之后,它与 tail/output 变成同一个形。只修 tail/output 而
83
+ * 留下本腿 = 同一根因只修当格。停行(`stopEngineTask`)尤其不能留:它是有副作用的动作。
84
+ * 空串归一仍只在 `engineSessionParamFor` 一处,本函数不重铸。
85
+ */
86
+ function sessionOf(ctx) {
87
+ const s = ctx.record?.sessionId ?? engineSessionParamFor(ctx.key);
88
+ return s !== undefined ? { session: s } : {};
89
+ }
73
90
  // 游标累积(按 handle;进程内存态,与查看态生命周期同级——端重启即空,重读从引擎再取)
74
91
  const accumulated = new Map();
75
92
  /** 测试钩:清累积缓存。 */
@@ -81,8 +98,10 @@ export function __resetTaskHandleWireForTests() {
81
98
  * 域外或已 reap / 网错)——调用方回落既有渲染(receipt/outputFile tail)。绝不 throw。
82
99
  */
83
100
  export async function fetchEngineTaskOutput(handle, opts) {
84
- const cfg = engineWireTarget();
85
- if (!cfg || !engineTaskHandlesCapable())
101
+ // design/285 3(D7b):wire target 与 caps 门都按**这条句柄所属的槽**取,不再写死默认槽。
102
+ const owner = resolveOwnerContext(handle);
103
+ const cfg = engineWireTargetFor(owner.key);
104
+ if (!cfg || !engineTaskHandlesCapable(owner.key))
86
105
  return null;
87
106
  const runId = resolveHostRun(handle);
88
107
  if (!runId)
@@ -97,7 +116,8 @@ export async function fetchEngineTaskOutput(handle, opts) {
97
116
  try {
98
117
  const r = await client.runs.taskOutput(runId, handle, {
99
118
  ...(opts?.signal ? { signal: opts.signal } : {}),
100
- ...engineSessionParamSpread(),
119
+ // design/285 批 3:会话二态(行登记的优先,缺则退本槽现势)——判据本体见 sessionOf 头注。
120
+ ...(sessionOf(owner)),
101
121
  });
102
122
  const fresh = typeof r?.content === 'string' ? r.content : '';
103
123
  const prev = accumulated.get(handle) ?? '';
@@ -195,8 +215,10 @@ export function classifyTaskStopConflict(e) {
195
215
  * 已随 G1 去字面化搬到 `engineErrorCodes.ts` 的 `STOP_CONFLICT_CODES` —— 顺序即语义那条注也在那边。 */
196
216
  /** 停一个引擎侧任务句柄(新面 B)。只有 ok:true(=HTTP 200)算停了。绝不 throw。 */
197
217
  export async function stopEngineTask(handle, opts) {
198
- const cfg = engineWireTarget();
199
- if (!cfg || !engineTaskHandlesCapable())
218
+ // design/285 3(D7b):同 `fetchEngineTaskOutput` —— 停行是有副作用的动作,打错引擎更坏。
219
+ const owner = resolveOwnerContext(handle);
220
+ const cfg = engineWireTargetFor(owner.key);
221
+ if (!cfg || !engineTaskHandlesCapable(owner.key))
200
222
  return { ok: false, reason: 'unavailable' };
201
223
  const runId = resolveHostRun(handle);
202
224
  if (!runId)
@@ -211,7 +233,8 @@ export async function stopEngineTask(handle, opts) {
211
233
  try {
212
234
  const r = await client.runs.taskStop(runId, handle, {
213
235
  ...(opts?.signal ? { signal: opts.signal } : {}),
214
- ...engineSessionParamSpread(),
236
+ // design/285 批 3:会话二态(行登记的优先,缺则退本槽现势)——判据本体见 sessionOf 头注。
237
+ ...(sessionOf(owner)),
215
238
  });
216
239
  const out = r?.output ?? {};
217
240
  if (engineWireDebugEnabled()) {
@@ -203,12 +203,58 @@ export interface SubagentOwnerRecord {
203
203
  runId: string;
204
204
  /** 登记时的会话 id;缺席即不带 `?session=`(空串按「带了且不匹配」处理 = 404,恒不铸空串)。 */
205
205
  sessionId?: string;
206
+ /**
207
+ * 🔴 design/285 批 2(D7a):登记时那条流所属的**会话槽键**(`installHostFor(key, …)` 的 key)。
208
+ *
209
+ * 为什么记它而不是只记 `sessionId`:读侧要的不只是 `?session=`,还有**打哪台服务器、用谁的
210
+ * token 与 principal** —— 那三件在 `engineWireTargetFor(sessionKey)` 上,按槽装配。只记会话
211
+ * 而 wire target 仍走默认槽 = 「会话对了、凭证是别人的」(D7b),per-key 会话锚只做完一半。
212
+ *
213
+ * 缺席 ⇒ 读侧回落 `DEFAULT_SESSION_KEY`,与 0.44.0 逐字同(见 `resolveOwnerContext`)。
214
+ * 🔴 缺席的**唯一**合法来源是「这一写没有通道证据」:兼容入口 `ledger.applyFrame` 每帧现读槽
215
+ * 会话、库里不留锚,它写进来的行归属不可知,所以两个新位一律不写(与 0.44.0 逐字同)。
216
+ */
217
+ sessionKey?: string;
206
218
  }
219
+ /**
220
+ * 🔴 R6(design/285 §7):同一个 `runId` 上先后被喂进**两个不同**会话时的留痕。
221
+ *
222
+ * 为什么要有:合表后 tick 腿(壳 liveClient 开流时捕获)与 fleet 两腿(ingress 捕获)会写同一条
223
+ * 记录。口径仍是「带值的写照常赢」(last-write-wins),但那不能是**静默**的 —— 两条腿对同一个宿主
224
+ * run 给出不同会话,要么是宿主把两条流的帧喂进了同一本账,要么是某一腿的捕获点写错了,两种都是
225
+ * 承重缺陷。**判据是计数而不是日志**:本 store 是零 import 的内核闭包(见文件头注),而且
226
+ * 「只落日志的告警在测试里等于不存在」(本仓既有裁定,见 `auditRetainWithoutWake` 头注)。
227
+ */
228
+ export interface SubagentOwnerSessionConflict {
229
+ taskId: string;
230
+ runId: string;
231
+ /** 冲突前记录上的**出身对**(整对记,不是只记会话 —— 换槽键与换会话同样是换出身)。 */
232
+ prior: {
233
+ sessionId?: string;
234
+ sessionKey?: string;
235
+ };
236
+ /** 这一写带来的出身对(它整对赢)。 */
237
+ next: {
238
+ sessionId?: string;
239
+ sessionKey?: string;
240
+ };
241
+ }
242
+ /** R6 读口:`count` = 累计冲突次数(恒应为 0);`last` = 最后一次的四元组(诚实缺席用 undefined)。 */
243
+ export declare function subagentOwnerSessionConflicts(): {
244
+ count: number;
245
+ last?: SubagentOwnerSessionConflict;
246
+ };
207
247
  /**
208
248
  * fleet 行帧 / bg 通知腿的登记口(既有两写点)。
209
249
  * `sessionId` 是 #242 批 2 additive 补位:缺席则不落键(读面据此判「不带 `?session=`」)。
250
+ * `sessionKey` 是 design/285 批 2 additive 补位,同款「缺席则不落键」。
251
+ *
252
+ * 🔴 **两个新位的值必须来自 ingress 的不可变捕获**(`ledger.issueStream()` 那一刻解析一次),
253
+ * **不是**每帧现读某个可变槽 —— 逐帧现读会让重连后旧流的迟到帧被登记成 `{旧 runId, 新会话}`,
254
+ * 正是本文件 `recordSubagentOwnerFromProgress` 头注明令禁止的错组合。ingress 的 epoch 闸让旧代际
255
+ * 的帧根本进不来,这才是「原子」的落点。
210
256
  */
211
- export declare function recordBgParentRun(taskId: string, runId: string, sessionId?: string): void;
257
+ export declare function recordBgParentRun(taskId: string, runId: string, sessionId?: string, sessionKey?: string): void;
212
258
  /**
213
259
  * 「这一行子代的宿主 run」统一登记口(**显式传值**,零推断,见本段头注)。
214
260
  * `runId` 缺席/空串 ⇒ 什么都不记(宁可「不知道」也不记一个错的宿主);`taskId === runId`
@@ -221,8 +267,15 @@ export declare function recordBgParentRun(taskId: string, runId: string, session
221
267
  * session-bound run 上 fail-closed 404)。
222
268
  * 口径:**同一 `runId` 上缺席不覆盖在场**(带值的写照常赢);`runId` 真变了 ⇒ 旧 session 必须丢
223
269
  * (那是另一条宿主 run 的会话,留着就是错组合)。
270
+ *
271
+ * 🔴 **`sessionId` 与 `sessionKey` 是一个「出身对」,整对进出**(design/285 批 2,异源对抗复审
272
+ * [high] 收紧):**这一写带了任一位就整对赢**;**两位都缺席**才走上面那条「缺席不覆盖在场」。
273
+ * 分开 merge(各留各的)会合成一条从来没被同一次捕获产生过的元组 —— 而读侧正是拿这一对去挑
274
+ * 服务器 / 凭证 / 会话,详见函数体里那段头注的两条可达路径。`runId` 变了 ⇒ 整对丢。
275
+ * 🔴 **同 runId 上换出身不静默**(R6):出身对(会话**或**槽键)变了就计 `ownerSessionConflictCount`
276
+ * 并留下两对(读口 {@link subagentOwnerSessionConflicts})。值仍按「带出身的写赢」,变的是它不再无声。
224
277
  */
225
- export declare function recordSubagentOwner(taskId: string, runId?: string, sessionId?: string): void;
278
+ export declare function recordSubagentOwner(taskId: string, runId?: string, sessionId?: string, sessionKey?: string): void;
226
279
  /**
227
280
  * `task_progress` 腿的登记口(#242 批 2 ①:收编前本表**零 task_progress 腿**,已直证)。
228
281
  *
@@ -238,14 +291,14 @@ export declare function recordSubagentOwner(taskId: string, runId?: string, sess
238
291
  * 会话切换之后晚到的旧流 tick 被登记成 `{旧 runId, 新 sessionId}`,这对组合去 resume 会带着
239
292
  * 错 session 打旧 run,fail-closed 404。
240
293
  */
241
- export declare function recordSubagentOwnerFromProgress(ev: unknown, ownerRunId?: string, ownerSessionId?: string): void;
294
+ export declare function recordSubagentOwnerFromProgress(ev: unknown, ownerRunId?: string, ownerSessionId?: string, ownerSessionKey?: string): void;
242
295
  export declare function getBgParentRun(taskId: string): string | undefined;
243
296
  /**
244
297
  * 整条宿主记录(runId + 登记时会话)。undefined = **诚实不知道**,调用方渲 no-run 出路 /
245
298
  * 不渲入口 —— 绝不在这里回落成「此刻在飞的那条 run」([anchor-on-the-deciding-quantity])。
246
299
  */
247
300
  export declare function getBgParentRunOwner(taskId: string | undefined): SubagentOwnerRecord | undefined;
248
- /** 测试钩:清宿主台账(module 级单例,同进程多组断言必须能清)。 */
301
+ /** 测试钩:清宿主台账(module 级单例,同进程多组断言必须能清)。R6 冲突台账一并清。 */
249
302
  export declare function __resetSubagentOwnerLedgerForTests(): void;
250
303
  export declare function recordOwnEngineRun(runId: string): void;
251
304
  export declare function isOwnEngineRun(runId: string): boolean;
@@ -396,12 +396,52 @@ export function getBgTerminalFacts(taskId) {
396
396
  // 满了按 LRU 淘汰,淘汰后果只是那一行退回「不知道宿主 run」= 诚实缺席,不是错值。
397
397
  const MAX_BG_PARENT_RUNS = 320;
398
398
  const bgParentRun = new Map();
399
+ /**
400
+ * 缺席 `sessionKey` 的**有效槽身份**。
401
+ *
402
+ * 🔴 值必须与 `sessionSlot.DEFAULT_SESSION_KEY` 逐字相同,但**刻意不 import**:本 store 是
403
+ * `runStream.ts` 可移植闭包里的零 import 件(文件头三处以此立身),拉一条边就破了那道门。
404
+ * 两处同值由 pure 门的 `B3-P31/R6h` 钉住(读侧 `resolveOwnerContext` 的回落键必须 === 本常量),
405
+ * 任一侧改字面当场红 —— 手抄一个常量而没有对账钉,才是真正会漂的形。
406
+ */
407
+ const DEFAULT_OWNER_SLOT = '__default__';
408
+ let ownerSessionConflictCount = 0;
409
+ let lastOwnerSessionConflict;
410
+ /**
411
+ * 🔴 **跨槽冲突的隔离台账**(异源对抗复审第六轮 [high] 采纳):`taskId` → 出事的那个 `runId`。
412
+ *
413
+ * 为什么光删记录不够(复审的可证伪点,实测成立):删完之后 `prior` 为空 ⇒ **任一条**后续帧都会
414
+ * 立刻把记录重建出来 —— 序列 `A 写 → B 写(冲突删)→ A 再写` 当场恢复 `{sessionKey:'A'}`,反向亦然。
415
+ * 两个来源**持续产帧**时,这条行会在两个槽之间来回翻,每翻一次就开一个错误出站窗;而读侧拿它做的
416
+ * 是 stop / resume 这类**有副作用**的动作。「后一帧自愈」只是**假设**另一条流已经停了,那不是证据。
417
+ *
418
+ * 隔离的**解除规则**(与复审给的口径一致,取其中不需要代际管道的那一条):
419
+ * · `runId` **变了** ⇒ 那是另一条宿主 run,与出事的那一条无关,隔离随之作废;
420
+ * · 测试钩 `__resetSubagentOwnerLedgerForTests()`。
421
+ * 🔴 隔离期间**记录保持缺席** ⇒ 五个读点走成文的诚实缺席(`noteBgOwnerAbsence` + null / no-run),
422
+ * 零读侧改动、零出站。⚠️ 计数恒应为 0 ⇒ 对接线正确的宿主本机制逐字节不可达。
423
+ * 有界:与 `bgParentRun` 同上限、同 LRU 淘汰口径(键同样来自 wire,不许无界)。
424
+ */
425
+ const conflictQuarantine = new Map();
426
+ /** R6 读口:`count` = 累计冲突次数(恒应为 0);`last` = 最后一次的四元组(诚实缺席用 undefined)。 */
427
+ export function subagentOwnerSessionConflicts() {
428
+ return {
429
+ count: ownerSessionConflictCount,
430
+ ...(lastOwnerSessionConflict !== undefined ? { last: lastOwnerSessionConflict } : {}),
431
+ };
432
+ }
399
433
  /**
400
434
  * fleet 行帧 / bg 通知腿的登记口(既有两写点)。
401
435
  * `sessionId` 是 #242 批 2 additive 补位:缺席则不落键(读面据此判「不带 `?session=`」)。
436
+ * `sessionKey` 是 design/285 批 2 additive 补位,同款「缺席则不落键」。
437
+ *
438
+ * 🔴 **两个新位的值必须来自 ingress 的不可变捕获**(`ledger.issueStream()` 那一刻解析一次),
439
+ * **不是**每帧现读某个可变槽 —— 逐帧现读会让重连后旧流的迟到帧被登记成 `{旧 runId, 新会话}`,
440
+ * 正是本文件 `recordSubagentOwnerFromProgress` 头注明令禁止的错组合。ingress 的 epoch 闸让旧代际
441
+ * 的帧根本进不来,这才是「原子」的落点。
402
442
  */
403
- export function recordBgParentRun(taskId, runId, sessionId) {
404
- recordSubagentOwner(taskId, runId, sessionId);
443
+ export function recordBgParentRun(taskId, runId, sessionId, sessionKey) {
444
+ recordSubagentOwner(taskId, runId, sessionId, sessionKey);
405
445
  }
406
446
  /**
407
447
  * 「这一行子代的宿主 run」统一登记口(**显式传值**,零推断,见本段头注)。
@@ -415,8 +455,15 @@ export function recordBgParentRun(taskId, runId, sessionId) {
415
455
  * session-bound run 上 fail-closed 404)。
416
456
  * 口径:**同一 `runId` 上缺席不覆盖在场**(带值的写照常赢);`runId` 真变了 ⇒ 旧 session 必须丢
417
457
  * (那是另一条宿主 run 的会话,留着就是错组合)。
458
+ *
459
+ * 🔴 **`sessionId` 与 `sessionKey` 是一个「出身对」,整对进出**(design/285 批 2,异源对抗复审
460
+ * [high] 收紧):**这一写带了任一位就整对赢**;**两位都缺席**才走上面那条「缺席不覆盖在场」。
461
+ * 分开 merge(各留各的)会合成一条从来没被同一次捕获产生过的元组 —— 而读侧正是拿这一对去挑
462
+ * 服务器 / 凭证 / 会话,详见函数体里那段头注的两条可达路径。`runId` 变了 ⇒ 整对丢。
463
+ * 🔴 **同 runId 上换出身不静默**(R6):出身对(会话**或**槽键)变了就计 `ownerSessionConflictCount`
464
+ * 并留下两对(读口 {@link subagentOwnerSessionConflicts})。值仍按「带出身的写赢」,变的是它不再无声。
418
465
  */
419
- export function recordSubagentOwner(taskId, runId, sessionId) {
466
+ export function recordSubagentOwner(taskId, runId, sessionId, sessionKey) {
420
467
  if (!taskId || typeof runId !== 'string' || runId === '' || taskId === runId)
421
468
  return;
422
469
  if (bgParentRun.size >= MAX_BG_PARENT_RUNS && !bgParentRun.has(taskId)) {
@@ -424,15 +471,104 @@ export function recordSubagentOwner(taskId, runId, sessionId) {
424
471
  if (oldest !== undefined)
425
472
  bgParentRun.delete(oldest);
426
473
  }
474
+ // 🔴 隔离闸(异源对抗复审第六轮):这条 `(taskId, runId)` 已被判过「两个槽同时认领」⇒ 记录保持
475
+ // 缺席,任何后续帧都不许把它重建出来(「后一帧自愈」是假设不是证据)。`runId` 变了即解除。
476
+ const quarantinedRun = conflictQuarantine.get(taskId);
477
+ // 🔴 **墓碑,不是可解除的锁**(异源对抗复审第七轮 [high] 采纳,推翻前一版的「runId 变了就删条目」):
478
+ // `runId` 是**无序字符串**,「不相等」证明不了前进方向。前一版的序列可复现:`(C,R)` 冲突 ⇒ 隔离;
479
+ // `(C,R2)` 把条目**删掉**并登记 R2;此时原冲突源**迟到**的 `(C,R)` 因为条目已删、`sameRun` 又为假,
480
+ // 直接把映射覆盖回旧 R 与旧槽 —— 跨会话误路由窗当场重开。
481
+ // ⇒ 条目**留着不删**:别的 `runId` 照常放行(另一条宿主 run 与出事的那条无关),而**那一条**
482
+ // 出事的 run 从此不许再回来。有界性不变(与 owner 表同上限、同 LRU 淘汰)。
483
+ if (quarantinedRun !== undefined && quarantinedRun === runId)
484
+ return;
427
485
  const prior = bgParentRun.get(taskId);
428
- const kept = typeof sessionId === 'string' && sessionId !== ''
429
- ? sessionId
430
- : (prior?.runId === runId ? prior.sessionId : undefined);
486
+ const sameRun = prior?.runId === runId;
487
+ const incoming = typeof sessionId === 'string' && sessionId !== '' ? sessionId : undefined;
488
+ const incomingKey = typeof sessionKey === 'string' && sessionKey !== '' ? sessionKey : undefined;
489
+ // 🔴 **出身对整对进出**(异源对抗复审 [high] 采纳):`sessionId` 与 `sessionKey` **不许各merge各的**。
490
+ // 分开 merge 会合成一条**从来没有被同一次捕获产生过**的元组,而读侧拿它挑服务器/凭证/会话:
491
+ // · ingress 写 `{S1,'A'}` 之后,另一本 keyed 到 `'B'` 的 ledger(其 SessionPort 无 id)经 ingress
492
+ // 写 `{undefined,'B'}` ⇒ 分开 merge 得 `{S1,'B'}` = 拿 A 的会话去打 B 的服务器;
493
+ // · ingress 写 `{S1,'A'}` 之后,tick 腿(只带会话、不带槽键)写 `{S2,undefined}` ⇒ 得 `{S2,'A'}`
494
+ // = 拿默认槽捕获的会话去打 A 的服务器。两条都是本设计要结构性堵死的错组合。
495
+ // 规矩:**这一写带了任一位就整对赢**(带的那一对就是它的出身,另一位缺席就是「这条出身上没有」);
496
+ // **两位都缺席**才走「缺席不覆盖在场」(= 兼容入口/无出身的写,它没有资格改出身)。
497
+ const carriesProvenance = incoming !== undefined || incomingKey !== undefined;
498
+ const nextPair = carriesProvenance
499
+ ? {
500
+ ...(incoming !== undefined ? { sessionId: incoming } : {}),
501
+ ...(incomingKey !== undefined ? { sessionKey: incomingKey } : {}),
502
+ }
503
+ : sameRun
504
+ ? {
505
+ ...(prior?.sessionId !== undefined ? { sessionId: prior.sessionId } : {}),
506
+ ...(prior?.sessionKey !== undefined ? { sessionKey: prior.sessionKey } : {}),
507
+ }
508
+ : {};
509
+ // R6:同一条宿主 run 上**换出身**(会话变了、或槽键变了)—— 留痕在前(赢家仍是带出身的这一写),
510
+ // 绝不静默 last-write-wins。🔴 槽键变化同样计:换槽键 = 换服务器与凭证,比换会话更严重。
511
+ const priorHasProvenance = prior?.sessionId !== undefined || prior?.sessionKey !== undefined;
512
+ if (sameRun &&
513
+ carriesProvenance &&
514
+ priorHasProvenance &&
515
+ (prior?.sessionId !== nextPair.sessionId || prior?.sessionKey !== nextPair.sessionKey)) {
516
+ ownerSessionConflictCount++;
517
+ lastOwnerSessionConflict = {
518
+ taskId,
519
+ runId,
520
+ prior: {
521
+ ...(prior?.sessionId !== undefined ? { sessionId: prior.sessionId } : {}),
522
+ ...(prior?.sessionKey !== undefined ? { sessionKey: prior.sessionKey } : {}),
523
+ },
524
+ next: { ...nextPair },
525
+ };
526
+ // 🔴 **两个槽同时认领同一条 run ⇒ 出身对整对作废**(异源对抗复审第二轮 [high] 的**可采半场**)。
527
+ //
528
+ // 复审的原议是「任何出身冲突都 fail-closed」,**只采窄的那一格**,理由是两条判据不同命:
529
+ // · **只换会话、槽键不变**(或两边都没有槽键)= #242 批 2 **已裁并钉死**的一格
530
+ // (常驻钉 `#242-R3c3`「带 session 的写照常覆盖,新值赢」)—— 那是 tick 腿重新捕获的正常形,
531
+ // 本批无权在施工轮把一条别的批次的裁定翻面;它的信号面是下面那个计数(R6 的原意)。
532
+ // · **两边都带槽键且不相等** = 本批**新引入**的一格,没有任何既有裁定,而且它不可能有合法读法:
533
+ // 两个不同的槽(= 两台引擎、两份凭证)同时声称同一条宿主 run 是谁的。此时「哪一份对」
534
+ // **不可知**,而读侧拿它挑的正是**服务器 / 凭证 / 会话** —— 留痕在库内零消费者,
535
+ // 拿后到的那份继续路由就是把不可知说成已知。按「错值比缺席更坏」整对丢:读侧回落到
536
+ // 「无通道证据」那条**成文的**降级路径,而不是一个编出来的元组。
537
+ // 🔴 **整条记录删掉,`runId` 也不留**(异源对抗复审第五轮 [high] 采纳,推翻了本批前一版的
538
+ // 「留 runId」)。前一版的理由是「`runId` 那一位没有争议」,**那条理由不成立**:`runId`
539
+ // 要能用,读侧必须给它配一个槽,而读侧对缺席的 `sessionKey` 的解释是 `DEFAULT_SESSION_KEY`
540
+ // —— 于是「留 runId、清出身」实际等于**断言这条 run 属于默认槽**,而冲突恰恰是「有一个 keyed
541
+ // 槽也认领了它」的正面证据。⇒ 那不是「不知道」,是一个已经被证否的猜测。
542
+ // 与兼容入口的无槽记录**不同命**:对纯兼容宿主来说默认槽就是它唯一的槽(那条记录是对的);
543
+ // 在冲突这一格上默认槽是**被证否**的候选之一。
544
+ // 🔴 删记录**不需要任何读侧改动**:五个读点对「台账缺席」早已是成文的诚实缺席
545
+ // (`noteBgOwnerAbsence` 留痕 + null / no-run,#242 批 3 [4000] Q3=B 裁定),本分支直接复用它。
546
+ // 🔴 **不设粘性 quarantine**:删完之后 `prior` 为空 ⇒ 下一条带出身的帧照常重建记录 —— 那正是
547
+ // 复审要的「唯一槽证据」恢复规则:真正还活着的那条流会继续写,停掉的那条不再写。粘性会把一条
548
+ // 本可自愈的行永久钉死;而「冲突到下一帧之间的窗」与「冲突帧就是终帧」两种情形下,记录不在
549
+ // = 读面诚实缺席,**没有错误出站**,那正是 fail-closed 要的结局。
550
+ // ⚠️ 冲突计数**恒应为 0** ⇒ 对任何接线正确的宿主,本分支逐字节不可达。
551
+ // 🔴 **按「有效槽」比,不按「键在不在」比**(异源对抗复审第三轮 [high] 采纳):读侧的
552
+ // `resolveOwnerContext` 把**缺席的 sessionKey 读成 `DEFAULT_SESSION_KEY`** —— 所以「不带键的
553
+ // 带出身写」不是「没有槽」,它就是**默认槽**在认领这条 run。只比显式值会漏掉最常见的那一格:
554
+ // keyed ingress 写 `{S1,'A'}` 之后,一个仍按三参老签名调用的 tick(`{S2, 无键}`)会把记录改成
555
+ // `{sessionId:S2}` ⇒ 读侧拿 **A 的 runId** 去打**默认槽**的服务器与凭证。两向都要挡。
556
+ // ⚠️ 单会话宿主上两边的有效槽都是 `DEFAULT_SESSION_KEY` ⇒ 恒不触发,#242-R3c3 那条钉一字未动。
557
+ const priorSlot = prior?.sessionKey ?? DEFAULT_OWNER_SLOT;
558
+ const nextSlot = nextPair.sessionKey ?? DEFAULT_OWNER_SLOT;
559
+ if (priorSlot !== nextSlot) {
560
+ bgParentRun.delete(taskId);
561
+ if (conflictQuarantine.size >= MAX_BG_PARENT_RUNS && !conflictQuarantine.has(taskId)) {
562
+ const oldestQ = conflictQuarantine.keys().next().value;
563
+ if (oldestQ !== undefined)
564
+ conflictQuarantine.delete(oldestQ);
565
+ }
566
+ conflictQuarantine.set(taskId, runId);
567
+ return;
568
+ }
569
+ }
431
570
  bgParentRun.delete(taskId); // LRU touch(F11):活跃映射不被容量淘汰挤掉 → 读面退错 runId
432
- bgParentRun.set(taskId, {
433
- runId,
434
- ...(kept !== undefined && kept !== '' ? { sessionId: kept } : {}),
435
- });
571
+ bgParentRun.set(taskId, { runId, ...nextPair });
436
572
  }
437
573
  /**
438
574
  * `task_progress` 腿的登记口(#242 批 2 ①:收编前本表**零 task_progress 腿**,已直证)。
@@ -449,7 +585,7 @@ export function recordSubagentOwner(taskId, runId, sessionId) {
449
585
  * 会话切换之后晚到的旧流 tick 被登记成 `{旧 runId, 新 sessionId}`,这对组合去 resume 会带着
450
586
  * 错 session 打旧 run,fail-closed 404。
451
587
  */
452
- export function recordSubagentOwnerFromProgress(ev, ownerRunId, ownerSessionId) {
588
+ export function recordSubagentOwnerFromProgress(ev, ownerRunId, ownerSessionId, ownerSessionKey) {
453
589
  if (typeof ev !== 'object' || ev === null)
454
590
  return;
455
591
  const o = ev;
@@ -459,7 +595,7 @@ export function recordSubagentOwnerFromProgress(ev, ownerRunId, ownerSessionId)
459
595
  return;
460
596
  if (typeof ownerRunId !== 'string' || ownerRunId === '')
461
597
  return;
462
- recordSubagentOwner(o.taskId, ownerRunId, ownerSessionId);
598
+ recordSubagentOwner(o.taskId, ownerRunId, ownerSessionId, ownerSessionKey);
463
599
  }
464
600
  export function getBgParentRun(taskId) {
465
601
  touchLru(bgParentRun, taskId);
@@ -475,9 +611,12 @@ export function getBgParentRunOwner(taskId) {
475
611
  touchLru(bgParentRun, taskId);
476
612
  return bgParentRun.get(taskId);
477
613
  }
478
- /** 测试钩:清宿主台账(module 级单例,同进程多组断言必须能清)。 */
614
+ /** 测试钩:清宿主台账(module 级单例,同进程多组断言必须能清)。R6 冲突台账一并清。 */
479
615
  export function __resetSubagentOwnerLedgerForTests() {
480
616
  bgParentRun.clear();
617
+ conflictQuarantine.clear();
618
+ ownerSessionConflictCount = 0;
619
+ lastOwnerSessionConflict = undefined;
481
620
  }
482
621
  // ── 本壳自己发起过的引擎 run 台账([1498]④ 帧级校验换锚)────────────────────────────────────
483
622
  // 喂给:engineToolDetach.setActiveEngineTaskId(liveClient 从 run_started 帧绑定,本壳每个交互