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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/USAGE.md +2 -1
  2. package/dist/adoption/plan.js +16 -0
  3. package/dist/boot/budget-tracing.d.ts +1 -1
  4. package/dist/boot/budget-tracing.js +12 -5
  5. package/dist/boot/config-center.d.ts +27 -2
  6. package/dist/boot/config-center.js +148 -15
  7. package/dist/boot/limit-sync.d.ts +32 -0
  8. package/dist/boot/limit-sync.js +35 -0
  9. package/dist/boot/reapers.d.ts +68 -0
  10. package/dist/boot/reapers.js +63 -4
  11. package/dist/boot/resolve-spec.js +2 -0
  12. package/dist/boot/runner-deps.d.ts +1 -1
  13. package/dist/boot/runner-deps.js +4 -0
  14. package/dist/boot/stores.js +13 -0
  15. package/dist/config-center/apply-effective.d.ts +38 -3
  16. package/dist/config-center/apply-effective.js +75 -5
  17. package/dist/config-center/apply-ledger.d.ts +67 -0
  18. package/dist/config-center/apply-ledger.js +83 -0
  19. package/dist/config-center/facade.d.ts +1 -1
  20. package/dist/config-center/hot-keys-registry.d.ts +62 -0
  21. package/dist/config-center/hot-keys-registry.js +54 -0
  22. package/dist/config-center/http-client.js +12 -2
  23. package/dist/config-center/restart-signal.js +5 -2
  24. package/dist/config-center/stage-limits.d.ts +59 -0
  25. package/dist/config-center/stage-limits.js +188 -0
  26. package/dist/config-center/types.d.ts +60 -0
  27. package/dist/config-invariants.d.ts +65 -0
  28. package/dist/config-invariants.js +139 -0
  29. package/dist/config-provider.js +11 -0
  30. package/dist/config-types.d.ts +10 -1
  31. package/dist/config.d.ts +11 -0
  32. package/dist/config.js +37 -5
  33. package/dist/http/routes/admin-config-refresh.d.ts +20 -0
  34. package/dist/http/routes/admin-config-refresh.js +43 -0
  35. package/dist/http/routes/diagnostics.js +4 -0
  36. package/dist/http/server.d.ts +8 -0
  37. package/dist/http/server.js +23 -0
  38. package/dist/main.js +49 -6
  39. package/dist/memory-sync.js +50 -1
  40. package/dist/observability/cost-quota.d.ts +23 -3
  41. package/dist/observability/cost-quota.js +27 -4
  42. package/dist/observability/fail-open.d.ts +8 -0
  43. package/dist/observability/fail-open.js +8 -0
  44. package/dist/observability/rate-limit.d.ts +11 -1
  45. package/dist/observability/rate-limit.js +8 -2
  46. package/dist/plan-cache-probe.d.ts +5 -7
  47. package/dist/plan-cache-probe.js +27 -5
  48. package/dist/plugins/caching-session-store.d.ts +20 -4
  49. package/dist/plugins/caching-session-store.js +43 -2
  50. package/dist/plugins/fork-routing-session-store.d.ts +26 -4
  51. package/dist/plugins/fork-routing-session-store.js +134 -9
  52. package/dist/plugins/memory-engine-pg.d.ts +1 -0
  53. package/dist/plugins/memory-engine-pg.js +34 -4
  54. package/dist/plugins/memory-engine-tidb.d.ts +1 -0
  55. package/dist/plugins/memory-engine-tidb.js +17 -2
  56. package/dist/plugins/memory-origin-law.d.ts +69 -0
  57. package/dist/plugins/memory-origin-law.js +118 -1
  58. package/dist/plugins/pg-cost-quota.d.ts +7 -2
  59. package/dist/plugins/pg-cost-quota.js +25 -2
  60. package/dist/plugins/pg-rate-limiter.d.ts +7 -2
  61. package/dist/plugins/pg-rate-limiter.js +20 -2
  62. package/dist/plugins/pg-session-storage.d.ts +30 -7
  63. package/dist/plugins/pg-session-storage.js +145 -23
  64. package/dist/plugins/retention-store-sql.js +10 -1
  65. package/dist/plugins/session-placement.d.ts +113 -0
  66. package/dist/plugins/session-placement.js +153 -0
  67. package/dist/plugins/session-store.js +7 -1
  68. package/dist/plugins/tidb-cost-quota.d.ts +7 -2
  69. package/dist/plugins/tidb-cost-quota.js +26 -2
  70. package/dist/plugins/tidb-pool.js +15 -1
  71. package/dist/plugins/tidb-rate-limiter.d.ts +9 -2
  72. package/dist/plugins/tidb-rate-limiter.js +22 -2
  73. package/dist/plugins/tidb-session-storage.js +10 -2
  74. package/dist/plugins/tidb-session-store.d.ts +55 -7
  75. package/dist/plugins/tidb-session-store.js +174 -24
  76. package/dist/plugins/write-behind-counter.d.ts +17 -2
  77. package/dist/plugins/write-behind-counter.js +19 -1
  78. package/dist/runs.d.ts +3 -1
  79. package/dist/runs.js +5 -2
  80. package/dist/trace/engine-notice-wire.d.ts +1 -1
  81. package/dist/trace/engine-notice-wire.js +24 -6
  82. package/package.json +3 -3
