@sema-agent/client-core 0.44.0 → 0.46.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.
- package/CHANGELOG.md +515 -1
- package/README.md +2 -1
- package/dist/adapt/arms.js +33 -0
- package/dist/adapt.d.ts +1 -1
- package/dist/adapt.js +2 -0
- package/dist/adapter/downstream/eventToSdkMessage.js +82 -0
- package/dist/engineSessionParam.d.ts +27 -1
- package/dist/engineSessionParam.js +37 -6
- package/dist/fleet/fleetLedger.d.ts +174 -18
- package/dist/fleet/fleetLedger.js +321 -26
- package/dist/hitl/planReviewWire.d.ts +1 -1
- package/dist/hitl/planReviewWire.js +44 -1
- package/dist/printToolResultFrame.js +6 -1
- package/dist/seam.d.ts +41 -1
- package/dist/seam.js +4 -0
- package/dist/subagent/engineCompactWire.d.ts +32 -5
- package/dist/subagent/engineCompactWire.js +129 -48
- package/dist/subagent/engineDelegatedPrompt.js +9 -4
- package/dist/subagent/engineRowStopGate.d.ts +10 -3
- package/dist/subagent/engineRowStopGate.js +15 -6
- package/dist/subagent/engineSubagentOutput.js +12 -6
- package/dist/subagent/engineSubagentResume.d.ts +53 -3
- package/dist/subagent/engineSubagentResume.js +57 -14
- package/dist/subagent/engineSubagentSteer.js +20 -11
- package/dist/subagent/engineSubagentTail.js +18 -6
- package/dist/subagent/engineTaskHandleWire.js +33 -10
- package/dist/subagentContentStore.d.ts +57 -4
- package/dist/subagentContentStore.js +152 -13
- package/dist/toolResult.js +22 -3
- package/dist/wireErrorTriage.d.ts +41 -0
- package/dist/wireErrorTriage.js +88 -1
- package/docs/INTEGRATION-CLIENTS.md +152 -21
- package/package.json +1 -1
|
@@ -1,14 +1,22 @@
|
|
|
1
1
|
import { hostLog } from '../host.js';
|
|
2
2
|
import { engineCapTrue } from '../engineCapsCache.js';
|
|
3
3
|
import { makeEngineWireClient } from '../engineWireSdk.js';
|
|
4
|
-
import {
|
|
5
|
-
import { activeEngineRunId,
|
|
4
|
+
import { engineWireTargetFor } from '../engineWireTarget.js';
|
|
5
|
+
import { activeEngineRunId, engineSessionParamFor } from '../engineSessionParam.js';
|
|
6
|
+
import { DEFAULT_SESSION_KEY } from '../sessionSlot.js';
|
|
6
7
|
import { getBgParentRunOwner, parentToolCallIdOf, } from '../subagentContentStore.js';
|
|
7
8
|
/** 能力位键(server `routes/capabilities.ts` 的 `subagentResume`;SDK `Capabilities.subagentResume`)。 */
|
|
8
9
|
export const SUBAGENT_RESUME_CAP = 'subagentResume';
|
|
9
|
-
/**
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
/**
|
|
11
|
+
* 复活口在不在。未判/缺键 = false = 详情面不渲「继续」腿。
|
|
12
|
+
*
|
|
13
|
+
* 🔴 design/285 批 3(D7b):`baseUrl` 缺席时的派生从零参 `engineWireTarget()`(写死默认槽)换成
|
|
14
|
+
* `engineWireTargetFor(sessionKey)`。**本腿没有行上下文**(它答的是「这台引擎有没有这个口」,
|
|
15
|
+
* 不是「这一行该打到哪」)⇒ 槽键只能由调用方给,缺省 `DEFAULT_SESSION_KEY` = 与 0.44.0 逐字同。
|
|
16
|
+
* 显式 `baseUrl` 恒赢(既有注入缝不动;空串仍原样保留 —— `??` 只对 `undefined` 让位,与默认参形同)。
|
|
17
|
+
*/
|
|
18
|
+
export function subagentResumeAvailable(baseUrl, sessionKey = DEFAULT_SESSION_KEY) {
|
|
19
|
+
return engineCapTrue(baseUrl ?? engineWireTargetFor(sessionKey)?.baseUrl, SUBAGENT_RESUME_CAP);
|
|
12
20
|
}
|
|
13
21
|
/**
|
|
14
22
|
* 「继续」腿的 resume 取址(1.0.74 终扫 P2-11)。
|
|
@@ -90,13 +98,35 @@ export function classifySubagentResumeFailure(e) {
|
|
|
90
98
|
*
|
|
91
99
|
* @param activeRunId 注入缝(缺省 = `activeEngineRunId()`)—— 判据要能在没有 SessionPort 的纯层里验。
|
|
92
100
|
*/
|
|
93
|
-
export function resolveOwnerRunId(childTaskId, activeRunId
|
|
101
|
+
export function resolveOwnerRunId(childTaskId, activeRunId) {
|
|
94
102
|
const owner = getBgParentRunOwner(childTaskId);
|
|
95
103
|
if (owner !== undefined)
|
|
96
104
|
return owner.runId;
|
|
97
105
|
if (childTaskId !== undefined && childTaskId !== '')
|
|
98
106
|
return undefined;
|
|
99
|
-
|
|
107
|
+
// 🔴 **惰性求值**(design/285 批 3,异源对抗复审 [medium] 采纳):此前 `activeRunId` 是**默认参**,
|
|
108
|
+
// 于是**每一次**调用都先读一遍**默认槽**的 `SessionPort` —— 哪怕台账里明明有这一行、第三态根本
|
|
109
|
+
// 走不到。批 3 之后 keyed 宿主是成文支持的路径,那次白读会给 keyed-only 宿主(默认槽本就没装)
|
|
110
|
+
// 刷 `hostPortMisses()`,而接入档要求宿主的自检**恒空**:一个恒响的假警报会把真漏装淹掉。
|
|
111
|
+
// 注入语义一字未变(传值即用;传 `undefined` 与不传都落到这条真读)。
|
|
112
|
+
return activeRunId ?? activeEngineRunId();
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* 「这一行子代属于哪个会话槽」—— design/285 批 3(D6+D7b)读侧的**一次读**。
|
|
116
|
+
*
|
|
117
|
+
* 五个读点(tail / output / taskHandle 两口 / delegatedPrompt)此前各自读一次台账,而 wire target
|
|
118
|
+
* 一律走零参 `engineWireTarget()`(= 默认槽)。keyed 多会话宿主上那意味着「会话对了、打的服务器与
|
|
119
|
+
* 凭证可能还是别人的」(D7b)。本函数把「取哪一行的记录 + 它属于哪个槽」收成一处;
|
|
120
|
+
* `key` 缺席回落默认槽 ⇒ `engineWireTargetFor(DEFAULT_SESSION_KEY)` ≡ `engineWireTarget()`,
|
|
121
|
+
* 单会话宿主逐字同。
|
|
122
|
+
*
|
|
123
|
+
* 🔴 **会话策略刻意留在各腿**(不在这里合并):steer/tail/output 是二态(行的会话优先,缺则退现势),
|
|
124
|
+
* resume 是三态(指名了行却没有会话 ⇒ 干脆不带,AT-MOST-ONCE 不许猜)。收成一份就必然要加一个
|
|
125
|
+
* 「模式」参数,那等于把两条**有意不同**的口径伪装成同一个函数的两种配置。
|
|
126
|
+
*/
|
|
127
|
+
export function resolveOwnerContext(childTaskId) {
|
|
128
|
+
const record = getBgParentRunOwner(childTaskId);
|
|
129
|
+
return { record, runId: record?.runId, key: record?.sessionKey ?? DEFAULT_SESSION_KEY };
|
|
100
130
|
}
|
|
101
131
|
/**
|
|
102
132
|
* 产品 facade + 运行定位。**逐字复用 `steerEngineSubagent` 的三步**(engineWireTarget → 无则 no-wire;
|
|
@@ -111,10 +141,20 @@ export function resolveOwnerRunId(childTaskId, activeRunId = activeEngineRunId()
|
|
|
111
141
|
* 猜错会话可能落到**另一个会话的同名子代**上,那是不可回收的伤害;不带参数的失败只是 server
|
|
112
142
|
* fail-closed 的干净 404,已有 `not-found` 出路文案接。与本模块「错值比缺席更坏」同一条口径。
|
|
113
143
|
* · 调用方**完全没指名行**(无行上下文的通用调用)⇒ 才退当前会话(既有口径不变)。
|
|
114
|
-
* ⚠️
|
|
144
|
+
* ⚠️ 供给面欠账(**已由 design/285 批 2 补上**):fleet/通知两腿此前手里没有流会话;ingress 落地后
|
|
145
|
+
* 它们按 `{runId, sessionId, sessionKey}` 三值原子写,第二态自然变成第一态,判据一字未改 ——
|
|
146
|
+
* 这正是本注 0.30.x 时预告过的那一步。
|
|
147
|
+
*
|
|
148
|
+
* 🔴 design/285 批 3(D7b):wire target 从零参 `engineWireTarget()`(默认槽)换成
|
|
149
|
+
* `engineWireTargetFor(owner.sessionKey ?? DEFAULT_SESSION_KEY)`。会话对了而 baseUrl/token/
|
|
150
|
+
* principal 还是别人的,等于把 resume 这条 AT-MOST-ONCE 的动作发到另一台引擎上。
|
|
151
|
+
* 第三态(没指名行)的现势会话回落**同样按 key**(`engineSessionParamFor(key)`)—— 没指名行时
|
|
152
|
+
* key 恒是默认槽,与 0.44.0 逐字同。
|
|
115
153
|
*/
|
|
116
154
|
export function resolveSubagentResumeContext(childTaskId) {
|
|
117
|
-
|
|
155
|
+
// 记录先取(纯台账读):`key` 决定 wire target 取哪个槽,所以它必须排在 cfg 之前。
|
|
156
|
+
const { record: owner, key } = resolveOwnerContext(childTaskId);
|
|
157
|
+
const cfg = engineWireTargetFor(key);
|
|
118
158
|
if (!cfg)
|
|
119
159
|
return { facade: null, runId: undefined, session: {} };
|
|
120
160
|
const client = makeEngineWireClient({
|
|
@@ -125,13 +165,16 @@ export function resolveSubagentResumeContext(childTaskId) {
|
|
|
125
165
|
if (!client)
|
|
126
166
|
return { facade: null, runId: undefined, session: {} };
|
|
127
167
|
const named = childTaskId !== undefined && childTaskId !== '';
|
|
128
|
-
const owner = getBgParentRunOwner(childTaskId);
|
|
129
168
|
const sessionFromRow = owner?.sessionId;
|
|
169
|
+
// 第三态(没指名行)的现势会话回落**也按 key** —— 没指名行时 key 恒是默认槽,与 0.44.0 逐字同。
|
|
170
|
+
const fallback = named ? undefined : engineSessionParamFor(key);
|
|
130
171
|
const session = sessionFromRow !== undefined
|
|
131
172
|
? { session: sessionFromRow }
|
|
132
|
-
:
|
|
133
|
-
? {} //
|
|
134
|
-
:
|
|
173
|
+
: fallback !== undefined
|
|
174
|
+
? { session: fallback } // 没指名行 ⇒ 退当前会话(既有第三态口径不变)
|
|
175
|
+
: {}; // 指名了行却没有它的会话 ⇒ 诚实缺席,绝不用当前会话冒充
|
|
176
|
+
// 取址三态的唯一判据(含「没指名行才回落在飞 run」那一态);刻意排在 wire 两道闸**之后**,
|
|
177
|
+
// 与 0.44.0 求值序逐字同 —— 无 wire 时一次 SessionPort 都不读。
|
|
135
178
|
return { facade: client.runs, runId: resolveOwnerRunId(childTaskId), session };
|
|
136
179
|
}
|
|
137
180
|
/** UNTRUSTED 串的呈前消毒。 */
|
|
@@ -193,7 +236,7 @@ const warnedRetainWithoutWake = new Set();
|
|
|
193
236
|
* @param retainRequested 本端 run 请求里的 `retainSubagentSessions`(缺省 true = 包内 taskRequest 的缺省)。
|
|
194
237
|
*/
|
|
195
238
|
export function auditRetainWithoutWake(input) {
|
|
196
|
-
const baseUrl = input.baseUrl ??
|
|
239
|
+
const baseUrl = input.baseUrl ?? engineWireTargetFor(input.sessionKey ?? DEFAULT_SESSION_KEY)?.baseUrl;
|
|
197
240
|
if (!engineCapTrue(baseUrl, SUBAGENT_RESUME_CAP))
|
|
198
241
|
return undefined;
|
|
199
242
|
if (input.retainRequested === false)
|
|
@@ -26,10 +26,9 @@
|
|
|
26
26
|
*/
|
|
27
27
|
import { hostLog } from '../host.js';
|
|
28
28
|
import { makeEngineWireClient } from '../engineWireSdk.js';
|
|
29
|
-
import {
|
|
30
|
-
import {
|
|
31
|
-
import {
|
|
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
|
-
//
|
|
84
|
-
|
|
85
|
-
const
|
|
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 {
|
|
53
|
-
import {
|
|
54
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
44
|
-
import {
|
|
45
|
-
import {
|
|
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 =
|
|
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
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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
|
-
|
|
199
|
-
|
|
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
|
-
|
|
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
|
|
429
|
-
|
|
430
|
-
|
|
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 帧绑定,本壳每个交互
|