@sema-agent/server 7.118.0 → 7.120.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 (139) hide show
  1. package/MIGRATION.md +139 -0
  2. package/README.md +1 -1
  3. package/README.zh-CN.md +1 -1
  4. package/USAGE.md +26 -13
  5. package/deploy/sema-up/README.md +2 -0
  6. package/deploy/sema-up/chart/templates/minio.yaml +2 -1
  7. package/deploy/sema-up/chart/templates/server-v4pro.yaml +1 -0
  8. package/deploy/sema-up/chart/templates/server.yaml +1 -0
  9. package/deploy/sema-up/chart/values.yaml +7 -1
  10. package/deploy/sema-up/cluster-up.sh +20 -8
  11. package/deploy/sema-up/kube-up.sh +29 -16
  12. package/deploy/sema-up/lib/buckets.sh +21 -0
  13. package/deploy/sema-up/lib/health.sh +33 -0
  14. package/deploy/sema-up/sema-up.sh +14 -9
  15. package/deploy/sema-up/smoke.sh +16 -6
  16. package/deploy/sema-up/stack/compose.yaml +1 -1
  17. package/deploy/sema-up/stack/sema-stack-cluster.yaml +4 -1
  18. package/dist/approval-ask-audit-store.js +2 -7
  19. package/dist/approval-card.d.ts +5 -5
  20. package/dist/approval.d.ts +51 -5
  21. package/dist/approval.js +76 -13
  22. package/dist/audit.d.ts +12 -1
  23. package/dist/audit.js +62 -2
  24. package/dist/auto-mode-face.d.ts +2 -3
  25. package/dist/bake-runner/main.js +1 -6
  26. package/dist/bake-runner/protocol.d.ts +18 -0
  27. package/dist/bake-runner/protocol.js +22 -2
  28. package/dist/bake-runner/runner.d.ts +12 -8
  29. package/dist/bake-runner/runner.js +5 -9
  30. package/dist/boot/config-center.js +6 -6
  31. package/dist/boot/coordinators.js +4 -1
  32. package/dist/boot/device-lane.d.ts +3 -3
  33. package/dist/boot/device-lane.js +1 -2
  34. package/dist/boot/execution-env.js +3 -2
  35. package/dist/boot/fatal-exit.d.ts +7 -1
  36. package/dist/boot/fatal-exit.js +2 -1
  37. package/dist/boot/leader.d.ts +4 -4
  38. package/dist/boot/leader.js +1 -2
  39. package/dist/boot/parked-revive-gate.d.ts +5 -1
  40. package/dist/boot/parked-revive-gate.js +8 -2
  41. package/dist/boot/resolve-spec.js +7 -4
  42. package/dist/boot/runner-deps.d.ts +4 -3
  43. package/dist/boot/runner-deps.js +7 -3
  44. package/dist/boot/stage-01-config.js +2 -0
  45. package/dist/boot/stage-07-capability-layer.js +5 -1
  46. package/dist/boot/stage-10-http-server.js +3 -2
  47. package/dist/boot/stores.js +12 -7
  48. package/dist/capabilities/builtin-tools.d.ts +31 -8
  49. package/dist/capabilities/builtin-tools.js +22 -1
  50. package/dist/capabilities/center-plugins.d.ts +2 -0
  51. package/dist/capabilities/center-plugins.js +24 -6
  52. package/dist/capabilities/code-review-council.js +8 -11
  53. package/dist/capabilities/scenarios.d.ts +42 -13
  54. package/dist/capabilities/scenarios.js +36 -9
  55. package/dist/capabilities/send-user-file-tool.d.ts +6 -3
  56. package/dist/capabilities/send-user-file-tool.js +1 -1
  57. package/dist/config-catalog.js +5 -5
  58. package/dist/config-lkg.d.ts +33 -5
  59. package/dist/config-lkg.js +55 -11
  60. package/dist/config-types.d.ts +8 -1
  61. package/dist/config.js +11 -11
  62. package/dist/deployment-governance.d.ts +8 -3
  63. package/dist/deployment-governance.js +7 -4
  64. package/dist/device-store.d.ts +7 -0
  65. package/dist/device-store.js +4 -0
  66. package/dist/device-ws-hub.js +17 -9
  67. package/dist/device-ws-protocol.d.ts +15 -0
  68. package/dist/host-decision.d.ts +52 -27
  69. package/dist/host-decision.js +14 -7
  70. package/dist/http/admission.js +6 -6
  71. package/dist/http/resume-legs.js +6 -1
  72. package/dist/http/routes/approvals-assistant.d.ts +25 -3
  73. package/dist/http/routes/approvals-assistant.js +9 -5
  74. package/dist/http/routes/capabilities.js +5 -3
  75. package/dist/http/routes/devices.js +3 -3
  76. package/dist/http/routes/memory-origin.d.ts +1 -1
  77. package/dist/http/routes/rules.js +13 -6
  78. package/dist/http/routes/runs.js +11 -5
  79. package/dist/http/routes/sessions.js +20 -16
  80. package/dist/http/wire-types.d.ts +12 -13
  81. package/dist/images/manifest.d.ts +7 -1
  82. package/dist/images/manifest.js +18 -16
  83. package/dist/list-entry-display.d.ts +14 -0
  84. package/dist/list-entry-display.js +14 -0
  85. package/dist/memory-store-refusal.d.ts +7 -12
  86. package/dist/memory-store-refusal.js +4 -5
  87. package/dist/namespaced-rule-name.d.ts +4 -3
  88. package/dist/namespaced-rule-name.js +2 -6
  89. package/dist/observability/fail-open.d.ts +9 -1
  90. package/dist/observability/fail-open.js +9 -1
  91. package/dist/operator-ask.d.ts +7 -6
  92. package/dist/plugins/approval-exemption-store.d.ts +4 -0
  93. package/dist/plugins/approval-exemption-store.js +1 -0
  94. package/dist/plugins/blob-backend.d.ts +13 -0
  95. package/dist/plugins/blob-backend.js +26 -0
  96. package/dist/plugins/local-session-store.d.ts +78 -47
  97. package/dist/plugins/local-session-store.js +152 -122
  98. package/dist/plugins/one-time-migrations.d.ts +14 -0
  99. package/dist/plugins/one-time-migrations.js +90 -3
  100. package/dist/plugins/permission-rule-store-file.js +2 -6
  101. package/dist/plugins/permission-rule-store-sql.d.ts +64 -6
  102. package/dist/plugins/permission-rule-store-sql.js +166 -59
  103. package/dist/plugins/remote-env-device.d.ts +12 -1
  104. package/dist/plugins/remote-env-device.js +13 -5
  105. package/dist/plugins/scheduler-support.d.ts +8 -4
  106. package/dist/plugins/scheduler-support.js +5 -5
  107. package/dist/plugins/snapshot-bucket-presence.d.ts +36 -0
  108. package/dist/plugins/snapshot-bucket-presence.js +40 -0
  109. package/dist/plugins/store-backend.d.ts +17 -6
  110. package/dist/plugins/store-backend.js +46 -16
  111. package/dist/plugins/store-open-wait.d.ts +33 -0
  112. package/dist/plugins/store-open-wait.js +73 -0
  113. package/dist/rule-entry-list.d.ts +0 -1
  114. package/dist/rule-entry-list.js +5 -10
  115. package/dist/rules-consent.d.ts +12 -3
  116. package/dist/rules-consent.js +5 -4
  117. package/dist/run-local.js +6 -3
  118. package/dist/security.d.ts +5 -3
  119. package/dist/security.js +4 -0
  120. package/dist/sema-config-dir.d.ts +19 -0
  121. package/dist/sema-config-dir.js +33 -0
  122. package/dist/session-policy-wire.js +10 -14
  123. package/dist/spec-fields.d.ts +7 -2
  124. package/dist/store-posture.d.ts +14 -6
  125. package/dist/store-posture.js +7 -4
  126. package/dist/task-settings.d.ts +30 -58
  127. package/dist/task-settings.js +6 -18
  128. package/dist/tool-approval.d.ts +97 -43
  129. package/dist/tool-approval.js +120 -31
  130. package/dist/trace/engine-notice-wire.d.ts +6 -4
  131. package/dist/trace/engine-notice-wire.js +16 -9
  132. package/dist/trace/redact.d.ts +6 -0
  133. package/dist/trace/redact.js +9 -9
  134. package/dist/trace/self-minted-wire.d.ts +15 -1
  135. package/dist/trace/self-minted-wire.js +2 -0
  136. package/dist/user-input-ledger.d.ts +19 -15
  137. package/dist/user-input-ledger.js +12 -20
  138. package/docs/DEVICE-EXECUTOR-PROTOCOL.md +56 -14
  139. package/package.json +2 -2
