@sema-agent/server 7.124.1 → 7.125.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 (152) hide show
  1. package/MIGRATION.md +138 -2
  2. package/USAGE.md +46 -8
  3. package/dist/audit.d.ts +27 -5
  4. package/dist/audit.js +42 -8
  5. package/dist/boot/execution-env.d.ts +33 -1
  6. package/dist/boot/execution-env.js +31 -2
  7. package/dist/boot/resolve-spec.js +4 -3
  8. package/dist/boot/session-faces.d.ts +14 -1
  9. package/dist/boot/session-faces.js +6 -5
  10. package/dist/boot/stage-02-web-admin-pull.js +5 -1
  11. package/dist/boot/stage-05-execution-env.js +6 -2
  12. package/dist/boot/stage-09-leader.d.ts +1 -1
  13. package/dist/boot/stage-10-http-server.d.ts +1 -1
  14. package/dist/boot/web-admin.js +130 -140
  15. package/dist/capabilities/center-wiring.d.ts +2 -1
  16. package/dist/capabilities/composite-members.d.ts +1 -0
  17. package/dist/capabilities/composite-members.js +1 -0
  18. package/dist/capability-facts.d.ts +28 -3
  19. package/dist/capability-facts.js +20 -0
  20. package/dist/config-catalog.js +4 -1
  21. package/dist/config-d-migration.js +2 -1
  22. package/dist/config-lkg.d.ts +4 -1
  23. package/dist/config-lkg.js +16 -12
  24. package/dist/config-provider.d.ts +4 -1
  25. package/dist/config-provider.js +1 -0
  26. package/dist/config-types.d.ts +10 -3
  27. package/dist/config.js +44 -14
  28. package/dist/env-facts.d.ts +2 -2
  29. package/dist/env-facts.js +3 -2
  30. package/dist/fleet/fleet-bus.d.ts +24 -12
  31. package/dist/fleet/fleet-bus.js +4 -1
  32. package/dist/fleet/workspace-snapshot-residual.d.ts +10 -0
  33. package/dist/fleet/workspace-snapshot-residual.js +35 -0
  34. package/dist/hooks/hook-runner.d.ts +15 -27
  35. package/dist/hooks/hook-runner.js +19 -59
  36. package/dist/http/active-run-conflict.d.ts +3 -0
  37. package/dist/http/active-run-conflict.js +3 -0
  38. package/dist/http/admission.js +7 -7
  39. package/dist/http/approval-sweeps.js +2 -4
  40. package/dist/http/device-placement.js +1 -0
  41. package/dist/http/dispatch.js +1 -1
  42. package/dist/http/durable-run-leg.d.ts +20 -6
  43. package/dist/http/durable-run-leg.js +4 -4
  44. package/dist/http/idempotency.d.ts +83 -38
  45. package/dist/http/idempotency.js +38 -13
  46. package/dist/http/request-guards.d.ts +18 -0
  47. package/dist/http/request-guards.js +12 -3
  48. package/dist/http/resume-legs.js +7 -1
  49. package/dist/http/route-ctx.d.ts +3 -3
  50. package/dist/http/route-ctx.js +1 -1
  51. package/dist/http/routes/a2a-serve.js +27 -11
  52. package/dist/http/routes/capabilities.js +1 -0
  53. package/dist/http/routes/fleet.js +2 -0
  54. package/dist/http/routes/images.js +2 -2
  55. package/dist/http/routes/leader.d.ts +4 -0
  56. package/dist/http/routes/leader.js +13 -3
  57. package/dist/http/routes/observability.js +2 -0
  58. package/dist/http/routes/runs.d.ts +6 -2
  59. package/dist/http/routes/runs.js +71 -101
  60. package/dist/http/routes/session-sync.d.ts +3 -0
  61. package/dist/http/routes/session-sync.js +30 -1
  62. package/dist/http/routes/sessions-list.d.ts +11 -0
  63. package/dist/http/routes/sessions-list.js +6 -6
  64. package/dist/http/routes/sessions.d.ts +6 -1
  65. package/dist/http/routes/sessions.js +41 -10
  66. package/dist/http/routes/tasks.js +63 -21
  67. package/dist/http/run-read-face.d.ts +26 -0
  68. package/dist/http/run-read-face.js +67 -0
  69. package/dist/http/server.d.ts +4 -4
  70. package/dist/http/server.js +1 -2
  71. package/dist/http/submit-replay.d.ts +40 -0
  72. package/dist/http/submit-replay.js +22 -0
  73. package/dist/leader/endpoint.d.ts +10 -5
  74. package/dist/leader/endpoint.js +27 -5
  75. package/dist/observability/cost-quota.d.ts +51 -7
  76. package/dist/observability/cost-quota.js +35 -17
  77. package/dist/observability/fail-open.d.ts +1 -1
  78. package/dist/observability/fail-open.js +1 -1
  79. package/dist/observability/run-terminal.d.ts +5 -9
  80. package/dist/observability/run-terminal.js +2 -7
  81. package/dist/plugins/approval-ask-store-sql.js +1 -1
  82. package/dist/plugins/caching-session-store.d.ts +7 -2
  83. package/dist/plugins/caching-session-store.js +6 -0
  84. package/dist/plugins/device-store-sql.js +1 -1
  85. package/dist/plugins/ensure-index.js +22 -0
  86. package/dist/plugins/file-run-store.d.ts +24 -14
  87. package/dist/plugins/file-run-store.js +52 -12
  88. package/dist/plugins/leader-run-store-sql.d.ts +1 -11
  89. package/dist/plugins/leader-run-store-sql.js +1 -5
  90. package/dist/plugins/local-session-store.d.ts +35 -6
  91. package/dist/plugins/local-session-store.js +87 -18
  92. package/dist/plugins/memory-run-store.d.ts +13 -12
  93. package/dist/plugins/memory-run-store.js +25 -0
  94. package/dist/plugins/one-time-migrations.js +1 -1
  95. package/dist/plugins/pg-cost-quota.d.ts +1 -1
  96. package/dist/plugins/pg-cost-quota.js +2 -5
  97. package/dist/plugins/pg-pool.js +3 -0
  98. package/dist/plugins/pg-session-storage.d.ts +11 -1
  99. package/dist/plugins/pg-session-storage.js +44 -8
  100. package/dist/plugins/remote-env-k8s.d.ts +127 -12
  101. package/dist/plugins/remote-env-k8s.js +270 -27
  102. package/dist/plugins/run-store-sql.d.ts +26 -12
  103. package/dist/plugins/run-store-sql.js +50 -25
  104. package/dist/plugins/schema-consistency.d.ts +1 -13
  105. package/dist/plugins/schema-consistency.js +1 -41
  106. package/dist/plugins/schema-indexes.js +1 -0
  107. package/dist/plugins/schema-live-columns.d.ts +20 -0
  108. package/dist/plugins/schema-live-columns.js +42 -0
  109. package/dist/plugins/store-contracts.d.ts +40 -2
  110. package/dist/plugins/tidb-cost-quota.d.ts +1 -1
  111. package/dist/plugins/tidb-cost-quota.js +2 -5
  112. package/dist/plugins/tidb-pool.js +14 -3
  113. package/dist/plugins/tidb-session-store.d.ts +22 -2
  114. package/dist/plugins/tidb-session-store.js +33 -6
  115. package/dist/plugins/write-behind-counter.d.ts +12 -5
  116. package/dist/plugins/write-behind-counter.js +14 -7
  117. package/dist/prompts-domain-validate.js +3 -2
  118. package/dist/rules-consent.d.ts +2 -2
  119. package/dist/rules-consent.js +2 -2
  120. package/dist/run-local.js +31 -23
  121. package/dist/runs.d.ts +9 -0
  122. package/dist/runs.js +8 -0
  123. package/dist/runtime-governance.d.ts +3 -3
  124. package/dist/runtime-governance.js +2 -4
  125. package/dist/security.d.ts +19 -2
  126. package/dist/session-row.d.ts +53 -0
  127. package/dist/session-row.js +47 -0
  128. package/dist/session-sync.d.ts +6 -0
  129. package/dist/session-sync.js +5 -1
  130. package/dist/session-titler.d.ts +9 -5
  131. package/dist/session-titler.js +3 -1
  132. package/dist/spec-fields.js +2 -2
  133. package/dist/task-cwd.d.ts +0 -20
  134. package/dist/task-cwd.js +2 -10
  135. package/dist/tool-approval.d.ts +5 -7
  136. package/dist/trace/redact.d.ts +23 -50
  137. package/dist/trace/redact.js +42 -9
  138. package/dist/trace/self-minted-wire.d.ts +25 -0
  139. package/dist/trace/self-minted-wire.js +9 -0
  140. package/dist/web-admin-client/apply-effective.d.ts +10 -12
  141. package/dist/web-admin-client/apply-effective.js +2 -3
  142. package/dist/web-admin-client/candidate-gate.d.ts +69 -0
  143. package/dist/web-admin-client/candidate-gate.js +27 -0
  144. package/dist/web-admin-client/http-client.d.ts +2 -1
  145. package/dist/web-admin-client/http-client.js +2 -1
  146. package/dist/web-admin-client/pull-state.d.ts +6 -4
  147. package/dist/web-admin-client/read-warnings.d.ts +29 -5
  148. package/dist/web-admin-client/read-warnings.js +24 -4
  149. package/dist/web-admin-client/stage-limits.js +4 -1
  150. package/dist/workspace-space.d.ts +41 -0
  151. package/dist/workspace-space.js +5 -0
  152. package/package.json +3 -3
