@sema-agent/client-core 0.44.0 → 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.
- package/CHANGELOG.md +495 -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/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/docs/INTEGRATION-CLIENTS.md +152 -21
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { hostLog } from '../host.js';
|
|
2
2
|
import { engineWireDebugEnabled } from '../engineWireTarget.js';
|
|
3
|
-
import { engineSessionParam } from '../engineSessionParam.js';
|
|
3
|
+
import { engineSessionParam, engineSessionParamFor } from '../engineSessionParam.js';
|
|
4
4
|
// `isHookNoticeFrame` 不在这里 re-export —— 它已由 index.ts 的 `export * from './notifications.js'`
|
|
5
5
|
// 出口(重复 re-export 只会让同名绑定在包根变含糊)。端要先分诊再喂台账,从包根 import 即可。
|
|
6
6
|
import { enqueueBgChildNotification, isOwnWorkflowRun } from '../notifications.js';
|
|
@@ -87,12 +87,18 @@ export function __resetFleetLedgerRegistryForTests() {
|
|
|
87
87
|
/**
|
|
88
88
|
* `client.fleet.stream(...)` 的会话过滤参数。缺席(端未装 SessionPort / 还没有会话 id)⇒ `{}`
|
|
89
89
|
* = principal 级视图,**与 1.37 之前逐字同行为**(不是错误态)。
|
|
90
|
+
*
|
|
91
|
+
* 🔴 **默认槽形**(design/285 §2.1):读的是 `DEFAULT_SESSION_KEY` 槽的 `SessionPort`。单会话宿主
|
|
92
|
+
* (cli 及今天的三端)用它**正确**;**keyed 多会话宿主别用它开流** —— 用
|
|
93
|
+
* `createFleetLedger(hooks, { sessionKey }).issueStream()` 拿 `ingress.options`,那是本包对
|
|
94
|
+
* keyed 开流参数的唯一承诺形(理由见文件头 design/285 段)。
|
|
90
95
|
*/
|
|
91
96
|
export function fleetStreamOptions() {
|
|
92
97
|
const s = engineSessionParam();
|
|
93
98
|
return s ? { session: s } : {};
|
|
94
99
|
}
|
|
95
|
-
/** G19:`client.fleet.snapshot(...)` 的参数(
|
|
100
|
+
/** G19:`client.fleet.snapshot(...)` 的参数(同上,同为默认槽形;首屏/温切探针用)。
|
|
101
|
+
* keyed 宿主走 `ledger.issueSnapshot(opts)`。 */
|
|
96
102
|
export function fleetSnapshotOptions(opts) {
|
|
97
103
|
return {
|
|
98
104
|
...fleetStreamOptions(),
|
|
@@ -121,12 +127,49 @@ export function createFleetLedger(hooks = {}, opts = {}) {
|
|
|
121
127
|
/** [2393] notif-F12:另两条丢弃路径的计数(未知帧型 / 隔离优先丢弃)。 */
|
|
122
128
|
let droppedUnknownFrame = 0;
|
|
123
129
|
let droppedForeignBgNotification = 0;
|
|
130
|
+
/** [2393] notif-F12 姊妹位(design/285 批 1):迟到 ingress(旧 epoch / 已关)上的帧丢弃计数。 */
|
|
131
|
+
let droppedStaleIngress = 0;
|
|
124
132
|
let connected = false;
|
|
125
133
|
let version = null;
|
|
126
134
|
let scoped = null;
|
|
127
135
|
let sessionScopedMeta;
|
|
128
136
|
let bgNotifyFailClosedMeta = false;
|
|
129
137
|
let disposed = false;
|
|
138
|
+
// ── design/285 批 1:ingress 发放与代际 ───────────────────────────────────────────────────────
|
|
139
|
+
/** ingress 发放序号(stream 与 snapshot 共用一条单调序列,值本身只用于「是不是当前那一只」)。 */
|
|
140
|
+
let epochSeq = 0;
|
|
141
|
+
/** 当前 live stream ingress 的 epoch;**0 = 从未 `issueStream()`**(宿主还在用兼容入口)。 */
|
|
142
|
+
let liveStreamEpoch = 0;
|
|
143
|
+
/** 当前 live snapshot ingress 的 epoch(与 stream 分开记 —— 快照绝不作废正在跑的流)。 */
|
|
144
|
+
let liveSnapshotEpoch = 0;
|
|
145
|
+
/** 当前 stream ingress 开流那一刻捕获的会话锚(`liveStreamEpoch === 0` 时无意义)。 */
|
|
146
|
+
let streamAnchor;
|
|
147
|
+
/**
|
|
148
|
+
* 本 ledger **有没有采用过 ingress**(对抗复审第九轮 [medium])。
|
|
149
|
+
* 用途:判「兼容入口是不是有一条活流」时,`connected` 只对**纯兼容入口宿主**有权威性 —— 采用了
|
|
150
|
+
* ingress 的宿主,流的存活由 `liveStreamEpoch` 说了算,`connected` 可能是上一条流留下的陈旧值
|
|
151
|
+
* (`close()` 只退流模式,不替宿主改它)。不分开的话,合法的降级序列「开流 → setConnected(true) →
|
|
152
|
+
* close → 取一次性快照兜底」会把那只快照**永久**判过期,SSE 断掉之后的快照恢复路径静默失效。
|
|
153
|
+
*/
|
|
154
|
+
let everIssuedStream = false;
|
|
155
|
+
/**
|
|
156
|
+
* 🔴 **内容归属锚**(对抗复审第二轮 [high]):本 ledger 里现有的行/留存/连接级 meta **是哪个会话的**。
|
|
157
|
+
* 只由**经 ingress 到达**的帧写(兼容入口刻意不写:它每帧现读槽会话,再为它加一次端口读只会给
|
|
158
|
+
* cli 热路径多计一份 miss,而兼容入口按契约本来就只承诺默认槽单会话)。
|
|
159
|
+
* 用途:`issueStream()` 发现新捕获的会话与它不同 ⇒ 这是**换会话**不是重连,旧会话的账必须先清 ——
|
|
160
|
+
* 否则 `status().sessionAnchor` 报 B 而 `project()` 还在渲 A 的行,又是一本读面上看不出来的混合账。
|
|
161
|
+
*/
|
|
162
|
+
let contentAnchor;
|
|
163
|
+
let contentAnchorSet = false;
|
|
164
|
+
/**
|
|
165
|
+
* 🔴 **账本写序号**(对抗复审第五轮 [high]):**任何**入口(ingress 或兼容 `ledger.applyFrame`)成功
|
|
166
|
+
* 落一帧就 +1。快照 ingress 在发放那一刻记下它,落账时变了即判过期。
|
|
167
|
+
* 为什么不能只看 `liveStreamEpoch`:兼容入口消费活流时那个值**恒为 0**,于是「兼容流写 running →
|
|
168
|
+
* 取快照 → 兼容流写 completed → 过期快照 REPLACE 回 running」这条路能整条绕过快照闸。
|
|
169
|
+
* 副产品(有意,已成文):一只快照 ingress 是**一次性**的 —— 落账一次之后序号即变,要再取一次快照
|
|
170
|
+
* 就再 `issueSnapshot()`。
|
|
171
|
+
*/
|
|
172
|
+
let ledgerWrites = 0;
|
|
130
173
|
const debug = (line) => {
|
|
131
174
|
if (engineWireDebugEnabled())
|
|
132
175
|
hostLog('debug', line);
|
|
@@ -186,7 +229,8 @@ export function createFleetLedger(hooks = {}, opts = {}) {
|
|
|
186
229
|
}
|
|
187
230
|
views.add(bgView);
|
|
188
231
|
}
|
|
189
|
-
function applyFrame(frame) {
|
|
232
|
+
function applyFrame(frame, ing) {
|
|
233
|
+
ledgerWrites++; // 账本写序号:全入口通用(见 ledgerWrites 顶注 —— 只看 liveStreamEpoch 会漏掉兼容入口)
|
|
190
234
|
switch (frame.type) {
|
|
191
235
|
case 'meta': {
|
|
192
236
|
version = frame.version;
|
|
@@ -262,7 +306,10 @@ export function createFleetLedger(hooks = {}, opts = {}) {
|
|
|
262
306
|
if (parentOwned)
|
|
263
307
|
recordOwnEngineRun(childTid);
|
|
264
308
|
if (parentOwned || sessionScopedMeta === true) {
|
|
265
|
-
|
|
309
|
+
// 🔴 design/285 批 2(D5):三值原子写 —— 会话与槽键取**本 ingress 开流那一刻的不可变
|
|
310
|
+
// 捕获**,不是现读任何可变槽。兼容入口(`ing === null`)两个新参都是 `undefined`
|
|
311
|
+
// ⇒ 与 0.44.0 逐字同(它每帧现读槽会话、库里不留锚,归属不可知就不替它背书)。
|
|
312
|
+
recordBgParentRun(childTid, parentTid, ing?.session, ing?.sessionKey);
|
|
266
313
|
}
|
|
267
314
|
else {
|
|
268
315
|
debug(`[fleet-frame] task row parent-map SKIPPED foreign/unowned(row=${row.id} parent=${parentTid})`);
|
|
@@ -322,13 +369,20 @@ export function createFleetLedger(hooks = {}, opts = {}) {
|
|
|
322
369
|
break;
|
|
323
370
|
case 'hook_notice':
|
|
324
371
|
// hook_notice(server 1.280.0,[1790]):「本轮 Stop 守卫未能评估」纯 observe 帧。
|
|
372
|
+
// 🔴 **在册残余 P-31 的第三格**(对抗复审第九轮 [high] 登记):第二参交出去的就是那个**未经会话值
|
|
373
|
+
// 校验**的连接级信任位 `sessionScopedMeta` —— 与通知臂的 `server_fail_closed`、行帧车道的
|
|
374
|
+
// `sessionScoped` 让位**同一个根因**(连接级零内容自校)。keyed ledger 上一条被误接的 A 流
|
|
375
|
+
// 报 sessionScoped=true 时,A 的 hook_notice 会带着「已按会话过滤」这句话进 B 的宿主面,而本
|
|
376
|
+
// 回调既不带锚也不带 evidence,端无从自裁。本批**不改签名**(additive 第三参属另一批的公开面
|
|
377
|
+
// 决定),按残余登记 + 钉现状(B3-P31/R9a);根治与另两格同一个 = 上游在 meta 帧回显本连接
|
|
378
|
+
// session id ⇒ 一次修好三条通道。
|
|
325
379
|
// 判定本体在 notifications.classifyHookNoticeFrame(#105 F3:判别不许留在分发处,否则
|
|
326
380
|
// 离线门只能 grep);写 store 归宿主。SDK 0.0.117 起本帧**已在 FleetFrame 联合里**,
|
|
327
381
|
// 不再走 default 臂的 `type` 字面判别。
|
|
328
382
|
hooks.onHookNotice?.(frame, sessionScopedMeta === true);
|
|
329
383
|
break;
|
|
330
384
|
case 'bg_notification':
|
|
331
|
-
applyBgNotification(frame.notification);
|
|
385
|
+
applyBgNotification(frame.notification, ing);
|
|
332
386
|
break;
|
|
333
387
|
default:
|
|
334
388
|
// 未知未来帧 —— 开集,忽略。**但要留痕**([2393] notif-F12):忽略也是丢数据,
|
|
@@ -344,7 +398,7 @@ export function createFleetLedger(hooks = {}, opts = {}) {
|
|
|
344
398
|
* REF-CC-048(fleet2-10):参数类型此前写成 `FleetFrame extends never ? never : BgNotificationWire`
|
|
345
399
|
* —— 条件类型只对**裸类型参数**分发,`FleetFrame` 是具体 union,整个表达式恒等于
|
|
346
400
|
* `BgNotificationWire`,伪装成了一个从不成立的守卫。直写目标类型,不留误导性的仪式感。 */
|
|
347
|
-
function applyBgNotification(n) {
|
|
401
|
+
function applyBgNotification(n, ing) {
|
|
348
402
|
if (typeof n?.taskId !== 'string' || typeof n?.status !== 'string') {
|
|
349
403
|
droppedMalformed++;
|
|
350
404
|
debug('[fleet-frame] MALFORMED bg_notification dropped(taskId 或 status 缺席/非串)');
|
|
@@ -383,9 +437,17 @@ export function createFleetLedger(hooks = {}, opts = {}) {
|
|
|
383
437
|
// 硬证据。🔴 与行帧的 `rowSessionTag` 不同 —— server `streamFleet` 只从**行**上剥
|
|
384
438
|
// scope/sessionId,通知帧只剥 ownerScope/ownerSessionId,`rootSessionId` **照发**
|
|
385
439
|
// (1.291.0 dist 实读)⇒ 这条腿是活的,不在 G12 删除范围内。
|
|
440
|
+
// 🔴 design/285 批 1(P-31 正位解的判据半场):比对锚从**默认槽**换成**本 ledger 的会话锚** ——
|
|
441
|
+
// 经 ingress 到达 ⇒ 该 ingress 开流那一刻的不可变捕获(逐帧现读会让重连后旧流的迟到帧配上
|
|
442
|
+
// 新会话,那正是 owner 台账明禁的错组合);兼容入口 ⇒ `engineSessionParamFor(sessionKey)` 现读。
|
|
443
|
+
// 默认槽 + 兼容入口 ⇒ `engineSessionParamFor(DEFAULT_SESSION_KEY)` ≡ `engineSessionParam()`,
|
|
444
|
+
// 与 0.32.0 同一条取值链、逐字节等价。
|
|
445
|
+
// ⚠️ keyed 车道这是**真行为改变**:`ownByRoot` 是下面那道丢弃门的放行腿之一,换锚会改放行/
|
|
446
|
+
// 丢弃集 —— 带**别的会话** `rootSessionId` 的通知对本键 ledger 本就该判 foreign(方向更正确)。
|
|
447
|
+
const anchor = ing !== null ? ing.session : engineSessionParamFor(sessionKey);
|
|
386
448
|
const ownByRoot = typeof n.rootSessionId === 'string' &&
|
|
387
449
|
n.rootSessionId.length > 0 &&
|
|
388
|
-
n.rootSessionId ===
|
|
450
|
+
n.rootSessionId === anchor;
|
|
389
451
|
if (!serverFailClosed &&
|
|
390
452
|
!ownByRoot &&
|
|
391
453
|
typeof n.parentTaskId === 'string' &&
|
|
@@ -400,23 +462,44 @@ export function createFleetLedger(hooks = {}, opts = {}) {
|
|
|
400
462
|
// `parentTaskId` 在不在分开(走到这里且带非空 parentTaskId ⇒ 它必然 ∈ own-run 台账,
|
|
401
463
|
// 否则上面已丢弃)。多臂同时成立报第一条 —— 报的是「这一帧**实际**靠哪条过的门」,
|
|
402
464
|
// 不是「最强的那条理论上也成立」。
|
|
403
|
-
// 🔴
|
|
404
|
-
//
|
|
405
|
-
//
|
|
406
|
-
//
|
|
407
|
-
//
|
|
408
|
-
//
|
|
409
|
-
//
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
465
|
+
// 🔴 可信谓词**按臂拆分**(design/285 §2.3.3,P-31 正位解;取代 0.32.0「非默认 key 一律封顶」
|
|
466
|
+
// 那一刀切)。两臂不同命的理由 = **自校能力不同**,不是版本先后:
|
|
467
|
+
// · `own_root` 拿通知内容里的 `rootSessionId` 与本锚**逐值比对** —— 宿主把 A 流喂进 B 的
|
|
468
|
+
// ingress 时,A 的通知带 `rootSessionId = sid-A ≠ sid-B`,自动拒。⇒ 锚换对了这一臂就真可信:
|
|
469
|
+
// 默认槽恒可信(0.32.0 逐字同);keyed 上**经 ingress 到达**即可信(通道证据),走兼容入口
|
|
470
|
+
// 仍封顶(没有通道证据,ledger 无从知道帧来自哪条连接)。
|
|
471
|
+
// · `serverFailClosed` **零内容自校**:只读 meta 两个布尔位,不读任何会话、不比对任何值,
|
|
472
|
+
// 而任何一条 session-bound 流的 meta 都长这样 ⇒ 跨-key 误接时它会替别的会话作证,ingress
|
|
473
|
+
// 对此证明不了任何东西。⇒ **keyed 上恒封顶**,待上游在 meta 帧回显本连接 session id 后再议。
|
|
474
|
+
// 封顶词仍是 `session_anchor_untrusted`(不降到 own_parent:经会话级臂放行的帧其 parentTaskId
|
|
475
|
+
// 可能是 foreign,标进程成员比错标会话更糟;也不标 absent_parent:键可能在场)。
|
|
476
|
+
const defaultSlot = sessionKey === DEFAULT_SESSION_KEY;
|
|
477
|
+
const viaIngress = ing !== null;
|
|
478
|
+
/** 内容自校臂:锚对了就可信(空锚时 `ownByRoot` 结构性恒 false —— 没有锚就没法逐值比对,
|
|
479
|
+
* 所以不需要第二道 anchorOk;这也是为什么空锚只挡得住这一臂,挡不住零自校的那一臂)。 */
|
|
480
|
+
const ownRootTrusted = defaultSlot || viaIngress;
|
|
481
|
+
/** 零内容自校臂:只有「前提可成立」的默认槽那一格可信。
|
|
482
|
+
* ⚠️ **刻意不叠 anchorOk**:这一臂的前提是「这条流是替谁开的」,与本端有没有装 `SessionPort`
|
|
483
|
+
* 无关 —— 默认槽 + 空锚 + 生产 meta 仍报本词,与 0.32.0 逐字同(钉:B3-P31/R5c)。 */
|
|
484
|
+
const serverFailClosedTrusted = defaultSlot;
|
|
485
|
+
// 🔴 臂序 = 求值序,但**只在「成立且可信」的臂之间**排序(对抗复审第一轮 [high] 采纳,红先绿后)。
|
|
486
|
+
// 旧写法「无条件先报 serverFailClosed」在**生产 meta 组合**(两位恒 true)下会让 keyed 车道
|
|
487
|
+
// 永远走不到 own_root —— 哪怕 `rootSessionId` 与本锚精确相等、内容自校已经成立。那等于把一条
|
|
488
|
+
// **真的**会话级证明扔掉、改报一个封顶的非证据词,本批的核心净收益在生产配置下完全不可达。
|
|
489
|
+
// 新规矩:**第一条「成立 ∧ 可信」的会话级臂**胜出;一条都没有而至少有一条成立 ⇒ 封顶词。
|
|
490
|
+
// 🔴 绝不因此下探到 `own_parent`:会话级臂放行的帧根本没验过 `parentTaskId ∈ own-run 台账`
|
|
491
|
+
// (放行门在 serverFailClosed/ownByRoot 上短路),报进程成员就是谎报。
|
|
492
|
+
const trustedSessionArm = serverFailClosed && serverFailClosedTrusted
|
|
493
|
+
? 'server_fail_closed'
|
|
494
|
+
: ownByRoot && ownRootTrusted
|
|
495
|
+
? 'own_root'
|
|
496
|
+
: undefined;
|
|
497
|
+
const evidence = trustedSessionArm ??
|
|
498
|
+
(serverFailClosed || ownByRoot
|
|
499
|
+
? 'session_anchor_untrusted'
|
|
500
|
+
: typeof n.parentTaskId === 'string' && n.parentTaskId.length > 0
|
|
501
|
+
? 'own_parent'
|
|
502
|
+
: 'absent_parent');
|
|
420
503
|
debug(`[fleet-frame] bg_notification taskId=${n.taskId} status=${n.status} seq=${n.seq ?? '-'} accept=${evidence}`);
|
|
421
504
|
// 键域(#242 批 3):`n.taskId` = registry handle = 行 id 的**尾段**。live 集按「整行 id 或
|
|
422
505
|
// 尾段命中」收齐**全部**别名(server 双生行:裸 id 与 `runId childId` 复合形同时在场时两条
|
|
@@ -429,7 +512,8 @@ export function createFleetLedger(hooks = {}, opts = {}) {
|
|
|
429
512
|
}
|
|
430
513
|
if (TERMINAL_FLEET_TASK_STATUSES.has(n.status)) {
|
|
431
514
|
if (typeof n.parentTaskId === 'string' && n.parentTaskId) {
|
|
432
|
-
|
|
515
|
+
// design/285 批 2(D5,通知腿):与行帧腿同一口径 —— 三值取本 ingress 的不可变捕获。
|
|
516
|
+
recordBgParentRun(n.taskId, n.parentTaskId, ing?.session, ing?.sessionKey);
|
|
433
517
|
}
|
|
434
518
|
// `transcriptId`(server 1.278.0)= 委派 prompt 的取件锚。现势:通知帧是它**今天真的在场**
|
|
435
519
|
// 的主要位置(行帧那条腿已同款接好,引擎一发即亮 —— 见 case 'task')。
|
|
@@ -677,11 +761,214 @@ export function createFleetLedger(hooks = {}, opts = {}) {
|
|
|
677
761
|
workflows: projectWorkflows([...wfMap.values()].filter(ownWorkflowRow)),
|
|
678
762
|
};
|
|
679
763
|
}
|
|
764
|
+
/**
|
|
765
|
+
* ingress 工厂(stream/snapshot 共用)。`isLive` 让两条 ingress 各查各的代际位。
|
|
766
|
+
*
|
|
767
|
+
* 🔴 **单锚不变量**(对抗复审第一轮 [high] 采纳):一本 ledger 在**任一时刻只有一个会话锚**。
|
|
768
|
+
* 两条 ingress 的代际位分开记,但两者**不许同时 live 且锚不同** —— 否则同一个 `taskMap` /
|
|
769
|
+
* `retained` / 连接级 meta 会被两个会话代际同写(snapshot 的 REPLACE 还会抹掉 stream 那一代的行),
|
|
770
|
+
* 而 `status().sessionAnchor` 只报 stream 那一个,混合态从读面上完全看不出来。
|
|
771
|
+
* 落地:`issueStream()` **作废在飞的 snapshot ingress**;`issueSnapshot()` 在 stream 模式下
|
|
772
|
+
* **继承** stream 的捕获而不自己现读(见各自实现)。
|
|
773
|
+
*/
|
|
774
|
+
const makeIngress = (kind, epoch, session, isLive) => {
|
|
775
|
+
let closed = false;
|
|
776
|
+
return {
|
|
777
|
+
session,
|
|
778
|
+
sessionKey,
|
|
779
|
+
epoch,
|
|
780
|
+
applyFrame(frame) {
|
|
781
|
+
if (disposed)
|
|
782
|
+
return;
|
|
783
|
+
if (closed || !isLive()) {
|
|
784
|
+
// 迟到帧(重连后旧 ingress / 已关的 ingress / 被流超车的快照):丢弃 + 计数。结构性堵死
|
|
785
|
+
// 「旧流的帧配上新会话」这一错组合 —— 那正是 owner 台账「开流时一次性捕获、不逐帧现读」
|
|
786
|
+
// 要防的形。
|
|
787
|
+
droppedStaleIngress++;
|
|
788
|
+
debug(`[fleet-frame] STALE ingress frame dropped(kind=${kind} epoch=${epoch} closed=${String(closed)}` +
|
|
789
|
+
` type=${String(frame.type)})`);
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
applyFrame(frame, { session, sessionKey });
|
|
793
|
+
contentAnchor = session;
|
|
794
|
+
contentAnchorSet = true;
|
|
795
|
+
},
|
|
796
|
+
close() {
|
|
797
|
+
closed = true;
|
|
798
|
+
// 🔴 收流要**真的退出流模式**(对抗复审第二轮 [medium]):只翻闭包位的话,`liveStreamEpoch`
|
|
799
|
+
// 仍非 0 ⇒ 此后每次 `issueSnapshot()` 都把一条**已关**的流当 live、继承它的陈旧锚,于是
|
|
800
|
+
// 「关流 → 换会话 → 取快照」会拿旧会话去打服务器,而 `status()` 还报旧会话。
|
|
801
|
+
// ⚠️ 退出流模式**不等于**锚回落现读:账里还有上一会话的内容时 `status().sessionAnchor`
|
|
802
|
+
// 照实报**内容归属锚**(见 `FleetLedgerStatus.sessionAnchor` 三态)。
|
|
803
|
+
// epoch 相等才动:关一只**过期**的 ingress 绝不许影响更新的那一只。
|
|
804
|
+
if (kind === 'stream' && liveStreamEpoch === epoch)
|
|
805
|
+
liveStreamEpoch = 0;
|
|
806
|
+
if (kind === 'snapshot' && liveSnapshotEpoch === epoch)
|
|
807
|
+
liveSnapshotEpoch = 0;
|
|
808
|
+
},
|
|
809
|
+
};
|
|
810
|
+
};
|
|
811
|
+
/**
|
|
812
|
+
* 🔴 **连接级 meta 复位** —— 每开一条新连接就清(对抗复审第三轮 [high])。
|
|
813
|
+
*
|
|
814
|
+
* `version`/`scoped`/`sessionScoped`/`bgNotifyFailClosed` 是「**那条连接**」的事实,不是会话内容:
|
|
815
|
+
* `sessionScoped` 决定行级 own 判别整条**让位**,`bgNotifyFailClosed` 决定通知的台账判别整条**让位**
|
|
816
|
+
* —— 两个都是 fail-**open** 方向的信任位。重连若落到老版本 / 降级节点 / 忽略 `?session=` 的节点,
|
|
817
|
+
* 或新 `meta` 缺席/迟到,沿用上一条连接的这两位 = 广播行被投影、foreign 通知绕过本地归属门去改状态
|
|
818
|
+
* 和触发钩子。**保留行与留存是对的(R7),保留连接信任位不是。**
|
|
819
|
+
* 复位后到新 `meta` 到达之间那一小段:`sessionScoped=undefined` ⇒ 行级 own 判别回到本地台账
|
|
820
|
+
* (成文的「宁藏勿串」),`bgNotifyFailClosed=false` ⇒ 通知回到 parentTaskId 判别 —— 两边都更严,方向对。
|
|
821
|
+
* ⚠️ SDK 的发帧序是 `meta` **第一帧**,所以这个窗在真实连接上就是「首帧之前」。
|
|
822
|
+
*/
|
|
823
|
+
const resetConnectionMeta = (why) => {
|
|
824
|
+
version = null;
|
|
825
|
+
scoped = null;
|
|
826
|
+
sessionScopedMeta = undefined;
|
|
827
|
+
bgNotifyFailClosedMeta = false;
|
|
828
|
+
debug(`[fleet-frame] 连接级 meta 复位(${why})—— 新连接自报之前不许沿用上一条连接的让位/信任位`);
|
|
829
|
+
};
|
|
830
|
+
/**
|
|
831
|
+
* 🔴 换会话时把**上一会话的内容**整体清掉(对抗复审第二/三轮 [high])。
|
|
832
|
+
* 射程 = 行 / 留存 / 行时刻 + 内容归属锚本身。计数器是生命期量,不清;连接级 meta 由
|
|
833
|
+
* {@link resetConnectionMeta} 单独管(两件事拆开:换会话必清内容,换连接必清 meta)。
|
|
834
|
+
* ⚠️ 与「重连」严格分开:**同一会话**重开流不清内容(留存池是渲染层宽限窗,R7 反钉:重连不该
|
|
835
|
+
* 抽走已完成行)。判据是**捕获的会话变没变**,不是「有没有重开流」。
|
|
836
|
+
*/
|
|
837
|
+
/** 账里有没有内容(行 / 工作流 / 留存)—— 换会话清账判据里「来路不明的存量」那一格用。 */
|
|
838
|
+
const hasLedgerContent = () => taskMap.size > 0 || wfMap.size > 0 || retained.size > 0;
|
|
839
|
+
/**
|
|
840
|
+
* 🔴 发放 ingress 前的清账判据(对抗复审第六轮 [high] 补齐兼容入口那一格):
|
|
841
|
+
* · 账里的内容**经 ingress 落的** ⇒ 有确切的归属锚,按「捕获值变没变」判(同会话重连 ⇒ 不清,R7);
|
|
842
|
+
* · 账里的内容**经兼容入口落的** ⇒ 归属**不可知**(兼容入口每帧现读槽会话,库里不留锚;为它加一次
|
|
843
|
+
* per-frame 端口读只会给 cli 热路径白白多计一份 miss)。不可知 = 不能替它背书 ⇒ **清**。
|
|
844
|
+
* 实际影响面只有「从兼容入口迁到 ingress」那一次(清完 `hasLedgerContent()` 即假);
|
|
845
|
+
* 而 cli 这类**纯兼容入口**宿主从不调 `issue*`,这条判据对它恒不触发 ⇒ 默认槽逐字节零变化。
|
|
846
|
+
*/
|
|
847
|
+
const shouldResetContentFor = (session) => contentAnchorSet ? contentAnchor !== session : hasLedgerContent();
|
|
848
|
+
const resetLedgerContentOnSessionChange = (from, to) => {
|
|
849
|
+
taskMap.clear();
|
|
850
|
+
wfMap.clear();
|
|
851
|
+
rowMeta.clear();
|
|
852
|
+
retained.clear();
|
|
853
|
+
contentAnchorSet = false;
|
|
854
|
+
contentAnchor = undefined;
|
|
855
|
+
debug(`[fleet-frame] 会话锚变更(${from ?? '-'} → ${to ?? '-'})⇒ 清上一会话的行/留存` +
|
|
856
|
+
'(换会话不是重连:再渲上一会话的行就是跨会话残影)');
|
|
857
|
+
};
|
|
680
858
|
return {
|
|
681
859
|
applyFrame(frame) {
|
|
682
860
|
if (disposed)
|
|
683
861
|
return;
|
|
684
|
-
|
|
862
|
+
// 兼容入口:没有通道证据 ⇒ 会话锚现读本键槽(默认槽逐字节等价于 0.32.0)。
|
|
863
|
+
applyFrame(frame, null);
|
|
864
|
+
},
|
|
865
|
+
issueStream() {
|
|
866
|
+
const session = engineSessionParamFor(sessionKey);
|
|
867
|
+
// 🔴 换会话 ≠ 重连:新捕获的会话与**账里现有内容所属**的会话不同 ⇒ 先清旧账再发新 ingress,
|
|
868
|
+
// 否则 status() 报新会话而 project()/在飞读面还在渲旧会话的行(混合账,读面上看不出来)。
|
|
869
|
+
// 同会话重开流(真正的重连)走不到这里,留存池原样保留(R7)。
|
|
870
|
+
if (shouldResetContentFor(session)) {
|
|
871
|
+
resetLedgerContentOnSessionChange(contentAnchorSet ? contentAnchor : '<兼容入口,归属不可知>', session);
|
|
872
|
+
}
|
|
873
|
+
// 🔴 连接级 meta **每开一条新连接就清**(与内容保留策略拆开):那两位是 fail-open 方向的信任位,
|
|
874
|
+
// 沿用上一条连接的会让「重连落到降级/老版本/忽略 ?session= 的节点」直接绕过本地归属门。
|
|
875
|
+
resetConnectionMeta('新 stream ingress');
|
|
876
|
+
const epoch = ++epochSeq;
|
|
877
|
+
liveStreamEpoch = epoch;
|
|
878
|
+
streamAnchor = session;
|
|
879
|
+
everIssuedStream = true;
|
|
880
|
+
// 🔴 单锚不变量:开真流即作废在飞的一次性快照 ingress —— 快照是时点探针,流一开它就被取代了;
|
|
881
|
+
// 留着它 = 允许「快照那一代的会话」与「本流的会话」并发写同一本账。
|
|
882
|
+
liveSnapshotEpoch = 0;
|
|
883
|
+
debug(`[fleet-frame] ingress issued(kind=stream key=${sessionKey} epoch=${epoch} session=${session ?? '-'})`);
|
|
884
|
+
return {
|
|
885
|
+
...makeIngress('stream', epoch, session, () => liveStreamEpoch === epoch),
|
|
886
|
+
options: session !== undefined ? { session } : {},
|
|
887
|
+
};
|
|
888
|
+
},
|
|
889
|
+
issueSnapshot(snapOpts) {
|
|
890
|
+
// 🔴 单锚不变量:stream 模式下**继承** stream 的不可变捕获,绝不自己现读。现读的形在「槽内会话
|
|
891
|
+
// 已轮换、宿主还没重开流」时会铸出第二个锚,于是两条 live ingress 各按各的会话给同一本账
|
|
892
|
+
// 背书。继承同时满足 design/285「snapshot 绝不改写 stream 锚」——它只读不写。
|
|
893
|
+
// 真换了会话的正确姿势是先 `issueStream()`(= 重连),那一步会把锚整体推进。
|
|
894
|
+
const inStreamMode = liveStreamEpoch !== 0;
|
|
895
|
+
// 🔴 **兼容入口消费的活流也是活流**(对抗复审第八轮 [high]):那条车道不设 `liveStreamEpoch`,
|
|
896
|
+
// 只有宿主的 `setConnected(true)` 说得出「现在有一条流连着」。漏判它 = 把一条**正活着**的
|
|
897
|
+
// 连接当 snapshot-only 处理,当场清掉它的连接级 meta —— 而那条流的 `meta` 只在连接首帧出现过,
|
|
898
|
+
// 于是 `sessionScoped`/`bgNotifyFailClosed` 会在**剩余整个连接生命期**保持关闭(行投影退回本地
|
|
899
|
+
// own-run 判别而静默藏行)。⇒ 活流期间签发的快照:**不清内容、不清 meta、永久判过期**。
|
|
900
|
+
// 🔴 但 `connected` 只对**纯兼容入口宿主**有权威性(第九轮 [medium]):采用了 ingress 的宿主,
|
|
901
|
+
// 流的存活由 `liveStreamEpoch` 说了算 —— `close()` 只退流模式、不替宿主改 `connected`,拿一个
|
|
902
|
+
// 陈旧的 true 当「活流」会把合法降级序列「开流 → close → 取一次性快照兜底」的快照永久判废。
|
|
903
|
+
const compatStreamLive = !inStreamMode && connected && !everIssuedStream;
|
|
904
|
+
const duringLiveStream = inStreamMode || compatStreamLive;
|
|
905
|
+
// 🔴 **活流分支绝不现读端口**(对抗复审第十轮 [medium]):无条件先 `engineSessionParamFor()` 的写法
|
|
906
|
+
// 与本批「stream 模式下继承捕获、不自己现读」那句承诺自相矛盾 —— 端口热重载期间它会铸出**虚假的
|
|
907
|
+
// session miss**(实测:开流后撤端口再签发快照,`hostPortMissesFor(key).session` 0→1),端口实现
|
|
908
|
+
// 抛错时还会让 `issueSnapshot()` 整条失败。取值只在 snapshot-only 分支发生。
|
|
909
|
+
const session = inStreamMode ? streamAnchor : engineSessionParamFor(sessionKey);
|
|
910
|
+
// 🔴 **snapshot-only 车道的换会话也要清账**(对抗复审第三轮 [high]):没有 live stream 时快照
|
|
911
|
+
// 自己现读,而「A 流写完终态留存 + meta 后 close → 槽切到 B → 只取快照」这条真实时序里,
|
|
912
|
+
// snapshot 的 REPLACE **刻意不清留存**(R7)、也带不来新 meta ⇒ B 的活跃行会与 A 的留存行同账,
|
|
913
|
+
// 且 A 那条连接的 `sessionScoped=true` 还在替整本账放行。走与 issueStream 同一套清账。
|
|
914
|
+
// ⚠️ 只在**真的没有活流**时做:活流期间签发的快照本来就永久判过期,清账/清 meta 只会破坏
|
|
915
|
+
// 那条还活着的连接(第八轮 [high])。
|
|
916
|
+
if (!duringLiveStream) {
|
|
917
|
+
if (shouldResetContentFor(session)) {
|
|
918
|
+
resetLedgerContentOnSessionChange(contentAnchorSet ? contentAnchor : '<兼容入口,归属不可知>', session);
|
|
919
|
+
}
|
|
920
|
+
// 🔴 **快照连接自带不了 meta**(对抗复审第四轮 [high]):SDK 的 `fleet.snapshot()` 内部开流→取
|
|
921
|
+
// snapshot 帧→关流,`meta` 在封装里被吞 ⇒ 这条连接的行**没有任何 scoping 断言**。若沿用上一条
|
|
922
|
+
// (已关的)stream 连接的 `sessionScoped=true`,快照落在降级/不按 `?session=` 过滤的节点上时,
|
|
923
|
+
// 它带回的外来行会被一条**不相干连接**的信任位无条件放行投影,而且此后**没有任何 meta 帧**能
|
|
924
|
+
// 纠正它。⇒ 快照车道一律复位连接级 meta,行回到本地台账判别(fail-closed 方向)。
|
|
925
|
+
resetConnectionMeta('快照连接:SDK 封装吞掉了它自己的 meta,不许沿用别条连接的信任位');
|
|
926
|
+
}
|
|
927
|
+
if (inStreamMode) {
|
|
928
|
+
debug(`[fleet-frame] snapshot ingress 继承 stream 锚(${streamAnchor ?? '-'};活流期间不现读槽会话 ——` +
|
|
929
|
+
' 要切会话请先 issueStream())');
|
|
930
|
+
}
|
|
931
|
+
const epoch = ++epochSeq;
|
|
932
|
+
liveSnapshotEpoch = epoch; // 🔴 不碰 liveStreamEpoch / streamAnchor
|
|
933
|
+
debug(`[fleet-frame] ingress issued(kind=snapshot key=${sessionKey} epoch=${epoch} session=${session ?? '-'})`);
|
|
934
|
+
// 🔴 **有 live stream 时快照一律不落账**(对抗复审第二/四轮 [high] 的合流解):
|
|
935
|
+
// ①两条连接两个写者,而 `applySnapshot` 是无条件 REPLACE —— 「服务端取样之后、落账之前」流
|
|
936
|
+
// 又推了一帧时,照落会把**更新的**行覆盖回旧值,而 fleet 是 latest-state 总线,没有任何
|
|
937
|
+
// 后续帧保证修回来(只能等那一行下次 transition);
|
|
938
|
+
// ②快照连接的 `meta` 被 SDK 封装吞掉 ⇒ 它的行**自带零 scoping 断言**,落进一本正持着别的
|
|
939
|
+
// 连接 `sessionScoped=true` 的账里,就会被那条**不相干连接**的信任位无条件放行投影。
|
|
940
|
+
// 而 live stream 本身就是「snapshot 先行的 latest-state 总线」—— 它一开就带全量快照并持续
|
|
941
|
+
// 刷新,此时再落一份一次性快照**零收益**。⇒ 丢弃 + 计 `droppedStaleIngress`。
|
|
942
|
+
// 一次性快照的正当场景(首屏/温切探针)本来就发生在**没有 live stream**的时候。
|
|
943
|
+
// 写序号闸(对抗复审第五轮 [high]):「有 live stream 时不落账」挡不住**兼容入口**消费的活流
|
|
944
|
+
// (那条路上 `liveStreamEpoch` 恒为 0)⇒ 再叠一道全入口通用的账本写序号。
|
|
945
|
+
const writesAtIssue = ledgerWrites;
|
|
946
|
+
// 🔴 「有没有 live stream」必须**固化在发放那一刻**(对抗复审第七轮 [high] 的 close-before-result
|
|
947
|
+
// 竞态):落账时才现查 `liveStreamEpoch === 0` 的话,「流活着时签发快照 → 快照还在路上、流先
|
|
948
|
+
// `close()` → 快照返回落账」会因为 close 把 epoch 清零而**反而通过闸**;而这只快照当初正是在
|
|
949
|
+
// stream 模式下签发的 —— 它跳过了 `resetConnectionMeta()`,于是会沿用一条**已关连接**的
|
|
950
|
+
// `sessionScoped=true` 去给自己带回来的行背书。⇒ 流存活期间签发的快照**永久判过期**。
|
|
951
|
+
// (发放时无流的那只不必再查 `liveStreamEpoch`:此后一旦 `issueStream()`,它会把
|
|
952
|
+
// `liveSnapshotEpoch` 置 0,epoch 那一条已经把它挡下了;兼容车道则由 `ledgerWrites` 挡。)
|
|
953
|
+
const issuedDuringStream = duringLiveStream;
|
|
954
|
+
const base = makeIngress('snapshot', epoch, session, () => liveSnapshotEpoch === epoch && !issuedDuringStream && ledgerWrites === writesAtIssue);
|
|
955
|
+
return {
|
|
956
|
+
...base,
|
|
957
|
+
// SDK 的 `fleet.snapshot()` 回的是 `{tasks, workflows}`(不是帧)—— 帧形在库内合成一处,
|
|
958
|
+
// 端不再手抄。`ts` 走本地钟只是占位:台账对 snapshot 帧的时刻恒用 `nowFn()`,不读 `frame.ts`。
|
|
959
|
+
applySnapshot(snapshot) {
|
|
960
|
+
base.applyFrame({
|
|
961
|
+
type: 'snapshot',
|
|
962
|
+
tasks: snapshot.tasks,
|
|
963
|
+
workflows: snapshot.workflows,
|
|
964
|
+
ts: nowFn(),
|
|
965
|
+
});
|
|
966
|
+
},
|
|
967
|
+
options: {
|
|
968
|
+
...(session !== undefined ? { session } : {}),
|
|
969
|
+
...(snapOpts?.timeoutMs !== undefined ? { timeoutMs: snapOpts.timeoutMs } : {}),
|
|
970
|
+
},
|
|
971
|
+
};
|
|
685
972
|
},
|
|
686
973
|
setConnected(v) {
|
|
687
974
|
connected = v;
|
|
@@ -698,6 +985,14 @@ export function createFleetLedger(hooks = {}, opts = {}) {
|
|
|
698
985
|
droppedMalformed,
|
|
699
986
|
droppedUnknownFrame,
|
|
700
987
|
droppedForeignBgNotification,
|
|
988
|
+
// 「此刻的判据/内容所属锚」三态(对抗复审第五轮 [high] 收紧):
|
|
989
|
+
// ①有 live stream ⇒ 它开流那一刻的捕获(它的帧就按这个判);
|
|
990
|
+
// ②没有 live stream 但**账里有内容** ⇒ 内容归属锚 —— 绝不改报一个还没被用过的现读值,
|
|
991
|
+
// 否则「close 之后槽会话轮换」这一刻 status 就会报新会话而账里装的还是旧会话的行;
|
|
992
|
+
// ③空账无流 ⇒ 兼容入口下一帧会现读的那个值。
|
|
993
|
+
sessionAnchor: liveStreamEpoch !== 0 ? streamAnchor : contentAnchorSet ? contentAnchor : engineSessionParamFor(sessionKey),
|
|
994
|
+
sessionAnchorEpoch: liveStreamEpoch,
|
|
995
|
+
droppedStaleIngress,
|
|
701
996
|
}),
|
|
702
997
|
dispose() {
|
|
703
998
|
disposed = true;
|
|
@@ -80,7 +80,7 @@ export declare function _resetArmedPlanReviewsForTest(): void;
|
|
|
80
80
|
* REF-CC-027 above); false = caller renders the terminal as usual. Fail-soft: any error inside must
|
|
81
81
|
* never break the stream drain.
|
|
82
82
|
*/
|
|
83
|
-
export declare function armPlanReviewApproval(result: unknown): boolean;
|
|
83
|
+
export declare function armPlanReviewApproval(result: unknown, sessionKey?: string): boolean;
|
|
84
84
|
/** POST the decision; on settle enqueue an isMeta turn so the model reports the resume outcome.
|
|
85
85
|
* SDK 宪法迁移批:SDK 0.0.52 assistant.planReview verb(同 wire `POST /v1/assistant/tasks/:id/
|
|
86
86
|
* plan_review` 同 body {decision})——raw fetch 退役。错误模型:非 2xx 抛 APIError(message =
|
|
@@ -148,11 +148,39 @@ function publishPlanReviewCard(taskId) {
|
|
|
148
148
|
* REF-CC-027 above); false = caller renders the terminal as usual. Fail-soft: any error inside must
|
|
149
149
|
* never break the stream drain.
|
|
150
150
|
*/
|
|
151
|
-
export function armPlanReviewApproval(result) {
|
|
151
|
+
export function armPlanReviewApproval(result, sessionKey) {
|
|
152
152
|
let armedTaskId;
|
|
153
153
|
try {
|
|
154
154
|
if (!isPlanReviewPark(result))
|
|
155
155
|
return false;
|
|
156
|
+
// 🔴 **非默认槽 ⇒ 运行时 fail-closed**(异源对抗复审第六轮 [high] 采纳:「文档登记不能代替运行时
|
|
157
|
+
// 隔离」)。additive 第二参让 keyed 宿主**说得出**这条 park 属于哪个槽;库这边诚实回答
|
|
158
|
+
// 「这条链今天服务不了它」,而不是**默默按默认槽立卡、再按默认槽把决断发出去** ——
|
|
159
|
+
// 后者在「默认槽 + keyed 槽同时装配」的宿主上会把 B 会话的 approve/reject 带着**默认槽**的
|
|
160
|
+
// baseUrl/token/principal 发出去(对端有同名任务就改错会话的状态,没有就 404 而原 run 继续
|
|
161
|
+
// 停在审批门)。整条链(卡注册 / 退役 / responder 台账 / decide)同批转 keyed 是 additive
|
|
162
|
+
// 公开面改动,归后续工单;在那之前**明确拒绝**比静默回落默认槽诚实。
|
|
163
|
+
// ⚠️ 不传 / 传默认槽 ⇒ 与 0.44.0 逐字同(单会话宿主零受迫)。
|
|
164
|
+
if (sessionKey !== undefined && sessionKey !== DEFAULT_SESSION_KEY) {
|
|
165
|
+
hostLog('error', `planReviewWire: plan_review 卡未 arm —— 本链今天只服务默认会话槽,收到 sessionKey=${JSON.stringify(sessionKey)}。` +
|
|
166
|
+
'多会话宿主的 plan-mode 归后续工单(卡台账/responder/decide 需同批转 keyed);' +
|
|
167
|
+
'此处拒绝而不是回落默认槽 —— 回落会把这条会话的决断带着别的槽的 baseUrl/token/principal 发出去。');
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
// 🔴 design/285 批 3 的**显式裁定:plan 决策链两处 `engineWireTarget()` 本批豁免**(D7b 全量
|
|
171
|
+
// 清单里唯二不换的两处;设计稿 §1 明令「施工时显式裁入射程或豁免 + 理由」,不许静默漏)。
|
|
172
|
+
//
|
|
173
|
+
// 豁免的理由**不是**「它没有这个病」——它有:keyed 宿主上 plan 决断会走默认槽的 baseUrl /
|
|
174
|
+
// token / principal。理由是**只换这一跳会造出一个新的自相矛盾**:整条 plan_review HITL 链
|
|
175
|
+
// 今天是默认槽单会话形 —— 卡的注册与退役按 `DEFAULT_SESSION_KEY`(下一行 `retireActiveReopen`
|
|
176
|
+
// 与 `liveQuestionStore` 的 responder 台账同键),`decidePlanReview` 又是从那张卡的 responder
|
|
177
|
+
// 里回调的。把 wire target 单独换成 keyed = 「按默认槽立卡、按 keyed 槽发决断」,比现状更坏
|
|
178
|
+
// (现状至少两头一致地错在同一个槽上,一眼可见;半 keyed 形是两头不一致,只在多会话下现形)。
|
|
179
|
+
// ⇒ 正确的做法是整条链(卡台账 + responder + wire)**同批**转 keyed,那是 additive 公开面
|
|
180
|
+
// 改动(`armPlanReviewApproval` 要收 sessionKey 并把它捎进 responder 闭包),归后续工单;
|
|
181
|
+
// 与本行下方那条「多会话宿主的 arm 路径归后续工单」是同一条欠账,不是两笔。
|
|
182
|
+
// 现状由常驻钉 `B3-P31/G18b` 钉住(装了 keyed 槽而默认槽为空 ⇒ 本腿仍判 offline),
|
|
183
|
+
// 防「悄悄换成 keyed」与「悄悄以为已经 keyed 了」两向。
|
|
156
184
|
if (!engineWireTarget())
|
|
157
185
|
return false;
|
|
158
186
|
const { taskId } = result;
|
|
@@ -234,6 +262,8 @@ export async function decidePlanReview(taskId, decision) {
|
|
|
234
262
|
// 请求都没发出去,而模型/用户没有任何回程信号。两条早退现在都汇进同一条 outcome 管道(下方
|
|
235
263
|
// 唯一的 enqueue 调用点),不再是两条独立的静默 return。
|
|
236
264
|
let outcome;
|
|
265
|
+
// 🔴 design/285 批 3 显式裁定:本处与 `armPlanReviewApproval` 同一笔豁免(理由逐字见那边)——
|
|
266
|
+
// 决断是从 arm 立的那张卡的 responder 回调进来的,槽键必须与立卡时同源,单换这一跳即两头不一致。
|
|
237
267
|
const cfg = engineWireTarget();
|
|
238
268
|
if (!cfg) {
|
|
239
269
|
hostLog('error', `planReviewWire: ${decision} NOT sent — engineWireTarget() unavailable`);
|
|
@@ -374,6 +404,19 @@ export function reopenPlanReviewCard(taskId, opts) {
|
|
|
374
404
|
if (!hasQuestionOverlayFor(sessionKey)) {
|
|
375
405
|
return receiptMs === undefined ? { reopened: false } : Promise.resolve({ reopened: false });
|
|
376
406
|
}
|
|
407
|
+
// 🔴 **非默认槽 + 没有自带投递口 ⇒ 拒绝重开**(异源对抗复审第七轮 [high] 采纳)。本函数按传入的
|
|
408
|
+
// `sessionKey` 在 B 槽发卡与记账,但**缺省 responder 投递走 `decidePlanReview`,而那一处固定读
|
|
409
|
+
// 默认槽的 `engineWireTarget()`** ⇒ 用户在 B 看到并点下去的 approve/reject 会带着 **A 的**
|
|
410
|
+
// baseUrl/token/principal 出站(对端有同名任务就改错会话的状态,没有就 404 而 B 的任务继续锁死)。
|
|
411
|
+
// arm 臂那道 fail-closed 覆盖不到这条 409/自愈重开入口 —— 两个入口必须同命。
|
|
412
|
+
// ⚠️ 宿主自带 `deliverDecision`(它自己按槽投递)⇒ 放行,那时投递口不再是库内的默认槽管道。
|
|
413
|
+
if (sessionKey !== DEFAULT_SESSION_KEY && opts?.deliverDecision === undefined) {
|
|
414
|
+
hostLog('error', `planReviewWire: plan_review 卡未重开 —— sessionKey=${JSON.stringify(sessionKey)} 是非默认槽,` +
|
|
415
|
+
'而缺省决断投递口(decidePlanReview)今天固定打默认槽的 baseUrl/token/principal。' +
|
|
416
|
+
'整条链转 keyed 之前,非默认槽要重开请自带 deliverDecision(按本槽投递);' +
|
|
417
|
+
'此处拒绝而不是照发 —— 照发会把这条会话的决断发到另一个槽上。');
|
|
418
|
+
return receiptMs === undefined ? { reopened: false } : Promise.resolve({ reopened: false });
|
|
419
|
+
}
|
|
377
420
|
const canonicalId = planReviewQuestionId(taskId);
|
|
378
421
|
// 先查(firstSight)后记 —— 顺序决定判决正确性;台账按 sessionKey 分账,键 = **当代** plan 键
|
|
379
422
|
// (A-024.4 分代:0 代与 canonical 字节同形,决断消费后落新代 —— 恒定键会对同 run 的下一只
|
package/dist/seam.d.ts
CHANGED
|
@@ -389,7 +389,47 @@ export type ChromeEvent = {
|
|
|
389
389
|
* `actor.hostAsserted` 是消费端唯一能判「这个署名可信吗」的位:渲署名而不渲这个位 = 把一个
|
|
390
390
|
* 未经验证的名字渲成可信的(core 自己的 `[from …]` 渲染就是靠它决定加不加 `(unverified)`)。
|
|
391
391
|
*/
|
|
392
|
-
| HumanInputChromeEvent | EngineNoticeChromeEvent;
|
|
392
|
+
| HumanInputChromeEvent | EngineNoticeChromeEvent | TextSegmentEndChromeEvent;
|
|
393
|
+
/**
|
|
394
|
+
* {@link ChromeEvent} 的 `text_segment_end` 臂(#323 / core #447,core ≥5.63 / server ≥7.50)——
|
|
395
|
+
* 「**assistant 的这一段散文写完了**」,由引擎明说,不是由本包猜。
|
|
396
|
+
*
|
|
397
|
+
* ── 它替代的是什么 ──────────────────────────────────────────────────────────────────────────
|
|
398
|
+
* 本包的 `adapt/textStream.ts` 至今用**启发式**猜段边界:静默 ≥1.5s + 段尾是句末/段末字符 +
|
|
399
|
+
* 每段至多一刀(`takeAnswerSegmentOnIdle`,#323 症状① 的止血件)。那是猜 —— 慢模型的一次卡顿会
|
|
400
|
+
* 被当成段结束,一轮回复被切成 N 条 assistant 消息。本臂是**引擎报的真边界**:模型关掉了那个
|
|
401
|
+
* text content block。CC 对位:CC 在 provider 的 `content_block_stop` 上把每个写完的块当作一条
|
|
402
|
+
* 独立 assistant 消息 —— 块结束**就是**分段信号,本臂把同一个边界搬到了 wire 上。
|
|
403
|
+
*
|
|
404
|
+
* 🔴 **宿主消费义务**(全部可选、fail-soft;本臂存在的第一价值 = 边界信号不再落 `unknown_arm`):
|
|
405
|
+
* ① **它是信号,不是内容**。`content` = 那一段的**权威全文**,而这些字节**已经**以 `stream_delta`
|
|
406
|
+
* 活体增量流过、并且会由 transcript 平面的 assistant 消息给出 committed 形。
|
|
407
|
+
* 🔴 拿 `content` 再渲一行 = **同一段文字上屏两遍**。它的正当用途是**对账**(用引擎的权威全文
|
|
408
|
+
* 校/换掉自己缝合出来的那一段)与**定界**(现在可以提交这一段了)。
|
|
409
|
+
* ② **诚实缺席,不可反推**(core 臂注逐字):只有会报块结束的 Brain 才发它 —— 三个一方 brain 都发,
|
|
410
|
+
* 自定义 brain 可能整条流一帧都没有。⇒ 缺席 = 「**没报**」,**永远不等于**「这一段没结束」。
|
|
411
|
+
* 消费方按**每条流**判「这条流带不带边界帧」,只在整条流一帧都没有时才回落自己的启发式;
|
|
412
|
+
* 按**单帧**缺席就回落 = 把「这一拍还没到边界」误当「引擎不支持」。
|
|
413
|
+
* ③ **空段无帧**:引擎只为**有字节**的段发帧(空 text block 静默关闭)—— 所以宿主永远不会收到
|
|
414
|
+
* 空 `content`,收到了也不该提交一个幻影段(本包投影层已按 `empty_payload` 拦下)。
|
|
415
|
+
* ④ 🔴 **子流让位**:带 `parentToolCallId` 的段边界属于子代/编排流,拿它去提交 leader 的段就是
|
|
416
|
+
* 跨 lane 状态破坏(与 `stream_delta` 的 thinking 半场、`retry_status` 同一条纪律)。所以本臂
|
|
417
|
+
* 在库内**已经断闸** —— 子流的段边界**根本不产出本 chrome 事件**,本臂因此没有
|
|
418
|
+
* `parentToolCallId` 位(有位才是可疑的:那意味着 leader 面上会出现别人的边界)。
|
|
419
|
+
* 要消费子流边界的宿主读 SDKMessage 平面的 `text_end` 内部臂,那一层原样带 `parentToolCallId`。
|
|
420
|
+
*
|
|
421
|
+
* ⚠️ **UNTRUSTED、仅展示**:`content` 是模型输出,契约与活体增量同 —— 渲染,绝不回喂模型。
|
|
422
|
+
* 📋 如实留白:本包的 `takeAnswerSegmentOnIdle` 启发式**本批不撤**(撤它要按 ② 做一条 per-stream
|
|
423
|
+
* 的状态化策略,属行为面改动,按宪法三问单独走)。本批只把边界送到宿主手上。
|
|
424
|
+
*/
|
|
425
|
+
export interface TextSegmentEndChromeEvent {
|
|
426
|
+
kind: 'text_segment_end';
|
|
427
|
+
laneProof: LaneProof;
|
|
428
|
+
/** 该段的**权威全文**(与那一段活体增量的拼接逐字节相等)。🔴 对账/定界用,别拿它再渲一行。 */
|
|
429
|
+
content: string;
|
|
430
|
+
/** core 铸的事件身份(uuidv7 形);wire 未必带 ⇒ 缺席时本键不在场。 */
|
|
431
|
+
eventId?: string;
|
|
432
|
+
}
|
|
393
433
|
/**
|
|
394
434
|
* {@link ChromeEvent} 的 `engine_notice` 臂(#310 / #318 件①,server ≥7.36;契约 = server
|
|
395
435
|
* `ASSISTANT-WIRE-CONTRACT` 附录 D)——「**引擎想让这条会话的人知道一件事**」。
|
package/dist/seam.js
CHANGED
|
@@ -48,6 +48,10 @@ const CHROME_ARM_TABLE = {
|
|
|
48
48
|
required: false,
|
|
49
49
|
duty: '可选:渲引擎通告(按 code+detail,message 仅 fallback;未知 code 也必须渲不许丢;durable 重放按 eventId 幂等;harvest 的 moved/escalated 并列呈现绝不相减)',
|
|
50
50
|
},
|
|
51
|
+
text_segment_end: {
|
|
52
|
+
required: false,
|
|
53
|
+
duty: '可选:引擎明报的 assistant 散文段边界(#323/core #447)。🔴 content 是对账/定界用的权威全文,拿它再渲一行 = 同一段上屏两遍;缺席只表示「没报」,绝不等于「段没结束」——要退回自家启发式必须按整条流判、不按单帧判',
|
|
54
|
+
},
|
|
51
55
|
};
|
|
52
56
|
/**
|
|
53
57
|
* chrome 臂**覆盖率断言出口**(§8-1 裁决的一半:client-core 定接口不定实现)。
|