@sema-agent/server 6.5.0 → 6.7.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
@@ -2,7 +2,7 @@ import { once } from "node:events";
2
2
  import { randomBytes, createHash } from "node:crypto";
3
3
  import { sessionLogDigest, sessionLogDigestsComparable, StreamingImportValidator, SessionError, SessionPolicyError } from "@sema-agent/core";
4
4
  import { StringDecoder } from "node:string_decoder";
5
- import { exportSessionManifest, fileSnapshotImportFace } from "../../session-sync.js";
5
+ import { exportSessionManifest, fileSnapshotImportFace, replayPolicyRecords, overwriteWipeIncapable, wipeSessionAttendants } from "../../session-sync.js";
6
6
  import { classifySyncRelationshipByIds, SyncConflictError } from "../../session-sync-kernel.js";
7
7
  import { isUuidShape } from "../../security.js";
8
8
  import { sendJson, sendError, msg } from "../send.js";
@@ -392,9 +392,10 @@ async function handleSessionSyncBody(req, res, url, ctx, miss) {
392
392
  }
393
393
  }
394
394
  const opOk = explicitOperatorOk(gatedPrincipal(req, deps.config), deps.config.operatorPrincipals);
395
+ // #137 ①:等内容跳过(replayPolicyRecords)——这条腿在**每次** identical 重推上都跑,
396
+ // 无条件 putRules 会把 rev(operator 乐观锁 CAS 令牌)按重推次数推着走。
395
397
  if (deps.sessionPolicyStore)
396
- for (const rec of policy)
397
- await deps.sessionPolicyStore.putRules(sessionId, rec.principal, rec.rules, { operator: opOk });
398
+ await replayPolicyRecords(deps.sessionPolicyStore, sessionId, policy, opOk);
398
399
  const importingP = scope.fleetWide ? (owner ?? null) : scope.gateOwner;
399
400
  if (deps.resumeAnchorStore)
400
401
  for (const a of anchors)
@@ -647,9 +648,29 @@ async function handleSessionSyncBody(req, res, url, ctx, miss) {
647
648
  sendError(res, 409, "session_active", "session started an active run while the import streamed; sync after it settles", { activeTaskId: activeAtCommit });
648
649
  return;
649
650
  }
650
- // ── commit (the atomic swap) replay snapshots/policy/anchors → cache evict → release lease ──
651
+ // #137 能力预检(fail-loud,[2195] 判据:保护型能力缺席=fail-closed):consent overwrite
652
+ // 而 policy 店无 purge seam ⇒ 在 commit **之前**干净中止(此刻零写入落地),绝不静默留混合态。
653
+ // 只看 resolution 不看 relation(commit 的 in-txn re-classify 还没跑):保守方向——你要求了
654
+ // overwrite 而这套部署擦不动,哪怕最终 re-classify 判 fresh 也先拒,拒因里给了无损重跑姿势。
655
+ if (pending.resolution !== undefined && overwriteWipeIncapable(deps.sessionPolicyStore)) {
656
+ await pending.handle.abort().catch(() => undefined);
657
+ cleanupStaging(stagingId);
658
+ sendError(res, 501, "capability.policy_purge_required", "overwrite-dst cannot proceed: this deployment's session-policy store has no deleteBySession purge seam, so the destination's stale policy rows cannot be wiped and the import would land a MIXED state (the source's conversation plus the destination's leftover policy rows, which keep gating). Upgrade the policy store (every in-tree store carries deleteBySession since core 1.423), or re-run without resolution overwrite-dst");
659
+ return;
660
+ }
661
+ // ── commit (the atomic swap) → wipe (overwrite-dst) → replay snapshots/policy/anchors → cache evict → release lease ──
651
662
  try {
652
663
  const committed = await pending.handle.commit(pending.owner, pending.resolution ? { resolution: pending.resolution } : undefined);
664
+ // #137 ②(死码 ⓪ 擦除步的活路由移植,判据钉=fanout-characterization L2):consented
665
+ // overwrite-dst = keep-source ⇒ 在重放**之前**擦掉目的端遗留的 policy/anchor/快照行,否则
666
+ // 悬空 anchor 指向已 purge 的 entry、被放弃分支的 policy 行继续 gating、共享 key 快照因
667
+ // importManifest create-once 永远留着目的端字节。条件按 commit 权威 relation 收窄:fresh 不擦
668
+ // (B5 钉:注册过但零 entry 的目的端,原有附随物原地留存)、fast_forward 不擦(同支延长,无
669
+ // 被放弃分支)。崩溃残余(commit 与 wipe 之间死进程 ⇒ 重试落 identical 支无 wipe)在
670
+ // wipeSessionAttendants 的 doc 注如实记录——窗口窄于修前(修前=遗留行恒存活)。
671
+ if (pending.resolution === "overwrite-dst" && (committed.relation === "fork" || committed.relation === "stale")) {
672
+ await wipeSessionAttendants({ fileSnapshotStore: deps.fileSnapshotStore, sessionPolicyStore: deps.sessionPolicyStore, resumeAnchorStore: deps.resumeAnchorStore }, sessionId);
673
+ }
653
674
  // ① snapshots — content-addressed import (idempotent). importManifest never throws → a {ok:false} is a
654
675
  // fail-closed 422 (a blob the Phase-A presence check couldn't catch — corrupted in transit). Note: the
655
676
  // ENTRIES are already committed (the swap), so a snapshot failure here means the conversation synced but a
@@ -666,10 +687,11 @@ async function handleSessionSyncBody(req, res, url, ctx, miss) {
666
687
  }
667
688
  }
668
689
  // ② policy — replay each record; the E6 tighten-only gate applies unless the importer is a verified operator.
690
+ // #137 ①:等内容跳过(replayPolicyRecords)——crash-retry 会整段重跑本重放,无条件 put 让
691
+ // 每次重试都推 rev(operator 乐观锁 CAS 令牌)。
669
692
  const policyStore = deps.sessionPolicyStore;
670
693
  if (policyStore)
671
- for (const rec of pending.policy)
672
- await policyStore.putRules(sessionId, rec.principal, rec.rules, { operator: pending.operatorOk });
694
+ await replayPolicyRecords(policyStore, sessionId, pending.policy, pending.operatorOk);
673
695
  // ③ anchors — owner RE-KEYED to the importing principal (§9).
674
696
  const anchorStore = deps.resumeAnchorStore;
675
697
  if (anchorStore)
