@sema-agent/server 7.121.0 → 7.123.0

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 (136) hide show
  1. package/MIGRATION.md +175 -0
  2. package/README.md +6 -1
  3. package/README.zh-CN.md +6 -1
  4. package/USAGE.md +57 -18
  5. package/dist/approval-reconciler.d.ts +8 -0
  6. package/dist/approval-reconciler.js +72 -52
  7. package/dist/audit.d.ts +1 -1
  8. package/dist/audit.js +52 -22
  9. package/dist/auto-mode-face.d.ts +16 -5
  10. package/dist/auto-mode-face.js +7 -4
  11. package/dist/bench/s1/live-deps.js +1 -0
  12. package/dist/boot/config-center.js +3 -0
  13. package/dist/boot/leader.js +1 -0
  14. package/dist/boot/reapers.d.ts +3 -0
  15. package/dist/boot/reapers.js +2 -1
  16. package/dist/boot/resolve-spec.js +1 -4
  17. package/dist/boot/shutdown.js +6 -1
  18. package/dist/boot/stage-08-reapers.js +2 -2
  19. package/dist/capabilities/memory-notice.d.ts +1 -1
  20. package/dist/config-catalog.js +4 -4
  21. package/dist/config-center/apply-effective.d.ts +1 -1
  22. package/dist/config-center/apply-effective.js +5 -3
  23. package/dist/config-center/center-request.d.ts +6 -9
  24. package/dist/config-center/center-request.js +11 -27
  25. package/dist/config-center/http-client.js +3 -3
  26. package/dist/config-center/read-warnings.d.ts +56 -35
  27. package/dist/config-center/read-warnings.js +67 -19
  28. package/dist/config-center/types.d.ts +7 -8
  29. package/dist/config-lkg.d.ts +12 -9
  30. package/dist/config-lkg.js +38 -39
  31. package/dist/config-provider.d.ts +3 -2
  32. package/dist/config-provider.js +2 -2
  33. package/dist/config-types.d.ts +20 -9
  34. package/dist/config-types.js +1 -1
  35. package/dist/config.d.ts +10 -10
  36. package/dist/config.js +92 -127
  37. package/dist/fleet/drain-entry.d.ts +28 -3
  38. package/dist/fleet/drain-entry.js +3 -0
  39. package/dist/fleet/engine-signal.d.ts +54 -23
  40. package/dist/fleet/engine-signal.js +29 -5
  41. package/dist/fleet/serving-lease.js +13 -15
  42. package/dist/fleet-client.d.ts +8 -6
  43. package/dist/fleet-client.js +4 -3
  44. package/dist/governance-overrides.d.ts +7 -2
  45. package/dist/http/admission.js +7 -5
  46. package/dist/http/approval-sweeps.js +2 -2
  47. package/dist/http/checkpoint-ctx.d.ts +60 -11
  48. package/dist/http/checkpoint-ctx.js +23 -9
  49. package/dist/http/dispatch.js +22 -19
  50. package/dist/http/health-self-report.d.ts +3 -0
  51. package/dist/http/resume-legs.js +8 -8
  52. package/dist/http/route-ctx.d.ts +19 -3
  53. package/dist/http/route-table.d.ts +3 -0
  54. package/dist/http/route-table.js +4 -0
  55. package/dist/http/routes/admin-drain.d.ts +5 -2
  56. package/dist/http/routes/admin-drain.js +14 -2
  57. package/dist/http/routes/approvals-assistant.d.ts +1 -0
  58. package/dist/http/routes/approvals-assistant.js +6 -6
  59. package/dist/http/routes/capabilities.js +7 -5
  60. package/dist/http/routes/fleet.d.ts +1 -0
  61. package/dist/http/routes/fleet.js +1 -1
  62. package/dist/http/routes/images.d.ts +1 -0
  63. package/dist/http/routes/images.js +1 -1
  64. package/dist/http/routes/leader.js +6 -0
  65. package/dist/http/routes/runs.d.ts +2 -0
  66. package/dist/http/routes/runs.js +5 -4
  67. package/dist/http/routes/sessions.d.ts +1 -0
  68. package/dist/http/routes/sessions.js +2 -5
  69. package/dist/http/routes/trace-usage.d.ts +1 -0
  70. package/dist/http/routes/trace-usage.js +1 -1
  71. package/dist/http/routes/workflows.d.ts +1 -0
  72. package/dist/http/routes/workflows.js +1 -1
  73. package/dist/http/send.d.ts +13 -0
  74. package/dist/http/send.js +9 -0
  75. package/dist/http/server.d.ts +3 -0
  76. package/dist/http/stored-body-replay.d.ts +28 -0
  77. package/dist/http/stored-body-replay.js +22 -0
  78. package/dist/index.d.ts +2 -0
  79. package/dist/index.js +1 -0
  80. package/dist/leader/leader.d.ts +23 -1
  81. package/dist/leader/leader.js +6 -1
  82. package/dist/leader/repair-wire.d.ts +1 -1
  83. package/dist/leader/repair-wire.js +2 -2
  84. package/dist/leader/run-entry.d.ts +27 -0
  85. package/dist/leader/run-entry.js +29 -0
  86. package/dist/leader/wire.d.ts +10 -0
  87. package/dist/leader/wire.js +15 -10
  88. package/dist/model-entry-refusal.d.ts +18 -9
  89. package/dist/model-entry-refusal.js +19 -14
  90. package/dist/model-route-endpoint.d.ts +21 -1
  91. package/dist/model-route-endpoint.js +9 -3
  92. package/dist/model-route-gap.d.ts +103 -0
  93. package/dist/model-route-gap.js +80 -0
  94. package/dist/model-route-url.d.ts +13 -20
  95. package/dist/model-route-url.js +28 -24
  96. package/dist/namespaced-rule-name.d.ts +16 -30
  97. package/dist/namespaced-rule-name.js +2 -20
  98. package/dist/observability/fail-open.d.ts +23 -10
  99. package/dist/observability/fail-open.js +8 -8
  100. package/dist/plugins/approval-ask-store-file.d.ts +1 -0
  101. package/dist/plugins/approval-ask-store-file.js +3 -0
  102. package/dist/plugins/approval-ask-store-memory.d.ts +1 -0
  103. package/dist/plugins/approval-ask-store-memory.js +6 -0
  104. package/dist/plugins/approval-ask-store-sql.d.ts +12 -0
  105. package/dist/plugins/approval-ask-store-sql.js +4 -0
  106. package/dist/plugins/one-time-migrations.js +40 -0
  107. package/dist/plugins/park-commit-watch.d.ts +19 -5
  108. package/dist/plugins/park-commit-watch.js +24 -17
  109. package/dist/plugins/session-policy-store-sql.js +1 -2
  110. package/dist/plugins/workflow-run-store-sql.d.ts +4 -2
  111. package/dist/router/route-orchestration.d.ts +4 -2
  112. package/dist/router/route-orchestration.js +2 -1
  113. package/dist/rule-entry-list.d.ts +3 -13
  114. package/dist/rule-entry-list.js +1 -12
  115. package/dist/run-local.js +4 -2
  116. package/dist/runtime-governance.d.ts +67 -32
  117. package/dist/runtime-governance.js +28 -9
  118. package/dist/session-policy-wire.d.ts +47 -49
  119. package/dist/session-policy-wire.js +41 -36
  120. package/dist/session-sync.d.ts +5 -1
  121. package/dist/session-sync.js +2 -1
  122. package/dist/sighup-idle.d.ts +7 -1
  123. package/dist/sighup-idle.js +4 -4
  124. package/dist/spec-fields.d.ts +4 -4
  125. package/dist/spec-fields.js +1 -4
  126. package/dist/task-settings.d.ts +36 -14
  127. package/dist/task-settings.js +20 -23
  128. package/dist/tool-approval.d.ts +6 -0
  129. package/dist/tool-approval.js +13 -1
  130. package/dist/trace/redact.d.ts +3 -3
  131. package/dist/unattended-permission-mode.d.ts +17 -13
  132. package/dist/unattended-permission-mode.js +3 -1
  133. package/dist/url-shape.d.ts +31 -0
  134. package/dist/url-shape.js +41 -0
  135. package/package.json +3 -3
  136. package/scripts/drain-check-local.mjs +1 -1
