@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
package/dist/main.js CHANGED
@@ -51,6 +51,7 @@ import { openStores } from "./boot/stores.js";
51
51
  import { runAdoptionBootScan } from "./boot/adoption.js";
52
52
  import { auditDormantPermissionRules } from "./boot/permission-rules-audit.js";
53
53
  import { createBudgetAndTracing } from "./boot/budget-tracing.js";
54
+ import { createLimitSync } from "./boot/limit-sync.js"; // #322 批1:限额座的换代口(恒构造 + 关断哨兵)
54
55
  import { createSideQueryLane } from "./boot/side-query-lane.js";
55
56
  import { createWebFetchSummarizeLane } from "./boot/webfetch-summarize-lane.js";
56
57
  import { createRuleConsentLane } from "./rules-consent.js";
@@ -113,12 +114,17 @@ function serviceVersion() {
113
114
  */
114
115
  async function main() {
115
116
  const config = loadConfig();
117
+ // ── CROSS-INVARIANT-REGION:begin ────────────────────────────────────────────────────────────────
116
118
  // Liveness invariant: the running instance refreshes updated_at every HEARTBEAT_MS; the reaper fails
117
119
  // a run whose updated_at is older than runStaleSec. If the stale window were ≤ the heartbeat, the
118
120
  // reaper would race a perfectly alive run. Fail fast on a misconfiguration instead of reaping live work.
121
+ // #322:判据本体已搬进 `config-invariants.ts` 的单源表 —— 热路径(config-center stage)跑的是**同一条**,
122
+ // 所以一个过小的 runStaleSec 既不能从 env 起服,也不能从中心热发布进来(禁手抄第二份判据)。
119
123
  if (config.runStaleSec * 1000 <= HEARTBEAT_MS) {
124
+ // CROSS-INVARIANT:run-stale-heartbeat
120
125
  throw new Error(`REAP_RUN_STALE_SEC (${config.runStaleSec}s) must exceed the ${HEARTBEAT_MS / 1000}s liveness heartbeat`);
121
126
  }
127
+ // ── CROSS-INVARIANT-REGION:end ──────────────────────────────────────────────────────────────────
122
128
  const logger = createLogger(config.logLevel);
123
129
  const metrics = createMetrics();
124
130
  // #157 fail-open 治理:把 logger/metrics 接到进程级 failOpen 记录器上(见 observability/fail-open.ts)。
@@ -735,11 +741,13 @@ async function main() {
735
741
  logger.warn("multi_tenant_no_service_token", { detail: "REQUIRE_PRINCIPAL is on but no SERVICE_AUTH_TOKEN/authTokens/TRACE_TOKEN — fleet/trace/session-list are owner-gated only (never fleet-wide, tenant-isolation safe). Set a token for cross-tenant ops visibility." });
736
742
  // Rate limiter: TiDB-backed (cross-replica SOFT limit, same write-behind counter as the cost quota)
737
743
  // when a pool exists, else in-memory per-replica. Both satisfy RateGate. (design/30 P0-2)
738
- const rateLimiter = config.rateLimitPerMin > 0
739
- ? backend?.rateLimiter // local omits rateLimiter() in-memory per-process RateLimiter (single-replica)
740
- ? backend.rateLimiter(config.rateLimitPerMin, counterDegradeHook).startRefresh()
741
- : new RateLimiter(config.rateLimitPerMin)
742
- : undefined;
744
+ //
745
+ // 🔴 #322 批1:与 costQuota 同一条改造(恒构造 + 0=关断哨兵)——修前 `rateLimitPerMin > 0` 才构造,
746
+ // 默认部署零实例,于是热改 0→N 无座可 set。恒构造之后 0↔N 是纯值变(boot/limit-sync.ts 一次 setLimits),
747
+ // 关断态由座位自己表达:恒放行、不记窗、SQL 腿不起刷新环(零 SQL)
748
+ const rateLimiter = backend?.rateLimiter // local omits rateLimiter() → in-memory per-process RateLimiter (single-replica)
749
+ ? backend.rateLimiter(config.rateLimitPerMin, counterDegradeHook).startRefresh()
750
+ : new RateLimiter(config.rateLimitPerMin);
743
751
  // Capability layer: scenario → {tools (+ subagent roster), prompt, skills}. Heavy bits (skills,
744
752
  // git client) are built ONCE here; per request we only select a scenario and bind coordinates.
745
753
  let skills = loadSkills(config.skillsDir);
@@ -933,7 +941,36 @@ async function main() {
933
941
  : undefined;
934
942
  // design/158 A10 尾刀:60s refresh 循环 + boot-deferred 到货续接搬到 boot/config-center.ts(逐字)。
935
943
  // ⚠️ 位置即契约:必须在 new Runner 之后 —— runnerTierFrozen 与构造函数读的是同一 config 代际。
936
- configCenter.startRefreshLoop({ runnerTierFrozen, pricing });
944
+ // #322 批1:限额座的换代口(恒构造的两只座 —— rateLimiter 上面建、costQuota 在 budget-tracing 段建)
945
+ // boot 期先 sync 一次:让「上一代读数」落位,于是第一次真变更才会留 `limit_gates_reloaded` 那一行。
946
+ const limitSync = createLimitSync({ config, rateGate: rateLimiter, quota: costQuota, logger });
947
+ limitSync.sync();
948
+ // core 5.49 `Runner.swapModels` 接缝([4775]①,models-tiers defer 臂退役):plane 候选在 commit 前
949
+ // 喂给**全部** boot 单例 Runner 原子换代。五实例必须同代——swap 替代 restart 后,漏换的长命 Runner
950
+ // 会永久停在旧代(比 defer+restart 更糟);leader lane 的 Runner 是逐调用现构(读 config.models 活
951
+ // 引用),不在此列。校验对同一候选输入是确定性的:首个 swap 抛 ⇒ 零实例换装、候选整拒;首个成功后
952
+ // 后继实例抛=结构性不可达,若真发生则 error 留痕后照抛(下一拍幂等重 swap 收敛)。
953
+ const bootRunners = [
954
+ ["runner", runner],
955
+ ["handsless", handslessRunner],
956
+ ["hookAgent", hookAgentRunner],
957
+ ["sub", subRunner],
958
+ ["handslessSub", handslessSubRunner],
959
+ ];
960
+ const swapRunnerModels = (plane) => {
961
+ for (let i = 0; i < bootRunners.length; i++) {
962
+ const [name, rn] = bootRunners[i];
963
+ try {
964
+ rn.swapModels({ models: plane.models, tiers: plane.tiers });
965
+ }
966
+ catch (err) {
967
+ if (i > 0)
968
+ logger.error("model_plane_swap_split", { failedAt: name, swapped: bootRunners.slice(0, i).map(([n]) => n), err: String(err) });
969
+ throw err;
970
+ }
971
+ }
972
+ };
973
+ configCenter.startRefreshLoop({ runnerTierFrozen, pricing, limitSync, swapRunnerModels });
937
974
  // design/158 A10 尾刀:per-model key resolver 首建(+ Bearer 网关组合告警)搬到 boot/config-center.ts。
938
975
  // ⚠️ 位置即契约:首建读的必须是 boot apply **之后**的 config.modelApiKeyEnv/modelApiKeys。
939
976
  configCenter.initKeyResolver();
@@ -989,6 +1026,10 @@ async function main() {
989
1026
  sessionDurability: staticWiring.session.store,
990
1027
  posture: staticWiring.interaction.posture,
991
1028
  streamApproval: streamApprovalGate.active ? "active" : streamApprovalGate.reason,
1029
+ // debt #292-manifest:C18 部署级读数上 operator 日志——`full` = agent 行店在线 ∧ session 店
1030
+ // 声明 subagent placement 分区 durable(ForkRoutingSessionStore 从 host 透传声明)。诊断端点
1031
+ // GET /v1/diagnostics/wiring 携带全量 manifest,这里只投这枚部署级结论。
1032
+ subagentTranscripts: staticWiring.fleet.subagentTranscripts,
992
1033
  });
993
1034
  // ═══════════ design/158 A8:ServiceDeps 分组装配(七组窄接口)═══════════
994
1035
  // 每组一个**带类型标注**的中间 const —— 标注位触发 TS 多余属性检查,键放错组当场编译红。
@@ -1160,6 +1201,8 @@ async function main() {
1160
1201
  // pull's restart-to-apply slices differ from boot. A live getter (not a snapshot) so /health always reads current.
1161
1202
  restartState: () => configCenter.restartState(),
1162
1203
  planeDeferredState: () => configCenter.planeDeferredState(), // codex R18: /health.modelPlaneDeferred (stuck-deferred plane, no handoff)
1204
+ configApplyState: () => configCenter.configApplyState(), // #322 世代账:/health 三键 + 诊断 configApply 段(同一份读数)
1205
+ configRefreshNow: () => configCenter.refreshNow(), // #322 追加件:POST /v1/admin/config/refresh(触发既有拍,在飞则 join)
1163
1206
  // shared drain state — SIGTERM flips `draining`, createServer assigns `inflight`, /health mirrors it.
1164
1207
  drainState,
1165
1208
  storeDegraded: storeBackendDegraded, // S5: /health twin of the store_backend_degraded gauge
@@ -34,7 +34,7 @@
34
34
  //
35
35
  // putCursor 失败于 applyPatches 之后 = 补丁已落、游标陈旧:向上抛(路由 500),客户端按同一
36
36
  // baseRevs 重放即幂等自愈(identical revs 全程 no-op)——游标写不是本轮正确性的一部分。
37
- import { computeEntryRev, nextSyncBaseline, reconcileMemoryEntries, screenInboundEntries, } from "@sema-agent/core";
37
+ import { computeEntryRev, MEMORY_ORIGIN_CAUSES, nextSyncBaseline, reconcileMemoryEntries, screenInboundEntries, } from "@sema-agent/core";
38
38
  import { exportMemoryScope } from "./memory-export.js";
39
39
  // ── 验型(HTTP 面的 422 判据;返回 error 字符串而非 throw——validateUserSkills 姿势) ──────────
40
40
  /** 一轮 push 的条目数上限 = core REMOTE_HARVEST_MAX_FILES 同款口径(一个 scope 的文件面上限 500;
@@ -99,6 +99,41 @@ export function parseMemorySyncRequest(body, scope) {
99
99
  if (fm["extra"] !== undefined && !(Array.isArray(fm["extra"]) && fm["extra"].every((s) => typeof s === "string"))) {
100
100
  return { ok: false, error: "entry.frontmatter.extra must be an array of strings when present" };
101
101
  }
102
+ // #323([4769]):三只 provenance 载体入白名单——修前这里静默剥掉 origin/trust/provenance,
103
+ // 带标条目过 sync wire 即丢标(污染追踪断链)、rev 恒假冲突(client 以含标载体算 rev),且
104
+ // 「typed origin + 冲突 extra 块」的歧义表示被选择性改写成非歧义形绕过 core 的
105
+ // ambiguousOriginRepresentation 拒绝门(applyPatches 直调路径拒、sync 路径放行=同法两答)。
106
+ // 修=pick+闭集验形(照 core MemoryEntryFrontmatter 单源形),坏形响亮拒;歧义**判定**刻意不在
107
+ // 这里重复——保真透传后 core 受理门(clause 31)与 sync 路径同答([4770] core 已确认零改动)。
108
+ const rawOrigin = fm["origin"];
109
+ if (rawOrigin !== undefined) {
110
+ if (rawOrigin === null || typeof rawOrigin !== "object" || Array.isArray(rawOrigin))
111
+ return { ok: false, error: "entry.frontmatter.origin must be an object { taint, at, cause? } when present" };
112
+ const o = rawOrigin;
113
+ if (o["taint"] !== "external")
114
+ return { ok: false, error: 'entry.frontmatter.origin.taint must be the literal "external"' };
115
+ if (typeof o["at"] !== "number" || !Number.isFinite(o["at"]))
116
+ return { ok: false, error: "entry.frontmatter.origin.at must be a finite number (ms epoch)" };
117
+ if (o["cause"] !== undefined && !MEMORY_ORIGIN_CAUSES.includes(o["cause"])) {
118
+ return { ok: false, error: `entry.frontmatter.origin.cause must be one of ${JSON.stringify(MEMORY_ORIGIN_CAUSES)} when present` };
119
+ }
120
+ }
121
+ if (fm["trust"] !== undefined && fm["trust"] !== "untrusted")
122
+ return { ok: false, error: 'entry.frontmatter.trust must be the literal "untrusted" when present (single-value union — there is no trusted spelling)' };
123
+ const rawProv = fm["provenance"];
124
+ if (rawProv !== undefined) {
125
+ if (rawProv === null || typeof rawProv !== "object" || Array.isArray(rawProv))
126
+ return { ok: false, error: "entry.frontmatter.provenance must be an object { kind, path, contentHash, ingestedAt } when present" };
127
+ const p = rawProv;
128
+ if (p["kind"] !== "repo_file")
129
+ return { ok: false, error: 'entry.frontmatter.provenance.kind must be the literal "repo_file"' };
130
+ if (typeof p["path"] !== "string" || p["path"].length === 0)
131
+ return { ok: false, error: "entry.frontmatter.provenance.path must be a non-empty string" };
132
+ if (typeof p["contentHash"] !== "string" || p["contentHash"].length === 0)
133
+ return { ok: false, error: "entry.frontmatter.provenance.contentHash must be a non-empty string" };
134
+ if (typeof p["ingestedAt"] !== "number" || !Number.isFinite(p["ingestedAt"]))
135
+ return { ok: false, error: "entry.frontmatter.provenance.ingestedAt must be a finite number (ms epoch)" };
136
+ }
102
137
  entries.push({
103
138
  id: e["id"],
104
139
  slug: e["slug"],
@@ -110,6 +145,20 @@ export function parseMemorySyncRequest(body, scope) {
110
145
  ...(fm["description"] !== undefined ? { description: fm["description"] } : {}),
111
146
  ...(fm["type"] !== undefined ? { type: fm["type"] } : {}),
112
147
  ...(fm["deleted"] !== undefined ? { deleted: fm["deleted"] } : {}),
148
+ // #323:三载体保真重建(pick,不 spread——上面已闭集验形;origin 逐成员重建防原对象带私货键)
149
+ ...(rawOrigin !== undefined
150
+ ? {
151
+ origin: {
152
+ taint: "external",
153
+ at: rawOrigin.at,
154
+ ...(rawOrigin.cause !== undefined ? { cause: rawOrigin.cause } : {}),
155
+ },
156
+ }
157
+ : {}),
158
+ ...(fm["trust"] !== undefined ? { trust: "untrusted" } : {}),
159
+ ...(rawProv !== undefined
160
+ ? { provenance: { kind: "repo_file", path: rawProv.path, contentHash: rawProv.contentHash, ingestedAt: rawProv.ingestedAt } }
161
+ : {}),
113
162
  ...(fm["extra"] !== undefined ? { extra: [...fm["extra"]] } : {}),
114
163
  },
115
164
  });
@@ -20,17 +20,37 @@ export interface QuotaDecision {
20
20
  /** The hot-path surface shared by the in-memory {@link CostQuota} (single replica) and the cross-replica
21
21
  * {@link import("../plugins/tidb-cost-quota.js").TiDBCostQuota}. `key` is opaque (principal today;
22
22
  * principal/team/feature later — design/30 N3), so both stores are key-based. */
23
+ /** #322 批1:配额的**值换代**入参(四实现类同一形;单位与构造参数一致 —— USD/秒 → micro/ms 的换算
24
+ * 只有一处,`boot/limit-sync.ts` 的 `buildQuotaBounds`)。`limitMicroUsd <= 0` = **关断哨兵**。 */
25
+ export interface QuotaLimits {
26
+ limitMicroUsd: number;
27
+ windowMs: number;
28
+ }
23
29
  export interface QuotaTracker {
24
30
  check(key: string): QuotaDecision;
25
31
  add(key: string, micro: number): void;
32
+ /** #322:值换代座位。**接口级**声明——新实现漏掉它 = 编译红,而不是热更新在那条腿上静默失效。 */
33
+ setLimits(next: QuotaLimits): void;
26
34
  }
27
35
  export declare class CostQuota implements QuotaTracker {
28
- private readonly limitMicroUsd;
29
- private readonly windowMs;
30
36
  private readonly now;
31
37
  private windows;
32
- /** @param limitMicroUsd ceiling per window (integer micro-USD). @param windowMs rolling window. */
38
+ /** 🔴 不再 `readonly`:限额/窗长是比较参数,换代只动它们,窗内已累计的花费一个不动(design/322 §5) */
39
+ private limitMicroUsd;
40
+ private windowMs;
41
+ /** @param limitMicroUsd ceiling per window (integer micro-USD; `<=0` = OFF sentinel). @param windowMs rolling window. */
33
42
  constructor(limitMicroUsd: number, windowMs: number, now?: () => number);
43
+ /**
44
+ * 值换代。两件事**语义不同**,别混:
45
+ * · **限额**换代 = 纯比较参数 ⇒ 窗内已累计的花费一个不动(design/322 §5 定案:调小当场更严)。
46
+ * · **窗长**换代 = 换**记账周期** ⇒ 当代累计重开。
47
+ *
48
+ * 🔴 后者是 codex R1-F4(真 finding,红先):SQL 腿的窗长是**桶键的一部分**(`floor(now/windowMs)`),
49
+ * 换窗长后当代桶天然重算 ⇒ 已累计不跟着搬家;内存腿修前却保留原窗口累计。于是**同一条发布**在
50
+ * 内存部署上继续拒绝超额 principal、在 PG/TiDB 部署上重新放行一整个额度 —— 后端相关的成本 fail-open。
51
+ * 统一取 SQL 腿那一侧(唯一能在两条腿上都实现的语义:SQL 侧无法把旧桶累计搬进新桶)。
52
+ */
53
+ setLimits(next: QuotaLimits): void;
34
54
  /** Is this principal under quota right now? (Reads only — does not consume.) */
35
55
  check(key: string): QuotaDecision;
36
56
  /** Add spend for a principal, starting a fresh window if the prior one expired. */
@@ -1,16 +1,37 @@
1
1
  export class CostQuota {
2
- limitMicroUsd;
3
- windowMs;
4
2
  now;
5
3
  windows = new Map();
6
- /** @param limitMicroUsd ceiling per window (integer micro-USD). @param windowMs rolling window. */
4
+ /** 🔴 不再 `readonly`:限额/窗长是比较参数,换代只动它们,窗内已累计的花费一个不动(design/322 §5) */
5
+ limitMicroUsd;
6
+ windowMs;
7
+ /** @param limitMicroUsd ceiling per window (integer micro-USD; `<=0` = OFF sentinel). @param windowMs rolling window. */
7
8
  constructor(limitMicroUsd, windowMs, now = () => Date.now()) {
9
+ this.now = now;
8
10
  this.limitMicroUsd = limitMicroUsd;
9
11
  this.windowMs = windowMs;
10
- this.now = now;
12
+ }
13
+ /**
14
+ * 值换代。两件事**语义不同**,别混:
15
+ * · **限额**换代 = 纯比较参数 ⇒ 窗内已累计的花费一个不动(design/322 §5 定案:调小当场更严)。
16
+ * · **窗长**换代 = 换**记账周期** ⇒ 当代累计重开。
17
+ *
18
+ * 🔴 后者是 codex R1-F4(真 finding,红先):SQL 腿的窗长是**桶键的一部分**(`floor(now/windowMs)`),
19
+ * 换窗长后当代桶天然重算 ⇒ 已累计不跟着搬家;内存腿修前却保留原窗口累计。于是**同一条发布**在
20
+ * 内存部署上继续拒绝超额 principal、在 PG/TiDB 部署上重新放行一整个额度 —— 后端相关的成本 fail-open。
21
+ * 统一取 SQL 腿那一侧(唯一能在两条腿上都实现的语义:SQL 侧无法把旧桶累计搬进新桶)。
22
+ */
23
+ setLimits(next) {
24
+ this.limitMicroUsd = next.limitMicroUsd;
25
+ if (next.windowMs !== this.windowMs) {
26
+ this.windowMs = next.windowMs;
27
+ this.windows.clear();
28
+ }
11
29
  }
12
30
  /** Is this principal under quota right now? (Reads only — does not consume.) */
13
31
  check(key) {
32
+ // 关断哨兵:零窗表访问,如实报 `limitMicroUsd: 0`(消费方据此知道「本部署此刻没有 ceiling」)。
33
+ if (this.limitMicroUsd <= 0)
34
+ return { allowed: true, usedMicroUsd: 0, limitMicroUsd: 0, retryAfterSec: 0 };
14
35
  const t = this.now();
15
36
  const w = this.windows.get(key);
16
37
  const live = w && t - w.start < this.windowMs;
@@ -21,6 +42,8 @@ export class CostQuota {
21
42
  }
22
43
  /** Add spend for a principal, starting a fresh window if the prior one expired. */
23
44
  add(key, micro) {
45
+ if (this.limitMicroUsd <= 0)
46
+ return; // 关断哨兵:不为一个不存在的门攒内存(0→N 打开时是全新的窗口)
24
47
  if (micro <= 0)
25
48
  return;
26
49
  const t = this.now();
@@ -13,6 +13,14 @@ export declare const FAIL_OPEN_TAGS: {
13
13
  readonly cls: "P-DEBT";
14
14
  readonly note: "件6(#43 案 [4281]):`FileRunStore.hydrate()` 的 `readdirSync(runs/)` 失败 ⇒ 引擎以**空账**起动。旧形是空 catch + return:data root 不可读(权限/挂载丢失/路径被换走)时零告警,此后每一次 getRun 恒 miss —— 而「恒 miss」正是 #43 现场看到的形,一次环境故障被伪装成「本来就没有这条 run」。方向问题在于吞掉之后被读成的是「证不出有行 ⇒ 没有行」,两句话不等价。**不拒启是刻意保留的行为**(拒启是行为变更,另立),所以这条留作债:响亮化(error 级启动日志带 runs 绝对路径+异常文案)+ 本计数,让它在遥测里显形而不是只活在一行日志里。终局=给 file 后端一条可选的 fail-closed 启动门。";
15
15
  };
16
+ readonly "server.fork-routing.placed-probe-host-error": {
17
+ readonly cls: "F";
18
+ readonly note: "#292 placement 半场:hostPlacedProbe 对 host 店的 requireExisting 探针失败且**不是 not_found**(host 读故障)时折叠为 absent —— 路由回旧规则(claim-create 会在 transient 建新会话)。放行的是「host 暂不可达时 transient 腿保持独立可用」;最坏后果=故障窗内对一个真 placed id 铸出 transient 影子(host 恢复后双身,requireExisting 冷探针会重新指回 host 真身)。not_found 是常态路径不计数;只有真故障臂进本计数。";
19
+ };
20
+ readonly "server.fork-routing.placed-roster-meta-unreadable": {
21
+ readonly cls: "F";
22
+ readonly note: "#292 placement 半场:host-fallback 命中后顺手读 meta 入 placed 名册失败(stub 会话无 getMetadata / 读故障)。名册只是省探针的缓存,不是判定 —— 缺一条=该 id 下次 acquire 多付一次 hostPlacedProbe,正确性零影响。计数留痕是为了让「meta 读一直在失败」这种环境病显形。";
23
+ };
16
24
  readonly "server.hitl.frame-undelivered-stream-closed": {
17
25
  readonly cls: "P-DEBT";
18
26
  readonly note: "HITL 的 **open** 帧写向一条已断/已关的 SSE 连接 ⇒ 静默丢弃,上游据此把投递记成成功,该 ask 挂到 TTL 才按无人应答结算。⚠️ #173 后 question 开帧改走断流 THROW(立即结算 unavailable),因此本 tag 在产的只剩 **elicitation** 一族——读计数时勿把它当作 question 的人在环缺口。no-op 而非 throw 对 elicitation 仍是既有的刻意决定,本条只保证它不再无声。";
@@ -36,6 +36,14 @@ export const FAIL_OPEN_TAGS = {
36
36
  cls: "P-DEBT",
37
37
  note: "件6(#43 案 [4281]):`FileRunStore.hydrate()` 的 `readdirSync(runs/)` 失败 ⇒ 引擎以**空账**起动。旧形是空 catch + return:data root 不可读(权限/挂载丢失/路径被换走)时零告警,此后每一次 getRun 恒 miss —— 而「恒 miss」正是 #43 现场看到的形,一次环境故障被伪装成「本来就没有这条 run」。方向问题在于吞掉之后被读成的是「证不出有行 ⇒ 没有行」,两句话不等价。**不拒启是刻意保留的行为**(拒启是行为变更,另立),所以这条留作债:响亮化(error 级启动日志带 runs 绝对路径+异常文案)+ 本计数,让它在遥测里显形而不是只活在一行日志里。终局=给 file 后端一条可选的 fail-closed 启动门。",
38
38
  },
39
+ "server.fork-routing.placed-probe-host-error": {
40
+ cls: "F",
41
+ note: "#292 placement 半场:hostPlacedProbe 对 host 店的 requireExisting 探针失败且**不是 not_found**(host 读故障)时折叠为 absent —— 路由回旧规则(claim-create 会在 transient 建新会话)。放行的是「host 暂不可达时 transient 腿保持独立可用」;最坏后果=故障窗内对一个真 placed id 铸出 transient 影子(host 恢复后双身,requireExisting 冷探针会重新指回 host 真身)。not_found 是常态路径不计数;只有真故障臂进本计数。",
42
+ },
43
+ "server.fork-routing.placed-roster-meta-unreadable": {
44
+ cls: "F",
45
+ note: "#292 placement 半场:host-fallback 命中后顺手读 meta 入 placed 名册失败(stub 会话无 getMetadata / 读故障)。名册只是省探针的缓存,不是判定 —— 缺一条=该 id 下次 acquire 多付一次 hostPlacedProbe,正确性零影响。计数留痕是为了让「meta 读一直在失败」这种环境病显形。",
46
+ },
39
47
  "server.hitl.frame-undelivered-stream-closed": {
40
48
  cls: "P-DEBT",
41
49
  note: "HITL 的 **open** 帧写向一条已断/已关的 SSE 连接 ⇒ 静默丢弃,上游据此把投递记成成功,该 ask 挂到 TTL 才按无人应答结算。⚠️ #173 后 question 开帧改走断流 THROW(立即结算 unavailable),因此本 tag 在产的只剩 **elicitation** 一族——读计数时勿把它当作 question 的人在环缺口。no-op 而非 throw 对 elicitation 仍是既有的刻意决定,本条只保证它不再无声。",
@@ -10,18 +10,28 @@ export interface RateDecision {
10
10
  retryAfterSec: number;
11
11
  remaining: number;
12
12
  }
13
+ /** #322 批1:限额的**值换代**入参(六实现类同一形)。语义定案(design/322 §5):窗口内计数**不清零**,
14
+ * 新限额即时用于比较 —— 调小 = 窗内立刻更严(可能当场 429,符合运营意图),调大 = 立刻放宽。 */
15
+ export interface RateLimits {
16
+ /** 每窗请求上限;`<= 0` = **关断哨兵**(恒放行、不记账、SQL 腿不起刷新环)。 */
17
+ rateLimitPerMin: number;
18
+ }
13
19
  /** The surface the HTTP layer uses — satisfied by the in-memory {@link RateLimiter} (single replica)
14
20
  * and the cross-replica {@link import("../plugins/tidb-rate-limiter.js").TiDBRateLimiter}. */
15
21
  export interface RateGate {
16
22
  check(key: string): RateDecision;
17
23
  sweep(now?: number): void;
24
+ /** #322:值换代座位。**接口级**声明(不是各类自选):新实现漏掉它 = 编译红,而不是热更新在那条腿上静默失效。 */
25
+ setLimits(next: RateLimits): void;
18
26
  }
19
27
  export declare class RateLimiter implements RateGate {
20
- private readonly limit;
21
28
  private readonly windowMs;
22
29
  private readonly windows;
30
+ /** 🔴 不再 `readonly`:限额是**比较参数**,不是窗表状态 —— 换代只动它,窗内已计的数一个不动(§5)。 */
31
+ private limit;
23
32
  constructor(limit: number, windowMs?: number);
24
33
  check(key: string): RateDecision;
34
+ setLimits(next: RateLimits): void;
25
35
  /** Drop expired windows (call periodically). */
26
36
  sweep(now?: number): void;
27
37
  }
@@ -1,12 +1,15 @@
1
1
  export class RateLimiter {
2
- limit;
3
2
  windowMs;
4
3
  windows = new Map();
4
+ /** 🔴 不再 `readonly`:限额是**比较参数**,不是窗表状态 —— 换代只动它,窗内已计的数一个不动(§5)。 */
5
+ limit;
5
6
  constructor(limit, windowMs = 60_000) {
6
- this.limit = limit;
7
7
  this.windowMs = windowMs;
8
+ this.limit = limit;
8
9
  }
9
10
  check(key) {
11
+ // 关断哨兵:零比较、零窗表写入。off 期间不攒计数是刻意的——0→N 打开时应该是一个完整的新窗口,
12
+ // 而不是「上来就超」(那段时间根本没有 ceiling,补记账等于追溯执法)。
10
13
  if (this.limit <= 0)
11
14
  return { allowed: true, retryAfterSec: 0, remaining: Number.POSITIVE_INFINITY };
12
15
  const now = Date.now();
@@ -21,6 +24,9 @@ export class RateLimiter {
21
24
  }
22
25
  return { allowed: true, retryAfterSec: 0, remaining: this.limit - w.count };
23
26
  }
27
+ setLimits(next) {
28
+ this.limit = next.rateLimitPerMin;
29
+ }
24
30
  /** Drop expired windows (call periodically). */
25
31
  sweep(now = Date.now()) {
26
32
  for (const [k, w] of this.windows)
@@ -8,11 +8,11 @@
8
8
  * does an agent's traffic actually re-pose near-duplicate objectives per tenant? (arXiv 2602.18922:
9
9
  * heterogeneous queries can hit 0–12%.) We hold the real multi-tenant traffic, so this is ours to count.
10
10
  *
11
- * **The token criterion is copied verbatim from core `src/core/strategy-store.ts`** (`tokens()` /
12
- * `significant()` / `find()` lines 46-52, 107-110) — otherwise we'd measure something other than what
13
- * plan-cache retrieval would actually use. Note the **asymmetry**: a stored problem is matched on its
14
- * full `tokens()` set, the query on its `significant()` set, and a hit = every significant query token
15
- * is present in some stored problem (`significant(new) ⊆ tokens(historical)`).
11
+ * **The token criterion is copied verbatim from core `strategy-store` (`compileStrategyQuery`)**
12
+ * otherwise we'd measure something other than what plan-cache retrieval would actually use. Note the
13
+ * **asymmetry**: a stored problem is matched on its full `tokens()` set, the query on its
14
+ * `significant()` set. A hit = `significant(new) tokens(historical)` OR (5.49 legacy acceptance
15
+ * arm) the non-CJK subset `qAlnum` is non-empty and fully contained (`qAlnum ⊆ tokens(historical)`).
16
16
  *
17
17
  * Fidelity choices (stated to search AI for confirmation): the history ring holds only **completed**
18
18
  * tasks (plan-cache only stores plans from successful runs), while the hit test runs for **every**
@@ -21,9 +21,7 @@
21
21
  */
22
22
  import type { Metrics } from "./observability/metrics.js";
23
23
  import type { Logger } from "./observability/logger.js";
24
- /** Lowercased word tokens (core口径). */
25
24
  export declare function tokens(s: string): string[];
26
- /** Meaningful tokens: ≥3 chars, not a stopword (core口径). */
27
25
  export declare function significant(s: string): string[];
28
26
  /** Per-scope view for the 2-week handoff back to search AI. */
29
27
  export interface ScopeRecurrence {
@@ -8,12 +8,30 @@ const STOP = new Set([
8
8
  "of", "to", "in", "on", "is", "it", "or", "as", "at", "by", "be", "do", "if", "so", "no", "up", "we",
9
9
  ]);
10
10
  /** Lowercased word tokens (core口径). */
11
+ /** core 5.49 G2(CJK 检索修)同形:拉丁/数字按非字母数字切段 + CJK 按单字与相邻二元组入集
12
+ * (core `memory-vector.termSet` 逐字搬)。旧形 `match(/[a-z0-9]+/g)` 对纯中文任务名恒空 ⇒
13
+ * probe 测出的 recurrence 与 plan-cache 真命中脱钩——同源钉(plan-cache-probe.test.ts)就是
14
+ * 为这次换形红的。 */
15
+ const CJK_RUN = /[぀-ヿㇰ-ㇿ㐀-䶿一-鿿가-힣豈-﫿]+/g;
11
16
  export function tokens(s) {
12
- return s.toLowerCase().match(/[a-z0-9]+/g) ?? [];
17
+ const lower = s.toLowerCase();
18
+ const out = new Set();
19
+ for (const run of lower.split(/[^a-z0-9]+/))
20
+ if (run)
21
+ out.add(run);
22
+ for (const m of lower.matchAll(CJK_RUN)) {
23
+ const chars = [...m[0]];
24
+ for (const c of chars)
25
+ out.add(c);
26
+ for (let i = 0; i + 1 < chars.length; i++)
27
+ out.add(chars[i] + chars[i + 1]);
28
+ }
29
+ return [...out];
13
30
  }
14
- /** Meaningful tokens: ≥3 chars, not a stopword (core口径). */
31
+ /** CJK 词条豁免长度/停词判(core 5.49 `CJK_LEAD` 同形:单字即显著);拉丁保持 ≥3 且非停词。 */
32
+ const CJK_LEAD = /^[぀-ヿㇰ-ㇿ㐀-䶿一-鿿가-힣豈-﫿]/;
15
33
  export function significant(s) {
16
- return tokens(s).filter((t) => t.length >= 3 && !STOP.has(t));
34
+ return tokens(s).filter((t) => (CJK_LEAD.test(t) ? true : t.length >= 3 && !STOP.has(t)));
17
35
  }
18
36
  const RING = 1000; // most-recent completed tasks per scope (= search AI's N=1000)
19
37
  const MAX_SCOPES = 2000; // LRU cap on tracked scopes — bounds memory under high tenant cardinality
@@ -38,12 +56,16 @@ export class PlanCacheProbe {
38
56
  const scope = scopeRaw && scopeRaw.length > 0 ? scopeRaw : "anon";
39
57
  const qSig = significant(objective);
40
58
  const st = this.touch(scope);
41
- // Hit = some historical (completed) task's full token set ⊇ this task's significant tokens.
59
+ // Hit = some historical (completed) task's full token set ⊇ this task's significant tokens,
60
+ // OR(core 5.49 compileStrategyQuery 的 legacy acceptance 臂,合并窗重扫 medium)⊇ 其拉丁/数字
61
+ // 子集 qAlnum(非空时)——changelog 原句 "old hits preserved via a legacy acceptance arm"。
62
+ // 少这条臂时「CJK 团不全匹配但拉丁标识符全匹配」的真命中被判 miss ⇒ recurrence 系统性低估。
42
63
  // qSig empty → never a hit (mirrors find()'s `qSig.length===0 → []`, no injection).
43
64
  let hit = false;
44
65
  if (qSig.length > 0) {
66
+ const qAlnum = qSig.filter((t) => !CJK_LEAD.test(t));
45
67
  for (const hist of st.ring) {
46
- if (qSig.every((t) => hist.has(t))) {
68
+ if (qSig.every((t) => hist.has(t)) || (qAlnum.length > 0 && qAlnum.every((t) => hist.has(t)))) {
47
69
  hit = true;
48
70
  break;
49
71
  }
@@ -1,6 +1,7 @@
1
- import type { AcquiredSession, RetentionDeclaration, SessionStore, SessionTreeEntry } from "@sema-agent/core";
1
+ import type { AcquiredSession, PlacedSessionRow, RetentionDeclaration, SessionPlacementRecord, SessionStore, SessionTreeEntry } from "@sema-agent/core";
2
2
  import type { SessionListItem } from "../security.js";
3
3
  import type { StagingHandle } from "../session-sync-kernel.js";
4
+ import { type PlacementAcquireOpts } from "./session-placement.js";
4
5
  export interface CachingOptions {
5
6
  /** Idle seconds a woken session is kept warm. 0 disables caching (pure passthrough). */
6
7
  ttlSec: number;
@@ -105,10 +106,25 @@ export declare class CachingSessionStore implements SessionStore {
105
106
  * 构造期常量,一个会变的声明本身就是缺陷。缺席则不设(保持 fail-closed 的缺席读法)。
106
107
  */
107
108
  readonly retention?: RetentionDeclaration;
109
+ /**
110
+ * #292 C18 —— 内层店的 **placement 声明**转发。
111
+ *
112
+ * 🔴 这一格与 {@link retention} 是同一条教训的同一族(而且更狠):生产默认 `SESSION_CACHE_TTL_SEC=300`
113
+ * 把真店包进本类,于是 boot 递给 core 的就是**包装层**。core 的铸造门判「row store ∧ **session 店声明**」
114
+ * ⇒ 不转发 = 整条 subagent 转录持久化在默认部署上**静默不生效**(placement 参数被合法忽略、子代转录
115
+ * 退回普通会话、声明的 durable tier 变成一句空话),而且没有任何一处会报错。
116
+ * 只读快照而非 getter:声明是店的构造期常量(会变的声明本身就是缺陷)。
117
+ */
118
+ readonly placements?: SessionStore["placements"];
119
+ /** #292:placed 分区的两只读面(reap 分区腿 / 准入探针)——同款转发理由:reaper 与准入判据拿到的
120
+ * 是包装层,不转发 = 分区腿静默不可用、暖命中的 claim 关门静默失效。缺席则不设(诚实降级)。 */
121
+ readonly listPlaced?: (kind: "subagent", opts?: {
122
+ olderThanMs?: number;
123
+ scope?: string;
124
+ }) => Promise<PlacedSessionRow[]>;
125
+ readonly placementOf?: (sessionId: string) => Promise<SessionPlacementRecord | undefined>;
108
126
  constructor(inner: SessionStore, opts: CachingOptions);
109
- acquire(sessionId?: string, opts?: {
110
- requireExisting?: boolean;
111
- }): Promise<AcquiredSession>;
127
+ acquire(sessionId?: string, opts?: PlacementAcquireOpts): Promise<AcquiredSession>;
112
128
  touch(sessionId: string): Promise<void>;
113
129
  release(sessionId: string): Promise<void>;
114
130
  /** Drop a session from the warm cache (e.g. after a cross-instance write conflict). */
@@ -1,3 +1,4 @@
1
+ import { assertPlacementAdmission } from "./session-placement.js";
1
2
  /**
2
3
  * Warm/sticky cache in front of a durable {@link SessionStore} (Option A).
3
4
  *
@@ -79,12 +80,34 @@ export class CachingSessionStore {
79
80
  * 构造期常量,一个会变的声明本身就是缺陷。缺席则不设(保持 fail-closed 的缺席读法)。
80
81
  */
81
82
  retention;
83
+ /**
84
+ * #292 C18 —— 内层店的 **placement 声明**转发。
85
+ *
86
+ * 🔴 这一格与 {@link retention} 是同一条教训的同一族(而且更狠):生产默认 `SESSION_CACHE_TTL_SEC=300`
87
+ * 把真店包进本类,于是 boot 递给 core 的就是**包装层**。core 的铸造门判「row store ∧ **session 店声明**」
88
+ * ⇒ 不转发 = 整条 subagent 转录持久化在默认部署上**静默不生效**(placement 参数被合法忽略、子代转录
89
+ * 退回普通会话、声明的 durable tier 变成一句空话),而且没有任何一处会报错。
90
+ * 只读快照而非 getter:声明是店的构造期常量(会变的声明本身就是缺陷)。
91
+ */
92
+ placements;
93
+ /** #292:placed 分区的两只读面(reap 分区腿 / 准入探针)——同款转发理由:reaper 与准入判据拿到的
94
+ * 是包装层,不转发 = 分区腿静默不可用、暖命中的 claim 关门静默失效。缺席则不设(诚实降级)。 */
95
+ listPlaced;
96
+ placementOf;
82
97
  constructor(inner, opts) {
83
98
  this.inner = inner;
84
99
  this.ttlMs = Math.max(0, opts.ttlSec) * 1000;
85
100
  const declared = inner.retention;
86
101
  if (declared !== undefined)
87
102
  this.retention = declared;
103
+ // #292:声明 + 两只读面逐字转发(理由见各自的字段注)。
104
+ if (inner.placements !== undefined)
105
+ this.placements = inner.placements;
106
+ if (typeof inner.listPlaced === "function")
107
+ this.listPlaced = (kind, o) => inner.listPlaced(kind, o);
108
+ const innerPlacementOf = inner.placementOf;
109
+ if (typeof innerPlacementOf === "function")
110
+ this.placementOf = (id) => innerPlacementOf.call(inner, id);
88
111
  const ownerAware = inner;
89
112
  if (ownerAware.ownerOf)
90
113
  this.ownerOf = ownerAware.ownerOf.bind(inner);
@@ -187,6 +210,13 @@ export class CachingSessionStore {
187
210
  return fresh;
188
211
  }
189
212
  const hit = this.cache.get(sessionId);
213
+ // 🔴 #292 义务 6(claim 关门)在**暖命中**上的半场:一次 claim 形 acquire 撞上缓存里的 placed 会话,
214
+ // 若按下面的「命中即返回」放行,包装层就成了义务的一个**旁路**——同一条 id 在冷缓存上被拒、在暖
215
+ // 缓存上被接受,而生产默认恰恰是暖的。判据仍吃**持久化元组**(一次 PK 探针,只在声明了分区的内层
216
+ // 店 + 非 requireExisting 的形上付),绝不用缓存里的影子事实当准入依据。
217
+ if (hit && opts?.requireExisting !== true && this.placements !== undefined && this.placementOf !== undefined) {
218
+ assertPlacementAdmission(sessionId, await this.placementOf(sessionId), opts);
219
+ }
190
220
  if (hit) {
191
221
  // A warm cache hit means the session EXISTS → it satisfies `requireExisting` (no need to re-open the durable
192
222
  // log). design/114 Phase3: the fail-loud only ever fires on a genuinely-missing (uncached) session below.
@@ -208,8 +238,19 @@ export class CachingSessionStore {
208
238
  return a;
209
239
  }
210
240
  const inflight = this.pending.get(sessionId);
211
- if (inflight)
212
- return inflight;
241
+ if (inflight) {
242
+ // 🔴 #292(codex 对抗复审 R1-[high],验真后修):**in-flight 合并也要逐调用者复判**准入。
243
+ // 病:两个并发调用一冷一后到,后到者直接拿走**首个**调用者的 promise —— 于是
244
+ // · 可信 placement 创建先到 + claim 形后到 ⇒ 后者当场附着到一条 placed 转录上(义务 6 被绕过);
245
+ // · 顺序反过来 ⇒ placement 创建方拿到一只**普通**会话(镜像方向的 squat 判本该拒,却被合并吞掉)。
246
+ // 内层 SQL 店自己有逐调用者复判(它的 pending 分支同款),但**默认生产形是本包装层**,而本层在
247
+ // 到达内层之前就把第二个调用者截断了 —— 判据被一层缓存挡在门外,正是本类反复吃过的那一类亏。
248
+ const acquired = await inflight;
249
+ if (this.placements !== undefined && this.placementOf !== undefined) {
250
+ assertPlacementAdmission(sessionId, await this.placementOf(sessionId), opts);
251
+ }
252
+ return acquired;
253
+ }
213
254
  const p = this.inner
214
255
  .acquire(sessionId, opts)
215
256
  .then((a) => {
@@ -23,7 +23,12 @@
23
23
  * 路由表 LRU 上限 {@link ROUTE_MAX}:满额逐最老(refresh-on-use);被逐后下次 `acquire` 按上述规则重derive
24
24
  * (requireExisting 的 host-first 重试仍落对店),中途 touch/noteTaskRun 缺路由=落 transient(no-op 级)。
25
25
  */
26
- import type { SessionStore, AcquiredSession, SessionStoreSummary } from "@sema-agent/core";
26
+ import { type SessionStore, type AcquiredSession, type SessionStoreSummary, type SessionPlacement, type PlacedSessionRow } from "@sema-agent/core";
27
+ /** acquire 选项形(core 5.48 SessionStore 契约):#292 placement=受信 spawner 的分区建会话声明。 */
28
+ type AcquireOpts = {
29
+ requireExisting?: boolean;
30
+ placement?: SessionPlacement;
31
+ };
27
32
  export declare class ForkRoutingSessionStore implements SessionStore {
28
33
  private readonly host;
29
34
  private readonly transient;
@@ -35,12 +40,28 @@ export declare class ForkRoutingSessionStore implements SessionStore {
35
40
  * 亲读),逐出后 transient-first 的 requireExisting 会把陈旧快照端回给 revive。本集不逐出(上界=
36
41
  * parked 子代数,天然小);pick/acquire 首查。 */
37
42
  private readonly promoted;
43
+ /** #292 placement 半场:placed id 名册(与 promoted 同姿势——不随路由 LRU 逐出)。placed 会话的
44
+ * 真居所恒=host(分区在 durable 店),任何形的 acquire/touch/release 都不得落 transient——
45
+ * 否则 transient 的 claim-create 会铸出同 id 影子会话,requireExisting 的 transient-first 顺序
46
+ * 会让影子遮蔽真转录(F-7 顺序对 placed 反向成害)。 */
47
+ private readonly placed;
48
+ /** #292:host 店的 placements 声明透传(声明制——包装层不 duck-type,host 声明了才武装 placed
49
+ * 路由;host 不声明时本店全行为与 5.47 前逐字节同)。 */
50
+ readonly placements?: SessionStore["placements"];
51
+ /** #292:分区枚举透传(host 有才有——恒在的空实现会把「不能枚举」谎报成「没有 placed 会话」)。 */
52
+ readonly listPlaced?: (kind: "subagent", opts?: {
53
+ olderThanMs?: number;
54
+ scope?: string;
55
+ }) => Promise<PlacedSessionRow[]>;
38
56
  constructor(host: SessionStore, transient: SessionStore);
39
57
  private remember;
40
58
  private pick;
41
- acquire(sessionId?: string, opts?: {
42
- requireExisting?: boolean;
43
- }): Promise<AcquiredSession>;
59
+ /** #292:host 声明分区时的冷缓存判位探针。判据=host `requireExisting` acquire `SessionMetadata.placement`
60
+ * (5.48 additive,File/SQL/Ttl 三形都带——比 `placementOf` 面更普适:Ttl 缓存层不透传 placementOf)。
61
+ * placed 命中把 acquired 会话一并交回(调用方正需要它时零二次 acquire);非 placed/absent 的探针
62
+ * acquire 一律 **forget** 收尾([4743] 义务①:验存在的 acquire 禁 release——release(placed)=真删)。 */
63
+ private hostPlacedProbe;
64
+ acquire(sessionId?: string, opts?: AcquireOpts): Promise<AcquiredSession>;
44
65
  touch(sessionId: string): void | Promise<void>;
45
66
  release(sessionId: string): Promise<void>;
46
67
  forget(sessionId: string): void | Promise<void>;
@@ -70,4 +91,5 @@ export declare class ForkRoutingSessionStore implements SessionStore {
70
91
  get size(): number;
71
92
  dispose(): void | Promise<void>;
72
93
  }
94
+ export {};
73
95
  //# sourceMappingURL=fork-routing-session-store.d.ts.map