@@ -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;
@@ -1,23 +1,55 @@
1
1
  /**
2
- * 2c session-sync — P1b ORCHESTRATION: export a session's WHOLE portable state from one StoreBackend and import it
3
- * verbatim into another (the seamless local↔cloud "save state" move; sema-internal server/docs/DESIGN-session-sync.md §5–§10). All the
4
- * per-store PRIMITIVES already exist (P1a entries + P1b snapshots/policy/anchor); this module is the COORDINATOR that
5
- * (a) reads them into a {@link SessionBundle} and (b) replays them across backends in the §8 fail-closed atomic order.
2
+ * 2c session-sync — P1d-α PULL manifest export (`exportSessionManifest`) + §7 dry-run planning (`planSync`) over a
3
+ * `StoreBackend` (sema-internal server/docs/DESIGN-session-sync.md §5–§10, §15). The PUSH half is the two-phase
4
+ * staged streamed import in `src/http/routes/session-sync.ts` (§8 fanout re-implemented there, entries-off-the-wire).
6
5
  *
7
- * This is a SERVICE function the caller (the future HTTP route, P1d) owns the `sessionOwnerScope` gate (§9: export
8
- * 404s a non-owner, import re-stamps owner to the authenticated principal). exportSession/importSession themselves do
9
- * NOT authenticate; they assume the caller proved ownership.
6
+ * 🪦 **TOMBSTONE (2026-08-06, mechanical removal car, [2557] 2-i precondition + #51 死码判定)**: this module used to
7
+ * ALSO export a whole-bundle `exportSession`/`importSession` orchestrator pair (the P1b one-shot in-memory move:
8
+ * read entries+snapshots+policy+anchors into one {@link SessionBundle}, replay them across backends in the §8
9
+ * atomic order). #51's audit (src git history around this date; see `test/session-sync-routes.test.ts`'s former
10
+ * "死码判定的常驻门" describe, now a removal-proof tombstone test) established BOTH functions had **zero production
11
+ * call sites** — PULL uses `exportSessionManifest` below + the keyset-paged NDJSON `/sync/entries` stream; PUSH uses
12
+ * the two-phase staging route, which reimplements the §8 fanout itself (`handle.commit` / `importManifest` /
13
+ * `putRules` / `resumeAnchorStore().put`) and never called `importSession`. The only consumers were three test
14
+ * files. [2557] 2-i's precondition for removal (B4/G1 re-anchored to the live streaming path) was met, so this car
15
+ * deleted both functions + the tests that existed solely to exercise them.
10
16
  *
11
- * Scope (P1b): a one-shot whole-session move with FAIL-CLOSED validation (§10) + cross-store atomic ordering (§8) +
12
- * owner re-stamp (§9). NOT in scope here: sync-watermark / fork detection (§7) and HTTP/streaming blob negotiation
13
- * (§5) those are P1c/P1d. Blobs are NOT held in memory (§5 OOM guard): the bundle carries only the manifest
14
- * (relPath→blobHash); bytes flow through a `getBlob(hash)` getter the importer pulls content-addressed (deduped).
17
+ * ⚠️ **Two invariants the deleted `importSession` uniquely protected have NO live-route equivalent** (verified by
18
+ * reading the current code, not assumed) flagging rather than silently dropping the coverage:
19
+ * 1. **Policy-rev stability on idempotent retry/duplicate sync** (the deleted step's fix: skip `putRules` when
20
+ * the incoming record's content already equals the dst row, so a retried/duplicated sync never bumps `rev`
21
+ * `rev` is the operator's optimistic-lock CAS token). The LIVE two-phase PUSH route (`src/http/routes/
22
+ * session-sync.ts` lines ~382 and ~608) calls `sessionPolicyStore.putRules(...)` UNCONDITIONALLY on every
23
+ * policy-record replay (identical-branch short-circuit AND the post-commit replay) — no content-equality skip.
24
+ * A crash-retry or a harmless duplicate sync on the live PUSH route WILL bump every policy row's rev, unlike
25
+ * the now-deleted `importSession`. The deleted tests (fanout-characterization §A A4/A5/A6, §F F1/F1c) pinned
26
+ * this ONLY on the dead function; no wire/live-route test exercises it.
27
+ * 2. **overwrite-dst subsystem wipe** (the deleted ⓪ step: before replaying policy/anchors/snapshots under a
28
+ * consented `overwrite-dst`, purge the destination's stale rows for principals/keys NOT in the incoming
29
+ * bundle, so an abandoned fork branch's policy/anchor/snapshot rows don't survive as a MIXED state). The LIVE
30
+ * two-phase PUSH route has NO equivalent wipe step — its overwrite-dst commit only purges/renames
31
+ * `session_event` (entries); the post-commit policy/anchor replay (lines ~606–611) only `put`s the records the
32
+ * bundle carries, never deletes a dst-only row. A dangling anchor pointing at a purged entry, or a
33
+ * dst-only-principal policy row from the abandoned branch, can survive an overwrite-dst PUSH on the live
34
+ * route. The deleted tests (fanout-characterization §C C1/C1b, `session-sync-routes.test.ts`'s deleted
35
+ * "[2195] 同族: overwrite-dst wipe" describe) pinned this ONLY on the dead function.
36
+ * Neither gap is fixed by this car (mechanical removal, no behavior changes) — they are reported here + in the
37
+ * removal's final report for separate triage (port the fix to the live route, or explicitly accept the regression).
38
+ *
39
+ * ✅ **STATUS UPDATE (2026-08-06 同日,#137 红先修绿)**:上面两条「NO live-route equivalent」已作废——
40
+ * 两个保护都已移植到活路由(本文件尾部 #137 段:`sameRulesContent`/`replayPolicyRecords`/
41
+ * `overwriteWipeIncapable`/`wipeSessionAttendants`;路由接线=src/http/routes/session-sync.ts 的两条重放腿
42
+ * + commit 后擦除步 + commit 前能力预检 501)。判据钉=fanout-characterization「#137 live 路由无等价物
43
+ * 缺口」describe(L1 rev 零漂移+反向臂 / L2 三子系统擦除+共享 key 换源端字节 / L3 缺 seam fail-loud),
44
+ * L1/L2 先红后绿,两处变异抽验(擦除步禁用⇒L2 红、等值跳过禁用⇒L1 红)。原文保留供考古。
15
45
  */
16
46
  import type { StoreBackend } from "./plugins/store-backend.js";
17
47
  import { type SyncRelation } from "./session-sync-kernel.js";
18
- import type { SessionTreeEntry, SessionRulesRecord, FileSnapshotResult } from "@sema-agent/core";
48
+ import { type SessionTreeEntry, type SessionRulesRecord, type FileSnapshotResult, type SessionPermissionRules, type StoredSessionRules, type SessionPolicyStore } from "@sema-agent/core";
49
+ import type { ServiceFileSnapshotStore, ServiceSessionPolicyStore, ResumeAnchorStore } from "./plugins/store-backend.js";
19
50
  /** A blob getter: content-addressed bytes for a sha256 hash, or `undefined` if the source can't supply them.
20
- * Returned by {@link exportSession} (closes over the source store), consumed by {@link importSession}. */
51
+ * Returned by {@link exportSessionManifest} (closes over the source store); consumed wherever a caller pulls
52
+ * content-addressed snapshot bytes lazily (the live PUSH route's `importManifest` replay). */
21
53
  export type BlobGetter = (hash: string) => Promise<Uint8Array | undefined>;
22
54
  /** The snapshot-import capability face of a `FileSnapshotStore` — signature verbatim = the authoritative
23
55
  * implementation, `FileSnapshotStoreSql.importManifest` (src/plugins/file-snapshot-store-sql.ts:201). */
@@ -83,58 +115,11 @@ export interface SessionManifest {
83
115
  owner: string | null;
84
116
  }>;
85
117
  }
