@sema-agent/server 6.4.0 → 6.6.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.
@@ -280,6 +280,15 @@ export function createResolveSpec(ctx) {
280
280
  if (rawAddDirs !== undefined && !cwdHonored(config)) {
281
281
  logger.warn("task_additional_directories_ignored", { honored: false, sessionId: auth?.sessionId ?? null });
282
282
  }
283
+ // core 5.11.0([2625] Added①,[2597] P0 案三决③A):`additionalReadDirectories` —— 只读围栏拓宽。
284
+ // 与 additionalDirectories 同门(host 单用户车道)、同形校验(parseAdditionalDirectories:绝对路径
285
+ // 数组 fail-loud)、同「忽略必留声」纪律;差异只在语义:只进 READ 面(classify 读边界 auto-allow +
286
+ // read_file/grep 等 containment),写面照旧拒——读授权永不静默变写授权(core 侧保证,我们只透传)。
287
+ const rawAddReadDirs = body.additionalReadDirectories;
288
+ const additionalReadDirectories = cwdHonored(config) ? parseAdditionalDirectories(rawAddReadDirs) : undefined;
289
+ if (rawAddReadDirs !== undefined && !cwdHonored(config)) {
290
+ logger.warn("task_additional_read_directories_ignored", { honored: false, sessionId: auth?.sessionId ?? null });
291
+ }
283
292
  // body.model (explicit /model picker or run --model) WINS over settings.model (the config default); both gate
284
293
  // against the catalog allow-list via resolveTaskModel (a settings.model not in the catalog is ignored, never injected).
285
294
  // 🔴 TOB 档位联跑曾 FAIL(坐标级):the gate must see the TIER-EXPANDED catalog — expandTiers
@@ -320,7 +329,7 @@ export function createResolveSpec(ctx) {
320
329
  metrics.inc("images_omitted_total", { model: picked.model });
321
330
  logger.warn("images_omitted_no_vision_model", { model: picked.model, count: body.images.length });
322
331
  }
323
- return { taskHooks, additionalDirectories, wireCatalog, picked };
332
+ return { taskHooks, additionalDirectories, additionalReadDirectories, wireCatalog, picked };
324
333
  };
325
334
  /** 阶段③(anchors):吃 请求体 + auth,吐 两个历史锚解析后的 entryId(resumeAt / rewindFilesTo —— eventId→entryId
326
335
  * 过 anchor store,未知锚在此 4xx 而不是交给 core)与 142-S4 项目登记簿种子 s4DefaultScopes。 */
