@sema-agent/server 7.37.0 → 7.38.0-rc.2

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 (83) hide show
  1. package/USAGE.md +2 -1
  2. package/dist/adoption/plan.js +16 -0
  3. package/dist/boot/budget-tracing.d.ts +1 -1
  4. package/dist/boot/budget-tracing.js +12 -5
  5. package/dist/boot/config-center.d.ts +27 -2
  6. package/dist/boot/config-center.js +168 -15
  7. package/dist/boot/limit-sync.d.ts +32 -0
  8. package/dist/boot/limit-sync.js +35 -0
  9. package/dist/boot/reapers.d.ts +68 -0
  10. package/dist/boot/reapers.js +63 -4
  11. package/dist/boot/resolve-spec.js +2 -0
  12. package/dist/boot/runner-deps.d.ts +1 -1
  13. package/dist/boot/runner-deps.js +4 -0
  14. package/dist/boot/stores.js +13 -0
  15. package/dist/config-center/apply-effective.d.ts +41 -3
  16. package/dist/config-center/apply-effective.js +87 -10
  17. package/dist/config-center/apply-ledger.d.ts +82 -0
  18. package/dist/config-center/apply-ledger.js +104 -0
  19. package/dist/config-center/facade.d.ts +1 -1
  20. package/dist/config-center/hot-keys-registry.d.ts +62 -0
  21. package/dist/config-center/hot-keys-registry.js +54 -0
  22. package/dist/config-center/http-client.js +12 -2
  23. package/dist/config-center/restart-signal.js +5 -2
  24. package/dist/config-center/stage-limits.d.ts +59 -0
  25. package/dist/config-center/stage-limits.js +188 -0
  26. package/dist/config-center/types.d.ts +60 -0
  27. package/dist/config-invariants.d.ts +65 -0
  28. package/dist/config-invariants.js +139 -0
  29. package/dist/config-provider.js +11 -0
  30. package/dist/config-types.d.ts +10 -1
  31. package/dist/config.d.ts +11 -0
  32. package/dist/config.js +37 -5
  33. package/dist/http/routes/admin-config-refresh.d.ts +20 -0
  34. package/dist/http/routes/admin-config-refresh.js +43 -0
  35. package/dist/http/routes/diagnostics.js +4 -0
  36. package/dist/http/routes/runs.js +10 -6
  37. package/dist/http/server.d.ts +8 -0
  38. package/dist/http/server.js +26 -0
  39. package/dist/main.js +49 -6
  40. package/dist/memory-sync.js +50 -1
  41. package/dist/observability/cost-quota.d.ts +23 -3
  42. package/dist/observability/cost-quota.js +27 -4
  43. package/dist/observability/fail-open.d.ts +8 -0
  44. package/dist/observability/fail-open.js +8 -0
  45. package/dist/observability/rate-limit.d.ts +11 -1
  46. package/dist/observability/rate-limit.js +8 -2
  47. package/dist/plan-cache-probe.d.ts +5 -7
  48. package/dist/plan-cache-probe.js +27 -5
  49. package/dist/plugins/caching-session-store.d.ts +20 -4
  50. package/dist/plugins/caching-session-store.js +43 -2
  51. package/dist/plugins/fork-routing-session-store.d.ts +26 -4
  52. package/dist/plugins/fork-routing-session-store.js +134 -9
  53. package/dist/plugins/memory-engine-pg.d.ts +1 -0
  54. package/dist/plugins/memory-engine-pg.js +34 -4
  55. package/dist/plugins/memory-engine-tidb.d.ts +1 -0
  56. package/dist/plugins/memory-engine-tidb.js +17 -2
  57. package/dist/plugins/memory-origin-law.d.ts +69 -0
  58. package/dist/plugins/memory-origin-law.js +118 -1
  59. package/dist/plugins/pg-cost-quota.d.ts +7 -2
  60. package/dist/plugins/pg-cost-quota.js +25 -2
  61. package/dist/plugins/pg-rate-limiter.d.ts +7 -2
  62. package/dist/plugins/pg-rate-limiter.js +20 -2
  63. package/dist/plugins/pg-session-storage.d.ts +30 -7
  64. package/dist/plugins/pg-session-storage.js +145 -23
  65. package/dist/plugins/retention-store-sql.js +10 -1
  66. package/dist/plugins/session-placement.d.ts +113 -0
  67. package/dist/plugins/session-placement.js +153 -0
  68. package/dist/plugins/session-store.js +7 -1
  69. package/dist/plugins/tidb-cost-quota.d.ts +7 -2
  70. package/dist/plugins/tidb-cost-quota.js +26 -2
  71. package/dist/plugins/tidb-pool.js +15 -1
  72. package/dist/plugins/tidb-rate-limiter.d.ts +9 -2
  73. package/dist/plugins/tidb-rate-limiter.js +22 -2
  74. package/dist/plugins/tidb-session-storage.js +10 -2
  75. package/dist/plugins/tidb-session-store.d.ts +55 -7
  76. package/dist/plugins/tidb-session-store.js +174 -24
  77. package/dist/plugins/write-behind-counter.d.ts +17 -2
  78. package/dist/plugins/write-behind-counter.js +19 -1
  79. package/dist/runs.d.ts +3 -1
  80. package/dist/runs.js +5 -2
  81. package/dist/trace/engine-notice-wire.d.ts +1 -1
  82. package/dist/trace/engine-notice-wire.js +24 -6
  83. package/package.json +3 -3
