@sema-agent/client-core 0.30.4 → 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 +57 -0
- package/README.md +1 -1
- package/dist/env/localeGeo.d.ts +0 -40
- package/dist/env/localeGeo.js +11 -10
- package/dist/env/localeTag.d.ts +40 -0
- package/dist/env/localeTag.js +44 -0
- package/dist/env/uiLanguage.d.ts +18 -0
- package/dist/env/uiLanguage.js +37 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +15 -0
- package/dist/model/providerCatalog.d.ts +65 -0
- package/dist/model/providerCatalog.js +74 -0
- package/dist/model/tierVocabulary.d.ts +43 -0
- package/dist/model/tierVocabulary.js +59 -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 +8 -8
- package/package.json +1 -1
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. */
|