86
- /**
87
- * EXPORT a session's whole portable state from `srcBackend` into a {@link SessionBundle} + a {@link BlobGetter}.
88
- *
89
- * - entries: `exportEntries(sessionId)` — `null` ⇒ the session does not exist ⇒ this returns `null` (no partial bundle).
90
- * - snapshots: `listKeys(sessionId)` → for each key `exportManifest(scope,key)` → `{key, [...manifest]}`. A key whose
91
- * manifest reads back `null` (a concurrent reap between listKeys and exportManifest) is dropped, not exported empty.
92
- * - policy: `listBySession(sessionId)` (empty `[]` if the store lacks the optional seam — honest degrade).
93
- * - anchors: `listBySession(sessionId)` (empty `[]` if the store has no resumeAnchor seam).
94
- * - getBlob: bound to the source `fileSnapshot().getBlob` so the importer can pull content-addressed bytes lazily.
95
- *
96
- * The OWNER is NOT exported (import re-stamps it, §9). The owner-scope GATE is the CALLER's (route P1d): this returns
97
- * `null` ONLY for "session does not exist", never as an authz signal.
98
- *
99
- * NB (P1d-α): the HTTP PULL no longer uses this — it uses {@link exportSessionManifest} (entries lifted off the wire)
100
- * + the keyset-paged `exportEntriesStream` NDJSON route so memory is bounded by ROW COUNT, not the whole-log array.
101
- *
102
- * 🔴 **TEST-ONLY(2026-07-29 死码判定,#51 / [2012] cli 反查;上一版这段 NB 的结论已过期)**。
103
- * 上面那句「STAYS for the local same-process import path, P1d-β (PUSH), and the tests」**三条里两条已不成立**:
104
- * 逐条亲验(`grep -rn 'exportSession\|importSession' src/`)——
105
- * · **本地同进程 import 路径**:不存在这样的调用点(全仓零处);
106
- * · **P1d-β(PUSH)**:路由改成了两阶段 staging + NDJSON 流,§8 扇出**在路由里自己重写了一遍**
107
- * (`src/http/routes/session-sync.ts` 的 commit 链直接调 `handle.commit` / `importManifest` / `putRules` /
108
- * `resumeAnchorStore`),**不经过** {@link importSession};
109
- * · **tests**:成立,且是**唯一**成立的一条(三个测试文件在用)。
110
- * 还有两处**只 import 不调用**的死接线(`src/http/server.ts` / `src/http/routes/session-sync.ts`)——本次已摘;
111
- * 它们能活到今天是因为 tsconfig 没开 `noUnusedLocals`。这两个函数也**不在发布公面**上
112
- * (`package.json` 的 exports 只有 `.` / `./main` / `./package.json`,`src/index.ts` 不 re-export 本模块)。
113
- *
114
- * **为什么本批只判定 + 上机器门,没有直接删**:两个仍在用它们的测试文件
115
- * (`session-sync-fanout-characterization` / `session-sync-fanout-crash-recovery`)是 §8 崩溃/重试幂等语义的
116
- * **唯一可执行验证**,并且其中 B4/G1 两笔 `it.fails` 记的是 `classifySyncRelationshipByIds` 的**分类缺口**——
117
- * 而那个分类器**在活路由上仍被调用**(`routes/session-sync.ts:312` / `:632`)。直接删函数=连带删掉两套
118
- * 覆盖与一笔**仍然有效**的欠账记账,那正是本仓 `vitest.config.ts` 里写死的判据「覆盖丢失伪装成绿,
119
- * 比假红更坏」。⇒ **删除的前置条件**是先把 B4/G1 重锚到活的流式路径上;做完那件,这两个函数连同它们的
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
- * 别混进行为修的车。
128
- */
129
- export declare function exportSession(sessionId: string, srcBackend: StoreBackend): Promise<{
130
- bundle: SessionBundle;
131
- getBlob: BlobGetter;
132
- } | null>;
133
118
  /**
134
119
  * P1d-α (PULL streaming, §15) — EXPORT a session's portable state as a {@link SessionManifest} (entries LIFTED OFF
135
- * the wire) + a {@link BlobGetter}. Like {@link exportSession} but it does NOT load entry payloads: it reads the
120
+ * the wire), so it does NOT load entry payloads: it reads the
136
121
  * IDS-only projection via `listEntryIds` (`null` ⇒ no such session ⇒ this returns `null`), counts them, reads the
137
- * current `leafId`, and builds snapshots/policy/anchors EXACTLY as exportSession does. The paired NDJSON
122
+ * current `leafId`, and builds snapshots/policy/anchors. The paired NDJSON
138
123
  * `GET /sync/entries` route streams the entry payloads separately (keyset-paged → bounded memory), so a 1M-entry
139
124
  * session's manifest stays small (ids + counts + the snapshot/policy/anchor metadata).
140
125
  *
@@ -146,69 +131,46 @@ export declare function exportSessionManifest(sessionId: string, srcBackend: Sto
146
131
  manifest: SessionManifest;
147
132
  getBlob: BlobGetter;
148
133
  } | null>;
149
- /**
150
- * IMPORT a {@link SessionBundle} (+ its {@link BlobGetter}) into `dstBackend` under `bundle.sessionId`, re-stamping
151
- * ownership to `importingPrincipal`. Returns the snapshot tally (imported vs honestly-skipped on a destination whose
152
- * FileSnapshotStore lacks the importManifest seam — pre-1.141.0; core's InMemory/File NOW carry it, so a local dst
153
- * imports snapshots for real).
154
- *
155
- * §10 FAIL-CLOSED VALIDATION runs FIRST — BEFORE any write:
156
- * 1. `validateEntriesForImport(bundle.entries)` (the single core invariant gate: unique ids / parent-before-child /
157
- * one root / leaf-resolvable). Throws on violation; NOTHING is written.
158
- * 2. Every blobHash referenced by every snapshot manifest MUST be supplied by `getBlob` — a missing blob would
159
- * restore a PARTIAL tree (files half-written, conversation whole → breaks the entry↔file lockstep). A lightweight
160
- * PRESENCE pre-check (fetch each distinct hash once, no hashing) fails FAST here; the AUTHORITATIVE content-address
161
- * integrity check (`sha256(bytes)===hash`) is done ONCE inside `importManifest` (core 1.141.0), so we don't double-
162
- * hash. (Runs only when the dst supports snapshot import; otherwise there is nothing to half-write.)
163
- *
164
- * §7 CLASSIFICATION GATE — BEFORE any write, the destination's CURRENT entry log is read and compared to the bundle
165
- * via {@link classifySyncRelationship} (a sound entry-id SET comparison, not leaf_id). A `fork` (true divergence) or a
166
- * `stale` source (the dst is strictly ahead) WOULD lose destination history → it throws {@link SyncConflictError}
167
- * (route → 409) UNLESS the caller passes `{ resolution: "overwrite-dst" }`. An `identical` relation SKIPS the entries
168
- * write entirely (the log is already present & equal; the idempotent snapshot/policy/anchor replay below still runs to
169
- * heal anything missing). `fresh` / `fast_forward` (and an overwrite-resolved fork/stale) write the entries via the
170
- * IDEMPOTENT {@link OwnerAwareSessionStore.replaceEntries} (purge-then-import) — NOT `importEntries`, whose plain
171
- * INSERT crashes on a duplicate PK when the session already exists at the destination.
172
- *
173
- * §8 CROSS-STORE ATOMIC ORDER + IDEMPOTENCY — session_meta (written LAST inside `replaceEntries`) is the sole
174
- * commit point, so a crash mid-import leaves orphan blobs/manifests/anchors with NO session_meta = invisible to
175
- * wake (never a half-session, only collectable orphans). Order:
176
- * ① snapshots (manifests + content-addressed blobs) — content-addressed + create-once = idempotent retry-to-completion.
177
- * `importManifest` returns a {@link FileSnapshotResult} (NEVER throws — core 1.141.0); a `{ok:false}` (a
178
- * rejecting/missing/hash-mismatched blob the §10 pre-check didn't catch — e.g. a blob corrupted in transit) is
179
- * re-raised as a fail-closed THROW so the import aborts BEFORE the entries commit (no entry↔file split).
180
- * ② entries (`replaceEntries` runs the core gate AGAIN and writes session_meta LAST = the commit point; idempotent
181
- * over an existing session — skipped entirely when the relation is `identical`)
182
- * ③ policy (replay each record via putRules; `opts.operatorOk` defaults FALSE → the E6 tighten-only gate APPLIES, so
183
- * a replay that would LOOSEN an operator-tightened dst policy throws `SessionPolicyError("loosen_forbidden")` —
184
- * `operator:true` is set ONLY when the authenticated importer is itself a verified operator)
185
- * ④ anchors (owner RE-KEYED to `importingPrincipal`, §9)
186
- * Steps ③/④ are post-commit; re-running them is idempotent — ③ SKIPS a put whose content already equals the dst row
187
- * (a "no-op rev bump" is NOT idempotent: rev is the operator's optimistic-lock token — the A6 fix below), ④ is a
188
- * same-value upsert — so a crash between ②–④ is recovered by a retry that converges to the same bytes.
189
- *
190
- * @param opts.resolution — `"overwrite-dst"` consents to a `fork`/`stale` import that overwrites destination history
191
- * (the user's keep-source decision); omitted ⇒ such an import is refused with {@link SyncConflictError}.
192
- * @param opts.operatorOk — when `true`, the policy replay (step ③) is an OPERATOR write (may loosen the dst rules); set
193
- * ONLY when the AUTHENTICATED importer is a verified operator. DEFAULT false → the E6 tighten-only gate applies, so a
194
- * non-operator import is TIGHTEN-ONLY (design §4): a replay that would loosen an operator-tightened dst session policy
195
- * throws `SessionPolicyError("loosen_forbidden")`, which propagates (NOT swallowed) so the route can refuse the loosen.
196
- */
197
- export declare function importSession(bundle: SessionBundle, getBlob: BlobGetter, dstBackend: StoreBackend, importingPrincipal: string | null, opts?: {
198
- resolution?: "overwrite-dst";
199
- operatorOk?: boolean;
200
- }): Promise<{
201
- snapshotsImported: number;
202
- snapshotsSkipped: number;
203
- relation: SyncRelation["relation"];
204
- }>;
205
134
  /**
206
135
  * §7 DRY-RUN — classify what importing `sessionId` from `srcBackend` into `dstBackend` WOULD do, WITHOUT writing
207
- * anything. The route (P1d) calls this to surface a conflict (fork/stale) to the user BEFORE committing an
208
- * {@link importSession} (so the shell can present a keep-local / keep-cloud / fork-new choice up front).
136
+ * anything (the shell can present a keep-local / keep-cloud / fork-new choice up front).
209
137
  *
210
138
  * Returns `null` when the SOURCE session does not exist (no bundle to plan); otherwise the {@link SyncRelation}
211
139
  * between the source's full durable log and the destination's current log (a `null` dst log ⇒ `fresh`). No writes.
212
140
  */