@@ -16,6 +16,7 @@ import { TiDBSessionStorage } from "./tidb-session-storage.js";
16
16
  import { escapeLike } from "./sql-escape.js";
17
17
  import { MANAGED_RETENTION } from "./retention-store-sql.js";
18
18
  import { contentForkRelation, fastForwardSharedContentDiverged } from "../session-sync-content.js";
19
+ import { assertPlacementAdmission, LIST_PLACED_BATCH, PLACEMENT_SELECT_COLUMNS, placedRowFromRow, placementFromRow, placementInsertValues, SQL_SESSION_PLACEMENTS, } from "./session-placement.js";
19
20
  import { classifySyncRelationshipByIds, SyncConflictError, stagingIdFor, STAGING_ID_MARKER, STAGING_GC_GRACE_MS, // 两孪生店同源(理由见 kernel 处顶注:同值双抄单边改值 ⇒ 两后端 GC 判定分家)
20
21
  } from "../session-sync-kernel.js";
21
22
  export class TiDBSessionStore {
@@ -23,6 +24,10 @@ export class TiDBSessionStore {
23
24
  /** #270 车1:托管留存声明。读法与「为什么 SQL 店答 managed 而实现体在别处」逐字见
24
25
  * `retention-store-sql.ts` 的 {@link MANAGED_RETENTION}。 */
25
26
  retention = MANAGED_RETENTION;
27
+ /** #292 C18(core 5.48.0 design/323):placement **声明**。六条义务的实装点逐条见
28
+ * {@link SQL_SESSION_PLACEMENTS} 顶注与本类的 acquire / listSessions / release / listPlaced /
29
+ * placementOf 各处的 `#292` 注。 */
30
+ placements = SQL_SESSION_PLACEMENTS;
26
31
  /** In-flight acquisitions keyed by id, so concurrent acquire(sameId) in one process share one. */
27
32
  pending = new Map();
28
33
  constructor(pool) {
@@ -31,42 +36,95 @@ export class TiDBSessionStore {
31
36
  async acquire(sessionId, opts) {
32
37
  if (!sessionId) {
33
38
  const id = uuidv7();
34
- await this.register(id, null);
35
- return this.load(id);
39
+ // #292 义务:placement 与行**同一条 INSERT** 落库(创建时一次落行,first-write 不可变)
40
+ await this.register(id, null, opts?.placement);
41
+ return this.load(id, false, opts);
36
42
  }
37
43
  // design/114 Phase3 (F1): a `requireExisting` acquire NEVER creates and can REJECT (not_found) — it MUST bypass
38
44
  // the shared create-dedup `pending`, else a concurrent create-capable waiter awaiting that same promise would
39
45
  // inherit the not_found rejection instead of creating-on-miss. Two concurrent requireExisting opens each just
40
46
  // wake independently (both resolve or both fail loud) — harmless (it only opens, never double-creates).
41
47
  if (opts?.requireExisting)
42
- return this.load(sessionId, true);
48
+ return this.load(sessionId, true, opts);
49
+ // #292:准入判据**按调用方逐一判**(core `TtlSessionStore` 的同款纪律)——共享的 in-flight promise
50
+ // 只共享「会话被唤醒」这件事,绝不让一个 claim 形等待者继承可信创建方的准入,或反过来。
43
51
  const inflight = this.pending.get(sessionId);
44
- if (inflight)
45
- return inflight;
46
- const p = this.load(sessionId).finally(() => this.pending.delete(sessionId));
52
+ if (inflight) {
53
+ const acquired = await inflight;
54
+ assertPlacementAdmission(sessionId, await this.placementOf(sessionId), opts);
55
+ return acquired;
56
+ }
57
+ const p = this.load(sessionId, false, opts).finally(() => this.pending.delete(sessionId));
47
58
  this.pending.set(sessionId, p);
48
59
  return p;
49
60
  }
50
- async load(sessionId, requireExisting = false) {
61
+ async load(sessionId, requireExisting = false, opts) {
51
62
  let storage = await TiDBSessionStorage.wake(this.pool, sessionId);
63
+ if (storage) {
64
+ // #292 义务 6(claim 关门)+ 镜像方向:判据吃的是**已持久化**的元组(wake 顺路带回,零额外往返)。
65
+ assertPlacementAdmission(sessionId, (await storage.getMetadata()).placement, opts);
66
+ return { session: new StoredSession(storage), sessionId };
67
+ }
68
+ // design/114 Phase3: `requireExisting` ⇒ FAIL LOUD on a genuinely missing session (no session_meta row),
69
+ // never the silent create-on-miss that turns a reuse-style warm-resume into a fresh empty run. core's
70
+ // prepare-task maps this store `not_found` to the caller-facing `resume.session_not_found`.
71
+ if (requireExisting)
72
+ throw new SessionError("not_found", `session ${sessionId} not found`);
73
+ // No meta row yet (a caller-chosen brand-new id the service didn't pre-register). Create it
74
+ // ownerless; only the caller who chose this id can reach it, and it holds no prior data.
75
+ await this.register(sessionId, null, opts?.placement);
76
+ storage = await TiDBSessionStorage.wake(this.pool, sessionId);
77
+ // 刚 register 完却唤不醒 = 有人在这两步之间把行删了(带外 purge / 留存腿)。旧形在这里是一个
78
+ // `storage!` 非空断言,真撞上时炸成一条无信息的 `TypeError: reading 'getMetadata' of null`
79
+ // (本批实测撞到过一次:邻车在同一测试库上整表 DELETE)。改成响亮且**说得出发生了什么**的错。
52
80
  if (!storage) {
53
- // design/114 Phase3: `requireExisting` FAIL LOUD on a genuinely missing session (no session_meta row),
54
- // never the silent create-on-miss that turns a reuse-style warm-resume into a fresh empty run. core's
55
- // prepare-task maps this store `not_found` to the caller-facing `resume.session_not_found`.
56
- if (requireExisting)
57
- throw new SessionError("not_found", `session ${sessionId} not found`);
58
- // No meta row yet (a caller-chosen brand-new id the service didn't pre-register). Create it
59
- // ownerless; only the caller who chose this id can reach it, and it holds no prior data.
60
- await this.register(sessionId, null);
61
- storage = await TiDBSessionStorage.wake(this.pool, sessionId);
81
+ throw new SessionError("storage", `session ${sessionId} vanished between register and wake (a concurrent purge won the race) — retry`);
62
82
  }
83
+ // #292 镜像方向(pre-claim squat):带 placement 的创建必须**回读**到持久化的同组元组——并发的
84
+ // claim 形若抢先建出普通行,我们的 INSERT 是 no-op,回读即缺席 ⇒ 响亮拒(不是静默降级成普通会话)。
85
+ assertPlacementAdmission(sessionId, (await storage.getMetadata()).placement, opts);
63
86
  return { session: new StoredSession(storage), sessionId };
64
87
  }
65
- /** Idempotently create a session_meta row with an owner. Existing owner is never overwritten. */
66
- async register(sessionId, owner) {
88
+ /** Idempotently create a session_meta row with an owner. Existing owner is never overwritten.
89
+ * #292:`placement` 在场时与行同一条 INSERT 落六列;ON DUPLICATE 臂是 no-op ⇒ 已存在的行的元组
90
+ * **永不被重写**(first-write 不可变,义务 5 的写侧半场)。 */
91
+ async register(sessionId, owner, placement) {
67
92
  const now = new Date();
68
- await this.pool.query("INSERT INTO session_meta (session_id, owner, created_at, updated_at, leaf_id, leaf_seq) " +
69
- "VALUES (?,?,?,?,NULL,0) ON DUPLICATE KEY UPDATE session_id = session_id", [sessionId, owner, now, now]);
93
+ await this.pool.query("INSERT INTO session_meta (session_id, owner, created_at, updated_at, leaf_id, leaf_seq, " +
94
+ "placement_kind, placement_scope, placement_parent_session_id, placement_root_session_id, placement_handle, placed_at) " +
95
+ "VALUES (?,?,?,?,NULL,0,?,?,?,?,?,?) ON DUPLICATE KEY UPDATE session_id = session_id", [sessionId, owner, now, now, ...placementInsertValues(placement, now)]);
96
+ }
97
+ /**
98
+ * #292 义务 6 的廉价探针 —— 一行 PK SELECT:`undefined` = 普通会话或无此行。
99
+ * (release 的冷缓存腿、包装店的暖命中准入判、诊断面共用这一只。)
100
+ */
101
+ async placementOf(sessionId) {
102
+ const [rows] = await this.pool.query(`SELECT ${PLACEMENT_SELECT_COLUMNS} FROM session_meta WHERE session_id = ?`, [sessionId]);
103
+ return placementFromRow(rows[0]);
104
+ }
105
+ /**
106
+ * #292 义务 5 —— placed 分区的**自有枚举**(联合 reap 的分区腿输入)。
107
+ *
108
+ * 年龄锚 = `COALESCE(updated_at, placed_at)`:`updated_at` 是本店的「最后活动」标记(register 时置、
109
+ * 每次 append/touch 推进),取不到才回落 `placed_at` —— 与 core File 店「mtime 优先、placedAt 回落」
110
+ * 的读法同义。行按年龄升序、上界 {@link LIST_PLACED_BATCH}(理由见那里)。
111
+ * join 半缺(scope/handle 任一为 NULL)⇒ `tupleIncomplete` 诚实变体,消费方对它 fail-closed。
112
+ */
113
+ async listPlaced(kind, opts) {
114
+ const params = [kind];
115
+ let sql = `SELECT session_id, updated_at, ${PLACEMENT_SELECT_COLUMNS} FROM session_meta WHERE placement_kind = ?`;
116
+ if (opts?.scope !== undefined) {
117
+ sql += " AND placement_scope = ?";
118
+ params.push(opts.scope);
119
+ }
120
+ if (opts?.olderThanMs !== undefined) {
121
+ sql += " AND COALESCE(updated_at, placed_at) < ?";
122
+ params.push(new Date(Date.now() - opts.olderThanMs));
123
+ }
124
+ sql += " ORDER BY COALESCE(updated_at, placed_at) ASC LIMIT ?";
125
+ params.push(LIST_PLACED_BATCH);
126
+ const [rows] = await this.pool.query(sql, params);
127
+ return rows.map((r) => placedRowFromRow(String(r.session_id), r, r.updated_at instanceof Date ? r.updated_at.getTime() : undefined));
70
128
  }
71
129
  /** Owner principal of a session, `null` if registered ownerless, `undefined` if no such session. */
72
130
  async ownerOf(sessionId) {
@@ -97,9 +155,58 @@ export class TiDBSessionStore {
97
155
  return undefined;
98
156
  return { owner: rows[0].owner ?? null, leafId: rows[0].leaf_id ?? null };
99
157
  }
100
- /** Durable history is retained; just drop any in-flight cache entry. */
158
+ /**
159
+ * Durable history is retained; just drop any in-flight cache entry.
160
+ *
161
+ * 🔴 #292 义务 4 —— **placed 会话例外:release 是真删除**(durable 史一并)。联合 reap 先按 rev 条件删
162
+ * 掉 agent 行、赢了才调本方法终结转录;若这里还按「归还租约」走,一条 30 天的转录就会失去属主行地
163
+ * 悬着(正是这条义务要关死的 strand)。判据取**持久化元组**(冷缓存/重启后照样成立,不依赖任何进程
164
+ * 内状态);删除本身是 ABA 安全的**条件删**(`placement_kind IS NOT NULL`)——探针与删除之间若真有
165
+ * 「行被删掉又以同 id 重建成普通会话」的世代翻转,条件删当场落空,不会误杀那只无辜的新会话。
166
+ * 删除失败**响亮上浮**(`SessionError("storage")`):吞掉失败等于让 reap 数一条「已终结」而字节还在
167
+ * (core File 店同批的 rmSync 失败上浮是同一条法)。
168
+ */
101
169
  async release(sessionId) {
102
170
  this.pending.delete(sessionId);
171
+ const placed = await this.placementOf(sessionId);
172
+ if (placed === undefined)
173
+ return; // 普通会话:acquire/release 语义逐字节不变(义务 3)
174
+ await this.deletePlacedSession(sessionId);
175
+ }
176
+ /**
177
+ * core B-17 的 `forget` 面 —— **丢引用,不动 durable 史**(与 {@link release} 的分工:release 是
178
+ * 生命周期终结口,forget 是「我用完了这只句柄」)。#292 之后这条分工是承重的:release 对 placed
179
+ * 会话已是真删除,于是每一处「只为验存在 / 写完收工」的收尾都必须落在 forget 上(preflight 纪律,
180
+ * [4743] 义务①)。本店的进程内状态只有 in-flight 去重表,故实现就是丢那一格。
181
+ */
182
+ forget(sessionId) {
183
+ this.pending.delete(sessionId);
184
+ }
185
+ /** 真删除一只 placed 会话的两张表(条件删门在 `placement_kind IS NOT NULL`;事务内 meta 先删=提交点,
186
+ * 与 {@link deleteSession} 同序)。任何 I/O 失败上浮成 `SessionError("storage")`,绝不吞。 */
187
+ async deletePlacedSession(sessionId) {
188
+ const conn = await this.pool.getConnection();
189
+ try {
190
+ await conn.beginTransaction();
191
+ const [metaRes] = await conn.query("DELETE FROM session_meta WHERE session_id = ? AND placement_kind IS NOT NULL", [sessionId]);
192
+ if (metaRes.affectedRows > 0)
193
+ await conn.query("DELETE FROM session_event WHERE session_id = ?", [sessionId]);
194
+ await conn.commit();
195
+ }
196
+ catch (err) {
197
+ try {
198
+ await conn.rollback();
199
+ }
200
+ catch {
201
+ /* ignore */
202
+ }
203
+ throw err instanceof SessionError
204
+ ? err
205
+ : new SessionError("storage", `placed session ${sessionId} could not be deleted: ${err instanceof Error ? err.message : String(err)}`, err instanceof Error ? err : undefined);
206
+ }
207
+ finally {
208
+ conn.release();
209
+ }
103
210
  }
104
211
  // ───────────────────────── SessionRepo seam (§0.5 session-ownership) ─────────────────────────
105
212
  // E16/E17/E21: the service EXTENDS core's `SessionRepo` (list/fork/delete) over its OWN session_meta /
@@ -118,7 +225,10 @@ export class TiDBSessionStore {
118
225
  */
119
226
  async listSessions(opts) {
120
227
  const params = [];
121
- const where = [];
228
+ // 🔴 #292 义务 2:placed 会话**永不上宿主枚举面**——壳的 /resume 选择器不许把一条子代转录当成可
229
+ // 续聊的会话摆出来(而且它一旦被选中,claim 形 acquire 还会当场拒)。谓词打在**持久化列**上 ⇒
230
+ // 重启后照样成立(义务的「restart-stable」一半)。
231
+ const where = ["sm.placement_kind IS NULL"];
122
232
  if (opts.owner) {
123
233
  // [4198] 窄互认第二臂的第三条件在此:`includeUnowned` 时谓词放宽到「本人的 ∪ 无主的」。
124
234
  // 仍然**不是** `owner IS NOT DISTINCT FROM ?`——具名的别人一行都不多给。
@@ -357,14 +467,28 @@ export class TiDBSessionStore {
357
467
  * point, so a mid-replace crash (post-delete, pre-insert) leaves orphan-free state (no meta = invisible to wake).
358
468
  */
359
469
  async replaceEntries(sessionId, owner, entries) {
470
+ // 🔴 #292:placed 会话不接受 session-sync 的整段换装。两条理由各自独立成立:①这条路径 DELETE 掉
471
+ // meta 行再重插,会**重写掉** placement 元组(first-write 不可变的写侧破口);②即便元组保住,一条
472
+ // 子代转录的内容被外部 push 覆盖,等于 agent 行还指着它、里面已经是别人的历史。⇒ 响亮拒。
473
+ await this.assertNotPlaced(sessionId, "replace"); // 快路(零工:命中即拒,不进事务)
360
474
  const validated = validateEntriesForImport(entries); // fail-closed gate BEFORE any write (mirrors importEntries)
361
475
  const conn = await this.pool.getConnection();
362
476
  try {
363
477
  await conn.beginTransaction();
478
+ // 🔴 #292(codex 对抗复审 R1-[high] 的同族窗,验真后修):上面那道快路是**事务外**的 ⇒ 与本事务
479
+ // 之间有 TOCTOU 窗。真门在事务内:①锁读一次元组(存在的行被行锁住;MySQL 的 gap lock 还会挡住
480
+ // 同键的并发 INSERT);②meta 的 DELETE 加 `placement_kind IS NULL` 硬条件 —— 即便判据被绕过,
481
+ // 一条 placed 行也**不可能**被这条路径删掉(placed 行的 owner 恒 NULL,而 replace(owner=null)
482
+ // 的属主门恰好匹配它 —— 没有这个条件,一次匿名 push 就能把子代转录整只换掉)。
483
+ const [placedRows] = await conn.query("SELECT placement_kind FROM session_meta WHERE session_id = ? FOR UPDATE", [sessionId]);
484
+ if (placedRows[0]?.placement_kind != null) {
485
+ await conn.rollback();
486
+ throw new SessionError("placement_refused", `session ${sessionId} belongs to the ${String(placedRows[0].placement_kind)} placement partition — a session-sync replace cannot overwrite a delegation transcript`);
487
+ }
364
488
  // Purge the existing log first (idempotent: a missing session deletes 0 rows). Events first, then the
365
489
  // owner-guarded meta delete (`owner <=> ?` — a legacy null-owner session matches owner=null), defense-in-depth.
366
490
  await conn.query("DELETE FROM session_event WHERE session_id = ?", [sessionId]);
367
- await conn.query("DELETE FROM session_meta WHERE session_id = ? AND owner <=> ?", [sessionId, owner]);
491
+ await conn.query("DELETE FROM session_meta WHERE session_id = ? AND owner <=> ? AND placement_kind IS NULL", [sessionId, owner]);
368
492
  // …then re-insert the validated log (entries + meta-as-commit-point) via the SHARED body.
369
493
  await this.insertValidatedLog(conn, sessionId, owner, validated);
370
494
  await conn.commit();
@@ -416,6 +540,13 @@ export class TiDBSessionStore {
416
540
  beginImportStaging(realSessionId, token) {
417
541
  return new TiDBStagingHandle(this.pool, realSessionId, stagingIdFor(realSessionId, token));
418
542
  }
543
+ /** #292:「这个 id 是 placed ⇒ 该写路径不适用」的共用拒绝(session-sync 的两条换装腿共用)。 */
544
+ async assertNotPlaced(sessionId, what) {
545
+ const placed = await this.placementOf(sessionId);
546
+ if (placed !== undefined) {
547
+ throw new SessionError("placement_refused", `session ${sessionId} belongs to the ${placed.kind} placement partition — a session-sync ${what} cannot overwrite a delegation transcript`);
548
+ }
549
+ }
419
550
  /**
420
551
  * 2c session-sync P1d-β (staged-row inspection) — read the rows STAGED under `stagingId` (NO session_meta probe — a
421
552
  * staging id has no meta row by construction, so {@link exportEntries} would return null), oldest-first. The Phase-B
@@ -569,7 +700,15 @@ class TiDBStagingHandle {
569
700
  // commit/append to the dst). The staged ids ARE the source (already dense 0..N-1 under the staging id).
570
701
  const [stagedRows] = await conn.query("SELECT entry_id FROM session_event WHERE session_id = ? ORDER BY seq ASC FOR UPDATE", [this.stagingId]);
571
702
  const [dstRows] = await conn.query("SELECT entry_id FROM session_event WHERE session_id = ? ORDER BY seq ASC FOR UPDATE", [this.realSessionId]);
572
- const [dstMeta] = await conn.query("SELECT 1 FROM session_meta WHERE session_id = ?", [this.realSessionId]);
703
+ // `placement_kind` 捎带在既有的存在性探针上(#292:placed 目的地拒绝整段换装,理由与
704
+ // `replaceEntries` 的同款注逐字同——两条腿是同一件事的两种写法)。这里**刻意不加 `FOR UPDATE`**:
705
+ // 它对**缺席行**在 PG 上什么都锁不到(窗关不上),在 MySQL 上又会 gap-lock 住整段键区间(把一次
706
+ // 本可以合法先到的 placement 创建堵成锁等待)——两边都不划算。真门在提交前的那次权威复读(见下)。
707
+ const [dstMeta] = await conn.query("SELECT placement_kind FROM session_meta WHERE session_id = ?", [this.realSessionId]);
708
+ if (dstMeta[0]?.placement_kind != null) {
709
+ await conn.rollback();
710
+ throw new SessionError("placement_refused", `session ${this.realSessionId} belongs to the ${String(dstMeta[0].placement_kind)} placement partition — a session-sync staged commit cannot overwrite a delegation transcript`);
711
+ }
573
712
  const srcIds = stagedRows.map((r) => String(r.entry_id));
574
713
  const dstIds = dstMeta[0] ? dstRows.map((r) => String(r.entry_id)) : null; // no dst meta ⇒ fresh (no dst log to relate to)
575
714
  const rel = classifySyncRelationshipByIds(srcIds, dstIds);
@@ -612,6 +751,17 @@ class TiDBStagingHandle {
612
751
  const now = new Date();
613
752
  await conn.query("INSERT INTO session_meta (session_id, owner, created_at, updated_at, leaf_id, leaf_seq) VALUES (?,?,?,?,?,?) " +
614
753
  "ON DUPLICATE KEY UPDATE owner = VALUES(owner), updated_at = VALUES(updated_at), leaf_id = VALUES(leaf_id), leaf_seq = VALUES(leaf_seq)", [this.realSessionId, owner, now, now, leafId, srcIds.length]);
754
+ // 🔴 #292(codex 对抗复审 R1-[high],验真后修)—— **提交前的最后一道门**:上面那次 upsert 之后,
755
+ // 这一行**一定**被本事务锁住(不管它是我们刚插的、还是并发方先插我们随后 UPDATE 的),于是这次
756
+ // 复读是**权威**的。病:目标行在(1)的探针时**还不存在** ⇒ 探针无行可锁(PG 的 FOR UPDATE 对
757
+ // 缺席行不锁任何东西),另一事务可以在此期间把同 id 铸成 placed 转录;没有这道复读,本事务会把
758
+ // 它的事件整段换掉、并把 owner/leaf 改写成 push 方的 —— agent 行还指着那条会话,里面已经是别人
759
+ // 的历史。命中即整事务回滚(事件的 DELETE/rename 一并撤销),响亮 `placement_refused`。
760
+ const [afterMeta] = await conn.query("SELECT placement_kind FROM session_meta WHERE session_id = ?", [this.realSessionId]);
761
+ if (afterMeta[0]?.placement_kind != null) {
762
+ await conn.rollback();
763
+ throw new SessionError("placement_refused", `session ${this.realSessionId} belongs to the ${String(afterMeta[0].placement_kind)} placement partition — a session-sync staged commit cannot overwrite a delegation transcript`);
764
+ }
615
765
  await conn.commit();
616
766
  return { relation: rel.relation };
617
767
  }
@@ -60,7 +60,8 @@ export declare class SqlWriteBehindCounter {
60
60
  private readonly dialect;
61
61
  private readonly pool;
62
62
  private readonly t;
63
- private readonly windowMs;
63
+ /** 🔴 不再 `readonly`(#322):`costQuotaWindowSec` 热更新经 {@link setWindowMs} 换代,语义见那里。 */
64
+ private windowMs;
64
65
  private readonly now;
65
66
  private readonly onDegraded?;
66
67
  private readonly queryTimeoutMs;
@@ -70,7 +71,9 @@ export declare class SqlWriteBehindCounter {
70
71
  private flushing;
71
72
  private flushStartedAt;
72
73
  private failStreak;
73
- constructor(dialect: CounterDialect, pool: MySqlPool | PgPool, t: CounterTable, windowMs: number, now?: () => number, onDegraded?: CounterDegradeHook | undefined, // S9: observability tap — never affects counting
74
+ constructor(dialect: CounterDialect, pool: MySqlPool | PgPool, t: CounterTable,
75
+ /** 🔴 不再 `readonly`(#322):`costQuotaWindowSec` 热更新经 {@link setWindowMs} 换代,语义见那里。 */
76
+ windowMs: number, now?: () => number, onDegraded?: CounterDegradeHook | undefined, // S9: observability tap — never affects counting
74
77
  queryTimeoutMs?: number);
75
78
  /** The ONLY place the two drivers' query shapes diverge. Returns {rows, affected}. */
76
79
  private exec;
@@ -103,6 +106,18 @@ export declare class SqlWriteBehindCounter {
103
106
  reap(): Promise<number>;
104
107
  startRefresh(intervalMs?: number): this;
105
108
  stop(): void;
109
+ /** #322:刷新环是否在跑(关断哨兵的判据:off 态必须**零 SQL**,含这条后台环)。 */
110
+ refreshRunning(): boolean;
111
+ /**
112
+ * #322 批1:窗长换代(`costQuotaWindowSec` 热更新)。
113
+ *
114
+ * 🔴 如实语义:窗长是**桶键的一部分**(`floor(now/windowMs)`),不是行里的一个可改字段。换窗长 ⇒ 当代
115
+ * 桶重算 ⇒ 本副本对「当前窗已用多少」的读数从新桶起算(旧桶的行还在库里,按它自己的窗长归属)。
116
+ * 未 flush 的本地增量仍带着**它自己的**桶键(rowKey 里已含),所以它们仍落回原来那个桶,不会被搬家。
117
+ * 这与限额值的换代不同(那个是纯比较参数,窗内计数一个不动),因此单独成注:运营改窗长要知道自己在
118
+ * 换的是**记账周期**,不是一个阈值。
119
+ */
120
+ setWindowMs(windowMs: number): void;
106
121
  }
107
122
  /** MySQL-protocol (TiDB) binding — historical class name + ctor shape preserved. */
108
123
  export declare class WriteBehindCounter extends SqlWriteBehindCounter {
@@ -20,7 +20,9 @@ export class SqlWriteBehindCounter {
20
20
  flushing = false;
21
21
  flushStartedAt = 0; // S9 review MED-1: hang-watchdog anchor // reentrancy guard: a flush slower than the timer interval must not overlap
22
22
  failStreak = new Map(); // S9: consecutive-failure runs, per kind
23
- constructor(dialect, pool, t, windowMs, now = () => Date.now(), onDegraded, // S9: observability tap — never affects counting
23
+ constructor(dialect, pool, t,
24
+ /** 🔴 不再 `readonly`(#322):`costQuotaWindowSec` 热更新经 {@link setWindowMs} 换代,语义见那里。 */
25
+ windowMs, now = () => Date.now(), onDegraded, // S9: observability tap — never affects counting
24
26
  // S9 deeper fix: per-query inactivity timeout. mysql2's is the client-side `{sql, timeout}` form (the
25
27
  // connection is held until the hung command really ends, so a poisoned connection is never reused
26
28
  // mid-query); node-pg's is `query_timeout` on the QueryConfig. Overridable via DB_QUERY_TIMEOUT_MS
@@ -203,6 +205,22 @@ export class SqlWriteBehindCounter {
203
205
  clearInterval(this.timer);
204
206
  this.timer = undefined;
205
207
  }
208
+ /** #322:刷新环是否在跑(关断哨兵的判据:off 态必须**零 SQL**,含这条后台环)。 */
209
+ refreshRunning() {
210
+ return this.timer !== undefined;
211
+ }
212
+ /**
213
+ * #322 批1:窗长换代(`costQuotaWindowSec` 热更新)。
214
+ *
215
+ * 🔴 如实语义:窗长是**桶键的一部分**(`floor(now/windowMs)`),不是行里的一个可改字段。换窗长 ⇒ 当代
216
+ * 桶重算 ⇒ 本副本对「当前窗已用多少」的读数从新桶起算(旧桶的行还在库里,按它自己的窗长归属)。
217
+ * 未 flush 的本地增量仍带着**它自己的**桶键(rowKey 里已含),所以它们仍落回原来那个桶,不会被搬家。
218
+ * 这与限额值的换代不同(那个是纯比较参数,窗内计数一个不动),因此单独成注:运营改窗长要知道自己在
219
+ * 换的是**记账周期**,不是一个阈值。
220
+ */
221
+ setWindowMs(windowMs) {
222
+ this.windowMs = windowMs;
223
+ }
206
224
  }
207
225
  /** MySQL-protocol (TiDB) binding — historical class name + ctor shape preserved. */
208
226
  export class WriteBehindCounter extends SqlWriteBehindCounter {
package/dist/runs.d.ts CHANGED
@@ -277,6 +277,8 @@ approval?: {
277
277
  windowMarginMs: number;
278
278
  }): Promise<void>;
279
279
  /** How often a running instance refreshes its run's updated_at (liveness, independent of events).
280
- * Must stay strictly below `runStaleSec` (asserted at startup) or the reaper would race live runs. */
280
+ * Must stay strictly below `runStaleSec` (asserted at startup AND on every hot config candidate) or the
281
+ * reaper would race live runs. #322:数值的单源是 `config-invariants.ts`(那条不变量的属主),两个消费面
282
+ * ——心跳环与 run-stale 判据——因此不可能各读各的。 */
281
283
  export declare const HEARTBEAT_MS = 30000;
282
284
  //# sourceMappingURL=runs.d.ts.map
package/dist/runs.js CHANGED
@@ -10,6 +10,7 @@ import { fleetRunResiduals, isFleetAgentTerminalNotification } from "./fleet/fle
10
10
  import { defaultSubagentTailBus, projectTailFrame } from "./fleet/subagent-tail-bus.js";
11
11
  import { emitPendingWorkflowCompletions, taskNotificationInboxEntry, taskNotificationStreamKey, NotifiedKeys } from "./orchestration/workflow-completion-inbox.js";
12
12
  import { recordFailOpen } from "./observability/fail-open.js"; // #310:通告 durable 写失败的留痕口
13
+ import { LIVENESS_HEARTBEAT_MS } from "./config-invariants.js"; // #322:心跳周期的单源 = 它服务的那条不变量
13
14
  /**
14
15
  * stoppedBy (core 1.252): a service cancel is a USER stop — mark the run's still-running background
15
16
  * children BEFORE core's teardown reaps them (bare abort = attribution falls back to "system"; core's own
@@ -1012,6 +1013,8 @@ approval) {
1012
1013
  }
1013
1014
  }
1014
1015
  /** How often a running instance refreshes its run's updated_at (liveness, independent of events).
1015
- * Must stay strictly below `runStaleSec` (asserted at startup) or the reaper would race live runs. */
1016
- export const HEARTBEAT_MS = 30_000;
1016
+ * Must stay strictly below `runStaleSec` (asserted at startup AND on every hot config candidate) or the
1017
+ * reaper would race live runs. #322:数值的单源是 `config-invariants.ts`(那条不变量的属主),两个消费面
1018
+ * ——心跳环与 run-stale 判据——因此不可能各读各的。 */
1019
+ export const HEARTBEAT_MS = LIVENESS_HEARTBEAT_MS;
1017
1020
  //# sourceMappingURL=runs.js.map
@@ -4,7 +4,7 @@
4
4
  * 三码同族:都是「这条 session 的记忆姿态」的披露,resume 之后仍然相关 ⇒ 也进 durable 账本
5
5
  * (cli 断连补看走既有 events 重放腿,[4634](ii))。
6
6
  */
7
- export declare const ENGINE_NOTICE_WIRE_CODES: readonly ["memory.session_polluted", "memory.harvest_quarantined", "memory.delegation_static_mark_waived"];
7
+ export declare const ENGINE_NOTICE_WIRE_CODES: readonly ["memory.session_polluted", "memory.harvest_quarantined", "memory.delegation_static_mark_waived", "memory.hold_opened", "memory.hold_released", "memory.hold_disposed"];
8
8
  export type EngineNoticeWireCode = (typeof ENGINE_NOTICE_WIRE_CODES)[number];
9
9
  /** 白名单谓词(单点):路由与门都读这一个,不许第二处手抄码串。 */
10
10
  export declare function isEngineNoticeWireCode(code: string): code is EngineNoticeWireCode;
@@ -18,12 +18,17 @@
18
18
  * **不投**(fail-closed 方向 = 宁缺席不串台;猜一个"当前活跃会话"就是串台的定义)。日志终点仍逐字
19
19
  * 保留 ⇒ 运维观察面零回退,事实一条不丢。
20
20
  *
21
- * ## 白名单的演化机制(设计稿 ④)
22
- * 本表是**过渡形**。core [4631] 已认领 `NOTICE_AUDIENCE` **码级注册表**(code → "user"|"operator",
23
- * 闭集映射,随 5.46 / 最迟 5.47)+ `EngineNotice.sessionId` 顶层可选。到货后本表**退役为消费该表**,
24
- * 不在本仓自造第二份「哪类码该上 wire」的判定语义。
25
- * 🔴 在此之前的固定动作:**每一次 core 提货批**,LEDGER 对表 core `EngineNotice` 码册的新增码,
26
- * 三选一登记(入册 / 明拒 + 理由 / 申裁),登记在 `docs/CORE-CONSUMPTION-LEDGER.md` 的该批行里。
21
+ * ## 白名单的演化机制(设计稿 ④)—— **注册表已到货(core 5.47/5.48)**
22
+ * core [4631] 认领的 `NOTICE_AUDIENCE` **码级注册表**(code → "user"|"operator";缺席读 `"operator"`,
23
+ * 保守默认)已随 5.47/5.48 到货,`EngineNotice.sessionId` 顶层键亦然。#292 提货批的处置是
24
+ * **消费它当上界,而不是当场把本表整只退役**:
25
+ * · 本表是**投递可行性**的判定(有没有 sessionId 归属键、core 侧的去重单位是不是 sink),
26
+ * `NOTICE_AUDIENCE` 是**受众**的判定(这条码是给谁看的)——两者是合取,不是同一件事;
27
+ * · 于是机器门(`test/engine-notice-wire.test.ts` ⑥)现在**双向**咬:白名单里出现一个 core 判
28
+ * `"operator"` 的码 ⇒ 红(本仓不许自造第二份受众语义);core 判 `"user"` 的码没入册也没明拒 ⇒ 红。
29
+ * · 真正的整表退役条件 = 上游把「归属键在不在」也纳入注册表(那时本表退化成一次查表),届时删。
30
+ * 🔴 固定动作不变:**每一次 core 提货批**对表 core `EngineNotice` 码册的新增码,三选一登记
31
+ * (入册 / 明拒 + 理由 / 申裁),并在 `docs/core-consumption/LEDGER.json` 的该批行里留痕。
27
32
  * 漏对表的后果不是报错而是**静默缺席**:新码永远不上 wire,消费端(cli)无从知道该码存在。
28
33
  */
29
34
  import { redactSecrets } from "./redact.js";
@@ -44,6 +49,19 @@ export const ENGINE_NOTICE_WIRE_CODES = [
44
49
  /** design/324 #324①:`memoryDelegationEvidence:"attested-only"` 下,一次静态面本会打的污染标记被豁免。
45
50
  * core 去重单位 = **prepared leg**。`detail: { reason, subagentType?, sessionId? }` */
46
51
  "memory.delegation_static_mark_waived",
52
+ /** design/336 §4/§6.3(core 5.47/5.48 到货,#292 提货批对表入册):**instruction-hold 生命周期**三码。
53
+ * 同族三条都是「这条 session 的记忆此刻处在什么姿态」的披露,与既有三码同一条判据:
54
+ * · core `NOTICE_AUDIENCE` 明列 **"user"**(到货的码级注册表,见本文件顶注的演化机制);
55
+ * · 铸点在 `memoryHoldNotices(report, sessionId)`,`detail.sessionId` 随 harvest 报告一起给 ⇒ 投得出去;
56
+ * · core 去重单位 = **harvest**(检查点收与终局收是两件事),不是 sink ⇒ 上 per-session 流不翻面。
57
+ * `detail: { count, paths, sessionId? }`(paths 已 neutralize + 截 20 条)。 */
58
+ "memory.hold_opened",
59
+ /** 同族:被扣住的 instruction 条目在写者会话干净结算(或宿主阀门放行)后重走全套门并提交。
60
+ * `detail: { count, paths, sessionId? }` */
61
+ "memory.hold_released",
62
+ /** 同族:被扣住的条目移交控制面隔离区。`detail: { count, disposed: [{ path, terminal }], sessionId? }`
63
+ * —— 🔴 `terminal:"expired"` 是**超时不是定罪**(core 铸文逐字如此),消费端不得渲染成裁决语气。 */
64
+ "memory.hold_disposed",
47
65
  ];
48
66
  const WIRE_CODES = new Set(ENGINE_NOTICE_WIRE_CODES);
49
67
  /** 白名单谓词(单点):路由与门都读这一个,不许第二处手抄码串。 */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/server",
3
- "version": "7.37.0",
3
+ "version": "7.38.0-rc.2",
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,8 +54,8 @@
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.46.0",
58
- "@sema-agent/registry-core": "^0.18.0",
57
+ "@sema-agent/core": "^5.50.0",
58
+ "@sema-agent/registry-core": "^0.19.0",
59
59
  "e2b": "^2.28.0",
60
60
  "libsodium-wrappers": "^0.8.4",
61
61
  "mysql2": "^3.22.4",