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

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 (82) 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 +148 -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 +38 -3
  16. package/dist/config-center/apply-effective.js +75 -5
  17. package/dist/config-center/apply-ledger.d.ts +67 -0
  18. package/dist/config-center/apply-ledger.js +83 -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/server.d.ts +8 -0
  37. package/dist/http/server.js +23 -0
  38. package/dist/main.js +49 -6
  39. package/dist/memory-sync.js +50 -1
  40. package/dist/observability/cost-quota.d.ts +23 -3
  41. package/dist/observability/cost-quota.js +27 -4
  42. package/dist/observability/fail-open.d.ts +8 -0
  43. package/dist/observability/fail-open.js +8 -0
  44. package/dist/observability/rate-limit.d.ts +11 -1
  45. package/dist/observability/rate-limit.js +8 -2
  46. package/dist/plan-cache-probe.d.ts +5 -7
  47. package/dist/plan-cache-probe.js +27 -5
  48. package/dist/plugins/caching-session-store.d.ts +20 -4
  49. package/dist/plugins/caching-session-store.js +43 -2
  50. package/dist/plugins/fork-routing-session-store.d.ts +26 -4
  51. package/dist/plugins/fork-routing-session-store.js +134 -9
  52. package/dist/plugins/memory-engine-pg.d.ts +1 -0
  53. package/dist/plugins/memory-engine-pg.js +34 -4
  54. package/dist/plugins/memory-engine-tidb.d.ts +1 -0
  55. package/dist/plugins/memory-engine-tidb.js +17 -2
  56. package/dist/plugins/memory-origin-law.d.ts +69 -0
  57. package/dist/plugins/memory-origin-law.js +118 -1
  58. package/dist/plugins/pg-cost-quota.d.ts +7 -2
  59. package/dist/plugins/pg-cost-quota.js +25 -2
  60. package/dist/plugins/pg-rate-limiter.d.ts +7 -2
  61. package/dist/plugins/pg-rate-limiter.js +20 -2
  62. package/dist/plugins/pg-session-storage.d.ts +30 -7
  63. package/dist/plugins/pg-session-storage.js +145 -23
  64. package/dist/plugins/retention-store-sql.js +10 -1
  65. package/dist/plugins/session-placement.d.ts +113 -0
  66. package/dist/plugins/session-placement.js +153 -0
  67. package/dist/plugins/session-store.js +7 -1
  68. package/dist/plugins/tidb-cost-quota.d.ts +7 -2
  69. package/dist/plugins/tidb-cost-quota.js +26 -2
  70. package/dist/plugins/tidb-pool.js +15 -1
  71. package/dist/plugins/tidb-rate-limiter.d.ts +9 -2
  72. package/dist/plugins/tidb-rate-limiter.js +22 -2
  73. package/dist/plugins/tidb-session-storage.js +10 -2
  74. package/dist/plugins/tidb-session-store.d.ts +55 -7
  75. package/dist/plugins/tidb-session-store.js +174 -24
  76. package/dist/plugins/write-behind-counter.d.ts +17 -2
  77. package/dist/plugins/write-behind-counter.js +19 -1
  78. package/dist/runs.d.ts +3 -1
  79. package/dist/runs.js +5 -2
  80. package/dist/trace/engine-notice-wire.d.ts +1 -1
  81. package/dist/trace/engine-notice-wire.js +24 -6
  82. package/package.json +3 -3