@@ -329,7 +329,7 @@ export const FAIL_OPEN_TAGS = {
329
329
  },
330
330
  "server.session-repo.corrupt-record-skipped": {
331
331
  cls: "F",
332
- note: "S-990(`plugins/store-backend.ts` 的 `FileSessionRepo` 座,local 车道的会话存档):一份会话存档里有记录读不出(整行毁 / 半截 JSON / 形不对,core 记成 `kind:\"record\"` 逐条披露)或存档文件 / 目录整体读不出(`kind:\"file\"`)⇒ core 的成文处置是跳过 + 披露,会话照常打开、列表少一行。最坏后果 = 模型读到的历史少了一截 / 列表静默少一份会话 —— 都是**可用性**损失,不开任何门;发起这一轮的人另有 wire 面披露(打开自己存档时的 `engine_notice` 码 `session.records_skipped`,与本 tag 无关、不动)⇒ F。**计数单位 = 一条被跳过的记录 = 一次命中**(`replay` 级是同一批记录的汇总,不计,S-616 同教训);**每命中一行探针**(`SEMA_FAILOPEN_PROBE`)带 `path` / `sessionId` / `line` / `form` / `reason` 逐条明细 —— 运维要看「哪份存档的哪一行坏了」读探针,不读本条 warn:本条是每进程每 tag 一次的告警(`detail` 是首条明细,不是全部)。修前:每命中一行 warn,一份 113 行坏记录的存档做一次列表 + 一次查看打出 342 行,存档没修好每读再发。",
332
+ note: "S-990(`plugins/local-session-store.ts` `createLocalSessionRepo` 的 `FileSessionRepo` 座,local 车道的会话存档;`LocalSessionStore` 与会话视图口 `audit.ts` 共用,S-879):一份会话存档里有记录读不出(整行毁 / 半截 JSON / 形不对,core 记成 `kind:\"record\"` 逐条披露)或存档文件 / 目录整体读不出(`kind:\"file\"`)⇒ core 的成文处置是跳过 + 披露,会话照常打开、列表少一行。最坏后果 = 模型读到的历史少了一截 / 列表静默少一份会话 —— 都是**可用性**损失,不开任何门;发起这一轮的人另有 wire 面披露(打开自己存档时的 `engine_notice` 码 `session.records_skipped`,与本 tag 无关、不动)⇒ F。**计数单位 = 一条被跳过的记录 = 一次命中**(`replay` 级是同一批记录的汇总,不计,S-616 同教训);**每命中一行探针**(`SEMA_FAILOPEN_PROBE`)带 `path` / `sessionId` / `line` / `form` / `reason` 逐条明细 —— 运维要看「哪份存档的哪一行坏了」读探针,不读本条 warn:本条是每进程每 tag 一次的告警(`detail` 是首条明细,不是全部)。修前:每命中一行 warn,一份 113 行坏记录的存档做一次列表 + 一次查看打出 342 行,存档没修好每读再发。",
333
333
  },