@@ -374,7 +383,7 @@ export function createResolveSpec(ctx) {
374
383
  /** 阶段④(spec 字面量):吃 请求体 + auth + `opts.leg` + 前三阶段的全部产出,吐**尚未过治理层**的 TaskSpec
375
384
  * 字面量 —— 部署方拥有的 body→TaskSpec 映射本体(compactionModel 的 fresh 腿 400 也在这一段)。 */
376
385
  const assembleSpecLiteral = (body, auth, opts, parts) => {
377
- const { scenarioName, cap, centerDecls, acceptedAppend, parsedSettings, attachmentNotice, taskHooks, additionalDirectories, wireCatalog, picked, resumeAtEntryId, rewindFilesToEntryId, s4DefaultScopes, } = parts;
386
+ const { scenarioName, cap, centerDecls, acceptedAppend, parsedSettings, attachmentNotice, taskHooks, additionalDirectories, additionalReadDirectories, wireCatalog, picked, resumeAtEntryId, rewindFilesToEntryId, s4DefaultScopes, } = parts;
378
387
  const spec = {
379
388
  // D-1:附件告知随 objective 进 durable 流(只有名字/mime/尺寸——**内容永不进流**,这正是
380
389
  // 「拼进 objective 是伪方案」的账要划清的线;文件名已消毒为安全字符集,无注入面)。
@@ -464,6 +473,8 @@ export function createResolveSpec(ctx) {
464
473
  // design/119 (CC --add-dir): extra host dirs the FILE tools may access (core canonicalizes each into the
465
474
  // containment allowlist + lists them in the `# Environment` block). Single-user host lane only (gated above).
466
475
  additionalDirectories,
476
+ // core 5.11.0:只读围栏(见上方 parse 段注)。缺席=字节等价([2625] 自查③)。
477
+ additionalReadDirectories,
467
478
  // EnterPlanMode (core 1.167): MODEL-DRIVEN plan — `enablePlanMode:true` alone (writable
468
479
  // start, NO handsReadOnly) auto-mounts `enter_plan_mode`/`present_plan` so the model can self-enter read-only
469
480
  // plan AT ANY TIME it judges a task needs planning. 🆕 DEFAULT ON (clay: resident; PLAN_MODE_ENABLED=false opts
@@ -404,8 +404,11 @@ async function handleSessionSyncBody(req, res, url, ctx, miss) {
404
404
  // **不是内容寻址**,所以「同 id 异载荷」结构上完全可能。此时 entries 被**整段跳过**,客户端拿到 200,
405
405
  // 目的端却仍是它自己那份内容(探针用真 HTTP 复现过:本地改过内容再 push,云端保持原样、200)。
406
406
  // 现在把判据写进响应(`basis`)并显式声明**未验证载荷**,这样客户端不会把它读成"内容已同步"。
407
- // ⚠️ 这只是止谎,**不是修**:真修要 Phase A 能拿到内容摘要(Phase A 只收 entryIds,载荷在 Phase B),
408
- // 那是跨仓 wire 契约改动,已带证据上黑板与 cli/core 对齐。别把 `basis` 当成问题已解决。
407
+ // 状态更新(2026-08-05,[2557] 2-i 核账):上面说的「真修」——Phase A 拿内容摘要——**已经落了**
408
+ // (`logDigest` 就在本函数上方,三态钉=fanout-characterization §H2/F3);commit 半场的载荷判据也齐
409
+ // (identical=照常改写、fast_forward 分叉=409,见 session-sync-content.ts)。仍到不了这里的只剩
410
+ // **不送 digest 的老客户端**(按设计永久保留的老路,现状钉=G1)——对它们 `basis:"entry-ids"` 的
411
+ // 未验声明就是全部真相。
409
412
  sendJson(res, 200, {
410
413
  relation: "identical",
411
414
  // 判据如实:调用方送了可比摘要且相等 ⇒ 内容**真的**验过;否则仍只是 id 集合相等。
@@ -6,12 +6,21 @@ import { defaultSubagentTailBus, projectTailFrame } from "../../fleet/subagent-t
6
6
  import { emitPendingWorkflowCompletions, taskNotificationInboxEntry, taskNotificationStreamKey, NotifiedKeys } from "../../orchestration/workflow-completion-inbox.js";
7
7
  import { createLedgerSink } from "../../trace/ledger-sink.js";
8
8
  import { redactSecrets } from "../../trace/redact.js";
9
- import { turnEndEventData, contextUsageEventData, toolStartEventData, toolEndEventData, taskProgressEventData, taskNotificationEventData, compactedEventData, diagnosticsEventData, brainStatusEventData, steeringInjectedEventData, compactionOutcomeEventData, workspaceChangedEventData, appendModelUsageDelta, attachModelUsage } from "../../trace/project.js";
9
+ import { contextUsageEventData, toolStartEventData, toolEndEventData, taskProgressEventData, taskNotificationEventData, compactedEventData, diagnosticsEventData, brainStatusEventData, steeringInjectedEventData, compactionOutcomeEventData, workspaceChangedEventData, appendModelUsageDelta, attachModelUsage } from "../../trace/project.js";
10
10
  import { cascadeConfig, runMeta } from "../run-meta.js";
11
11
  import { scopedIdempotencyKey } from "../idempotency.js";
12
12
  import { sendJson, sendError, sseHeaders } from "../send.js";
13
13
  import { buildActiveRunConflict, toDoneFrameResult } from "../active-run-conflict.js";
14
14
  import { headerStr, gatedPrincipal } from "../principal-gate.js";
15
+ /** #135([C95]/[C96]):live 腿 SSE 数据帧的**唯一**出口 —— 载荷带账本可锚 `eventId`(非空 string)的帧
16
+ * 前缀 `id: <eventId>` 行(SSE 游标=账本 eventId,壳/SDK 拿它锚进账本或断线换道 events tail 续读);
17
+ * 无 eventId 的帧**不发行**,EventSource 沿用上一游标 —— 绝不合成占位 id(那会把不可锚的位置伪装成可锚)。
18
+ * durable 腿的 `id:` 行在 sse-log.ts(同一契约的另半);行为钉=test/live-sse-cursor.test.ts(规则式全流判据)。 */
19
+ function sseData(res, payload) {
20
+ const anchor = payload.eventId;
21
+ const idLine = typeof anchor === "string" && anchor.length > 0 ? `id: ${anchor}\n` : "";
22
+ res.write(`${idLine}data: ${JSON.stringify(payload)}\n\n`);
23
+ }
15
24
  export async function handleTasks(req, res, url, ctx) {
16
25
  const miss = { fell: false };
17
26
  await handleTasksBody(req, res, url, ctx, miss);
@@ -52,7 +61,7 @@ async function handleTasksBody(req, res, url, ctx, miss) {
52
61
  try {
53
62
  const resp = await cached;
54
63
  if (!res.writableEnded)
55
- res.write(`data: ${JSON.stringify({ type: "done", result: resp.body, replay: true })}\n\n`);
64
+ sseData(res, { type: "done", result: resp.body, replay: true });
56
65
  }
57
66
  finally {
58
67
  clearInterval(hb);
@@ -226,7 +235,7 @@ async function handleTasksBody(req, res, url, ctx, miss) {
226
235
  // Headers are already SSE — encode the rejection as the stream's terminal event; the 409 in
227
236
  // the returned resp is for a deduplicated concurrent caller (and is never idem-cached).
228
237
  // done 帧 additive 携带同一份材料 —— 形状铸在 toDoneFrameResult(有名字有类型),不在这行内联挑键。
229
- res.write(`data: ${JSON.stringify({ type: "done", result: toDoneFrameResult(conflict) })}\n\n`);
238
+ sseData(res, { type: "done", result: toDoneFrameResult(conflict) });
230
239
  return { status: 409, body: conflict };
231
240
  }
232
241
  durableTaskId = tid;
@@ -399,7 +408,7 @@ async function handleTasksBody(req, res, url, ctx, miss) {
399
408
  return;
400
409
  const t = e.type;
401
410
  if (t === "task_progress") {
402
- res.write(`data: ${JSON.stringify({ type: "task_progress", ...taskProgressEventData(e) })}\n\n`);
411
+ sseData(res, { type: "task_progress", ...taskProgressEventData(e) });
403
412
  }
404
413
  else if (t === "tool_start") {
405
414
  // C1 (core 1.219, spec.forwardSubagentEvents): a delegated child's live CONTENT events now
@@ -407,22 +416,22 @@ async function handleTasksBody(req, res, url, ctx, miss) {
407
416
  // delegation card). Echo through the SAME shared whitelist+redact builders as the top
408
417
  // stream (§E1 — forwarded events are UNTRUSTED RAW; redaction is ours at the wire).
409
418
  const tsv = e;
410
- res.write(`data: ${JSON.stringify({ type: "tool_start", ...toolStartEventData(tsv) })}\n\n`);
419
+ sseData(res, { type: "tool_start", ...toolStartEventData(tsv) });
411
420
  }
412
421
  else if (t === "tool_end") {
413
422
  const te = e;
414
- res.write(`data: ${JSON.stringify({ type: "tool_end", ...toolEndEventData(te) })}\n\n`); // label 现由 builder 自带,外拼冗余已清
423
+ sseData(res, { type: "tool_end", ...toolEndEventData(te) }); // label 现由 builder 自带,外拼冗余已清
415
424
  }
416
425
  else if (t === "text_delta") {
417
426
  // Child text is plain model output (same trust class as the top stream's text_delta, which
418
427
  // rides verbatim); parentToolCallId attributes it. Whitelist the three fields explicitly.
419
428
  const td = e;
420
- res.write(`data: ${JSON.stringify({ type: "text_delta", delta: td.delta, ...(td.eventId ? { eventId: td.eventId } : {}), ...(td.parentToolCallId ? { parentToolCallId: td.parentToolCallId } : {}) })}\n\n`);
429
+ sseData(res, { type: "text_delta", delta: td.delta, ...(td.eventId ? { eventId: td.eventId } : {}), ...(td.parentToolCallId ? { parentToolCallId: td.parentToolCallId } : {}) });
421
430
  }
422
431
  else if (t === "reasoning_delta") {
423
432
  // §E1 parity with the top stream's live CoT: redactSecrets before echo.
424
433
  const rd = e;
425
- res.write(`data: ${JSON.stringify({ type: "reasoning_delta", delta: redactSecrets(rd.delta), ...(rd.eventId ? { eventId: rd.eventId } : {}), ...(rd.parentToolCallId ? { parentToolCallId: rd.parentToolCallId } : {}) })}\n\n`);
434
+ sseData(res, { type: "reasoning_delta", delta: redactSecrets(rd.delta), ...(rd.eventId ? { eventId: rd.eventId } : {}), ...(rd.parentToolCallId ? { parentToolCallId: rd.parentToolCallId } : {}) });
426
435
  }
427
436
  },
428
437
  // background-completion observer — flip the child's fleet
@@ -472,7 +481,7 @@ async function handleTasksBody(req, res, url, ctx, miss) {
472
481
  const key = taskNotificationStreamKey(n);
473
482
  if (syncNotifiedKeys.get(key) === undefined) {
474
483
  syncNotifiedKeys.set(key, Promise.resolve(true));
475
- res.write(`data: ${JSON.stringify({ type: "task_notification", ...taskNotificationEventData({ notification: n }) })}\n\n`);
484
+ sseData(res, { type: "task_notification", ...taskNotificationEventData({ notification: n }) });
476
485
  }
477
486
  }
478
487
  },
@@ -552,7 +561,7 @@ async function handleTasksBody(req, res, url, ctx, miss) {
552
561
  await ledgerSink.onDone(finalResult);
553
562
  ledgerTerminal = true;
554
563
  }
555
- res.write(`data: ${JSON.stringify({ ...ev, result: finalResult })}\n\n`);
564
+ sseData(res, { ...ev, result: finalResult });
556
565
  continue;
557
566
  }
558
567
  // [900]① detach 车道逐事件落账(await 保序 —— 与 bg 腿同代价姿势;message_committed/未知型
@@ -581,18 +590,18 @@ async function handleTasksBody(req, res, url, ctx, miss) {
581
590
  // 可能回显 provider 的 rate-limit/retry 报文)此前落裸 catch-all 未脱敏,与 durable 两腿
582
591
  // (ledger-sink.onEvent + resume switch)共用的 brainStatusEventData 脱敏不一致——同类
583
592
  // 「留在 catch-all」教训(1.72 task_progress),现挪显式分支,三腿同门同形。
584
- res.write(`data: ${JSON.stringify({ type: "status", ...brainStatusEventData(ev) })}\n\n`);
593
+ sseData(res, { type: "status", ...brainStatusEventData(ev) });
585
594
  }
586
595
  else if (ev.type === "tool_end") {
587
596
  const te = ev;
588
- res.write(`data: ${JSON.stringify({ type: "tool_end", ...toolEndEventData(te) })}\n\n`); // label 现由 builder 自带,外拼冗余已清
597
+ sseData(res, { type: "tool_end", ...toolEndEventData(te) }); // label 现由 builder 自带,外拼冗余已清
589
598
  }
590
599
  else if (ev.type === "tool_start") {
591
600
  // 🔒 E1 parity (TOC adversarial review): redact tool_start.args on the LIVE wire too — the model's
592
601
  // tool-call inputs can echo a secret it read (e.g. a value from a redacted tool_end fed into the
593
602
  // next call). Whitelist for the same reason as tool_end (shared durable builder = one shape).
594
603
  const tsv = ev;
595
- res.write(`data: ${JSON.stringify({ type: "tool_start", ...toolStartEventData(tsv) })}\n\n`);
604
+ sseData(res, { type: "tool_start", ...toolStartEventData(tsv) });
596
605
  }
597
606
  else if (ev.type === "task_notification") {
598
607
  // core 1.202 (design/115 P2): a background task (workflow) finished while THIS harness was live —
@@ -614,7 +623,7 @@ async function handleTasksBody(req, res, url, ctx, miss) {
614
623
  if (tnvPrior === undefined || !(await tnvPrior)) {
615
624
  if (tnvKey !== undefined)
616
625
  syncNotifiedKeys.set(tnvKey, Promise.resolve(true)); // stream delivered this key
617
- res.write(`data: ${JSON.stringify({ type: "task_notification", ...taskNotificationEventData(ev) })}\n\n`);
626
+ sseData(res, { type: "task_notification", ...taskNotificationEventData(ev) });
618
627
  }
619
628
  }
620
629
  else if (ev.type === "reasoning_delta" && typeof ev.delta === "string") {
@@ -626,41 +635,41 @@ async function handleTasksBody(req, res, url, ctx, miss) {
626
635
  // re-proven here: a spread forwards an unknown/future field RAW; this arm was the one
627
636
  // sibling branch that still spread — the FORWARDED twin below [t === "reasoning_delta"]
628
637
  // already used the narrow form, this now matches it byte-for-byte).
629
- res.write(`data: ${JSON.stringify({ type: "reasoning_delta", delta: redactSecrets(ev.delta), ...(ev.eventId ? { eventId: ev.eventId } : {}), ...(ev.parentToolCallId ? { parentToolCallId: ev.parentToolCallId } : {}) })}\n\n`);
638
+ sseData(res, { type: "reasoning_delta", delta: redactSecrets(ev.delta), ...(ev.eventId ? { eventId: ev.eventId } : {}), ...(ev.parentToolCallId ? { parentToolCallId: ev.parentToolCallId } : {}) });
630
639
  }
631
640
  else if (ev.type === "task_progress") {
632
641
  // 🔒 E1 live-wire parity: explicit whitelist via the SHARED builder (UNTRUSTED
633
642
  // `name` redacted; 1.78: E2 identity rides — the shell binds concurrent subagent ticks by
634
643
  // eventId/parentToolCallId) — parity with the durable runs.ts + resume legs.
635
- res.write(`data: ${JSON.stringify({ type: "task_progress", ...taskProgressEventData(ev) })}\n\n`);
644
+ sseData(res, { type: "task_progress", ...taskProgressEventData(ev) });
636
645
  }
637
646
  else if (ev.type === "compacted") {
638
647
  // core 1.219 (attachWorkingFiles default ON): `compacted` now carries `attachedFiles` — route it
639
648
  // through the SHARED whitelist builder (paths redactSecrets'd) instead of the raw catch-all, so
640
649
  // the live wire matches the durable legs' shape exactly (one builder, no drift).
641
- res.write(`data: ${JSON.stringify({ type: "compacted", ...compactedEventData(ev) })}\n\n`);
650
+ sseData(res, { type: "compacted", ...compactedEventData(ev) });
642
651
  }
643
652
  else if (ev.type === "diagnostics") {
644
653
  // core 1.220 (design/121): the shell's render source for CC's diagnostics row.
645
654
  // Shared whitelist builder (§E1: language-server output UNTRUSTED RAW → redactSecrets).
646
- res.write(`data: ${JSON.stringify({ type: "diagnostics", ...diagnosticsEventData(ev) })}\n\n`);
655
+ sseData(res, { type: "diagnostics", ...diagnosticsEventData(ev) });
647
656
  }
648
657
  else if (ev.type === "steering_injected") {
649
658
  // [1550]/[1552]: core's mid-run system-reminder injection — shared whitelist builder
650
659
  // (§E1: preview is UNTRUSTED free text → redactSecrets). Previously fell through to the
651
660
  // raw catch-all below (unredacted `{...ev}`) — moved to an explicit builder for the same
652
661
  // reason every other content type left the catch-all (the 1.72 task_progress lesson).
653
- res.write(`data: ${JSON.stringify({ type: "steering_injected", ...steeringInjectedEventData(ev) })}\n\n`);
662
+ sseData(res, { type: "steering_injected", ...steeringInjectedEventData(ev) });
654
663
  }
655
664
  else if (ev.type === "workspace_changed") {
656
665
  // [1559]二 core 裁定:bash cd/EnterWorktree 移动任务逻辑 cwd 后一帧,给 shell UI 的路径
657
666
  // 提示行。cwd 与已展示的工具 args 同信任级 → 共享白名单构造器(redactSecrets + bound)。
658
- res.write(`data: ${JSON.stringify({ type: "workspace_changed", ...workspaceChangedEventData(ev) })}\n\n`);
667
+ sseData(res, { type: "workspace_changed", ...workspaceChangedEventData(ev) });
659
668
  }
660
669
  else if (ev.type === "compaction_outcome") {
661
670
  // core 2.5.0([2105]②)压缩非 compacted 结局帧。[2373]C-2:内联挑键收进共享 builder,
662
671
  // 与两条 durable 腿同源(它曾是唯一不走 builder 的内容帧=三方普查共同盲区)。
663
- res.write(`data: ${JSON.stringify({ type: "compaction_outcome", ...compactionOutcomeEventData(ev) })}\n\n`);
672
+ sseData(res, { type: "compaction_outcome", ...compactionOutcomeEventData(ev) });
664
673
  }
665
674
  else if (ev.type === "text_delta" || ev.type === "turn_end" || ev.type === "message_committed" || ev.type === "context_usage") {
666
675
  // 裸 catch-all 白名单化收官(probe-wire-1 表征钉的根治半场):最后四个已知臂离开
@@ -684,10 +693,10 @@ async function handleTasksBody(req, res, url, ctx, miss) {
684
693
  : ev.type === "message_committed"
685
694
  ? { type: "message_committed", entryId: e.entryId, role: e.role, ...(e.toolCallId !== undefined ? { toolCallId: e.toolCallId } : {}), ...ident }
686
695
  : { type: "context_usage", ...contextUsageEventData(ev), ...ident };
687
- res.write(`data: ${JSON.stringify(arm)}\n\n`);
696
+ sseData(res, arm);
688
697
  }
689
698
  else {
690
- res.write(`data: ${JSON.stringify(ev)}\n\n`);
699
+ sseData(res, ev);
691
700
  }
692
701
  }
693
702
  });
@@ -728,7 +737,7 @@ async function handleTasksBody(req, res, url, ctx, miss) {
728
737
  const f = frame;
729
738
  if (f.type === "task_notification" && f.task_id)
730
739
  syncNotifiedKeys.set(taskNotificationStreamKey({ task_type: f.task_type, task_id: f.task_id, status: String(f.status), seq: f.seq }), Promise.resolve(true));
731
- res.write(`data: ${JSON.stringify(frame)}\n\n`);
740
+ sseData(res, frame);
732
741
  },
733
742
  // emit-target diagnostics — this drain rides the caller's OWN live SSE response.
734
743
  { route: "sync-stream-open", connection: "live-sse", log: (m, x) => deps.logger?.info?.(m, x) });
@@ -950,7 +959,7 @@ async function handleTasksBody(req, res, url, ctx, miss) {
950
959
  }
951
960
  // A concurrent caller the cache deduplicated (ranLive=false) received no live events — hand it the terminal.
952
961
  if (!ranLive && resp && !res.writableEnded) {
953
- res.write(`data: ${JSON.stringify({ type: "done", result: resp.body, replay: true })}\n\n`);
962
+ sseData(res, { type: "done", result: resp.body, replay: true });
954
963
  }
955
964
  res.end();
956
965
  }
@@ -691,6 +691,24 @@ export function createHttpServer(rawDeps) {
691
691
  return null;
692
692
  }
693
693
  }
694
+ // core 5.11.0:additionalReadDirectories(只读围栏)—— 与 additionalDirectories 同形 fail-loud
695
+ // (绝对路径数组、无 `..`、同条数上限);语义差异(只进 READ 面)在 core,形校验在此对齐。
696
+ const addReadDirsRaw = body.additionalReadDirectories;
697
+ if (addReadDirsRaw !== undefined) {
698
+ if (!Array.isArray(addReadDirsRaw)) {
699
+ sendError(res, 400, "request.field_invalid", "additionalReadDirectories must be an array of absolute host paths");
700
+ return null;
701
+ }
702
+ if (addReadDirsRaw.length > MAX_ADDITIONAL_DIRS) {
703
+ sendError(res, 400, "request.field_invalid", `additionalReadDirectories must have at most ${MAX_ADDITIONAL_DIRS} entries`);
704
+ return null;
705
+ }
706
+ const badRead = addReadDirsRaw.find((d) => !isValidCwd(d));
707
+ if (badRead !== undefined) {
708
+ sendError(res, 400, "request.field_invalid", "each additionalReadDirectories entry must be a non-empty absolute host path (no '..' segments)");
709
+ return null;
710
+ }
711
+ }
694
712
  // body.settings (client SemaSettings stamp) — fail-loud shape/size caps on submit, matching the sibling
695
713
  // fields' posture (parseTaskSettings is also defensive on every path incl. resume; this 400s a bad submit early
696
714
  // rather than silently coercing — adversarial review). The semantic projection (tighten-only) happens in resolveSpec.
@@ -131,7 +131,11 @@ export declare function leaderResourceConfig(env?: NodeJS.ProcessEnv): {
131
131
  /** design/74 Slice 6: opt-in (LEADER_RESOURCE_SUSPEND=true) resource-slice suspend for workers. When set, a
132
132
  * worker SUSPENDS at the per-slice window (`sliceMaxCostUsd`) instead of failing at the budget, and the
133
133
  * leader auto-resumes (resumeWithVerification) until the TOTAL (`totalBudgetUsd` = workerBudgetUsd) is spent
134
- * — a budget overrun becomes a resumable pause, not a worker failure. `maxSuspends` is sized to the slice
134
+ * — a budget overrun becomes a resumable pause, not a worker failure. ⚠️ 语义更新(core 5.11.0 B3,
135
+ * 2026-08-06):`maxSuspends` 现在只数「连续无进展」park(批准+执行后链基归零),不再是总 park 数上限
136
+ * ——下面按预算切片总数放大的 sizing 公式因此从「必要」变「保守冗余」(合法的资源续跑链永不触 cap,
137
+ * cap 只剩防空转环职责);公式保留是无害的(上限只会更松不误杀),别再按「总数预算」理解它。
138
+ * 原注:`maxSuspends` is sized to the slice
135
139
  * count (+margin) so the suspend-loop cap doesn't fire before the budget is legitimately spent. */
136
140
  resourceSuspend?: {
137
141
  totalBudgetUsd: number;
@@ -18,6 +18,12 @@ export declare class Metrics {
18
18
  setGauge(name: string, value: number, labels?: Labels): void;
19
19
  addGauge(name: string, delta: number, labels?: Labels): void;
20
20
  observe(name: string, value: number, labels?: Labels): void;
21
+ /** [2557] 2-j:dynamic gauge 求值抛错不再折成 0 —— 0 是该 gauge 的合法读数,折 0 让故障态与真实 0
22
+ * 在 wire 上逐字节不可区分(与上方 inc 静默 no-op 同族:观测面的 fail-silent 正是观测要消灭的东西)。
23
+ * 披露方向:错帧**不发样本行**(缺席=读数未知,面板显示 no data 而非假 0)+ 计
24
+ * `metric_gauge_eval_errors_total{name}`(响亮、可告警)。每次求值(每次刮取/snapshot)错一次计一次;
25
+ * 仍然绝不 propagate(/metrics 不因单个读数器坏而 500)。 */
26
+ private evalDynamic;
21
27
  /** Prometheus text exposition (content-type text/plain; version=0.0.4). */
22
28
  render(): string;
23
29
  /** Structured snapshot of the whole registry — the OTLP exporter maps this to OTel data points
@@ -87,8 +87,30 @@ export class Metrics {
87
87
  s.count += 1;
88
88
  h.series.set(k, s);
89
89
  }
90
+ /** [2557] 2-j:dynamic gauge 求值抛错不再折成 0 —— 0 是该 gauge 的合法读数,折 0 让故障态与真实 0
91
+ * 在 wire 上逐字节不可区分(与上方 inc 静默 no-op 同族:观测面的 fail-silent 正是观测要消灭的东西)。
92
+ * 披露方向:错帧**不发样本行**(缺席=读数未知,面板显示 no data 而非假 0)+ 计
93
+ * `metric_gauge_eval_errors_total{name}`(响亮、可告警)。每次求值(每次刮取/snapshot)错一次计一次;
94
+ * 仍然绝不 propagate(/metrics 不因单个读数器坏而 500)。 */
95
+ evalDynamic(name, fn) {
96
+ try {
97
+ return fn();
98
+ }
99
+ catch {
100
+ this.inc("metric_gauge_eval_errors_total", { name });
101
+ return undefined;
102
+ }
103
+ }
90
104
  /** Prometheus text exposition (content-type text/plain; version=0.0.4). */
91
105
  render() {
106
+ // dynamic 先求值(2-j):错帧的 inc 要落进**本次**刮取的 counters 段,不能等下一次。
107
+ const dyn = [];
108
+ for (const [name, d] of this.dynamic) {
109
+ const v = this.evalDynamic(name, d.fn);
110
+ dyn.push(`# HELP ${name} ${d.help}`, `# TYPE ${name} gauge`);
111
+ if (v !== undefined)
112
+ dyn.push(`${name} ${v}`);
113
+ }
92
114
  const out = [];
93
115
  for (const [name, c] of this.counters) {
94
116
  out.push(`# HELP ${name} ${c.help}`, `# TYPE ${name} counter`);
@@ -100,9 +122,7 @@ export class Metrics {
100
122
  for (const s of g.series.values())
101
123
  out.push(`${name}${renderLabels(s.labels)} ${s.value}`);
102
124
  }
103
- for (const [name, d] of this.dynamic) {
104
- out.push(`# HELP ${name} ${d.help}`, `# TYPE ${name} gauge`, `${name} ${safe(d.fn)}`);
105
- }
125
+ out.push(...dyn);
106
126
  for (const [name, h] of this.histos) {
107
127
  out.push(`# HELP ${name} ${h.help}`, `# TYPE ${name} histogram`);
108
128
  for (const s of h.series.values()) {
@@ -119,6 +139,8 @@ export class Metrics {
119
139
  /** Structured snapshot of the whole registry — the OTLP exporter maps this to OTel data points
120
140
  * (counters→Sum monotonic, gauges/dynamic→Gauge, histograms→Histogram). Read-only copy. */
121
141
  snapshot() {
142
+ // dynamic 先求值(2-j,同 render):错帧的 inc 要进本次快照的 counters。
143
+ const dynVals = [...this.dynamic].map(([name, d]) => ({ name, help: d.help, v: this.evalDynamic(name, d.fn) }));
122
144
  const counters = [...this.counters].map(([name, c]) => ({
123
145
  name,
124
146
  help: c.help,
@@ -129,8 +151,9 @@ export class Metrics {
129
151
  help: g.help,
130
152
  series: [...g.series.values()].map((s) => ({ labels: s.labels, value: s.value })),
131
153
  }));
132
- for (const [name, d] of this.dynamic)
133
- gauges.push({ name, help: d.help, series: [{ labels: {}, value: safe(d.fn) }] });
154
+ // 错帧 series 空:名字/HELP 仍披露(指标存在),但绝不铸 {value:0} 假数据点。
155
+ for (const { name, help, v } of dynVals)
156
+ gauges.push({ name, help, series: v === undefined ? [] : [{ labels: {}, value: v }] });
134
157
  const histograms = [...this.histos].map(([name, h]) => ({
135
158
  name,
136
159
  help: h.help,
@@ -192,17 +215,10 @@ export class Metrics {
192
215
  };
193
216
  }
194
217
  }
195
- function safe(fn) {
196
- try {
197
- return fn();
198
- }
199
- catch {
200
- return 0;
201
- }
202
- }
203
218
  /** Register the service's standard metric set. */
204
219
  export function createMetrics() {
205
220
  const m = new Metrics();
221
+ m.counter("metric_gauge_eval_errors_total", "Dynamic gauge eval errors by gauge name (an erroring gauge emits NO sample that scrape — absence means unknown, not 0)");
206
222
  m.counter("http_requests_total", "HTTP requests by route/method/status");
207
223
  m.histogram("http_request_duration_seconds", "HTTP request duration");
208
224
  m.counter("rate_limited_total", "Requests rejected by the rate limiter");
@@ -30,7 +30,7 @@ import { join, resolve } from "node:path";
30
30
  // 版本掩盖」这个缺陷机制仍值得后人读;宣称"待办"的部分已按实况改写(注释宣称≠代码实做是本仓
31
31
  // 已立案的病族,过期待办比没有更有害)。
32
32
  import { SessionError, validateEntriesForImport } from "@sema-agent/core";
33
- import { identicalIdsAlsoIdenticalContent } from "../session-sync-content.js";
33
+ import { contentForkRelation, fastForwardSharedContentDiverged, identicalIdsAlsoIdenticalContent } from "../session-sync-content.js";
34
34
  import { classifySyncRelationshipByIds, SyncConflictError, stagingIdFor, } from "../session-sync-kernel.js";
35
35
  const ownerEq = (a, b) => (a ?? null) === (b ?? null);
36
36
  const isNotFound = (e) => e instanceof SessionError && e.code === "not_found";
@@ -692,6 +692,15 @@ class LocalStagingHandle {
692
692
  if ((rel.relation === "fork" || rel.relation === "stale") && opts?.resolution !== "overwrite-dst") {
693
693
  throw new SyncConflictError(this.realSessionId, rel);
694
694
  }
695
+ // 🔴 [2557] A5/F2(2026-08-05):fast_forward 的**载荷**盲区 —— dst ⊆ src 按 id,但共享 id 的
696
+ // 内容可能已分叉(目的端纠正过某条、源端拿旧版+追加)。整段换装会静默销毁目的端的纠正,
697
+ // §7 在载荷层同样要求 409(除非显式 overwrite-dst)。判据与铸形见 session-sync-content.ts。
698
+ if (rel.relation === "fast_forward" && opts?.resolution !== "overwrite-dst" && dstEntries !== null && dstEntries.length > 0) {
699
+ const dstSet = new Set(dstEntries.map((e) => e.id));
700
+ const diverged = fastForwardSharedContentDiverged(this.staged.filter((e) => dstSet.has(e.id)), dstEntries);
701
+ if (diverged)
702
+ throw new SyncConflictError(this.realSessionId, contentForkRelation(diverged, rel.newEntryIds));
703
+ }
695
704
  // 🔴 2026-07-26:`identical` 不再等于"内容相同"。id 是 uuidv7、不是内容寻址 ⇒ 同 id 异载荷完全可能,
696
705
  // 而 commit 这里**手上同时有两份日志**,所以内容比较不需要调用方配合(见 `session-sync-content.ts` 顶注:
697
706
  // 我在黑板 [1701] 说过"server 单方面修不了",那句只对 Phase A 成立,对这一半是错的)。
@@ -23,6 +23,7 @@ import { BaseSessionStorage, StoredSession, SessionError, uuidv7, validateEntrie
23
23
  import { escapeLike } from "./sql-escape.js";
24
24
  import { pgSafeJsonStringify } from "./pg-safe-json.js";
25
25
  import { toIso } from "./sql-row-helpers.js";
26
+ import { contentForkRelation, fastForwardSharedContentDiverged } from "../session-sync-content.js";
26
27
  import { classifySyncRelationshipByIds, SyncConflictError, stagingIdFor, STAGING_ID_MARKER, } from "../session-sync-kernel.js";
27
28
  /** Staging-row reaper grace window (twin of the TiDB store's STAGING_GC_GRACE_MS) — see that store for the rationale. */
28
29
  const STAGING_GC_GRACE_MS = 3_600_000;
@@ -609,6 +610,20 @@ class PgStagingHandle {
609
610
  await conn.query("ROLLBACK");
610
611
  throw new SyncConflictError(this.realSessionId, rel);
611
612
  }
613
+ // 🔴 [2557] A5/F2(2026-08-05):fast_forward 的**载荷**判据(tidb twin 同注)——共享段分叉 ⇒ 409,
614
+ // 除非显式 overwrite-dst;载荷只在这条臂上读,行已被 FOR UPDATE 锁住,同 txn 重读是一致读。
615
+ if (rel.relation === "fast_forward" && opts?.resolution !== "overwrite-dst" && dstIds !== null && dstIds.length > 0) {
616
+ const dstFull = await conn.query("SELECT payload FROM session_event WHERE session_id = $1 ORDER BY seq ASC", [this.realSessionId]);
617
+ const stagedFull = await conn.query("SELECT entry_id, payload FROM session_event WHERE session_id = $1 ORDER BY seq ASC", [this.stagingId]);
618
+ const dstSet = new Set(dstIds);
619
+ const dstEntries = dstFull.rows.map((r) => parseEntry(r.payload));
620
+ const stagedShared = stagedFull.rows.filter((r) => dstSet.has(String(r.entry_id))).map((r) => parseEntry(r.payload));
621
+ const diverged = fastForwardSharedContentDiverged(stagedShared, dstEntries);
622
+ if (diverged) {
623
+ await conn.query("ROLLBACK");
624
+ throw new SyncConflictError(this.realSessionId, contentForkRelation(diverged, rel.newEntryIds));
625
+ }
626
+ }
612
627
  await conn.query("DELETE FROM session_event WHERE session_id = $1", [this.realSessionId]);
613
628
  await conn.query("UPDATE session_event SET session_id = $1 WHERE session_id = $2", [this.realSessionId, this.stagingId]);
614
629
  const lastRes = await conn.query("SELECT payload FROM session_event WHERE session_id = $1 ORDER BY seq DESC LIMIT 1", [this.realSessionId]);
@@ -158,7 +158,9 @@ class PgBackend {
158
158
  * The session store stays the §0.5 owner-aware {@link LocalSessionStore} wrapper, but over a fresh `FileSessionRepo`
159
159
  * (NOT `fileBackend.sessionStore`, which is core's bare `TtlSessionStore` lacking the §0.5 owner/list/fork/delete
160
160
  * ops); policy/snapshot delegate to the fileBackend's own file stores. The fileBackend takes a coarse boot
161
- * `flock` on `root/LOCK` (single-instance per data dir) — `close()` MUST `dispose()` it so a graceful restart can
161
+ * single-instance lock on `root/LOCK` (an `O_EXCL`+`linkSync` PIDFILE, not flock [2557] F4; same-host only:
162
+ * liveness = `process.kill(pid,0)`, so a foreign PID namespace or NFS defeats it, see DEPLOY-PREREQS) —
163
+ * `close()` MUST `dispose()` it so a graceful restart can
162
164
  * re-acquire (a stale lock from a dead PID is pruned by core, but graceful restart relies on the release).
163
165
  *
164
166
  * P0.5 variant-1 (clay 2026-06-26): `run()`/`resumeAnchor()` are now FILE-backed too (FileRunStore /
@@ -184,7 +186,7 @@ class LocalBackend {
184
186
  /** design/73 §1 — owner-only JSONL outcome-fact sink (stateless append; no fd held). */
185
187
  outcomeSinkInst;
186
188
  constructor(root, config) {
187
- // The fileBackend ctor mkdirs `root` + takes the boot flock — a SECOND instance over the same dir FAILS FAST.
189
+ // The fileBackend ctor mkdirs `root` + takes the boot pidfile lock — a same-host SECOND instance FAILS FAST.
188
190
  // Re-throw with a clear operator message (the raw core error is "another instance owns this data dir").
189
191
  try {
190
192
  this.fileBackend = new FileStorageBackend({ root, ...(config.rewindSnapshotMaxMb !== undefined ? { snapshotBounds: { maxBytes: Math.round(config.rewindSnapshotMaxMb * 1024 * 1024) } } : {}) }); // REWIND_SNAPSHOT_MAX_MB
@@ -14,6 +14,7 @@
14
14
  import { StoredSession, SessionError, uuidv7, validateEntriesForImport, leafIdAfterEntry } from "@sema-agent/core";
15
15
  import { TiDBSessionStorage } from "./tidb-session-storage.js";
16
16
  import { escapeLike } from "./sql-escape.js";
17
+ import { contentForkRelation, fastForwardSharedContentDiverged } from "../session-sync-content.js";
17
18
  import { classifySyncRelationshipByIds, SyncConflictError, stagingIdFor, STAGING_ID_MARKER, } from "../session-sync-kernel.js";
18
19
  /** Staging-row reaper grace window — a staging session's rows are GC'able only if it has NO session_meta (never
19
20
  * committed) AND its OLDEST row is older than this (an in-flight stream keeps appending fresh rows → safe). Mirrors
@@ -573,6 +574,24 @@ class TiDBStagingHandle {
573
574
  await conn.rollback();
574
575
  throw new SyncConflictError(this.realSessionId, rel);
575
576
  }
577
+ // (1b) 🔴 [2557] A5/F2(2026-08-05):fast_forward 的**载荷**判据 —— dst ⊆ src 按 id,但共享 id 的
578
+ // 内容可能已分叉(目的端纠正过某条、源端拿旧版+追加);整段换装会静默销毁目的端版本,§7 在载荷层
579
+ // 同样要求 409(除非显式 overwrite-dst)。共享段载荷只在这条臂上读(commit 本就是 O(total) 行操作
580
+ // ——DELETE dst + rename staged——所以这次成比例的载荷读不改变量级);行已被上面的 FOR UPDATE 锁住,
581
+ // 同 txn 重读载荷是一致读。判据与 fork 铸形见 session-sync-content.ts。
582
+ if (rel.relation === "fast_forward" && opts?.resolution !== "overwrite-dst" && dstIds !== null && dstIds.length > 0) {
583
+ const [dstFull] = await conn.query("SELECT payload FROM session_event WHERE session_id = ? ORDER BY seq ASC", [this.realSessionId]);
584
+ const [stagedFull] = await conn.query("SELECT entry_id, payload FROM session_event WHERE session_id = ? ORDER BY seq ASC", [this.stagingId]);
585
+ const parseEntry = (p) => (typeof p === "string" ? JSON.parse(p) : p);
586
+ const dstSet = new Set(dstIds);
587
+ const dstEntries = dstFull.map((r) => parseEntry(r.payload));
588
+ const stagedShared = stagedFull.filter((r) => dstSet.has(String(r.entry_id))).map((r) => parseEntry(r.payload));
589
+ const diverged = fastForwardSharedContentDiverged(stagedShared, dstEntries);
590
+ if (diverged) {
591
+ await conn.rollback();
592
+ throw new SyncConflictError(this.realSessionId, contentForkRelation(diverged, rel.newEntryIds));
593
+ }
594
+ }
576
595
  // (2) purge the dst's existing log (the replaceEntries purge; idempotent — fresh deletes 0).
577
596
  await conn.query("DELETE FROM session_event WHERE session_id = ?", [this.realSessionId]);
578
597
  // (3) RENAME the staged rows onto the real session id (row-key UPDATE — metadata only, NO payload re-copy). seq is
@@ -46,4 +46,33 @@ import { type SessionTreeEntry } from "@sema-agent/core";
46
46
  * 不符只意味着"多干一趟活",而不是"这次同步非法"。
47
47
  */
48
48
  export declare function identicalIdsAlsoIdenticalContent(staged: readonly SessionTreeEntry[], dst: readonly SessionTreeEntry[] | null): boolean;
49
+ /**
50
+ * `fast_forward` 的**内容判据**([2557] 审计 A5/F2,2-i)——id 分类器的另一格盲区:
51
+ * `dst ⊆ src` 只说明 **id 集合**是子集,共享 id 的**载荷**可能已分叉(目的端把某条 entry
52
+ * 补写/纠正过、id 不变,源端拿着旧版本 + 新追加)。此时按「clean append」整段换装,
53
+ * 目的端那几条纠正会被**静默销毁** —— §7「会丢目的端历史必须 409(除非显式 overwrite-dst)」
54
+ * 在载荷层同样成立。与 `identical` 格的口径**方向不同**是刻意的:identical 格 core 已定
55
+ * 「不符=多干一趟活、照常改写」(那里 src 按 id 完整覆盖 dst,push 语义=以源为准);本格
56
+ * dst 的共享版本与 src 的追加互为对方没有的东西,是真分叉,归 409 那一族。
57
+ *
58
+ * 入参:`stagedShared` = staged 里 id ∈ dst 集合的那部分(**staged 顺序**);`dst` = 目的端全量
59
+ * (**dst 顺序**)。两侧都保留各自顺序 —— 摘要顺序显著,共享段的**乱序**同样是分叉。
60
+ *
61
+ * 返回 `null` = 无分叉(安全 append);返回非空数组 = 分叉的 entry id(dst 顺序):
62
+ * 逐条单 entry 摘要不等的 id;若逐条全等而整段摘要不等(纯乱序),列出位置错开的 id。
63
+ */
64
+ export declare function fastForwardSharedContentDiverged(stagedShared: readonly SessionTreeEntry[], dst: readonly SessionTreeEntry[]): string[] | null;
65
+ /**
66
+ * 把 fast_forward 的内容级分叉铸成 §7 冲突载荷(SyncConflictError.relation,wire 上是 409 的
67
+ * `{relation}` 详情)。用现有 `fork` 成员而不是扩词表:内容层它**就是** fork(双方各握着对方
68
+ * 没有的东西),且 SDK/客户端的关系词表是闭集,扩成员=BREAKING。字段口径(内容级,与 id 级
69
+ * 注释的措辞差异在此登记):`srcExclusive` = 源端要追加的新 id;`dstExclusive` = 目的端将被
70
+ * 销毁的共享版本的 id;`commonAncestor` = null(未逐条定位分叉点 —— 定位成本与收益不配)。
71
+ */
72
+ export declare function contentForkRelation(divergedIds: readonly string[], newEntryIds: readonly string[]): {
73
+ relation: "fork";
74
+ commonAncestor: null;
75
+ srcExclusive: string[];
76
+ dstExclusive: string[];
77
+ };
49
78
  //# sourceMappingURL=session-sync-content.d.ts.map
@@ -50,4 +50,46 @@ export function identicalIdsAlsoIdenticalContent(staged, dst) {
50
50
  return false; // 目的端没有日志 ⇒ 谈不上"内容相同",按需改写
51
51
  return sessionLogDigest(staged) === sessionLogDigest(dst);
52
52
  }
53
+ /**
54
+ * `fast_forward` 的**内容判据**([2557] 审计 A5/F2,2-i)——id 分类器的另一格盲区:
55
+ * `dst ⊆ src` 只说明 **id 集合**是子集,共享 id 的**载荷**可能已分叉(目的端把某条 entry
56
+ * 补写/纠正过、id 不变,源端拿着旧版本 + 新追加)。此时按「clean append」整段换装,
57
+ * 目的端那几条纠正会被**静默销毁** —— §7「会丢目的端历史必须 409(除非显式 overwrite-dst)」
58
+ * 在载荷层同样成立。与 `identical` 格的口径**方向不同**是刻意的:identical 格 core 已定
59
+ * 「不符=多干一趟活、照常改写」(那里 src 按 id 完整覆盖 dst,push 语义=以源为准);本格
60
+ * dst 的共享版本与 src 的追加互为对方没有的东西,是真分叉,归 409 那一族。
61
+ *
62
+ * 入参:`stagedShared` = staged 里 id ∈ dst 集合的那部分(**staged 顺序**);`dst` = 目的端全量
63
+ * (**dst 顺序**)。两侧都保留各自顺序 —— 摘要顺序显著,共享段的**乱序**同样是分叉。
64
+ *
65
+ * 返回 `null` = 无分叉(安全 append);返回非空数组 = 分叉的 entry id(dst 顺序):
66
+ * 逐条单 entry 摘要不等的 id;若逐条全等而整段摘要不等(纯乱序),列出位置错开的 id。
67
+ */
68
+ export function fastForwardSharedContentDiverged(stagedShared, dst) {
69
+ if (sessionLogDigest([...stagedShared]) === sessionLogDigest([...dst]))
70
+ return null;
71
+ const byId = new Map(stagedShared.map((e) => [e.id, e]));
72
+ const diverged = [];
73
+ for (const d of dst) {
74
+ const s = byId.get(d.id);
75
+ if (!s || sessionLogDigest([s]) !== sessionLogDigest([d]))
76
+ diverged.push(d.id);
77
+ }
78
+ if (diverged.length === 0) {
79
+ for (let i = 0; i < dst.length; i++)
80
+ if (stagedShared[i]?.id !== dst[i].id)
81
+ diverged.push(dst[i].id);
82
+ }
83
+ return diverged;
84
+ }
85
+ /**
86
+ * 把 fast_forward 的内容级分叉铸成 §7 冲突载荷(SyncConflictError.relation,wire 上是 409 的
87
+ * `{relation}` 详情)。用现有 `fork` 成员而不是扩词表:内容层它**就是** fork(双方各握着对方
88
+ * 没有的东西),且 SDK/客户端的关系词表是闭集,扩成员=BREAKING。字段口径(内容级,与 id 级
89
+ * 注释的措辞差异在此登记):`srcExclusive` = 源端要追加的新 id;`dstExclusive` = 目的端将被
90
+ * 销毁的共享版本的 id;`commonAncestor` = null(未逐条定位分叉点 —— 定位成本与收益不配)。
91
+ */
92
+ export function contentForkRelation(divergedIds, newEntryIds) {
93
+ return { relation: "fork", commonAncestor: null, srcExclusive: [...newEntryIds], dstExclusive: [...divergedIds] };
94
+ }
53
95
  //# sourceMappingURL=session-sync-content.js.map
@@ -118,6 +118,13 @@ export interface SessionManifest {
118
118
  * 覆盖与一笔**仍然有效**的欠账记账,那正是本仓 `vitest.config.ts` 里写死的判据「覆盖丢失伪装成绿,
119
119
  * 比假红更坏」。⇒ **删除的前置条件**是先把 B4/G1 重锚到活的流式路径上;做完那件,这两个函数连同它们的
120
120
  * 测试可一并摘除。判定与机器门见 `test/session-sync-routes.test.ts` 的「死码判定」describe。
121
+ *
122
+ * ✅ **前置条件已达成**(2026-08-05,[2557] 审计 2-i):B4 已重锚活路由(fanout-characterization §H ×3 +
123
+ * sync-commit-write-parity 三后端判据,commit 半场的 fast_forward 载荷缺口**已修**)并销账;G1 改性质为
124
+ * 「老客户端路现状钉」(wire 级、不依赖本模块)。⇒ 本模块 + 依赖它的测试段现在**可以整体摘除**——
125
+ * 剩余依赖=fanout-characterization §A/§B/§F 与 crash-recovery 全文件(§8 扇出切点矩阵的死码腿;活路由的
126
+ * 崩溃/重试语义已由 E 系列 + staging reaper 钉覆盖,摘除前需逐段核对该等价性)。单独排一班机械车做,
127
+ * 别混进行为修的车。
121
128
  */
122
129
  export declare function exportSession(sessionId: string, srcBackend: StoreBackend): Promise<{
123
130
  bundle: SessionBundle;
@@ -52,6 +52,13 @@ function ownerAware(backend) {
52
52
  * 覆盖与一笔**仍然有效**的欠账记账,那正是本仓 `vitest.config.ts` 里写死的判据「覆盖丢失伪装成绿,
53
53
  * 比假红更坏」。⇒ **删除的前置条件**是先把 B4/G1 重锚到活的流式路径上;做完那件,这两个函数连同它们的
54
54
  * 测试可一并摘除。判定与机器门见 `test/session-sync-routes.test.ts` 的「死码判定」describe。
55
+ *
56
+ * ✅ **前置条件已达成**(2026-08-05,[2557] 审计 2-i):B4 已重锚活路由(fanout-characterization §H ×3 +
57
+ * sync-commit-write-parity 三后端判据,commit 半场的 fast_forward 载荷缺口**已修**)并销账;G1 改性质为
58
+ * 「老客户端路现状钉」(wire 级、不依赖本模块)。⇒ 本模块 + 依赖它的测试段现在**可以整体摘除**——
59
+ * 剩余依赖=fanout-characterization §A/§B/§F 与 crash-recovery 全文件(§8 扇出切点矩阵的死码腿;活路由的
60
+ * 崩溃/重试语义已由 E 系列 + staging reaper 钉覆盖,摘除前需逐段核对该等价性)。单独排一班机械车做,
61
+ * 别混进行为修的车。
55
62
  */
56
63
  export async function exportSession(sessionId, srcBackend) {
57
64
  const session = ownerAware(srcBackend);
@@ -382,18 +382,19 @@ export class ToolApprovalCoordinator {
382
382
  // [1559]三:emit 完成 vs TTL/abort/respond 三选一落定——显式 race,不让挂死的 emit 独占执行权。
383
383
  await Promise.race([emitAllP, allowedP]);
384
384
  const settled = await allowedP;
385
- // Completion breadcrumb (card dismiss) — fire-and-forget to every target that actually got the OPEN frame
386
- // (aliveCtxs a ctx filtered out at entry as already-dead never saw `tool_approval` in the first place, so
387
- // it has nothing to dismiss), so a slow/hung emit never delays returning to core AND every connection in
388
- // the set learns the card resolved elsewhere([1559]四「其余连接收 resolved/dismissed 通知」的直接落点——
389
- // 单连接场景退化为原来的一次 emit)。**per-ctx 链在该 ctx 自己的 openP 之后**(F2 修的落点)——
390
- // `openByCtx.get(c)!` 永不 reject(emitOne 内部已吸收),所以不需要额外 `.catch` 才能继续链下去;
391
- // `.then(() => c.emit(...))` 包一层是必须的:该 ctx 的 complete emit 若同步 throw,要先转成
392
- // rejection 才能被后面的 `.catch` 接住,否则会在此处同步抛出、跳过 catch。
385
+ // Completion breadcrumb (card dismiss) — fire-and-forget to every target that actually got the OPEN frame,
386
+ // so a slow/hung emit never delays returning to core AND every connection that saw the card learns it
387
+ // resolved elsewhere([1559]四「其余连接收 resolved/dismissed 通知」的直接落点——单连接场景退化为
388
+ // 原来的一次 emit)。**per-ctx 链在该 ctx 自己的 openP 之后且看它的布尔结果**([2557] 批3 F2 D1 修,
389
+ // 2026-08-06):emitOne 失败是 resolve(false) 不是 reject,旧形 `.then(() => emit(complete))`
390
+ // resolve 值全放行 ⇒ open 没送达的连接(瞬断型 emit 目标)也收 `tool_approval_complete` = 孤儿
391
+ // close,注释与代码曾不符(红先=wire-pairing-approval D1,修后翻正)。`delivered` 门后:
392
+ // `.then((delivered) => delivered ? c.emit(...) : undefined)` —— complete emit 若同步 throw,
393
+ // `.then` 回调内抛出转成 rejection 由尾部 `.catch` 吸收(链形不变)。
393
394
  for (const c of aliveCtxs) {
394
395
  void openByCtx
395
396
  .get(c)
396
- .then(() => c.emit({ type: "tool_approval_complete", approvalId: id, outcome: lastOutcome }))
397
+ .then((delivered) => (delivered ? c.emit({ type: "tool_approval_complete", approvalId: id, outcome: lastOutcome }) : undefined))
397
398
  .catch(() => undefined);
398
399
  }
399
400
  // emit 全灭 = 卡从未送达任何人(≠人拒绝/TTL 走人)——同属「无活人可达」类 ⇒ "unavailable" 交 durable
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/server",
3
- "version": "6.4.0",
3
+ "version": "6.6.0",
4
4
  "description": "Sema Server — the server/API implementation layer for Sema, wiring core, registry, model providers, and cloud agent execution. Built on @sema-agent/core.",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",
@@ -54,7 +54,7 @@
54
54
  "build:binary:run-local:darwin-arm64": "bun build --compile --target=bun-darwin-arm64 src/run-local.ts --outfile dist/run-local-darwin-arm64"
55
55
  },
56
56
  "dependencies": {
57
- "@sema-agent/core": "^5.10.0",
57
+ "@sema-agent/core": "^5.11.0",
58
58
  "@sema-agent/registry-core": "^0.14.0",
59
59
  "e2b": "^2.28.0",
60
60
  "libsodium-wrappers": "^0.8.4",
@@ -68,7 +68,7 @@
68
68
  "sharp": "^0.35.3"
69
69
  },
70
70
  "devDependencies": {
71
- "@sema-agent/sdk": "^6.3.0",
71
+ "@sema-agent/sdk": "^6.4.0",
72
72
  "@types/libsodium-wrappers": "^0.7.14",
73
73
  "@types/node": "22.10.2",
74
74
  "@types/pg": "^8.20.0",