213
141
  export declare function planSync(sessionId: string, srcBackend: StoreBackend, dstBackend: StoreBackend): Promise<SyncRelation | null>;
142
+ /** #137 ①(死码 A6 修的移植):同内容判据 —— 比较「归一化、去 rev」后的字节形。putRules 每次
143
+ * 无条件写都 rev+1,而 rev 是 operator 乐观锁的 CAS 令牌(`PutRulesOptions.expectedRev`),
144
+ * 幂等重试/无害重复同步不该把在途 CAS 打失败。归一化用 core 的 `normalizeRules`(store 落盘
145
+ * 前走同一函数 ⇒ 两侧同坐标);`stripRev` 去掉记录内嵌的 rev(`listBySession` 的 rules 含 rev,
146
+ * 它不是内容)。 */
147
+ export declare function sameRulesContent(stored: StoredSessionRules | null, incoming: SessionPermissionRules): boolean;
148
+ /** #137 ① 的重放腿共享实现:PUSH 路由的两条 policy 重放腿(Phase A identical 短路支 / Phase B
149
+ * commit 后支)都从这里走 —— 等内容跳过(rev 零漂移),真变更照常落地(E6 tighten-only 门在
150
+ * putRules 内部,此处不重述)。 */
151
+ export declare function replayPolicyRecords(store: Pick<SessionPolicyStore, "getRules" | "putRules">, sessionId: string, records: readonly SessionRulesRecord[], operatorOk: boolean): Promise<void>;
152
+ /** #137 ② 能力预检(fail-loud 方向,[2195] 判据:保护型能力缺席=fail-closed):consented
153
+ * overwrite-dst 需要 policy 店的 `deleteBySession` purge seam 才能不留混合态;缺席=true,路由在
154
+ * commit **之前**用它干净中止(此时零写入落地),wire 拒因字面量归路由所有(api-error-text-freeze
155
+ * 锚字面站点)。anchor 店的 deleteBySession 是类型必填、快照店有 core 必填的 `reap` 兜底,都不构成
156
+ * 缺席面;唯一可缺的就是 policy 这条(core 接口上是可选方法)。 */
157
+ export declare function overwriteWipeIncapable(policyStore: ServiceSessionPolicyStore | undefined): boolean;
158
+ /** #137 ②(死码 ⓪ 擦除步的移植):consented overwrite-dst = keep-source,结果必须是**源端那
159
+ * 一份**,不得混成「源端对话 + 目的端遗留 policy/anchor/快照」(混合态比丢历史更危险:悬空
160
+ * anchor 指向已 purge 的 entry、被放弃分支的 policy 行继续 gating、共享 key 快照因
161
+ * importManifest create-once 永远留着目的端字节)。调用时点=commit(原子 swap)成功**之后**、
162
+ * 附随物重放**之前**:死码在任何写入前擦(其 entries 写本身非原子);活路由有真 commit point,
163
+ * 擦在 commit 前会在「commit 失败」时留下反向混合态(目的端对话完好、附随物已毁)——方向更坏。
164
+ * 诚实残余:进程在 commit 与本函数之间崩溃时,重试落 Phase A identical 支(无 wipe)⇒ 遗留行
165
+ * 可存活;该窗口窄于修前世界(修前=遗留行**恒**存活),与 §8「快照重放失败 ⇒ 422 重试自愈」
166
+ * 同款接受,记录于此不装没有。
167
+ * 各店姿势(死码 verbatim 语义):快照=deleteBySession(SQL 孪生:manifest 即删、blob 走异步
168
+ * 孤儿 GC)缺席则 core 必填 `reap(scope, [])`(内容寻址,他 scope 仍引用的 blob 存活);
169
+ * anchor=直调(类型必填);policy=deleteBySession(路由已用 {@link overwriteWipeIncapable}
170
+ * 预检过,此处缺席=类型外的 JS 层残缺实现,直调抛 TypeError 即 fail-loud)。 */
171
+ export declare function wipeSessionAttendants(stores: {
172
+ fileSnapshotStore?: ServiceFileSnapshotStore | undefined;
173
+ sessionPolicyStore?: ServiceSessionPolicyStore | undefined;
174
+ resumeAnchorStore?: Pick<ResumeAnchorStore, "deleteBySession"> | undefined;
175
+ }, sessionId: string): Promise<void>;
214
176
  //# sourceMappingURL=session-sync.d.ts.map