@@ -23,6 +23,7 @@ import { BaseSessionStorage, StoredSession, SessionError, uuidv7, validateEntrie
23
23
  import { escapeLike } from "./sql-escape.js";
24
24
  import { pgSafeJsonStringify } from "./pg-safe-json.js";
25
25
  import { toIso } from "./sql-row-helpers.js";
26
+ import { assertPlacementAdmission, LIST_PLACED_BATCH, PLACEMENT_SELECT_COLUMNS, placedRowFromRow, placementFromRow, placementInsertValues, SQL_SESSION_PLACEMENTS, } from "./session-placement.js";
26
27
  import { contentForkRelation, fastForwardSharedContentDiverged } from "../session-sync-content.js";
27
28
  import { classifySyncRelationshipByIds, SyncConflictError, stagingIdFor, STAGING_ID_MARKER, STAGING_GC_GRACE_MS, // 两孪生店同源(理由见 kernel 处顶注)
28
29
  } from "../session-sync-kernel.js";
@@ -52,9 +53,22 @@ export const PG_SESSION_SCHEMA = [
52
53
  -- Write-once auto-title (setTitleIfNull / probeTitle): NULL = "untitled", i.e. still claimable; the
53
54
  -- three-state probe distinguishes it from "no such session". PG twin of the TiDB COLUMN_MIGRATIONS leg.
54
55
  title VARCHAR(120) COLLATE "C",
56
+ -- #292 C18(core 5.48.0 design/323)subagent 转录持久化 —— placement 元组(TiDB 孪生同名同宽,
57
+ -- 语义与六条义务见 src/plugins/session-placement.ts 顶注):创建时一次落行、first-write 不可变;
58
+ -- placement_kind 在场 ⇒ 住在 placement 分区(不上宿主枚举 / claim 形 acquire 恒拒 / release=真删除 /
59
+ -- 留存视界腿不碰);(placement_scope, placement_handle) = 与 background_agent 行的 join 键;
60
+ -- placed_at = 分区 GC 的年龄锚。
61
+ placement_kind VARCHAR(32) COLLATE "C",
62
+ placement_scope VARCHAR(190) COLLATE "C",
63
+ placement_parent_session_id VARCHAR(190) COLLATE "C",
64
+ placement_root_session_id VARCHAR(190) COLLATE "C",
65
+ placement_handle VARCHAR(190) COLLATE "C",
66
+ placed_at TIMESTAMPTZ(3),
55
67
  PRIMARY KEY (session_id)
56
68
  )`,
57
69
  `CREATE INDEX IF NOT EXISTS idx_session_meta_owner ON session_meta (owner)`,
70
+ // listPlaced 的检索腿(TiDB 孪生的 KEY idx_session_meta_placement 同形)。
71
+ `CREATE INDEX IF NOT EXISTS idx_session_meta_placement ON session_meta (placement_kind, placement_scope, placed_at)`,
58
72
  `CREATE TABLE IF NOT EXISTS session_event (
59
73
  session_id VARCHAR(64) COLLATE "C" NOT NULL,
60
74
  seq BIGINT NOT NULL,
@@ -110,14 +124,18 @@ export class PgSessionStorage extends BaseSessionStorage {
110
124
  }
111
125
  /** @see TiDBSessionStorage.wake — F3 bounded `[floor..leaf]` rebuild; null if the session does not exist. */
112
126
  static async wake(pool, sessionId) {
113
- const metaRes = await pool.query("SELECT created_at, leaf_id, leaf_seq FROM session_meta WHERE session_id = $1", [sessionId]);
127
+ const metaRes = await pool.query(
128
+ // #292 C18(TiDB 孪生同形):placement 六列捎带在既有 meta SELECT 上,acquire 热路径零额外往返。
129
+ `SELECT created_at, leaf_id, leaf_seq, ${PLACEMENT_SELECT_COLUMNS} FROM session_meta WHERE session_id = $1`, [sessionId]);
114
130
  const meta = metaRes.rows[0];
115
131
  if (!meta)
116
132
  return null;
117
133
  const { floorEntryId, floorSeq } = await PgSessionStorage.computeFloor(pool, sessionId);
118
134
  const eventRes = await pool.query("SELECT payload FROM session_event WHERE session_id = $1 AND seq >= $2 ORDER BY seq ASC", [sessionId, floorSeq]);
119
135
  const entries = eventRes.rows.map((r) => parseEntry(r.payload));
120
- const metadata = { id: sessionId, createdAt: toIso(meta.created_at) };
136
+ // #292 C18:持久化元组骑 core 5.48 `SessionMetadata.placement`(TiDB 孪生逐字同义)
137
+ const placement = placementFromRow(meta);
138
+ const metadata = { id: sessionId, createdAt: toIso(meta.created_at), ...(placement !== undefined ? { placement } : {}) };
121
139
  return new PgSessionStorage(pool, sessionId, metadata, entries, meta.leaf_id ?? null, floorEntryId, floorSeq);
122
140
  }
123
141
  /** The bounded-window floor = last compaction's firstKeptEntryId (and its seq). @see TiDBSessionStorage.computeFloor */
@@ -210,6 +228,9 @@ export class PgSessionStore {
210
228
  pool;
211
229
  /** #270 车1:托管留存声明(TiDB 孪生同格)。读法见 `retention-store-sql.ts` 的 {@link MANAGED_RETENTION}。 */
212
230
  retention = MANAGED_RETENTION;
231
+ /** #292 C18(core 5.48.0 design/323):placement 声明(TiDB 孪生同格,义务逐条见
232
+ * `src/plugins/session-placement.ts` 顶注)。 */
233
+ placements = SQL_SESSION_PLACEMENTS;
213
234
  /** In-flight acquisitions keyed by id, so concurrent acquire(sameId) in one process share one. */
214
235
  pending = new Map();
215
236
  constructor(pool) {
@@ -218,38 +239,75 @@ export class PgSessionStore {
218
239
  async acquire(sessionId, opts) {
219
240
  if (!sessionId) {
220
241
  const id = uuidv7();
221
- await this.register(id, null);
222
- return this.load(id);
242
+ await this.register(id, null, opts?.placement); // #292:元组与行同一条 INSERT(TiDB 孪生同义)
243
+ return this.load(id, false, opts);
223
244
  }
224
245
  // design/114 Phase3 (F1): a `requireExisting` acquire NEVER creates and can REJECT (not_found) — it MUST bypass
225
246
  // the shared create-dedup `pending`, else a concurrent create-capable waiter awaiting that same promise would
226
247
  // inherit the not_found rejection instead of creating-on-miss. (Twin of TiDBSessionStore.acquire.)
227
248
  if (opts?.requireExisting)
228
- return this.load(sessionId, true);
249
+ return this.load(sessionId, true, opts);
250
+ // #292:准入按调用方逐一判(共享 promise 不许让 claim 形继承可信创建方的准入;TiDB 孪生同义)。
229
251
  const inflight = this.pending.get(sessionId);
230
- if (inflight)
231
- return inflight;
232
- const p = this.load(sessionId).finally(() => this.pending.delete(sessionId));
252
+ if (inflight) {
253
+ const acquired = await inflight;
254
+ assertPlacementAdmission(sessionId, await this.placementOf(sessionId), opts);
255
+ return acquired;
256
+ }
257
+ const p = this.load(sessionId, false, opts).finally(() => this.pending.delete(sessionId));
233
258
  this.pending.set(sessionId, p);
234
259
  return p;
235
260
  }
236
- async load(sessionId, requireExisting = false) {
261
+ async load(sessionId, requireExisting = false, opts) {
237
262
  let storage = await PgSessionStorage.wake(this.pool, sessionId);
263
+ if (storage) {
264
+ assertPlacementAdmission(sessionId, (await storage.getMetadata()).placement, opts); // #292 claim 关门
265
+ return { session: new StoredSession(storage), sessionId };
266
+ }
267
+ // design/114 Phase3: `requireExisting` ⇒ FAIL LOUD on a genuinely missing session (no session_meta row),
268
+ // never the silent create-on-miss. core's prepare-task maps this `not_found` to `resume.session_not_found`.
269
+ if (requireExisting)
270
+ throw new SessionError("not_found", `session ${sessionId} not found`);
271
+ await this.register(sessionId, null, opts?.placement);
272
+ storage = await PgSessionStorage.wake(this.pool, sessionId);
273
+ // 刚 register 完却唤不醒 = 并发 purge 抢在两步之间(TiDB 孪生同款注:旧形的 `storage!` 断言会炸成
274
+ // 一条无信息的 TypeError)。
238
275
  if (!storage) {
239
- // design/114 Phase3: `requireExisting` FAIL LOUD on a genuinely missing session (no session_meta row),
240
- // never the silent create-on-miss. core's prepare-task maps this `not_found` to `resume.session_not_found`.
241
- if (requireExisting)
242
- throw new SessionError("not_found", `session ${sessionId} not found`);
243
- await this.register(sessionId, null);
244
- storage = await PgSessionStorage.wake(this.pool, sessionId);
276
+ throw new SessionError("storage", `session ${sessionId} vanished between register and wake (a concurrent purge won the race) — retry`);
245
277
  }
278
+ // #292 镜像方向(pre-claim squat):回读缺失/异组 ⇒ 响亮拒(TiDB 孪生同义)。
279
+ assertPlacementAdmission(sessionId, (await storage.getMetadata()).placement, opts);
246
280
  return { session: new StoredSession(storage), sessionId };
247
281
  }
248
- /** Idempotently create a session_meta row with an owner. Existing owner is never overwritten. */
249
- async register(sessionId, owner) {
282
+ /** Idempotently create a session_meta row with an owner. Existing owner is never overwritten.
283
+ * #292:`placement` 与行同一条 INSERT;`DO NOTHING` 臂 ⇒ 已存在行的元组永不被重写(first-write 不可变) */
284
+ async register(sessionId, owner, placement) {
250
285
  const now = new Date();
251
- await this.pool.query("INSERT INTO session_meta (session_id, owner, created_at, updated_at, leaf_id, leaf_seq) " +
252
- "VALUES ($1,$2,$3,$4,NULL,0) ON CONFLICT (session_id) DO NOTHING", [sessionId, owner, now, now]);
286
+ await this.pool.query("INSERT INTO session_meta (session_id, owner, created_at, updated_at, leaf_id, leaf_seq, " +
287
+ "placement_kind, placement_scope, placement_parent_session_id, placement_root_session_id, placement_handle, placed_at) " +
288
+ "VALUES ($1,$2,$3,$4,NULL,0,$5,$6,$7,$8,$9,$10) ON CONFLICT (session_id) DO NOTHING", [sessionId, owner, now, now, ...placementInsertValues(placement, now)]);
289
+ }
290
+ /** #292 义务 6 的廉价探针(TiDB 孪生 {@link TiDBSessionStore.placementOf} 同义)。 */
291
+ async placementOf(sessionId) {
292
+ const res = await this.pool.query(`SELECT ${PLACEMENT_SELECT_COLUMNS} FROM session_meta WHERE session_id = $1`, [sessionId]);
293
+ return placementFromRow(res.rows[0]);
294
+ }
295
+ /** #292 义务 5 —— placed 分区枚举(年龄锚/上界/tupleIncomplete 三条读法与 TiDB 孪生逐字同义,
296
+ * 见 {@link TiDBSessionStore.listPlaced})。 */
297
+ async listPlaced(kind, opts) {
298
+ const params = [kind];
299
+ const ph = (v) => {
300
+ params.push(v);
301
+ return `$${params.length}`;
302
+ };
303
+ let sql = `SELECT session_id, updated_at, ${PLACEMENT_SELECT_COLUMNS} FROM session_meta WHERE placement_kind = $1`;
304
+ if (opts?.scope !== undefined)
305
+ sql += ` AND placement_scope = ${ph(opts.scope)}`;
306
+ if (opts?.olderThanMs !== undefined)
307
+ sql += ` AND COALESCE(updated_at, placed_at) < ${ph(new Date(Date.now() - opts.olderThanMs))}`;
308
+ sql += ` ORDER BY COALESCE(updated_at, placed_at) ASC LIMIT ${ph(LIST_PLACED_BATCH)}`;
309
+ const res = await this.pool.query(sql, params);
310
+ return res.rows.map((r) => placedRowFromRow(String(r.session_id), r, r.updated_at instanceof Date ? r.updated_at.getTime() : undefined));
253
311
  }
254
312
  /** Owner principal of a session, `null` if registered ownerless, `undefined` if no such session. */
255
313
  async ownerOf(sessionId) {
@@ -277,9 +335,51 @@ export class PgSessionStore {
277
335
  return undefined;
278
336
  return { owner: r.rows[0].owner ?? null, leafId: r.rows[0].leaf_id ?? null };
279
337
  }
280
- /** Durable history is retained; just drop any in-flight cache entry. */
338
+ /** Durable history is retained; just drop any in-flight cache entry.
339
+ * 🔴 #292 义务 4:**placed 会话例外 —— release 是真删除**(理由/ABA 条件删/失败上浮三条读法逐字见
340
+ * {@link TiDBSessionStore.release},本腿是它的 PG 孪生)。 */
281
341
  async release(sessionId) {
282
342
  this.pending.delete(sessionId);
343
+ const placed = await this.placementOf(sessionId);
344
+ if (placed === undefined)
345
+ return; // 普通会话:语义逐字节不变(义务 3)
346
+ await this.deletePlacedSession(sessionId);
347
+ }
348
+ /** @see TiDBSessionStore.forget — 丢引用不动 durable 史(preflight 纪律的落点;PG 孪生)。 */
349
+ forget(sessionId) {
350
+ this.pending.delete(sessionId);
351
+ }
352
+ /** @see TiDBSessionStore.deletePlacedSession — PG 孪生(条件删门 + meta 先删 + 失败上浮同义)。 */
353
+ async deletePlacedSession(sessionId) {
354
+ const conn = await this.pool.connect();
355
+ try {
356
+ await conn.query("BEGIN");
357
+ const metaRes = await conn.query("DELETE FROM session_meta WHERE session_id = $1 AND placement_kind IS NOT NULL", [sessionId]);
358
+ if ((metaRes.rowCount ?? 0) > 0)
359
+ await conn.query("DELETE FROM session_event WHERE session_id = $1", [sessionId]);
360
+ await conn.query("COMMIT");
361
+ }
362
+ catch (err) {
363
+ try {
364
+ await conn.query("ROLLBACK");
365
+ }
366
+ catch {
367
+ /* ignore */
368
+ }
369
+ throw err instanceof SessionError
370
+ ? err
371
+ : new SessionError("storage", `placed session ${sessionId} could not be deleted: ${err instanceof Error ? err.message : String(err)}`, err instanceof Error ? err : undefined);
372
+ }
373
+ finally {
374
+ conn.release();
375
+ }
376
+ }
377
+ /** #292:placed id 拒绝 session-sync 的整段换装(TiDB 孪生 {@link TiDBSessionStore.assertNotPlaced} 同义)。 */
378
+ async assertNotPlaced(sessionId, what) {
379
+ const placed = await this.placementOf(sessionId);
380
+ if (placed !== undefined) {
381
+ throw new SessionError("placement_refused", `session ${sessionId} belongs to the ${placed.kind} placement partition — a session-sync ${what} cannot overwrite a delegation transcript`);
382
+ }
283
383
  }
284
384
  // ───────────────────────── SessionRepo seam (§0.5) — PG twin of TiDBSessionStore ─────────────────────────
285
385
  // Behaviourally identical to {@link TiDBSessionStore.listSessions/fork/deleteSession}; only the SQL dialect
@@ -291,7 +391,8 @@ export class PgSessionStore {
291
391
  params.push(v);
292
392
  return `$${params.length}`;
293
393
  };
294
- const where = [];
394
+ // 🔴 #292 义务 2:placed 会话不上宿主枚举面(TiDB 孪生逐字同义,理由见那里)。
395
+ const where = ["sm.placement_kind IS NULL"];
295
396
  // [4198] 窄互认第二臂的第三条件(TiDB 孪生逐字同义):`includeUnowned` ⇒「本人的 ∪ 无主的」。
296
397
  if (opts.owner)
297
398
  where.push(opts.includeUnowned ? `(sm.owner = ${ph(opts.owner)} OR sm.owner IS NULL)` : `sm.owner = ${ph(opts.owner)}`);
@@ -454,13 +555,21 @@ export class PgSessionStore {
454
555
  * defense-in-depth), then re-insert via the SHARED body (entries + session_meta LAST as the commit point), ALL in
455
556
  * ONE txn. Safe to run OVER an existing session (fast-forward / §8 retry) — unlike importEntries' fresh-only INSERT. */
456
557
  async replaceEntries(sessionId, owner, entries) {
558
+ await this.assertNotPlaced(sessionId, "replace"); // 快路(事务外,零工拒);真门在事务内(TiDB 孪生同款注)
457
559
  const validated = validateEntriesForImport(entries); // fail-closed gate BEFORE any write (mirrors importEntries)
458
560
  const conn = await this.pool.connect();
459
561
  try {
460
562
  await conn.query("BEGIN");
563
+ // #292(codex R1-[high] 同族窗):事务内锁读 + DELETE 的 `placement_kind IS NULL` 硬条件
564
+ // (TiDB 孪生逐字同义 —— placed 行 owner 恒 NULL,而 replace(owner=null) 的属主门恰好匹配它)。
565
+ const placedRes = await conn.query("SELECT placement_kind FROM session_meta WHERE session_id = $1 FOR UPDATE", [sessionId]);
566
+ if (placedRes.rows[0]?.placement_kind != null) {
567
+ await conn.query("ROLLBACK");
568
+ throw new SessionError("placement_refused", `session ${sessionId} belongs to the ${placedRes.rows[0].placement_kind} placement partition — a session-sync replace cannot overwrite a delegation transcript`);
569
+ }
461
570
  // Purge first (idempotent: a missing session deletes 0 rows). Events, then the owner-guarded meta delete.
462
571
  await conn.query("DELETE FROM session_event WHERE session_id = $1", [sessionId]);
463
- await conn.query("DELETE FROM session_meta WHERE session_id = $1 AND owner IS NOT DISTINCT FROM $2", [sessionId, owner]);
572
+ await conn.query("DELETE FROM session_meta WHERE session_id = $1 AND owner IS NOT DISTINCT FROM $2 AND placement_kind IS NULL", [sessionId, owner]);
464
573
  // …then re-insert the validated log (entries + meta-as-commit-point) via the SHARED body.
465
574
  await this.insertValidatedLog(conn, sessionId, owner, validated);
466
575
  await conn.query("COMMIT");
@@ -605,7 +714,12 @@ class PgStagingHandle {
605
714
  await conn.query("BEGIN");
606
715
  const staged = await conn.query("SELECT entry_id FROM session_event WHERE session_id = $1 ORDER BY seq ASC FOR UPDATE", [this.stagingId]);
607
716
  const dst = await conn.query("SELECT entry_id FROM session_event WHERE session_id = $1 ORDER BY seq ASC FOR UPDATE", [this.realSessionId]);
608
- const dstMeta = await conn.query("SELECT 1 FROM session_meta WHERE session_id = $1", [this.realSessionId]);
717
+ // `placement_kind` 捎带在存在性探针上(#292:placed 目的地拒整段换装;TiDB 孪生同形)
718
+ const dstMeta = await conn.query("SELECT placement_kind FROM session_meta WHERE session_id = $1", [this.realSessionId]);
719
+ if (dstMeta.rows[0]?.placement_kind != null) {
720
+ await conn.query("ROLLBACK");
721
+ 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`);
722
+ }
609
723
  const srcIds = staged.rows.map((r) => String(r.entry_id));
610
724
  const dstIds = dstMeta.rows.length > 0 ? dst.rows.map((r) => String(r.entry_id)) : null;
611
725
  const rel = classifySyncRelationshipByIds(srcIds, dstIds);
@@ -636,6 +750,14 @@ class PgStagingHandle {
636
750
  const now = new Date();
637
751
  await conn.query("INSERT INTO session_meta (session_id, owner, created_at, updated_at, leaf_id, leaf_seq) VALUES ($1,$2,$3,$4,$5,$6) " +
638
752
  "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]);
753
+ // 🔴 #292(codex R1-[high]):**提交前的最后一道门** —— upsert 之后这一行必被本事务锁住,故这次
754
+ // 复读是权威的;它关的是「探针时行还不存在、并发方随后把同 id 铸成 placed」那半个窗(PG 的
755
+ // FOR UPDATE 对缺席行不锁任何东西)。理由与失败经济学逐字见 TiDB 孪生同处的注。
756
+ const afterMeta = await conn.query("SELECT placement_kind FROM session_meta WHERE session_id = $1", [this.realSessionId]);
757
+ if (afterMeta.rows[0]?.placement_kind != null) {
758
+ await conn.query("ROLLBACK");
759
+ throw new SessionError("placement_refused", `session ${this.realSessionId} belongs to the ${afterMeta.rows[0].placement_kind} placement partition — a session-sync staged commit cannot overwrite a delegation transcript`);
760
+ }
639
761
  await conn.query("COMMIT");
640
762
  return { relation: rel.relation };
641
763
  }
@@ -491,11 +491,20 @@ export class SqlRetentionStore {
491
491
  * (`originatingSessionId` 住在 run blob 里 ⇒ JSON 提取是真方言分叉)。fork 子代**结构上没有边**,
492
492
  * 理由见 {@link SqlRetentionStore.deleteExpiredSessions} 的头注。
493
493
  */
494
+ /* 🔴 #292 C18 追加的**第五条腿**(placed 分区,core 5.48 design/323 义务 4 的 server 侧兑现):
495
+ * 一只 `placement_kind` 在场的会话是某条委派的**转录**,它的删除权**独家**属于联合 reap
496
+ * (`reapDurableAgents`:先按 rev 条件删 agent 行、赢了才 `release` 终结转录;以及行已不在时的分区
497
+ * 收割腿)。让视界腿也去删它会造出两个删除权威,而它们的交叠恰好是这条义务要关死的那个状态:
498
+ * 行还在、指着一份已经不存在的转录(core 的 `delegation.transcript_integrity` 通告说的就是它)。
499
+ * ⇒ 把「placed」当成一条**活引用**:候选谓词排除它,`countProtectedSessions` 同时把它记成 skipped
500
+ * (两侧共用本函数 ⇒ 不可能分家)。它不会因此永生:分区收割腿按 `backgroundAgentRetentionMs` 的
501
+ * 年龄 + 「行确实不在」联合判定后真删。 */
494
502
  liveReferenceExists() {
495
503
  const workflow = this.db.dialect === "tidb"
496
504
  ? `EXISTS (SELECT 1 FROM workflow_run wr WHERE wr.status = 'running' AND JSON_UNQUOTE(JSON_EXTRACT(wr.run, '$.originatingSessionId')) = sm.session_id)`
497
505
  : `EXISTS (SELECT 1 FROM workflow_run wr WHERE wr.status = 'running' AND wr.run::json->>'originatingSessionId' = sm.session_id)`;
498
- return (`(EXISTS (SELECT 1 FROM task_active ta WHERE ta.session_id = sm.session_id)` +
506
+ return (`(sm.placement_kind IS NOT NULL` + // #292:placed 转录的删除权独家归联合 reap(见本函数上方的第五条腿注)
507
+ ` OR EXISTS (SELECT 1 FROM task_active ta WHERE ta.session_id = sm.session_id)` +
499
508
  ` OR EXISTS (SELECT 1 FROM checkpoint c2 WHERE c2.session_id = sm.session_id AND c2.status = 'pending')` +
500
509
  ` OR EXISTS (SELECT 1 FROM background_agent ba WHERE ba.status IN ('running','parked')` +
501
510
  ` AND (ba.session_id = sm.session_id OR ba.parent_session_id = sm.session_id OR ba.root_session_id = sm.session_id))` +
@@ -0,0 +1,113 @@
1
+ /**
2
+ * #292 C18(core 5.48.0 design/323「不造新店」提货批,黑板 [4743] 点名义务)——**subagent 转录持久化**
3
+ * 的 placement 语义,SQL 孪生共用的一份判据。
4
+ *
5
+ * ## 这是什么
6
+ *
7
+ * core 5.48.0 给 `SessionStore` 开了一组**全 optional** 的 placement 面:一只**声明** `placements`
8
+ * (「本店懂 placement 语义,且落在 subagent 分区里的会话是这个耐久度」)+ `acquire(id,{placement})`
9
+ * 的创建形 + `listPlaced` / `placementOf` 两只读面。声明即承诺**六条义务**(core `SessionStore.placements`
10
+ * 顶注逐字):
11
+ *
12
+ * 1. placed 会话享受所声明的耐久度(重启后 `acquire(id,{requireExisting:true})` 仍到得了);
13
+ * 2. placed 会话**不上宿主枚举面**(壳的 /resume 列表不许把子代转录当成可续聊会话);
14
+ * 3. **未** placed 的会话,acquire 语义逐字节不变;
15
+ * 4. `release(placedId)` 是**真删除**(durable 史一并),联合 reap 的「行与会话同亡」靠它;
16
+ * 5. placement **first-write 不可变**:re-open(`requireExisting`,带不带 placement 参数)返回同一
17
+ * 会话且**永不重写**元组;
18
+ * 6. placed id **不可被 claim**:后来的 claim 形 acquire(无 `requireExisting`、无创建 placement)
19
+ * 撞上已存在的 placed id ⇒ 响亮拒 `SessionError("placement_refused")`,绝不附着。
20
+ *
21
+ * ## 为什么判据住在这里而不是各写一份
22
+ *
23
+ * 本仓成文教训(`memory-origin-law.ts` 顶注同族、`key-resolver.ts` 顶注「链只有一条」):两支方言各
24
+ * 抄一份准入判据,迟早在某一支上少一个分支——而少掉的那一支**没有任何报错面**,表现只是「这台机器上
25
+ * 一个陌生人可以把自己接到别人子代的转录上」。所以准入判据(两个方向)、列名、行→元组的映射都在
26
+ * 这一处;两支后端只负责在自己的 I/O 点上调用它。
27
+ *
28
+ * ## 判据的两个方向(缺一不可,core `TtlSessionStore.assertPlacementAdmission` 的镜像)
29
+ *
30
+ * ① **claim 关门**:已 placed 的行拒绝 claim 形 acquire;
31
+ * ② **镜像方向(pre-claim squat 竞态)**:子会话 id 在 spawn 帧上**先于**子代 prepare 公布,于是一次
32
+ * 并发的 claim 形 acquire 可能抢先建出**普通**会话,而后带 placement 的可信调用方拿到的就是一只
33
+ * 宿主可枚举、release 不真删的普通会话 ——「声明了 full tier 却在静默说谎」。⇒ 在**声明了分区**的
34
+ * 店上,带 placement 的 acquire 必须回读到**已持久化**且同一元组身份的行,回读缺失/异组=响亮拒。
35
+ */
36
+ import { type PlacedSessionRow, type SessionPlacement, type SessionPlacementRecord } from "@sema-agent/core";
37
+ /**
38
+ * 两支 SQL 孪生的 placement **声明**(core `SessionStore.placements`)。
39
+ *
40
+ * 🔴 声明制,不是鸭子类型:core 的铸造门判「row store ∧ session 店声明」,不声明的店**不收** placed
41
+ * 语义(合法降级:placement 参数被忽略,会话就是普通会话)。所以这一格只有在上面六条义务**全部**在
42
+ * 本店实装之后才允许存在——它是一句关于本店行为的承诺,不是一个功能开关。
43
+ * `durable` 的依据:两支孪生的会话史落在 `session_meta`+`session_event` 两张真表上,进程重启后
44
+ * `acquire(id,{requireExisting:true})` 从事件日志重放(义务 1 的机器证在
45
+ * `test/session-placement-db-integration.test.ts` 的跨进程复活格)。
46
+ */
47
+ export declare const SQL_SESSION_PLACEMENTS: {
48
+ readonly subagent: {
49
+ readonly durability: "durable";
50
+ };
51
+ };
52
+ /**
53
+ * `session_meta` 上承载 placement 元组的六列,**两方言同名**(单点定义:列名手抄两份必漂,而漂掉的
54
+ * 那一支只会表现为「这台机器上 placed 行读不出来」——静默,不报错)。
55
+ * `placed_at` 是 GC 的年龄锚(core `PlacedSessionRow.placedAt`);其余五列是元组本身。
56
+ */
57
+ export declare const PLACEMENT_SELECT_COLUMNS = "placement_kind, placement_scope, placement_parent_session_id, placement_root_session_id, placement_handle, placed_at";
58
+ /** 一行 `session_meta` 的 placement 列(两方言的驱动都把列名原样带回,故一份形)。 */
59
+ export interface PlacementColumns {
60
+ placement_kind?: unknown;
61
+ placement_scope?: unknown;
62
+ placement_parent_session_id?: unknown;
63
+ placement_root_session_id?: unknown;
64
+ placement_handle?: unknown;
65
+ placed_at?: unknown;
66
+ }
67
+ /**
68
+ * 行 → 持久化的 placement 元组(`undefined` = 这是一只普通会话)。
69
+ *
70
+ * 🔴 判别位是 `placement_kind`:它在场即 placed。`placed_at` 缺失(带外 SQL 写进半只元组)时回落
71
+ * `0` 而**不是**把整只元组读成缺席——把「元组半缺」读成「没有 placement」等于让义务 2/4/6 在那一行上
72
+ * 集体失效(宿主枚举会重新列出它、release 退回租约语义);`listPlaced` 那一侧另有 `tupleIncomplete`
73
+ * 的诚实变体负责让 GC 对它 fail-closed。
74
+ */
75
+ export declare function placementFromRow(row: PlacementColumns | undefined): SessionPlacementRecord | undefined;
76
+ /**
77
+ * 行 → 一条 {@link PlacedSessionRow}(`listPlaced` 的行形)。
78
+ *
79
+ * core 契约:join 键是**完整**的 `(scope, handle)`;缺任何一半 ⇒ 返回 `tupleIncomplete` 的诚实变体,
80
+ * 消费方对它 fail-closed(永不按年龄收割)。一个「无行」与「另一个租户有行」分不清的 join 不许删东西。
81
+ */
82
+ export declare function placedRowFromRow(sessionId: string, row: PlacementColumns, lastActivityMs?: number): PlacedSessionRow;
83
+ /** 创建时一次落行的六个绑定值(顺序 = {@link PLACEMENT_SELECT_COLUMNS});无 placement ⇒ 六个 NULL。 */
84
+ export declare function placementInsertValues(placement: SessionPlacement | undefined, placedAt: Date): [string | null, string | null, string | null, string | null, string | null, Date | null];
85
+ /** `acquire` 的 opts 面(core `SessionStore.acquire` 的第二参,窄成本模块要判的两键)。 */
86
+ export interface PlacementAcquireOpts {
87
+ requireExisting?: boolean;
88
+ placement?: SessionPlacement;
89
+ }
90
+ /**
91
+ * 准入判据 —— **两个方向**(顶注 ①②;core `TtlSessionStore.assertPlacementAdmission` 的 SQL 镜像,
92
+ * 逐条同义,文案自铸)。
93
+ *
94
+ * @param persisted 该 id 在库里**已持久化**的元组(缺席 = 普通会话 / 无此行)。
95
+ *
96
+ * · 方向①(claim 关门):`persisted` 在场 + 非 `requireExisting` + 调用方没带创建 placement ⇒ 拒。
97
+ * `requireExisting`(读/复活形,义务 5)与「创建调用及其幂等重取」(带 placement 的可信内部链)
98
+ * 是仅有的两个合法形。placement 不可变意味着带 placement 的参数**永远重写不了**任何东西——持久化
99
+ * 的那一份恒赢,所以放行它不开任何后门。
100
+ * · 方向②(镜像/squat):带 placement 的创建形回读到**缺席**或**异组**元组 ⇒ 拒。缺席 = 并发的
101
+ * claim 形抢先建出了普通会话(或带外写把元组抹了);异组 = 同 id 上坐着另一条委派链的转录。
102
+ * 两者都不许让「声明了 durable 分区」这句话在静默中变成谎。
103
+ */
104
+ export declare function assertPlacementAdmission(sessionId: string, persisted: SessionPlacementRecord | undefined, opts?: PlacementAcquireOpts): void;
105
+ /**
106
+ * 一次 `listPlaced` 最多带回的行数。
107
+ *
108
+ * core 契约不设上界,但两支孪生面对的是一张可以很大的真表:无界 SELECT 会让一次 reap tick 把整个
109
+ * placed 分区拉进内存。行按**年龄升序**(最老先出)⇒ 截断的语义是「本 tick 先收最老的一批,下一 tick
110
+ * 接着收」,而不是「有些行永远看不见」。判据消费方(core 的分区收割腿)本就是每 tick 重新枚举的。
111
+ */
112
+ export declare const LIST_PLACED_BATCH = 1000;
113
+ //# sourceMappingURL=session-placement.d.ts.map
@@ -0,0 +1,153 @@
1
+ /**
2
+ * #292 C18(core 5.48.0 design/323「不造新店」提货批,黑板 [4743] 点名义务)——**subagent 转录持久化**
3
+ * 的 placement 语义,SQL 孪生共用的一份判据。
4
+ *
5
+ * ## 这是什么
6
+ *
7
+ * core 5.48.0 给 `SessionStore` 开了一组**全 optional** 的 placement 面:一只**声明** `placements`
8
+ * (「本店懂 placement 语义,且落在 subagent 分区里的会话是这个耐久度」)+ `acquire(id,{placement})`
9
+ * 的创建形 + `listPlaced` / `placementOf` 两只读面。声明即承诺**六条义务**(core `SessionStore.placements`
10
+ * 顶注逐字):
11
+ *
12
+ * 1. placed 会话享受所声明的耐久度(重启后 `acquire(id,{requireExisting:true})` 仍到得了);
13
+ * 2. placed 会话**不上宿主枚举面**(壳的 /resume 列表不许把子代转录当成可续聊会话);
14
+ * 3. **未** placed 的会话,acquire 语义逐字节不变;
15
+ * 4. `release(placedId)` 是**真删除**(durable 史一并),联合 reap 的「行与会话同亡」靠它;
16
+ * 5. placement **first-write 不可变**:re-open(`requireExisting`,带不带 placement 参数)返回同一
17
+ * 会话且**永不重写**元组;
18
+ * 6. placed id **不可被 claim**:后来的 claim 形 acquire(无 `requireExisting`、无创建 placement)
19
+ * 撞上已存在的 placed id ⇒ 响亮拒 `SessionError("placement_refused")`,绝不附着。
20
+ *
21
+ * ## 为什么判据住在这里而不是各写一份
22
+ *
23
+ * 本仓成文教训(`memory-origin-law.ts` 顶注同族、`key-resolver.ts` 顶注「链只有一条」):两支方言各
24
+ * 抄一份准入判据,迟早在某一支上少一个分支——而少掉的那一支**没有任何报错面**,表现只是「这台机器上
25
+ * 一个陌生人可以把自己接到别人子代的转录上」。所以准入判据(两个方向)、列名、行→元组的映射都在
26
+ * 这一处;两支后端只负责在自己的 I/O 点上调用它。
27
+ *
28
+ * ## 判据的两个方向(缺一不可,core `TtlSessionStore.assertPlacementAdmission` 的镜像)
29
+ *
30
+ * ① **claim 关门**:已 placed 的行拒绝 claim 形 acquire;
31
+ * ② **镜像方向(pre-claim squat 竞态)**:子会话 id 在 spawn 帧上**先于**子代 prepare 公布,于是一次
32
+ * 并发的 claim 形 acquire 可能抢先建出**普通**会话,而后带 placement 的可信调用方拿到的就是一只
33
+ * 宿主可枚举、release 不真删的普通会话 ——「声明了 full tier 却在静默说谎」。⇒ 在**声明了分区**的
34
+ * 店上,带 placement 的 acquire 必须回读到**已持久化**且同一元组身份的行,回读缺失/异组=响亮拒。
35
+ */
36
+ import { SessionError } from "@sema-agent/core";
37
+ /**
38
+ * 两支 SQL 孪生的 placement **声明**(core `SessionStore.placements`)。
39
+ *
40
+ * 🔴 声明制,不是鸭子类型:core 的铸造门判「row store ∧ session 店声明」,不声明的店**不收** placed
41
+ * 语义(合法降级:placement 参数被忽略,会话就是普通会话)。所以这一格只有在上面六条义务**全部**在
42
+ * 本店实装之后才允许存在——它是一句关于本店行为的承诺,不是一个功能开关。
43
+ * `durable` 的依据:两支孪生的会话史落在 `session_meta`+`session_event` 两张真表上,进程重启后
44
+ * `acquire(id,{requireExisting:true})` 从事件日志重放(义务 1 的机器证在
45
+ * `test/session-placement-db-integration.test.ts` 的跨进程复活格)。
46
+ */
47
+ export const SQL_SESSION_PLACEMENTS = { subagent: { durability: "durable" } };
48
+ /**
49
+ * `session_meta` 上承载 placement 元组的六列,**两方言同名**(单点定义:列名手抄两份必漂,而漂掉的
50
+ * 那一支只会表现为「这台机器上 placed 行读不出来」——静默,不报错)。
51
+ * `placed_at` 是 GC 的年龄锚(core `PlacedSessionRow.placedAt`);其余五列是元组本身。
52
+ */
53
+ export const PLACEMENT_SELECT_COLUMNS = "placement_kind, placement_scope, placement_parent_session_id, placement_root_session_id, placement_handle, placed_at";
54
+ /** DATETIME/TIMESTAMPTZ 单元格 → epoch ms。两支池都返回 `Date`(mysql2 `timezone:"Z"` / node-pg 原生),
55
+ * 字符串臂是防御性回落(驱动配置若变,宁可解析一次也不要静默把 placedAt 读成 NaN)。 */
56
+ function toEpochMs(v) {
57
+ if (v == null)
58
+ return undefined;
59
+ if (v instanceof Date)
60
+ return v.getTime();
61
+ const ms = new Date(String(v)).getTime();
62
+ return Number.isFinite(ms) ? ms : undefined;
63
+ }
64
+ const asStringOrUndefined = (v) => (typeof v === "string" && v.length > 0 ? v : undefined);
65
+ /**
66
+ * 行 → 持久化的 placement 元组(`undefined` = 这是一只普通会话)。
67
+ *
68
+ * 🔴 判别位是 `placement_kind`:它在场即 placed。`placed_at` 缺失(带外 SQL 写进半只元组)时回落
69
+ * `0` 而**不是**把整只元组读成缺席——把「元组半缺」读成「没有 placement」等于让义务 2/4/6 在那一行上
70
+ * 集体失效(宿主枚举会重新列出它、release 退回租约语义);`listPlaced` 那一侧另有 `tupleIncomplete`
71
+ * 的诚实变体负责让 GC 对它 fail-closed。
72
+ */
73
+ export function placementFromRow(row) {
74
+ if (row === undefined)
75
+ return undefined;
76
+ const kind = asStringOrUndefined(row.placement_kind);
77
+ if (kind === undefined)
78
+ return undefined;
79
+ return {
80
+ kind: kind,
81
+ ...(asStringOrUndefined(row.placement_scope) !== undefined ? { scope: asStringOrUndefined(row.placement_scope) } : {}),
82
+ ...(asStringOrUndefined(row.placement_parent_session_id) !== undefined ? { parentSessionId: asStringOrUndefined(row.placement_parent_session_id) } : {}),
83
+ ...(asStringOrUndefined(row.placement_root_session_id) !== undefined ? { rootSessionId: asStringOrUndefined(row.placement_root_session_id) } : {}),
84
+ ...(asStringOrUndefined(row.placement_handle) !== undefined ? { handle: asStringOrUndefined(row.placement_handle) } : {}),
85
+ placedAt: toEpochMs(row.placed_at) ?? 0,
86
+ };
87
+ }
88
+ /**
89
+ * 行 → 一条 {@link PlacedSessionRow}(`listPlaced` 的行形)。
90
+ *
91
+ * core 契约:join 键是**完整**的 `(scope, handle)`;缺任何一半 ⇒ 返回 `tupleIncomplete` 的诚实变体,
92
+ * 消费方对它 fail-closed(永不按年龄收割)。一个「无行」与「另一个租户有行」分不清的 join 不许删东西。
93
+ */
94
+ export function placedRowFromRow(sessionId, row, lastActivityMs) {
95
+ const placedAt = toEpochMs(row.placed_at) ?? lastActivityMs ?? 0;
96
+ const scope = asStringOrUndefined(row.placement_scope);
97
+ const handle = asStringOrUndefined(row.placement_handle);
98
+ return scope !== undefined && handle !== undefined
99
+ ? { sessionId, placedAt, scope, handle }
100
+ : { sessionId, placedAt, tupleIncomplete: true };
101
+ }
102
+ /** 创建时一次落行的六个绑定值(顺序 = {@link PLACEMENT_SELECT_COLUMNS});无 placement ⇒ 六个 NULL。 */
103
+ export function placementInsertValues(placement, placedAt) {
104
+ if (placement === undefined)
105
+ return [null, null, null, null, null, null];
106
+ return [
107
+ placement.kind,
108
+ placement.scope ?? null,
109
+ placement.parentSessionId ?? null,
110
+ placement.rootSessionId ?? null,
111
+ placement.handle ?? null,
112
+ placedAt,
113
+ ];
114
+ }
115
+ /**
116
+ * 准入判据 —— **两个方向**(顶注 ①②;core `TtlSessionStore.assertPlacementAdmission` 的 SQL 镜像,
117
+ * 逐条同义,文案自铸)。
118
+ *
119
+ * @param persisted 该 id 在库里**已持久化**的元组(缺席 = 普通会话 / 无此行)。
120
+ *
121
+ * · 方向①(claim 关门):`persisted` 在场 + 非 `requireExisting` + 调用方没带创建 placement ⇒ 拒。
122
+ * `requireExisting`(读/复活形,义务 5)与「创建调用及其幂等重取」(带 placement 的可信内部链)
123
+ * 是仅有的两个合法形。placement 不可变意味着带 placement 的参数**永远重写不了**任何东西——持久化
124
+ * 的那一份恒赢,所以放行它不开任何后门。
125
+ * · 方向②(镜像/squat):带 placement 的创建形回读到**缺席**或**异组**元组 ⇒ 拒。缺席 = 并发的
126
+ * claim 形抢先建出了普通会话(或带外写把元组抹了);异组 = 同 id 上坐着另一条委派链的转录。
127
+ * 两者都不许让「声明了 durable 分区」这句话在静默中变成谎。
128
+ */
129
+ export function assertPlacementAdmission(sessionId, persisted, opts) {
130
+ if (opts?.requireExisting === true)
131
+ return; // 读/复活形:义务 5 的合法入口,两方向都不判
132
+ if (persisted !== undefined && opts?.placement === undefined) {
133
+ throw new SessionError("placement_refused", `session ${sessionId} belongs to the ${persisted.kind} placement partition — a claim-form acquire cannot attach to it (read it with requireExisting, or address the agent through its a* handle)`);
134
+ }
135
+ if (opts?.placement === undefined)
136
+ return;
137
+ const want = opts.placement;
138
+ if (persisted === undefined) {
139
+ throw new SessionError("placement_refused", `session ${sessionId} exists OUTSIDE the ${want.kind} placement partition it was declared into — refusing to run a subagent transcript on it (a concurrent claim-form create won this id, or the tuple was written away out of band)`);
140
+ }
141
+ if (persisted.kind !== want.kind || persisted.scope !== want.scope || persisted.handle !== want.handle) {
142
+ throw new SessionError("placement_refused", `session ${sessionId}'s persisted placement does not match the declared one (persisted ${persisted.kind}/${persisted.scope ?? "-"}/${persisted.handle ?? "-"} vs declared ${want.kind}/${want.scope ?? "-"}/${want.handle ?? "-"}) — refusing a cross-tuple attach`);
143
+ }
144
+ }
145
+ /**
146
+ * 一次 `listPlaced` 最多带回的行数。
147
+ *
148
+ * core 契约不设上界,但两支孪生面对的是一张可以很大的真表:无界 SELECT 会让一次 reap tick 把整个
149
+ * placed 分区拉进内存。行按**年龄升序**(最老先出)⇒ 截断的语义是「本 tick 先收最老的一批,下一 tick
150
+ * 接着收」,而不是「有些行永远看不见」。判据消费方(core 的分区收割腿)本就是每 tick 重新枚举的。
151
+ */
152
+ export const LIST_PLACED_BATCH = 1000;
153
+ //# sourceMappingURL=session-placement.js.map
@@ -57,7 +57,13 @@ export async function ensureSessionDurable(hostStore, sessionId) {
57
57
  throw new Error(`session "${sessionId}" exists in the durable store but is empty (no leaf entry) — likely a claim-create shell row, not real durable content`);
58
58
  }
59
59
  finally {
60
- await hostStore.release(acquired.sessionId);
60
+ // 🔴 #292 C18(core 5.48.0 [4743] 点名义务①,**preflight/探活法**):这是一次「只为验存在」的
61
+ // acquire ⇒ 收尾必须是 `forget`(丢暖引用),**绝不能**是 `release`。core 5.48 起
62
+ // `release(placedId)` 被重定义为**真删除**,而本函数正是 park 前的探活腿:一条跨重启 parked 的
63
+ // 子代转录在这里被 release 一次就等于**删掉转录 + 烧掉那次审批**(core 发车前重扫的 P1 同款撞点,
64
+ // 四域复现)。缺 `forget` 面 ⇒ 什么都不做(core B-17 判例:绝不代之以 release);两支 SQL 孪生与
65
+ // 包装店都实装了 forget,所以生产路径上这一步是真的丢引用,不是 no-op。
66
+ await hostStore.forget?.(acquired.sessionId);
61
67
  }
62
68
  }
63
69
  /**
@@ -5,12 +5,17 @@
5
5
  * `maxCostUsd` gate). `scope_key` is opaque (principal today; team/feature later — design/30 N3).
6
6
  */
7
7
  import type { Pool } from "mysql2/promise";
8
- import type { QuotaDecision, QuotaTracker } from "../observability/cost-quota.js";
8
+ import type { QuotaDecision, QuotaLimits, QuotaTracker } from "../observability/cost-quota.js";
9
9
  import { type CounterDegradeHook } from "./write-behind-counter.js";
10
10
  export declare class TiDBCostQuota implements QuotaTracker {
11
- private readonly limitMicroUsd;
12
11
  private readonly counter;
12
+ /** #322:限额=比较参数(行里只有花费);窗长换代经 counter.setWindowMs(桶键语义见那里)。 */
13
+ private limitMicroUsd;
14
+ private refreshRequested;
15
+ private refreshIntervalMs;
13
16
  constructor(pool: Pool, limitMicroUsd: number, windowMs: number, now?: () => number, onDegraded?: CounterDegradeHook, queryTimeoutMs?: number);
17
+ setLimits(next: QuotaLimits): void;
18
+ private syncRefresh;
14
19
  check(scopeKey: string): QuotaDecision;
15
20
  add(scopeKey: string, micro: number): void;
16
21
  flush(): Promise<void>;