@sema-agent/client-core 0.30.5 → 0.30.6
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 +22 -0
- package/dist/workflowClient.d.ts +62 -2
- package/dist/workflowClient.js +586 -18
- package/dist/workflowMonitor.d.ts +7 -0
- package/docs/INTEGRATION-CLIENTS.md +4 -4
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -16,6 +16,28 @@
|
|
|
16
16
|
> 🔴 **互链**(web [C166]⑦):各版「已知局限」段只记**该版新增**;接入面已知局限的完整台账在
|
|
17
17
|
> `docs/INTEGRATION-CLIENTS.md` §6e/§7 —— **只读其一会漏**,两处都过。
|
|
18
18
|
|
|
19
|
+
## 0.30.6(2026-08-15)
|
|
20
|
+
|
|
21
|
+
**#242 批 1 workflow 单流化(A-028.8 收编)——wire 行为变更,消费端请对表:**
|
|
22
|
+
|
|
23
|
+
- **同一 workflowId 恰一条 SSE**:运行期 activity 台账上收包内(模块级常驻,workflow 起跑即积累;
|
|
24
|
+
`createLiveWorkflowSource` 不再自开流,改搭台账 change-tick 做节流 re-GET,逐拍语义等价)。
|
|
25
|
+
此前包流帧体整丢只当 ticker、消费端(sema-cli)自建第二条同 id 流 —— 连接数从 2 降到 1。
|
|
26
|
+
**若你的端也自建了第二条 workflow detail 流,请改读包台账。**
|
|
27
|
+
- **新公开面**:`ensureWorkflowActivityLedger(cfg)`(预热口,幂等)/`stopWorkflowActivityLedger(id)`
|
|
28
|
+
(收口)/`readWorkflowActivityLedger(id)`(读口,null=没开过账)+ `WorkflowActivityLedgerView`/
|
|
29
|
+
`WorkflowActivityAgentView` 类型。
|
|
30
|
+
- **投影新增 `WorkflowAgent.callKey`**(additive):SSE activity 帧的主键此前被投影丢弃,label 因此
|
|
31
|
+
成了消费端唯一钥匙;现在重名 agent 也能精确归属。label 归属只在「该 label 恰一个 acc」时成立,
|
|
32
|
+
归属不可判整组跳过(诚实缺席)。
|
|
33
|
+
- 台账语义:`agent_end.toolCalls` 权威、活动期 start 计数为下界;收口分因(terminal/stopped/error,
|
|
34
|
+
仅 error 可被 ensure 重开且事实保留);连线身份(baseUrl/principal/凭证)变更整条换新,被顶掉的
|
|
35
|
+
消费者停手降级 GET 轮询(不 ping-pong 抢回)。
|
|
36
|
+
|
|
37
|
+
已知局限(本版新增;完整台账见 docs/INTEGRATION-CLIENTS.md §6e/§7):台账注册表按 workflowId 单键
|
|
38
|
+
(两个不同连线身份同 id 并存时后者赢、前者退 GET 轮询);台账自预热口首调起积累(attach 老 run 时
|
|
39
|
+
运行期计数是下界);常驻上限 16 条,满且全部在飞时新 id 不建账(详情退 GET 轮询,响亮留痕)。
|
|
40
|
+
|
|
19
41
|
## 0.30.5
|
|
20
42
|
|
|
21
43
|
- **#244 F4 族D:配置/模型表单源化(A-028.17/.18/.20 三件单源上收 + A-028.19 对账钉,additive,
|
package/dist/workflowClient.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { WorkflowRun } from '@sema-agent/sdk';
|
|
2
|
-
import type { WorkflowRunState } from './workflowMonitor.js';
|
|
1
|
+
import type { WorkflowRun, WorkflowStreamEvent } from '@sema-agent/sdk';
|
|
2
|
+
import type { WorkflowRunState, WorkflowToolCall } from './workflowMonitor.js';
|
|
3
3
|
/** The live controller the host drives — a projected-snapshot reader + diagnostics + teardown. */
|
|
4
4
|
export interface LiveWorkflowController {
|
|
5
5
|
/** the latest projected run state, or null (not yet resolved / degraded 501-404 / empty). */
|
|
@@ -63,6 +63,66 @@ export interface LiveWorkflowConfig {
|
|
|
63
63
|
* tolerates absent/permissive fields and never throws (the graceful-degrade contract lives above, in the
|
|
64
64
|
* source loop; this just maps a well-formed run). Exported for the mock-parity unit cross-check. */
|
|
65
65
|
export declare function projectWorkflowRun(run: WorkflowRun): WorkflowRunState;
|
|
66
|
+
/** 台账里的一个 agent(读口产物,只读快照;缺席 = 不知道,绝不渲成 0/空)。 */
|
|
67
|
+
export interface WorkflowActivityAgentView {
|
|
68
|
+
/** `ctx.agent` 调用的稳定身份(SSE 主键);老引擎/无 callKey 的帧上缺席。 */
|
|
69
|
+
callKey?: string;
|
|
70
|
+
label: string;
|
|
71
|
+
/** `agent_end.toolCalls` 权威;缺席时 = `agent_activity` 的 start 相位计数(**下界**)。 */
|
|
72
|
+
toolCalls: number;
|
|
73
|
+
/** per-toolCallId 折好的 call 序(上限 {@link MAX_WORKFLOW_ACTIVITY_CALLS},超限丢最老)。 */
|
|
74
|
+
activity: WorkflowToolCall[];
|
|
75
|
+
lastToolName?: string;
|
|
76
|
+
lastToolSummary?: string;
|
|
77
|
+
/** `agent_start.model` —— **请求词原样**(不是生效模型)。 */
|
|
78
|
+
requestedModel?: string;
|
|
79
|
+
/** `agent_end.modelResolved` —— 实跑模型;core 的 workflow 记录从不折入它 ⇒ GET 结构上到不了。 */
|
|
80
|
+
resolvedModel?: string;
|
|
81
|
+
/** 尾条 beat 的 ts。 */
|
|
82
|
+
lastProgressAt?: number;
|
|
83
|
+
}
|
|
84
|
+
/** 台账读口产物。`byLabel` **已排除**归属不可判的 label —— 端侧零重名逻辑。 */
|
|
85
|
+
export interface WorkflowActivityLedgerView {
|
|
86
|
+
workflowId: string;
|
|
87
|
+
byCallKey: ReadonlyMap<string, WorkflowActivityAgentView>;
|
|
88
|
+
byLabel: ReadonlyMap<string, WorkflowActivityAgentView>;
|
|
89
|
+
/** 同 label 多个非 replay 的 agent_start ⇒ 归属不可判(诊断面;`byLabel` 已按它过滤)。 */
|
|
90
|
+
ambiguousLabels: ReadonlySet<string>;
|
|
91
|
+
/** 流已收口(run 终态 / 显式 stop)——数据保留只读,不再消费。 */
|
|
92
|
+
closed: boolean;
|
|
93
|
+
/** 已折入的帧数(反空转:0 = 这条台账一帧都没收到,「全都没有」不构成证据)。 */
|
|
94
|
+
frames: number;
|
|
95
|
+
/** 最近一次 SSE 错误的人话;从没出过错 = null。**诊断面**,不参与归属判定。 */
|
|
96
|
+
lastError: string | null;
|
|
97
|
+
}
|
|
98
|
+
/** 预热口/单流化源共用的连线配置(与 {@link LiveWorkflowConfig} 同域,workflowId 必填)。 */
|
|
99
|
+
export interface WorkflowActivityLedgerConfig {
|
|
100
|
+
baseUrl: string;
|
|
101
|
+
authToken: string | {
|
|
102
|
+
mode: 'same-origin-relay';
|
|
103
|
+
};
|
|
104
|
+
principal: string | undefined;
|
|
105
|
+
workflowId: string;
|
|
106
|
+
fetchImpl?: typeof fetch;
|
|
107
|
+
}
|
|
108
|
+
/** 台账读口:没有该 id 的台账 ⇒ **null**(不是空视图 —— 「没开过账」与「开了账但一帧没收到」
|
|
109
|
+
* 是两件事,端侧据此分辨)。产物只读,按 frames/closed 缓存。 */
|
|
110
|
+
export declare function readWorkflowActivityLedger(workflowId: string): WorkflowActivityLedgerView | null;
|
|
111
|
+
/**
|
|
112
|
+
* **预热口**(幂等):fleet 流上见到 running workflow 行就调 —— 从 workflow 起跑积累工具事实,
|
|
113
|
+
* monitor 无论何时打开都有自那时起的完整台账。已有台账(哪怕已收口)即早退,**绝不开第二条流**。
|
|
114
|
+
*/
|
|
115
|
+
export declare function ensureWorkflowActivityLedger(cfg: WorkflowActivityLedgerConfig): void;
|
|
116
|
+
/** fleet 行终态/remove 的收口口(空流收口是主径;这里防重连定时器竞态)。数据保留只读。 */
|
|
117
|
+
export declare function stopWorkflowActivityLedger(workflowId: string): void;
|
|
118
|
+
/** 测试钩:清空全部台账(abort 在飞流)。 */
|
|
119
|
+
export declare function resetWorkflowActivityLedgers(): void;
|
|
120
|
+
/**
|
|
121
|
+
* 测试钩:**不起 SSE 流**,直接把一帧喂进(缺则新建)指定 run 的台账 —— 折叠规则走产品那一份
|
|
122
|
+
* `foldWorkflowActivityFrame`,不是测试自己抄一份。建出来的台账是 closed 的,后续
|
|
123
|
+
* `ensureWorkflowActivityLedger` 命中它即早退,离线单测因此永不触网。
|
|
124
|
+
*/
|
|
125
|
+
export declare function __feedWorkflowActivityFrameForTests(workflowId: string, frame: WorkflowStreamEvent): void;
|
|
66
126
|
/**
|
|
67
127
|
* createLiveWorkflowSource({ baseUrl, authToken, principal, workflowId? }) — the LIVE workflow-monitor source.
|
|
68
128
|
*
|
package/dist/workflowClient.js
CHANGED
|
@@ -180,6 +180,10 @@ function projectAgent(row, index) {
|
|
|
180
180
|
const last = activity.length ? activity[activity.length - 1] : undefined;
|
|
181
181
|
const label = typeof row.label === 'string' && row.label.trim() ? row.label.trim() : id || 'agent';
|
|
182
182
|
const agent = { id, agentId, label, state };
|
|
183
|
+
// #242 批 1:callKey 是 SSE 侧 activity 台账的**主键**(agent_activity 帧必带),此前投影把它丢了
|
|
184
|
+
// ⇒ 合并层只剩 label 一把钥匙,重名 agent 只能整组跳过。带出来之后重名也能精确归属。
|
|
185
|
+
if (typeof row.callKey === 'string' && row.callKey)
|
|
186
|
+
agent.callKey = row.callKey;
|
|
183
187
|
if (typeof row.model === 'string')
|
|
184
188
|
agent.model = row.model;
|
|
185
189
|
if (typeof row.tokens === 'number')
|
|
@@ -337,6 +341,521 @@ const STREAM_DROP_BACKOFF_MS = 1000;
|
|
|
337
341
|
/** stream() ticker 帧触达时,距上一次 `refresh()` 的最小间隔——节流 re-GET 频率,不是退避:
|
|
338
342
|
* 帧到来表示「有变化」,但没必要每一帧都打一次权威快照。 */
|
|
339
343
|
const REFRESH_THROTTLE_MS = 400;
|
|
344
|
+
// ══ #242 批 1(台账 A-028.8)—— 运行期 activity 台账:模块级常驻 + 每 workflowId 恰一条 SSE ═══
|
|
345
|
+
//
|
|
346
|
+
// ── 修前的两半(组合 = 同一 workflowId 上两条 SSE、两份台账)────────────────────────────────
|
|
347
|
+
// ① core:workflow run 记录的 `agents[].toolCalls` / `agents[].activity` **只在 agent_end 折入**
|
|
348
|
+
// ⇒ 运行中 agent 的 GET 详情永远拿不到工具信息;运行期的工具事实**只在** detail SSE 上
|
|
349
|
+
// (`agent_activity` 带 callKey/label/toolName/arg/phase/ts;`agent_end` 带权威 toolCalls;
|
|
350
|
+
// `agent_end.modelResolved` 更是 core 记录从不折入的量 —— SSE 是唯一到达路径)。
|
|
351
|
+
// ② 本文件:把这条 SSE 只当**变化 ticker**(`for await (const _frame …) { void _frame }`),帧体整丢。
|
|
352
|
+
// ⇒ 壳(sema-cli `workflowLiveActivity.ts`)只好**另起一条同 id 的 SSE**自建模块级台账再自己合并。
|
|
353
|
+
//
|
|
354
|
+
// ── 本段(单流化)──────────────────────────────────────────────────────────────────────────
|
|
355
|
+
// 台账收进包内,并且成为该 workflowId 的**唯一 SSE 属主**:
|
|
356
|
+
// · `ensureWorkflowActivityLedger(cfg)` —— **预热口**(幂等):fleet 流上 workflow 行一出现就调,
|
|
357
|
+
// 从 workflow 起跑积累工具事实。🔴 台账是**模块级常驻**而非 per-controller —— 这是刻意的:
|
|
358
|
+
// 壳侧一版做成「开 monitor 才开流」,实测两个洞(打开前的 beat 全丢:monitor 刚开的头几秒
|
|
359
|
+
// stats 恒空;重开 monitor = 新流 = 账本清零失忆)。`createLiveWorkflowSource().dispose()`
|
|
360
|
+
// 只收它自己的 GET 循环,**不掐台账**,两个洞因此不会随上收复发。
|
|
361
|
+
// · `createLiveWorkflowSource` 不再自开流,改搭台账的 change-tick 做节流 re-GET(逐拍等价)。
|
|
362
|
+
// · `readWorkflowActivityLedger(id)` —— 台账读口(端侧合并层消费:壳 `mergeSnapshot` 只补
|
|
363
|
+
// base 缺席的键)。归属钥匙 **callKey 优先**(agent_activity 帧必带、GET 行也带),
|
|
364
|
+
// label 次之;同 label 多个非 replay 的 agent_start ⇒ 归属不可判 ⇒ label 读口**整组跳过**
|
|
365
|
+
// (诚实缺席,绝不猜挂)。
|
|
366
|
+
// 限界(如实):台账自预热口首次调用起积累 —— 壳自己没看过的时段(重启后 attach 老 run)仍是
|
|
367
|
+
// 下界;`agent_end.toolCalls` 到手后计数转权威。
|
|
368
|
+
/** detail 页 Activity 尾窗是 3(HIO),折叠上限给足余量即可;超限丢最老(与 server 存储折入同向)。 */
|
|
369
|
+
const MAX_WORKFLOW_ACTIVITY_CALLS = 40;
|
|
370
|
+
/** 常驻台账上限(并发运行的 workflow 数远小于此;超限先逐已收口的,再逐最老)。 */
|
|
371
|
+
const MAX_WORKFLOW_ACTIVITY_LEDGERS = 16;
|
|
372
|
+
/** 运行中的意外断流回退(终态收口是**干净空流**自然退出,不再重连)。 */
|
|
373
|
+
const WORKFLOW_ACTIVITY_RECONNECT_MS = 1500;
|
|
374
|
+
/** 「一帧都没收到就抛」的连续次数上限 —— 满了才收口。1 次即收口会把瞬时不可达固化成永久失活;
|
|
375
|
+
* 不设上限则 S8 关闭(恒 501)的部署上会无限重连。3 次 ≈ 3s 观察窗,两头都不失守。 */
|
|
376
|
+
const MAX_ACTIVITY_STREAM_ERROR_STREAK = 3;
|
|
377
|
+
const activityLedgers = new Map();
|
|
378
|
+
function newActivityLedger(workflowId, scope) {
|
|
379
|
+
return {
|
|
380
|
+
workflowId,
|
|
381
|
+
scope,
|
|
382
|
+
testOnly: false,
|
|
383
|
+
displaced: false,
|
|
384
|
+
byCallKey: new Map(),
|
|
385
|
+
labelAccs: new Map(),
|
|
386
|
+
startedLabels: new Map(),
|
|
387
|
+
ambiguous: new Set(),
|
|
388
|
+
closed: false,
|
|
389
|
+
closedReason: null,
|
|
390
|
+
connected: false,
|
|
391
|
+
frames: 0,
|
|
392
|
+
consumers: 0,
|
|
393
|
+
lastError: null,
|
|
394
|
+
errorStreak: 0,
|
|
395
|
+
abort: new AbortController(),
|
|
396
|
+
ticks: new Set(),
|
|
397
|
+
view: null,
|
|
398
|
+
viewFrames: -1,
|
|
399
|
+
viewClosed: false,
|
|
400
|
+
viewLastError: null,
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
/** 连线身份相等:凭证只比较、不落键不落日志。relay 形按 mode 比。 */
|
|
404
|
+
function sameActivityScope(a, b) {
|
|
405
|
+
if (a.baseUrl !== b.baseUrl)
|
|
406
|
+
return false;
|
|
407
|
+
if (a.principal !== b.principal)
|
|
408
|
+
return false;
|
|
409
|
+
const at = a.authToken;
|
|
410
|
+
const bt = b.authToken;
|
|
411
|
+
if (typeof at === 'string' || typeof bt === 'string')
|
|
412
|
+
return at === bt;
|
|
413
|
+
return at.mode === bt.mode;
|
|
414
|
+
}
|
|
415
|
+
function newActivityAcc(label) {
|
|
416
|
+
return { label, calls: new Map(), order: [], startCount: 0 };
|
|
417
|
+
}
|
|
418
|
+
function registerLabelAcc(l, label, acc) {
|
|
419
|
+
const accs = l.labelAccs.get(label);
|
|
420
|
+
if (!accs) {
|
|
421
|
+
l.labelAccs.set(label, [acc]);
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
if (!accs.includes(acc))
|
|
425
|
+
accs.push(acc);
|
|
426
|
+
}
|
|
427
|
+
/** label 位是否**唯一可判**:恰一个 acc、且没被重名探测标记。两条缺一不可 —— 前者盖住
|
|
428
|
+
* 「中途 attach 只见 beat」「replayed 重跑」这两条不经 agent_start 的产生路径,后者是显式诊断位。 */
|
|
429
|
+
function soleAccOfLabel(l, label) {
|
|
430
|
+
if (l.ambiguous.has(label))
|
|
431
|
+
return undefined;
|
|
432
|
+
const accs = l.labelAccs.get(label);
|
|
433
|
+
if (!accs || accs.length !== 1)
|
|
434
|
+
return undefined;
|
|
435
|
+
return accs[0];
|
|
436
|
+
}
|
|
437
|
+
/** callKey 在场:它是主键。同 label 已有一条**尚未绑 callKey** 的 acc(agent_start 无 callKey 在先)
|
|
438
|
+
* ⇒ 认领它,不新建第二份;否则新建并登记。 */
|
|
439
|
+
function accByCallKey(l, callKey, label) {
|
|
440
|
+
const hit = l.byCallKey.get(callKey);
|
|
441
|
+
if (hit)
|
|
442
|
+
return hit;
|
|
443
|
+
// 🔴 只认领「还没被 label-only 的 agent_end 写过权威事实」的 orphan —— 带事实的那种一旦认领,
|
|
444
|
+
// 另一个 agent 的 toolCalls/modelResolved 就永久钉在这个 callKey 上了(callKey 读口不过
|
|
445
|
+
// 重名过滤)。不认领时新建第二份 ⇒ 该 label 变成非唯一 ⇒ label 读口自动改口诚实缺席。
|
|
446
|
+
const orphan = (l.labelAccs.get(label) ?? []).find(a => a.callKey === undefined && a.hasLabelOnlyEndFacts !== true);
|
|
447
|
+
const acc = orphan ?? newActivityAcc(label);
|
|
448
|
+
acc.callKey = callKey;
|
|
449
|
+
l.byCallKey.set(callKey, acc);
|
|
450
|
+
registerLabelAcc(l, label, acc);
|
|
451
|
+
return acc;
|
|
452
|
+
}
|
|
453
|
+
/** 只有 label 的写入(`agent_end` 帧不带 callKey)。🔴 该 label 下不止一个 acc ⇒ **不记**
|
|
454
|
+
* ——错值比缺席更坏:写进去会被 callKey 读口原样读出来,把另一个 agent 的权威 toolCalls/
|
|
455
|
+
* modelResolved 冒充成这一个的(对抗复审 [high] 采纳)。 */
|
|
456
|
+
function accByLabelForWrite(l, label) {
|
|
457
|
+
const accs = l.labelAccs.get(label);
|
|
458
|
+
if (accs && accs.length > 1)
|
|
459
|
+
return undefined;
|
|
460
|
+
if (l.ambiguous.has(label))
|
|
461
|
+
return undefined;
|
|
462
|
+
if (accs && accs.length === 1)
|
|
463
|
+
return accs[0];
|
|
464
|
+
const acc = newActivityAcc(label);
|
|
465
|
+
registerLabelAcc(l, label, acc);
|
|
466
|
+
return acc;
|
|
467
|
+
}
|
|
468
|
+
/** 一帧 → 台账。开集帧形:只读认得的键,其余原样忽略(不认得的 event 直接落空)。 */
|
|
469
|
+
function foldWorkflowActivityFrame(l, frame) {
|
|
470
|
+
l.frames += 1;
|
|
471
|
+
if (frame.event === 'agent_start') {
|
|
472
|
+
const label = frame.data.label;
|
|
473
|
+
if (typeof label !== 'string' || !label)
|
|
474
|
+
return;
|
|
475
|
+
const n = (l.startedLabels.get(label) ?? 0) + 1;
|
|
476
|
+
l.startedLabels.set(label, n);
|
|
477
|
+
// 重跑(replayed)不算重名;同 label 第二个非 replay start ⇒ 归属不可判。
|
|
478
|
+
if (n > 1 && frame.data.replayed !== true)
|
|
479
|
+
l.ambiguous.add(label);
|
|
480
|
+
const callKey = frame.data.callKey;
|
|
481
|
+
const acc = typeof callKey === 'string' && callKey
|
|
482
|
+
? accByCallKey(l, callKey, label)
|
|
483
|
+
: accByLabelForWrite(l, label);
|
|
484
|
+
if (!acc)
|
|
485
|
+
return;
|
|
486
|
+
const requested = frame.data.model;
|
|
487
|
+
if (typeof requested === 'string' && requested)
|
|
488
|
+
acc.requestedModel = requested;
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
if (frame.event === 'agent_end') {
|
|
492
|
+
const label = frame.data.label;
|
|
493
|
+
if (typeof label !== 'string' || !label)
|
|
494
|
+
return;
|
|
495
|
+
// agent_end 帧**不带 callKey**(wire 直证)⇒ 只有 label 一把钥匙;重名时不记。
|
|
496
|
+
const acc = accByLabelForWrite(l, label);
|
|
497
|
+
if (!acc)
|
|
498
|
+
return;
|
|
499
|
+
if (typeof frame.data.toolCalls === 'number') {
|
|
500
|
+
acc.endedToolCalls = frame.data.toolCalls;
|
|
501
|
+
if (acc.callKey === undefined)
|
|
502
|
+
acc.hasLabelOnlyEndFacts = true;
|
|
503
|
+
}
|
|
504
|
+
const resolved = frame.data.modelResolved;
|
|
505
|
+
if (typeof resolved === 'string' && resolved) {
|
|
506
|
+
acc.resolvedModel = resolved;
|
|
507
|
+
if (acc.callKey === undefined)
|
|
508
|
+
acc.hasLabelOnlyEndFacts = true;
|
|
509
|
+
}
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
if (frame.event !== 'agent_activity')
|
|
513
|
+
return;
|
|
514
|
+
const d = frame.data;
|
|
515
|
+
if (typeof d.label !== 'string' || !d.label)
|
|
516
|
+
return;
|
|
517
|
+
if (typeof d.toolName !== 'string' || !d.toolName)
|
|
518
|
+
return;
|
|
519
|
+
const acc = typeof d.callKey === 'string' && d.callKey
|
|
520
|
+
? accByCallKey(l, d.callKey, d.label)
|
|
521
|
+
: accByLabelForWrite(l, d.label);
|
|
522
|
+
if (!acc)
|
|
523
|
+
return;
|
|
524
|
+
if (typeof d.ts === 'number')
|
|
525
|
+
acc.lastTs = d.ts;
|
|
526
|
+
const key = typeof d.toolCallId === 'string' && d.toolCallId ? d.toolCallId : `#${acc.order.length}`;
|
|
527
|
+
if (d.phase === 'start')
|
|
528
|
+
acc.startCount += 1;
|
|
529
|
+
const prev = acc.calls.get(key);
|
|
530
|
+
const arg = typeof d.arg === 'string' && d.arg.trim() ? oneLineSummary(d.arg.trim()) : prev?.summary;
|
|
531
|
+
const folded = { name: d.toolName };
|
|
532
|
+
if (arg)
|
|
533
|
+
folded.summary = arg;
|
|
534
|
+
if (!acc.calls.has(key)) {
|
|
535
|
+
acc.order.push(key);
|
|
536
|
+
// 上限:丢最老(order 与 map 同步),detail 尾窗只看最近几条。
|
|
537
|
+
while (acc.order.length > MAX_WORKFLOW_ACTIVITY_CALLS) {
|
|
538
|
+
const drop = acc.order.shift();
|
|
539
|
+
if (drop !== undefined)
|
|
540
|
+
acc.calls.delete(drop);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
acc.calls.set(key, { ...prev, ...folded });
|
|
544
|
+
}
|
|
545
|
+
function projectActivityAcc(acc) {
|
|
546
|
+
const activity = acc.order
|
|
547
|
+
.map(k => acc.calls.get(k))
|
|
548
|
+
.filter((c) => !!c)
|
|
549
|
+
.map(c => ({ name: c.name, ...(c.summary ? { summary: c.summary } : {}) }));
|
|
550
|
+
const view = {
|
|
551
|
+
label: acc.label,
|
|
552
|
+
// agent_end 的权威值优先,否则 start 计数下界。
|
|
553
|
+
toolCalls: acc.endedToolCalls ?? acc.startCount,
|
|
554
|
+
activity,
|
|
555
|
+
};
|
|
556
|
+
if (acc.callKey !== undefined)
|
|
557
|
+
view.callKey = acc.callKey;
|
|
558
|
+
const tail = activity.length > 0 ? activity[activity.length - 1] : undefined;
|
|
559
|
+
if (tail) {
|
|
560
|
+
view.lastToolName = tail.name;
|
|
561
|
+
if (tail.summary)
|
|
562
|
+
view.lastToolSummary = tail.summary;
|
|
563
|
+
}
|
|
564
|
+
if (acc.requestedModel !== undefined)
|
|
565
|
+
view.requestedModel = acc.requestedModel;
|
|
566
|
+
if (acc.resolvedModel !== undefined)
|
|
567
|
+
view.resolvedModel = acc.resolvedModel;
|
|
568
|
+
if (acc.lastTs !== undefined)
|
|
569
|
+
view.lastProgressAt = acc.lastTs;
|
|
570
|
+
return view;
|
|
571
|
+
}
|
|
572
|
+
/** 台账读口:没有该 id 的台账 ⇒ **null**(不是空视图 —— 「没开过账」与「开了账但一帧没收到」
|
|
573
|
+
* 是两件事,端侧据此分辨)。产物只读,按 frames/closed 缓存。 */
|
|
574
|
+
export function readWorkflowActivityLedger(workflowId) {
|
|
575
|
+
const l = activityLedgers.get(workflowId);
|
|
576
|
+
if (!l)
|
|
577
|
+
return null;
|
|
578
|
+
if (l.view !== null &&
|
|
579
|
+
l.viewFrames === l.frames &&
|
|
580
|
+
l.viewClosed === l.closed &&
|
|
581
|
+
l.viewLastError === l.lastError) {
|
|
582
|
+
return l.view;
|
|
583
|
+
}
|
|
584
|
+
const projected = new Map();
|
|
585
|
+
const viewOf = (acc) => {
|
|
586
|
+
let v = projected.get(acc);
|
|
587
|
+
if (!v) {
|
|
588
|
+
v = projectActivityAcc(acc);
|
|
589
|
+
projected.set(acc, v);
|
|
590
|
+
}
|
|
591
|
+
return v;
|
|
592
|
+
};
|
|
593
|
+
const byCallKey = new Map();
|
|
594
|
+
for (const [k, acc] of l.byCallKey)
|
|
595
|
+
byCallKey.set(k, viewOf(acc));
|
|
596
|
+
const byLabel = new Map();
|
|
597
|
+
for (const label of l.labelAccs.keys()) {
|
|
598
|
+
// 🔴 归属不可判(同 label 多个 acc / 重名探测命中)⇒ 整组跳过(诚实缺席,绝不猜挂)。
|
|
599
|
+
// callKey 读口不受影响 —— 它才是真钥匙。
|
|
600
|
+
const sole = soleAccOfLabel(l, label);
|
|
601
|
+
if (!sole)
|
|
602
|
+
continue;
|
|
603
|
+
byLabel.set(label, viewOf(sole));
|
|
604
|
+
}
|
|
605
|
+
l.view = {
|
|
606
|
+
workflowId,
|
|
607
|
+
byCallKey,
|
|
608
|
+
byLabel,
|
|
609
|
+
ambiguousLabels: new Set(l.ambiguous),
|
|
610
|
+
closed: l.closed,
|
|
611
|
+
frames: l.frames,
|
|
612
|
+
lastError: l.lastError,
|
|
613
|
+
};
|
|
614
|
+
l.viewFrames = l.frames;
|
|
615
|
+
l.viewClosed = l.closed;
|
|
616
|
+
l.viewLastError = l.lastError;
|
|
617
|
+
return l.view;
|
|
618
|
+
}
|
|
619
|
+
/** 一拍。一个抛异常的订阅者不许弄死后台消费循环(与本源全篇 fail-soft 一致)。 */
|
|
620
|
+
function emitActivityTick(l) {
|
|
621
|
+
for (const t of [...l.ticks]) {
|
|
622
|
+
try {
|
|
623
|
+
t();
|
|
624
|
+
}
|
|
625
|
+
catch (err) {
|
|
626
|
+
if (engineWireDebugEnabled()) {
|
|
627
|
+
hostLog('debug', `[sema][workflow] activity tick subscriber threw (ignored): ${String(err)}`);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
/** 等下一拍:'tick' = 有帧到达 / 'closed' = 台账收口 / 'abort' = 宿主 dispose。 */
|
|
633
|
+
function waitActivityTick(l, signal) {
|
|
634
|
+
if (signal.aborted)
|
|
635
|
+
return Promise.resolve('abort');
|
|
636
|
+
if (l.closed)
|
|
637
|
+
return Promise.resolve('closed');
|
|
638
|
+
return new Promise(resolve => {
|
|
639
|
+
let settled = false;
|
|
640
|
+
const finish = (r) => {
|
|
641
|
+
if (settled)
|
|
642
|
+
return;
|
|
643
|
+
settled = true;
|
|
644
|
+
l.ticks.delete(onTick);
|
|
645
|
+
signal.removeEventListener('abort', onAbort);
|
|
646
|
+
resolve(r);
|
|
647
|
+
};
|
|
648
|
+
const onTick = () => finish(l.closed ? 'closed' : 'tick');
|
|
649
|
+
const onAbort = () => finish('abort');
|
|
650
|
+
l.ticks.add(onTick);
|
|
651
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
/** 台账的 SSE 消费循环 —— **该 workflowId 唯一的一条流**。 */
|
|
655
|
+
async function consumeWorkflowActivity(l, cfg) {
|
|
656
|
+
// 唯一构造点 + 显式 maxRetries: 2(与 createLiveWorkflowSource 同口径)。
|
|
657
|
+
const client = makeEngineWireClient({
|
|
658
|
+
baseUrl: cfg.baseUrl,
|
|
659
|
+
...(cfg.authToken ? { token: cfg.authToken } : {}),
|
|
660
|
+
principal: cfg.principal,
|
|
661
|
+
maxRetries: 2,
|
|
662
|
+
...(cfg.fetchImpl ? { fetchImpl: cfg.fetchImpl } : {}),
|
|
663
|
+
});
|
|
664
|
+
// fail-soft:工厂造不出 client ⇒ 台账收口、读口如实报 closed(绝不抛进宿主)。
|
|
665
|
+
if (client === null) {
|
|
666
|
+
l.closed = true;
|
|
667
|
+
l.closedReason = 'error';
|
|
668
|
+
l.lastError = 'client construction failed (invalid baseUrl/credentials)';
|
|
669
|
+
emitActivityTick(l);
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
while (!l.closed && !l.abort.signal.aborted) {
|
|
673
|
+
let sawFrame = false;
|
|
674
|
+
let threw = false;
|
|
675
|
+
try {
|
|
676
|
+
const stream = client.workflows.stream(l.workflowId, {
|
|
677
|
+
signal: l.abort.signal,
|
|
678
|
+
...engineSessionParamSpread(),
|
|
679
|
+
});
|
|
680
|
+
for await (const frame of stream) {
|
|
681
|
+
if (l.closed)
|
|
682
|
+
return;
|
|
683
|
+
sawFrame = true;
|
|
684
|
+
l.connected = true;
|
|
685
|
+
try {
|
|
686
|
+
foldWorkflowActivityFrame(l, frame);
|
|
687
|
+
}
|
|
688
|
+
catch {
|
|
689
|
+
// 单帧折叠失败不拆流(开集帧形;台账缺一拍好过整层失效)。
|
|
690
|
+
}
|
|
691
|
+
emitActivityTick(l);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
catch (err) {
|
|
695
|
+
if (l.closed || l.abort.signal.aborted)
|
|
696
|
+
return;
|
|
697
|
+
threw = true;
|
|
698
|
+
l.lastError = err instanceof Error ? err.message : String(err);
|
|
699
|
+
if (engineWireDebugEnabled()) {
|
|
700
|
+
hostLog('debug', `[sema][workflow] activity stream drop: ${l.lastError}`);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
l.connected = false;
|
|
704
|
+
// 🔴 两种「本轮零帧」必须分开裁(对抗复审 [high] 采纳 —— 合并成一条会把**一次瞬时不可达**
|
|
705
|
+
// 固化成台账永久失活,而 ensure 对已存在台账恒早退 ⇒ 引擎恢复后 SSE 再也开不回来,
|
|
706
|
+
// 详情页此后只剩 GET 轮询,而运行期 tool/activity 恰恰是 GET 结构上给不出的量):
|
|
707
|
+
// · **干净空流**(迭代器正常结束、一帧没有)= run 终态/不在本副本(core subscribeWorkflow
|
|
708
|
+
// 非活跃即空迭代器)⇒ 收口,GET 此后是权威;
|
|
709
|
+
// · **抛在首帧前**(501/404/连接失败)= 可能只是这一拍不可达 ⇒ 退避重连,连击满
|
|
710
|
+
// MAX_ACTIVITY_STREAM_ERROR_STREAK 次(全程零帧)才收口,lastError 留痕。
|
|
711
|
+
if (!sawFrame && !threw) {
|
|
712
|
+
l.closed = true;
|
|
713
|
+
l.closedReason = 'terminal';
|
|
714
|
+
emitActivityTick(l);
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
if (sawFrame)
|
|
718
|
+
l.errorStreak = 0;
|
|
719
|
+
else if (threw) {
|
|
720
|
+
l.errorStreak += 1;
|
|
721
|
+
if (l.errorStreak >= MAX_ACTIVITY_STREAM_ERROR_STREAK) {
|
|
722
|
+
// 🔴 只是「连不上」⇒ 标 error(可被下一次 ensure 重开),不是终态。
|
|
723
|
+
l.closed = true;
|
|
724
|
+
l.closedReason = 'error';
|
|
725
|
+
emitActivityTick(l);
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
emitActivityTick(l);
|
|
730
|
+
if (!l.closed && !l.abort.signal.aborted) {
|
|
731
|
+
await abortableSleep(WORKFLOW_ACTIVITY_RECONNECT_MS, l.abort.signal);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
emitActivityTick(l);
|
|
735
|
+
}
|
|
736
|
+
/**
|
|
737
|
+
* **预热口**(幂等):fleet 流上见到 running workflow 行就调 —— 从 workflow 起跑积累工具事实,
|
|
738
|
+
* monitor 无论何时打开都有自那时起的完整台账。已有台账(哪怕已收口)即早退,**绝不开第二条流**。
|
|
739
|
+
*/
|
|
740
|
+
export function ensureWorkflowActivityLedger(cfg) {
|
|
741
|
+
if (!cfg.workflowId)
|
|
742
|
+
return;
|
|
743
|
+
const scope = {
|
|
744
|
+
baseUrl: cfg.baseUrl,
|
|
745
|
+
principal: cfg.principal,
|
|
746
|
+
authToken: cfg.authToken,
|
|
747
|
+
};
|
|
748
|
+
const existing = activityLedgers.get(cfg.workflowId);
|
|
749
|
+
if (existing) {
|
|
750
|
+
// 测试钩建的离线台账:恒早退(不换、不起流)。
|
|
751
|
+
if (existing.testOnly)
|
|
752
|
+
return;
|
|
753
|
+
if (sameActivityScope(existing.scope, scope)) {
|
|
754
|
+
// 🔴 同连线身份:只有**因错误**收口的才重开(对抗复审 R2 [high] 采纳)——干净空流(run
|
|
755
|
+
// 终态)与显式 stop 是永久收口,重开等于跟一个已经结束的 run 死磕。重开**复用同一条
|
|
756
|
+
// 台账对象**,已积累的事实原样保留(重连不失忆)。
|
|
757
|
+
if (existing.closed && existing.closedReason === 'error') {
|
|
758
|
+
existing.closed = false;
|
|
759
|
+
existing.closedReason = null;
|
|
760
|
+
existing.errorStreak = 0;
|
|
761
|
+
existing.abort = new AbortController();
|
|
762
|
+
void consumeWorkflowActivity(existing, cfg);
|
|
763
|
+
}
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
// 🔴 同 id 但**连线身份变了**(换引擎 baseUrl / 换 principal / 换凭证)⇒ 旧台账连的是另一台
|
|
767
|
+
// 服务,复用它等于把上一处的活动事实当成这一处的。整条换新,并给旧的打 displaced ——
|
|
768
|
+
// 旧台账的消费者据此**不再重新 ensure**(否则两个不同 scope 的消费者会互相把槽位换来换去)。
|
|
769
|
+
existing.closed = true;
|
|
770
|
+
existing.closedReason = 'stopped';
|
|
771
|
+
existing.displaced = true;
|
|
772
|
+
existing.abort.abort();
|
|
773
|
+
emitActivityTick(existing);
|
|
774
|
+
activityLedgers.delete(cfg.workflowId);
|
|
775
|
+
}
|
|
776
|
+
if (activityLedgers.size >= MAX_WORKFLOW_ACTIVITY_LEDGERS) {
|
|
777
|
+
// 逐出顺序:① 已收口的(数据只读,重开 monitor 走 GET 权威)→ ② 无在飞消费者的最老一条。
|
|
778
|
+
// 🔴 判活用**消费者租约** `consumers` 而不是瞬时的 `ticks.size`(对抗复审 R2 [medium]):
|
|
779
|
+
// 消费者被一帧唤醒、正在跑一发慢 GET 的那一窗里 ticks 是空的,按它判会把在飞台账逐掉。
|
|
780
|
+
// 🔴 **绝不逐出正在被消费的台账**:那会当场丢掉它积累的 SSE-only 历史,而被叫醒的消费方
|
|
781
|
+
// 退避后又会重建、再逐掉另一条在飞的 —— 逐出/重连自激。
|
|
782
|
+
// 全 16 条都有在飞消费者时**不建新账**(如实降级:该 id 的详情页退回 GET 轮询)。
|
|
783
|
+
let victim;
|
|
784
|
+
for (const [id, other] of activityLedgers) {
|
|
785
|
+
if (other.closed && other.consumers === 0) {
|
|
786
|
+
victim = id;
|
|
787
|
+
break;
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
if (victim === undefined) {
|
|
791
|
+
for (const [id, other] of activityLedgers) {
|
|
792
|
+
if (other.consumers === 0) {
|
|
793
|
+
victim = id;
|
|
794
|
+
break;
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
if (victim === undefined) {
|
|
799
|
+
if (engineWireDebugEnabled()) {
|
|
800
|
+
hostLog('debug', `[sema][workflow] activity ledger cap ${MAX_WORKFLOW_ACTIVITY_LEDGERS} reached with every entry consumed — not opening one for ${cfg.workflowId} (detail falls back to GET polling)`);
|
|
801
|
+
}
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
const evicted = activityLedgers.get(victim);
|
|
805
|
+
if (evicted) {
|
|
806
|
+
evicted.closed = true;
|
|
807
|
+
evicted.closedReason = 'stopped';
|
|
808
|
+
evicted.abort.abort();
|
|
809
|
+
emitActivityTick(evicted);
|
|
810
|
+
}
|
|
811
|
+
activityLedgers.delete(victim);
|
|
812
|
+
}
|
|
813
|
+
const ledger = newActivityLedger(cfg.workflowId, scope);
|
|
814
|
+
activityLedgers.set(cfg.workflowId, ledger);
|
|
815
|
+
void consumeWorkflowActivity(ledger, cfg);
|
|
816
|
+
}
|
|
817
|
+
/** fleet 行终态/remove 的收口口(空流收口是主径;这里防重连定时器竞态)。数据保留只读。 */
|
|
818
|
+
export function stopWorkflowActivityLedger(workflowId) {
|
|
819
|
+
const l = activityLedgers.get(workflowId);
|
|
820
|
+
if (!l)
|
|
821
|
+
return;
|
|
822
|
+
l.closed = true;
|
|
823
|
+
l.closedReason = 'stopped';
|
|
824
|
+
l.abort.abort();
|
|
825
|
+
emitActivityTick(l);
|
|
826
|
+
}
|
|
827
|
+
/** 测试钩:清空全部台账(abort 在飞流)。 */
|
|
828
|
+
export function resetWorkflowActivityLedgers() {
|
|
829
|
+
for (const l of activityLedgers.values()) {
|
|
830
|
+
l.closed = true;
|
|
831
|
+
l.closedReason = 'stopped';
|
|
832
|
+
l.abort.abort();
|
|
833
|
+
emitActivityTick(l);
|
|
834
|
+
}
|
|
835
|
+
activityLedgers.clear();
|
|
836
|
+
}
|
|
837
|
+
/**
|
|
838
|
+
* 测试钩:**不起 SSE 流**,直接把一帧喂进(缺则新建)指定 run 的台账 —— 折叠规则走产品那一份
|
|
839
|
+
* `foldWorkflowActivityFrame`,不是测试自己抄一份。建出来的台账是 closed 的,后续
|
|
840
|
+
* `ensureWorkflowActivityLedger` 命中它即早退,离线单测因此永不触网。
|
|
841
|
+
*/
|
|
842
|
+
export function __feedWorkflowActivityFrameForTests(workflowId, frame) {
|
|
843
|
+
let l = activityLedgers.get(workflowId);
|
|
844
|
+
if (!l) {
|
|
845
|
+
l = newActivityLedger(workflowId, { baseUrl: '', principal: undefined, authToken: '' });
|
|
846
|
+
l.testOnly = true;
|
|
847
|
+
l.closed = true;
|
|
848
|
+
l.closedReason = 'stopped';
|
|
849
|
+
activityLedgers.set(workflowId, l);
|
|
850
|
+
}
|
|
851
|
+
// 🔴 只往**离线台账**里喂(对抗复审 R2 [medium] 采纳):这个钩虽在 `_` 前缀的测试钩面上
|
|
852
|
+
// (不进公开导出基线),但它此前能把伪造帧折进一条**真在跑**的台账。现在真台账一律拒绝
|
|
853
|
+
// —— 测试要用它必须先 `resetWorkflowActivityLedgers()`,离线契约从「靠调用方自觉」变成结构。
|
|
854
|
+
if (!l.testOnly)
|
|
855
|
+
return;
|
|
856
|
+
foldWorkflowActivityFrame(l, frame);
|
|
857
|
+
emitActivityTick(l);
|
|
858
|
+
}
|
|
340
859
|
/**
|
|
341
860
|
* createLiveWorkflowSource({ baseUrl, authToken, principal, workflowId? }) — the LIVE workflow-monitor source.
|
|
342
861
|
*
|
|
@@ -357,6 +876,8 @@ export function createLiveWorkflowSource(config) {
|
|
|
357
876
|
let lastGetAt = 0;
|
|
358
877
|
/** 入场打点用:只有第一发 get 是「入场」,后续都是 ticker 触发的 re-GET。 */
|
|
359
878
|
let first = true;
|
|
879
|
+
/** 台账槽位被别的连线身份顶掉过 ⇒ 本源放弃 activity 腿(防两个 scope 互相换账)。 */
|
|
880
|
+
let activityDisabled = false;
|
|
360
881
|
const listeners = new Set();
|
|
361
882
|
/** 通知订阅者「快照变了」。一个抛异常的订阅者不许弄死后台消费循环(fail-soft 与本源全篇一致)。 */
|
|
362
883
|
function notify() {
|
|
@@ -477,27 +998,74 @@ export function createLiveWorkflowSource(config) {
|
|
|
477
998
|
await abortableSleep(DEGRADED_BACKOFF_MS, abort.signal);
|
|
478
999
|
continue;
|
|
479
1000
|
}
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
1001
|
+
// 🔴 #242 批 1 单流化:本源**不再自开 SSE**。该 workflowId 的唯一 SSE 属主是模块级 activity
|
|
1002
|
+
// 台账(同时也是 fleet 行预热腿的属主);本源只搭它的 change-tick 做节流 re-GET —— 与旧
|
|
1003
|
+
// `for await (const _frame of stream)` 的逐拍语义等价,而连接数从「壳一条 + 本源一条」降到 1。
|
|
1004
|
+
// 帧体不再被丢弃:台账消费它(callKey 折叠 / agent_end.toolCalls 权威 / requested-resolved model)。
|
|
1005
|
+
if (!activityDisabled) {
|
|
1006
|
+
// 🔴 停手检查必须先于 ensure(主会话收货修,#242-E13 抓出的时序洞):循环尾的
|
|
1007
|
+
// `ledger.displaced` 检查读的是「本轮拿的对象」,而被顶掉发生在上一轮的 backoff 期
|
|
1008
|
+
// ——下一轮若先 ensure,本源会在检测到自己被顶之前就把别的连线身份的台账换掉
|
|
1009
|
+
// (伤害先于检测,ping-pong 防护形同虚设)。所以进入 ensure 前先看当前槽位:
|
|
1010
|
+
// 已被**另一连线身份**持有 ⇒ 本源就地停手降级(GET 轮询),绝不抢回。
|
|
1011
|
+
const cur = activityLedgers.get(id);
|
|
1012
|
+
if (cur &&
|
|
1013
|
+
!cur.testOnly &&
|
|
1014
|
+
!sameActivityScope(cur.scope, {
|
|
1015
|
+
baseUrl: config.baseUrl,
|
|
1016
|
+
principal: config.principal,
|
|
1017
|
+
authToken: config.authToken,
|
|
1018
|
+
})) {
|
|
1019
|
+
activityDisabled = true;
|
|
1020
|
+
if (engineWireDebugEnabled()) {
|
|
1021
|
+
hostLog('debug', `[sema][workflow] activity ledger for ${id} is held by another engine target/principal — this source stands down (GET polling only)`);
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
else {
|
|
1025
|
+
ensureWorkflowActivityLedger({
|
|
1026
|
+
baseUrl: config.baseUrl,
|
|
1027
|
+
authToken: config.authToken,
|
|
1028
|
+
principal: config.principal,
|
|
1029
|
+
workflowId: id,
|
|
1030
|
+
...(config.fetchImpl ? { fetchImpl: config.fetchImpl } : {}),
|
|
1031
|
+
});
|
|
493
1032
|
}
|
|
494
1033
|
}
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
1034
|
+
const ledger = activityDisabled ? undefined : activityLedgers.get(id);
|
|
1035
|
+
if (ledger) {
|
|
1036
|
+
// 🔴 消费者租约:整段消费期持有(不是瞬时的 tick 订阅)—— 逐出判活读的就是它,
|
|
1037
|
+
// 「被一帧唤醒、正在跑慢 GET」的那一窗里台账不会被误判成无人消费。
|
|
1038
|
+
ledger.consumers += 1;
|
|
1039
|
+
try {
|
|
1040
|
+
for (;;) {
|
|
1041
|
+
const beat = await waitActivityTick(ledger, abort.signal);
|
|
1042
|
+
if (beat === 'abort' || disposed)
|
|
1043
|
+
return;
|
|
1044
|
+
if (beat === 'tick')
|
|
1045
|
+
connected = true;
|
|
1046
|
+
if (Date.now() - lastGetAt >= REFRESH_THROTTLE_MS)
|
|
1047
|
+
await refresh(id, abort.signal);
|
|
1048
|
+
if (degraded)
|
|
1049
|
+
break;
|
|
1050
|
+
// 台账收口(run 终态 / 显式 stop / 错误连击 / 被别的连线身份顶掉)⇒ 退回外层的
|
|
1051
|
+
// 「退避 + 重取权威快照」;下一轮的 ensure 决定要不要重开(只有 error 收口会重开)。
|
|
1052
|
+
if (beat === 'closed')
|
|
1053
|
+
break;
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
finally {
|
|
1057
|
+
ledger.consumers -= 1;
|
|
1058
|
+
}
|
|
1059
|
+
// 🔴 本源的台账被**另一个连线身份**顶掉过 ⇒ 本源就此不再 ensure(否则两个 scope 的
|
|
1060
|
+
// 消费者会把同一个槽位来回换)。如实降级:详情页此后只走 GET 轮询。
|
|
1061
|
+
if (ledger.displaced) {
|
|
1062
|
+
activityDisabled = true;
|
|
1063
|
+
if (engineWireDebugEnabled()) {
|
|
1064
|
+
hostLog('debug', `[sema][workflow] activity ledger for ${id} was taken over by another engine target/principal — this source stops re-opening it (GET polling only)`);
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
499
1067
|
}
|
|
500
|
-
//
|
|
1068
|
+
// ledger 收口/降级 → 小回退后在下一轮重取权威快照(GET 此后是权威)。
|
|
501
1069
|
if (!disposed && !abort.signal.aborted)
|
|
502
1070
|
await abortableSleep(STREAM_DROP_BACKOFF_MS, abort.signal);
|
|
503
1071
|
}
|
|
@@ -43,6 +43,13 @@ export interface WorkflowAgent {
|
|
|
43
43
|
lastAttemptReason?: 'throttled' | 'user-retry' | 'stalled';
|
|
44
44
|
error?: string;
|
|
45
45
|
agentId?: string;
|
|
46
|
+
/**
|
|
47
|
+
* #242 批 1(A-028.8):`ctx.agent` 调用的**稳定确定性身份**(resume-journal key,
|
|
48
|
+
* SDK `WorkflowAgentRow.callKey`)。运行期 activity 台账的 SSE 帧(`agent_activity`)以它为键,
|
|
49
|
+
* 而此前投影把它丢了 —— 于是 label 成了合并层唯一的钥匙,同名 agent 只能整组跳过。
|
|
50
|
+
* 投影带出它之后,重名 label 也能精确归属;缺席(老引擎/老 run)时才回落到 label。
|
|
51
|
+
*/
|
|
52
|
+
callKey?: string;
|
|
46
53
|
/** detail "Prompt" body; promptPreview is the fallback when no transcript is loaded. */
|
|
47
54
|
promptPreview?: string;
|
|
48
55
|
/** detail "Activity" rows. */
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
| peer:wire 契约 | `@sema-agent/sdk` **>=6.17.2**(value-level,非 type-only) | `package.json` `peerDependencies` |
|
|
24
24
|
| peer:会话词汇表 | `@sema-agent/agent-types` **>=0.2.0**(type-only,零运行时) | 同上 |
|
|
25
25
|
| runtime dep | `diff` ^9.0.0(**唯一**一条;portability 门按**等值**钉死) | `package.json` `dependencies` |
|
|
26
|
-
| 公开导出面 | **
|
|
26
|
+
| 公开导出面 | **728** 个运行期符号(+ 34 个测试钩) | `scripts/public-export-baseline.json` 的 `count` / `testHookCount` —— **别手抄进别处,以该文件为准** |
|
|
27
27
|
| 常驻门 | 以 `scripts/gates-manifest.json` 的 `suites` 长度为准(**本档不抄这个数**) | `scripts/gates-manifest.json`;`npm test` 的名单等值门与它逐名对账 |
|
|
28
28
|
| 沿革档 | 0.29.0 起建 `CHANGELOG.md`;更早批次记账在 `src/index.ts` 文件头 + `docs/REFACTOR-LEDGER.md` | — |
|
|
29
29
|
|
|
@@ -99,7 +99,7 @@ SDK 核对「本包 import 的每个值级符号仍然导出」「`TaskStats.cos
|
|
|
99
99
|
|
|
100
100
|
## §2 公共导出面地图(按域)
|
|
101
101
|
|
|
102
|
-
> 全集真源 = `scripts/public-export-baseline.json` 的 `names`(**
|
|
102
|
+
> 全集真源 = `scripts/public-export-baseline.json` 的 `names`(**728** 项)。
|
|
103
103
|
> 本节**不逐名抄**,只给「域 → 承重导出 → 用途 → 实现锚」。承重导出 = 一个端为了让这个域干活
|
|
104
104
|
> **必须**直接调到的那几个符号;其余是它们的类型、变体与辅助位。
|
|
105
105
|
> 单一入口:`import { … } from '@sema-agent/client-core'`(`exports` 只有 `.` 一个;
|
|
@@ -118,7 +118,7 @@ SDK 核对「本包 import 的每个值级符号仍然导出」「`TaskStats.cos
|
|
|
118
118
|
端依赖这些类型是合法的,但**不要**拿基线 diff 当"类型面没变"的证据。
|
|
119
119
|
- `src/agentSession/contract.ts` 对基线贡献 **0** 项(纯类型模块,`export *` 在 dist 里是空转发)。
|
|
120
120
|
|
|
121
|
-
|
|
121
|
+
728 项的内部构成(帮助端估读表大小):**217** 项是 `SCREAMING_SNAKE` 常量数据表/词汇表
|
|
122
122
|
(矩阵、键集、env 名、锚串)而非可调用物;**4** 项是 PascalCase 运行期值
|
|
123
123
|
(`ControlRouter` / `ControlSafetyError` / `HitlBridge` / `HitlSafetyError`);
|
|
124
124
|
**39** 项是 `*For(sessionKey, …)` 的 per-session 变体(§6;其中 `engineNamespaceKeyFor` 是命名巧合 —— 参数是 baseUrl 不是 sessionKey,见域 14)。
|
|
@@ -138,7 +138,7 @@ SDK 核对「本包 import 的每个值级符号仍然导出」「`TaskStats.cos
|
|
|
138
138
|
| 9 | **能力/旋钮 wire 门族** | 86 | `projectAgentsForWire` / `awaitTaskAgentsWire` · `hooksForWire` · `mcpConfigsToSpecs` · `ultracodeForRequest` · `resolveWebSearch` + `buildWebSearchEnv` · `attachmentsForRequest` · `projectEffectiveBody` | 逐功能的「引擎有没有这个能力 + 这个字段怎么上 wire」投影器,由 `buildTaskRequest` 组装。🔴 `hooksForWire()` 是 **fail-closed**:无 `SettingsPort` ⇒ 返回 `undefined`(信任门,不是取值) | 17 个 `src/*WireCaps.ts` + `src/scratchpadWireCaps.ts`、`src/hooksWireCaps.ts`、`src/goalStopHook.ts`、`src/websearch/searchProviderPresets.ts` |
|
|
139
139
|
| 10 | **headless / 部署旋钮 wire** | 61 | `parseSandboxArgv` / `sandboxRequestFields` · `parseLimitsArgv` / `limitsForPrint` · `resolveHeadlessFinalVerify` · `resolveHeadlessPermissionMode` · `resolveHeadlessInteractiveTools` · `armDetachCancel` + `detachCancelArm` + `isDetachArmed` · `withHeadlessR1Reconnect` | `-p`/headless 车道的 env+argv 旋钮。🔴 `detachWire` 是**拆**的补偿:判定与 cancel-arm 台账在库,信号路径的裸 fetch 留宿主(`detachCancelArm()` 是取件口) | `src/sandboxWire.ts`、`scenarioWire.ts`、`finalVerifyWire.ts`、`limitsWire.ts`、`interactiveToolsWire.ts`、`headlessPermissionModeWire.ts`、`headlessReconnectWire.ts`、`detachWire.ts` |
|
|
140
140
|
| 11 | **模型目录与预算** | 64 | `resolveModelCatalog` · `loadCatalogWithSources` · `PROVIDER_PRESETS` / `MODEL_FAMILIES` · `defaultMaxTokensFor` · `getLiveModelCatalog` / `setLiveModelCatalogRefresher` · `providerAuthMethods` / `beginDeviceCodeAuth` · `providerCatalogRows` / `providerCatalogRowDetail` / `providerPresetById`(#244 F4 族D A-028.17:46 家表的规范折表层 —— 全表不重排、诚实缺席「model id typed in」,cli 目录/web 向导同一份折表)· `TIER_ORDER` / `CC_TIER_ALIASES` / `isTier` / `resolveTierBinding`(A-028.18:档位词表+校验+fail-open 降档派生单源;settings 存储归宿主) | 三层 provider 目录解析(线上 URL → 包内预设 → 用户覆盖)+ per-model `maxTokens` 封顶。线上腿需注入 `CatalogFetchJson`,缺席 ⇒ 整条不启用(`online.reason='no-fetch-port'`);缓存落盘经 `CatalogCachePort` | `src/model/{catalog,catalogLoader,providerAuth,providerPresets,providerCatalog,tierVocabulary}.ts`、`src/liveModelCatalog.ts`、`src/modelBudgetRule.ts`、`src/sessionModelLatch.ts`、`src/effortWire.ts` |
|
|
141
|
-
| 12 | **workflow 与后台工作视图** |
|
|
141
|
+
| 12 | **workflow 与后台工作视图** | 19 | `projectWorkflowRun` · `createLiveWorkflowSource` · `ensureWorkflowActivityLedger` · `readWorkflowActivityLedger` · `stopWorkflowActivityLedger` · `resetWorkflowActivityLedgers` · `createBackgroundView` · `projectBackgroundView` · `recordWorkflowAgentTaskId` · `agentDisplayStatus` | 活过一个 turn 的长任务读面:workflow run + 跨 session 后台任务归一表(`assistant.tasks` 与 fleet SSE **两源独立降级**) | `src/workflow.ts`、`src/workflowClient.ts`、`src/workflowMonitor.ts`、`src/agentSession/backgroundView.ts`(+ 纯类型 `src/agentSession/contract.ts`) |
|
|
142
142
|
| 13 | **座位 IPC 契约** | 33 | `LOCAL_SESSIONS_SPEC` · `SEAT_METHOD_NAMES` · `SEAT_EVENT_TYPES` · `isLocalSessionEvent` · `isToolPermissionRequest` · `toolPermissionRequestId` · `SEAT_VALIDATOR_KEY_COVERAGE` | desktop↔web 座位 IPC 契约的**单一真源**(此前两边各一份、名字零重合 ⇒ 编译器永远不会告诉你它们漂了)。🔴 加 verb 忘了加 `LOCAL_SESSIONS_SPEC` **不报错**:preload 不注册 channel、渲染端读到 `undefined` | `src/seatContract.ts`(**零 import**,纯类型 + 常量 + 纯谓词) |
|
|
143
143
|
| 14 | **宿主端口与会话槽** | 26 | `installHost` · `installHostFor` · `hostPortMisses(For)` · `DEFAULT_SESSION_KEY` · `hostEnv` · `unrefTimer` · `parseLocaleTag` / `pickUiLanguage`(#244 F4 族D A-028.20:locale tag 手术单源 + UI 语言判定;与 `resolveRegionHint` 双出口成文 —— 语言偏好域 en/zh ≠ 地址可达域 cn/intl/unknown,`zh-Hant` 前者 zh 后者 intl 是设计)· `engineNamespaceKeyFor` / `mergeSessionMapRecord` / `mergeEngineEntry`(A-028.12:会话 id 映射单一键形 + merge 判定;存储经 `SessionMapStorePort` 归端 —— cli 文件锁/原子写,web localStorage)| 进程/端级装配层(settings/fs/queue/timers/session/log/probe),与 per-turn 的 `AdapterContext` **分层**。头注的判定规则:**这个能力每 turn 都会变吗?** 会 ⇒ `ctx`;不会 ⇒ `installHost` | `src/host.ts`、`src/hostEnv.ts`、`src/sessionSlot.ts`、`src/unrefTimer.ts`、`src/env/{localeGeo,localeTag,uiLanguage}.ts`、`src/sessionMap.ts` |
|
|
144
144
|
| 15 | **控制面与传输** | 68 | `ControlRouter`(+ `ControlSafetyError`)· `makeEngineWireClient` + `resolveWireAuth` · `installEngineWireTarget` · `diagnoseSseIdleTear` / `isSseIdleError` · `attemptActiveRunSelfHeal` + `activeRunBusySignal` + `activeRunSelfHealRow` / `activeRunBusyHeadlessRow` · `kickEngineCapsProbe` / `engineCapTrue` · `mapBrainStatusToRetry` · `waitForClaimRelease` + `CLAIM_RELEASED_STATES` / `CLAIM_HELD_STATES` · `atMostOnceFailureClass` / `readSteerDelivery` · `INTERACTIVE_WAY_OUT`(默认出路串单源)· `normalizeWirePrincipal`(A-028.10:principal 在场性 trim 原语 —— 全空白=缺席不发头,engineWireTarget 两臂/makeEngineWireClient/壳 livePrincipal 同尺)· `classifyTurnWireError` / `isWireTransportError` / `isPreStreamDrainingReject` / `isResumeAtRejection` / `drainingRetryDelayMs` / `scenarioDenyFromError` + `WIRE_NETWORK_ERROR_PATTERN`(A-028.11/.13:turn 错误分型判定半场,人话文案与渲染归端) | 上行通道的**监管**半场(submit / steer / kill / 队列命令定序)+ 传输构造、caps 探测、SSE 断流分诊、**409 active-run 自愈** | `src/controlRouter.ts`、`steering.ts`、`sseIdleTriage.ts`、`retryStatus.ts`、`diagnostics.ts`、`engineWireSdk.ts`、`engineWireTarget.ts`、`src/principalWire.ts`、`src/wireErrorTriage.ts`、`engineSessionParam.ts`、`engineCapsCache.ts`、`liveInitToolFace.ts`、`adapter/activeRunSelfHeal.ts` |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sema-agent/client-core",
|
|
3
|
-
"version": "0.30.
|
|
3
|
+
"version": "0.30.6",
|
|
4
4
|
"description": "Client-side session runtime shared by every sema human client (TUI / web / desktop): sema wire frames (AgentEvent) -> CC session vocabulary (SDKMessage) with dual-plane output (transcript/chrome), deterministic transcript ids, lane discipline as a type, and the notification/dedup ledgers. Every CC-skin shape is collected here so the wire itself stays neutral. Blackboard [1832] design axioms; [1651]/[1652]/[1653] signed seam design. Renamed from @sema-agent/wire-cc-adapter (0.1.x).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|