@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
@@ -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>;
@@ -1,17 +1,38 @@
1
1
  import { WriteBehindCounter } from "./write-behind-counter.js";
2
2
  export class TiDBCostQuota {
3
- limitMicroUsd;
4
3
  counter;
4
+ /** #322:限额=比较参数(行里只有花费);窗长换代经 counter.setWindowMs(桶键语义见那里)。 */
5
+ limitMicroUsd;
6
+ refreshRequested = false;
7
+ refreshIntervalMs;
5
8
  constructor(pool, limitMicroUsd, windowMs, now, onDegraded, queryTimeoutMs) {
6
9
  this.limitMicroUsd = limitMicroUsd;
7
10
  this.counter = new WriteBehindCounter(pool, { table: "cost_quota", keyCol: "scope_key", valCol: "micro_usd" }, windowMs, now, onDegraded, queryTimeoutMs);
8
11
  }
12
+ setLimits(next) {
13
+ this.limitMicroUsd = next.limitMicroUsd;
14
+ this.counter.setWindowMs(next.windowMs);
15
+ this.syncRefresh();
16
+ }
17
+ syncRefresh() {
18
+ if (!this.refreshRequested)
19
+ return;
20
+ if (this.limitMicroUsd > 0)
21
+ this.counter.startRefresh(this.refreshIntervalMs);
22
+ else
23
+ this.counter.stop();
24
+ }
9
25
  check(scopeKey) {
26
+ // 关断哨兵:零 counter 访问、零 SQL(恒构造的代价必须是 0)。
27
+ if (this.limitMicroUsd <= 0)
28
+ return { allowed: true, usedMicroUsd: 0, limitMicroUsd: 0, retryAfterSec: 0 };
10
29
  const used = this.counter.used(scopeKey);
11
30
  const allowed = used < this.limitMicroUsd;
12
31
  return { allowed, usedMicroUsd: used, limitMicroUsd: this.limitMicroUsd, retryAfterSec: allowed ? 0 : this.counter.retryAfterSec() };
13
32
  }
14
33
  add(scopeKey, micro) {
34
+ if (this.limitMicroUsd <= 0)
35
+ return; // off ⇒ 不攒本地增量(也就没有要 flush 的东西)
15
36
  this.counter.addLocal(scopeKey, micro);
16
37
  }
17
38
  // write-behind lifecycle (delegated; exposed for the reaper/tests)
@@ -25,10 +46,13 @@ export class TiDBCostQuota {
25
46
  return this.counter.reap();
26
47
  }
27
48
  startRefresh(intervalMs) {
28
- this.counter.startRefresh(intervalMs);
49
+ this.refreshRequested = true;
50
+ this.refreshIntervalMs = intervalMs;
51
+ this.syncRefresh();
29
52
  return this;
30
53
  }
31
54
  stop() {
55
+ this.refreshRequested = false;
32
56
  this.counter.stop();
33
57
  }
34
58
  }
