@sema-agent/client-core 0.30.5 → 0.30.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -16,6 +16,75 @@
16
16
  > 🔴 **互链**(web [C166]⑦):各版「已知局限」段只记**该版新增**;接入面已知局限的完整台账在
17
17
  > `docs/INTEGRATION-CLIENTS.md` §6e/§7 —— **只读其一会漏**,两处都过。
18
18
 
19
+ ## 0.30.7(2026-08-15)
20
+
21
+ **#242 批 2 subagent 归属单表 + resume 腿归位(A-028.6/.7 收编)——公开面 additive,一处行为面加强:**
22
+
23
+ - **「这一行子代属于哪条 run」从此只有一张表**:`subagentContentStore` 的 `bgParentRun` 收编了
24
+ sema-cli 的壳侧 `subagentOwnerLedger`。收编前同一个问题两仓两份台账、**答案规则相反**(壳表按
25
+ `task_progress` tick 喂、缺席即诚实缺席;包表按 fleet 行帧/bg 通知喂、消费方一律回落在飞 run),
26
+ 且两表覆盖面互补(壳表只覆盖发 tick 的子代、包表覆盖零 tick 的 fleet-row lane)。
27
+ - 新写入口 `recordSubagentOwnerFromProgress(ev, ownerRunId, ownerSessionId)` —— 收编前本表
28
+ **零 `task_progress` 腿**。🔴 宿主 run **必须由持有 stream-local 值的调用方显式传入**,包内
29
+ 绝不从 `activeEngineRunId()` 推断(进程级值被并发 main/fork 流互相覆写 + 每个 query 的 finally
30
+ 清空 ⇒ 推断出的宿主可能是别的 run,而**错值比缺席更坏**)。
31
+ - `recordBgParentRun(taskId, runId)` 补 **additive** 第三参 `sessionId`;新读口
32
+ `getBgParentRunOwner(taskId)` 回整条 `{runId, sessionId?}`(旧读口 `getBgParentRun` 签名不变)。
33
+ - 表上限 64 → **320**(收编前两表之和;收货订正:本行中期文案曾写 256「较大者」,与终版实现、
34
+ 断言 R1l/R1m 及本段下方「表容量」条相矛盾——之和才不缩窄既有保障,详见下方)。
35
+ - **resume 腿与它的孪生 steer 同居**:新 `subagent/engineSubagentResume.ts`(判定半场:能力位 gate
36
+ `subagentResume` / 取址三态 / 409 **六族**分类禁合并 / AT-MOST-ONCE 调用腿 / UNTRUSTED 收据消毒)。
37
+ 此前 steer 在包、resume 整条在 sema-cli,且两腿**取址的决定量不同**(steer 取在飞 run、resume 取
38
+ 行锚)—— 那正是「同一个 agent 有时能说话有时不能」的成因。出路文案(CC 措辞面)留端。
39
+ 新公开面:`resumeSettledSubagent` / `resolveSubagentResumeContext` / `resolveOwnerRunId` /
40
+ `classifySubagentResumeFailure` / `resolveSubagentContinueTarget` / `subagentResumeAvailable` /
41
+ `SUBAGENT_RESUME_CAP` + `SubagentResumeContext` / `SubagentResumeFacade` /
42
+ `SubagentResumeFailureKind` / `SubagentResumeOutcome` 类型。
43
+ - **caps × verb 组合告警**(`auditRetainWithoutWake`):引擎 caps 宣示 `subagentResume` ∧ 本端在请求
44
+ `retainSubagentSessions` ∧ 本端**未实现** `wakeSubagent` ⇒ 响亮一条 warn(每 baseUrl 至多一次)。
45
+ 返回 `RetainWithoutWakeVerdict {message, emitted}`(`undefined` = 组合不成立):`message` 答「组合
46
+ 成不成立」、`emitted` 答「**本次**响没响」——两问分开,按返回值呈现的端才不会重复提示。`CLIENT_VERBS.wakeSubagent` 按裁定**维持 `required:false`**(fail-soft)——
47
+ 留存成本天天在付而一个兑现口都没有,这笔账不该只有沉默。
48
+ ⚠️ 键名订正:caps 上没有 `retainSubagentSessions` 位,它是 **run 请求位**;引擎宣示兑现口的
49
+ caps 位是 `subagentResume`。
50
+ - **🔴 刻意未做(候三端表态)**:包内三处 `getBgParentRun(x) ?? activeEngineRunId()` 回落
51
+ (`engineSubagentTail` / `engineTaskHandleWire` / `engineSubagentOutput`)**口径一字未改**,只各加
52
+ 一条候裁注释。统一到 resume 腿的「指名了行就诚实缺席」口径会让台账缺席时这三腿从「能连上(可能
53
+ 连错 run)」变成「不连」= 真行为翻面,需 cli/web/desktop 表态后单批改。
54
+ ⚠️ **本批的一个附带效果**:tick 腿补进单表后,这三处在过去恒缺席、必走回落的那些子代行,现在能
55
+ 查到**真**宿主 run 了 —— 回落分支的触发面因此缩小(方向是从「可能连错」变成「连对」)。
56
+ - **🔴 resume 的 `?session=` 口径(端请注意)**:`resolveSubagentResumeContext` 对「台账认得这一行
57
+ 但没捕到它的会话」**不带 `?session=`**,绝不用「此刻的会话」冒充 —— resume 是 AT-MOST-ONCE 的
58
+ 副作用动作,猜错会话可能落到另一个会话的同名子代上(不可回收),而不带参数只是 server 干净 404。
59
+ 今天 fleet 行帧腿/通知腿不传 session,所以 fleet-only 行稳定落这一格;**供给面欠账**:两腿补上
60
+ 流会话后这一格自然变成「有真值」,判据无需再改。「完全没指名行」的通用调用仍退当前会话(口径不变)。
61
+ - **表容量**:合表上限取收编前**两表之和 320**(不是较大者 256)—— 按 256 钉会出现「tick 池已满、
62
+ 再来一条 fleet 行就淘汰一条 tick」,那条子代的 resume 入口凭空消失而收编前还在。
63
+ 另:同 `runId` 上**无 session 的刷新不覆盖已捕获的 session**(`runId` 真变了才丢)——
64
+ fleet/通知两腿的无 session 写此前会抹掉 tick 一次性捕获的会话。
65
+
66
+ ## 0.30.6(2026-08-15)
67
+
68
+ **#242 批 1 workflow 单流化(A-028.8 收编)——wire 行为变更,消费端请对表:**
69
+
70
+ - **同一 workflowId 恰一条 SSE**:运行期 activity 台账上收包内(模块级常驻,workflow 起跑即积累;
71
+ `createLiveWorkflowSource` 不再自开流,改搭台账 change-tick 做节流 re-GET,逐拍语义等价)。
72
+ 此前包流帧体整丢只当 ticker、消费端(sema-cli)自建第二条同 id 流 —— 连接数从 2 降到 1。
73
+ **若你的端也自建了第二条 workflow detail 流,请改读包台账。**
74
+ - **新公开面**:`ensureWorkflowActivityLedger(cfg)`(预热口,幂等)/`stopWorkflowActivityLedger(id)`
75
+ (收口)/`readWorkflowActivityLedger(id)`(读口,null=没开过账)+ `WorkflowActivityLedgerView`/
76
+ `WorkflowActivityAgentView` 类型。
77
+ - **投影新增 `WorkflowAgent.callKey`**(additive):SSE activity 帧的主键此前被投影丢弃,label 因此
78
+ 成了消费端唯一钥匙;现在重名 agent 也能精确归属。label 归属只在「该 label 恰一个 acc」时成立,
79
+ 归属不可判整组跳过(诚实缺席)。
80
+ - 台账语义:`agent_end.toolCalls` 权威、活动期 start 计数为下界;收口分因(terminal/stopped/error,
81
+ 仅 error 可被 ensure 重开且事实保留);连线身份(baseUrl/principal/凭证)变更整条换新,被顶掉的
82
+ 消费者停手降级 GET 轮询(不 ping-pong 抢回)。
83
+
84
+ 已知局限(本版新增;完整台账见 docs/INTEGRATION-CLIENTS.md §6e/§7):台账注册表按 workflowId 单键
85
+ (两个不同连线身份同 id 并存时后者赢、前者退 GET 轮询);台账自预热口首调起积累(attach 老 run 时
86
+ 运行期计数是下界);常驻上限 16 条,满且全部在飞时新 id 不建账(详情退 GET 轮询,响亮留痕)。
87
+
19
88
  ## 0.30.5