334
334
  "server.workflow-journal.corrupt-record-skipped": {
335
335
  cls: "F",
@@ -363,6 +363,14 @@ export const FAIL_OPEN_TAGS = {
363
363
  cls: "F",
364
364
  note: "S-925(`outbound-dispatcher.ts` 的 undici 诊断通道订阅者:`undici:request:create` / `:headers` / `:error`):一只订阅回调抛了。通道在发布方的调用栈里同步回调订阅者,抛出去在 Node 上是未捕获异常(整进程倒),所以每只回调整体 try、落本 tag。最坏后果 = 这一条请求的建连失败没被记下:少一行 `outbound_connect_failed`、诊断段 `outbound.lastConnectFailure` 不更新、那次模型调用不铸 `server.model_unreachable` 用户通告 —— 请求本身、它的错误、run 的终态一个字节不受影响(观察只读)。计数恒零是证据;非零 = undici 通道载荷的形变了,去看 detail 的 site。",
365
365
  },
366
+ "server.snapshot-bucket.presence-unproven": {
367
+ cls: "F",
368
+ note: "S-1195(7.119.0;`plugins/snapshot-bucket-presence.ts` 的 `ensureSnapshotBucket`,boot 期核一次快照桶 `SESSION_SNAPSHOT_BUCKET`;detail = `bucket=… endpoint=<buildDisplaySafeUrl 的显示形> <HTTP 状态 | request failed: 错类>`):presigned 列举对 `MINIO_ENDPOINT` **连不上 / 403 / 5xx / 没有 NoSuchBucket 的 404**,或探针到窗尾一次都没答过(detail 写 `probe still pending when the window ended`)⇒ **证明不了桶缺**,不拒启、照常起服(只有 404 + `NoSuchBucket` 才是「缺」,那一形窗内重试、到窗拒启,不走本 tag)。三问:①错误落到哪 —— 本 tag 的计数 / 探针行 / 记录器的一次性 warn,detail 带端点显示形与状态或错类;②谁受害 —— 桶其实不存在、或凭据不能列举的部署:之后每一次快照 offload 写与 SendUserFile 私有 presigned 请求会答 404 / 403,孤儿 sweep 每分钟一条失败,运维读到的是一串失败请求而不是「桶没建」(与 7.118.x 的既有形状同形,不是新损失);③缺席倒向哪边 —— 倒向**放行起服**:没有证据既不拒启、也不伪造「桶在场」或「桶缺」,不是门 / 权限 / 凭证。最坏后果 = 缺桶的部署晚一步才暴露;不放行的代价是对象存储一次抖动 / 一次 403 就让整只服务起不来。计数非零 = 这台部署启动时没能确认快照桶,按 detail 的端点与状态去核对网络 / 凭据 / 桶。",
369
+ },
370
+ "server.config-lkg.landed-path-unresolved": {
371
+ cls: "P-DEBT",
372
+ note: "7.120.0(`config-lkg.ts` 的 `landedPathOf`,`lkgReadDenyRows` 每次铸部署姿态座席时调;detail = `segment=<那一段的路径>`):`CONFIG_LKG_PATH` 覆写路径上有一段**目录在、却解不开**(悬空符号链接 / 链接环 / 读不了的目录:`realpath` 失败而 `lstat` 不说「不存在」)⇒ 这一段之下 LKG 真正会落在哪说不出来,读拒行只剩声明的拼写那一行(照旧在),真实落点那一行缺席。段**不存在**(`ENOENT` / `ENOTDIR`:还没建出来)是常态,不进这一臂;末段(文件本身)是悬空链接也不进(落盘的 rename 换掉的是那条目录项,文件就落在声明的拼写上)。归 P 的理由:这一行守的是 LKG 文件(含凭据),链接之后通了、LKG 写到链接目标下,模型按目标路径读得到它,直到重启重铸座席。归 DEBT 的理由:引擎没有「锚定单文件」的读拒行、座席只在 Runner 构造时铸一次,本服务说不出将来的落点;拿文件名在任意深度拒会把用户仓里同名文件一并挡掉。计数非零 = 去看 detail 点名的那一段(链接指向哪、卷挂了没有),修好后重启。",
373
+ },
366
374
  };
367
375
  export function failOpenTagForDroppedFrame(frameType) {
368
376
  switch (frameType) {
@@ -54,20 +54,21 @@ export declare function operatorAsk(rule: string, message: string): OperatorAskR
54
54
  * 任何 scope)退不掉,只有**这一次**的应答能答它。
55
55
  *
56
56
  * **为什么现在需要**:修前这两类 ask 挡住会话授权靠的是策略层的**次序**(`createDurableAskPolicy` 先判 neverAuto / 预算耗尽,再查
57
- * 豁免探针)。候裁 16 把会话授权的兑现位收到**审批席**(协调器 `standingGrantSettles`,判据 = `standingAnswerMayRetire`),而审批席
57
+ * 豁免探针)。候裁 16 把会话授权的兑现位收到**审批席**(协调器 `standingGrantSettles`,判据 = `standingAnswerHome`),而审批席
58
58
  * 读不到 `matchedAskRule`(core 刻意不上卡)—— 不盖出身,一条 `remember:"session"` 就会在审批席上退役「恒问人」。盖了之后
59
59
  * 席的同一只判据按 `mandated` 挡住它,耐久 `/decide` 腿的可记性(park 行 `mandated` 孪生位)同判 ⇒ 回执 `rememberApplied:false`。
60
60
  * live 腿修前这里本就是个洞(进程内 `allow_session` 授权可退役 neverAuto 卡),随类收掉。
61
61
  *