@@ -161,6 +161,14 @@ export class LocalConfigProvider {
161
161
  onWarning: (w) => {
162
162
  if (w.kind === "domain-defaulted")
163
163
  parseWarnings.push({ domain: w.domain, error: w.error instanceof Error ? w.error.message : String(w.error) });
164
+ // registry-core 0.19.0 响亮化三 kind(LANDING-7.38 接线件③):dropped/unread=operator 写了但没生效
165
+ // (与 defaulted 同级),carried=承运但键表方(stage-limits)才认——都入诊断面,禁静默丢。
166
+ else if (w.kind === "unknown-keys-dropped")
167
+ parseWarnings.push({ domain: w.domain, error: `unknown keys dropped by domain schema: ${w.keys.join(", ")}` });
168
+ else if (w.kind === "unknown-keys-carried")
169
+ parseWarnings.push({ domain: w.domain, error: `unknown keys carried to consumer (spelling?): ${w.keys.join(", ")}` });
170
+ else if (w.kind === "unread-config-file")
171
+ parseWarnings.push({ domain: w.domain ?? "config.d", error: `unread config file ${w.file} (${w.why})` });
164
172
  },
165
173
  });
166
174
  const { effective: eff, domainErrors } = await store.getEffective({ tolerant: true });
@@ -333,6 +341,9 @@ export function mapToServiceEffective(eff, version) {
333
341
  ...(eff.runtime.maxPrincipalCostUsd !== undefined ? { maxPrincipalCostUsd: eff.runtime.maxPrincipalCostUsd } : {}),
334
342
  ...(eff.runtime.costQuotaWindowSec !== undefined ? { costQuotaWindowSec: eff.runtime.costQuotaWindowSec } : {}),
335
343
  },
344
+ // #322 批1(registry-core 0.19.0 第 17 域):`limits` 整包透传——本地腿的最后一米在此收口;漏了
345
+ // 这行,14 键在 local lane 整域消失、继续吃 env 基线(LANDING-7.38 接线件②的事故形)。
346
+ ...(eff.limits !== undefined ? { limits: eff.limits } : {}),
336
347
  // Governance trio (autonomy/commandPolicy/approvalRequire): moved OUT of runtime into the dedicated
337
348
  // governance domain (registry-core 0.6, runtime double-write mirror removed in 0.7.1 — buildEffective
338
349
  // still READ-lifts legacy raw runtime values into governance, so old on-disk config keeps working).