@@ -1,6 +1,5 @@
1
- import { identicalIdsAlsoIdenticalContent } from "./session-sync-content.js";
2
- import { classifySyncRelationship, SyncConflictError } from "./session-sync-kernel.js";
3
- import { validateEntriesForImport, normalizeRules, stripRev } from "@sema-agent/core";
1
+ import { classifySyncRelationship } from "./session-sync-kernel.js";
2
+ import { normalizeRules, stripRev } from "@sema-agent/core";
4
3
  /** Single-point capability probe (design/158 R7): does `fs` carry the optional `importManifest` snapshot-import
5
4
  * seam? Returns the bound method (never a naked function reference — `this` inside `importManifest` must resolve
6
5
  * to `fs`, exactly like `asServiceDeps`'s other capability-probe-then-bind authorities) or `undefined` when the
@@ -17,96 +16,11 @@ export function fileSnapshotImportFace(fs) {
17
16
  function ownerAware(backend) {
18
17
  return backend.session();
19
18
  }
20
- /**
21
- * EXPORT a session's whole portable state from `srcBackend` into a {@link SessionBundle} + a {@link BlobGetter}.
22
- *
23
- * - entries: `exportEntries(sessionId)` — `null` ⇒ the session does not exist ⇒ this returns `null` (no partial bundle).
24
- * - snapshots: `listKeys(sessionId)` → for each key `exportManifest(scope,key)` → `{key, [...manifest]}`. A key whose
25
- * manifest reads back `null` (a concurrent reap between listKeys and exportManifest) is dropped, not exported empty.
26
- * - policy: `listBySession(sessionId)` (empty `[]` if the store lacks the optional seam — honest degrade).
27
- * - anchors: `listBySession(sessionId)` (empty `[]` if the store has no resumeAnchor seam).
28
- * - getBlob: bound to the source `fileSnapshot().getBlob` so the importer can pull content-addressed bytes lazily.
29
- *
30
- * The OWNER is NOT exported (import re-stamps it, §9). The owner-scope GATE is the CALLER's (route P1d): this returns
31
- * `null` ONLY for "session does not exist", never as an authz signal.
32
- *
33
- * NB (P1d-α): the HTTP PULL no longer uses this — it uses {@link exportSessionManifest} (entries lifted off the wire)
34
- * + the keyset-paged `exportEntriesStream` NDJSON route so memory is bounded by ROW COUNT, not the whole-log array.
35
- *
36
- * 🔴 **TEST-ONLY(2026-07-29 死码判定,#51 / [2012] cli 反查;上一版这段 NB 的结论已过期)**。
37
- * 上面那句「STAYS for the local same-process import path, P1d-β (PUSH), and the tests」**三条里两条已不成立**:
38
- * 逐条亲验(`grep -rn 'exportSession\|importSession' src/`)——
39
- * · **本地同进程 import 路径**:不存在这样的调用点(全仓零处);
40
- * · **P1d-β(PUSH)**:路由改成了两阶段 staging + NDJSON 流,§8 扇出**在路由里自己重写了一遍**
41
- * (`src/http/routes/session-sync.ts` 的 commit 链直接调 `handle.commit` / `importManifest` / `putRules` /
42
- * `resumeAnchorStore`),**不经过** {@link importSession};
43
- * · **tests**:成立,且是**唯一**成立的一条(三个测试文件在用)。
44
- * 还有两处**只 import 不调用**的死接线(`src/http/server.ts` / `src/http/routes/session-sync.ts`)——本次已摘;
45
- * 它们能活到今天是因为 tsconfig 没开 `noUnusedLocals`。这两个函数也**不在发布公面**上
46
- * (`package.json` 的 exports 只有 `.` / `./main` / `./package.json`,`src/index.ts` 不 re-export 本模块)。
47
- *
48
- * **为什么本批只判定 + 上机器门,没有直接删**:两个仍在用它们的测试文件
49
- * (`session-sync-fanout-characterization` / `session-sync-fanout-crash-recovery`)是 §8 崩溃/重试幂等语义的
50
- * **唯一可执行验证**,并且其中 B4/G1 两笔 `it.fails` 记的是 `classifySyncRelationshipByIds` 的**分类缺口**——
51
- * 而那个分类器**在活路由上仍被调用**(`routes/session-sync.ts:312` / `:632`)。直接删函数=连带删掉两套
52
- * 覆盖与一笔**仍然有效**的欠账记账,那正是本仓 `vitest.config.ts` 里写死的判据「覆盖丢失伪装成绿,
53
- * 比假红更坏」。⇒ **删除的前置条件**是先把 B4/G1 重锚到活的流式路径上;做完那件,这两个函数连同它们的
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
- * 别混进行为修的车。
62
- */
63
- export async function exportSession(sessionId, srcBackend) {
64
- const session = ownerAware(srcBackend);
65
- // entries FIRST — null ⇒ no such session ⇒ no bundle at all (the route maps this to 404).
66
- const exportEntries = session.exportEntries?.bind(session);
67
- if (!exportEntries) {
68
- // A backend whose session store lacks the export seam can't be a sync SOURCE (older/in-memory dev store). Treat as
69
- // "nothing to export" rather than crash — the route already gates on the durable backend being present.
70
- return null;
71
- }
72
- const entries = await exportEntries(sessionId);
73
- if (entries === null)
74
- return null; // session does not exist
75
- // snapshots — listKeys → per-key manifest. exportManifest may be absent (no snapshot export seam) → no snapshots.
76
- const fs = srcBackend.fileSnapshot();
77
- const snapshots = [];
78
- // [2373]B-5b:core 接口 listKeys 必填(恒真半支删);exportManifest? 可选——探测只留真可选的那半。
79
- if (typeof fs.exportManifest === "function") {
80
- const keys = await fs.listKeys(sessionId);
81
- for (const key of keys) {
82
- const manifest = await fs.exportManifest(sessionId, key);
83
- if (manifest === null)
84
- continue; // raced reap between listKeys and exportManifest — skip (never an empty snapshot)
85
- snapshots.push({ key, manifest: [...manifest] });
86
- }
87
- }
88
- // policy — every (principal, rules) record across ALL principals (E6 listBySession; optional → []).
89
- const policyStore = srcBackend.sessionPolicy();
90
- const policy = typeof policyStore.listBySession === "function"
91
- ? await policyStore.listBySession(sessionId)
92
- : [];
93
- // anchors — every E18 resume anchor (eventId→entryId + source owner). resumeAnchor is REQUIRED on all backends.
94
- const anchorStore = srcBackend.resumeAnchor();
95
- // [2373]B-5a:ResumeAnchorStore 是三实现的闭合联合、listBySession 全必填——feature-detect 恒真,
96
- // `: []` 臂不可达([2195] deleteBySession 同判据,那轮漏了这对双胞胎)。直调。
97
- const anchors = await anchorStore.listBySession(sessionId);
98
- // getBlob — bound to the SOURCE snapshot store so the importer pulls bytes content-addressed (deduped), lazily.
99
- // A source without getBlob (no blobs to move) supplies a getter that always returns undefined → snapshots with a
100
- // missing blob fail the §10 validation below before any write (never a partial restore).
101
- const getBlobFn = fs.getBlob?.bind(fs);
102
- const getBlob = getBlobFn ? (hash) => getBlobFn(hash) : async () => undefined;
103
- return { bundle: { sessionId, entries, snapshots, policy, anchors }, getBlob };
104
- }
105
19
  /**
106
20
  * P1d-α (PULL streaming, §15) — EXPORT a session's portable state as a {@link SessionManifest} (entries LIFTED OFF
107
- * the wire) + a {@link BlobGetter}. Like {@link exportSession} but it does NOT load entry payloads: it reads the
21
+ * the wire), so it does NOT load entry payloads: it reads the
108
22
  * IDS-only projection via `listEntryIds` (`null` ⇒ no such session ⇒ this returns `null`), counts them, reads the
109
- * current `leafId`, and builds snapshots/policy/anchors EXACTLY as exportSession does. The paired NDJSON
23
+ * current `leafId`, and builds snapshots/policy/anchors. The paired NDJSON
110
24
  * `GET /sync/entries` route streams the entry payloads separately (keyset-paged → bounded memory), so a 1M-entry
111
25
  * session's manifest stays small (ids + counts + the snapshot/policy/anchor metadata).
112
26
  *
@@ -127,7 +41,7 @@ export async function exportSessionManifest(sessionId, srcBackend) {
127
41
  // leafId — the session's current leaf (cache-bypassing single read). Absent seam / no leaf ⇒ null.
128
42
  const getLeafId = session.getLeafId?.bind(session);
129
43
  const leafId = getLeafId ? await getLeafId(sessionId) : null;
130
- // snapshots / policy / anchors — IDENTICAL to exportSession (no entry payloads loaded anywhere here).
44
+ // snapshots / policy / anchors — no entry payloads loaded anywhere here.
131
45
  const fs = srcBackend.fileSnapshot();
132
46
  const snapshots = [];
133
47
  // [2373]B-5b:core 接口 listKeys 必填(恒真半支删);exportManifest? 可选——探测只留真可选的那半。
@@ -148,7 +62,7 @@ export async function exportSessionManifest(sessionId, srcBackend) {
148
62
  // [2373]B-5a:ResumeAnchorStore 是三实现的闭合联合、listBySession 全必填——feature-detect 恒真,
149
63
  // `: []` 臂不可达([2195] deleteBySession 同判据,那轮漏了这对双胞胎)。直调。
150
64
  const anchors = await anchorStore.listBySession(sessionId);
151
- // getBlob — bound to the SOURCE snapshot store (content-addressed, lazy), exactly like exportSession.
65
+ // getBlob — bound to the SOURCE snapshot store (content-addressed, lazy).
152
66
  const getBlobFn = fs.getBlob?.bind(fs);
153
67
  const getBlob = getBlobFn ? (hash) => getBlobFn(hash) : async () => undefined;
154
68
  return {
@@ -156,225 +70,9 @@ export async function exportSessionManifest(sessionId, srcBackend) {
156
70
  getBlob,
157
71
  };
158
72
  }
159
- /**
160
- * IMPORT a {@link SessionBundle} (+ its {@link BlobGetter}) into `dstBackend` under `bundle.sessionId`, re-stamping
161
- * ownership to `importingPrincipal`. Returns the snapshot tally (imported vs honestly-skipped on a destination whose
162
- * FileSnapshotStore lacks the importManifest seam — pre-1.141.0; core's InMemory/File NOW carry it, so a local dst
163
- * imports snapshots for real).
164
- *
165
- * §10 FAIL-CLOSED VALIDATION runs FIRST — BEFORE any write:
166
- * 1. `validateEntriesForImport(bundle.entries)` (the single core invariant gate: unique ids / parent-before-child /
167
- * one root / leaf-resolvable). Throws on violation; NOTHING is written.
168
- * 2. Every blobHash referenced by every snapshot manifest MUST be supplied by `getBlob` — a missing blob would
169
- * restore a PARTIAL tree (files half-written, conversation whole → breaks the entry↔file lockstep). A lightweight
170
- * PRESENCE pre-check (fetch each distinct hash once, no hashing) fails FAST here; the AUTHORITATIVE content-address
171
- * integrity check (`sha256(bytes)===hash`) is done ONCE inside `importManifest` (core 1.141.0), so we don't double-
172
- * hash. (Runs only when the dst supports snapshot import; otherwise there is nothing to half-write.)
173
- *
174
- * §7 CLASSIFICATION GATE — BEFORE any write, the destination's CURRENT entry log is read and compared to the bundle
175
- * via {@link classifySyncRelationship} (a sound entry-id SET comparison, not leaf_id). A `fork` (true divergence) or a
176
- * `stale` source (the dst is strictly ahead) WOULD lose destination history → it throws {@link SyncConflictError}
177
- * (route → 409) UNLESS the caller passes `{ resolution: "overwrite-dst" }`. An `identical` relation SKIPS the entries
178
- * write entirely (the log is already present & equal; the idempotent snapshot/policy/anchor replay below still runs to
179
- * heal anything missing). `fresh` / `fast_forward` (and an overwrite-resolved fork/stale) write the entries via the
180
- * IDEMPOTENT {@link OwnerAwareSessionStore.replaceEntries} (purge-then-import) — NOT `importEntries`, whose plain
181
- * INSERT crashes on a duplicate PK when the session already exists at the destination.
182
- *
183
- * §8 CROSS-STORE ATOMIC ORDER + IDEMPOTENCY — session_meta (written LAST inside `replaceEntries`) is the sole
184
- * commit point, so a crash mid-import leaves orphan blobs/manifests/anchors with NO session_meta = invisible to
185
- * wake (never a half-session, only collectable orphans). Order:
186
- * ① snapshots (manifests + content-addressed blobs) — content-addressed + create-once = idempotent retry-to-completion.
187
- * `importManifest` returns a {@link FileSnapshotResult} (NEVER throws — core 1.141.0); a `{ok:false}` (a
188
- * rejecting/missing/hash-mismatched blob the §10 pre-check didn't catch — e.g. a blob corrupted in transit) is
189
- * re-raised as a fail-closed THROW so the import aborts BEFORE the entries commit (no entry↔file split).
190
- * ② entries (`replaceEntries` runs the core gate AGAIN and writes session_meta LAST = the commit point; idempotent
191
- * over an existing session — skipped entirely when the relation is `identical`)
192
- * ③ policy (replay each record via putRules; `opts.operatorOk` defaults FALSE → the E6 tighten-only gate APPLIES, so
193
- * a replay that would LOOSEN an operator-tightened dst policy throws `SessionPolicyError("loosen_forbidden")` —
194
- * `operator:true` is set ONLY when the authenticated importer is itself a verified operator)
195
- * ④ anchors (owner RE-KEYED to `importingPrincipal`, §9)
196
- * Steps ③/④ are post-commit; re-running them is idempotent — ③ SKIPS a put whose content already equals the dst row
197
- * (a "no-op rev bump" is NOT idempotent: rev is the operator's optimistic-lock token — the A6 fix below), ④ is a
198
- * same-value upsert — so a crash between ②–④ is recovered by a retry that converges to the same bytes.
199
- *
200
- * @param opts.resolution — `"overwrite-dst"` consents to a `fork`/`stale` import that overwrites destination history
201
- * (the user's keep-source decision); omitted ⇒ such an import is refused with {@link SyncConflictError}.
202
- * @param opts.operatorOk — when `true`, the policy replay (step ③) is an OPERATOR write (may loosen the dst rules); set
203
- * ONLY when the AUTHENTICATED importer is a verified operator. DEFAULT false → the E6 tighten-only gate applies, so a
204
- * non-operator import is TIGHTEN-ONLY (design §4): a replay that would loosen an operator-tightened dst session policy
205
- * throws `SessionPolicyError("loosen_forbidden")`, which propagates (NOT swallowed) so the route can refuse the loosen.
206
- */
207
- export async function importSession(bundle, getBlob, dstBackend, importingPrincipal, opts) {
208
- const sessionId = bundle.sessionId;
209
- const fs = dstBackend.fileSnapshot();
210
- const importFace = fileSnapshotImportFace(fs);
211
- const canImportSnapshots = importFace !== undefined;
212
- // ── §7 classification gate, BEFORE any write ───────────────────────────────────────────────────────────────
213
- // Read the destination's CURRENT entry log and classify the relationship (sound entry-id set comparison). A fork or
214
- // a stale source would lose dst history → refuse with a typed 409 unless the caller explicitly resolved overwrite-dst.
215
- const dstEntries = (await ownerAware(dstBackend).exportEntries?.(sessionId)) ?? null;
216
- const rel = classifySyncRelationship(bundle.entries, dstEntries);
217
- if ((rel.relation === "fork" || rel.relation === "stale") && opts?.resolution !== "overwrite-dst") {
218
- throw new SyncConflictError(sessionId, rel); // 409 — surface the exclusive sets so the user picks keep-local/cloud
219
- }
220
- // ── §10 fail-closed validation, BEFORE any write ────────────────────────────────────────────────────────────
221
- // (1) entries invariants (the core gate). validateEntriesForImport throws on any violation; we discard its result
222
- // here (importEntries re-runs it as the authoritative write-time gate) — this is the early fail-closed check so
223
- // we never start writing snapshots for a session whose entries the gate will reject.
224
- validateEntriesForImport(bundle.entries);
225
- // (2) every referenced blob must be fetchable — ONLY when the destination can actually import snapshots (otherwise
226
- // there is nothing to half-write, so a missing blob is moot; the snapshot is honestly skipped below). Fetch each
227
- // DISTINCT hash once (content-addressed) so a large manifest doesn't refetch. This is a lightweight PRESENCE
228
- // pre-check (fail-FAST before we write any entries) — the AUTHORITATIVE sha256 content-address integrity check is
229
- // done ONCE inside importManifest (core 1.141.0); we deliberately don't re-hash here to avoid double-hashing
230
- // a possibly-large blob set. The importManifest {ok:false} below is the backstop for anything the presence check
231
- // can't see (a blob present-but-corrupted-in-transit, a getBlob that rejects only on a later call).
232
- if (canImportSnapshots) {
233
- const distinct = new Set();
234
- for (const snap of bundle.snapshots)
235
- for (const [, hash] of snap.manifest)
236
- distinct.add(hash);
237
- for (const hash of distinct) {
238
- const bytes = await getBlob(hash);
239
- if (!bytes) {
240
- throw new Error(`session-sync import: missing blob ${hash} for session ${sessionId} (refusing a partial-tree import)`);
241
- }
242
- }
243
- }
244
- // ── ⓪ overwrite-dst subsystem WIPE (fork/stale with explicit consent ONLY) ─────────────────────────────────
245
- // "overwrite-dst" means the user chose KEEP-SOURCE — the result must be the SOURCE's state, not "the source's
246
- // conversation + the destination's leftover policy/anchors/snapshots" (a MIXED state is worse than lost history:
247
- // dangling anchors resolve onto purged entries, an abandoned branch's policy rows keep gating, and a shared-key
248
- // snapshot keeps the dst's bytes because importManifest is create-once). So before replaying ①/③/④ we wipe the
249
- // dst's per-session subsystem state. Crash-safety: the user consented to discarding the dst's state, and every
250
- // wipe+replay leg is idempotent → a crash mid-wipe is recovered by the same retry-to-completion as §8's ②–④.
251
- // · snapshots: prefer the purge seam `deleteBySession` (SQL twins: manifests drop now, blobs go to the async
252
- // orphan GC); fall back to the core-REQUIRED `reap(scope, [])` (in-memory/file stores GC unreferenced blobs
253
- // inline — content-addressed, so blobs still referenced by other scopes survive).
254
- // · anchors / policy: `deleteBySession` where the store carries it — since core 1.423 that is EVERY in-tree
255
- // store (the seam is optional on core's SessionPolicyStore interface and implemented by its File/InMemory
256
- // stores too — [1796]§三 → [1801]; the feature-detect stays for third-party stores that predate the seam).
257
- const overwriting = (rel.relation === "fork" || rel.relation === "stale") && opts?.resolution === "overwrite-dst";
258
- if (overwriting) {
259
- const fsWipe = fs;
260
- if (typeof fsWipe.deleteBySession === "function")
261
- await fsWipe.deleteBySession(sessionId);
262
- else
263
- await fsWipe.reap(sessionId, []);
264
- // 🔴 [2195] 同族(2026-08-01):这两条此前是「有 seam 就删,没有就**静默跳过**」——而本段开头
265
- // 逐字论证了为什么必须删(「a MIXED state is worse than lost history:dangling anchors resolve onto
266
- // purged entries, an abandoned branch's policy rows keep gating」)。论证了坏状态不可接受,却在能力
267
- // 缺席时放行了它自己点名的那个坏状态,且**一声不响**。与 session owner 门那条同族:
268
- // **能力探测的失败方向决定整条保证的失败方向**,保护型能力缺席必须 fail-closed。
269
- //
270
- // 改成 fail-loud:wipe 不了就不继续(此时还**没有**任何写入落地 —— 本段在 §8 的 ①③④ 之前,
271
- // 所以抛在这里是干净的中止,不是半写状态)。in-tree 每个 store 自 core 1.423 起都带这个 seam
272
- // ⇒ 我们自己的部署零影响;受影响的只有 seam 之前的第三方 store,而它们此前拿到的是坏数据。
273
- // 🔴 [2195] 同族(2026-08-01):这两条此前都是「有 seam 就删,没有就**静默跳过**」——而本段开头
274
- // 逐字论证了为什么必须删(「a MIXED state is worse than lost history:dangling anchors resolve onto
275
- // purged entries, an abandoned branch's policy rows keep gating」)。论证了坏状态不可接受,却在能力
276
- // 缺席时放行了它自己点名的那个坏状态,且一声不响。
277
- //
278
- // 但两条的**类型事实不同**(旧码用同一个 `as { deleteBySession?: … }` 把两者抹平了,那个 `as` 正是
279
- // 遮蔽物):
280
- // · `ResumeAnchorStore.deleteBySession` —— **必填**。类型系统已经排除了「没有」的可能,那个
281
- // feature-detect 是**为一个不可达场景**写的分支;注释里「for third-party stores that predate the
282
- // seam」的说法与类型不符(这样的 store 根本进不来)。⇒ 删掉探测,直接调用。真有 JS 层绕过来的
283
- // 残缺实现,直接调用抛 TypeError 也是 fail-loud,不必自己写。
284
- // · `SessionPolicyStore.deleteBySession` —— **可选**(且返回 `Promise<void>`,与 anchor 的
285
- // `Promise<number>` 不同;这个差异也是被那个 `as` 抹掉的)。⇒ 探测保留,但缺席时 fail-loud。
286
- //
287
- // 抛在这里是干净的中止:本段在 §8 的 ①③④ 之前,还没有任何写入落地。
288
- await dstBackend.resumeAnchor().deleteBySession(sessionId);
289
- const policyWipe = dstBackend.sessionPolicy();
290
- if (typeof policyWipe.deleteBySession !== "function") {
291
- throw new Error(`overwrite-dst cannot proceed: this deployment's session-policy store has no deleteBySession purge seam, so the destination's policy rows for this session cannot be wiped. Completing the import would leave a MIXED state (the source's conversation plus the destination's stale policy rows, which keep gating), which is worse than the history loss you consented to. Upgrade to a policy store carrying deleteBySession (every in-tree store has it since core 1.423), or re-run without resolution:"overwrite-dst".`);
292
- }
293
- await policyWipe.deleteBySession(sessionId);
294
- }
295
- // ── §8 atomic order ─────────────────────────────────────────────────────────────────────────────────────────
296
- // ① snapshots — content-addressed blobs + manifest, BEFORE session_meta. Feature-detect importManifest: ALL backends
297
- // now carry it (durable TiDB/PG + core's File/InMemory since 1.141.0) → a local dst imports snapshots for
298
- // real. The only remaining honest-degrade is a backend whose FileSnapshotStore predates the seam (none in-tree).
299
- // importManifest NEVER throws — it returns a FileSnapshotResult; a {ok:false} (rejecting/missing/hash-mismatched
300
- // blob — e.g. corrupted in transit) is re-raised here as a fail-closed THROW so we abort BEFORE the entries commit
301
- // (no entry↔file split). The whole import is the caller's transaction: a throw here means no session_meta is ever
302
- // written (the commit point is inside importEntries, step ②), so the dst stays clean (only collectable orphans).
303
- let snapshotsImported = 0;
304
- let snapshotsSkipped = 0;
305
- if (importFace) {
306
- const importManifest = importFace.importManifest;
307
- for (const snap of bundle.snapshots) {
308
- const r = await importManifest(sessionId, snap.key, new Map(snap.manifest), getBlob);
309
- if (!r.ok) {
310
- throw new Error(`session-sync import: snapshot import failed for session ${sessionId} key ${snap.key} (${r.error.code}: ${r.error.message}) — fail-closed, refusing a partial-tree import`);
311
- }
312
- snapshotsImported++;
313
- }
314
- }
315
- else {
316
- snapshotsSkipped = bundle.snapshots.length; // pre-1.141.0 dst — files pend the import seam; honest degrade (NOT a silent drop)
317
- }
318
- // ② entries — core gate (again, authoritative) + session_meta LAST = the commit point. Owner = importingPrincipal
319
- // (NEVER the bundle's, §9). Written via the IDEMPOTENT replaceEntries (purge-then-import) — NOT importEntries,
320
- // whose plain INSERT crashes on a duplicate PK when the session already exists (fast-forward / overwrite-resolved
321
- // / a §8 retry). When the relation is `identical` the log is already present & equal → SKIP the entries write
322
- // entirely (the snapshot/policy/anchor replay below is idempotent and still runs to heal anything missing). A
323
- // destination without the replace seam can't accept a session at all → throw.
324
- // 🔴 同上(`session-sync-content.ts` 顶注):`identical` 只说明 id 集合相等,不说明内容相同。这条路上
325
- // `bundle.entries` 与目的端日志都在手上 ⇒ 直接比内容摘要;不符就照常改写(不抛错、不 409)。复用 §7 已经
326
- // 读到的 `dstEntries`(同一个 dstBackend/sessionId)——这两次读之间 dst 未被任何写触碰(overwrite-dst 擦除
327
- // 只在 relation 为 fork/stale 时才跑,identical 分支不可能落进那条腿),不必也不该对同一行重新 exportEntries
328
- // 一次(旧实现还用 `.catch(() => null)` 把这次读的故障裸吞成"目的端为空",见下方 identicalIdsAlsoIdenticalContent
329
- // 对 null 的处置——一次真实的店读故障会被悄悄当成"内容不等"而不是 fail-loud)。
330
- const contentEqual = rel.relation === "identical" && identicalIdsAlsoIdenticalContent(bundle.entries, dstEntries);
331
- if (rel.relation !== "identical" || !contentEqual) {
332
- const session = ownerAware(dstBackend);
333
- const replaceEntries = session.replaceEntries?.bind(session);
334
- if (!replaceEntries) {
335
- throw new Error(`session-sync import: destination backend (${dstBackend.kind}) has no replaceEntries seam`);
336
- }
337
- await replaceEntries(sessionId, importingPrincipal, bundle.entries);
338
- }
339
- // ③ policy — replay each (principal, rules) record VERBATIM. The E6 tighten-only gate is the security boundary here:
340
- // we do NOT pass operator:true unconditionally (a non-operator pushing a client-controlled bundle could otherwise
341
- // LOOSEN an operator-tightened dst session policy — the loosen the E6 route refuses). `opts.operatorOk` defaults
342
- // FALSE → the tighten gate applies, so a TIGHTEN-ONLY import succeeds (design §4 "merge tighten-only") and a
343
- // loosening replay throws SessionPolicyError("loosen_forbidden") — which we do NOT swallow; it propagates to the
344
- // route so the malicious loosen is refused (entries may already have committed: the conversation syncs, only the
345
- // loosen is refused = the correct fail-closed outcome). operator:true ONLY when the AUTHENTICATED importer is a
346
- // verified operator. principal is carried VERBATIM (NOT re-keyed to importingPrincipal): the policy `principal` is
347
- // the per-TASK rule-owner key (`spec.principal`), not the session owner, so re-keying would collapse distinct
348
- // per-principal rows and could clobber. The session-wide row (principal=undefined) stays session-wide and still
349
- // applies to the new owner.
350
- // IDEMPOTENT REPLAY (the A6 fix): a put whose content (rev stripped, normalized) already equals the dst row is
351
- // SKIPPED — putRules bumps `rev` on every write, and `rev` is the optimistic-lock token operators hold for
352
- // read-modify-write. Without the skip, every §8 crash-retry (and every harmless duplicate sync) advances every
353
- // row's rev while changing nothing ⇒ the operator's next putRules({expectedRev}) throws `conflict` on rules whose
354
- // content never moved. With it, retry-after-crash converges to the same BYTES as a single clean run, which is
355
- // what "steps ③/④ are idempotent" (§8 above) actually has to mean. normalizeRules gives both sides the same
356
- // canonical field order/shape, so JSON.stringify is a sound equality here (array ORDER is significant on purpose:
357
- // a reordered list is a different stored byte-state and must be written).
358
- const policyStore = dstBackend.sessionPolicy();
359
- const canonRules = (r) => JSON.stringify(normalizeRules(stripRev(r)));
360
- for (const rec of bundle.policy) {
361
- const cur = await policyStore.getRules(sessionId, rec.principal);
362
- if (cur !== null && canonRules(cur) === canonRules(rec.rules))
363
- continue; // content already identical — don't touch rev
364
- await policyStore.putRules(sessionId, rec.principal, rec.rules, { operator: opts?.operatorOk === true });
365
- }
366
- // ④ anchors — owner RE-KEYED to importingPrincipal (§9): the E18 owner-guard resolves under the NEW owner so
367
- // rewind-to-message keeps working post-move (the source owner, e.g. local null, would never resolve in the cloud).
368
- const anchorStore = dstBackend.resumeAnchor();
369
- for (const a of bundle.anchors) {
370
- await anchorStore.put(sessionId, a.eventId, a.entryId, importingPrincipal);
371
- }
372
- return { snapshotsImported, snapshotsSkipped, relation: rel.relation };
373
- }
374
73
  /**
375
74
  * §7 DRY-RUN — classify what importing `sessionId` from `srcBackend` into `dstBackend` WOULD do, WITHOUT writing
376
- * anything. The route (P1d) calls this to surface a conflict (fork/stale) to the user BEFORE committing an
377
- * {@link importSession} (so the shell can present a keep-local / keep-cloud / fork-new choice up front).
75
+ * anything (the shell can present a keep-local / keep-cloud / fork-new choice up front).
378
76
  *
379
77
  * Returns `null` when the SOURCE session does not exist (no bundle to plan); otherwise the {@link SyncRelation}
380
78
  * between the source's full durable log and the destination's current log (a `null` dst log ⇒ `fresh`). No writes.
@@ -386,4 +84,64 @@ export async function planSync(sessionId, srcBackend, dstBackend) {
386
84
  const dstEntries = (await ownerAware(dstBackend).exportEntries?.(sessionId)) ?? null;
387
85
  return classifySyncRelationship(srcEntries, dstEntries);
388
86
  }
87
+ // ═══ #137 —— 死码 importSession 独有保护的活路由移植(910c524 顶注 TOMBSTONE 两缺口的修复面)═══
88
+ // 本段是「测红复现(fanout-characterization #137 L1/L2)→ 修绿」的绿半场;顶注 TOMBSTONE 的
89
+ // 「NO live-route equivalent」声明随本批作废(保留原文供考古,状态以本段+路由接线为准)。
90
+ /** #137 ①(死码 A6 修的移植):同内容判据 —— 比较「归一化、去 rev」后的字节形。putRules 每次
91
+ * 无条件写都 rev+1,而 rev 是 operator 乐观锁的 CAS 令牌(`PutRulesOptions.expectedRev`),
92
+ * 幂等重试/无害重复同步不该把在途 CAS 打失败。归一化用 core 的 `normalizeRules`(store 落盘
93
+ * 前走同一函数 ⇒ 两侧同坐标);`stripRev` 去掉记录内嵌的 rev(`listBySession` 的 rules 含 rev,
94
+ * 它不是内容)。 */
95
+ export function sameRulesContent(stored, incoming) {
96
+ if (stored === null)
97
+ return false;
98
+ const norm = (r) => JSON.stringify(normalizeRules(stripRev(r)));
99
+ return norm(stored) === norm(incoming);
100
+ }
101
+ /** #137 ① 的重放腿共享实现:PUSH 路由的两条 policy 重放腿(Phase A identical 短路支 / Phase B
102
+ * commit 后支)都从这里走 —— 等内容跳过(rev 零漂移),真变更照常落地(E6 tighten-only 门在
103
+ * putRules 内部,此处不重述)。 */
104
+ export async function replayPolicyRecords(store, sessionId, records, operatorOk) {
105
+ for (const rec of records) {
106
+ const cur = await store.getRules(sessionId, rec.principal);
107
+ if (sameRulesContent(cur, rec.rules))
108
+ continue;
109
+ await store.putRules(sessionId, rec.principal, rec.rules, { operator: operatorOk });
110
+ }
111
+ }
112
+ /** #137 ② 能力预检(fail-loud 方向,[2195] 判据:保护型能力缺席=fail-closed):consented
113
+ * overwrite-dst 需要 policy 店的 `deleteBySession` purge seam 才能不留混合态;缺席=true,路由在
114
+ * commit **之前**用它干净中止(此时零写入落地),wire 拒因字面量归路由所有(api-error-text-freeze
115
+ * 锚字面站点)。anchor 店的 deleteBySession 是类型必填、快照店有 core 必填的 `reap` 兜底,都不构成
116
+ * 缺席面;唯一可缺的就是 policy 这条(core 接口上是可选方法)。 */
117
+ export function overwriteWipeIncapable(policyStore) {
118
+ return policyStore !== undefined && typeof policyStore.deleteBySession !== "function";
119
+ }
120
+ /** #137 ②(死码 ⓪ 擦除步的移植):consented overwrite-dst = keep-source,结果必须是**源端那
121
+ * 一份**,不得混成「源端对话 + 目的端遗留 policy/anchor/快照」(混合态比丢历史更危险:悬空
122
+ * anchor 指向已 purge 的 entry、被放弃分支的 policy 行继续 gating、共享 key 快照因
123
+ * importManifest create-once 永远留着目的端字节)。调用时点=commit(原子 swap)成功**之后**、
124
+ * 附随物重放**之前**:死码在任何写入前擦(其 entries 写本身非原子);活路由有真 commit point,
125
+ * 擦在 commit 前会在「commit 失败」时留下反向混合态(目的端对话完好、附随物已毁)——方向更坏。
126
+ * 诚实残余:进程在 commit 与本函数之间崩溃时,重试落 Phase A identical 支(无 wipe)⇒ 遗留行
127
+ * 可存活;该窗口窄于修前世界(修前=遗留行**恒**存活),与 §8「快照重放失败 ⇒ 422 重试自愈」
128
+ * 同款接受,记录于此不装没有。
129
+ * 各店姿势(死码 verbatim 语义):快照=deleteBySession(SQL 孪生:manifest 即删、blob 走异步
130
+ * 孤儿 GC)缺席则 core 必填 `reap(scope, [])`(内容寻址,他 scope 仍引用的 blob 存活);
131
+ * anchor=直调(类型必填);policy=deleteBySession(路由已用 {@link overwriteWipeIncapable}
132
+ * 预检过,此处缺席=类型外的 JS 层残缺实现,直调抛 TypeError 即 fail-loud)。 */
133
+ export async function wipeSessionAttendants(stores, sessionId) {
134
+ const fs = stores.fileSnapshotStore;
135
+ if (fs !== undefined) {
136
+ if (typeof fs.deleteBySession === "function")
137
+ await fs.deleteBySession(sessionId);
138
+ else
139
+ await fs.reap(sessionId, []);
140
+ }
141
+ if (stores.resumeAnchorStore !== undefined)
142
+ await stores.resumeAnchorStore.deleteBySession(sessionId);
143
+ const policy = stores.sessionPolicyStore;
144
+ if (policy !== undefined)
145
+ await policy.deleteBySession(sessionId);
146
+ }
389
147
  //# sourceMappingURL=session-sync.js.map
@@ -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.5.0",
3
+ "version": "6.7.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.5.0",
72
72
  "@types/libsodium-wrappers": "^0.7.14",
73
73
  "@types/node": "22.10.2",
74
74
  "@types/pg": "^8.20.0",