20
89
 
21
90
  - **#244 F4 族D:配置/模型表单源化(A-028.17/.18/.20 三件单源上收 + A-028.19 对账钉,additive,
package/README.md CHANGED
@@ -35,7 +35,7 @@ Renamed from **`@sema-agent/wire-cc-adapter`** (0.1.x, deprecated — see *Migra
35
35
 
36
36
  ## Scope
37
37
 
38
- **Version:** 0.30.5
38
+ **Version:** 0.30.6
39
39
 
40
40
  - **Today** — the adapter seam, the whole `adapt()` pipeline (all 14 A-layer arms plus the
41
41
  B/D/E tool-card layers), the notification/caps/model families, the adapter kernel (stream driver
package/dist/index.d.ts CHANGED
@@ -203,6 +203,7 @@ export * from './subagent/engineDelegatedPrompt.js';
203
203
  export * from './subagent/engineRowStopGate.js';
204
204
  export * from './subagent/engineSubagentOutput.js';
205
205
  export * from './subagent/engineSubagentSteer.js';
206
+ export * from './subagent/engineSubagentResume.js';
206
207
  export * from './subagent/engineTaskHandleWire.js';
207
208
  export * from './subagent/engineCompactWire.js';
208
209
  export * from './subagent/engineSubagentTail.js';
package/dist/index.js CHANGED
@@ -264,6 +264,8 @@ export * from './subagent/engineDelegatedPrompt.js';
264
264
  export * from './subagent/engineRowStopGate.js';
265
265
  export * from './subagent/engineSubagentOutput.js';
266
266
  export * from './subagent/engineSubagentSteer.js';
267
+ // #242 批 2(A-028.7):resume 判定半场与 steer 同居 —— 两条孪生腿的取址口径才有单一真源。
268
+ export * from './subagent/engineSubagentResume.js';
267
269
  export * from './subagent/engineTaskHandleWire.js';
268
270
  export * from './subagent/engineCompactWire.js';
269
271
  export * from './subagent/engineSubagentTail.js';
@@ -64,6 +64,9 @@ export async function fetchEngineSubagentReport(taskId, opts) {
64
64
  const cfg = engineWireTarget();
65
65
  if (!cfg)
66
66
  return null;
67
+ // 🔴 候裁([4000] Q3 / design-242 §5 Q3,#242 批 2 **刻意不动**):这条回落与 resume 腿的
68
+ // `resolveOwnerRunId`(指名了行就诚实缺席)口径相反;统一口径是真行为翻面,候三端表态。
69
+ // 同族另两处:`engineSubagentTail.ts:tailEngineSubagent` / `engineTaskHandleWire.ts:resolveHostRun`。
67
70
  const runId = getBgParentRun(taskId) ?? activeEngineRunId();
68
71
  if (!runId)
69
72
  return null;
@@ -0,0 +1,198 @@
1
+ /**
2
+ * ⇄ #242 批 2 搬迁(2026-08-15,design/242 §3 批 2 / A-028.7):cli `src/sema/subagentResumeWire.ts`
3
+ * 的**判定半场**整搬(取址三态 + 409 六族分类 + at-most-once 调用腿),**逐条搬迁不重新发明**。
4
+ *
5
+ * ── 为什么搬(A-028.7 的真缺口)────────────────────────────────────────────────────────────────
6
+ * 孪生两腿此前**分居两仓**:steer 在包(`engineSubagentSteer.ts`),resume 整条在壳(270 行)。
7
+ * 而两腿的**决定量口径不同** —— steer 取址 `activeEngineRunId()`(在飞的那条),resume 取址
8
+ * `resolveOwnerRunId()`(按行锚)—— 正是壳侧头注自己警告的那种不对称:「同一个 agent 有时能说话
9
+ * 有时不能」。同居之后两腿的取址口径才有单一真源可对(口径统一本身是行为面,候 [4000] Q3)。
10
+ *
11
+ * ── 拆缝(判定进包 / 呈现留壳,与 T35/T36/T44 同一条原则)────────────────────────────────────
12
+ * **进包**:能力位 gate · 取址(`resolveOwnerRunId` 三态 + `resolveSubagentContinueTarget`)·
13
+ * 409 六族分类 · at-most-once 调用腿 · 收据消毒 · caps×verb 组合告警。
14
+ * **留壳**:出路文案(`subagentResumeCopy`,CC 措辞面)与 Ink 卡面动作。
15
+ *
16
+ * ── 搬迁差分(行为一字节不变,端资产上移到 port)────────────────────────────────────────────
17
+ * · `capsBaseUrl()` 直读 `process.env.SEMA_LIVE_BASEURL` → `engineWireTarget()?.baseUrl`
18
+ * (本包零 `process.env`,portability 门盯着;壳侧 env 臂由 `engineWireTarget` 同源派生,
19
+ * 且这一形对**显式装配** wire target 的宿主 —— web/桌面 —— 才答得对。同 `engineRowStopGate:44`)。
20
+ * · `logForDebugging` → `hostLog('debug', …)`(同 steer 腿搬迁差分)。
21
+ *
22
+ * ── 以下为原文件的领域说明(逐字保留)────────────────────────────────────────────────────────
23
+ *
24
+ * subagentResumeWire — 保留子代的**复活口**消费面(A-022.1 + A-022.12 的人机面 / 缺口 G2+G9)。
25
+ *
26
+ * 取址/收据/错误族逐字对位既有的 `steerEngineSubagent` —— 同一个 `target`(parentToolCallId 或
27
+ * agentName)、同一个 `{content}` 体、同一个 200 `SubagentSteerReceipt`。**两条腿刻意同形**:
28
+ * steer 是「它还在跑,插句话」,resume 是「它已经结束了,叫醒它」——同一个人机动作的两个时相,
29
+ * 分不清哪个时相就会拿错 verb 吃 409。
30
+ *
31
+ * ── 🔴 能力位 gate:`subagentResume` ────────────────────────────────────────────────────────
32
+ * SDK `runs.d.ts` 头注逐字「Gate on `capabilities.subagentResume`」。未判/缺键 = false = **不渲入口**
33
+ * (渲了就是一个恒 404/409 的假 affordance)。
34
+ *
35
+ * ── 🔴 409 族是**六个不同的处置**,禁合并 ───────────────────────────────────────────────────
36
+ * `resume.retain_off`(这条 run 压根没开留存 —— 与「留存过期了」是两回事)/ `resume.evicted` /
37
+ * `resume.cap` / `resume.session_not_found`(留存失效)/ `steering.still_running`(**它还在跑,
38
+ * 该 steer 不该 resume**)/ `steering.not_running`(本副本无匹配 / run 已 settle)。
39
+ * 判据是 `errorCode` 串(机器轴,开集),**不按 HTTP 数字分支**;未知码原样透传落诚实兜底臂。
40
+ *
41
+ * ── UNTRUSTED ───────────────────────────────────────────────────────────────────────────────
42
+ * 收据 `note` 是 server 铸的文案(引擎会把子代名字拼进去,名字是 spawning model 的自由文本)——
43
+ * 呈前消毒 + 截长,只渲染。
44
+ */
45
+ import type { SubagentSteerReceipt } from '@sema-agent/sdk';
46
+ /** 能力位键(server `routes/capabilities.ts` 的 `subagentResume`;SDK `Capabilities.subagentResume`)。 */
47
+ export declare const SUBAGENT_RESUME_CAP = "subagentResume";
48
+ /** 复活口在不在。未判/缺键 = false = 详情面不渲「继续」腿。 */
49
+ export declare function subagentResumeAvailable(baseUrl?: string | undefined): boolean;
50
+ /**
51
+ * 「继续」腿的 resume 取址(1.0.74 终扫 P2-11)。
52
+ *
53
+ * 🔴 resume API 的 `target` 契约只认两种取址:委派工具调用的 **parentToolCallId**(一等精确取址,
54
+ * 同型并发子代唯一能分开的键)与**子代显示名**(同名多员 ⇒ server 409 `steering.ambiguous_target`)。
55
+ * 行 taskId 不是契约取址形 —— 传它只会得到一条不带替代路径的歧义/未知错。
56
+ * alias 台账(`registerSubagentAlias`,task_progress 双携建;taskToParent 无删除无淘汰,子代 settle
57
+ * 后仍可取)⇒ 精确取址优先;显示名仅回落(它可能是 'Task' 字面占位,同名多员的 409 由每格出路
58
+ * 文案接);两支皆缺 ⇒ undefined,调用方按诚实缺席**不渲入口**。
59
+ */
60
+ export declare function resolveSubagentContinueTarget(rowId: string, displayName: string | undefined): string | undefined;
61
+ /** 本层消费的 `client.runs` 切片(注入缝)。 */
62
+ export interface SubagentResumeFacade {
63
+ resumeSubagent(taskId: string, target: string, message: {
64
+ content: string;
65
+ }, opts?: {
66
+ session?: string;
67
+ signal?: AbortSignal;
68
+ }): Promise<SubagentSteerReceipt>;
69
+ }
70
+ export type SubagentResumeFailureKind =
71
+ /** 没有 live 引擎 / 没有在飞 run —— 连地址都拼不出来(与「引擎拒了」是两回事)。 */
72
+ 'no-wire' | 'no-run'
73
+ /** `resume.retain_off` —— 这条 run 没开 retainSubagentSessions(改配置才有救,重试无用)。 */
74
+ | 'retain-off'
75
+ /** `resume.evicted` / `resume.cap` / `resume.session_not_found` —— 留存**曾经在**,现在没了。 */
76
+ | 'retention-lapsed'
77
+ /** `steering.still_running` —— 它还在跑:该 steer,不该 resume(唯一「换个动作就成」的一格)。 */
78
+ | 'still-running'
79
+ /** `steering.not_running` —— 本副本无匹配 / run 已 settle。 */
80
+ | 'not-resumable'
81
+ /** `steering.ambiguous_target` —— 按名取址撞上同名多员(取址口径本身可分,是唯一「换个地址就成」的一格)。 */
82
+ | 'ambiguous-target'
83
+ /** 404 —— 未知 run / 非属主(**无存在性谕示**,两者同形)。 */
84
+ | 'not-found'
85
+ /** 400 —— 空 content 等入参问题。 */
86
+ | 'bad-request'
87
+ /** 开集兜底。 */
88
+ | 'error';
89
+ export type SubagentResumeOutcome = {
90
+ ok: true;
91
+ receipt: string;
92
+ } | {
93
+ ok: false;
94
+ reason: SubagentResumeFailureKind;
95
+ detail: string;
96
+ };
97
+ /**
98
+ * resume 失败 → 处置分类。机器轴 = `errorCode`(开集);`status` 只用于**码缺席**时的两格粗分
99
+ * (404 无存在性谕示 / 400 入参),绝不用数字去猜某个具体 409 成因(六个 409 只有 `errorCode`
100
+ * 分得开,按数字分支等于把六种处置压成一种)。
101
+ */
102
+ export declare function classifySubagentResumeFailure(e: unknown): {
103
+ reason: SubagentResumeFailureKind;
104
+ detail: string;
105
+ };
106
+ /**
107
+ * 「这一行子代该打到哪条 run」——**纯函数,单点判据**(对抗复审 H1 的修 + 它的可证伪点)。
108
+ *
109
+ * 🔴 **按行取,不取「此刻在飞的那条」**:`activeEngineRunId()` 是 turn-scoped(壳 `seamQuery` 在
110
+ * turn 收尾 `setActiveEngineTaskId(null)`),而「继续」这个动作按定义只在子代**已 settle** 之后
111
+ * 才出现 —— 那时它恒空;更坏的一支是另一条 turn 正飞时它有值但**是别的 run**,请求会打到不
112
+ * 相干的 run 上(404,或叫醒那条 run 里的同名子代)。判据必须锚在决定量(这一行的宿主 run)
113
+ * 上,不是锚在「有没有 run 在飞」这个前置条件上([anchor-on-the-deciding-quantity])。
114
+ *
115
+ * 三态:
116
+ * · 台账有这一行 ⇒ 用台账值(**哪怕此刻另有 run 在飞**);
117
+ * · 台账没有、且调用方**指名了某一行** ⇒ `undefined`(诚实不知道 ⇒ no-run 出路),绝不回落成在飞的那条;
118
+ * · 调用方**没指名行**(无行上下文的通用调用)⇒ 才回落到在飞的那条。
119
+ *
120
+ * @param activeRunId 注入缝(缺省 = `activeEngineRunId()`)—— 判据要能在没有 SessionPort 的纯层里验。
121
+ */
122
+ export declare function resolveOwnerRunId(childTaskId?: string, activeRunId?: string | undefined): string | undefined;
123
+ /** 一次 resume 调用需要的三件:facade(null = 无 wire)、宿主 run(undefined = 诚实缺席)、会话参数。 */
124
+ export interface SubagentResumeContext {
125
+ facade: SubagentResumeFacade | null;
126
+ runId: string | undefined;
127
+ session: Record<string, string>;
128
+ }
129
+ /**
130
+ * 产品 facade + 运行定位。**逐字复用 `steerEngineSubagent` 的三步**(engineWireTarget → 无则 no-wire;
131
+ * 取运行 → 无则 no-run;makeEngineWireClient)—— 两条腿共用同一套装配,免得 steer 能到达而 resume
132
+ * 到不了(或反过来),那种不对称在用户面上表现为「同一个 agent 有时能说话有时不能」。
133
+ *
134
+ * 🔴 `session` 的三态(#242 批 2 定口径,codex 复审 medium 收紧):
135
+ * · 台账有这一行**且**捕到了它的会话 ⇒ 用**行登记的**会话(resume 是 per-id 读面,配错的
136
+ * `{runId, session}` 组合在 session-bound run 上 fail-closed 404);
137
+ * · 台账**有这一行但没捕到会话**(fleet 行帧腿/通知腿今天不传 session)⇒ **不带 `?session=`**,
138
+ * 🔴 **绝不拿「此刻的会话」去猜** —— resume 是有副作用的动作(AT-MOST-ONCE,叫醒 = 真跑一轮),
139
+ * 猜错会话可能落到**另一个会话的同名子代**上,那是不可回收的伤害;不带参数的失败只是 server
140
+ * fail-closed 的干净 404,已有 `not-found` 出路文案接。与本模块「错值比缺席更坏」同一条口径。
141
+ * · 调用方**完全没指名行**(无行上下文的通用调用)⇒ 才退当前会话(既有口径不变)。
142
+ * ⚠️ 供给面欠账:fleet/通知两腿手里没有流会话,补上之后第二态自然变成第一态,判据无需再改。
143
+ */
144
+ export declare function resolveSubagentResumeContext(childTaskId?: string): SubagentResumeContext;
145
+ /**
146
+ * 叫醒一个已 settle 的保留子代。
147
+ *
148
+ * @param target parentToolCallId 或 agentName(与 `steerEngineSubagent` 同一取址口径;
149
+ * 同名多个活体 ⇒ server 409 `steering.ambiguous_target`)。
150
+ * 🔴 AT-MOST-ONCE:server 明写不重试。本层因此**不重试任何一格** —— 「叫醒」是有副作用的动作,
151
+ * 重投一次可能真的多跑一轮。
152
+ */
153
+ export declare function resumeSettledSubagent(facade: SubagentResumeFacade | null, runId: string | undefined, target: string, content: string, opts?: {
154
+ session?: string;
155
+ signal?: AbortSignal;
156
+ }): Promise<SubagentResumeOutcome>;
157
+ /**
158
+ * {@link auditRetainWithoutWake} 的判决(codex 复审 low 收紧的**返回契约**)。
159
+ *
160
+ * 两位分别回答两个不同的问题,**不许合成一个**:
161
+ * · `message` = 组合成不成立(成立才有这条记录;`undefined` 返回 = 不成立);
162
+ * · `emitted` = **本次**响没响(per-baseUrl once 去重命中时为 `false`)。
163
+ * 旧形(去重命中仍回正文)有双义:按返回值呈现的调用方会**重复提示**,而「至多一次」的承诺
164
+ * 只兑现在日志上。调用方要「只提示一次」就看 `emitted`,要「组合还成不成立」就看有没有记录。
165
+ */
166
+ export interface RetainWithoutWakeVerdict {
167
+ message: string;
168
+ emitted: boolean;
169
+ }
170
+ /** {@link auditRetainWithoutWake} 的入参(命名形:导出签名里不留 ≥3 成员的内联匿名形)。 */
171
+ export interface RetainWithoutWakeInput {
172
+ baseUrl?: string | undefined;
173
+ /**
174
+ * 本端的 `ClientSliceLike` 实现(或它的任意子集)。类型刻意窄到 `wakeSubagent` 这一位的**函数
175
+ * 形**而不是 `unknown` —— 判据是「这个 verb 实现了没有」,而 `unknown` 会把「端传了个字符串」
176
+ * 也算成合法入参(边界层的 unknown 必须在本模块内窄化,typeshape 门守的正是这条)。
177
+ */
178
+ slice?: {
179
+ wakeSubagent?: ((input: {
180
+ taskId: string;
181
+ message: string;
182
+ }) => Promise<void>) | undefined;
183
+ } | null | undefined;
184
+ retainRequested?: boolean | undefined;
185
+ }
186
+ /**
187
+ * 「引擎有复活口 + 本端在付留存成本 + 本端没有 wake 实现」⇒ 响亮一条(每 baseUrl 至多一次)。
188
+ *
189
+ * 返回 {@link RetainWithoutWakeVerdict}:`undefined` = 组合不成立(没这笔账);有记录 = 组合成立,
190
+ * 其中 `emitted` 说明**本次**响没响。**返回值即判据** —— 只落日志的告警在测试里等于不存在,
191
+ * 而只回一个字符串又分不出「本次响了」与「早响过了」(按返回值呈现的调用方会重复提示)。
192
+ *
193
+ * @param slice 本端的 `ClientSliceLike` 实现(或它的任意子集);`wakeSubagent` 不是函数即算未实现。
194
+ * @param retainRequested 本端 run 请求里的 `retainSubagentSessions`(缺省 true = 包内 taskRequest 的缺省)。
195
+ */
196
+ export declare function auditRetainWithoutWake(input: RetainWithoutWakeInput): RetainWithoutWakeVerdict | undefined;
197
+ /** 测试钩:清 once 去重台账。 */
198
+ export declare function __resetRetainWithoutWakeWarningForTests(): void;
@@ -0,0 +1,210 @@
1
+ import { hostLog } from '../host.js';
2
+ import { engineCapTrue } from '../engineCapsCache.js';
3
+ import { makeEngineWireClient } from '../engineWireSdk.js';
4
+ import { engineWireTarget } from '../engineWireTarget.js';
5
+ import { activeEngineRunId, engineSessionParamSpread } from '../engineSessionParam.js';
6
+ import { getBgParentRunOwner, parentToolCallIdOf, } from '../subagentContentStore.js';
7
+ /** 能力位键(server `routes/capabilities.ts` 的 `subagentResume`;SDK `Capabilities.subagentResume`)。 */
8
+ export const SUBAGENT_RESUME_CAP = 'subagentResume';
9
+ /** 复活口在不在。未判/缺键 = false = 详情面不渲「继续」腿。 */
10
+ export function subagentResumeAvailable(baseUrl = engineWireTarget()?.baseUrl) {
11
+ return engineCapTrue(baseUrl, SUBAGENT_RESUME_CAP);
12
+ }
13
+ /**
14
+ * 「继续」腿的 resume 取址(1.0.74 终扫 P2-11)。
15
+ *
16
+ * 🔴 resume API 的 `target` 契约只认两种取址:委派工具调用的 **parentToolCallId**(一等精确取址,
17
+ * 同型并发子代唯一能分开的键)与**子代显示名**(同名多员 ⇒ server 409 `steering.ambiguous_target`)。
18
+ * 行 taskId 不是契约取址形 —— 传它只会得到一条不带替代路径的歧义/未知错。
19
+ * alias 台账(`registerSubagentAlias`,task_progress 双携建;taskToParent 无删除无淘汰,子代 settle
20
+ * 后仍可取)⇒ 精确取址优先;显示名仅回落(它可能是 'Task' 字面占位,同名多员的 409 由每格出路
21
+ * 文案接);两支皆缺 ⇒ undefined,调用方按诚实缺席**不渲入口**。
22
+ */
23
+ export function resolveSubagentContinueTarget(rowId, displayName) {
24
+ const precise = parentToolCallIdOf(rowId);
25
+ if (precise !== undefined && precise !== '')
26
+ return precise;
27
+ const name = typeof displayName === 'string' ? displayName.trim() : '';
28
+ return name !== '' ? name : undefined;
29
+ }
30
+ /** 留存失效族(三码同处置:留存曾经在、现在没了;文案一致,留痕分得开)。 */
31
+ const RETENTION_LAPSED_CODES = new Set([
32
+ 'resume.evicted',
33
+ 'resume.cap',
34
+ 'resume.session_not_found',
35
+ ]);
36
+ function shapeOf(e) {
37
+ const o = (e ?? {});
38
+ return {
39
+ ...(typeof o.status === 'number' ? { status: o.status } : {}),
40
+ ...(typeof o.errorCode === 'string' ? { errorCode: o.errorCode } : {}),
41
+ message: typeof o.message === 'string' && o.message !== '' ? o.message : String(e),
42
+ };
43
+ }
44
+ /**
45
+ * resume 失败 → 处置分类。机器轴 = `errorCode`(开集);`status` 只用于**码缺席**时的两格粗分
46
+ * (404 无存在性谕示 / 400 入参),绝不用数字去猜某个具体 409 成因(六个 409 只有 `errorCode`
47
+ * 分得开,按数字分支等于把六种处置压成一种)。
48
+ */
49
+ export function classifySubagentResumeFailure(e) {
50
+ const { status, errorCode, message } = shapeOf(e);
51
+ if (errorCode === 'resume.retain_off')
52
+ return { reason: 'retain-off', detail: message };
53
+ if (errorCode !== undefined && RETENTION_LAPSED_CODES.has(errorCode)) {
54
+ return { reason: 'retention-lapsed', detail: message };
55
+ }
56
+ if (errorCode === 'steering.still_running')
57
+ return { reason: 'still-running', detail: message };
58
+ if (errorCode === 'steering.not_running')
59
+ return { reason: 'not-resumable', detail: message };
60
+ if (errorCode === 'steering.ambiguous_target')
61
+ return { reason: 'ambiguous-target', detail: message };
62
+ if (errorCode === undefined && status === 404)
63
+ return { reason: 'not-found', detail: message };
64
+ if (errorCode === undefined && status === 400)
65
+ return { reason: 'bad-request', detail: message };
66
+ if (errorCode?.startsWith('not_found.') === true)
67
+ return { reason: 'not-found', detail: message };
68
+ return { reason: 'error', detail: message };
69
+ }
70
+ /**
71
+ * 「这一行子代该打到哪条 run」——**纯函数,单点判据**(对抗复审 H1 的修 + 它的可证伪点)。
72
+ *
73
+ * 🔴 **按行取,不取「此刻在飞的那条」**:`activeEngineRunId()` 是 turn-scoped(壳 `seamQuery` 在
74
+ * turn 收尾 `setActiveEngineTaskId(null)`),而「继续」这个动作按定义只在子代**已 settle** 之后
75
+ * 才出现 —— 那时它恒空;更坏的一支是另一条 turn 正飞时它有值但**是别的 run**,请求会打到不
76
+ * 相干的 run 上(404,或叫醒那条 run 里的同名子代)。判据必须锚在决定量(这一行的宿主 run)
77
+ * 上,不是锚在「有没有 run 在飞」这个前置条件上([anchor-on-the-deciding-quantity])。
78
+ *
79
+ * 三态:
80
+ * · 台账有这一行 ⇒ 用台账值(**哪怕此刻另有 run 在飞**);
81
+ * · 台账没有、且调用方**指名了某一行** ⇒ `undefined`(诚实不知道 ⇒ no-run 出路),绝不回落成在飞的那条;
82
+ * · 调用方**没指名行**(无行上下文的通用调用)⇒ 才回落到在飞的那条。
83
+ *
84
+ * @param activeRunId 注入缝(缺省 = `activeEngineRunId()`)—— 判据要能在没有 SessionPort 的纯层里验。
85
+ */
86
+ export function resolveOwnerRunId(childTaskId, activeRunId = activeEngineRunId() ?? undefined) {
87
+ const owner = getBgParentRunOwner(childTaskId);
88
+ if (owner !== undefined)
89
+ return owner.runId;
90
+ if (childTaskId !== undefined && childTaskId !== '')
91
+ return undefined;
92
+ return activeRunId;
93
+ }
94
+ /**
95
+ * 产品 facade + 运行定位。**逐字复用 `steerEngineSubagent` 的三步**(engineWireTarget → 无则 no-wire;
96
+ * 取运行 → 无则 no-run;makeEngineWireClient)—— 两条腿共用同一套装配,免得 steer 能到达而 resume
97
+ * 到不了(或反过来),那种不对称在用户面上表现为「同一个 agent 有时能说话有时不能」。
98
+ *
99
+ * 🔴 `session` 的三态(#242 批 2 定口径,codex 复审 medium 收紧):
100
+ * · 台账有这一行**且**捕到了它的会话 ⇒ 用**行登记的**会话(resume 是 per-id 读面,配错的
101
+ * `{runId, session}` 组合在 session-bound run 上 fail-closed 404);
102
+ * · 台账**有这一行但没捕到会话**(fleet 行帧腿/通知腿今天不传 session)⇒ **不带 `?session=`**,
103
+ * 🔴 **绝不拿「此刻的会话」去猜** —— resume 是有副作用的动作(AT-MOST-ONCE,叫醒 = 真跑一轮),
104
+ * 猜错会话可能落到**另一个会话的同名子代**上,那是不可回收的伤害;不带参数的失败只是 server
105
+ * fail-closed 的干净 404,已有 `not-found` 出路文案接。与本模块「错值比缺席更坏」同一条口径。
106
+ * · 调用方**完全没指名行**(无行上下文的通用调用)⇒ 才退当前会话(既有口径不变)。
107
+ * ⚠️ 供给面欠账:fleet/通知两腿手里没有流会话,补上之后第二态自然变成第一态,判据无需再改。
108
+ */
109
+ export function resolveSubagentResumeContext(childTaskId) {
110
+ const cfg = engineWireTarget();
111
+ if (!cfg)
112
+ return { facade: null, runId: undefined, session: {} };
113
+ const client = makeEngineWireClient({
114
+ baseUrl: cfg.baseUrl,
115
+ ...(cfg.token ? { token: cfg.token } : {}),
116
+ ...(cfg.principal !== undefined ? { principal: cfg.principal } : {}),
117
+ });
118
+ if (!client)
119
+ return { facade: null, runId: undefined, session: {} };
120
+ const named = childTaskId !== undefined && childTaskId !== '';
121
+ const owner = getBgParentRunOwner(childTaskId);
122
+ const sessionFromRow = owner?.sessionId;
123
+ const session = sessionFromRow !== undefined
124
+ ? { session: sessionFromRow }
125
+ : named
126
+ ? {} // 指名了行却没有它的会话 ⇒ 诚实缺席,绝不用当前会话冒充
127
+ : engineSessionParamSpread();
128
+ return { facade: client.runs, runId: resolveOwnerRunId(childTaskId), session };
129
+ }
130
+ /** UNTRUSTED 串的呈前消毒。 */
131
+ function clean(v, max = 200) {
132
+ // eslint-disable-next-line no-control-regex
133
+ return String(v ?? '').replace(/[\x00-\x1f\x7f]/g, '.').slice(0, max);
134
+ }
135
+ /**
136
+ * 叫醒一个已 settle 的保留子代。
137
+ *
138
+ * @param target parentToolCallId 或 agentName(与 `steerEngineSubagent` 同一取址口径;
139
+ * 同名多个活体 ⇒ server 409 `steering.ambiguous_target`)。
140
+ * 🔴 AT-MOST-ONCE:server 明写不重试。本层因此**不重试任何一格** —— 「叫醒」是有副作用的动作,
141
+ * 重投一次可能真的多跑一轮。
142
+ */
143
+ export async function resumeSettledSubagent(facade, runId, target, content, opts) {
144
+ if (facade === null)
145
+ return { ok: false, reason: 'no-wire', detail: 'no engine wire' };
146
+ if (runId === undefined || runId === '')
147
+ return { ok: false, reason: 'no-run', detail: 'no active engine run' };
148
+ const text = content.trim();
149
+ if (text === '')
150
+ return { ok: false, reason: 'bad-request', detail: 'empty message' };
151
+ try {
152
+ const receipt = await facade.resumeSubagent(runId, target, { content: text }, opts);
153
+ const note = receipt?.note;
154
+ return {
155
+ ok: true,
156
+ receipt: typeof note === 'string' && note !== ''
157
+ ? clean(note, 240)
158
+ : 'Message queued for delivery; the agent will continue in the background.',
159
+ };
160
+ }
161
+ catch (e) {
162
+ const f = classifySubagentResumeFailure(e);
163
+ hostLog('debug', `engineSubagentResume: resume(${clean(target, 64)}) → ${f.reason} (${clean(f.detail, 160)})`);
164
+ return { ok: false, ...f };
165
+ }
166
+ }
167
+ // ── caps × verb 组合告警([4000] Q5 裁定的另一半)────────────────────────────────────────────
168
+ // Q5 裁定:`ClientSliceLike.wakeSubagent` 在 `CLIENT_VERBS` 表里**维持 `required:false`**
169
+ // (fail-soft —— 不实现只是少一个入口,不丢已发生的行为);但「引擎这边留存在付费、客户端这边
170
+ // 一个兑现口都没有」这个**组合**必须响亮说出来,否则它就是一笔谁也看不见的常驻成本。
171
+ //
172
+ // 🔴 键名订正(勘设计稿措辞):caps 上**没有** `retainSubagentSessions` 这个位 ——
173
+ // `retainSubagentSessions` 是**run 请求位**(`request/taskRequest.ts:118/320`,壳默认 true),
174
+ // 引擎宣示兑现口的 caps 位是 **`subagentResume`**(SDK `Capabilities.subagentResume`,
175
+ // `types.d.ts:1879` 逐字「还需 run 侧 retainSubagentSessions」)。所以本告警的判据是
176
+ // **两半都在场**:caps 宣示 `subagentResume` ∧ 本端的 run 请求真在打 retain ⇒ 成本真在付。
177
+ const warnedRetainWithoutWake = new Set();
178
+ /**
179
+ * 「引擎有复活口 + 本端在付留存成本 + 本端没有 wake 实现」⇒ 响亮一条(每 baseUrl 至多一次)。
180
+ *
181
+ * 返回 {@link RetainWithoutWakeVerdict}:`undefined` = 组合不成立(没这笔账);有记录 = 组合成立,
182
+ * 其中 `emitted` 说明**本次**响没响。**返回值即判据** —— 只落日志的告警在测试里等于不存在,
183
+ * 而只回一个字符串又分不出「本次响了」与「早响过了」(按返回值呈现的调用方会重复提示)。
184
+ *
185
+ * @param slice 本端的 `ClientSliceLike` 实现(或它的任意子集);`wakeSubagent` 不是函数即算未实现。
186
+ * @param retainRequested 本端 run 请求里的 `retainSubagentSessions`(缺省 true = 包内 taskRequest 的缺省)。
187
+ */
188
+ export function auditRetainWithoutWake(input) {
189
+ const baseUrl = input.baseUrl ?? engineWireTarget()?.baseUrl;
190
+ if (!engineCapTrue(baseUrl, SUBAGENT_RESUME_CAP))
191
+ return undefined;
192
+ if (input.retainRequested === false)
193
+ return undefined;
194
+ if (typeof input.slice?.wakeSubagent === 'function')
195
+ return undefined;
196
+ const message = 'client-core: engine advertises capabilities.subagentResume and this client requests ' +
197
+ 'retainSubagentSessions, but the client slice implements no wakeSubagent verb — retained ' +
198
+ 'subagent sessions are being paid for with no way to redeem them (CLIENT_VERBS.wakeSubagent ' +
199
+ 'stays fail-soft by design; wire an entry point or stop requesting retention).';
200
+ const key = baseUrl ?? '<no-base>';
201
+ if (warnedRetainWithoutWake.has(key))
202
+ return { message, emitted: false };
203
+ warnedRetainWithoutWake.add(key);
204
+ hostLog('warn', message);
205
+ return { message, emitted: true };
206
+ }
207
+ /** 测试钩:清 once 去重台账。 */
208
+ export function __resetRetainWithoutWakeWarningForTests() {
209
+ warnedRetainWithoutWake.clear();
210
+ }
@@ -114,6 +114,11 @@ export function tailEngineSubagent(taskId) {
114
114
  const cfg = engineWireTarget();
115
115
  if (!cfg)
116
116
  return;
117
+ // 🔴 候裁([4000] Q3 / design-242 §5 Q3,#242 批 2 **刻意不动**):这条 `?? activeEngineRunId()`
118
+ // 与 resume 腿的 `resolveOwnerRunId`(指名了行就诚实缺席,绝不回落)口径**相反**。壳侧对抗
119
+ // 复审的结论是「错值比缺席更坏」;统一到那个口径 ⇒ 台账缺席时本腿从「能连上(可能连错 run)」
120
+ // 变成「不连」= 真行为翻面,需三端(cli/web/desktop)表态后单批改。同族另两处:
121
+ // `engineTaskHandleWire.ts:resolveHostRun` / `engineSubagentOutput.ts:fetchEngineSubagentReport`。
117
122
  const runId = getBgParentRun(taskId) ?? activeEngineRunId();
118
123
  if (!runId)
119
124
  return;
@@ -57,6 +57,9 @@ import { STOP_CONFLICT_CODES, STOP_NOT_LANDED, STOP_NOT_LOCAL, STOP_PARKED, STOP
57
57
  /** 宿主 run 解析:fleet 行/通知帧喂的 parent 映射优先(行 id=`${runId} ${handle}` 的 parentId
58
58
  * 投影),缺席退当前交互 run(register 时刻的活跃 run 即宿主——engineSide bash 行的出生形)。 */
59
59
  function resolveHostRun(handle) {
60
+ // 🔴 候裁([4000] Q3 / design-242 §5 Q3,#242 批 2 **刻意不动**):这条回落与 resume 腿的
61
+ // `resolveOwnerRunId`(指名了行就诚实缺席)口径相反;统一口径是真行为翻面,候三端表态。
62
+ // 同族另两处:`engineSubagentTail.ts:tailEngineSubagent` / `engineSubagentOutput.ts`。
60
63
  return getBgParentRun(handle) ?? activeEngineRunId() ?? null;
61
64
  }
62
65
  // 游标累积(按 handle;进程内存态,与查看态生命周期同级——端重启即空,重读从引擎再取)
@@ -192,8 +192,56 @@ export interface BgTerminalFacts {
192
192
  }
193
193
  export declare function recordBgTerminalFacts(taskId: string, facts: BgTerminalFacts): void;
194
194
  export declare function getBgTerminalFacts(taskId: string): BgTerminalFacts | undefined;
195
- export declare function recordBgParentRun(taskId: string, runId: string): void;
195
+ /** 一行子代的宿主记录。`sessionId` = 登记时刻那条流的会话(per-id 读面要拼 `?session=`) */
196
+ export interface SubagentOwnerRecord {
197
+ /** 宿主引擎 run id(resume / subagentOutput / tail 三条读面寻址的第一段)。 */
198
+ runId: string;
199
+ /** 登记时的会话 id;缺席即不带 `?session=`(空串按「带了且不匹配」处理 = 404,恒不铸空串)。 */
200
+ sessionId?: string;
201
+ }
202
+ /**
203
+ * fleet 行帧 / bg 通知腿的登记口(既有两写点)。
204
+ * `sessionId` 是 #242 批 2 additive 补位:缺席则不落键(读面据此判「不带 `?session=`」)。
205
+ */
206
+ export declare function recordBgParentRun(taskId: string, runId: string, sessionId?: string): void;
207
+ /**
208
+ * 「这一行子代的宿主 run」统一登记口(**显式传值**,零推断,见本段头注)。
209
+ * `runId` 缺席/空串 ⇒ 什么都不记(宁可「不知道」也不记一个错的宿主);`taskId === runId`
210
+ * 的退化自指映射同样拒收(顶层 run 行不是任何人的子代)。
211
+ *
212
+ * 🔴 **`sessionId` 的缺席不覆盖在场**(合表的承重口径,#242 批 2 自查抓出):喂本表的三条腿里
213
+ * **只有 tick 腿带 session**(它在开流时一次性捕获),fleet 行帧腿与 bg 通知腿都不带。合表前
214
+ * 两表各记各的,合表后若让「后到的无 session 写」直接盖掉整条记录,tick 捕获的会话就会被抹掉,
215
+ * resume 退回「此刻的会话」—— 那正是对抗复审轮5 M1 要防的错组合(旧 runId + 新 sessionId ⇒
216
+ * session-bound run 上 fail-closed 404)。
217
+ * 口径:**同一 `runId` 上缺席不覆盖在场**(带值的写照常赢);`runId` 真变了 ⇒ 旧 session 必须丢
218
+ * (那是另一条宿主 run 的会话,留着就是错组合)。
219
+ */
220
+ export declare function recordSubagentOwner(taskId: string, runId?: string, sessionId?: string): void;
221
+ /**
222
+ * `task_progress` 腿的登记口(#242 批 2 ①:收编前本表**零 task_progress 腿**,已直证)。
223
+ *
224
+ * 一条帧进,认得就记,认不得就什么都不做(**绝不改帧**)。只认 `task_progress` —— 引擎侧子代的
225
+ * 累计 tick,带子代自己的 `taskId`。
226
+ *
227
+ * 🔴 **`ownerRunId` / `ownerSessionId` 必须由调用方显式喂**,本函数**不许**从
228
+ * `activeEngineRunId()` 推断(壳 `subagentOwnerLedger.ts:20-22` 对抗复审轮3 H2 实撞的教训:
229
+ * 进程级值被并发 main/fork 流互相覆写 + 每个 query 的 finally 清空 ⇒ run A 的 tick 可能被
230
+ * 登记到 run B 名下,而错值比缺席更坏)。登记点必须在**持有 stream-local run id 的那个闭包**
231
+ * 里(壳 = `liveClient.ts` 的 `stampSubagentOwners`),把不可变的值传进来。
232
+ * 🔴 `ownerSessionId` 同理**开流时一次性捕获**,不逐帧现读:逐帧现读会让 `/clear`、resume、
233
+ * 会话切换之后晚到的旧流 tick 被登记成 `{旧 runId, 新 sessionId}`,这对组合去 resume 会带着
234
+ * 错 session 打旧 run,fail-closed 404。
235
+ */
236
+ export declare function recordSubagentOwnerFromProgress(ev: unknown, ownerRunId?: string, ownerSessionId?: string): void;
196
237
  export declare function getBgParentRun(taskId: string): string | undefined;
238
+ /**
239
+ * 整条宿主记录(runId + 登记时会话)。undefined = **诚实不知道**,调用方渲 no-run 出路 /
240
+ * 不渲入口 —— 绝不在这里回落成「此刻在飞的那条 run」([anchor-on-the-deciding-quantity])。
241
+ */
242
+ export declare function getBgParentRunOwner(taskId: string | undefined): SubagentOwnerRecord | undefined;
243
+ /** 测试钩:清宿主台账(module 级单例,同进程多组断言必须能清)。 */
244
+ export declare function __resetSubagentOwnerLedgerForTests(): void;
197
245
  export declare function recordOwnEngineRun(runId: string): void;
198
246
  export declare function isOwnEngineRun(runId: string): boolean;
199
247
  /** Single consumer (the panel hook). Returns an unsubscribe. */