@sema-agent/server 7.133.0 → 7.134.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.
Files changed (185) hide show
  1. package/MIGRATION.md +87 -0
  2. package/README.md +2 -2
  3. package/README.zh-CN.md +2 -2
  4. package/USAGE.md +129 -11
  5. package/deploy/sema-up/README.md +16 -0
  6. package/deploy/sema-up/chart/Chart.yaml +1 -1
  7. package/deploy/sema-up/chart/templates/NOTES.txt +15 -0
  8. package/deploy/sema-up/chart/templates/_helpers.tpl +129 -0
  9. package/deploy/sema-up/chart/templates/sealed-key.yaml +24 -0
  10. package/deploy/sema-up/chart/templates/server-v4pro.yaml +14 -0
  11. package/deploy/sema-up/chart/templates/server.yaml +14 -0
  12. package/deploy/sema-up/chart/values-full-single.yaml +5 -1
  13. package/deploy/sema-up/chart/values.yaml +34 -0
  14. package/dist/approval-ask-audit-store.d.ts +1 -1
  15. package/dist/approval-card.d.ts +4 -4
  16. package/dist/approval-card.js +1 -2
  17. package/dist/approval-content-kind.d.ts +5 -2
  18. package/dist/approval-content-kind.js +16 -3
  19. package/dist/approval-gate-currency.d.ts +72 -0
  20. package/dist/approval-gate-currency.js +94 -0
  21. package/dist/approval.d.ts +6 -0
  22. package/dist/approval.js +31 -14
  23. package/dist/boot/coordinators.d.ts +6 -1
  24. package/dist/boot/coordinators.js +6 -3
  25. package/dist/boot/leader.d.ts +2 -0
  26. package/dist/boot/leader.js +2 -1
  27. package/dist/boot/read-deny-anchor-audit.js +2 -2
  28. package/dist/boot/runner-deps.d.ts +18 -5
  29. package/dist/boot/runner-deps.js +9 -0
  30. package/dist/boot/stage-06-runners.d.ts +4 -3
  31. package/dist/boot/stage-06-runners.js +15 -2
  32. package/dist/boot/stage-07-capability-layer.d.ts +2 -1
  33. package/dist/boot/stage-08-reapers.d.ts +2 -1
  34. package/dist/boot/stage-09-leader.d.ts +2 -1
  35. package/dist/boot/stage-09-leader.js +2 -2
  36. package/dist/boot/stage-10-http-server.d.ts +2 -1
  37. package/dist/boot/stage-10-http-server.js +2 -1
  38. package/dist/boot/stores.d.ts +8 -0
  39. package/dist/boot/stores.js +8 -12
  40. package/dist/boot/web-admin.d.ts +6 -0
  41. package/dist/boot/web-admin.js +11 -2
  42. package/dist/budget.js +5 -1
  43. package/dist/caller-text-echo.d.ts +2 -0
  44. package/dist/caller-text-echo.js +2 -1
  45. package/dist/canonical-json.d.ts +2 -41
  46. package/dist/canonical-json.js +2 -13
  47. package/dist/capabilities/composite-members.d.ts +1 -1
  48. package/dist/capabilities/composite-members.js +1 -1
  49. package/dist/capabilities/mcp-probe.js +2 -2
  50. package/dist/capability-facts.d.ts +2 -0
  51. package/dist/capability-facts.js +4 -4
  52. package/dist/checkpoint-ctx-seal-m28.d.ts +42 -0
  53. package/dist/checkpoint-ctx-seal-m28.js +19 -0
  54. package/dist/field-conflict.d.ts +2 -1
  55. package/dist/field-conflict.js +1 -0
  56. package/dist/file-history-disclosure.d.ts +34 -5
  57. package/dist/file-history-disclosure.js +2 -2
  58. package/dist/fleet/fleet-bus.d.ts +4 -0
  59. package/dist/fleet/fleet-bus.js +1 -0
  60. package/dist/hooks/hook-runner.js +2 -2
  61. package/dist/http/admission.js +8 -0
  62. package/dist/http/approval-sweeps.js +3 -1
  63. package/dist/http/checkpoint-ctx.d.ts +157 -2
  64. package/dist/http/checkpoint-ctx.js +158 -2
  65. package/dist/http/dispatch.js +3 -3
  66. package/dist/http/durable-run-leg.js +3 -2
  67. package/dist/http/idempotency.d.ts +2 -2
  68. package/dist/http/idempotency.js +2 -2
  69. package/dist/http/principal-gate.d.ts +32 -6
  70. package/dist/http/principal-gate.js +27 -4
  71. package/dist/http/refusal-body.d.ts +12 -0
  72. package/dist/http/refusal-body.js +11 -0
  73. package/dist/http/request-guards.d.ts +0 -9
  74. package/dist/http/request-guards.js +2 -16
  75. package/dist/http/resume-ctx-read.d.ts +48 -1
  76. package/dist/http/resume-ctx-read.js +26 -3
  77. package/dist/http/resume-legs.d.ts +2 -1
  78. package/dist/http/resume-legs.js +37 -22
  79. package/dist/http/route-ctx.d.ts +0 -9
  80. package/dist/http/routes/agents-roster.js +2 -3
  81. package/dist/http/routes/approvals-assistant.js +43 -9
  82. package/dist/http/routes/capabilities.js +1 -1
  83. package/dist/http/routes/diagnostics.d.ts +3 -3
  84. package/dist/http/routes/diagnostics.js +1 -1
  85. package/dist/http/routes/fleet.js +4 -4
  86. package/dist/http/routes/memory-origin.d.ts +1 -1
  87. package/dist/http/routes/rules.js +4 -4
  88. package/dist/http/routes/runs.js +30 -9
  89. package/dist/http/routes/session-sync.d.ts +3 -2
  90. package/dist/http/routes/session-sync.js +38 -20
  91. package/dist/http/routes/sessions-list.d.ts +1 -1
  92. package/dist/http/routes/sessions-list.js +3 -4
  93. package/dist/http/routes/sessions.js +4 -4
  94. package/dist/http/routes/tasks.js +21 -16
  95. package/dist/http/routes/trace-usage.js +7 -9
  96. package/dist/http/run-declared-families.d.ts +16 -0
  97. package/dist/http/run-declared-families.js +12 -0
  98. package/dist/http/server.d.ts +6 -0
  99. package/dist/http/server.js +5 -2
  100. package/dist/http/submit-ctx.d.ts +4 -3
  101. package/dist/http/submit-ctx.js +19 -3
  102. package/dist/http/wire-types.d.ts +7 -3
  103. package/dist/index.d.ts +2 -0
  104. package/dist/index.js +1 -0
  105. package/dist/leader/wire.d.ts +4 -0
  106. package/dist/leader/wire.js +8 -7
  107. package/dist/main.js +1 -1
  108. package/dist/memory-origin-fence.js +2 -2
  109. package/dist/model-compat.d.ts +1 -1
  110. package/dist/observability/prompt-manifest.d.ts +19 -8
  111. package/dist/observability/prompt-manifest.js +9 -8
  112. package/dist/observability/tool-trace.js +8 -9
  113. package/dist/orchestration/workflow-park-decision.d.ts +1 -1
  114. package/dist/orchestration/workflow-park-decision.js +1 -0
  115. package/dist/parked-decide.d.ts +4 -5
  116. package/dist/parked-decide.js +3 -1
  117. package/dist/permission-rule-vocab.d.ts +1 -23
  118. package/dist/permission-rule-vocab.js +0 -13
  119. package/dist/plugins/approval-payload-fingerprint.d.ts +14 -6
  120. package/dist/plugins/approval-payload-fingerprint.js +1 -1
  121. package/dist/plugins/caching-session-store.d.ts +1 -1
  122. package/dist/plugins/caching-session-store.js +4 -3
  123. package/dist/plugins/checkpoint-store-sql.d.ts +22 -1
  124. package/dist/plugins/checkpoint-store-sql.js +45 -1
  125. package/dist/plugins/local-checkpoint-store.d.ts +13 -2
  126. package/dist/plugins/local-checkpoint-store.js +26 -0
  127. package/dist/plugins/local-session-store.d.ts +12 -9
  128. package/dist/plugins/local-session-store.js +23 -14
  129. package/dist/plugins/local-session-window.d.ts +12 -6
  130. package/dist/plugins/local-session-window.js +1 -17
  131. package/dist/plugins/one-time-migrations.d.ts +11 -0
  132. package/dist/plugins/one-time-migrations.js +12 -0
  133. package/dist/plugins/permission-rule-store-file.js +10 -6
  134. package/dist/plugins/permission-rule-store-sql.js +8 -8
  135. package/dist/plugins/pg-session-storage.d.ts +1 -1
  136. package/dist/plugins/pg-session-storage.js +39 -17
  137. package/dist/plugins/remote-env-device.d.ts +14 -7
  138. package/dist/plugins/remote-env-device.js +23 -15
  139. package/dist/plugins/session-ownership.d.ts +11 -0
  140. package/dist/plugins/session-ownership.js +11 -0
  141. package/dist/plugins/session-placement.d.ts +12 -14
  142. package/dist/plugins/session-placement.js +2 -15
  143. package/dist/plugins/tidb-session-store.d.ts +1 -1
  144. package/dist/plugins/tidb-session-store.js +39 -17
  145. package/dist/request-tool-axes.d.ts +12 -0
  146. package/dist/request-tool-axes.js +20 -0
  147. package/dist/run-local.js +1 -0
  148. package/dist/runs.d.ts +2 -1
  149. package/dist/runs.js +4 -3
  150. package/dist/sealed-key.d.ts +32 -0
  151. package/dist/sealed-key.js +55 -1
  152. package/dist/security.d.ts +9 -7
  153. package/dist/session-error-wire.d.ts +12 -5
  154. package/dist/session-error-wire.js +4 -1
  155. package/dist/session-sync-kernel.d.ts +37 -10
  156. package/dist/session-sync-kernel.js +10 -0
  157. package/dist/task-a2a.js +4 -3
  158. package/dist/task-mcp.d.ts +2 -2
  159. package/dist/task-mcp.js +20 -8
  160. package/dist/task-settings.d.ts +5 -1
  161. package/dist/task-settings.js +1 -1
  162. package/dist/tool-approval.d.ts +151 -52
  163. package/dist/tool-approval.js +113 -20
  164. package/dist/tool-rule-names.d.ts +6 -0
  165. package/dist/trace/core-keyset-guard.d.ts +4 -3
  166. package/dist/trace/exit-seat.d.ts +13 -1
  167. package/dist/trace/exit-seat.js +55 -1
  168. package/dist/trace/ledger-events.d.ts +3 -1
  169. package/dist/trace/project.d.ts +18 -1
  170. package/dist/trace/project.js +16 -4
  171. package/dist/trace/redact.d.ts +6 -3
  172. package/dist/trace/redact.js +191 -23
  173. package/dist/trace/self-minted-wire.d.ts +4 -0
  174. package/dist/trace/self-minted-wire.js +1 -0
  175. package/dist/trace/wire-projection-faces.d.ts +6 -0
  176. package/dist/trace/wire-projection-faces.js +6 -0
  177. package/dist/web-admin-client/skills-mcp.d.ts +1 -1
  178. package/dist/web-admin-client/skills-mcp.js +2 -3
  179. package/dist/web-admin-client/types.d.ts +1 -1
  180. package/package.json +2 -2
  181. package/scripts/drain-check-local.mjs +34 -2
  182. package/scripts/drain-check.mysql.sql +20 -2
  183. package/scripts/drain-check.pg.sql +20 -2
  184. package/dist/mcp-tool-faces.d.ts +0 -69
  185. package/dist/mcp-tool-faces.js +0 -104