@@ -77,8 +77,22 @@ export const SCHEMA_STATEMENTS = [
77
77
  -- title: auto-generated session title (cheap-model one-liner at first submit; write-once via
78
78
  -- setTitleIfNull — the user-facing RENAME layer lives BFF-side as a label overlay, not here).
79
79
  title VARCHAR(120) NULL,
80
+ -- #292 C18(core 5.48.0 design/323)subagent 转录持久化 —— placement 元组,**创建时一次落行、
81
+ -- first-write 不可变**(register 的 ON DUPLICATE 臂永不重写这六列)。判别位 = placement_kind:
82
+ -- 在场 ⇒ 该会话住在 placement 分区里(不上宿主枚举、claim 形 acquire 恒拒、release=真删除、
83
+ -- 留存视界腿不碰)。scope/handle 是与 background_agent 行的 join 键(两列宽度对齐那张表的
84
+ -- VARCHAR(190),join 两侧不许一宽一窄);placed_at 是分区 GC 的年龄锚。语义与六条义务的完整
85
+ -- 读法见 src/plugins/session-placement.ts 顶注。
86
+ placement_kind VARCHAR(32) NULL,
87
+ placement_scope VARCHAR(190) NULL,
88
+ placement_parent_session_id VARCHAR(190) NULL,
89
+ placement_root_session_id VARCHAR(190) NULL,
90
+ placement_handle VARCHAR(190) NULL,
91
+ placed_at DATETIME(3) NULL,
80
92
  PRIMARY KEY (session_id),
81
- KEY idx_session_meta_owner (owner)
93
+ KEY idx_session_meta_owner (owner),
94
+ -- listPlaced 的检索腿(kind + 租户 + 年龄升序);普通会话这三列全 NULL ⇒ 不占索引选择性。
95
+ KEY idx_session_meta_placement (placement_kind, placement_scope, placed_at)
82
96
  ) COLLATE utf8mb4_bin`,
83
97
  `CREATE TABLE IF NOT EXISTS session_event (
84
98
  session_id VARCHAR(64) NOT NULL,
@@ -9,12 +9,19 @@
9
9
  * write-behind — same trade as the cross-replica circuit breaker and cost quota.
10
10
  */
11
11
  import type { Pool } from "mysql2/promise";
12
- import type { RateDecision, RateGate } from "../observability/rate-limit.js";
12
+ import type { RateDecision, RateGate, RateLimits } from "../observability/rate-limit.js";
13
13
  import { type CounterDegradeHook } from "./write-behind-counter.js";
14
14
  export declare class TiDBRateLimiter implements RateGate {
15
- private readonly limit;
16
15
  private readonly counter;
16
+ /** 🔴 #322:限额是比较参数、不是行状态(SQL 腿的行里只有计数),所以换代只动它,库里一行都不用碰。 */
17
+ private limit;
18
+ /** 装配点表达过的运维意图(「这条腿该有刷新环」)。关断哨兵靠它把环真正停掉/重开 —— 0 态零 SQL。 */
19
+ private refreshRequested;
20
+ private refreshIntervalMs;
17
21
  constructor(pool: Pool, limit: number, windowMs?: number, now?: () => number, onDegraded?: CounterDegradeHook, queryTimeoutMs?: number);
22
+ setLimits(next: RateLimits): void;
23
+ /** off ⇒ 停环(零 SQL);on 且装配点要过环 ⇒ 起环(幂等,counter 内部 `??=`)。 */
24
+ private syncRefresh;
18
25
  check(key: string): RateDecision;
19
26
  /** The flush loop self-reaps old windows; the periodic sweeper has nothing to do for this backend. */
20
27
  sweep(): void;
@@ -1,11 +1,28 @@
1
1
  import { WriteBehindCounter } from "./write-behind-counter.js";
2
2
  export class TiDBRateLimiter {
3
- limit;
4
3
  counter;
4
+ /** 🔴 #322:限额是比较参数、不是行状态(SQL 腿的行里只有计数),所以换代只动它,库里一行都不用碰。 */
5
+ limit;
6
+ /** 装配点表达过的运维意图(「这条腿该有刷新环」)。关断哨兵靠它把环真正停掉/重开 —— 0 态零 SQL。 */
7
+ refreshRequested = false;
8
+ refreshIntervalMs;
5
9
  constructor(pool, limit, windowMs = 60_000, now, onDegraded, queryTimeoutMs) {
6
10
  this.limit = limit;
7
11
  this.counter = new WriteBehindCounter(pool, { table: "rate_limit", keyCol: "limit_key", valCol: "hits" }, windowMs, now, onDegraded, queryTimeoutMs);
8
12
  }
13
+ setLimits(next) {
14
+ this.limit = next.rateLimitPerMin;
15
+ this.syncRefresh();
16
+ }
17
+ /** off ⇒ 停环(零 SQL);on 且装配点要过环 ⇒ 起环(幂等,counter 内部 `??=`)。 */
18
+ syncRefresh() {
19
+ if (!this.refreshRequested)
20
+ return;
21
+ if (this.limit > 0)
22
+ this.counter.startRefresh(this.refreshIntervalMs);
23
+ else
24
+ this.counter.stop();
25
+ }
9
26
  check(key) {
10
27
  if (this.limit <= 0)
11
28
  return { allowed: true, retryAfterSec: 0, remaining: Number.POSITIVE_INFINITY };
@@ -27,10 +44,13 @@ export class TiDBRateLimiter {
27
44
  return this.counter.refresh();
28
45
  }
29
46
  startRefresh(intervalMs) {
30
- this.counter.startRefresh(intervalMs);
47
+ this.refreshRequested = true;
48
+ this.refreshIntervalMs = intervalMs;
49
+ this.syncRefresh(); // 关断态下**不起环**:恒构造的代价必须是零 SQL,否则「默认部署」白付一条后台环
31
50
  return this;
32
51
  }
33
52
  stop() {
53
+ this.refreshRequested = false;
34
54
  this.counter.stop();
35
55
  }
36
56
  }
@@ -2,6 +2,7 @@ import { emitLeafAdvance } from "../session-leaf-bus.js";
2
2
  import { BaseSessionStorage, SessionError, leafIdAfterEntry, normalizePromptEpoch, } from "@sema-agent/core";
3
3
  import { toIso } from "./sql-row-helpers.js";
4
4
  import { isMysqlDupKeyError } from "./sql-errors.js";
5
+ import { PLACEMENT_SELECT_COLUMNS, placementFromRow } from "./session-placement.js";
5
6
  /** 判据属主 = `sql-errors.ts`(A-032 P1-①;旧形只认 `code`,只带 errno 的驱动错误漏判)。 */
6
7
  const isDupKey = isMysqlDupKeyError;
7
8
  /**
@@ -80,14 +81,21 @@ export class TiDBSessionStorage extends BaseSessionStorage {
80
81
  // actors drive the same session concurrently, which the task_active session lock already
81
82
  // serializes; the persist path then re-CASes leaf_id/leaf_seq, so a stale wake loses cleanly there. A
82
83
  // single-snapshot read would need a repeatable-read txn per wake — not worth it for this guard rail.
83
- const [metaRows] = await pool.query("SELECT created_at, leaf_id, leaf_seq FROM session_meta WHERE session_id = ?", [sessionId]);
84
+ const [metaRows] = await pool.query(
85
+ // #292 C18:placement 六列**捎带**在这条既有的 meta SELECT 上(不另开一次往返)——准入判据
86
+ // (claim 关门 / 镜像 squat 判)读的就是这一份持久化元组,而 acquire 的热路径不该为它多付一次 RTT。
87
+ `SELECT created_at, leaf_id, leaf_seq, ${PLACEMENT_SELECT_COLUMNS} FROM session_meta WHERE session_id = ?`, [sessionId]);
84
88
  const meta = metaRows[0];
85
89
  if (!meta)
86
90
  return null;
87
91
  const { floorEntryId, floorSeq } = await TiDBSessionStorage.computeFloor(pool, sessionId);
88
92
  const [eventRows] = await pool.query("SELECT payload FROM session_event WHERE session_id = ? AND seq >= ? ORDER BY seq ASC", [sessionId, floorSeq]);
89
93
  const entries = eventRows.map((r) => typeof r.payload === "string" ? JSON.parse(r.payload) : r.payload);
90
- const metadata = { id: sessionId, createdAt: toIso(meta.created_at) };
94
+ // #292 C18:持久化的 placement 元组骑在 core 5.48 的 `SessionMetadata.placement` 可选键上
95
+ // (普通会话不铸这个键)——于是「这只会话是不是 placed」对每一个拿到 session 的消费方都是**同一份**
96
+ // 事实,不需要谁再去猜或另查一次。
97
+ const placement = placementFromRow(meta);
98
+ const metadata = { id: sessionId, createdAt: toIso(meta.created_at), ...(placement !== undefined ? { placement } : {}) };
91
99
  return new TiDBSessionStorage(pool, sessionId, metadata, entries, meta.leaf_id ?? null, Number(meta.leaf_seq), floorEntryId, floorSeq);
92
100
  }
93
101
  /** The bounded-window floor = last compaction's firstKeptEntryId (and its seq). */
@@ -10,24 +10,50 @@
10
10
  * Persistence only. **Tenant authorization (who may wake which session) lives in the service layer**
11
11
  * (`src/security.ts`), which uses `ownerOf` / `register` below. The store does not infer identity.
12
12
  */
13
- import { type AcquiredSession, type SessionStore, type SessionTreeEntry } from "@sema-agent/core";
13
+ import { type AcquiredSession, type PlacedSessionRow, type SessionPlacement, type SessionPlacementRecord, type SessionStore, type SessionTreeEntry } from "@sema-agent/core";
14
14
  import type { Pool } from "mysql2/promise";
15
15
  import type { SessionSummary } from "./store-contracts.js";
16
+ import { type PlacementAcquireOpts } from "./session-placement.js";
16
17
  import { type StagingHandle } from "../session-sync-kernel.js";
17
18
  export declare class TiDBSessionStore implements SessionStore {
18
19
  private readonly pool;
19
20
  /** #270 车1:托管留存声明。读法与「为什么 SQL 店答 managed 而实现体在别处」逐字见
20
21
  * `retention-store-sql.ts` 的 {@link MANAGED_RETENTION}。 */
21
22
  readonly retention: import("@sema-agent/core").RetentionDeclaration;
23
+ /** #292 C18(core 5.48.0 design/323):placement **声明**。六条义务的实装点逐条见
24
+ * {@link SQL_SESSION_PLACEMENTS} 顶注与本类的 acquire / listSessions / release / listPlaced /
25
+ * placementOf 各处的 `#292` 注。 */
26
+ readonly placements: {
27
+ readonly subagent: {
28
+ readonly durability: "durable";
29
+ };
30
+ };
22
31
  /** In-flight acquisitions keyed by id, so concurrent acquire(sameId) in one process share one. */
23
32
  private readonly pending;
24
33
  constructor(pool: Pool);
25
- acquire(sessionId?: string, opts?: {
26
- requireExisting?: boolean;
27
- }): Promise<AcquiredSession>;
34
+ acquire(sessionId?: string, opts?: PlacementAcquireOpts): Promise<AcquiredSession>;
28
35
  private load;
29
- /** Idempotently create a session_meta row with an owner. Existing owner is never overwritten. */
30
- register(sessionId: string, owner: string | null): Promise<void>;
36
+ /** Idempotently create a session_meta row with an owner. Existing owner is never overwritten.
37
+ * #292:`placement` 在场时与行同一条 INSERT 落六列;ON DUPLICATE 臂是 no-op ⇒ 已存在的行的元组
38
+ * **永不被重写**(first-write 不可变,义务 5 的写侧半场)。 */
39
+ register(sessionId: string, owner: string | null, placement?: SessionPlacement): Promise<void>;
40
+ /**
41
+ * #292 义务 6 的廉价探针 —— 一行 PK SELECT:`undefined` = 普通会话或无此行。
42
+ * (release 的冷缓存腿、包装店的暖命中准入判、诊断面共用这一只。)
43
+ */
44
+ placementOf(sessionId: string): Promise<SessionPlacementRecord | undefined>;
45
+ /**
46
+ * #292 义务 5 —— placed 分区的**自有枚举**(联合 reap 的分区腿输入)。
47
+ *
48
+ * 年龄锚 = `COALESCE(updated_at, placed_at)`:`updated_at` 是本店的「最后活动」标记(register 时置、
49
+ * 每次 append/touch 推进),取不到才回落 `placed_at` —— 与 core File 店「mtime 优先、placedAt 回落」
50
+ * 的读法同义。行按年龄升序、上界 {@link LIST_PLACED_BATCH}(理由见那里)。
51
+ * join 半缺(scope/handle 任一为 NULL)⇒ `tupleIncomplete` 诚实变体,消费方对它 fail-closed。
52
+ */
53
+ listPlaced(kind: "subagent", opts?: {
54
+ olderThanMs?: number;
55
+ scope?: string;
56
+ }): Promise<PlacedSessionRow[]>;
31
57
  /** Owner principal of a session, `null` if registered ownerless, `undefined` if no such session. */
32
58
  ownerOf(sessionId: string): Promise<string | null | undefined>;
33
59
  touch(sessionId: string): Promise<void>;
@@ -42,8 +68,28 @@ export declare class TiDBSessionStore implements SessionStore {
42
68
  owner: string | null;
43
69
  leafId: string | null;
44
70
  } | undefined>;
45
- /** Durable history is retained; just drop any in-flight cache entry. */
71
+ /**
72
+ * Durable history is retained; just drop any in-flight cache entry.
73
+ *
74
+ * 🔴 #292 义务 4 —— **placed 会话例外:release 是真删除**(durable 史一并)。联合 reap 先按 rev 条件删
75
+ * 掉 agent 行、赢了才调本方法终结转录;若这里还按「归还租约」走,一条 30 天的转录就会失去属主行地
76
+ * 悬着(正是这条义务要关死的 strand)。判据取**持久化元组**(冷缓存/重启后照样成立,不依赖任何进程
77
+ * 内状态);删除本身是 ABA 安全的**条件删**(`placement_kind IS NOT NULL`)——探针与删除之间若真有
78
+ * 「行被删掉又以同 id 重建成普通会话」的世代翻转,条件删当场落空,不会误杀那只无辜的新会话。
79
+ * 删除失败**响亮上浮**(`SessionError("storage")`):吞掉失败等于让 reap 数一条「已终结」而字节还在
80
+ * (core File 店同批的 rmSync 失败上浮是同一条法)。
81
+ */
46
82
  release(sessionId: string): Promise<void>;
83
+ /**
84
+ * core B-17 的 `forget` 面 —— **丢引用,不动 durable 史**(与 {@link release} 的分工:release 是
85
+ * 生命周期终结口,forget 是「我用完了这只句柄」)。#292 之后这条分工是承重的:release 对 placed
86
+ * 会话已是真删除,于是每一处「只为验存在 / 写完收工」的收尾都必须落在 forget 上(preflight 纪律,
87
+ * [4743] 义务①)。本店的进程内状态只有 in-flight 去重表,故实现就是丢那一格。
88
+ */
89
+ forget(sessionId: string): void;
90
+ /** 真删除一只 placed 会话的两张表(条件删门在 `placement_kind IS NOT NULL`;事务内 meta 先删=提交点,
91
+ * 与 {@link deleteSession} 同序)。任何 I/O 失败上浮成 `SessionError("storage")`,绝不吞。 */
92
+ private deletePlacedSession;
47
93
  /**
48
94
  * E16 — list the caller's sessions (CC /resume picker), keyset-paginated newest-first by last activity.
49
95
  * Session IDENTITY/enumeration comes from `session_meta` (owner-filtered on the service's OWN owner column);
@@ -159,6 +205,8 @@ export declare class TiDBSessionStore implements SessionStore {
159
205
  * atomic swap) or aborts. No row is touched here — the handle is a stateless cursor over this pool.
160
206
  */
161
207
  beginImportStaging(realSessionId: string, token: string): StagingHandle;
208
+ /** #292:「这个 id 是 placed ⇒ 该写路径不适用」的共用拒绝(session-sync 的两条换装腿共用)。 */
209
+ private assertNotPlaced;
162
210
  /**
163
211
  * 2c session-sync P1d-β (staged-row inspection) — read the rows STAGED under `stagingId` (NO session_meta probe — a
164
212
  * staging id has no meta row by construction, so {@link exportEntries} would return null), oldest-first. The Phase-B