package/dist/config.js CHANGED
@@ -13,7 +13,7 @@ import { GIT_API_KINDS, isGitApiKind } from "./git-api-kind.js";
13
13
  import { CONFIG_PROVIDER_WORDS, RETENTION_MODES, MEMORY_CONSOLIDATION_MIN_INTERVAL_SEC, RESILIENCE_DEFAULTS, TASK_WRITE_FACE_ESCALATIONS, MAX_TIMER_DELAY_MS, AUTO_MODE_CLASSIFIER_TIMEOUT_BOUNDS, DRAIN_GRACE_FLOOR_MS, DRAIN_SECOND_SIGNAL_MIN_GAP_BOUNDS, DRAIN_SECOND_SIGNAL_MIN_GAP_DEFAULT_MS, FIRST_BYTE_TIMEOUT_BOUNDS, FLEET_LEASE_OUTAGE_MAX_DEFAULT_MS, fleetScopeOf } from "./config-types.js";
14
14
  import { FLEET_GENERATION_ENV, FLEET_SCOPE_ENV, SERVING_LEASE_TTL_FLOOR_MS, isCanonicalGeneration } from "@sema-agent/settings-schema/fleet";
15
15
  import { ScopeId } from "@sema-agent/settings-schema/api/scopes";
16
- import { centerLaneConfigured, pathBaseUrlFlaw } from "./config-center/center-request.js";
16
+ import { centerLaneConfigured, pathBaseUrlRefusal } from "./config-center/center-request.js";
17
17
  import { resolveLocalDataRoot } from "./local-data-root.js";
18
18
  import { buildWriteProtectionPosture } from "./write-protection.js";
19
19
  import { normalizeSshFingerprint } from "./ssh-host-key.js";
@@ -28,22 +28,20 @@ import { lkgPathOverrideOf } from "./config-lkg.js";
28
28
  import { REMOTE_EXEC_PROVIDER_WORDS, executionLaneOf, executionLaneRequiredRefusal, isSingleMachineDefaultLane, laneRequiredEnv } from "./execution-lane-caps.js";
29
29
  import { MEMORY_ENGINE_BACKENDS, SESSION_BACKENDS, STORE_BACKENDS } from "@sema-agent/settings-schema/execution-lanes";
30
30
  import { AUTONOMY_MODES } from "./runtime-governance.js";
31
- import { allowLayerEntryCountIssue, describeEmptyToolSegmentEntry, describeInertRuleEntries, emptyToolSegmentEntryOf, inertRuleEntriesOf, ruleEntryListShapeOk } from "./rule-entry-list.js";
31
+ import { allowLayerEntryCountIssue, describeInertRuleEntries, inertRuleEntriesOf, ruleEntryListShapeOk } from "./rule-entry-list.js";
32
32
  import { createCenterCredentialSource, readCenterCredentialDeclaration } from "./center-credential.js";
33
33
  import { parseCenterCredentialRenewLeadMs } from "./center-credential-renewer.js";
34
34
  import { modelProviderRefusal } from "./model-provider.js";
35
35
  import { modelRouteUrlRefusal } from "./model-route-url.js";
36
- import { modelRouteEndpointRefusal } from "./model-route-endpoint.js";
36
+ import { buildHttpUrlRefusal, httpUrlFlaws } from "./url-shape.js";
37
+ import { csvOf, envLaneTable, judgeModelRouteGap, mintedCheapId, readModelRouteEnv } from "./model-route-gap.js";
37
38
  import { displayListEntry } from "./list-entry-display.js";
38
39
  import { buildDisplaySafeUrl, redactSecrets } from "./trace/redact.js";
39
40
  import { allowlistedEnv } from "./server-secret-env.js";
40
41
  import { ENV_NAME_ALLOWLIST_KNOBS, ENV_NAME_ALLOWLIST_KNOB_NAMES, describeAllowlistPostures } from "./env-name-allowlist-knobs.js";
41
42
  export { RETENTION_MODES, MEMORY_CONSOLIDATION_MIN_INTERVAL_SEC } from "./config-types.js";
42
43
  function csv(name) {
43
- return (process.env[name] ?? "")
44
- .split(",")
45
- .map((s) => s.trim())
46
- .filter(Boolean);
44
+ return csvOf(process.env[name]);
47
45
  }
48
46
  export function parseAutonomy(raw) {
49
47
  const v = raw?.trim();
@@ -311,14 +309,6 @@ export function logConfigDiagnostics(logger) {
311
309
  }
312
310
  }
313
311
  }