@@ -449,6 +449,15 @@ export interface ServiceConfigFlat {
449
449
  * ⚠️ **不冻进 checkpoint**:resume 的那条腿跟**当前**部署配置走(core 成文)。
450
450
  * 运维读面 = `GET /v1/diagnostics/wiring` 的 `memoryPosture.provenance`(`null`=本部署未设)。 */
451
451
  memoryProvenance?: "off" | "carry";
452
+ /** debt #322-B(core 5.48 C18 提货件):委派入口 caps 的部署旋钮 —— env `DELEGATION_MAX_CONCURRENT` /
453
+ * `DELEGATION_MAX_PER_SESSION`(整数,numEnvBounded 响亮拒坏值)。**缺席 = 不铸键**(core 自缺省
454
+ * CC parity 20/200;`memoryDelegationEvidence` 同姿态);两键各自独立可设,pair 合法性
455
+ * (maxConcurrent ≤ maxCumulativePerSession)由 core prepare 的 `config.delegation_entry_caps`
456
+ * 响亮拒承担,本层不复刻第二判官。部署席 ONLY(TaskSpec 无同名键)。 */
457
+ delegationEntryCaps?: {
458
+ maxConcurrent?: number;
459
+ maxCumulativePerSession?: number;
460
+ };
452
461
  /** 件A 单机形目录:env `MEMORY_ORG_DIRECTORY_JSON` 原文(`Record<principal, Record<org:scope, {write?}>>`)。
453
462
  * config 解析期即整段校验(parseOrgDirectoryStatic,fail-loud);此处存**原文**(config=纯数据,
454
463
  * Map 在装配点重建)。与 config-center 目录腿互斥的裁决在装配点(两者都在=center 腿胜,env 表忽略
@@ -1392,7 +1401,7 @@ export type ServiceMemoryConfig = Pick<ServiceConfigFlat, "memoryEngineEnabled"
1392
1401
  * config 这一层它只是一句声明。 */
1393
1402
  export type ServiceAuthConfig = Pick<ServiceConfigFlat, "authToken" | "authTokens" | "allowUnauthedWrites" | "corsOrigins" | "principalHeader" | "requirePrincipal" | "autonomy" | "commandPolicy" | "compliancePosture" | "lockedConfigKeys" | "retentionPolicy" | "retentionSweep" | "operatorPrincipals" | "principalJwtPubkeys" | "principalJwtIss" | "principalJwtAud" | "principalJwtMaxTtlSec" | "bindHost" | "bindHostSource">;
1394
1403
  /** 组:orchestration(编排 + 执行车道 + 沙箱面 + workflow/后台 agent 存留)。 */
1395
- export type ServiceOrchestrationConfig = Pick<ServiceConfigFlat, "remoteExec" | "worktreeIsolation" | "leaderEnabled" | "leaderFanoutEnabled" | "routerEnabled" | "selfOrchestrationEnabled" | "selfOrchestrationModels" | "selfOrchestrationWorkerIsolation" | "forkEnabled" | "experimentalObserverAgents" | "schedulerEnabled" | "schedulerSessionWakeup" | "schedulerSessionLifetime" | "schedulerStorePath" | "planModeEnabled" | "workflowRunStoreBackend" | "workflowOrphanGraceMs" | "workflowJournalRetentionMs" | "workflowRunRetentionMs" | "usageWindows" | "workflowAgentsReadOnly" | "workflowSizeGuideline" | "backgroundAgentRetentionMs" | "backgroundAgentStaleRunningMs" | "backgroundAgentParkClaimStaleMs" | "rosterRetentionMs" | "leaderRunStaleMs" | "scratchpadSweepTtlMs" | "sandboxPkgSource" | "sessionAutoTitle" | "selectEnvironmentTool" | "envFactsEnabled" | "toolDeferLongtail" | "lspEnabled" | "lspHostEnabled" | "imageBakes" | "readFace" | "readDenyPatterns" | "readDenyBuiltinTiers" | "readDenyBuiltinExclude">;
1404
+ export type ServiceOrchestrationConfig = Pick<ServiceConfigFlat, "remoteExec" | "worktreeIsolation" | "leaderEnabled" | "leaderFanoutEnabled" | "routerEnabled" | "selfOrchestrationEnabled" | "selfOrchestrationModels" | "selfOrchestrationWorkerIsolation" | "forkEnabled" | "experimentalObserverAgents" | "schedulerEnabled" | "schedulerSessionWakeup" | "schedulerSessionLifetime" | "schedulerStorePath" | "planModeEnabled" | "workflowRunStoreBackend" | "workflowOrphanGraceMs" | "workflowJournalRetentionMs" | "workflowRunRetentionMs" | "usageWindows" | "workflowAgentsReadOnly" | "workflowSizeGuideline" | "backgroundAgentRetentionMs" | "backgroundAgentStaleRunningMs" | "backgroundAgentParkClaimStaleMs" | "delegationEntryCaps" | "rosterRetentionMs" | "leaderRunStaleMs" | "scratchpadSweepTtlMs" | "sandboxPkgSource" | "sessionAutoTitle" | "selectEnvironmentTool" | "envFactsEnabled" | "toolDeferLongtail" | "lspEnabled" | "lspHostEnabled" | "imageBakes" | "readFace" | "readDenyPatterns" | "readDenyBuiltinTiers" | "readDenyBuiltinExclude">;
1396
1405
  /** 组:limitsHttp(HTTP 面 + 各类上限/配额/回收窗)。 */
1397
1406
  export type ServiceLimitsHttpConfig = Pick<ServiceConfigFlat, "port" | "attachmentOrphanGraceMs" | "workspaceFileMaxBytes" | "attachmentMaxBytes" | "attachmentMimeAllowlist" | "attachmentUnboundTtlMs" | "infraCostRates" | "drainGraceMs" | "sighupIdleGraceMs" | "parentPid" | "rateLimitPerMin" | "maxTaskCostUsd" | "maxTaskTokens" | "maxPrincipalCostUsd" | "costQuotaWindowSec" | "reapIntervalSec" | "runStaleSec" | "toolResultTtlSec">;
1398
1407
  /** 组:observability(可观测)。 */
package/dist/config.d.ts CHANGED
@@ -200,6 +200,17 @@ export declare function formatUnmatchableToolNames(source: string, bad: readonly
200
200
  /** #270 车2 —— sweep 节律的上界(秒)。`setInterval` 的 delay 是 32 位有符号毫秒(2^31-1);越界会被
201
201
  * Node **重置成 1ms**,理由与拒启判据见调用点。导出供判据与文档单点引用。 */
202
202
  export declare const RETENTION_SWEEP_INTERVAL_MAX_SEC: number;
203
+ /** 域:limits+http(限额与 HTTP 面)—— 端口、附件/工作区体积与 TTL、成本与速率天花板、排空窗、回收周期。 */
204
+ /**
205
+ * #322:limitsHttp 组里**有下限**的三个数值旋钮的下限值 —— env 腿(夹取+点名)与 center 热腿(整批拒)
206
+ * 共用同一组阈值。判据同源、**处置刻意不同**:env 值是既有部署的存量,夹取不打断服务;center 发布是一次
207
+ * 显式且可重发的动作,拒绝才是有意义的反馈(#123 响亮拒)。
208
+ */
209
+ export declare const LIMITS_HTTP_FLOORS: {
210
+ readonly attachmentMaxBytes: 1024;
211
+ readonly attachmentUnboundTtlMs: 60000;
212
+ readonly drainGraceMs: 10000;
213
+ };
203
214
  /** 组名 → 该组取景的平铺键(introspection 面:测试用它钉「每个平铺键恰好被一组取景」)。 */
204
215
  export declare const CONFIG_GROUP_KEYS: {
205
216
  readonly [G in keyof ServiceConfigGroups]: readonly (keyof ServiceConfigFlat)[];
package/dist/config.js CHANGED
@@ -1639,6 +1639,17 @@ function parseMemoryDomain(ctx) {
1639
1639
  ...(process.env.MEMORY_PROVENANCE !== undefined
1640
1640
  ? { memoryProvenance: enumEnv("MEMORY_PROVENANCE", "carry", ["off", "carry"]) }
1641
1641
  : {}),
1642
+ // debt #322-B(core 5.48 C18):委派入口 caps。两 env 各自独立可设;设了=numEnvBounded 启动期
1643
+ // 响亮拒坏值(#210),缺席=键不铸(core 自缺省 CC parity 20/200,不复制上游缺省)。pair 合法性
1644
+ // (concurrent ≤ cumulative)归 core prepare 的 `config.delegation_entry_caps` 门,单判官不复刻。
1645
+ ...(process.env.DELEGATION_MAX_CONCURRENT !== undefined || process.env.DELEGATION_MAX_PER_SESSION !== undefined
1646
+ ? {
1647
+ delegationEntryCaps: {
1648
+ ...(process.env.DELEGATION_MAX_CONCURRENT !== undefined ? { maxConcurrent: numEnvBounded("DELEGATION_MAX_CONCURRENT", "20", 1, 10_000) } : {}),
1649
+ ...(process.env.DELEGATION_MAX_PER_SESSION !== undefined ? { maxCumulativePerSession: numEnvBounded("DELEGATION_MAX_PER_SESSION", "200", 1, 100_000) } : {}),
1650
+ },
1651
+ }
1652
+ : {}),
1642
1653
  ...(memoryOrgDirectoryJson !== undefined ? { memoryOrgDirectoryJson } : {}),
1643
1654
  memoryOrgGrantTtlMs,
1644
1655
  memoryOrgUnavailableBackoffMs,
@@ -2090,6 +2101,16 @@ function parseOrchestrationDomain(ctx) {
2090
2101
  };
2091
2102
  }
2092
2103
  /** 域:limits+http(限额与 HTTP 面)—— 端口、附件/工作区体积与 TTL、成本与速率天花板、排空窗、回收周期。 */
2104
+ /**
2105
+ * #322:limitsHttp 组里**有下限**的三个数值旋钮的下限值 —— env 腿(夹取+点名)与 center 热腿(整批拒)
2106
+ * 共用同一组阈值。判据同源、**处置刻意不同**:env 值是既有部署的存量,夹取不打断服务;center 发布是一次
2107
+ * 显式且可重发的动作,拒绝才是有意义的反馈(#123 响亮拒)。
2108
+ */
2109
+ export const LIMITS_HTTP_FLOORS = {
2110
+ attachmentMaxBytes: 1024,
2111
+ attachmentUnboundTtlMs: 60_000,
2112
+ drainGraceMs: 10_000,
2113
+ };
2093
2114
  function parseLimitsHttpDomain() {
2094
2115
  return {
2095
2116
  // #210 A 档:裸 Number ⇒ NaN 直进 `server.listen(NaN)`(Node 当成 0 = 随机端口),于是服务起来了、
@@ -2115,11 +2136,11 @@ function parseLimitsHttpDomain() {
2115
2136
  })(process.env.ATTACHMENT_ORPHAN_GRACE_MS),
2116
2137
  // workspace 浏览面(#3,[1894]①)单文件读上限。
2117
2138
  workspaceFileMaxBytes: optFinitePositiveEnv("WORKSPACE_FILE_MAX_BYTES") ?? 8 * 1024 * 1024,
2118
- attachmentMaxBytes: clampEnvWithWarn("ATTACHMENT_MAX_BYTES", String(32 * 1024 * 1024), 1024), // #210 B 档
2139
+ attachmentMaxBytes: clampEnvWithWarn("ATTACHMENT_MAX_BYTES", String(32 * 1024 * 1024), LIMITS_HTTP_FLOORS.attachmentMaxBytes), // #210 B 档
2119
2140
  ...(process.env.ATTACHMENT_MIME_ALLOWLIST
2120
2141
  ? { attachmentMimeAllowlist: process.env.ATTACHMENT_MIME_ALLOWLIST.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean) }
2121
2142
  : {}),
2122
- attachmentUnboundTtlMs: clampEnvWithWarn("ATTACHMENT_UNBOUND_TTL_MS", String(24 * 3600 * 1000), 60_000), // #210 B 档
2143
+ attachmentUnboundTtlMs: clampEnvWithWarn("ATTACHMENT_UNBOUND_TTL_MS", String(24 * 3600 * 1000), LIMITS_HTTP_FLOORS.attachmentUnboundTtlMs), // #210 B 档
2123
2144
  infraCostRates: {
2124
2145
  // #210 B 档:负单价夹到 0 是对的(负成本会让账面倒扣),但夹取必须点名——不然 operator 只看到
2125
2146
  // 基建成本恒 0,查不到是自己那个负号被吃掉了。
@@ -2135,7 +2156,7 @@ function parseLimitsHttpDomain() {
2135
2156
  return v;
2136
2157
  })(),
2137
2158
  },
2138
- drainGraceMs: clampEnvWithWarn("DRAIN_GRACE_MS", String(10 * 60 * 1000), 10_000), // SIGTERM drain window, default 10min, floor 10s(#210 B 档:把 600 当成"秒"填进来的手滑最常见,夹取后必须点名)
2159
+ drainGraceMs: clampEnvWithWarn("DRAIN_GRACE_MS", String(10 * 60 * 1000), LIMITS_HTTP_FLOORS.drainGraceMs), // SIGTERM drain window, default 10min, floor 10s(#210 B 档:把 600 当成"秒"填进来的手滑最常见,夹取后必须点名)
2139
2160
  sighupIdleGraceMs: clampEnvWithWarn("SIGHUP_IDLE_GRACE_MS", String(120_000), 5_000), // B3: SIGHUP idle-shutdown window, default 2min, floor 5s
2140
2161
  ...parentPidEnv(), // #219:壳 pid 存活监视,缺席 ⇒ 键缺席 ⇒ 整件不装配
2141
2162
  // BL-9: numEnv (not bare Number) — a non-numeric typo FAILS at startup instead of becoming NaN, which would
@@ -2375,7 +2396,7 @@ const ORCHESTRATION_GROUP_KEYS = [
2375
2396
  "schedulerEnabled", "schedulerSessionWakeup", "schedulerSessionLifetime", "schedulerStorePath", "planModeEnabled", "workflowRunStoreBackend",
2376
2397
  "workflowOrphanGraceMs", "workflowJournalRetentionMs", "workflowRunRetentionMs", "usageWindows", "workflowAgentsReadOnly",
2377
2398
  "workflowSizeGuideline", "backgroundAgentRetentionMs", "backgroundAgentStaleRunningMs",
2378
- "backgroundAgentParkClaimStaleMs", "rosterRetentionMs", "leaderRunStaleMs", "scratchpadSweepTtlMs", "sandboxPkgSource",
2399
+ "backgroundAgentParkClaimStaleMs", "delegationEntryCaps", "rosterRetentionMs", "leaderRunStaleMs", "scratchpadSweepTtlMs", "sandboxPkgSource",
2379
2400
  "sessionAutoTitle", "selectEnvironmentTool", "envFactsEnabled", "toolDeferLongtail", "lspEnabled", "lspHostEnabled",
2380
2401
  "imageBakes", "readFace", "readDenyPatterns", "readDenyBuiltinTiers", "readDenyBuiltinExclude",
2381
2402
  ];
@@ -2482,8 +2503,12 @@ export function loadConfig() {
2482
2503
  const limitsHttp = parseLimitsHttpDomain();
2483
2504
  const observability = parseObservabilityDomain();
2484
2505
  const integrations = parseIntegrationsDomain();
2485
- // ── 跨域耦合与跨域不变量:两条门(D-G 直连门、bake 门)的判据各自横跨 approval/auth/orchestration 三域,
2506
+ // ── CROSS-INVARIANT-REGION:begin ────────────────────────────────────────────────────────────────
2507
+ // 跨域耦合与跨域不变量:两条门(D-G 直连门、bake 门)的判据各自横跨 approval/auth/orchestration 三域,
2486
2508
  // 任何单域都不足以裁定,故留在装配层(旧名保留,断言与消息逐字不变)。
2509
+ // 🔴 #322:本区内的每一条拒启 throw 必须挂 `CROSS-INVARIANT:<id>` 锚,且 id 在
2510
+ // `src/config-invariants.ts` 的 CONFIG_CROSS_INVARIANTS 表内 —— 配置改热之后,一条**只活在 boot**
2511
+ // 的不变量就是热路径上的静默漏检。机械门:test/config-hot/batch1-cross-invariants.test.ts(双向)。
2487
2512
  const { directApprovalDoor, durableApproval: dgDurable, approvalHmacKeys: dgHmacKeys } = approval;
2488
2513
  const { principalJwtPubkeys, principalJwtIss, principalJwtAud, operatorPrincipals: dgOperators, authTokens } = auth;
2489
2514
  const { enabled: imageBakesEnabled, runnerPrincipal: bakeRunnerPrincipal } = orchestration.imageBakes;
@@ -2513,6 +2538,7 @@ export function loadConfig() {
2513
2538
  if (!requirePrincipal)
2514
2539
  missing.push("REQUIRE_PRINCIPAL=true (a direct door is multi-tenant; without it an absent principal is anonymous and the spoofable header governs owner/quota checks)");
2515
2540
  if (missing.length) {
2541
+ // CROSS-INVARIANT:direct-door-anchors
2516
2542
  throw new Error(`DIRECT_APPROVAL_DOOR=true but D-G anchors are missing: ${missing.join(", ")} — refusing to start a half-open direct door (design/80 D-G boot invariant)`);
2517
2543
  }
2518
2544
  }
@@ -2526,12 +2552,14 @@ export function loadConfig() {
2526
2552
  // single-tenant legacy semantic (REQUIRE_PRINCIPAL unset — the shared service token IS the operator boundary)
2527
2553
  // is deliberately preserved: only the multi-tenant form is a hole. Refuse to start it.
2528
2554
  if (dgDurable && requirePrincipal && dgOperators.length === 0) {
2555
+ // CROSS-INVARIANT:operator-principals-required
2529
2556
  throw new Error("DURABLE_APPROVAL=true + REQUIRE_PRINCIPAL=true but OPERATOR_PRINCIPALS is empty — on a multi-tenant worker the empty operator set makes isOperator() true-for-all, so every authenticated tenant could read every other tenant's pending-approval queue (GET /v1/approvals & siblings). Set OPERATOR_PRINCIPALS, or unset REQUIRE_PRINCIPAL if this is a single-tenant deployment (#157-① boot invariant)");
2530
2557
  }
2531
2558
  // 🔴 boot invariant (IMAGE-API-DESIGN.md §P2.4a): the bake door is build-host-RCE-capable, and isOperator([],p)
2532
2559
  // is true-for-all — so enabling bakes with an empty OPERATOR_PRINCIPALS = a WORLD-WRITABLE RCE door. Refuse to
2533
2560
  // start (never a half-open bake door). The explicit-operator gate in server.ts is the runtime twin of this.
2534
2561
  if (imageBakesEnabled && dgOperators.length === 0) {
2562
+ // CROSS-INVARIANT:bake-operator-principals
2535
2563
  throw new Error("IMAGE_BAKES_ENABLED=true but OPERATOR_PRINCIPALS is empty — the bake door runs build.sh on the privileged build host; an empty operator set makes isOperator() true-for-all (world-writable RCE). Set OPERATOR_PRINCIPALS or disable bakes (IMAGE-API-DESIGN.md §P2.4a boot invariant)");
2536
2564
  }
2537
2565
  // 🔴 boot invariant (IMAGE-API-DESIGN.md §P2.12 / H1): the bake-runner identity is DERIVED from its bearer
@@ -2539,6 +2567,7 @@ export function loadConfig() {
2539
2567
  // system == BAKE_RUNNER_PRINCIPAL MUST exist, else the runner can never satisfy isRunner and the entire
2540
2568
  // claim/ingest/heartbeat pipeline is silently dead. Refuse to start a bake door with no runner credential.
2541
2569
  if (imageBakesEnabled && !Object.values(authTokens).includes(bakeRunnerPrincipal)) {
2570
+ // CROSS-INVARIANT:bake-runner-credential
2542
2571
  throw new Error(`IMAGE_BAKES_ENABLED=true but no SERVICE_AUTH_TOKENS entry maps to the runner principal '${bakeRunnerPrincipal}' — the bake-runner authenticates by its credential (token-derived source), so a '<BAKE_RUNNER_TOKEN>=${bakeRunnerPrincipal}' SERVICE_AUTH_TOKENS entry is required or claim/ingest/heartbeat all 403 (IMAGE-API-DESIGN.md §P2.12 boot invariant)`);
2543
2572
  }
2544
2573
  // 🔴 boot invariant #193 车4 件2(台账 P1-10 / docs/DESIGN-193-CAR4-leader-failclosed.md 件2):leader
@@ -2552,6 +2581,7 @@ export function loadConfig() {
2552
2581
  // 竞态写 git 状态:不是报错,是**静默数据损坏**。真修(provisionWorker 给每个 worker 传唯一 workspace)
2553
2582
  // 不在本车;门先行,消灭「启用即损坏」的窗口。
2554
2583
  if (orchestration.leaderEnabled && orchestration.remoteExec?.provider === "host") {
2584
+ // CROSS-INVARIANT:leader-host-lane
2555
2585
  throw new Error("LEADER_ENABLED=true + REMOTE_EXEC=host is refused: the leader wire pins ONE absolute workspace path " +
2556
2586
  "(LEADER_WORKSPACE / the /home/user default) for every sub-worker, while the host adapter gives each env a " +
2557
2587
  "DIFFERENT random directory — so N concurrent host workers would share one physical git worktree and " +
@@ -2564,12 +2594,14 @@ export function loadConfig() {
2564
2594
  // 「多副本」这件事在 config 层判不了(副本数不是本进程的知识),可判的**代理**就是 durable 前提本身:
2565
2595
  // 一个没有外部 SQL 店的部署,与 leader run(N worker、最长 24h、真 push)的语义结构性不相容。
2566
2596
  if (orchestration.leaderEnabled && store.dbBackend !== "mysql" && store.dbBackend !== "pg") {
2597
+ // CROSS-INVARIANT:leader-durable-store
2567
2598
  throw new Error(`LEADER_ENABLED=true but DB_BACKEND=${store.dbBackend} (no external SQL store) is refused: a leader run is an ` +
2568
2599
  "N-worker fan-out lasting up to 24h that ends in a REAL git push, and without a durable store its registry is " +
2569
2600
  "a per-process Map — a cross-replica GET 404s, a restart forgets the run, and a retried POST starts a SECOND " +
2570
2601
  "push. Point the service at a durable store (DB_BACKEND=mysql|pg), or turn the leader off (unset LEADER_ENABLED) " +
2571
2602
  "(#193 车4 件2 / staleness ledger P1-9+P1-10 boot invariant)");
2572
2603
  }
2604
+ // ── CROSS-INVARIANT-REGION:end ──────────────────────────────────────────────────────────────────
2573
2605
  // #157 F 类观测:执行车道落点记账放在这里 —— 九域解析与上面全部跨域不变量都过了之后,即「这一轮
2574
2606
  // 确实启得起来」才成立的位置(写在 REMOTE_EXEC 解析途中的话,后置拒启会留下一条没启起来的落点)。
2575
2607
  EXEC_LANE_NOTICE = buildExecLaneNotice(orchestration.remoteExec);
@@ -0,0 +1,20 @@
1
+ /**
2
+ * #322 批1 追加件(clay 当场裁定,design/322 批1 行):`POST /v1/admin/config/refresh` —— operator 改完
3
+ * 配置**不等 60s tick**,手动触发一次立即拉取。
4
+ *
5
+ * 族属 = `POST /v1/admin/drain` 逐字同族(operator lane:同一条 `explicitOperatorOk` 门、billable=false、
6
+ * 刻意不吃 drain / roster-pending 两道 503 —— 运维在停机窗里也要打得通自己的口)。门序照那边:
7
+ * 身份 → 授权 →(本口无 body,无验型步)。
8
+ *
9
+ * 🔴 语义 = **触发一次既有的 refresh 拍**,不是「再实现一遍拉取」:实现面是
10
+ * `configCenter.refreshNow()`,它与 60s cadence 共用同一只 join 形拍(在飞则 join,绝不并发双拍)。
11
+ * 回执里的两个世代号取自**同一本**世代账(`/health` 的三键同源)—— 两面各算一次迟早会各说各话。
12
+ *
13
+ * 🔴 没有配置管道(纯 env 形)⇒ 诚实 409 指路,**不吞成 200**:一个「triggered:true 但什么都没发生」的
14
+ * 回执会让运维以为配置已经拉过了,那比拒绝糟得多。
15
+ */
16
+ import type { IncomingMessage, ServerResponse } from "node:http";
17
+ import type { RouteCtx } from "../route-ctx.js";
18
+ export declare const ADMIN_CONFIG_REFRESH_PATH = "/v1/admin/config/refresh";
19
+ export declare function handleAdminConfigRefresh(req: IncomingMessage, res: ServerResponse, url: string, ctx: RouteCtx): Promise<boolean>;
20
+ //# sourceMappingURL=admin-config-refresh.d.ts.map
@@ -0,0 +1,43 @@
1
+ import { sendJson, sendError } from "../send.js";
2
+ import { gatedPrincipal, explicitOperatorOk } from "../principal-gate.js";
3
+ export const ADMIN_CONFIG_REFRESH_PATH = "/v1/admin/config/refresh";
4
+ const OPERATOR_ONLY_MESSAGE = "config refresh is operator-only: the caller principal is not in OPERATOR_PRINCIPALS (an empty list means NO one is an operator — configure it to enable this endpoint)";
5
+ export async function handleAdminConfigRefresh(req, res, url, ctx) {
6
+ const miss = { fell: false };
7
+ await handleAdminConfigRefreshBody(req, res, url, ctx, miss);
8
+ return !miss.fell;
9
+ }
10
+ async function handleAdminConfigRefreshBody(req, res, url, ctx, miss) {
11
+ const { deps } = ctx;
12
+ const path = url.split("?")[0] ?? url;
13
+ if (req.method !== "POST" || path !== ADMIN_CONFIG_REFRESH_PATH) {
14
+ miss.fell = true;
15
+ return;
16
+ }
17
+ const principal = gatedPrincipal(req, deps.config);
18
+ if (deps.config.requirePrincipal && !principal) {
19
+ sendError(res, 401, "auth.principal_required", `missing principal header '${deps.config.principalHeader}'`);
20
+ return;
21
+ }
22
+ if (!explicitOperatorOk(principal, deps.config.operatorPrincipals)) {
23
+ sendError(res, 403, "auth.operator_only", OPERATOR_ONLY_MESSAGE);
24
+ return;
25
+ }
26
+ const trigger = deps.configRefreshNow;
27
+ const outcome = trigger ? await trigger() : "not-wired";
28
+ if (outcome === "failed") {
29
+ // 🔴 codex R2-F3:定时拍吞掉异常是为了保住循环,但**按下刷新的那个人**必须知道这一拍真的失败了
30
+ // ——「中心超时」与「已刷新」在回执上不可分辨,正是这个口存在的反面。502:失败在依赖侧(配置源)。
31
+ sendError(res, 502, "internal.config_refresh_failed", "the config refresh tick failed (source unreachable / unreadable / apply error) — the live config is unchanged; see the worker's sema_registry_refresh_failed log line for the cause");
32
+ return;
33
+ }
34
+ if (outcome === "not-wired") {
35
+ sendError(res, 409, "config.refresh_unavailable", "this deployment has no config pipeline to refresh — set CONFIG_CENTER (remote control plane) or CONFIG_PROVIDER=local (config.d on disk); an env-only worker has nothing to pull");
36
+ return;
37
+ }
38
+ // 世代号取自世代账(与 /health 的 configTargetVersion/configAppliedVersion 同一份读数)。账缺席只发生在
39
+ // 「触发口在场但世代账没接线」的夹具形 —— 诚实报 0(不编一个看起来像真答案的数)。
40
+ const gen = deps.configApplyState?.();
41
+ sendJson(res, 200, { triggered: true, targetVersion: gen?.configTargetVersion ?? 0, appliedVersion: gen?.configAppliedVersion ?? 0 });
42
+ }
43
+ //# sourceMappingURL=admin-config-refresh.js.map
@@ -162,6 +162,10 @@ async function handleDiagnosticsBody(req, res, url, ctx, miss) {
162
162
  // 同受众(operator-only,门在上方,本段不自带更松的门)。
163
163
  // 缺席 ⇒ 显式 `null`(「这个进程说不出来」),不铸一个看起来像真答案的空壳对象。
164
164
  memoryPosture: deps.memoryPosture ?? null,
165
+ // #322 世代账的 operator 面:按组的 appliedVersion / deferredKeys+铸因 / lastRejected(消毒形——
166
+ // 只有键名与判据文案,值从不进读面)。/health 那三键是同一份读数的窄投影,两面不可能各说各话。
167
+ // 缺席 ⇒ 显式 `null`(与 memoryPosture 同姿势:「这个进程说不出来」,不铸空壳)。
168
+ configApply: deps.configApplyState?.() ?? null,
165
169
  serverGates: buildServerWiringGates({
166
170
  toolApprovalEnabled: deps.toolApproval !== undefined,
167
171
  streamApprovalEnabled: deps.config.streamApproval?.enabled === true,
@@ -455,6 +455,14 @@ export interface ServiceDeploymentDeps {
455
455
  * auto rolling-restart the worker. Change-detected (not presence) — absent when nothing baked-at-boot
456
456
  * changed, so polling it never causes a restart loop. */
457
457
  restartState?: () => RestartSignal | undefined;
458
+ /** #322 世代账(design/322 §2):本副本的配置收敛状态 —— `/health` 取三键
459
+ * (configTargetVersion / configAppliedVersion / configApplyStaleMs),`GET /v1/diagnostics/wiring` 取整份
460
+ * (按组的 appliedVersion / deferredKeys+铸因 / lastRejected 消毒形)。缺席 = 这个进程没有配置管道
461
+ * (纯 env 部署 / 测试夹具)⇒ 两个面的形状逐字不变(additive)。 */
462
+ configApplyState?: () => import("../config-center/apply-ledger.js").ConfigApplyLedgerView | undefined;
463
+ /** #322 追加件:`POST /v1/admin/config/refresh` 的实现面 —— 触发**一次既有**的 refresh 拍(在飞则 join)。
464
+ * 缺席 / 回 `"not-wired"` = 本部署没有配置管道 ⇒ 该口诚实 4xx(绝不吞成 200)。 */
465
+ configRefreshNow?: () => Promise<"triggered" | "not-wired" | "failed">;
458
466
  /** codex R18 (models-tiers deferral): the persistent "deferred WITHOUT a next-boot handoff" state — set when a
459
467
  * changed model plane could not hot-apply (tier-frozen Runner / tiered candidate) AND no restart-surviving LKG
460
468
  * exists to justify forcing a restart. Surfaced on /health (`modelPlaneDeferred`) so the stuck-degraded state
@@ -44,6 +44,7 @@ import { handleDiagnostics } from "./routes/diagnostics.js";
44
44
  import { handleAdoption } from "./routes/adoption.js";
45
45
  import { handleRetention } from "./routes/retention-ops.js";
46
46
  import { handleAdminDrain } from "./routes/admin-drain.js";
47
+ import { handleAdminConfigRefresh } from "./routes/admin-config-refresh.js"; // #322 批1 追加件:配置手动刷新口(operator lane)
47
48
  import { handleMemoryPolicy } from "./routes/memory-policy.js";
48
49
  import { handleMemoryBundle } from "./routes/memory-bundle.js";
49
50
  import { handleSharedMemory } from "./routes/shared-memory.js";
@@ -218,6 +219,7 @@ const ROUTE_DOMAINS = [
218
219
  handleAdoption,
219
220
  handleRetention,
220
221
  handleAdminDrain,
222
+ handleAdminConfigRefresh,
221
223
  handleMemoryPolicy,
222
224
  handleMemoryBundle,
223
225
  handleRules,
@@ -494,6 +496,20 @@ export function createHttpServer(rawDeps) {
494
496
  const stuck = deps.planeDeferredState?.();
495
497
  return stuck ? { modelPlaneDeferred: { version: stuck.version, since: stuck.since, noHandoff: true, ...(stuck.blocked ? { blockedReasons: stuck.blocked } : {}) } } : {};
496
498
  })(),
499
+ // #322 世代账三键(additive;未接线 ⇒ 三键全缺席,/health 形状逐字不变)。
500
+ // 语义:target = 本副本见过的最新源版本;applied = 最后一次真的 commit 的版本;staleMs = 两者
501
+ // **持续**不等的时长(收敛即缺席)。编排器据此摘掉「配置持续落后」的副本 —— 修前这个状态只在
502
+ // 日志里,跨副本不可聚合。
503
+ ...(() => {
504
+ const gen = deps.configApplyState?.();
505
+ if (!gen)
506
+ return {};
507
+ return {
508
+ configTargetVersion: gen.configTargetVersion,
509
+ configAppliedVersion: gen.configAppliedVersion,
510
+ ...(gen.configApplyStaleMs !== undefined ? { configApplyStaleMs: gen.configApplyStaleMs } : {}),
511
+ };
512
+ })(),
497
513
  // B1 活体 store 探测(座注见 storeLiveState 声明;钉 test/health-store-live.test.ts)
498
514
  ...(() => {
499
515
  const probe = deps.storeLiveState?.();
@@ -717,6 +733,10 @@ export function createHttpServer(rawDeps) {
717
733
  // 同族(operator 门、billable=false、不吃 drain 503——它正是 drain 前要调的口),位置跟着 retention。
718
734
  if (await handleAdminDrain(req, res, url, ctx))
719
735
  return;
736
+ // #322 批1 追加件:配置手动刷新口(routes/admin-config-refresh.ts,operator lane;POST
737
+ // /v1/admin/config/refresh)。与 drain 同族同门同 billable 姿势,位置紧跟它。
738
+ if (await handleAdminConfigRefresh(req, res, url, ctx))
739
+ return;
720
740
  // design/158 A9:memory 导出/同步 + /v1/policy 只读面(routes/memory-policy.ts)。
721
741
  if (await handleMemoryPolicy(req, res, url, ctx))
722
742
  return;
@@ -3496,6 +3516,9 @@ const ROUTE_LABEL_LITERALS = new Set([
3496
3516
  "/v1/adoption",
3497
3517
  // #291:停机因由声明口(operator-only,drain 前一次性动作)。同 adoption 判:低频但只在停机现场被打。
3498
3518
  "/v1/admin/drain",
3519
+ // #322 批1 追加件:配置手动刷新口(operator-only)。同判:低频,但它的 duration 与失败率正是「这次
3520
+ // 配置到底拉没拉下来」的第一手信号,落进 other 桶就看不见了。同族纪律:写**字面量**不引常量。
3521
+ "/v1/admin/config/refresh",
3499
3522
  // #154 车二:CC settings 导入两口。字面量(无 id 段)⇒ 进 LITERALS 表而不是模式表。
3500
3523
  // 🔴 写**字面量**而不是引 `RULES_CC_IMPORT_*_PATH` 常量:名册门与 billable 申明门都是**扫源码文本**
3501
3524
  // 的(它们正则抓本表里的双引号词),引常量会让两条新路由对两道门隐形 —— 那不是「过了门」,是绕过门。
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. */