@@ -2,7 +2,7 @@ import type { TaskStatus } from "@sema-agent/core";
2
2
  import type { Pool as MySqlPool } from "mysql2/promise";
3
3
  import type { Pool as PgPool } from "pg";
4
4
  import { type UsageRow } from "../usage-analytics.js";
5
- import { type RunRecord, type SessionSummary, type RunEvent, type PersistedTaskResult, type LatestRunRef } from "./store-contracts.js";
5
+ import { type CreateRunOutcome, type NewRunMeta, type RunRecord, type SessionSummary, type RunEvent, type PersistedTaskResult, type LatestRunRef } from "./store-contracts.js";
6
6
  import { type SqlDriver } from "./sql-driver.js";
7
7
  import type { LedgerEventType } from "../trace/ledger-events.js";
8
8
  export type { RunRecord, SessionSummary, RunEvent, LatestRunRef } from "./store-contracts.js";
@@ -20,23 +20,27 @@ export declare class SqlRunStore {
20
20
  /** Run statements atomically; rolls back on failure. */
21
21
  private tx;
22
22
  /**
23
- * Create a run, but only if the session has no other active run (double-text guard).
23
+ * Create a run, but only if the session has no other active run (double-text guard) and no submission key
24
+ * of this request already names a row (submission idempotency) — **atomic win or observe**.
24
25
  *
25
26
  * Mutual exclusion is a unique-key claim on `task_active(session_id)` — NOT a SELECT…FOR UPDATE,
26
27
  * which on TiDB (no gap locks) would fail to serialize two concurrent createRuns when the session
27
28
  * has no prior row. A plain INSERT either claims the session or throws a duplicate-key error (TiDB
28
29
  * does NOT report a reliable affectedRows for `ON DUPLICATE KEY UPDATE …=…`, so we catch dup-key).
30
+ *
31
+ * Submission idempotency rides the SAME mechanism on the row itself: `task_run` carries two unique keys a
32
+ * submission can name — `task_id` (a caller-minted `body.taskId`) and `idem_key` (the scoped
33
+ * `Idempotency-Key`; NULL = none, which neither dialect's UNIQUE constrains). Claim + row are ONE
34
+ * transaction, so a concurrent same-key submit on another replica either commits first (we collide on the
35
+ * unique index — after its commit, PG/InnoDB/TiDB-pessimistic block on the uncommitted entry; TiDB-optimistic
36
+ * reports it at COMMIT, which is why the commit sits inside the same classification) or loses to us.
37
+ *
38
+ * Any dup-key ⇒ roll back (our claim goes with it) ⇒ OBSERVE, keys first: a row named by `idem_key`, else by
39
+ * `task_id` ⇒ `{ ok:false, existing }` (no new row; the submission surface renders the receipt); otherwise the
40
+ * session claim was the collision ⇒ `{ ok:false, activeTaskId }`. A dup on the ROW insert that names no
41
+ * observable row is not attributable to either key ⇒ rethrown (loud), never reported as a busy session.
29
42
  */
30
- createRun(taskId: string, sessionId: string, owner: string | null, instanceId: string, meta?: {
31
- jobId?: string | null;
32
- source?: string | null;
33
- objectivePreview?: string | null;
34
- }): Promise<{
35
- ok: true;
36
- } | {
37
- ok: false;
38
- activeTaskId: string;
39
- }>;
43
+ createRun(taskId: string, sessionId: string, owner: string | null, instanceId: string, meta?: NewRunMeta): Promise<CreateRunOutcome>;
40
44
  /** Request cancel of a RUNNING run (durable, cross-replica). Sets the flag the owning instance's heartbeat
41
45
  * tick polls; a same-replica caller additionally aborts the in-flight controller for instant effect. Only
42
46
  * flags a `running` row (terminal/suspended → 0 affected = no-op). Returns whether a running row was flagged.
@@ -134,6 +138,16 @@ export declare class SqlRunStore {
134
138
  * (今天没有消费点,所以没人发现)。列表收成一个常量 = 这类漏选从此不可能发生。 */
135
139
  private rowToRun;
136
140
  getRun(taskId: string): Promise<RunRecord | undefined>;
141
+ /** The row a submission's keys name — `idem_key` first, then `task_id` — read on the caller's (already rolled-back) connection. */
142
+ private observeSubmittedRow;
143
+ /** The run a submission's `Idempotency-Key` names (`idem_key` = the scoped key, `idemKeyOf(raw, caller, "run")`), if any.
144
+ * `uq_task_run_idem` makes it at most one row. */
145
+ getRunByIdem(idemKey: string): Promise<RunRecord | undefined>;
146
+ /** Undo THIS submission's key binding (`idem_key` → NULL) — called only where the submit surface refuses the submission
147
+ * before any work and terminalizes the row it just claimed (the sync leg's zero-work refusal, a device-placement reject):
148
+ * the error answer invites a retry, and a retry with the same key must run, not replay the refusal. The row stays (it
149
+ * is the refusal's record); only the key is freed. */
150
+ releaseIdemKey(taskId: string): Promise<void>;
137
151
  /** Task Trace list (S1): runs newest-first, keyset-paginated on (created_at, task_id). Optional status +
138
152
  * jobId filters (jobId = the work-view group, served by idx_job). Caller passes `limit+1` to detect a next
139
153
  * page. Cursor fields are bound as params (no SQL injection).
@@ -74,40 +74,48 @@ export class SqlRunStore {
74
74
  }
75
75
  }
76
76
  async createRun(taskId, sessionId, owner, instanceId, meta = {}) {
77
+ const idemKey = meta.idemKey ?? null;
77
78
  const conn = await this.db.connect();
79
+ let stage = "claim";
78
80
  try {
79
81
  await conn.begin();
80
82
  const now = new Date();
81
83
  try {
82
84
  await conn.query(this.q("INSERT INTO task_active (session_id, task_id, since) VALUES (?,?,?)", "INSERT INTO task_active (session_id, task_id, since) VALUES ($1,$2,$3)"), [sessionId, taskId, now]);
85
+ stage = "row";
86
+ await conn.query(this.q("INSERT INTO task_run (task_id, session_id, owner, status, instance_id, job_id, source, objective_preview, idem_key, created_at, updated_at) " +
87
+ "VALUES (?,?,?,'running',?,?,?,?,?,?,?)", "INSERT INTO task_run (task_id, session_id, owner, status, instance_id, job_id, source, objective_preview, idem_key, created_at, updated_at) " +
88
+ "VALUES ($1,$2,$3,'running',$4,$5,$6,$7,$8,$9,$10)"), [
89
+ taskId,
90
+ sessionId,
91
+ owner,
92
+ instanceId,
93
+ meta.jobId ?? null,
94
+ meta.source ?? null,
95
+ this.db.dialect === "tidb"
96
+ ? meta.objectivePreview ?? null
97
+ : meta.objectivePreview == null
98
+ ? null
99
+ : pgSanitizeText(meta.objectivePreview),
100
+ idemKey,
101
+ now,
102
+ now,
103
+ ]);
104
+ await conn.commit();
105
+ return { ok: true };
83
106
  }
84
107
  catch (err) {
85
- if (this.isDupKey(err)) {
86
- await conn.rollback();
87
- const { rows: active } = await conn.query(this.q("SELECT task_id FROM task_active WHERE session_id = ?", "SELECT task_id FROM task_active WHERE session_id = $1"), [sessionId]);
88
- return { ok: false, activeTaskId: String(active[0]?.task_id ?? "") };
89
- }
90
- throw err;
108
+ if (!this.isDupKey(err))
109
+ throw err;
110
+ await conn.rollback();
111
+ const existing = await this.observeSubmittedRow(conn, taskId, idemKey);
112
+ if (existing)
113
+ return { ok: false, existing };
114
+ if (stage === "row")
115
+ throw err;
116
+ const { rows: active } = await conn.query(this.q("SELECT task_id FROM task_active WHERE session_id = ?", "SELECT task_id FROM task_active WHERE session_id = $1"), [sessionId]);
117
+ return { ok: false, activeTaskId: String(active[0]?.task_id ?? "") };
91
118
  }
92
- await conn.query(this.q("INSERT INTO task_run (task_id, session_id, owner, status, instance_id, job_id, source, objective_preview, created_at, updated_at) " +
93
- "VALUES (?,?,?,'running',?,?,?,?,?,?)", "INSERT INTO task_run (task_id, session_id, owner, status, instance_id, job_id, source, objective_preview, created_at, updated_at) " +
94
- "VALUES ($1,$2,$3,'running',$4,$5,$6,$7,$8,$9)"), [
95
- taskId,
96
- sessionId,
97
- owner,
98
- instanceId,
99
- meta.jobId ?? null,
100
- meta.source ?? null,
101
- this.db.dialect === "tidb"
102
- ? meta.objectivePreview ?? null
103
- : meta.objectivePreview == null
104
- ? null
105
- : pgSanitizeText(meta.objectivePreview),
106
- now,
107
- now,
108
- ]);
109
- await conn.commit();
110
- return { ok: true };
111
119
  }
112
120
  catch (err) {
113
121
  try {
@@ -228,6 +236,22 @@ export class SqlRunStore {
228
236
  const { rows } = await this.db.query(this.q(`SELECT ${runRowCols("tidb")} FROM task_run WHERE task_id = ?`, `SELECT ${runRowCols("pg")} FROM task_run WHERE task_id = $1`), [taskId]);
229
237
  return rows[0] ? this.rowToRun(rows[0]) : undefined;
230
238
  }
239
+ async observeSubmittedRow(conn, taskId, idemKey) {
240
+ if (idemKey !== null) {
241
+ const { rows } = await conn.query(this.q(`SELECT ${runRowCols("tidb")} FROM task_run WHERE idem_key = ?`, `SELECT ${runRowCols("pg")} FROM task_run WHERE idem_key = $1`), [idemKey]);
242
+ if (rows[0])
243
+ return this.rowToRun(rows[0]);
244
+ }
245
+ const { rows } = await conn.query(this.q(`SELECT ${runRowCols("tidb")} FROM task_run WHERE task_id = ?`, `SELECT ${runRowCols("pg")} FROM task_run WHERE task_id = $1`), [taskId]);
246
+ return rows[0] ? this.rowToRun(rows[0]) : undefined;
247
+ }
248
+ async getRunByIdem(idemKey) {
249
+ const { rows } = await this.db.query(this.q(`SELECT ${runRowCols("tidb")} FROM task_run WHERE idem_key = ?`, `SELECT ${runRowCols("pg")} FROM task_run WHERE idem_key = $1`), [idemKey]);
250
+ return rows[0] ? this.rowToRun(rows[0]) : undefined;
251
+ }
252
+ async releaseIdemKey(taskId) {
253
+ await this.db.query(this.q("UPDATE task_run SET idem_key = NULL WHERE task_id = ?", "UPDATE task_run SET idem_key = NULL WHERE task_id = $1"), [taskId]);
254
+ }
231
255
  async listRuns(opts) {
232
256
  if (opts.cursor && Number.isNaN(Date.parse(opts.cursor.createdAt)))
233
257
  return [];
@@ -360,6 +384,7 @@ export class SqlRunStore {
360
384
  lastRunId: r.task_id ?? null,
361
385
  ...(typeof lastTaskRunId === "string" && lastTaskRunId !== "" ? { lastTaskRunId } : {}),
362
386
  title: null,
387
+ pinned: false,
363
388
  };
364
389
  }
365
390
  async deleteBySession(sessionId, expectedSessionOwner) {
@@ -32,6 +32,7 @@
32
32
  * `recordFailOpen` 在这条腿上**不适用** —— 它是 F 类(装饰/缓存)的登记口,不是 P 类保护臂的逃生门。
33
33
  */
34
34
  import { type SchemaDialect } from "./schema-ensure-legs.js";
35
+ import { type SchemaProbeRead } from "./schema-live-columns.js";
35
36
  /** 一列:名字 + 它在 `CREATE TABLE` 里的**原样定义文本**(生成 ADD COLUMN 指路用,不另手写)。 */
36
37
  export interface ParsedColumn {
37
38
  readonly name: string;
@@ -62,19 +63,6 @@ export interface TableMismatch {
62
63
  /** 库有、代码没有(库比代码**新** —— 滚动窗里的旧副本)。 */
63
64
  readonly ghost: readonly string[];
64
65
  }
65
- /** 探针读口:一条 SQL → 行数组(两方言由调用方各自绑)。 */
66
- export type SchemaProbeRead = (sql: string) => Promise<Array<Record<string, unknown>>>;
67
- /**
68
- * 库里实际的列集(表名 → 列名集合)。
69
- *
70
- * 🔴 **PG 侧读的是「查询真正会解析到的那个关系」,不是 `current_schema()`**(codex 对抗复审本轮 [medium]
71
- * 采纳):店里的 SQL 全是**不限定 schema** 的裸表名,它按 `search_path` **逐个** schema 解析,命中第一个;
72
- * 而 `current_schema()` 只是路径的第一段。两者不等的部署是真的存在(角色的 `search_path` 配了多段),那时
73
- * 只读第一段会让探针对「表其实住在路径后段、而且缺列」这一形**完全沉默**。⇒ 按 `current_schemas(false)`
74
- * 的**顺序**取每张表的第一次命中 —— 探针于是与执行器看同一张表。MySQL 协议没有这个概念(库 = 连接上的
75
- * `DATABASE()`),那条臂一个字不变。
76
- */
77
- export declare function readLiveColumns(read: SchemaProbeRead, dialect: SchemaDialect): Promise<Map<string, Set<string>>>;
78
66
  /** 对账(纯函数:期望 × 实况 → 不符表清单)。 */
79
67
  export declare function diffSchema(expected: Map<string, ParsedTable>, live: Map<string, Set<string>>): TableMismatch[];
80
68
  /** 拒启文案(表名 + 缺/多列 + 两条路;ADD COLUMN 句由解析结果**生成**)。 */
@@ -1,4 +1,5 @@
1
1
  import { recordEnsureStatements } from "./schema-ensure-legs.js";
2
+ import { readLiveColumns } from "./schema-live-columns.js";
2
3
  const QUOTES = new Set(["'", '"', "`"]);
3
4
  function stripLineComments(sql) {
4
5
  let out = "";
@@ -104,47 +105,6 @@ export async function expectedTableShapes(dialect) {
104
105
  return shapes;
105
106
  }
106
107
  export const SCHEMA_MISMATCH_ERROR_CODE = "config.schema_mismatch";
107
- export async function readLiveColumns(read, dialect) {
108
- const live = new Map();
109
- if (dialect !== "pg") {
110
- for (const row of await read("SELECT table_name AS t, column_name AS c FROM information_schema.columns WHERE table_schema = DATABASE()")) {
111
- addColumn(live, row["t"], row["c"], "fold");
112
- }
113
- return live;
114
- }
115
- const order = new Map();
116
- for (const row of await read("SELECT s AS s, ord AS ord FROM unnest(current_schemas(false)) WITH ORDINALITY AS t(s, ord)")) {
117
- const s = String(row["s"] ?? "");
118
- if (s !== "")
119
- order.set(s, Number(row["ord"]));
120
- }
121
- const winner = new Map();
122
- for (const row of await read("SELECT table_schema AS s, table_name AS t, column_name AS c FROM information_schema.columns WHERE table_schema = ANY (current_schemas(false))")) {
123
- const t = String(row["t"] ?? "");
124
- const rank = order.get(String(row["s"] ?? "")) ?? Number.MAX_SAFE_INTEGER;
125
- const held = winner.get(t);
126
- if (held !== undefined && rank > held)
127
- continue;
128
- if (held === undefined || rank < held) {
129
- winner.set(t, rank);
130
- live.delete(t);
131
- }
132
- addColumn(live, t, row["c"], "verbatim");
133
- }
134
- return live;
135
- }
136
- function addColumn(live, table, column, mode) {
137
- const t = mode === "fold" ? String(table ?? "").toLowerCase() : String(table ?? "");
138
- const c = mode === "fold" ? String(column ?? "").toLowerCase() : String(column ?? "");
139
- if (t === "" || c === "")
140
- return;
141
- let set = live.get(t);
142
- if (set === undefined) {
143
- set = new Set();
144
- live.set(t, set);
145
- }
146
- set.add(c);
147
- }
148
108
  export function diffSchema(expected, live) {
149
109
  const out = [];
150
110
  for (const [table, shape] of expected) {
@@ -3,6 +3,7 @@ export const CENTRAL_SCHEMA_INDEXES = [
3
3
  { table: "task_run", name: "idx_task_run_owner", columns: ["owner"] },
4
4
  { table: "task_run", name: "idx_task_run_created", columns: ["created_at"] },
5
5
  { table: "task_run", name: "idx_task_run_job", columns: ["job_id"] },
6
+ { table: "task_run", name: "uq_task_run_idem", columns: ["idem_key"], unique: true },
6
7
  { table: "task_active", name: "idx_task_active_task", columns: ["task_id"] },
7
8
  { table: "checkpoint", name: "idx_checkpoint_status_deadline", columns: ["status", "deadline"] },
8
9
  { table: "checkpoint", name: "idx_checkpoint_session_status", columns: ["session_id", "status"] },
@@ -0,0 +1,20 @@
1
+ /**
2
+ * 库里**实际**的列集 —— 唯一读者(S-547 列册探针 `schema-consistency.ts` 的对账读它;`ensure-index.ts` 的索引补建在失败之后判「是不是这张表
3
+ * 缺这条索引的列」也读它)。叶子模块:不值依赖任何模块(列册探针那边经录制驱动 / 建表腿值依赖 `ensure-index.ts`,读者若住在那边,
4
+ * `ensure-index.ts` 读它就是一个运行时环)。方言词是 `ensure-index.ts` 的闭集(类型边,擦除)。
5
+ */
6
+ import type { SqlDialect } from "./ensure-index.js";
7
+ /** 探针读口:一条 SQL → 行数组(两方言由调用方各自绑)。 */
8
+ export type SchemaProbeRead = (sql: string) => Promise<Array<Record<string, unknown>>>;
9
+ /**
10
+ * 库里实际的列集(表名 → 列名集合)。
11
+ *
12
+ * 🔴 **PG 侧读的是「查询真正会解析到的那个关系」,不是 `current_schema()`**(codex 对抗复审本轮 [medium]
13
+ * 采纳):店里的 SQL 全是**不限定 schema** 的裸表名,它按 `search_path` **逐个** schema 解析,命中第一个;
14
+ * 而 `current_schema()` 只是路径的第一段。两者不等的部署是真的存在(角色的 `search_path` 配了多段),那时
15
+ * 只读第一段会让探针对「表其实住在路径后段、而且缺列」这一形**完全沉默**。⇒ 按 `current_schemas(false)`
16
+ * 的**顺序**取每张表的第一次命中 —— 探针于是与执行器看同一张表。MySQL 协议没有这个概念(库 = 连接上的
17
+ * `DATABASE()`),那条臂一个字不变。
18
+ */
19
+ export declare function readLiveColumns(read: SchemaProbeRead, dialect: SqlDialect): Promise<Map<string, Set<string>>>;
20
+ //# sourceMappingURL=schema-live-columns.d.ts.map
@@ -0,0 +1,42 @@
1
+ export async function readLiveColumns(read, dialect) {
2
+ const live = new Map();
3
+ if (dialect !== "pg") {
4
+ for (const row of await read("SELECT table_name AS t, column_name AS c FROM information_schema.columns WHERE table_schema = DATABASE()")) {
5
+ addColumn(live, row["t"], row["c"], "fold");
6
+ }
7
+ return live;
8
+ }
9
+ const order = new Map();
10
+ for (const row of await read("SELECT s AS s, ord AS ord FROM unnest(current_schemas(false)) WITH ORDINALITY AS t(s, ord)")) {
11
+ const s = String(row["s"] ?? "");
12
+ if (s !== "")
13
+ order.set(s, Number(row["ord"]));
14
+ }
15
+ const winner = new Map();
16
+ for (const row of await read("SELECT table_schema AS s, table_name AS t, column_name AS c FROM information_schema.columns WHERE table_schema = ANY (current_schemas(false))")) {
17
+ const t = String(row["t"] ?? "");
18
+ const rank = order.get(String(row["s"] ?? "")) ?? Number.MAX_SAFE_INTEGER;
19
+ const held = winner.get(t);
20
+ if (held !== undefined && rank > held)
21
+ continue;
22
+ if (held === undefined || rank < held) {
23
+ winner.set(t, rank);
24
+ live.delete(t);
25
+ }
26
+ addColumn(live, t, row["c"], "verbatim");
27
+ }
28
+ return live;
29
+ }
30
+ function addColumn(live, table, column, mode) {
31
+ const t = mode === "fold" ? String(table ?? "").toLowerCase() : String(table ?? "");
32
+ const c = mode === "fold" ? String(column ?? "").toLowerCase() : String(column ?? "");
33
+ if (t === "" || c === "")
34
+ return;
35
+ let set = live.get(t);
36
+ if (set === undefined) {
37
+ set = new Set();
38
+ live.set(t, set);
39
+ }
40
+ set.add(c);
41
+ }
42
+ //# sourceMappingURL=schema-live-columns.js.map
@@ -221,6 +221,40 @@ export interface RunRecord {
221
221
  createdAt: string;
222
222
  updatedAt: string;
223
223
  }
224
+ /**
225
+ * `RunStore.createRun` 的行元数据(写进 `task_run` 的附加列)。
226
+ *
227
+ * `idemKey` = 这次提交的幂等键(唯一铸法 `idemKeyOf(raw, caller, "run")`,`http/idempotency.ts`;sha256 hex 定宽 64)。
228
+ * 缺席 / `null` = 调用方没带 `Idempotency-Key` ⇒ 列 NULL,不参与去重(UNIQUE 不约束 NULL,两方言同形)。
229
+ */
230
+ export interface NewRunMeta {
231
+ jobId?: string | null;
232
+ source?: string | null;
233
+ objectivePreview?: string | null;
234
+ idemKey?: string | null;
235
+ }
236
+ /**
237
+ * `RunStore.createRun` 的结局 —— 三形,一形一句话:
238
+ * · `{ ok: true }` —— 新行已建、会话 claim 已拿;
239
+ * · `{ ok: false, existing }` —— 这次提交的一把**幂等钥匙**已经指向一条行:`idem_key`(`Idempotency-Key`)或
240
+ * `task_id`(调用方自铸的 `body.taskId`)—— 两把钥匙是**同一条律**(资源行上的唯一键),先认 `idem_key` 再认 `task_id`。
241
+ * 这是「原子赢或观察」的**观察**臂:行原样交回,没建新行、没动会话;回执由提交面的唯一回执函数判。
242
+ * · `{ ok: false, activeTaskId }` —— 会话被**别的** run 占着(双发守卫),没建行。
243
+ * 另两位在每一形上恒 `undefined`(读 `created.existing` / `created.activeTaskId` 不用先窄化,判别仍是穷举的)。
244
+ */
245
+ export type CreateRunOutcome = {
246
+ ok: true;
247
+ existing?: undefined;
248
+ activeTaskId?: undefined;
249
+ } | {
250
+ ok: false;
251
+ existing: RunRecord;
252
+ activeTaskId?: undefined;
253
+ } | {
254
+ ok: false;
255
+ existing?: undefined;
256
+ activeTaskId: string;
257
+ };
224
258
  /**
225
259
  * `RunRecord["status"]`(= `"running" | TaskStatus`)的**唯一分类表** —— 词表的属主 = 判据的属主,
226
260
  * 与类型定义同住一个文件。四个类:
@@ -402,9 +436,13 @@ export interface SessionSummary {
402
436
  * 零迁移路线 —— result 在终局与 park 时写、认领续跑即清空(S-173)⇒ 在跑 run 键诚实缺席);OMIT-when-absent,恒不铸 null。轴辨析与
403
437
  * wire 半场见 `security.ts SessionListItem.lastTaskRunId`(同名同义)。 */
404
438
  lastTaskRunId?: string;
405
- /** The auto-generated session title (cheap-model one-liner, write-once). null = not (yet)
406
- * titled OR the lister face has no session_meta (the legacy task_run aggregate / memory twins — honest degrade). */
439
+ /** The session's title: the user's (`patchSessionMeta`, unconditional; `null` clears it back to automatic) or the auto-titler's
440
+ * (cheap-model one-liner, `setTitleIfNull` — fills NULL only). null = not (yet) titled OR the lister face has no session_meta (the legacy
441
+ * task_run aggregate / memory twins — honest degrade). Writer order: `src/session-row.ts`. */
407
442
  title: string | null;
443
+ /** Pinned by the user (`patchSessionMeta`). Always present, `false` by default; the legacy task_run aggregate / memory
444
+ * twins answer `false` (no session_meta ⇒ nothing there can pin it). */
445
+ pinned: boolean;
408
446
  }
409
447
  /**
410
448
  * S-1371 —— 会话**枚举面**读 run 账里「每会话的 run 投影」的窄读面:`runCount` / `lastStatus` / `lastRunId` / `lastTaskRunId` 四位,
@@ -5,7 +5,7 @@
5
5
  * `maxCostUsd` gate). `scope_key` is opaque (principal today; team/feature later — [ref] N3).
6
6
  */
7
7
  import type { Pool } from "mysql2/promise";
8
- import type { QuotaDecision, QuotaLimits, QuotaTracker } from "../observability/cost-quota.js";
8
+ import { type QuotaDecision, type QuotaLimits, type 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
11
  private readonly counter;
@@ -1,3 +1,4 @@
1
+ import { decideQuota } from "../observability/cost-quota.js";
1
2
  import { WriteBehindCounter } from "./write-behind-counter.js";
2
3
  export class TiDBCostQuota {
3
4
  counter;
@@ -22,11 +23,7 @@ export class TiDBCostQuota {
22
23
  this.counter.stop();
23
24
  }
24
25
  check(scopeKey) {
25
- if (this.limitMicroUsd <= 0)
26
- return { allowed: true, usedMicroUsd: 0, limitMicroUsd: 0, retryAfterSec: 0 };
27
- const used = this.counter.used(scopeKey);
28
- const allowed = used < this.limitMicroUsd;
29
- return { allowed, usedMicroUsd: used, limitMicroUsd: this.limitMicroUsd, retryAfterSec: allowed ? 0 : this.counter.retryAfterSec() };
26
+ return decideQuota(this.limitMicroUsd, this.counter.window(), (bucket) => this.counter.usedIn(scopeKey, bucket));
30
27
  }
31
28
  add(scopeKey, micro) {
32
29
  if (this.limitMicroUsd <= 0)
@@ -7,6 +7,7 @@ import { TIDB_ADOPTION_LOG_STATEMENTS } from "./adoption-log-sql.js";
7
7
  import { TIDB_PERMISSION_RULE_STATEMENTS } from "./permission-rule-store-sql.js";
8
8
  import { TIDB_RETENTION_STATEMENTS } from "./retention-store-sql.js";
9
9
  import { SESSION_META_INCARNATION_COLUMN } from "../session-incarnation.js";
10
+ import { SESSION_TITLE_MAX_CHARS } from "../session-row.js";
10
11
  import { ensureIndexes } from "./ensure-index.js";
11
12
  import { CENTRAL_SCHEMA_INDEXES } from "./schema-indexes.js";
12
13
  import { SESSION_INDEXES } from "./pg-session-storage.js";
@@ -56,9 +57,13 @@ export const SCHEMA_STATEMENTS = [
56
57
  updated_at DATETIME(3) NOT NULL,
57
58
  leaf_id VARCHAR(64) NULL,
58
59
  leaf_seq BIGINT NOT NULL DEFAULT 0,
59
- -- title: auto-generated session title (cheap-model one-liner at first submit; write-once via
60
- -- setTitleIfNull — the user-facing RENAME layer lives BFF-side as a label overlay, not here).
61
- title VARCHAR(120) NULL,
60
+ -- title: ONE column, two writers, one law (src/session-row.ts): the user's write (PATCH /v1/sessions/:id →
61
+ -- patchSessionMeta, unconditional; null = cleared back to the automatic title) and the auto-titler (cheap-model
62
+ -- one-liner on a submit that finds it NULL; setTitleIfNull, fills NULL only) — a user-set title is never overwritten,
63
+ -- a cleared one is filled again. Width = SESSION_TITLE_MAX_CHARS (the wire bound).
64
+ title VARCHAR(${SESSION_TITLE_MAX_CHARS}) NULL,
65
+ -- pinned: the user's pin (PATCH /v1/sessions/:id → patchSessionMeta); every list row carries it, false by default.
66
+ pinned BOOLEAN NOT NULL DEFAULT FALSE,
62
67
  -- instruction_source(S-690):本会话**上一次 run** 选中的项目指令文件 = 换源通告(instructions.source_changed)
63
68
  -- 的基线。NULL = 还没有任何 run 记过(首 run 只写不发);'' = 记过、那一次没有项目指令文件;其余 = 链成员相对路径
64
69
  -- (CLAUDE.md / .claude/CLAUDE.md / AGENTS.md / .claude/AGENTS.md)。服务端元数据:不进模型上下文、不进 wire
@@ -160,9 +165,15 @@ export const SCHEMA_STATEMENTS = [
160
165
  -- (markResuming) so a resumed leg is not re-preempted by a stale flag (the durable twin of core's
161
166
  -- pre-aborted-signal strip).
162
167
  preempt_requested TINYINT(1) NOT NULL DEFAULT 0,
168
+ -- idem_key: the submission's scoped Idempotency-Key (sha256 hex, fixed width 64; minted only by
169
+ -- http/idempotency.ts idemKeyOf, resource family run). The ONE enforcement point of submission idempotency for
170
+ -- runs: uq_task_run_idem makes createRun an atomic win-or-observe across replicas and restarts. NULL = the caller
171
+ -- sent no key (UNIQUE does not constrain NULL; same on both dialects).
172
+ idem_key VARCHAR(64) NULL,
163
173
  created_at DATETIME(3) NOT NULL,
164
174
  updated_at DATETIME(3) NOT NULL,
165
175
  PRIMARY KEY (task_id),
176
+ UNIQUE KEY uq_task_run_idem (idem_key),
166
177
  KEY idx_task_run_session_status (session_id, status),
167
178
  KEY idx_task_run_owner (owner),
168
179
  -- idx_job: WHERE job_id = ? (work-view grouping) would full-scan without it.
@@ -15,6 +15,7 @@ import type { Pool } from "mysql2/promise";
15
15
  import { type InstructionSourceBaseline } from "../instruction-source-notice.js";
16
16
  import { type SessionFace } from "../session-incarnation.js";
17
17
  import type { SessionSummary } from "./store-contracts.js";
18
+ import type { SessionRowPatch } from "../session-row.js";
18
19
  import { type PlacementAcquireOpts } from "./session-placement.js";
19
20
  import { type StagingHandle } from "../session-sync-kernel.js";
20
21
  export declare class TiDBSessionStore implements SessionStore {
@@ -115,11 +116,30 @@ export declare class TiDBSessionStore implements SessionStore {
115
116
  limit: number;
116
117
  q?: string;
117
118
  }): Promise<SessionSummary[]>;
119
+ /** ONE session's list row — {@link listSessions}' projection (same SELECT, same mapper) scoped to one id; `undefined` = not a
120
+ * row of the list face (no such session / placed). The run-preview subqueries are scoped to the same id (an index read on
121
+ * `task_run.session_id`, not the whole-ledger aggregate the list pays). */
122
+ sessionSummaryOf(sessionId: string): Promise<SessionSummary | undefined>;
123
+ /** The session row's writable bits — the ONLY writer of a user-set title / pin (writer order: `src/session-row.ts`):
124
+ * `title` unconditional (a string = the user's title — the auto-titler's {@link setTitleIfNull} fills NULL only, so it always
125
+ * wins; `null` = cleared back to the automatic title, which the auto-titler fills again on a later submit), `pinned` as given.
126
+ * Only a row of the list face takes it (`placement_kind IS NULL`), and only the occupant the caller verified (`incarnation = ?`
127
+ * from `face` — a session deleted and re-registered by anyone after the caller's gate is another occupant: zero rows updated).
128
+ * The read-back carries the same condition, so the answer is that occupant's list row or `undefined` (the decision does not
129
+ * read `affectedRows`: an UPDATE that wrote identical values and one that matched nothing differ there by driver flags, the
130
+ * conditioned read-back does not). `updated_at` is NOT touched (a rename is not activity — the list's newest-first order stays put). */
131
+ patchSessionMeta(sessionId: string, patch: SessionRowPatch, face: SessionFace): Promise<SessionSummary | undefined>;
132
+ /**
133
+ * The list projection's ONE SELECT + row mapper ({@link listSessions} and {@link sessionSummaryOf} both read through it).
134
+ * `runSessionId` scopes the two run-preview subqueries to one session (the single-row read); the list leaves them whole.
135
+ * Placeholder order follows the text: subquery params, then `where` params, then the LIMIT.
136
+ */
137
+ private selectSummaries;
118
138
  /** Cheap pre-LLM probe: "none" = session_meta row not yet REGISTERED (createRun fires
119
139
  * before the Runner's session acquire — the titler retries), "untitled" / "titled" as read. */
120
140
  probeTitle(sessionId: string): Promise<"none" | "untitled" | "titled">;
121
- /** Write-once auto-title: lands ONLY when title IS NULL (a concurrent/prior title wins;
122
- * the user-facing rename layer is BFF-side). Returns whether this call set it. */
141
+ /** The auto-titler's write: lands ONLY when title IS NULL (a concurrent/prior title — the user's included — wins;
142
+ * writer order: `src/session-row.ts`). Returns whether this call set it. */
123
143
  setTitleIfNull(sessionId: string, title: string): Promise<boolean>;
124
144
  /**
125
145
  * S-690 —— 换源通告的基线读(一行 PK SELECT;列的三态读法见 `tidb-pool.ts` 的列注)。行不在 ⇒ 读作「没记过」
@@ -153,18 +153,44 @@ export class TiDBSessionStore {
153
153
  where.push("(sm.updated_at < ? OR (sm.updated_at = ? AND sm.session_id < ?))");
154
154
  params.push(new Date(opts.cursor.lastActivityAt), new Date(opts.cursor.lastActivityAt), opts.cursor.sessionId);
155
155
  }
156
- const sql = "SELECT sm.session_id, sm.owner, sm.updated_at AS last_activity, sm.created_at AS first_activity, sm.title, " +
156
+ return this.selectSummaries({ where, params, limit: opts.limit });
157
+ }
158
+ async sessionSummaryOf(sessionId) {
159
+ const rows = await this.selectSummaries({ where: ["sm.placement_kind IS NULL", "sm.session_id = ?"], params: [sessionId], limit: 1, runSessionId: sessionId });
160
+ return rows[0];
161
+ }
162
+ async patchSessionMeta(sessionId, patch, face) {
163
+ const sets = [];
164
+ const params = [];
165
+ if (patch.title !== undefined) {
166
+ sets.push("title = ?");
167
+ params.push(patch.title);
168
+ }
169
+ if (patch.pinned !== undefined) {
170
+ sets.push("pinned = ?");
171
+ params.push(patch.pinned);
172
+ }
173
+ if (sets.length > 0) {
174
+ params.push(sessionId, face.incarnation);
175
+ await this.pool.query(`UPDATE session_meta SET ${sets.join(", ")} WHERE session_id = ? AND incarnation = ? AND placement_kind IS NULL`, params);
176
+ }
177
+ const rows = await this.selectSummaries({ where: ["sm.placement_kind IS NULL", "sm.session_id = ?", "sm.incarnation = ?"], params: [sessionId, face.incarnation], limit: 1, runSessionId: sessionId });
178
+ return rows[0];
179
+ }
180
+ async selectSummaries(q) {
181
+ const runWhere = q.runSessionId !== undefined ? " WHERE session_id = ?" : "";
182
+ const runParams = q.runSessionId !== undefined ? [q.runSessionId, q.runSessionId] : [];
183
+ const sql = "SELECT sm.session_id, sm.owner, sm.updated_at AS last_activity, sm.created_at AS first_activity, sm.title, sm.pinned, " +
157
184
  " COALESCE(r.run_count, 0) AS run_count, latest.objective_preview, latest.status, latest.task_id AS last_run_id, latest.last_task_run_id " +
158
185
  "FROM session_meta sm " +
159
- "LEFT JOIN (SELECT session_id, COUNT(*) AS run_count FROM task_run GROUP BY session_id) r ON r.session_id = sm.session_id " +
186
+ "LEFT JOIN (SELECT session_id, COUNT(*) AS run_count FROM task_run" + runWhere + " GROUP BY session_id) r ON r.session_id = sm.session_id " +
160
187
  "LEFT JOIN (SELECT session_id, objective_preview, status, task_id, last_task_run_id FROM (" +
161
188
  " SELECT session_id, objective_preview, status, task_id, JSON_UNQUOTE(JSON_EXTRACT(result, '$.runId')) AS last_task_run_id, " +
162
- " ROW_NUMBER() OVER (PARTITION BY session_id ORDER BY created_at DESC, task_id DESC) AS rn FROM task_run" +
189
+ " ROW_NUMBER() OVER (PARTITION BY session_id ORDER BY created_at DESC, task_id DESC) AS rn FROM task_run" + runWhere +
163
190
  " ) lr WHERE rn = 1) latest ON latest.session_id = sm.session_id " +
164
- (where.length ? "WHERE " + where.join(" AND ") + " " : "") +
191
+ (q.where.length ? "WHERE " + q.where.join(" AND ") + " " : "") +
165
192
  "ORDER BY sm.updated_at DESC, sm.session_id DESC LIMIT ?";
166
- params.push(opts.limit);
167
- const [rows] = await this.pool.query(sql, params);
193
+ const [rows] = await this.pool.query(sql, [...runParams, ...q.params, q.limit]);
168
194
  return rows.map((r) => {
169
195
  const runId = r.last_task_run_id;
170
196
  return {
@@ -178,6 +204,7 @@ export class TiDBSessionStore {
178
204
  lastRunId: r.last_run_id ?? null,
179
205
  ...(typeof runId === "string" && runId !== "" ? { lastTaskRunId: runId } : {}),
180
206
  title: r.title ?? null,
207
+ pinned: Number(r.pinned) === 1,
181
208
  };
182
209
  });
183
210
  }
@@ -16,8 +16,9 @@
16
16
  * up to (replicas × per-flush-interval spend/requests) at the boundary before peers' totals propagate.
17
17
  * Right for cost/fairness ceilings (and backstopped, for cost, by the hard per-task `maxCostUsd` gate).
18
18
  * A HARD compliance ceiling would instead need a linearizable CAS/atomic count, not write-behind — same
19
- * trade as the cross-replica circuit breaker. Windows are aligned fixed buckets (`floor(now/windowMs)`),
20
- * the standard distributed choice (a rolling per-key window is the hard part to coordinate cross-replica).
19
+ * trade as the cross-replica circuit breaker. Windows are clock-aligned fixed buckets — the ONE anchor
20
+ * {@link alignedWindow} (`observability/cost-quota.ts`), shared with the in-memory cost quota — the standard
21
+ * distributed choice (a rolling per-key window is the hard part to coordinate cross-replica).
21
22
  *
22
23
  * ── Dialect deltas, kept EXPLICIT (the four the PG twins' file headers used to list) ──────────────────
23
24
  * - placeholders `?` vs `$1..$n`
@@ -32,6 +33,7 @@
32
33
  */
33
34
  import type { Pool as MySqlPool } from "mysql2/promise";
34
35
  import type { Pool as PgPool } from "pg";
36
+ import { type AlignedWindow } from "../observability/cost-quota.js";
35
37
  export interface CounterTable {
36
38
  /** Table name; must have columns (<keyCol>, window_bucket BIGINT, <valCol>, updated_at). */
37
39
  table: string;
@@ -80,12 +82,17 @@ export declare class SqlWriteBehindCounter {
80
82
  /** S9: track the consecutive-failure streak per kind and notify on every failure + on the recovery edge
81
83
  * (streak>0 → 0). Steady healthy state stays silent. The hook must never break the counter. */
82
84
  private bump;
85
+ /** One clock read → the current aligned window (bucket + end), via the ONE anchor {@link alignedWindow}. */
86
+ window(): AlignedWindow;
83
87
  bucket(): number;
84
88
  private rk;
85
- /** Cached fleet total (peers, from the last refresh) + this replica's unflushed delta, current window. */
89
+ /** Cached fleet total (peers, from the last refresh) + this replica's unflushed delta, in `bucket`
90
+ * (the caller's own clock read — a decision reads its spend and its window end from ONE `now`). */
91
+ usedIn(key: string, bucket: number): number;
92
+ /** {@link usedIn} for the current window. */
86
93
  used(key: string): number;
87
94
  addLocal(key: string, n: number): void;
88
- /** Seconds until the current aligned window rolls over. */
95
+ /** Seconds until the current aligned window rolls over (the ONE formula {@link retryAfterSecUntil}). */
89
96
  retryAfterSec(): number;
90
97
  /** Atomically push pending deltas, then refresh the current window's fleet totals + reap old buckets.
91
98
  * Reentrancy-guarded: two overlapping flushes would each apply `+delta` from the same snapshot and
@@ -111,7 +118,7 @@ export declare class SqlWriteBehindCounter {
111
118
  /**
112
119
  * [ref] 批1:窗长换代(`costQuotaWindowSec` 热更新)。
113
120
  *
114
- * 🔴 如实语义:窗长是**桶键的一部分**(`floor(now/windowMs)`),不是行里的一个可改字段。换窗长 ⇒ 当代
121
+ * 🔴 如实语义:窗长是**桶键的一部分**(桶号由 {@link alignedWindow} 算),不是行里的一个可改字段。换窗长 ⇒ 当代
115
122
  * 桶重算 ⇒ 本副本对「当前窗已用多少」的读数从新桶起算(旧桶的行还在库里,按它自己的窗长归属)。
116
123
  * 未 flush 的本地增量仍带着**它自己的**桶键(rowKey 里已含),所以它们仍落回原来那个桶,不会被搬家。
117
124
  * 这与限额值的换代不同(那个是纯比较参数,窗内计数一个不动),因此单独成注:运营改窗长要知道自己在