62
- * ⚠️ **只**这两类:普通 `APPROVAL_REQUIRE` 仍只经 {@link operatorAsk}(不盖出身)。它的问带 `matchedAskRule` ⇒ 引擎类 `ask_rule`;站着的答案
63
- * (「本会话放行」、耐久豁免行)清不清得掉它,出处是引擎类表 `foldArbitrationRow(classes).ruleMayClear` —— `ask_rule` 那一行说清不掉
64
- * (`tool-approval.ts` 的 `standingAnswerMayRetire` 与它同读)。所以在「站着的答案清不掉」这一问上它与「恒问人」两类同答;不同在出身位
65
- * (只这两类盖部署出身 ⇒ core 读 `operator_forced`、盖 `mandated`),其余各列按引擎类表各读各的行。
62
+ * ⚠️ **只**这两类:普通 `APPROVAL_REQUIRE` 仍只经 {@link operatorAsk}(不盖出身)。它的问带 `matchedAskRule` ⇒ 引擎类 `ask_rule`:bypass 放不掉、
63
+ * auto 分类器答不了(引擎眼里的免疫只靠这一位 —— core 7.44.0 的类表里 `operator_forced` 分类器**可答**,所以不拿出身位替它)。它是 server 自己的问,
64
+ * 它的站着的答案(「本会话放行」、耐久豁免行)由名单门在出问之前读 server 的会话豁免店(`approval.ts`),引擎的类表只裁引擎铸的问
65
+ * (`tool-approval.ts` 的 `standingAnswerHome` 按问的属主分读)。不盖出身还有第二个理由:出身位让引擎盖 `mandated`,卡面就成了「强制卡」——
66
+ * 名单卡要带「本会话放行」,而强制卡上没有。「恒问人」两类盖出身 ⇒ core 读 `operator_forced`、盖 `mandated`,站着的答案在哪一层都退不掉它们。
66
67
  */
67
68
  export declare function operatorAlwaysHumanAsk(rule: string, message: string): OperatorAskResult;