@@ -1,5 +1,6 @@
1
1
  import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
2
  import { join, resolve } from "node:path";
3
+ import { assertFirstOwnerWritable, forkOwnerOf } from "./session-ownership.js";
3
4
  import { FileSessionRepo, SessionError, StoredSession, validateEntriesForImport } from "@sema-agent/core";
4
5
  import { z } from "zod";
5
6
  import { contentForkRelation, fastForwardSharedContentDiverged, identicalIdsAlsoIdenticalContent } from "../session-sync-content.js";
@@ -13,7 +14,7 @@ import { decodeInstructionSource, encodeInstructionSource, sameInstructionSource
13
14
  import { noteReplayInSessionOpenScope, openSessionForItself } from "../session-records-skipped-notice.js";
14
15
  import { createCorruptReadFailOpenSeat } from "../observability/corrupt-read-seat.js";
15
16
  import { buildObjectivePreview, isAttachmentFrame } from "../user-authored-text.js";
16
- import { classifySyncRelationshipByIds, SyncConflictError, stagingIdFor, } from "../session-sync-kernel.js";
17
+ import { classifySyncRelationshipByIds, ownerCellIs, SyncConflictError, StagedCommitOwnerChangedError, stagingIdFor, } from "../session-sync-kernel.js";
17
18
  const ownerEq = (a, b) => (a ?? null) === (b ?? null);
18
19
  const isNotFound = (e) => e instanceof SessionError && e.code === "not_found";
19
20
  const SESSION_OBJECTIVE_PREVIEW_CHARS = 200;
@@ -459,6 +460,7 @@ export class LocalSessionStore {
459
460
  await this.meta.ensureLoaded();
460
461
  if (ownerRecorded(this.meta.state.entries.get(sessionId)))
461
462
  return;
463
+ assertFirstOwnerWritable(sessionId, owner);
462
464
  const existed = await this.existsInRepo(sessionId);
463
465
  const claim = existed && owner !== null && ownerClaimWindowOpen();
464
466
  const wrote = await this.writeMeta(sessionId, (before) => {
@@ -580,17 +582,21 @@ export class LocalSessionStore {
580
582
  }
581
583
  async fork(sourceId, owner) {
582
584
  await this.meta.ensureLoaded();
583
- try {
584
- const session = await this.repo.fork({ id: sourceId, createdAt: "" }, {});
585
- const id = (await session.getMetadata()).id;
586
- await this.writeMeta(id, () => ({ incarnation: mintSessionIncarnation(), owner }));
587
- return id;
588
- }
589
- catch (e) {
590
- if (isNotFound(e))
591
- return null;
592
- throw e;
593
- }
585
+ return this.withSessionWriteLock(sourceId, async () => {
586
+ const source = this.meta.state.entries.get(sourceId);
587
+ const forkOwner = forkOwnerOf(owner, source === undefined ? undefined : readSessionMetaCell(source).owner);
588
+ try {
589
+ const session = await this.repo.fork({ id: sourceId, createdAt: "" }, {});
590
+ const id = (await session.getMetadata()).id;
591
+ await this.writeMeta(id, () => (forkOwner === undefined ? mintSessionIncarnation() : { incarnation: mintSessionIncarnation(), owner: forkOwner }));
592
+ return id;
593
+ }
594
+ catch (e) {
595
+ if (isNotFound(e))
596
+ return null;
597
+ throw e;
598
+ }
599
+ });
594
600
  }
595
601
  async exportEntries(sessionId) {
596
602
  if (!this.repo.exportEntries)
@@ -657,12 +663,15 @@ export class LocalSessionStore {
657
663
  }
658
664
  async commitStagedImport(sessionId, owner, staged, opts) {
659
665
  return this.withSessionWriteLock(sessionId, async () => {
666
+ const current = await this.metaOf(sessionId);
667
+ if (!ownerCellIs(opts.expectedOwner, current === undefined ? undefined : (current.owner ?? null)))
668
+ throw new StagedCommitOwnerChangedError(sessionId);
660
669
  const dstEntries = await this.exportEntries(sessionId);
661
670
  const rel = classifySyncRelationshipByIds(staged.map((e) => e.id), dstEntries === null ? null : dstEntries.map((e) => e.id));
662
- if ((rel.relation === "fork" || rel.relation === "stale") && opts?.resolution !== "overwrite-dst") {
671
+ if ((rel.relation === "fork" || rel.relation === "stale") && opts.resolution !== "overwrite-dst") {
663
672
  throw new SyncConflictError(sessionId, rel);
664
673
  }
665
- if (rel.relation === "fast_forward" && opts?.resolution !== "overwrite-dst" && dstEntries !== null && dstEntries.length > 0) {
674
+ if (rel.relation === "fast_forward" && opts.resolution !== "overwrite-dst" && dstEntries !== null && dstEntries.length > 0) {
666
675
  const dstSet = new Set(dstEntries.map((e) => e.id));
667
676
  const diverged = fastForwardSharedContentDiverged(staged.filter((e) => dstSet.has(e.id)), dstEntries);
668
677
  if (diverged)
@@ -16,12 +16,18 @@
16
16
  * branch below the floor — so the record the engine reads is the one the run's walk reaches.
17
17
  * Read-only: the log is read once (`exportEntries`, pure), no append log is opened, and the window refuses writes.
18
18
  *
19
- * ## Cost: one linear walk
20
- * The branch is the `parentId` chain from the leaf — the chain core's `getPathToRoot` follows, with the same stop rule (an entry
21
- * without a parent ends it; a parent the log does not hold is `invalid_session`). It is walked here once, in linear time: core
22
- * 7.52.0 builds its path with `unshift` (quadratic in the branch length — seconds on a long session, on the event loop), and the
23
- * view must cost a window read, not a whole-branch walk. Every later read of the window goes through core's own walk, bounded by
24
- * the floor.
19
+ * ## Cost: one linear walk, the engine's
20
+ * The branch is core's own `getPathToRoot` over the whole log (the walk every engine read of a local session makes, with its stop
21
+ * rules: an entry without a parent ends it, a parent the log does not hold is `invalid_session`, an unknown leaf is `not_found`).
22
+ * Since core 7.54.0 (#2092) that walk is linear; before, it built the path with `unshift` (quadratic — seconds on a long session, on
23
+ * the event loop) and this module walked the `parentId` chain itself. The second walker is retired: the view reads the branch the
24
+ * engine reads, by the engine's reading. Every later read of the window goes through the same walk, bounded by the floor.
25
+ * A parent CYCLE (a corrupted log; the engine never writes one) is refused by that same walk: since core 7.54.1 it carries one
26
+ * `ParentChainGuard`, and the entry it stands on twice ends it with `invalid_session` naming that entry — `wake` refuses a looped log
27
+ * the way it refuses one whose parent is missing. Core 7.54.0's walk had no guard (a looped log spun it forever on the event loop,
28
+ * and the view's wake with it). The retired walker's own cycle refusal went with it: the engine's guard is the one refusal now, shared
29
+ * by `getPathToRoot` and the run's resume ancestor scan (the two walks core 7.54.1 guards; this module has no walker of its own left).
30
+ * Pinned in `test/session-log-parent-cycle.test.ts`.
25
31
  */
26
32
  import { BaseSessionStorage } from "@sema-agent/core";
27
33
  export declare class LocalSessionWindow extends BaseSessionStorage {
@@ -30,25 +30,9 @@ export class LocalSessionWindow extends BaseSessionStorage {
30
30
  const metadata = (await repo.list()).find((m) => m.id === sessionId) ?? { id: sessionId, createdAt: new Date().toISOString() };
31
31
  const whole = new InMemorySessionStorage({ entries: log });
32
32
  const leafId = await whole.getLeafId();
33
- const branch = await branchOf(whole, leafId, log.length);
33
+ const branch = await whole.getPathToRoot(leafId);
34
34
  const { tail, floorEntryId } = boundedTail(branch);
35
35
  return new LocalSessionWindow(metadata, tail, leafId, floorEntryId, branch.slice(0, branch.length - tail.length));
36
36
  }
37
37
  }
38
- async function branchOf(whole, leafId, logLength) {
39
- const fromLeaf = [];
40
- let current = leafId === null ? undefined : await whole.getEntry(leafId);
41
- while (current) {
42
- fromLeaf.push(current);
43
- if (!current.parentId)
44
- break;
45
- if (fromLeaf.length > logLength)
46
- throw new SessionError("invalid_session", `Entry ${current.id} is on a parent cycle`);
47
- const parent = await whole.getEntry(current.parentId);
48
- if (!parent)
49
- throw new SessionError("invalid_session", `Entry ${current.parentId} not found`);
50
- current = parent;
51
- }
52
- return fromLeaf.reverse();
53
- }
54
38
  //# sourceMappingURL=local-session-window.js.map
@@ -256,6 +256,17 @@
256
256
  * 箱的键;判官拒的 owner 持不了租(`ack` / `releaseLease` 照「不是持租者」答、`claimLease` 抛),存量那一格随租约到期失效;② MySQL 协议上带孤立代理的主体
257
257
  * 在升级前写下的箱 —— 驱动把孤立代理换成 U+FFFD 的三字节,库里的键与字面带 U+FFFD 的那只主体的键逐字节相同,判官收这只键 ⇒ 不删(挑出来就得删掉一只律认为
258
258
  * 存在的箱)。local 车道用引擎自己的 File 信箱(盘上格式归引擎)⇒ 恒 0。
259
+ * · **M28 停驻行里的明文凭据原地封好**(7.134.0)—— 7.134.0 起提交体里请求面凭据位上的每一个值(闭集 `CHECKPOINT_CTX_CREDENTIAL_SLOTS` 与规则的属主
260
+ * `http/checkpoint-ctx.ts` 末段「凭据位密封」;本处不重列)在 `checkpoint_ctx` 里只以封签存在,续跑上下文读口对凭据位上的明文答 409 `conflict.checkpoint_ctx_unsealable`(`reason:"plaintext"`)。升级之前写下的
261
+ * 行是明文 ⇒ **起服扫一遍、原地封好**:不在本文件的迁移段里(封要保管店的封签器,而本段跑在建店的 web-admin 运行时能交给它之前的店层 ——
262
+ * 与 M10 / M19 / M25 同为在本段之外跑的条目),编排在 `checkpoint-ctx-seal-m28.ts`(起服第 6 段,checkpoint 店建成之后、任何路由装配之前调一次),
263
+ * 店腿是两孪生各自的 `rewriteCtxRowsM28`(SQL:键集分页 + 按原文 CAS、`updated_at_ms` 不动;local:逐档原子重写、信封的 `updatedAt` 不动),
264
+ * 行改写是读写两侧同一只封口(`sealStoredCheckpointCtxM28`)。不删行、不改续跑语义;读不出的行计数不动;已封与不带凭据的行零写。读数不进
265
+ * {@link OneTimeMigrationReport}(`reportKeys` 空,与 M25 同形),由起服 info 一行 `checkpoint_ctx_credentials_sealed` 说(扫了几行、封了几行几个值)。
266
+ * 窗外不扫。窗 = 发布日 + 30 天(与 M21 / M23 同一条论证:窗关时缺省部署上升级前停驻的行已被终局兜底收走);窗关之后仍是明文的行由读口的 `plaintext` 拒接住
267
+ * (出路 = 重提)。**本条不覆盖混跑(如实登记)**:≤7.133.x 的续跑读者对凭据位上的封签值没有拒臂(读口、重放判据、店的形判都不看那一格,各消费者按「形不对」把它丢掉、
268
+ * 任务照跑),而它改写过的行与 7.134.0 副本新写的行都是封签 ⇒ 升级序是**先停光 ≤7.133.x 副本、再起第一个 7.134.0 副本**(本条在它起服时跑),回滚之前先结掉带凭据的停驻行
269
+ * (随包 MIGRATION 7.134.0 第 1 条)。
259
270
  *
260
271
  * ## 收据账(一次起服一只,每一条迁移同一只)
261
272
  * 每一步把它**已提交**的改写 / 删除与点名读数当场记进同一只收据账({@link MigrationReceipts}):计数随每次提交累加、行随每次提交追加、形读数在那一步落定时写。走完 ⇒
@@ -450,6 +450,18 @@ export const ONE_TIME_MIGRATIONS = [
450
450
  "或照 `docs/DEPLOY-PREREQS.md` 7.133.0 SQL 节的查法按键手工删",
451
451
  reportKeys: ["mailboxRejectedKeyRowsRemoved"],
452
452
  },
453
+ {
454
+ id: "M28",
455
+ introducedIn: "7.134.0",
456
+ releasedOn: "2026-10-21",
457
+ removableAfter: "2026-11-20",
458
+ removal: "删 `src/checkpoint-ctx-seal-m28.ts` 整个文件与起服第 6 段对它的那一处调用;两孪生的店腿 `rewriteCtxRowsM28` 与读数型 `CtxRowsRewriteM28`;" +
459
+ "`http/checkpoint-ctx.ts` 的 `sealStoredCheckpointCtxM28`;`test/checkpoint-ctx-seal-m28.test.ts` 与 `test/checkpoint-ctx-credential-seal-db-integration.test.ts` 的 M28 格。" +
460
+ "**不随本条删**:凭据位密封的常设规则(封口 / 开口 / 闭集 / 读口第四态 / 409 码 / 装配期拒启)",
461
+ afterRemoval: "读口对凭据位上明文的常设拒臂接住:跳过了带本条的版本直接升的库,带明文凭据的停驻行续跑答 409 `conflict.checkpoint_ctx_unsealable`(`reason:\"plaintext\"`)," +
462
+ "行字节不动 —— 出路 = 作为新任务重提(仍停驻的先取消),或升级时先过一次带本条的版本",
463
+ reportKeys: [],
464
+ },
453
465
  ];
454
466
  export function migrationWindowOpen(window, today) {
455
467
  return window.removableAfter === null || today <= window.removableAfter;
@@ -6,8 +6,7 @@ import { FileDurableRulePartitionProvider, AppendLog, ensureDir, } from "@sema-a
6
6
  import { z } from "zod";
7
7
  import { DurableRuleScopeSchema, buildRulePayloadHash } from "./permission-rule-store-sql.js";
8
8
  import { replayJsonlGuarded } from "../observability/corrupt-read-seat.js";
9
- import { EDITED_RULE_BREADTH_WARNING_CODES, RULE_APPROVAL_RECORD_SCHEMA, UNCOVERED_SEGMENT_REASONS } from "../permission-rule-vocab.js";
10
- import { RULE_BEHAVIORS } from "@sema-agent/core";
9
+ import { EDITED_RULE_BREADTH_WARNING_CODES, RULE_APPROVAL_KINDS, RULE_APPROVAL_RECORD_SCHEMA, RULE_APPROVAL_STATES, RULE_BEHAVIORS, RULE_OWNER_KINDS, UNCOVERED_SEGMENT_REASONS } from "@sema-agent/core";
11
10
  const RuleDotSchema = z.object({ actor: z.string().min(1), counter: z.number().int().nonnegative().safe() }).strict();
12
11
  const RuleCandidateSchema = z.object({ behavior: z.enum(RULE_BEHAVIORS), rule: z.string(), scope: DurableRuleScopeSchema }).strict();
13
12
  const RuleOffer2Schema = z.union([
@@ -22,14 +21,19 @@ const RuleOffer2Schema = z.union([
22
21
  })
23
22
  .strict(),
24
23
  ]);
24
+ const OWNER_SHAPE_BY_KIND = {
25
+ principal: z.object({ kind: z.literal("principal"), principal: z.string() }).strict(),
26
+ "local-owner": z.object({ kind: z.literal("local-owner") }).strict(),
27
+ };
28
+ const RuleOwnerSchema = z.union([OWNER_SHAPE_BY_KIND.principal, OWNER_SHAPE_BY_KIND["local-owner"]]);
25
29
  const ApprovalRecordSchema = z
26
30
  .object({
27
31
  id: z.string().min(1),
28
32
  principal: z.string().optional(),
29
- owner: z.union([z.object({ kind: z.literal("principal"), principal: z.string() }).strict(), z.object({ kind: z.literal("local-owner") }).strict()]).optional(),
33
+ owner: RuleOwnerSchema.optional(),
30
34
  schema: z.literal(RULE_APPROVAL_RECORD_SCHEMA),
31
- kind: z.enum(["card", "import", "starter"]),
32
- state: z.enum(["pending", "approved", "redeemed"]),
35
+ kind: z.enum(RULE_APPROVAL_KINDS),
36
+ state: z.enum(RULE_APPROVAL_STATES),
33
37
  candidates: z.array(RuleCandidateSchema),
34
38
  offers: z.array(RuleOffer2Schema),
35
39
  createdAt: z.string(),
@@ -72,7 +76,7 @@ const StaleApprovalRowSchema = z
72
76
  id: z.string().min(1),
73
77
  schema: z.unknown().optional(),
74
78
  principal: z.string().optional(),
75
- owner: z.union([z.object({ kind: z.literal("principal"), principal: z.string() }).strict(), z.object({ kind: z.literal("local-owner") }).strict()]).optional(),
79
+ owner: RuleOwnerSchema.optional(),
76
80
  })
77
81
  .loose();
78
82
  const StaleApprovalLineSchema = z.object({ op: z.literal("put"), record: StaleApprovalRowSchema }).loose();
@@ -1,10 +1,10 @@
1
1
  import { createHash, randomBytes, randomUUID } from "node:crypto";
2
2
  import { z } from "zod";
3
- import { applyTombstones, assertDeleteDeltaCarriesNoAdd, assertRedemptionNotQuarantined, assertWriteDeltaScopeDurable, foldDelta, isValidDurableScope, parseRuleText, readRowBehavior, screenRuleSyncState, PERMISSION_RULE_WRITER, RULE_BEHAVIORS, RULE_SYNC_DROP_CODES, EMPTY_DURABLE_RULE_PARTITION, } from "@sema-agent/core";
3
+ import { applyTombstones, assertDeleteDeltaCarriesNoAdd, assertRedemptionNotQuarantined, assertWriteDeltaScopeDurable, foldDelta, isValidDurableScope, parseRuleText, readRowBehavior, screenRuleSyncState, PERMISSION_RULE_WRITER, RULE_ADD_ORIGINS, RULE_APPROVAL_KINDS, RULE_APPROVAL_RECORD_SCHEMA, RULE_APPROVAL_STATES, RULE_BEHAVIORS, RULE_SYNC_DROP_CODES, EDITED_RULE_BREADTH_WARNING_CODES, PERSISTED_RULE_MATCHES, UNCOVERED_SEGMENT_REASONS, EMPTY_DURABLE_RULE_PARTITION, } from "@sema-agent/core";
4
4
  import { dialectProtocolJsonEncoder, sqlDbNowMs, SQL_DB_NOW_MS } from "./sql-driver.js";
5
5
  import { isMissingColumnError } from "./sql-errors.js";
6
- import { canonicalJson } from "../canonical-json.js";
7
- import { EDITED_RULE_BREADTH_WARNING_CODES, PERSISTED_RULE_MATCHES, isPersistedRuleTool, RULE_APPROVAL_RECORD_SCHEMA, UNCOVERED_SEGMENT_REASONS } from "../permission-rule-vocab.js";
6
+ import { canonicalJsonStringify } from "@sema-agent/core";
7
+ import { isPersistedRuleTool } from "../permission-rule-vocab.js";
8
8
  import { ensureIndexes } from "./ensure-index.js";
9
9
  import { createZodColumnReader, decodeJsonColumn, foldJsonColumn, jsonColumnText, readJsonObjectColumn, SqlJsonColumnError } from "./sql-json-column.js";
10
10
  import { redactSecrets } from "../trace/redact.js";
@@ -25,7 +25,7 @@ export const DurableRuleScopeSchema = z.union([
25
25
  ]);
26
26
  const RuleDotSchema = z.object({ actor: z.string().min(1), counter: z.number().int().nonnegative().safe() }).strict();
27
27
  const RuleAddSchema = z
28
- .object({ dot: RuleDotSchema, origin: z.enum(["user", "imported-cc", "starter"]), createdAt: z.string() })
28
+ .object({ dot: RuleDotSchema, origin: z.enum(RULE_ADD_ORIGINS), createdAt: z.string() })
29
29
  .strict();
30
30
  const PersistedRuleRowSchema = z
31
31
  .object({
@@ -336,7 +336,7 @@ export function buildRuleOwnerKey(owner) {
336
336
  return createHash("sha256").update(material, "utf8").digest("hex");
337
337
  }
338
338
  export function buildRuleBucketChecksum(body) {
339
- return `sha256:${createHash("sha256").update(canonicalJson(body), "utf8").digest("hex")}`;
339
+ return `sha256:${createHash("sha256").update(canonicalJsonStringify(body), "utf8").digest("hex")}`;
340
340
  }
341
341
  const NEXT_DOT_MAX_ATTEMPTS = 8;
342
342
  const EMPTY_BUCKET = { actor: "", counter: 0, rev: 0, rules: [], tombstones: [], quarantined: [] };
@@ -586,8 +586,8 @@ export class SqlRuleApprovalRecordStore {
586
586
  const offers = decodeJsonColumn(row.offers_json, readOffersJson, `${at}.offers_json`);
587
587
  const dotsRaw = row.redeemed_dots_json === null || row.redeemed_dots_json === undefined ? undefined : decodeJsonColumn(row.redeemed_dots_json, readRedeemedDotsJson, `${at}.redeemed_dots_json`);
588
588
  const edited = row.edited_json === null || row.edited_json === undefined ? undefined : decodeJsonColumn(row.edited_json, readEditedJson, `${at}.edited_json`);
589
- const kind = z.enum(["card", "import", "starter"]).safeParse(row.kind);
590
- const state = z.enum(["pending", "approved", "redeemed"]).safeParse(row.state);
589
+ const kind = z.enum(RULE_APPROVAL_KINDS).safeParse(row.kind);
590
+ const state = z.enum(RULE_APPROVAL_STATES).safeParse(row.state);
591
591
  if (!kind.success || !state.success) {
592
592
  throw new Error(`${PERMISSION_RULE_APPROVAL_TABLE} row ${id} carries an unreadable kind/state (${String(row.kind)}/${String(row.state)})`);
593
593
  }
@@ -662,7 +662,7 @@ export class SqlRuleApprovalRecordStore {
662
662
  }
663
663
  export const RULE_IMPORT_TICKET_GC_GRACE_MS = 10 * 60_000;
664
664
  export function buildRulePayloadHash(candidates) {
665
- return createHash("sha256").update(canonicalJson(candidates), "utf8").digest("hex");
665
+ return createHash("sha256").update(canonicalJsonStringify(candidates), "utf8").digest("hex");
666
666
  }
667
667
  export class SqlRuleImportTicketStore {
668
668
  db;
@@ -149,7 +149,7 @@ export declare class PgSessionStore implements SessionStore {
149
149
  recordInstructionSource(sessionId: string, expected: InstructionSourceBaseline, source: string | null): Promise<void>;
150
150
  /** @see TiDBSessionStore.fork — copy the WHOLE durable history (full session_event log, compaction floor bypassed,
151
151
  * NOT wake's bounded tail) to a NEW uuidv7 session, re-seq dense from 0, ids/parents verbatim, owner=caller. */
152
- fork(sourceId: string, owner: string | null): Promise<string | null>;
152
+ fork(sourceId: string, owner?: string | null): Promise<string | null>;
153
153
  /** @see TiDBSessionStore.exportEntries — 2c session-sync EXPORT: the FULL durable session_event log (compaction floor
154
154
  * bypassed, NOT wake's tail) as `SessionTreeEntry[]`, oldest-first, verbatim. null if the source is unknown. */
155
155
  exportEntries(sessionId: string): Promise<SessionTreeEntry[] | null>;
@@ -1,5 +1,5 @@
1
1
  import { emitLeafAdvance } from "../session-leaf-bus.js";
2
- import { BaseSessionStorage, StoredSession, SessionError, uuidv7, validateEntriesForImport, leafIdAfterEntry, } from "@sema-agent/core";
2
+ import { BaseSessionStorage, StoredSession, SessionError, uuidv7, validateEntriesForImport, leafIdAfterEntry, admitSessionPlacement, } from "@sema-agent/core";
3
3
  import { SESSION_TITLE_MAX_CHARS } from "../session-row.js";
4
4
  import { escapeLike } from "./sql-escape.js";
5
5
  import { decodeSessionEventPayload } from "./session-event-payload.js";
@@ -7,16 +7,17 @@ import { scanEpochAnchor } from "./session-epoch-anchor.js";
7
7
  import { jsonColumnText } from "./sql-json-column.js";
8
8
  import { pgSafeJsonStringify } from "./pg-safe-json.js";
9
9
  import { toIso } from "./sql-row-helpers.js";
10
- import { assertPlacementAdmission, LIST_PLACED_BATCH, PLACEMENT_SELECT_COLUMNS, placedRowFromRow, placementFromRow, placementInsertValues, SQL_SESSION_PLACEMENTS, } from "./session-placement.js";
10
+ import { placementCapabilityOf, LIST_PLACED_BATCH, PLACEMENT_SELECT_COLUMNS, placedRowFromRow, placementFromRow, placementInsertValues, SQL_SESSION_PLACEMENTS, } from "./session-placement.js";
11
11
  import { contentForkRelation, fastForwardSharedContentDiverged } from "../session-sync-content.js";
12
12
  import { CLAIM_OWNERLESS_PLACED_SQL, claimOwnerlessParams, createPlacedSessionRow } from "../delegated-session-owner.js";
13
13
  import { createLogger } from "../observability/logger.js";
14
- import { classifySyncRelationshipByIds, SyncConflictError, stagingIdFor, STAGING_ID_MARKER, STAGING_GC_GRACE_MS, } from "../session-sync-kernel.js";
14
+ import { classifySyncRelationshipByIds, SyncConflictError, StagedCommitOwnerChangedError, stagingIdFor, STAGING_ID_MARKER, STAGING_GC_GRACE_MS, } from "../session-sync-kernel.js";
15
15
  import { isPgUniqueViolation } from "./sql-errors.js";
16
16
  import { BASELINE_IO_DEADLINE_MS, decodeInstructionSource, encodeInstructionSource } from "../instruction-source-notice.js";
17
17
  import { MANAGED_RETENTION } from "./retention-store-sql.js";
18
18
  import { SESSION_META_INCARNATION_COLUMN, carriedIncarnation, incarnationFromCell, mintSessionIncarnation, sessionFaceFromRow } from "../session-incarnation.js";
19
19
  import { ensureIndexes } from "./ensure-index.js";
20
+ import { assertFirstOwnerWritable, forkOwnerOf } from "./session-ownership.js";
20
21
  const isDupKey = isPgUniqueViolation;
21
22
  function baselineQuery(text, values) {
22
23
  return { text, values, query_timeout: BASELINE_IO_DEADLINE_MS };
@@ -193,7 +194,7 @@ export class PgSessionStore {
193
194
  const inflight = this.pending.get(sessionId);
194
195
  if (inflight) {
195
196
  const acquired = await inflight;
196
- assertPlacementAdmission(sessionId, await this.placementOf(sessionId), opts);
197
+ admitSessionPlacement(sessionId, await this.placementOf(sessionId), opts ?? {}, placementCapabilityOf(this));
197
198
  return acquired;
198
199
  }
199
200
  const p = this.load(sessionId, false, opts).finally(() => this.pending.delete(sessionId));
@@ -203,7 +204,7 @@ export class PgSessionStore {
203
204
  async load(sessionId, requireExisting = false, opts) {
204
205
  let storage = await PgSessionStorage.wake(this.pool, sessionId);
205
206
  if (storage) {
206
- assertPlacementAdmission(sessionId, (await storage.getMetadata()).placement, opts);
207
+ admitSessionPlacement(sessionId, (await storage.getMetadata()).placement, opts ?? {}, placementCapabilityOf(this));
207
208
  return { session: new StoredSession(storage), sessionId };
208
209
  }
209
210
  if (requireExisting)
@@ -213,10 +214,15 @@ export class PgSessionStore {
213
214
  if (!storage) {
214
215
  throw new SessionError("storage", `session ${sessionId} vanished between register and wake (a concurrent purge won the race) — retry`);
215
216
  }
216
- assertPlacementAdmission(sessionId, (await storage.getMetadata()).placement, opts);
217
+ admitSessionPlacement(sessionId, (await storage.getMetadata()).placement, opts ?? {}, placementCapabilityOf(this));
217
218
  return { session: new StoredSession(storage), sessionId };
218
219
  }
219
220
  async register(sessionId, owner) {
221
+ if (typeof owner !== "string" && owner !== null) {
222
+ if ((await this.ownerOf(sessionId)) !== undefined)
223
+ return;
224
+ assertFirstOwnerWritable(sessionId, owner);
225
+ }
220
226
  await this.insertMeta(sessionId, owner, undefined);
221
227
  }
222
228
  async createOnAcquire(sessionId, placement) {
@@ -429,10 +435,11 @@ export class PgSessionStore {
429
435
  }
430
436
  }
431
437
  async fork(sourceId, owner) {
432
- const metaRes = await this.pool.query("SELECT leaf_id FROM session_meta WHERE session_id = $1", [sourceId]);
438
+ const metaRes = await this.pool.query("SELECT leaf_id, owner FROM session_meta WHERE session_id = $1", [sourceId]);
433
439
  if (!metaRes.rows[0])
434
440
  return null;
435
441
  const leafId = metaRes.rows[0].leaf_id ?? null;
442
+ const forkOwner = forkOwnerOf(owner, metaRes.rows[0].owner ?? null) ?? null;
436
443
  const eventRes = await this.pool.query(`SELECT entry_id, parent_id, type, ${jsonColumnText("pg", "payload")}, ts FROM session_event WHERE session_id = $1 ORDER BY seq ASC`, [sourceId]);
437
444
  const rows = eventRes.rows;
438
445
  const newId = uuidv7();
@@ -445,7 +452,7 @@ export class PgSessionStore {
445
452
  const payload = String(r.payload);
446
453
  await conn.query("INSERT INTO session_event (session_id, seq, entry_id, parent_id, type, payload, ts) VALUES ($1,$2,$3,$4,$5,$6::jsonb,$7)", [newId, seq, r.entry_id, r.parent_id ?? null, r.type, payload, r.ts]);
447
454
  }
448
- await conn.query("INSERT INTO session_meta (session_id, owner, created_at, updated_at, leaf_id, leaf_seq, incarnation) VALUES ($1,$2,$3,$4,$5,$6,$7)", [newId, owner, now, now, leafId, rows.length, mintSessionIncarnation()]);
455
+ await conn.query("INSERT INTO session_meta (session_id, owner, created_at, updated_at, leaf_id, leaf_seq, incarnation) VALUES ($1,$2,$3,$4,$5,$6,$7)", [newId, forkOwner, now, now, leafId, rows.length, mintSessionIncarnation()]);
449
456
  await conn.query("COMMIT");
450
457
  return newId;
451
458
  }
@@ -642,6 +649,23 @@ class PgStagingHandle {
642
649
  const conn = await this.pool.connect();
643
650
  try {
644
651
  await conn.query("BEGIN");
652
+ const now = new Date();
653
+ const minted = mintSessionIncarnation();
654
+ if (opts.expectedOwner !== undefined) {
655
+ const cas = await conn.query("UPDATE session_meta SET leaf_seq = leaf_seq + 1 WHERE session_id = $1 AND owner IS NOT DISTINCT FROM $2", [this.realSessionId, opts.expectedOwner]);
656
+ if (cas.rowCount !== 1) {
657
+ await conn.query("ROLLBACK");
658
+ throw new StagedCommitOwnerChangedError(this.realSessionId);
659
+ }
660
+ }
661
+ else {
662
+ await conn.query("INSERT INTO session_meta (session_id, owner, created_at, updated_at, leaf_id, leaf_seq, incarnation) VALUES ($1,$2,$3,$4,NULL,0,$5) ON CONFLICT (session_id) DO NOTHING", [this.realSessionId, owner, now, now, minted]);
663
+ const claimed = await conn.query("SELECT incarnation FROM session_meta WHERE session_id = $1 FOR UPDATE", [this.realSessionId]);
664
+ if (String(claimed.rows[0]?.incarnation) !== minted) {
665
+ await conn.query("ROLLBACK");
666
+ throw new StagedCommitOwnerChangedError(this.realSessionId);
667
+ }
668
+ }
645
669
  const staged = await conn.query("SELECT entry_id FROM session_event WHERE session_id = $1 ORDER BY seq ASC FOR UPDATE", [this.stagingId]);
646
670
  const dst = await conn.query("SELECT entry_id FROM session_event WHERE session_id = $1 ORDER BY seq ASC FOR UPDATE", [this.realSessionId]);
647
671
  const dstMeta = await conn.query("SELECT placement_kind FROM session_meta WHERE session_id = $1", [this.realSessionId]);
@@ -650,15 +674,15 @@ class PgStagingHandle {
650
674
  throw new SessionError("placement_refused", `session ${this.realSessionId} belongs to the ${dstMeta.rows[0].placement_kind} placement partition — a session-sync staged commit cannot overwrite a delegation transcript`);
651
675
  }
652
676
  const srcIds = staged.rows.map((r) => String(r.entry_id));
653
- const dstIds = dstMeta.rows.length > 0 ? dst.rows.map((r) => String(r.entry_id)) : null;
677
+ const dstIds = opts.expectedOwner !== undefined ? dst.rows.map((r) => String(r.entry_id)) : null;
654
678
  const rel = classifySyncRelationshipByIds(srcIds, dstIds);
655
- if ((rel.relation === "fork" || rel.relation === "stale") && opts?.resolution !== "overwrite-dst") {
679
+ if ((rel.relation === "fork" || rel.relation === "stale") && opts.resolution !== "overwrite-dst") {
656
680
  await conn.query("ROLLBACK");
657
681
  throw new SyncConflictError(this.realSessionId, rel);
658
682
  }
659
- if (rel.relation === "fast_forward" && opts?.resolution !== "overwrite-dst" && dstIds !== null && dstIds.length > 0) {
660
- const dstFull = await conn.query(`SELECT seq, ${jsonColumnText("pg", "payload")} FROM session_event WHERE session_id = $1 ORDER BY seq ASC`, [this.realSessionId]);
661
- const stagedFull = await conn.query(`SELECT entry_id, ${jsonColumnText("pg", "payload")} FROM session_event WHERE session_id = $1 ORDER BY seq ASC`, [this.stagingId]);
683
+ if (rel.relation === "fast_forward" && opts.resolution !== "overwrite-dst" && dstIds !== null && dstIds.length > 0) {
684
+ const dstFull = await conn.query(`SELECT seq, ${jsonColumnText("pg", "payload")} FROM session_event WHERE session_id = $1 ORDER BY seq ASC FOR UPDATE`, [this.realSessionId]);
685
+ const stagedFull = await conn.query(`SELECT entry_id, ${jsonColumnText("pg", "payload")} FROM session_event WHERE session_id = $1 ORDER BY seq ASC FOR UPDATE`, [this.stagingId]);
662
686
  const dstSet = new Set(dstIds);
663
687
  const dstEntries = dstFull.rows.map((r) => decodeSessionEventPayload(r.payload, this.realSessionId, r.seq));
664
688
  const stagedShared = stagedFull.rows.filter((r) => dstSet.has(String(r.entry_id))).map((r) => decodeSessionEventPayload(r.payload, this.stagingId, r.entry_id));
@@ -670,13 +694,11 @@ class PgStagingHandle {
670
694
  }
671
695
  await conn.query("DELETE FROM session_event WHERE session_id = $1", [this.realSessionId]);
672
696
  await conn.query("UPDATE session_event SET session_id = $1 WHERE session_id = $2", [this.realSessionId, this.stagingId]);
673
- const lastRes = await conn.query(`SELECT ${jsonColumnText("pg", "payload")} FROM session_event WHERE session_id = $1 ORDER BY seq DESC LIMIT 1`, [this.realSessionId]);
697
+ const lastRes = await conn.query(`SELECT ${jsonColumnText("pg", "payload")} FROM session_event WHERE session_id = $1 ORDER BY seq DESC LIMIT 1 FOR UPDATE`, [this.realSessionId]);
674
698
  let leafId = null;
675
699
  if (lastRes.rows[0])
676
700
  leafId = leafIdAfterEntry(decodeSessionEventPayload(lastRes.rows[0].payload, this.realSessionId, "last staged row"));
677
- const now = new Date();
678
- await conn.query("INSERT INTO session_meta (session_id, owner, created_at, updated_at, leaf_id, leaf_seq, incarnation) VALUES ($1,$2,$3,$4,$5,$6,$7) " +
679
- "ON CONFLICT (session_id) DO UPDATE SET owner = EXCLUDED.owner, updated_at = EXCLUDED.updated_at, leaf_id = EXCLUDED.leaf_id, leaf_seq = EXCLUDED.leaf_seq", [this.realSessionId, owner, now, now, leafId, srcIds.length, mintSessionIncarnation()]);
701
+ await conn.query("UPDATE session_meta SET owner = $1, updated_at = $2, leaf_id = $3, leaf_seq = $4 WHERE session_id = $5", [owner, now, leafId, srcIds.length, this.realSessionId]);
680
702
  const afterMeta = await conn.query("SELECT placement_kind, owner, incarnation FROM session_meta WHERE session_id = $1", [this.realSessionId]);
681
703
  if (afterMeta.rows[0]?.placement_kind != null) {
682
704
  await conn.query("ROLLBACK");
@@ -17,10 +17,9 @@ type ExecOpts = Parameters<ExecutionEnv["exec"]>[1];
17
17
  * 🔴 三条不许折错的线:
18
18
  * ① `outcome_unknown` **原样过境**——core 把它排除在 `RETRYABLE_REMOTE_ERROR_CODES` 之外正是为了
19
19
  * 「可能已经执行过」这一格;折成 `timeout`/`transport_lost` 会让上层按可重试处理,重驱已提交副作用。
20
- * ② `target_unavailable`(never-started 的诚实词)在 `FileErrorCode` 上**没有对位词** —— 落
21
- * `transport_lost`:它的契约是「连接故事已知、重连后可重试」,而 never-started 的重试确实是安全的。
22
- * 诚实残余:file 面因此**分不出**「从未开始」与「传输中断」,两者都落在同一个可重试桶里(方向对、
23
- * 粒度粗)。要分得开需要 core 在 `FileErrorCode` 上补 never-started 词 —— 已作 O12 的后续请托登记。
20
+ * ② `target_unavailable`(never-started 的诚实词)**原样过境** —— core 7.54.0([ref] r3)给 `FileErrorCode` 补了同名词
21
+ * (目标在 commit 前拒、从未开始、零效果,目标回来后重试安全;写车道据它撤回首触记录、不计 edited)。修前文件面无对位词,
22
+ * 落 `transport_lost`(「传输中断」= 重试白名单读作「可能已执行」),file 面分不出「从未开始」与「传输中断」—— 那条 O12 后续请托随本版兑现。
24
23
  * ③ exec 专属词(`shell_unavailable`/`spawn_error`/`callback_error`/`suspended`/`auth_failed`)出现在
25
24
  * file 面上是**设备端协议违约**(它把一个 exec 结局贴到一次文件操作上)⇒ 落 `unknown`,不猜。
26
25
  */
@@ -38,9 +37,8 @@ type RejectCode = DeviceRejectCode;
38
37
  /**
39
38
  * 汇聚端的 **typed 拒**(投递门**之前**,§5.3 投递边界:设备从未可能见过这条指令)→ core 错误码。
40
39
  *
41
- * 🔴 全部落在**可安全重试**的那一侧,这是投递边界给的硬事实,不是乐观估计。exec 面用 O12 的
42
- * `target_unavailable`(「目标在 commit 前拒了,命令从未启动」的诚实词);file 面无对位词,同上落
43
- * `transport_lost`。
40
+ * 🔴 全部落在**可安全重试**的那一侧,这是投递边界给的硬事实,不是乐观估计。exec 面与 file 面用同一个
41
+ * `target_unavailable`(「目标在 commit 前拒了,指令从未启动」的诚实词;file 面自 core 7.54.0 起有它,修前落 `transport_lost`)。
44
42
  * 例外 `identity_mismatch` / `revoked` / `not_found`:那是**永久**拒(换设备/吊销/不是你的),重试
45
43
  * 只会同样失败 —— exec 面落 `auth_failed`(NEVER retry 的那一格),file 面无对位词落 `permission_denied`
46
44
  * (「这条路你走不通,而且不是暂时的」是它最近的语义)。
@@ -324,6 +322,15 @@ export declare class RemoteDeviceExecutionEnv implements RemoteExecutionEnv {
324
322
  * ③ 「这个 placement 根本没绑设备」是**装配/准入错误**,不是一次运行期业务拒绝 —— 响亮抛,让 spawn
325
323
  * 失败(core 对 `isolation:"worktree"` 的同款姿势:宁可 throw,绝不静默给一个不对的 env)。
326
324
  * 对比:「绑了但现在不在线」是运行期事实 ⇒ 构造成功,`connect()` 回 typed `device.offline`。
325
+ *
326
+ * 🔴 **工厂的每一只拒都是 typed 拒**(core 7.54.0 [ref] r3 契约:工厂抛的 `.code` 原样成失败终局的码):经 {@link deviceFactoryRefusal} 一处铸,码取
327
+ * `DEVICE_ERROR_CODES` 闭集(装配错误取 `config.placement_root_invalid`),句子里的 `[码]` 与 `.code` 同值。修前抛裸 `Error`,码只在句子里。
328
+ *
329
+ * 🔴 **续跑腿的投递围栏**(core 7.54.0 [ref] r3 / #2061,`ExecutionEnvFactoryContext.resumeWorkspace`):行记下了设备(`resumeWorkspace.deviceId`)⇒
330
+ * 本工厂**只**在那台设备上铸 —— placement 根此刻绑的不是它(换绑过)⇒ 抛 typed `device.identity_mismatch`(换绑后赎回的同一个词,零新码),
331
+ * 句子点名两台设备;不连另一台、不改道(新设备没有那个工作区)。读当前绑定只为判出这一拒与取属主,铸的设备恒是行记下的那一台。
332
+ * 「绑的就是它但现在不在线」与新跑同形:构造成功、`connect()` 回 typed `device.offline`(从不连别的设备)。
333
+ * 行没记设备(非设备车道 / 早于该字段的行)⇒ 不声明要兑现的设备,按普通路由走。句柄(`workspaceHandle()`)恒报连接真到达的设备,不抄 `resumeWorkspace`。
327
334
  */
328
335
  export declare function deviceExecutionEnvFactory(deps: DeviceEnvFactoryDeps): ExecutionEnvFactory;
329
336
  /**
@@ -22,7 +22,7 @@ export const DEVICE_OUTCOME_TO_FILE_ERROR = {
22
22
  shell_unavailable: "unknown",
23
23
  spawn_error: "unknown",
24
24
  suspended: "unknown",
25
- target_unavailable: "transport_lost",
25
+ target_unavailable: "target_unavailable",
26
26
  timeout: "timeout",
27
27
  transport_lost: "transport_lost",
28
28
  unknown: "unknown",
@@ -66,20 +66,20 @@ export const DEVICE_REJECT_TO_EXEC_ERROR = {
66
66
  "draining": "target_unavailable",
67
67
  };
68
68
  export const DEVICE_REJECT_TO_FILE_ERROR = {
69
- "device.not_bound": "transport_lost",
70
- "device.offline": "transport_lost",
71
- "device.busy": "transport_lost",
72
- "device.attached_elsewhere": "transport_lost",
73
- "device.claim_conflict": "transport_lost",
69
+ "device.not_bound": "target_unavailable",
70
+ "device.offline": "target_unavailable",
71
+ "device.busy": "target_unavailable",
72
+ "device.attached_elsewhere": "target_unavailable",
73
+ "device.claim_conflict": "target_unavailable",
74
74
  "device.provider_mismatch": "permission_denied",
75
75
  "device.identity_mismatch": "permission_denied",
76
76
  "device.revoked": "permission_denied",
77
77
  "device.enrollment_invalid": "permission_denied",
78
78
  "not_found.device": "permission_denied",
79
- "device.rebind_rev_conflict": "transport_lost",
80
- "device.rebind_active_run": "transport_lost",
81
- "limit.rate_exceeded": "transport_lost",
82
- "draining": "transport_lost",
79
+ "device.rebind_rev_conflict": "target_unavailable",
80
+ "device.rebind_active_run": "target_unavailable",
81
+ "limit.rate_exceeded": "target_unavailable",
82
+ "draining": "target_unavailable",
83
83
  };
84
84
  const DEVICE_REJECT_TO_REMOTE_ERROR = {
85
85
  "device.not_bound": "connect_failed",
@@ -232,7 +232,7 @@ export class RemoteDeviceExecutionEnv {
232
232
  failure: {
233
233
  remote: "connect_failed",
234
234
  exec: "target_unavailable",
235
- file: "transport_lost",
235
+ file: "target_unavailable",
236
236
  message: `[device.admission_unavailable] the device admission read failed, so this session's device could not be confirmed (fail-closed): ${err instanceof Error ? err.message : String(err)}`,
237
237
  },
238
238
  };
@@ -780,25 +780,33 @@ const OUTCOME_TO_REMOTE_ERROR = {
780
780
  transport_lost: "transport_lost",
781
781
  unknown: "unknown",
782
782
  };
783
+ function deviceFactoryRefusal(code, message) {
784
+ return Object.assign(new Error(message), { code });
785
+ }
783
786
  export function deviceExecutionEnvFactory(deps) {
784
787
  const execTimeoutMs = deps.execTimeoutMs ?? 1_800_000;
785
788
  return async (ctx) => {
786
789
  const rootSessionId = ctx.placementRootSessionId;
787
790
  if (typeof rootSessionId !== "string" || rootSessionId.length === 0) {
788
- throw new Error("device lane: ExecutionEnvFactoryContext.placementRootSessionId is empty [config.placement_root_invalid] — " +
791
+ throw deviceFactoryRefusal("config.placement_root_invalid", "device lane: ExecutionEnvFactoryContext.placementRootSessionId is empty [config.placement_root_invalid] — " +
789
792
  "the device placement is keyed on the run tree's placement root; defaulting to the child sessionId would read as a NEW placement (v2 §4.3.2 / core design/380 O1①)");
790
793
  }
791
794
  const binding = await deps.store.getSessionBinding(rootSessionId);
792
795
  if (!binding) {
793
- throw new Error(`device lane: no device binding for placement root ${rootSessionId} [${DEVICE_ERROR_CODES.NOT_BOUND}] — ` +
796
+ throw deviceFactoryRefusal(DEVICE_ERROR_CODES.NOT_BOUND, `device lane: no device binding for placement root ${rootSessionId} [${DEVICE_ERROR_CODES.NOT_BOUND}] — ` +
794
797
  "the submit-time admission chain must bind the root session to a device before a run reaches the factory (v2 §4.3.2)");
795
798
  }
799
+ const recordedDevice = ctx.resumeWorkspace?.deviceId;
800
+ if (recordedDevice !== undefined && recordedDevice !== binding.deviceId) {
801
+ throw deviceFactoryRefusal(DEVICE_ERROR_CODES.IDENTITY_MISMATCH, `device lane: this resume rebuilds the workspace on device ${recordedDevice}, but placement root ${rootSessionId} is now bound to ${binding.deviceId} [${DEVICE_ERROR_CODES.IDENTITY_MISMATCH}] — ` +
802
+ "a parked call does not follow a rebind (the new device has no copy of that workspace); finish or cancel the parked task, then start it again on the device now bound");
803
+ }
796
804
  const view = await deps.store.readDeviceForAdmission(binding.deviceId, binding.owner);
797
805
  if (!view) {
798
- throw new Error(`device lane: the bound device is gone or not owned by the binding's principal [${DEVICE_ERROR_CODES.NOT_FOUND}]`);
806
+ throw deviceFactoryRefusal(DEVICE_ERROR_CODES.NOT_FOUND, `device lane: the bound device is gone or not owned by the binding's principal [${DEVICE_ERROR_CODES.NOT_FOUND}]`);
799
807
  }
800
808
  if (view.row.status === "revoked") {
801
- throw new Error(`device lane: the bound device has been revoked [${DEVICE_ERROR_CODES.REVOKED}] — revocation is terminal; re-enrolling mints a NEW deviceId (v2 §8-R4)`);
809
+ throw deviceFactoryRefusal(DEVICE_ERROR_CODES.REVOKED, `device lane: the bound device has been revoked [${DEVICE_ERROR_CODES.REVOKED}] — revocation is terminal; re-enrolling mints a NEW deviceId (v2 §8-R4)`);
802
810
  }
803
811
  assertPosixDevicePlatform(view.row.platformOs, view.row.deviceId);
804
812
  return new RemoteDeviceExecutionEnv({
@@ -0,0 +1,11 @@
1
+ /**
2
+ * fork 拷贝的属主 —— core `SessionStore.fork` 契约的唯一读法:
3
+ * 显式串 ⇒ 该串;无参 / 显式 `null` ⇒ 源会话记下的属主(`undefined` = 源会话没记,拷贝也不记)。
4
+ */
5
+ export declare function forkOwnerOf(requested: string | null | undefined, sourceOwner: string | null | undefined): string | null | undefined;
6
+ /**
7
+ * `register` 首写的 owner 守卫 —— core `SessionStore.register` 契约的唯一读法(三只店同调):首写的 owner 既不是串也不是 `null` ⇒ 响亮拒(不留行);
8
+ * 「首写」由调用方判(已记下属主的会话上的调用照旧静默不改,不经这里)。
9
+ */
10
+ export declare function assertFirstOwnerWritable(sessionId: string, owner: unknown): asserts owner is string | null;
11
+ //# sourceMappingURL=session-ownership.d.ts.map
@@ -0,0 +1,11 @@
1
+ import { SessionError } from "@sema-agent/core";
2
+ export function forkOwnerOf(requested, sourceOwner) {
3
+ return typeof requested === "string" ? requested : sourceOwner;
4
+ }
5
+ export function assertFirstOwnerWritable(sessionId, owner) {
6
+ if (typeof owner === "string" || owner === null)
7
+ return;
8
+ throw new SessionError("storage", `register(${sessionId}): a first ownership write must carry a principal (string) or null (anonymous), got ${owner === undefined ? "undefined" : typeof owner} — ` +
9
+ "a stored value that ownerOf reads back as 'no row' would leave the session claimable forever and ownable never; refusing, nothing written");
10
+ }
11
+ //# sourceMappingURL=session-ownership.js.map