314
- function firstSetEnv(...names) {
315
- for (const n of names) {
316
- const v = process.env[n];
317
- if (v !== undefined && v !== "")
318
- return v;
319
- }
320
- return undefined;
321
- }
322
312
  function clampEnvWithWarn(name, fallback, min, max = Number.POSITIVE_INFINITY) {
323
313
  const n = numEnv(name, fallback);
324
314
  const used = Math.min(max, Math.max(min, n));
@@ -951,44 +941,37 @@ export function isRosterGateArmed(configCenter) {
951
941
  return configCenter !== undefined && !configCenter.dryRun && process.env.CONFIG_REQUIRE_ROSTER === "true";
952
942
  }
953
943
  function parseModelDomain() {
954
- const gatewayBaseUrl = firstSetEnv("MODEL_GATEWAY_BASEURL");
955
- const modelId = env("MODEL_ID", "");
956
- const explicitProvider = process.env.MODEL_PROVIDER;
957
- if (explicitProvider !== undefined && explicitProvider !== "") {
958
- const refusal = modelProviderRefusal(explicitProvider);
944
+ const route = readModelRouteEnv(process.env);
945
+ const gatewayBaseUrl = route.gatewayBaseUrl;
946
+ const modelId = route.mainId;
947
+ if (route.providerSource === "explicit") {
948
+ const refusal = modelProviderRefusal(route.provider);
959
949
  if (refusal !== undefined) {
960
950
  throw new Error(refusal);
961
951
  }
962
952
  }
963
953
  for (const [envKey, raw, knob] of [
964
954
  ["MODEL_GATEWAY_BASEURL", gatewayBaseUrl ?? "", "MODEL_API_KEY"],
965
- ...csv("MODEL_GATEWAY_FALLBACK_URLS").map((u, i) => [`MODEL_GATEWAY_FALLBACK_URLS[${i}]`, u, "MODEL_API_KEY"]),
966
- ["ANTHROPIC_BASE_URL", firstSetEnv("ANTHROPIC_BASE_URL") ?? "", "ANTHROPIC_API_KEY"],
955
+ ...route.gatewayFallbackUrls.map((u, i) => [`MODEL_GATEWAY_FALLBACK_URLS[${i}]`, u, "MODEL_API_KEY"]),
956
+ ["ANTHROPIC_BASE_URL", route.anthropicBaseUrl ?? "", "ANTHROPIC_API_KEY"],
967
957
  ]) {
968
958
  const refusal = modelRouteUrlRefusal(envKey, raw, knob);
969
959
  if (refusal !== undefined) {
970
960
  throw new Error(refusal);
971
961
  }
972
962
  }
973
- const inferredAnthropicBaseUrl = firstSetEnv("ANTHROPIC_BASE_URL");
974
- const inferredAnthropicCred = firstSetEnv("ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN");
975
- const provider = (() => {
976
- if (explicitProvider !== undefined && explicitProvider !== "")
977
- return explicitProvider;
978
- if (inferredAnthropicBaseUrl && inferredAnthropicCred) {
979
- CONFIG_NOTICES.push({
980
- level: "warn",
981
- event: "model_provider_inferred",
982
- fields: {
983
- provider: "anthropic",
984
- baseUrl: buildDisplaySafeUrl(inferredAnthropicBaseUrl),
985
- hint: "MODEL_PROVIDER is unset but an explicit ANTHROPIC base URL + credential are configured — inferred provider=anthropic (set MODEL_PROVIDER explicitly to override)",
986
- },
987
- });
988
- return "anthropic";
989
- }
990
- return "gateway";
991
- })();
963
+ const provider = route.provider;
964
+ if (route.providerSource === "inferred") {
965
+ CONFIG_NOTICES.push({
966
+ level: "warn",
967
+ event: "model_provider_inferred",
968
+ fields: {
969
+ provider: "anthropic",
970
+ baseUrl: buildDisplaySafeUrl(route.anthropicBaseUrl),
971
+ hint: "MODEL_PROVIDER is unset but an explicit ANTHROPIC base URL + credential are configured — inferred provider=anthropic (set MODEL_PROVIDER explicitly to override)",
972
+ },
973
+ });
974
+ }
992
975
  const extraBody = buildExtraBody();
993
976
  const thinkingDefault = parseDefaultThinking(process.env.MODEL_DEFAULT_THINKING);
994
977
  const envCompatDeclared = buildModelCompat({
@@ -1020,35 +1003,40 @@ function parseModelDomain() {
1020
1003
  ...(envCompat ? { compat: envCompat } : {}),
1021
1004
  ...(extraBody ? { extraBody } : {}),
1022
1005
  };
1023
- const cheapId = process.env.MODEL_CHEAP_ID;
1024
- if (cheapId && modelId === "") {
1025
- CONFIG_NOTICES.push({
1026
- level: "warn",
1027
- event: "model_cheap_slot_without_main",
1028
- fields: { hint: "MODEL_CHEAP_ID is set but MODEL_ID is not — the env cheap slot derives from the env main model, so it is not minted; the catalog (config.d/models.json or the config center) serves the roles. Set MODEL_ID to use the env slots, or unset MODEL_CHEAP_ID to silence this line." },
1029
- });
1030
- }
1031
- const cheapIsMain = cheapId !== undefined && cheapId !== "" && modelId !== "" && cheapId === modelId;
1032
- if (cheapIsMain) {
1033
- CONFIG_NOTICES.push({
1034
- level: "warn",
1035
- event: "config_env_entry_cheap_same_as_main",
1036
- fields: {
1037
- env: "MODEL_CHEAP_ID",
1038
- model: modelId,
1039
- hint: `MODEL_CHEAP_ID names the same model as MODEL_ID ("${modelId}") — the env cheap slot is the main gateway under a different id, ` +
1040
- `so the same id IS the main model: no cheap slot is minted and subagent / summarize stay on the main model (roles -> default); ` +
1041
- `any other MODEL_CHEAP_* key set here describes that slot and is not applied. Set MODEL_CHEAP_ID to a different (cheaper) model ` +
1042
- `id to run those roles cheap, or declare two differently-named entries in config.d/models.json to give one upstream id a second ` +
1043
- `shape (price / reasoning / window); unset MODEL_CHEAP_ID to silence this line.`,
1044
- },
1045
- });
1006
+ const cheapSlot = route.cheapSlot;
1007
+ switch (cheapSlot.kind) {
1008
+ case "without_main":
1009
+ CONFIG_NOTICES.push({
1010
+ level: "warn",
1011
+ event: "model_cheap_slot_without_main",
1012
+ fields: { hint: "MODEL_CHEAP_ID is set but MODEL_ID is not — the env cheap slot derives from the env main model, so it is not minted; the catalog (config.d/models.json or the config center) serves the roles. Set MODEL_ID to use the env slots, or unset MODEL_CHEAP_ID to silence this line." },
1013
+ });
1014
+ break;
1015
+ case "same_as_main":
1016
+ CONFIG_NOTICES.push({
1017
+ level: "warn",
1018
+ event: "config_env_entry_cheap_same_as_main",
1019
+ fields: {
1020
+ env: "MODEL_CHEAP_ID",
1021
+ model: modelId,
1022
+ hint: `MODEL_CHEAP_ID names the same model as MODEL_ID ("${modelId}") — the env cheap slot is the main gateway under a different id, ` +
1023
+ `so the same id IS the main model: no cheap slot is minted and subagent / summarize stay on the main model (roles -> default); ` +
1024
+ `any other MODEL_CHEAP_* key set here describes that slot and is not applied. Set MODEL_CHEAP_ID to a different (cheaper) model ` +
1025
+ `id to run those roles cheap, or declare two differently-named entries in config.d/models.json to give one upstream id a second ` +
1026
+ `shape (price / reasoning / window); unset MODEL_CHEAP_ID to silence this line.`,
1027
+ },
1028
+ });
1029
+ break;
1030
+ case "absent":
1031
+ case "minted":
1032
+ break;
1046
1033
  }
1047
- const cheapModel = cheapId && modelId !== "" && !cheapIsMain
1034
+ const cheapMintedId = mintedCheapId(cheapSlot);
1035
+ const cheapModel = cheapMintedId !== undefined
1048
1036
  ? {
1049
1037
  ...model,
1050
- id: cheapId,
1051
- name: cheapId,
1038
+ id: cheapMintedId,
1039
+ name: cheapMintedId,
1052
1040
  input: ((v) => (v === undefined ? model.input : v ? ["text", "image"] : ["text"]))(parseBoolWord("MODEL_CHEAP_VISION", process.env.MODEL_CHEAP_VISION)),
1053
1041
  ...buildModelCostKey(buildCostOrAbsent({
1054
1042
  input: costEnv("MODEL_CHEAP_COST_INPUT", "declares-zero"),
@@ -1092,9 +1080,7 @@ function parseModelDomain() {
1092
1080
  }
1093
1081
  noticeAutoCompactAboveWindow("cheap", "MODEL_CHEAP_AUTO_COMPACT_TOKENS", cheapModel);
1094
1082
  }
1095
- const models = modelId === "" ? {} : { default: model, [model.id]: model };
1096
- if (cheapModel)
1097
- models[cheapModel.id] = cheapModel;
1083
+ const models = envLaneTable(modelId, model, cheapModel);
1098
1084
  const gatewayApiKey = process.env.MODEL_API_KEY;
1099
1085
  if (gatewayBaseUrl === undefined && gatewayApiKey !== undefined && gatewayApiKey !== "") {
1100
1086
  CONFIG_NOTICES.push({
@@ -1128,13 +1114,13 @@ function parseModelDomain() {
1128
1114
  const degradeOn = parseDegradeOn(csv("MODEL_DEGRADE_ON"));
1129
1115
  return {
1130
1116
  ...(gatewayBaseUrl !== undefined ? { gatewayBaseUrl, gatewayApiKey } : {}),
1131
- gatewayFallbackUrls: csv("MODEL_GATEWAY_FALLBACK_URLS"),
1117
+ gatewayFallbackUrls: route.gatewayFallbackUrls,
1132
1118
  ...(process.env.GATEWAY_MAX_RETRIES ? { gatewayMaxRetries: numEnvBounded("GATEWAY_MAX_RETRIES", "2", 0, 20) } : {}),
1133
- anthropic: firstSetEnv("ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN")
1119
+ anthropic: route.anthropicRouteMounted
1134
1120
  ? {
1135
- apiKey: firstSetEnv("ANTHROPIC_API_KEY"),
1136
- authToken: firstSetEnv("ANTHROPIC_AUTH_TOKEN"),
1137
- baseUrl: firstSetEnv("ANTHROPIC_BASE_URL"),
1121
+ apiKey: route.anthropicApiKey,
1122
+ authToken: route.anthropicAuthToken,
1123
+ baseUrl: route.anthropicBaseUrl,
1138
1124
  version: process.env.ANTHROPIC_VERSION,
1139
1125
  cacheBreakpoints: boolEnv("ANTHROPIC_CACHE_BREAKPOINTS", true),
1140
1126
  ...(process.env.ANTHROPIC_MAX_RETRIES ? { maxRetries: numEnv("ANTHROPIC_MAX_RETRIES", "2") } : {}),
@@ -1154,6 +1140,7 @@ function parseModelDomain() {
1154
1140
  modelApiKeyEnv: {},
1155
1141
  modelApiKeys: {},
1156
1142
  modelRefusals: {},
1143
+ droppedCatalogEntries: [],
1157
1144
  modelQuotaWeights: {},
1158
1145
  tiers: {},
1159
1146
  projects: {},
@@ -1227,9 +1214,7 @@ function uncompilableRuleGuidance(name, behavior, pathBases) {
1227
1214
  const issue = issues[0];
1228
1215
  if (issue === undefined)
1229
1216
  return undefined;
1230
- return (`${issue.code} — core's rule grammar cannot compile this name (${issue.message}), so a table keyed by it matches nothing. ` +
1231
- `Tool names carry no backslashes and are far shorter than the 512-character rule cap; check the spelling, and use ` +
1232
- `mcp__<server>__* / a2a__<peer>__* for "every tool on that peer"`);
1217
+ return `${issue.code} — core's rule grammar cannot compile this entry: ${issue.message}`;
1233
1218
  }
1234
1219
  function findUnmatchableToolNames(names, opts) {
1235
1220
  const out = [];
@@ -1273,9 +1258,6 @@ export function approvalGrantRefusal(names) {
1273
1258
  const over = allowLayerEntryCountIssue(APPROVAL_GRANT_ENV, names);
1274
1259
  if (over !== undefined)
1275
1260
  return `${over.message} — refusing to start rather than running with a truncated list`;
1276
- const empty = emptyToolSegmentEntryOf(names);
1277
- if (empty !== undefined)
1278
- return `${describeEmptyToolSegmentEntry(APPROVAL_GRANT_ENV, empty)} — refusing to start rather than running without it`;
1279
1261
  const inert = inertRuleEntriesOf(names, "allow");
1280
1262
  if (inert.length > 0)
1281
1263
  return `${describeInertRuleEntries(APPROVAL_GRANT_ENV, inert)} — the engine would grant nothing for ${inert.length === 1 ? "it" : "them"}; refusing to start rather than running with an allow rule that never takes effect`;
@@ -1376,7 +1358,7 @@ function parseApprovalDomain(ctx) {
1376
1358
  const trimmed = raw.trim();
1377
1359
  if (trimmed === "" || trimmed.toLowerCase() === "off")
1378
1360
  return [];
1379
- return trimmed.split(",").map((p) => p.trim()).filter((p) => p.length > 0);
1361
+ return csvOf(trimmed);
1380
1362
  })(process.env.SENSITIVE_WRITE_PATTERNS),
1381
1363
  ...((decision) => (decision === undefined ? {} : { writeProtectedPaths: decision }))(parseWriteProtectedTableEnv(process.env.WRITE_PROTECTED_EXTRA, process.env.WRITE_PROTECTED_TABLE_REPLACE)),
1382
1364
  ...((raw) => {
@@ -1564,16 +1546,11 @@ function parseMemoryEmbedder(ctx) {
1564
1546
  if (endpoint === undefined || model === undefined || dimRaw === undefined) {
1565
1547
  throw new Error(`MEMORY_EMBEDDER_* is half-configured — missing ${missing.join(", ")}. The memory vector plane needs all three of MEMORY_EMBEDDER_ENDPOINT, MEMORY_EMBEDDER_MODEL, MEMORY_EMBEDDER_DIM; refusing to start rather than silently running lexical-only retrieval while the deployment believes vectors are on (#228).`);
1566
1548
  }
1567
- if (!URL.canParse(endpoint)) {
1568
- throw new Error(`MEMORY_EMBEDDER_ENDPOINT="${buildDisplaySafeUrl(endpoint)}" is not a valid URL (expected e.g. https://embeddings.example.com/v1 — the OpenAI-compatible base, or the full .../embeddings endpoint)`);
1569
- }
1570
- const parsedEndpoint = new URL(endpoint);
1571
- const proto = parsedEndpoint.protocol;
1572
- if (proto !== "http:" && proto !== "https:") {
1573
- throw new Error(`MEMORY_EMBEDDER_ENDPOINT="${buildDisplaySafeUrl(endpoint)}" must be http(s) (got "${proto}") — the embedder leg is an HTTP POST to an OpenAI-compatible /embeddings face`);
1574
- }
1575
- if (parsedEndpoint.username !== "" || parsedEndpoint.password !== "") {
1576
- throw new Error("MEMORY_EMBEDDER_ENDPOINT must not carry credentials in the URL (user:password@host) — URL userinfo leaks into transport errors, logs and memory conflict reports. Use MEMORY_EMBEDDER_API_KEY (sent as a Bearer header) instead.");
1549
+ const endpointFlaws = httpUrlFlaws(endpoint, ["userinfo"]);
1550
+ if (endpointFlaws.length > 0) {
1551
+ throw new Error(`${buildHttpUrlRefusal("MEMORY_EMBEDDER_ENDPOINT", endpoint, endpointFlaws)} — the embedder leg is an HTTP POST to an OpenAI-compatible /embeddings face ` +
1552
+ `(expected e.g. https://embeddings.example.com/v1 — the OpenAI-compatible base, or the full .../embeddings endpoint); ` +
1553
+ `credentials in the URL leak into transport errors, logs and memory conflict reports, so use MEMORY_EMBEDDER_API_KEY (sent as a Bearer header) instead.`);
1577
1554
  }
1578
1555
  const dimensions = Number(dimRaw);
1579
1556
  if (!Number.isSafeInteger(dimensions) || dimensions < 1) {
@@ -1832,7 +1809,7 @@ function parseAuthDomain(ctx) {
1832
1809
  authToken: process.env.SERVICE_AUTH_TOKEN,
1833
1810
  authTokens,
1834
1811
  allowUnauthedWrites: boolEnv("ALLOW_UNAUTHED_WRITES", false),
1835
- corsOrigins: (process.env.CORS_ORIGIN ?? "").split(",").map((s) => s.trim()).filter(Boolean),
1812
+ corsOrigins: csv("CORS_ORIGIN"),
1836
1813
  principalHeader: headerNameEnv("PRINCIPAL_HEADER", "x-agent-principal"),
1837
1814
  requirePrincipal,
1838
1815
  autonomy: parseAutonomy(process.env.AUTONOMY),
@@ -2017,7 +1994,7 @@ function parseOrchestrationDomain(ctx) {
2017
1994
  ? {
2018
1995
  repoRoot: process.env.WORKTREE_REPO_ROOT,
2019
1996
  ...(process.env.WORKTREE_ALLOWED_ROOTS
2020
- ? { allowedRoots: process.env.WORKTREE_ALLOWED_ROOTS.split(",").map((s) => s.trim()).filter(Boolean) }
1997
+ ? { allowedRoots: csv("WORKTREE_ALLOWED_ROOTS") }
2021
1998
  : {}),
2022
1999
  ...(process.env.WORKTREE_BASE_COMMIT ? { commit: process.env.WORKTREE_BASE_COMMIT } : {}),
2023
2000
  }
@@ -2101,7 +2078,7 @@ function parseLimitsHttpDomain() {
2101
2078
  workspaceFileMaxBytes: optFinitePositiveEnv("WORKSPACE_FILE_MAX_BYTES") ?? 8 * 1024 * 1024,
2102
2079
  attachmentMaxBytes: clampEnvWithWarn("ATTACHMENT_MAX_BYTES", String(32 * 1024 * 1024), LIMITS_HTTP_FLOORS.attachmentMaxBytes),
2103
2080
  ...(process.env.ATTACHMENT_MIME_ALLOWLIST
2104
- ? { attachmentMimeAllowlist: process.env.ATTACHMENT_MIME_ALLOWLIST.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean) }
2081
+ ? { attachmentMimeAllowlist: csv("ATTACHMENT_MIME_ALLOWLIST").map((s) => s.toLowerCase()) }
2105
2082
  : {}),
2106
2083
  attachmentUnboundTtlMs: clampEnvWithWarn("ATTACHMENT_UNBOUND_TTL_MS", String(24 * 3600 * 1000), LIMITS_HTTP_FLOORS.attachmentUnboundTtlMs),
2107
2084
  infraCostRates: {
@@ -2196,7 +2173,7 @@ function parseIntegrationsDomain(ctx) {
2196
2173
  const fleetAdvertiseAddress = parseFleetAdvertiseAddress(process.env.FLEET_ADVERTISE_ADDRESS);
2197
2174
  fleetFenceLaneOf({ ...fleetGeneration, configCenter, ...fleetAdvertiseAddress });
2198
2175
  return {
2199
- pluginsAllowHosts: (process.env.PLUGINS_ALLOW_HOSTS ?? "github.com").split(",").map((h) => h.trim()).filter(Boolean),
2176
+ pluginsAllowHosts: csvOf(process.env.PLUGINS_ALLOW_HOSTS ?? "github.com"),
2200
2177
  configBootFetchBudgetMs: clampEnvWithWarn("CONFIG_BOOT_FETCH_BUDGET_MS", "1500", 0),
2201
2178
  gitApiBaseUrl: process.env.GIT_API_BASEURL,
2202
2179
  gitApiToken: process.env.GIT_API_TOKEN,
@@ -2249,10 +2226,10 @@ function parseFleetScope(raw) {
2249
2226
  function parseFleetAdvertiseAddress(raw) {
2250
2227
  if (raw === undefined || raw === "")
2251
2228
  return {};
2252
- const why = pathBaseUrlFlaw(raw);
2253
- if (why !== undefined) {
2254
- throw fleetEnvRefusal("advertise_address_invalid", `env FLEET_ADVERTISE_ADDRESS ${why} (an http(s) base URL, e.g. "http://10.0.0.5:8090"); got ${JSON.stringify(buildDisplaySafeUrl(raw))} — ` +
2255
- `the fleet center rejects any other shape on every announce. Unset it if this engine does not announce itself.`);
2229
+ const refusal = pathBaseUrlRefusal("env FLEET_ADVERTISE_ADDRESS", raw);
2230
+ if (refusal !== undefined) {
2231
+ throw fleetEnvRefusal("advertise_address_invalid", `${refusal} — it must be an http(s) base URL such as "http://10.0.0.5:8090": the fleet center rejects any other shape on every announce. ` +
2232
+ `Unset it if this engine does not announce itself.`);
2256
2233
  }
2257
2234
  return { fleetAdvertiseAddress: raw };
2258
2235
  }
@@ -2280,7 +2257,8 @@ function parseFleetLeaseOutageMaxMs(raw) {
2280
2257
  const shown = raw.length > 40 ? `${raw.slice(0, 40)}…(${raw.length} chars)` : raw;
2281
2258
  throw new Error(`env FLEET_LEASE_OUTAGE_MAX_MS=${JSON.stringify(shown)} must be a whole number of milliseconds ≥ ${SERVING_LEASE_TTL_FLOOR_MS} (the serving-lease TTL floor; ` +
2282
2259
  `decimal digits only; unset = ${FLEET_LEASE_OUTAGE_MAX_DEFAULT_MS}, 24 h). It bounds how long this engine keeps taking new submissions after its serving ` +
2283
- `lease expired with the center unreachable — "0" and "infinite" are not values (the tolerance must be bounded and non-trivial). Refusing to boot.`);
2260
+ `lease expired with the center unreachable, and how far from now a drain request's deadlineAt may be — "0" and "infinite" are not values ` +
2261
+ `(the bound must be finite and non-trivial). Refusing to boot.`);
2284
2262
  }
2285
2263
  return n;
2286
2264
  }
@@ -2293,9 +2271,9 @@ function parseFleetLeaseFailClosed(raw, requirePrincipal) {
2293
2271
  function parseCenterBaseUrl(name, raw, role) {
2294
2272
  if (raw === undefined || raw === "")
2295
2273
  return undefined;
2296
- const why = pathBaseUrlFlaw(raw);
2297
- if (why !== undefined)
2298
- throw new Error(`env ${name} ${why}; got ${JSON.stringify(buildDisplaySafeUrl(raw))}. ${role}`);
2274
+ const refusal = pathBaseUrlRefusal(`env ${name}`, raw);
2275
+ if (refusal !== undefined)
2276
+ throw new Error(`${refusal}. ${role}`);
2299
2277
  return raw;
2300
2278
  }
2301
2279
  function parseConfigCenter(baseUrl, requirePrincipal, liveBaseUrl, webBaseUrl) {
@@ -2322,20 +2300,13 @@ function parseA2aServe() {
2322
2300
  `and that address is the operator's to declare (it is NOT derived from the request Host header: an anonymous discovery caller must not decide what our public card advertises). ` +
2323
2301
  `Set A2A_SERVE_URL to this deployment's externally reachable /v1/a2a URL (e.g. "https://agent.example.com/v1/a2a").`);
2324
2302
  }
2325
- let parsed;
2326
- try {
2327
- parsed = new URL(rawUrl);
2328
- }
2329
- catch {
2330
- parsed = undefined;
2331
- }
2332
- if (parsed === undefined || (parsed.protocol !== "http:" && parsed.protocol !== "https:")) {
2333
- throw new Error(`A2A_SERVE_URL must be a parseable http(s) URL (e.g. "https://agent.example.com/v1/a2a"); got ${JSON.stringify(buildDisplaySafeUrl(rawUrl))}`);
2334
- }
2335
- if (parsed.username !== "" || parsed.password !== "") {
2336
- throw new Error(`A2A_SERVE_URL must not carry userinfo (credentials before "@") — it is published verbatim on the ANONYMOUSLY readable agent card, so those credentials would be handed to every discovery caller. ` +
2337
- `Inbound authentication on this endpoint is header-shaped (a service credential), not URL-embedded; strip the userinfo and keep the secret in the caller's headers.`);
2303
+ const urlFlaws = httpUrlFlaws(rawUrl, ["userinfo"]);
2304
+ if (urlFlaws.length > 0) {
2305
+ throw new Error(`${buildHttpUrlRefusal("A2A_SERVE_URL", rawUrl, urlFlaws)} — it is published verbatim on the ANONYMOUSLY readable agent card as this deployment's JSON-RPC endpoint ` +
2306
+ `(e.g. "https://agent.example.com/v1/a2a"), so it must be a plain http(s) address and must not hand credentials to every discovery caller: ` +
2307
+ `inbound authentication on this endpoint is header-shaped (a service credential), not URL-embedded.`);
2338
2308
  }
2309
+ const parsed = new URL(rawUrl);
2339
2310
  const a2aCredentialScan = redactSecrets(parsed.href);
2340
2311
  if (a2aCredentialScan !== parsed.href) {
2341
2312
  throw new Error(`A2A_SERVE_URL carries something the credential-redaction law recognizes (${a2aCredentialScan}) — this address is published VERBATIM on the ANONYMOUSLY readable agent card (the card's url is a functional address: peers fetch it, so it is never rewritten for display), ` +
@@ -2400,7 +2371,7 @@ const STORE_GROUP_KEYS = [
2400
2371
  ];
2401
2372
  const MODEL_PLANE_GROUP_KEYS = [
2402
2373
  "gatewayBaseUrl", "gatewayApiKey", "gatewayFallbackUrls", "gatewayMaxRetries", "anthropic", "resilience", "model", "envModel", "models",
2403
- "modelApiKeyEnv", "modelApiKeys", "modelRefusals", "modelQuotaWeights", "tiers", "projects", "roles", "atModelAllowlist", "cascadeLadder", "degrade",
2374
+ "modelApiKeyEnv", "modelApiKeys", "modelRefusals", "droppedCatalogEntries", "modelQuotaWeights", "tiers", "projects", "roles", "atModelAllowlist", "cascadeLadder", "degrade",
2404
2375
  ];
2405
2376
  const APPROVAL_GROUP_KEYS = [
2406
2377
  "approvalRequire", "approvalDeny", "approvalTimeoutSec", "approvalAutoBudget", "approvalNeverAuto", "approvalGrant",
@@ -2539,15 +2510,9 @@ export function loadConfig() {
2539
2510
  throw new Error(laneRequiredRefusal);
2540
2511
  }
2541
2512
  {
2542
- const envLaneDispatchable = !isRosterGateArmed(integrations.configCenter);
2543
- const refusal = modelRouteEndpointRefusal({
2544
- gatewayBaseUrl: modelPlane.gatewayBaseUrl,
2545
- gatewayFallbackUrls: modelPlane.gatewayFallbackUrls,
2546
- anthropic: modelPlane.anthropic,
2547
- models: envLaneDispatchable ? modelPlane.models : {},
2548
- });
2549
- if (refusal !== undefined) {
2550
- throw new Error(refusal);
2513
+ const routeGap = judgeModelRouteGap(process.env, { envLaneDispatchable: !isRosterGateArmed(integrations.configCenter) });
2514
+ if (routeGap.gap !== "none") {
2515
+ throw new Error(routeGap.reason);
2551
2516
  }
2552
2517
  }
2553
2518
  EXEC_LANE_NOTICE = buildExecLaneNotice(orchestration.remoteExec);
@@ -8,6 +8,12 @@
8
8
  * 非编排路径 = 停止信号(SIGTERM)/ SIGHUP 升级 / 附着租约自退,动作号由引擎自铸({@link ENGINE_DRAIN_CAUSES})。全部汇到同一只入口。
9
9
  * 2. **期限在进入那一拍定死**:请求带的 `deadlineAt` 原样;引擎自起的排空 = 起点 + 此刻的排空窗(`drainGraceMs`)。之后改窗(中心热改
10
10
  * `limits.drainGraceMs`)不动在跑的期限 —— 排空驱动读的是期限,不是窗。
11
+ * 请求带的期限有**准入上界** = 进入那一拍 + `config.fleetLeaseOutageMaxMs`(`T_max`,env `FLEET_LEASE_OUTAGE_MAX_MS`,`loadConfig` 无条件
12
+ * 解析、缺省 24 h,成员与非成员同读):一台实例「活着而不服务」最长多久本就由这一席定义(服务租约失联后最多再接单多久,
13
+ * `fleet/serving-lease.ts`),排空口不另立第二个数。上界由调用方随入参声明({@link DrainEntry.deadlineBoundMs};排空口声明它,引擎自起的
14
+ * 排空不声明 —— 那些期限由引擎自己的窗定,拒了就是不排空),**由入口判**:只有这一发会进入排空时才判(已在排空 / 硬停已在进行 ⇒ 期限
15
+ * 不会被采用,照规则 3 回执);越界 ⇒ 零状态变化,入口答 {@link DrainDeadlineRefusal}(排空口 400 `request.field_invalid`)。「会不会
16
+ * 采用这只期限」只有入口一处判 —— 调用方不另读 `draining` 去猜。已过的期限不越界(进入即到期:在飞为零当拍硬停,否则下一拍)。
11
17
  * 3. **幂等**:已在排空 ⇒ 不重置,回执 `already_draining` 带**第一次**的动作 / 起点 / 期限(不论这次的动作号是否相同)。
12
18
  * 4. **身份**:逐字段比,任一不符 ⇒ 409 `conflict.fleet_identity`,零状态变化。
13
19
  * · `scope`:两侧都经 {@link fleetScopeOf} 读成值(缺席 = settings-schema 的缺省 scope,线上任何一处缺席的 scope 都读成它)再严格相等。
@@ -46,25 +52,39 @@ export declare function leaseDrainActionId(generation: string): LeaseDrainAction
46
52
  export declare const DRAIN_CAUSE_COUNTS_AS_FIRST_STOP_SIGNAL: {
47
53
  readonly [C in EngineDrainCause]: boolean;
48
54
  };
49
- /** 入口的入参:动作号 + 期限(epoch ms)。 */
55
+ /** 入口的入参:动作号 + 期限(epoch ms)+ 调用方声明的期限准入上界(规则 2)。 */
50
56
  export interface DrainEntry {
51
57
  readonly actionId: string;
52
58
  readonly deadlineAt: number;
59
+ /** 期限的准入上界(ms):这一发会进入排空而 `deadlineAt − 进入那一拍 > 它` ⇒ 入口零状态变化,答 {@link DrainDeadlineRefusal}。
60
+ * 排空口传 `config.fleetLeaseOutageMaxMs`;引擎自起的排空(停止信号 / 租约被拒 / 附着租约自退)不传。 */
61
+ readonly deadlineBoundMs?: number;
53
62
  }
63
+ /** 入口拒了这只期限(规则 2):越过调用方声明的上界,什么都没变。 */
64
+ export interface DrainDeadlineRefusal {
65
+ readonly state: "deadline_beyond_bound";
66
+ readonly boundMs: number;
67
+ }
68
+ /** 入口的答:契约回执(进入 / 已在排空),或期限越界的拒(只在调用方声明了上界时出现)。按 `state` 穷举。 */
69
+ export type DrainEntryAnswer = DrainReceipt | DrainDeadlineRefusal;
54
70
  /**
55
71
  * 引擎与 HTTP 面共享的排空盒子(装配层建一只,`createHttpServer` 与收尾链各自往上装读口 / 入口)。
56
72
  * · `draining` / `since` / `actionId` / `deadlineAt` —— **只由** {@link enter} 整组写入(排空事实 = 契约 `EngineDraining`);
57
73
  * · `enter` —— 唯一入口(收尾链装;缺席 = 这个进程没有收尾链,如嵌入宿主 / 测试夹具);
58
74
  * · `yieldInflight` —— 让出本副本在飞的可抢占腿(`createHttpServer` 装);
59
- * · 其余四只是既有的读口(在飞数 / 活性时钟 / 附着流),语义见 `http/server.ts`。
75
+ * · `endSubscriptions` —— 关掉本副本打开着的只读订阅流(发一帧 `draining` 终帧再关;`createHttpServer` 装,S-1281)。订阅流不是在飞的工作
76
+ * (不进 `inflight`),排空不等它;收尾链在排空进入那一拍与硬停起点各调一次(排空期间新开的订阅在硬停那一拍收);
77
+ * · 其余四只是既有的读口(在飞数 / 活性时钟 / 附着流),语义见 `http/server.ts`。`inflight` 只数**工作**(run 腿 / 计费提交 / HITL 应答 /
78
+ * admission 期的普通请求),只读订阅只进 `attachedStreams`(S-1281)。
60
79
  */
61
80
  export interface DrainState {
62
81
  draining: boolean;
63
82
  since?: number;
64
83
  actionId?: string;
65
84
  deadlineAt?: number;
66
- enter?: (entry: DrainEntry) => DrainReceipt;
85
+ enter?: (entry: DrainEntry) => DrainEntryAnswer;
67
86
  yieldInflight?: () => void;
87
+ endSubscriptions?: () => void;
68
88
  inflight?: () => number;
69
89
  lastActivityAt?: () => number;
70
90
  attachedStreams?: () => number;
@@ -72,6 +92,11 @@ export interface DrainState {
72
92
  }
73
93
  /** 排空盒子里的排空事实(三键齐才是;入口整组写,所以「缺一」只出现在手搭的夹具上)。 */
74
94
  export declare function drainingFactOf(s: Pick<DrainState, "draining" | "since" | "actionId" | "deadlineAt">): EngineDraining | undefined;
95
+ /**
96
+ * 规则 2 的准入上界:期限离进入那一拍(本引擎的挂钟)超过 `boundMs` ⇔ 越界。恰好等于上界不越界;已过的期限不越界。入口判它
97
+ * (`boot/shutdown.ts` 的 enterDrain,在「已在排空 / 硬停已在进行」两臂之后)。
98
+ */
99
+ export declare function drainDeadlineBeyondBound(deadlineAt: number, now: number, boundMs: number): boolean;
75
100
  /** 本进程的身份。`scope` 已读成值(`config.fleetScope`);其余三项与 `/health` 上读到的同一份。 */
76
101
  export interface EngineIdentity {
77
102
  readonly scope: string;
@@ -13,6 +13,9 @@ export const DRAIN_CAUSE_COUNTS_AS_FIRST_STOP_SIGNAL = {
13
13
  export function drainingFactOf(s) {
14
14
  return s.draining && s.actionId !== undefined && s.since !== undefined && s.deadlineAt !== undefined ? { actionId: s.actionId, since: s.since, deadlineAt: s.deadlineAt } : undefined;
15
15
  }
16
+ export function drainDeadlineBeyondBound(deadlineAt, now, boundMs) {
17
+ return deadlineAt - now > boundMs;
18
+ }
16
19
  export const DRAIN_IDENTITY_FIELDS = ["scope", "worker", "expectedGeneration", "instanceId"];
17
20
  export function drainIdentityMismatches(req, self) {
18
21
  return DRAIN_IDENTITY_FIELDS.filter((f) => {
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * 1. **一只铸点,三个出口**:{@link mintEngineSignal} 是「引擎此刻自己说自己什么」的唯一来源。`GET /health`(诊断面)、
7
7
  * `GET /readyz`(就绪探针)与 fleet 心跳(`fleet-client.ts`)各读它**一次**,再按各自的出口形投影;没有出口另算一遍
8
- * 就绪 / 重启 / 代际 / 租约。
8
+ * 就绪 / 排空 / 重启 / 代际 / 租约 / 世代账,也没有出口绕过它去读 `deps` 上的这些输入。
9
9
  * 2. **就绪**仍只有一只谓词(`http/readiness.ts` 的 `readinessOf`),铸点把它的判决原样带上(`verdict`)。`/readyz` 200 ⇔
10
10
  * `ready ∧ ¬draining ∧ leaseState ≠ denied` —— 后两项不是新判据:`draining` 本来就是就绪原因,而租约被拒**同一拍**经
11
11
  * 唯一的排空入口进入排空(`fleet/serving-lease.ts`),所以「被拒 ⇒ 排空 ⇒ 不就绪」是构造出来的,不靠第二只谓词。
@@ -15,21 +15,25 @@
15
15
  * 是安全可重启的,扣着的片不阻止它们);被扣的片为空(只差发布重试)⇒ 不是重启事实,不铸(契约 `RestartSignal.reasons` 至少一词;
16
16
  * 那一形的面只有每拍一行运维 warn + 计数)。「扣着」在两出口上只有这一种读法(7.120.x 的 `/health.modelPlaneDeferred` 本版删除)。
17
17
  * 合成只在 {@link buildRestartSignal} 一处,`restartState()` 是它唯一的调用方 —— 两个出口读的是同一只取值口。
18
- * 4. **代际与实例**:`generation` = env `FLEET_GENERATION` 的回声(规范十进制串;非规范在 boot 拒启,见 `config.ts`);
19
- * 缺席 ⇔ 非 fleet 成员。`instanceId` = 本进程起步自铸的那一只(`boot/stage-03` 的 uuidv7),不读 env。
20
- * 5. **出口方言**(契约 `EngineSignal` 与今天两出口的既有形两处不同:`draining` 是布尔、`/health` 就绪时不带 `ready` —— 契约包
21
- * 自己的 `EngineSignal` 头注写明了这两处;世代账两键 settings-schema 9.0.0 起契约也是数,与引擎同形)—— 两出口**照旧**用 8.0 方言。心跳体的类型
22
- * **就是**契约 `InstanceAnnouncement`(中心按它 `safeParse`;8.2.0 起它类型化 `generation` / `leaseState` / `leaseUntil`),没有
23
- * 契约外的键:世代账(`configTargetVersion` / `configAppliedVersion`)契约心跳形没有座位,只上 `/health`。新增的键
24
- * (`generation` / `leaseState` / `leaseUntil`、`restart.withheld`)只在 fleet 成员 / 扣着的候选在场时出现 ⇒ 非 fleet 部署的
25
- * 两出口逐字节同 7.120.0。方言收口归契约包下一个 major(布尔 `draining` 对退役、两出口整只承载 `EngineSignal`)与中心提货之后,
26
- * 由引擎同版翻转 —— 不在本层另造第二方言。
18
+ * 4. **代际**:`generation` = env `FLEET_GENERATION` 的回声(规范十进制串;非规范在 boot 拒启,见 `config.ts`);缺席 ⇔ 非 fleet 成员。
19
+ * **身份不是引擎态**:`instanceId`(本进程起步自铸的那一只,`boot/stage-03` 的 uuidv7)是体的身份,只在两出口体的**顶层**说一次,
20
+ * 铸点不读它、契约形里不重复它。
21
+ * 5. **两形一读(展开 / 收缩)**:两出口今天各发两形 —— 嵌套键 {@link ENGINE_SIGNAL_KEY}(契约 `EngineSignal` 整只,{@link buildEngineSignal}),
22
+ * 与 8.0 扁平方言(布尔 `draining` + `drainingSince`、`/health` 就绪时不带 `ready`、`restart`、成员三键、`/health` 的世代账)。两形读的是
23
+ * **同一次**铸点读数里的**同一个字段**,投影全在本模块({@link buildEngineSignal} / {@link heartbeatReadinessKeys} / {@link healthDrainingKeys} /
24
+ * {@link fleetMembershipKeys} / {@link healthConfigLedgerKeys}),出口文件不拼引擎态的键。排空在两形上读的都是排空**事实**(`EngineDraining`,
25
+ * 三键齐才在),不是就绪原因 —— 所以「`engineSignal.draining` 在场 ⇔ 扁平 `draining` 为真、`drainingSince` = `engineSignal.draining.since`」
26
+ * 是构造出来的,不靠「唯一入口整组写四键」这条运行时不变量(那条不变量只管就绪原因与事实一致)。
27
+ * 心跳体 = 契约 `InstanceAnnouncement` + 嵌套键这一只:契约包 10.x 的心跳形还没有它的座位,10.x 中心按默认 strip 解析丢掉它、照收心跳;
28
+ * 世代账扁平两键契约心跳形没有座位,只上 `/health`(嵌套形里有,两出口都带)。非 fleet 部署的扁平键与 7.122.0 逐字同。
29
+ * 收缩:扁平布尔对(`draining` / `drainingSince`)settings-schema 12.0.0 从契约形删、本引擎 7.124.0+ 删发(届时删它们的投影);嵌套键名在
30
+ * 契约包自有这只常量的那一版提货时改读它。
27
31
  * 6. **租约四态的词表属主是契约包**(`LeaseState`):本模块的读数型 {@link ServingLeaseView} 按它逐词对齐(下面的编译期断言两向钉),
28
32
  * 契约加一个词 ⇒ 本仓编译红,不会悄悄多出一个本仓读不出的态。
29
33
  *
30
- * 模块形:纯函数(只值 import 契约包与 `http/readiness.ts`)—— `http/` 与 `fleet-client.ts` 都读它,不许长出回环。
34
+ * 模块形:纯函数(契约包只 import 型;值 import 只有两只纯叶 `http/readiness.ts` 与 `fleet/drain-entry.ts`)—— `http/` 与 `fleet-client.ts` 都读它,不许长出回环。
31
35
  */
32
- import type { EngineDraining, InstanceAnnouncement, LeaseState, RestartSignal } from "@sema-agent/settings-schema/fleet";
36
+ import type { EngineDraining, EngineSignal, InstanceAnnouncement, LeaseState, RestartSignal } from "@sema-agent/settings-schema/fleet";
33
37
  import { type ReadinessInputs, type ReadinessVerdict } from "../http/readiness.js";
34
38
  /**
35
39
  * 租约此刻的读数(租约客户端交出;纯数据)。态的词表 = 契约 `LeaseState`(四词的语义在契约注里:`none` 不生效 / `granted` 持有 /
@@ -94,9 +98,16 @@ export declare function configHandoffMissing(ledger: ConfigHandoffLedger): boole
94
98
  /** `configHandoff` 的**唯一**合成点(纯):缺着 ⇒ `{ state: "missing", version: 应用着的那一代, since }`;否则缺席。`since` 由持账方锁存(发现缺着的那一拍)。
95
99
  * `build*` = 纯数据;config-center 的 `configHandoffState()` 是唯一调用方。 */
96
100
  export declare function buildConfigHandoff(ledger: ConfigHandoffLedger, since: number | undefined): ConfigHandoff | undefined;
101
+ /** 世代账的一次读数(config-center 的 `configApplyState()` 交出的那一份里本模块读的五键;`/health` 扁平五键与嵌套形的两键读同一份)。 */
102
+ export interface ConfigLedgerReading {
103
+ readonly configTargetVersion: number;
104
+ readonly configAppliedVersion: number;
105
+ readonly configTargetOrdinal: number;
106
+ readonly configAppliedOrdinal: number;
107
+ readonly configApplyStaleMs?: number;
108
+ }
97
109
  /** 铸点读的输入 —— `ServiceDeps` 上的同名键(HTTP 面与心跳读的是**同一个**对象)。 */
98
110
  export interface EngineSignalInputs extends ReadinessInputs {
99
- readonly instanceId?: string;
100
111
  /** `config.fleetGeneration` = env `FLEET_GENERATION` 的回声(boot 已判规范;缺席 ⇔ 非 fleet 成员)。 */
101
112
  readonly config?: {
102
113
  readonly fleetGeneration?: string;
@@ -108,34 +119,54 @@ export interface EngineSignalInputs extends ReadinessInputs {
108
119
  };
109
120
  readonly restartState?: () => RestartSignal | undefined;
110
121
  readonly servingLease?: () => ServingLeaseView;
111
- readonly configApplyState?: () => {
112
- readonly configTargetVersion: number;
113
- readonly configAppliedVersion: number;
114
- } | undefined;
122
+ readonly configApplyState?: () => ConfigLedgerReading | undefined;
115
123
  }
116
124
  /** 铸出的信号(纯数据)。 */
117
125
  export interface EngineSignalFacts {
118
126
  /** 就绪判(`readinessOf` 原样)。 */
119
127
  readonly verdict: ReadinessVerdict;
120
- /** 在场 ⇔ 排空中(契约 `EngineDraining`:动作 / 起点 / 期限)。 */
128
+ /** 在场 ⇔ 排空中(契约 `EngineDraining`:动作 / 起点 / 期限)—— 两出口两形读排空的唯一字段(规则 5)。 */
121
129
  readonly draining?: EngineDraining;
122
130
  readonly restart?: RestartSignal;
123
131
  readonly generation?: string;
124
- readonly instanceId?: string;
125
132
  readonly lease: ServingLeaseView;
126
- readonly configTargetVersion?: number;
127
- readonly configAppliedVersion?: number;
133
+ /** 世代账的那一次读数(未接线 ⇒ 缺席)。 */
134
+ readonly configApply?: ConfigLedgerReading;
128
135
  }
129
136
  /** **唯一**铸点(活判,每次调用现读输入)。 */
130
137
  export declare function mintEngineSignal(deps: EngineSignalInputs): EngineSignalFacts;
138
+ /**
139
+ * 两出口体上嵌套引擎信号的键名(规则 5;两出口同一只拼法)。契约包 10.x 没有这只常量(它的心跳形也没有这一座位);契约包自有它的那一版
140
+ * 提货时,本常量删、改读契约包的。
141
+ */
142
+ export declare const ENGINE_SIGNAL_KEY = "engineSignal";
143
+ /** 体上的嵌套引擎信号:键 {@link ENGINE_SIGNAL_KEY},值 = 契约 `EngineSignal`。 */
144
+ export type EngineSignalCarrier = {
145
+ readonly [ENGINE_SIGNAL_KEY]: EngineSignal;
146
+ };
147
+ /**
148
+ * 契约形 `EngineSignal` 的**唯一**投影(规则 5;两出口同调,同一次读数 ⇒ 同值同序)。键序照契约形:`ready` 恒说;`draining` = 排空事实;
149
+ * `restart` = 取值口交出的那个对象;成员三键 = {@link fleetMembershipKeys};世代账两键 = 同一次读数。身份不在这里(规则 4)。`build*` = 纯数据。
150
+ */
151
+ export declare function buildEngineSignal(s: EngineSignalFacts): EngineSignal;
131
152
  /**
132
153
  * fleet 成员身份三键的**唯一**投影(两出口同调,同值同序):`generation` / `leaseState` / `leaseUntil`。
133
154
  * 在场律:非 fleet 成员(无 `generation`)⇒ 空对象(两出口逐字节同 7.120.0);成员 ⇒ `generation` + `leaseState` 恒在,
134
155
  * `leaseUntil` 只在 `granted`。
135
156
  */
136
157
  export declare function fleetMembershipKeys(s: EngineSignalFacts): FleetMembershipKeys;
137
- /** 出口的 8.0 方言布尔 `draining`(心跳 `InstanceAnnouncement.draining` / `/health.draining`):就绪原因含排空。 */
138
- export declare function drainingFlag(s: EngineSignalFacts): boolean;
158
+ /** 心跳的 8.0 扁平方言 `ready` / `draining`(恒带,`false` 也带):`ready` = 就绪判,`draining` = 排空事实在场(规则 5,与嵌套形读同一个字段)。 */
159
+ export declare function heartbeatReadinessKeys(s: EngineSignalFacts): Pick<InstanceAnnouncement, "ready" | "draining">;
160
+ /** `/health` 的 8.0 扁平排空对:只在排空中带 —— `draining: true` + `drainingSince` = 排空事实的 `since`(规则 5,与嵌套形读同一个字段)。 */
161
+ export declare function healthDrainingKeys(s: EngineSignalFacts): {
162
+ readonly draining?: true;
163
+ readonly drainingSince?: number;
164
+ };
165
+ /**
166
+ * `/health` 的世代账扁平键(未接线 ⇒ 全缺席):target / applied 两版本(与嵌套形同一次读数)+ 两只收货序数(进程内单调,「见过多少代 / 停在哪一代」
167
+ * 的推进判据)+ `configApplyStaleMs`(两者持续不等的时长,收敛即缺席)。只上 `/health`(契约心跳形没有这些座位)。
168
+ */
169
+ export declare function healthConfigLedgerKeys(s: EngineSignalFacts): Partial<ConfigLedgerReading>;
139
170
  /**
140
171
  * `/health.restartRequired`(冗余顶层布尔,壳读它决定要不要问用户重启)的投影:在场 ⇔ 有**可执行**的重启信号
141
172
  * (`restart` 在场且未被扣)—— 与 7.120.0 的读数逐字同(那时被扣的候选根本不出 `restart`)。