68
69
  /**
69
70
  * 🔴 **S-958 —— 治理层的叶策略包装**:`leaf` 产的每一只 ask 盖部署出身(经 {@link withDeploymentAuthorship}),allow / deny 原物交回;
70
- * 成员(`projection` / `pathDenyRules` / `writeTargetGuard` / `nameSets`)经 core `wrapToolPolicy` 随行(契约「包装至少拒内层所拒」:
71
+ * 成员(`projection` / `pathRules` / `writeTargetGuard` / `nameSets`)经 core `wrapToolPolicy` 随行(契约「包装至少拒内层所拒」:
71
72
  * 只加一位、从不放宽)。同步叶保持同步返回;叶抛照抛(fail-closed 的属主仍是叶)。
72
73
  *
73
74
  * **只收叶**:治理层(`runtime-governance.ts` `applyRuntimeGovernance`)对每一片叶各盖一次,再交 core `combinePolicies` 折**一次**
@@ -1,6 +1,10 @@
1
1
  import type { Pool as MysqlPool } from "mysql2/promise";
2
2
  import type { Pool as PgPool } from "pg";
3
3
  import type { Logger } from "../observability/logger.js";
4
+ /** The server's own audit event for a question a row answered without asking anyone — one name for both reading positions
5
+ * (the list gate: field `seat: "deployment_list"`; the approval seat: `seat: "approval_seat"`). Not a settlement record: nobody
6
+ * was asked, so it names no human / person / policy and carries no `remember`. */
7
+ export declare const APPROVAL_EXEMPTED_EVENT = "approval_exempted";
4
8
  export interface ApprovalExemptionRow {
5
9
  toolName: string;
6
10
  grantedBy: string | null;
@@ -2,6 +2,7 @@ import { join } from "node:path";
2
2
  import { existsSync, readdirSync, rmSync } from "node:fs";
3
3
  import { AppendLog, ensureDir, sanitizePathComponent } from "@sema-agent/core";
4
4
  import { replayJsonlGuarded } from "../observability/corrupt-read-seat.js";
5
+ export const APPROVAL_EXEMPTED_EVENT = "approval_exempted";
5
6
  export class TiDBApprovalExemptionStore {
6
7
  pool;
7
8
  constructor(pool) {
@@ -108,6 +108,15 @@ export interface MinioBlobBackendConfig {
108
108
  * 同步路径,SendUserFile 工具调用永不返回。120s 对慢链路 ~64MiB 单 PUT/GET 仍宽裕(presignTtlSec
109
109
  * 注释同口径),远小于病形。send-user-file 与 leader/diffup 同 import 此常量。 */
110
110
  export declare const OBJECT_STORE_IO_TIMEOUT_MS = 120000;
111
+ /** 桶存在性探针的结果(闭集):`present` = 列举 2xx;`missing` = 404 且体里是 `NoSuchBucket`(**唯一**能证明缺桶的形);其余一切 = `unknown`(带一句原因,不回显体)。 */
112
+ export type BucketProbe = {
113
+ readonly state: "present";
114
+ } | {
115
+ readonly state: "missing";
116
+ } | {
117
+ readonly state: "unknown";
118
+ readonly detail: string;
119
+ };
111
120
  export declare class MinioBlobBackend implements BlobBackend {
112
121
  private readonly cfg;
113
122
  private readonly keyPrefix;
@@ -160,6 +169,10 @@ export declare class MinioBlobBackend implements BlobBackend {
160
169
  headObject(hash: string): Promise<{
161
170
  lastModifiedMs: number;
162
171
  } | undefined>;
172
+ /** 桶存在性探针(boot 期核**一次**,`plugins/snapshot-bucket-presence.ts`):presigned ListObjectsV2 `max-keys=1`(与 sweep 的列举同一条签名路径,零新依赖)。
173
+ * 🔴 只有「404 且体里是 `NoSuchBucket`」才算缺桶;网络不通 / 403(凭据不能列举 ≠ 桶不在)/ 5xx / 没有 NoSuchBucket 的 404(代理 / 路径错)
174
+ * 都**证明不了**缺桶 ⇒ `unknown`,调用方不拒启、只报一次。总函数:不抛。`timeoutMs`:boot 期调用方给的单次上界(黑洞端点不许把起服拖到 120s)。 */
175
+ probeBucket(timeoutMs?: number): Promise<BucketProbe>;
163
176
  /** Byte-delete the given hashes — called ONLY from the async sweepOrphanBlobs reaper (NEVER the synchronous purge
164
177
  * path). Honors the SAME created_at grace window as the SQL backend: with an index, DELETE the grace-passed index
165
178
  * rows first (the index's created_at is the authoritative age — an S3 object carries no service-controlled timestamp
@@ -253,6 +253,32 @@ export class MinioBlobBackend {
253
253
  const ms = lm ? Date.parse(lm) : NaN;
254
254
  return { lastModifiedMs: Number.isFinite(ms) ? ms : Date.now() };
255
255
  }
256
+ async probeBucket(timeoutMs = OBJECT_STORE_IO_TIMEOUT_MS) {
257
+ const f = this.cfg.fetchImpl ?? fetch;
258
+ const url = presignS3ListUrl({
259
+ endpoint: this.cfg.endpoint,
260
+ bucket: this.cfg.bucket,
261
+ prefix: this.keyPrefix,
262
+ maxKeys: 1,
263
+ accessKey: this.cfg.accessKey,
264
+ secretKey: this.cfg.secretKey,
265
+ ...(this.cfg.region ? { region: this.cfg.region } : {}),
266
+ expiresSec: this.ttl,
267
+ });
268
+ let res;
269
+ try {
270
+ res = await f(url, { method: "GET", signal: AbortSignal.timeout(timeoutMs) });
271
+ }
272
+ catch (err) {
273
+ return { state: "unknown", detail: `request failed: ${err instanceof Error ? err.message : String(err)}` };
274
+ }
275
+ if (res.ok)
276
+ return { state: "present" };
277
+ const body = await res.text().catch(() => "");
278
+ if (res.status === 404 && /<Code>NoSuchBucket<\/Code>/.test(body))
279
+ return { state: "missing" };
280
+ return { state: "unknown", detail: `HTTP ${res.status}` };
281
+ }
256
282
  async deleteBlobs(hashes) {
257
283
  if (hashes.length === 0)
258
284
  return 0;
@@ -1,8 +1,17 @@
1
- import { type AcquiredSession, type SessionStore, type SessionRepo, type SessionTreeEntry } from "@sema-agent/core";
1
+ import { FileSessionRepo, type AcquiredSession, type SessionStore, type SessionRepo, type SessionTreeEntry } from "@sema-agent/core";
2
2
  import type { SessionSummary } from "./store-contracts.js";
3
+ import { type Logger } from "../observability/logger.js";
3
4
  import { type SessionFace } from "../session-incarnation.js";
4
5
  import { type InstructionSourceBaseline } from "../instruction-source-notice.js";
5
6
  import { type StagingHandle } from "../session-sync-kernel.js";
7
+ /**
8
+ * local 车道会话存档的**唯一 repo 出口**:本仓里每一只读 `<root>/sessions/*.jsonl` 的 `FileSessionRepo` 都从这里取(`LocalSessionStore` 的底座与会话视图口
9
+ * `audit.ts#buildSessionAuditLocal` 的读旁路),所以坏记录的披露座只有这一份接线 —— 修前视图口自建一只不带座的 repo,同一份坏档在这里读到有计数 / 探针,
10
+ * 在视图口读到一个字都没有。座 = F 类 `server.session-repo.corrupt-record-skipped`(会话存档里读不出的记录丢的是可用性:历史少一截 / 列表少一行,不开任何门;
11
+ * 计数单位 = 一条被跳过的记录,逐命中探针行,warn 每进程一次),外加会话打开作用域的计数腿(`noteReplayInSessionOpenScope`:只在 `openSessionForItself`
12
+ * 的作用域里才记,作用域外 —— 列表 / 导出 / 视图口 —— 是空操作,那些读只进运维面)。
13
+ */
14
+ export declare function createLocalSessionRepo(root: string): FileSessionRepo;
6
15
  export declare class LocalSessionStore implements SessionStore {
7
16
  private readonly repo;
8
17
  /** [ref] 车1:托管留存声明 —— **诚实的 none**。本店的行没有任何按期删除的腿(留存 lane 的三方法只实现在
@@ -12,8 +21,6 @@ export declare class LocalSessionStore implements SessionStore {
12
21
  readonly retention: import("@sema-agent/core").RetentionDeclaration;
13
22
  /** In-flight acquisitions keyed by id, so concurrent acquire(sameId) in one process share one (mirrors TiDBSessionStore). */
14
23
  private readonly pending;
15
- /** Service-side owner scope per session (core's repo has no owner column). Registered-ownerless ⇒ null; absent ⇒ undefined. */
16
- private readonly owners;
17
24
  /** Last-activity marker (touch) for ordering — the keyset sort key for {@link listSessions}. PERSISTED to a JSON
18
25
  * sidecar under {@link dataDir} (when given) so the /resume picker's newest-first order SURVIVES a service restart:
19
26
  * without persistence the map starts EMPTY on boot and every session degrades to `createdAt` ordering (the picker
@@ -54,35 +61,52 @@ export declare class LocalSessionStore implements SessionStore {
54
61
  private readonly sidecarPath?;
55
62
  /** Auto-title map (`session-titles.json`). Separate file from the lastActivity sidecar ON PURPOSE (that format is a
56
63
  * #10 clobber area); titles are write-once + ultra-low-rate (one per session, ever). Cross-instance sharing: see
57
- * {@link SidecarStringMap}. */
64
+ * {@link SidecarMap}. */
58
65
  private readonly titles;
59
66
  /** S-690 — instruction-source baselines (`session-instruction-sources.json`): the chain member the session's previous
60
67
  * run selected, encoded like the SQL twins' `session_meta.instruction_source` column ('' = none; missing key = never
61
68
  * recorded). Its own file for the same reason titles have theirs: one small map per concern, no format coupling. */
62
69
  private readonly instructionSources;
63
- /** S-790 / S-506([ref] §2):会话化身(`session-incarnations.json`,`{ [sessionId]: token }`)。SQL 孪生的
64
- * `session_meta.incarnation` 列在本店的对位。🔴 **必须落盘**(不能像 `owners` 那样只活在内存):化身的口径是「一位住客一生
65
- * 稳定」,而记忆引擎按它绑定会话级记录(opt-out / 污染标记)—— 重启后换一只值 = 同一位住客的 opt-out 不再绑它 = 静默恢复采集。
66
- * 设计稿原说「存进属主边车」,但本店**没有**落盘的属主边车(`owners` 是纯内存 Map,S3 裁定),所以落在同一族的第四只边档
67
- * (同一只 `SidecarStringMap`:同写链、失败回滚)。铸点只在新建会话;盘上旧会话没有这一格 ⇒ 第一次读补铸并落盘。 */
68
- private readonly incarnations;
70
+ /**
71
+ * 会话元数据(`session-incarnations.json`,一格的形与两种读法见 {@link SessionMetaCell}):**属主与化身的唯一真源**,SQL 孪生
72
+ * `session_meta` 行上 `owner` / `incarnation` 两列在本店的对位。两样都**必须落盘**:
73
+ * · 化身(S-790 / S-506,[ref] §2)的口径是「一位住客一生稳定」,记忆引擎按它绑定会话级记录(opt-out / 污染标记)——
74
+ * 重启后换一只值 = 同一位住客的 opt-out 不再绑它 = 静默恢复采集;
75
+ * · 属主(7.119.0)是会话策略规则的存储键:规则按「会话 × 属主」存、按 run 的 principal 读 —— 重启后属主丢了,规则照样在跑,
76
+ * 属主自己却读不到也删不掉(`409 conflict.session_ownerless`)。7.118.x 及更早属主只活在一只进程内存 Map 里,就是这个病。
77
+ * 没有第二份:进程里的读视图就是这只边档按路径共享的那张表({@link SidecarMap},同写链;只发布已落盘的值 —— 写失败什么都不发布)。铸点只在新建会话;
78
+ * 盘上有会话而边档里没有它那一格 ⇒ 第一次读补铸一格只有化身的(属主没有事实可写)并落盘。严格读(读不出 ⇒ 整只拒,见 `SidecarMap.strict`)。
79
+ */
80
+ private readonly meta;
81
+ /** 认领行(`session_owner_claimed`)的出口 —— 一次性迁移 M10 的认领臂专用(窗口见 `plugins/one-time-migrations.ts` 登记表)。 */
82
+ private readonly log;
69
83
  /** @param repo the core SessionRepo this store projects into the §0.5 SessionStore shape.
70
84
  * @param dataDir the SAME data dir the file-backed repo persists under (LocalBackend passes `fileBackend.root`).
71
85
  * When given, lastActivity is persisted to `<dataDir>/session-last-activity.json` so it survives a
72
- * restart (the file-backed deployment); omit it for the in-memory deployment (nothing survives). */
73
- constructor(repo: SessionRepo, dataDir?: string);
86
+ * restart (the file-backed deployment); omit it for the in-memory deployment (nothing survives).
87
+ * @param opts.log the `session_owner_claimed` line's sink (LocalBackend passes a logger at the deployment's LOG_LEVEL;
88
+ * absent ⇒ an info-level JSON-line logger — a claim is never silent). */
89
+ constructor(repo: SessionRepo, dataDir?: string, opts?: {
90
+ log?: Pick<Logger, "info">;
91
+ });
92
+ /**
93
+ * 把 `sessionId` 那一格写成 `next(before)` 的结果并**落盘**(写链里在草稿上判、写、落盘后才发布给读者 —— 与 instruction-source 同一只 `commit`;
94
+ * 判据建立在每一节之前的盘上状态上,所以防 clobber 的「已记下就不动」写在 `next` 里,不写在链外)。`next` 返回 `undefined` = 不改。
95
+ * 落不了盘 ⇒ 抛(属主与化身都是正确性事实,不做「内存里先记着」的降级:一只没落盘的化身重启后会被换掉 —— 那是另一位住客;
96
+ * 一个没落盘的属主重启后就丢了 —— 那正是 7.119.0 修的病)。返回这一次是否真改了那一格。
97
+ */
98
+ private writeMeta;
99
+ /** 删一只会话的那一格(落盘;落不了 ⇒ 抛 —— 留下的格会让一只删掉的会话在重启后「还在」)。 */
100
+ private dropMeta;
101
+ /** 盘上有没有这只会话(repo 的唯一探针;`open` 读整只存档,所以只在边档里没有那一格时才走到这里)。 */
102
+ private existsInRepo;
74
103
  /**
75
- * S-790:把 `sessionId` 的化身写成 `next(before)` 的结果并**落盘**(写链里判、写、失败回滚 —— 与 instruction-source 同一只
76
- * `commit`)。`next` 返回 `undefined` = 不改。落不了盘 ⇒ 抛(化身是正确性事实,不做「内存里先记着」的降级:一只没落盘的化身
77
- * 重启后会被换掉,那就是另一位住客)。
104
+ * 一只会话的元数据 —— 属主与化身的**唯一读口**(`ownerOf` / `sessionFaceOf` / `getHead` / 删除与换装的属主门都经它)。`undefined` = 无此会话。
105
+ * 边档里有那一格 ⇒ 读它(一次同步读,两样同出一格,不会拼出混世代组合);没有那一格而盘上有会话(7.104.0 之前的会话从没被铸过化身)
106
+ * ⇒ 补铸一格只有化身的(属主没有事实可写,不伪造)并落盘后再答 —— 之后的读都命中那一格,不再回 repo。
78
107
  */
79
- private writeIncarnation;
80
- /** S-790:新建会话 ⇒ 铸一只新的(覆盖同 id 上任何残留 —— 新建行就是新住客)。 */
81
- private mintIncarnation;
82
- /** S-790:已存在的会话缺一只化身(盘上 7.104.0 之前的会话)⇒ 补铸;已有的一个字节不动。 */
83
- private ensureIncarnation;
84
- /** S-790:属主 + 化身一次读(SQL 孪生同义;`undefined` = 无此会话)。盘上有会话而边档里没有它的化身 ⇒ 补铸并落盘后再答;
85
- * 读的过程中会话被删 / 换了主 ⇒ `undefined`(与 {@link getHead} 同一条「读间被删」判据)。 */
108
+ private metaOf;
109
+ /** S-790:属主 + 化身一次读(SQL 孪生同义;`undefined` = 无此会话)—— 两样出自 {@link metaOf} 读到的同一格。 */
86
110
  sessionFaceOf(sessionId: string): Promise<SessionFace | undefined>;
87
111
  /** Cheap pre-LLM probe (local twin). The map write needs no session_meta row, so there is
88
112
  * no "none" (register-lag) dimension here — two states only. */
@@ -94,10 +118,10 @@ export declare class LocalSessionStore implements SessionStore {
94
118
  /** S-690 —— 换源通告的基线读(local 孪生;三态读法与 SQL 两孪生的 `session_meta.instruction_source` 同一只编解码)。 */
95
119
  readInstructionSource(sessionId: string): Promise<InstructionSourceBaseline>;
96
120
  /**
97
- * S-690 —— CAS 覆写基线(契约见 `InstructionSourceStore`:期望值不符 ⇒ 不写;**没落盘 ⇒ 回滚内存再抛**,与 SQL 孪生
121
+ * S-690 —— CAS 覆写基线(契约见 `InstructionSourceStore`:期望值不符 ⇒ 不写;**没落盘 ⇒ 内存不动、抛**,与 SQL 孪生
98
122
  * 「要么提交要么抛」同形 —— 修前写失败只 warn、内存照样前进,同来源的后续 run 在相等比较上短路、再也不重写,重启后
99
- * 比的是一份过期的盘,codex che-NM r1 [medium])。CAS 判定、落盘、失败回滚**整段在写链的一节里**(`commit`):下一节
100
- * 开始之前内存已回到盘上那份,判定永远不会建立在一个没落过盘的值上(codex che-NM r2 [medium])。
123
+ * 比的是一份过期的盘,codex che-NM r1 [medium])。CAS 判定(在草稿上)、落盘、发布**整段在写链的一节里**(`commit`):下一节
124
+ * 开始之前共享的那张表只含落过盘的值,判定永远不会建立在一个没落过盘的值上(codex che-NM r2 [medium])。
101
125
  */
102
126
  recordInstructionSource(sessionId: string, expected: InstructionSourceBaseline, source: string | null): Promise<void>;
103
127
  /** Best-effort, one-shot hydrate of the persisted lastActivity map (no-op when not persisting / no sidecar yet). A
@@ -141,28 +165,35 @@ export declare class LocalSessionStore implements SessionStore {
141
165
  requireExisting?: boolean;
142
166
  }): Promise<AcquiredSession>;
143
167
  private load;
144
- /** Idempotently record a session's owner — never overwrites an existing owner (mirrors TiDBSessionStore.register).
145
- * TiDB parity: register INSERTs a session_meta row so the session EXISTS (forkable) even before its
146
- * first acquire/run — so we also ensure an EMPTY repo session exists (open if present → never overwrite history;
147
- * create if not), else `ownerOf` would say a registered-but-never-acquired session exists while `fork` 404s it. */
148
- register(sessionId: string, owner: string | null): Promise<void>;
149
- /** Owner of a session: `null` if registered ownerless, `undefined` if no such session (mirrors TiDBSessionStore.ownerOf).
168
+ /**
169
+ * Idempotently record a session's owner — never overwrites a RECORDED owner (incl. a recorded `null`, "registered ownerless";
170
+ * mirrors TiDBSessionStore.register's first-write rule). TiDB parity: register INSERTs a session_meta row so the session EXISTS
171
+ * (forkable) even before its first acquire/run — so we also ensure an EMPTY repo session exists (open if present → never
172
+ * overwrite history; create if not), else `ownerOf` would say a registered-but-never-acquired session exists while `fork` 404s it.
150
173
  *
151
- * S3 修(clay 裁 2026-07-26:**local=单用户,多租户走云** ⇒ 跨重启不承诺 owner 隔离):`owners` 是纯内存
152
- * Map,重启后失忆——曾把「失忆」答成 `undefined`(=「无此会话」),让 DELETE /v1/sessions 的 route 门短路成
153
- * already-gone、checkpoint 的 EXISTS 镜像门 return 0 ⇒ **E21 对盘上老会话整体 no-op 且无人知道**。
154
- * 现在 Map miss 时探 repo:盘上在 ⇒ 按 load() 两处懒回填早已写下的同一判据回填 `null`(registered
155
- * ownerless);repo 真没有才 `undefined`。SQL 孪生无此形(owner 在 session_meta 行,重启无损)。 */
174
+ * 首写判据在写链里判(两只并发登记:先落盘的那一只赢,后一只不改、也不建会话)。一只**盘上已有、属主从没被记下**的会话
175
+ * (边档那一格只有化身 —— 7.118.x 及更早写下的,或更早到连化身都没有)是一次性迁移 M10 的对象(窗口见 `plugins/one-time-migrations.ts`
176
+ * 登记表):窗开着、且这次登记带着 principal(`owner !== null`)⇒ **认领**(记下属主,化身一个字节不动 —— 同一位住客),一行 info
177
+ * `session_owner_claimed`;否则只补化身(缺就补),属主仍是「没被记下」,读作无主。认领的安全前提是单用户 —— local 后端起服门拒
178
+ * `REQUIRE_PRINCIPAL=true`,授权口也只在那时才把 principal 递过来认领(`security.ts` `createAuthorizer`)。
179
+ */
180
+ register(sessionId: string, owner: string | null): Promise<void>;
181
+ /** Owner of a session: `null` if ownerless (registered ownerless, or an owner that was never recorded — a session written by
182
+ * an earlier version), `undefined` if no such session (mirrors TiDBSessionStore.ownerOf). Reads {@link metaOf}: the persisted
183
+ * cell, so the answer survives a restart exactly like the SQL twins' `session_meta.owner` column does. (Before 7.119.0 the
184
+ * owner lived in a process-local Map and this method answered `null` for every session after a restart — the S3 fallback
185
+ * "on disk ⇒ ownerless" kept the E21 delete working, but froze every owner-keyed session-policy rule.) */
156
186
  ownerOf(sessionId: string): Promise<string | null | undefined>;
157
- /** [ref] 六轮复审:owner+leafId 原子快照——单线程 JS 内两同步/顺序读之间无 await 打断 owner 判定
158
- * (owner 先取快照,leaf 读后 owner 复核,变了=undefined 视作删除中,调用方按删处理)。 */
187
+ /** [ref] 六轮复审:owner+leafId 原子快照 —— 先读那一格,读 leaf,再同步复读那一格:格没了(读间被删)或换了主 / 换了住客 ⇒
188
+ * undefined(调用方按删处理)。 */
159
189
  getHead(sessionId: string): Promise<{
160
190
  owner: string | null;
161
191
  leafId: string | null;
162
192
  } | undefined>;
163
- /** 2c P1d-β — re-stamp the service-side owner scope WITHOUT rewriting entries (the staged-commit `identical` path:
164
- * the log is already present & equal, so only the owner needs re-stamping to the importing principal, §9). */
165
- restampOwner(sessionId: string, owner: string | null): void;
193
+ /** 2c P1d-β — re-stamp the session's owner WITHOUT rewriting entries (the staged-commit `identical` path: the log is
194
+ * already present & equal, so only the owner needs re-stamping to the importing principal, §9). Persisted like every
195
+ * owner write; the incarnation is untouched (same occupant). No cell (no such session) ⇒ no-op. */
196
+ restampOwner(sessionId: string, owner: string | null): Promise<void>;
166
197
  touch(sessionId: string): Promise<void>;
167
198
  /** E18 — the session's current leaf SessionTreeEntry.id (@see TiDBSessionStore.getLeafId). Reads the repo's storage
168
199
  * leaf (no acquire-lock; InMemory/File repo reads don't lock). null when the session doesn't exist / has no leaf. */
@@ -174,10 +205,9 @@ export declare class LocalSessionStore implements SessionStore {
174
205
  * abstraction (`repo.list()`), keyset-paginated newest-first by last activity. Session IDENTITY comes from the
175
206
  * repo (one entry per persisted session); a {@link SessionSummary} is SYNTHESIZED per id so the wire shape is
176
207
  * byte-identical to {@link TiDBSessionStore.listSessions} (the run picker doesn't drift):
177
- * - `owner` — the service-side owner map (process-local; null when unknown/ownerless). Owner FILTERING
178
- * is best-effort here (the in-memory map IS the source of truth; a single-user local
179
- * deployment runs REQUIRE_PRINCIPAL=off so the principal path passes owner=null and every
180
- * null-owner session lists). The route already owner-gates at the HTTP layer.
208
+ * - `owner` — the session's persisted session-meta cell (null when ownerless / never recorded). A
209
+ * single-user local deployment runs REQUIRE_PRINCIPAL=off; the route already owner-gates
210
+ * at the HTTP layer.
181
211
  * - `firstActivityAt` — the session's `createdAt` (repo metadata).
182
212
  * - `lastActivityAt` — the touch marker (falls back to `createdAt`); the keyset sort key.
183
213
  * - `objectivePreview`— the last USER-text entry, synthesized from the session tree (no run ledger).
@@ -240,9 +270,10 @@ export declare class LocalSessionStore implements SessionStore {
240
270
  /** 2c session-sync — IMPORT a verbatim entry log into `sessionId` via core `SessionRepo.importEntries`
241
271
  * (which runs the `validateEntriesForImport` fail-closed gate before writing). `owner` is the authenticated
242
272
  * importing principal (re-stamped into the service-side owner map; core's local repo has no owner column, §9).
243
- * `owner=null` (single-user local) is passed as `undefined` to the core seam (its owner param is `string | undefined`). */
273
+ * `owner=null` (single-user local) is passed as `undefined` to the core seam (its owner param is `string | undefined`).
274
+ * The owner is recorded in the session-meta cell (persisted), together with the incarnation. */
244
275
  importEntries(sessionId: string, owner: string | null, entries: SessionTreeEntry[]): Promise<void>;
245
- /** importEntries / replaceEntries 的共用写半场:导日志 + 盖属主 + 落化身(新建铸的那只,或换装沿用的那只)。 */
276
+ /** importEntries / replaceEntries 的共用写半场:导日志 + 一格写全属主与化身(新建铸的那只,或换装沿用的那只)。 */
246
277
  private importEntriesAs;
247
278
  /** 2c session-sync (§7/§8) — IDEMPOTENT REPLACE. The local twin's replace is DELETE-then-IMPORT: core's
248
279
  * InMemorySessionRepo / FileSessionRepo expose no single replace primitive, but the local backend is single-writer