@sema-agent/server 7.100.0 → 7.101.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 (107) hide show
  1. package/MIGRATION.md +22 -1
  2. package/README.md +12 -1
  3. package/README.zh-CN.md +9 -1
  4. package/USAGE.md +80 -9
  5. package/dist/approval-card.d.ts +24 -12
  6. package/dist/approval-card.js +4 -3
  7. package/dist/approval-policy-settlement.d.ts +31 -0
  8. package/dist/approval-policy-settlement.js +16 -0
  9. package/dist/approval-reconciler.d.ts +8 -1
  10. package/dist/approval-reconciler.js +5 -4
  11. package/dist/boot/config-center.js +86 -20
  12. package/dist/boot/execution-env.js +20 -2
  13. package/dist/boot/parked-revive-gate.js +1 -0
  14. package/dist/boot/reapers.js +1 -0
  15. package/dist/boot/resolve-spec.js +25 -8
  16. package/dist/boot/runner-deps.js +9 -3
  17. package/dist/boot/stage-02-registry-pull.js +9 -0
  18. package/dist/boot/stage-10-http-server.js +2 -0
  19. package/dist/boot/stores.js +4 -0
  20. package/dist/config-catalog.js +5 -3
  21. package/dist/config-center/apply-effective.d.ts +25 -0
  22. package/dist/config-center/apply-effective.js +144 -69
  23. package/dist/config-center/apply-ledger.d.ts +12 -2
  24. package/dist/config-center/apply-ledger.js +4 -1
  25. package/dist/config-center/facade.d.ts +1 -1
  26. package/dist/config-center/facade.js +1 -1
  27. package/dist/config-center/http-client.d.ts +4 -1
  28. package/dist/config-center/http-client.js +15 -10
  29. package/dist/config-center/read-warnings.d.ts +115 -6
  30. package/dist/config-center/read-warnings.js +115 -5
  31. package/dist/config-center/types.d.ts +6 -0
  32. package/dist/config-invariants.d.ts +2 -2
  33. package/dist/config-invariants.js +15 -0
  34. package/dist/config-provider.d.ts +4 -0
  35. package/dist/config-provider.js +64 -9
  36. package/dist/config-types.d.ts +37 -8
  37. package/dist/config.js +87 -22
  38. package/dist/deployment-governance.d.ts +45 -12
  39. package/dist/deployment-governance.js +37 -9
  40. package/dist/env-name-allowlist-knobs.d.ts +37 -0
  41. package/dist/env-name-allowlist-knobs.js +24 -0
  42. package/dist/fleet/fleet-bus.d.ts +30 -4
  43. package/dist/fleet/fleet-bus.js +20 -4
  44. package/dist/fleet/fleet-reconciler.d.ts +29 -6
  45. package/dist/fleet/fleet-reconciler.js +109 -79
  46. package/dist/fleet-client.d.ts +2 -1
  47. package/dist/hooks/hook-llm.js +13 -4
  48. package/dist/host-lsp-manager.d.ts +14 -2
  49. package/dist/host-lsp-manager.js +7 -6
  50. package/dist/http/admission.js +22 -7
  51. package/dist/http/memory-store-refusal-reply.d.ts +24 -0
  52. package/dist/http/memory-store-refusal-reply.js +10 -0
  53. package/dist/http/routes/approvals-assistant.js +1 -0
  54. package/dist/http/routes/capabilities.js +3 -3
  55. package/dist/http/routes/memory-bundle.js +3 -0
  56. package/dist/http/routes/memory-compliance.js +3 -5
  57. package/dist/http/routes/memory-origin.js +3 -5
  58. package/dist/http/routes/sessions.js +7 -6
  59. package/dist/http/routes/side-query.js +10 -4
  60. package/dist/instruction-source-notice.d.ts +97 -0
  61. package/dist/instruction-source-notice.js +78 -0
  62. package/dist/memory-layer-legacy-lock.d.ts +14 -0
  63. package/dist/memory-layer-legacy-lock.js +74 -0
  64. package/dist/memory-operator-faces.js +1 -17
  65. package/dist/memory-store-refusal.d.ts +37 -0
  66. package/dist/memory-store-refusal.js +14 -0
  67. package/dist/model-entry-refusal.d.ts +130 -0
  68. package/dist/model-entry-refusal.js +157 -0
  69. package/dist/model-provider.d.ts +9 -3
  70. package/dist/model-route-endpoint.d.ts +1 -1
  71. package/dist/model-select.d.ts +49 -4
  72. package/dist/model-select.js +48 -17
  73. package/dist/observability/fail-open.d.ts +13 -1
  74. package/dist/observability/fail-open.js +13 -1
  75. package/dist/observability/metrics.js +1 -0
  76. package/dist/observability/secret-env-scrub.d.ts +27 -4
  77. package/dist/observability/secret-env-scrub.js +22 -7
  78. package/dist/plugins/caching-session-store.d.ts +6 -0
  79. package/dist/plugins/caching-session-store.js +6 -0
  80. package/dist/plugins/local-session-store.d.ts +20 -25
  81. package/dist/plugins/local-session-store.js +111 -64
  82. package/dist/plugins/pg-session-storage.d.ts +7 -0
  83. package/dist/plugins/pg-session-storage.js +19 -0
  84. package/dist/plugins/remote-env-host.d.ts +3 -1
  85. package/dist/plugins/remote-env-host.js +3 -6
  86. package/dist/plugins/tidb-pool.js +5 -0
  87. package/dist/plugins/tidb-session-store.d.ts +13 -0
  88. package/dist/plugins/tidb-session-store.js +14 -0
  89. package/dist/plugins/web-search.d.ts +2 -1
  90. package/dist/plugins/web-search.js +43 -24
  91. package/dist/project-memory.d.ts +10 -0
  92. package/dist/project-memory.js +28 -14
  93. package/dist/run-local.d.ts +17 -15
  94. package/dist/run-local.js +115 -15
  95. package/dist/sealed-key.d.ts +5 -2
  96. package/dist/server-secret-env.d.ts +23 -1
  97. package/dist/server-secret-env.js +9 -0
  98. package/dist/tool-approval.d.ts +74 -31
  99. package/dist/tool-approval.js +102 -49
  100. package/dist/trace/core-keyset-guard.d.ts +27 -5
  101. package/dist/trace/engine-notice-wire.d.ts +27 -1
  102. package/dist/trace/engine-notice-wire.js +8 -0
  103. package/dist/trace/wire-projection-faces.d.ts +2 -2
  104. package/dist/trace/wire-projection-faces.js +2 -2
  105. package/package.json +2 -2
  106. package/dist/memory-layer-preflight.d.ts +0 -46
  107. package/dist/memory-layer-preflight.js +0 -121
package/dist/config.js CHANGED
@@ -24,6 +24,8 @@ import { modelProviderRefusal } from "./model-provider.js";
24
24
  import { modelRouteUrlRefusal } from "./model-route-url.js";
25
25
  import { modelRouteEndpointRefusal } from "./model-route-endpoint.js";
26
26
  import { buildDisplaySafeUrl, redactSecrets } from "./trace/redact.js";
27
+ import { allowlistedEnv } from "./server-secret-env.js";
28
+ import { ENV_NAME_ALLOWLIST_KNOBS, describeAllowlistPostures } from "./env-name-allowlist-knobs.js";
27
29
  export { RETENTION_MODES, MEMORY_CONSOLIDATION_MIN_INTERVAL_SEC } from "./config-types.js";
28
30
  function csv(name) {
29
31
  return (process.env[name] ?? "")
@@ -84,15 +86,6 @@ export function parseUsageWindows(raw) {
84
86
  });
85
87
  return Object.freeze(out);
86
88
  }
87
- function resolveEnvForward(csvNames) {
88
- const out = {};
89
- for (const name of csvNames.split(",").map((s) => s.trim()).filter(Boolean)) {
90
- const v = process.env[name];
91
- if (v !== undefined && v !== "")
92
- out[name] = v;
93
- }
94
- return out;
95
- }
96
89
  function env(name, fallback) {
97
90
  const v = process.env[name];
98
91
  if (v === undefined || v === "") {
@@ -496,6 +489,73 @@ function parseNameListEnv(name, raw, opts) {
496
489
  }
497
490
  return names;
498
491
  }
492
+ const ENV_VAR_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
493
+ const ENV_NAME_ALLOWLIST_KNOB_NAMES = new Set(Object.keys(ENV_NAME_ALLOWLIST_KNOBS));
494
+ const ENV_NAME_ALLOWLIST_WITHHELD = "<withheld: entries are not echoed — a value pasted in place of a name can pass the name check>";
495
+ function parseEnvNameAllowlist(knob, singleUserTurnkey) {
496
+ const raw = process.env[knob];
497
+ if (raw === undefined || raw === "")
498
+ return undefined;
499
+ const spec = ENV_NAME_ALLOWLIST_KNOBS[knob];
500
+ const segments = raw.split(",").map((s) => s.trim());
501
+ segments.forEach((seg, i) => {
502
+ const problem = seg.length === 0
503
+ ? "empty"
504
+ : !ENV_VAR_NAME_RE.test(seg)
505
+ ? 'not an environment-variable name (letters, digits and "_", not starting with a digit)'
506
+ : ENV_NAME_ALLOWLIST_KNOB_NAMES.has(seg.toUpperCase())
507
+ ? `the name of a name-list knob (${[...ENV_NAME_ALLOWLIST_KNOB_NAMES].join(" / ")}: their raw text is withheld from every child process and sandbox; naming one would put that text back)`
508
+ : undefined;
509
+ if (problem === undefined)
510
+ return;
511
+ throw new Error(`env ${knob}: entry #${i + 1} of ${segments.length} is ${problem}` +
512
+ " — refusing to start rather than silently allowing a partial list (#157). The value is not echoed (a pasted NAME=value pair may carry a secret)." +
513
+ ` Write ${knob}=NAME,NAME with bare variable names, e.g. ${spec.example}.`);
514
+ });
515
+ if (!singleUserTurnkey) {
516
+ const posture = spec.multiTenant;
517
+ switch (posture.kind) {
518
+ case "ignored":
519
+ CONFIG_WARNINGS.push({
520
+ env: knob,
521
+ raw: ENV_NAME_ALLOWLIST_WITHHELD,
522
+ kind: "ineffective",
523
+ detail: `REQUIRE_PRINCIPAL=true (multi-tenant / hosted posture): the named allowlist (${segments.length} entr${segments.length === 1 ? "y" : "ies"}) is ignored — ${posture.why}`,
524
+ });
525
+ return [];
526
+ case "honored":
527
+ CONFIG_NOTICES.push({
528
+ event: "config_env_forwarded_multi_tenant",
529
+ fields: {
530
+ env: knob,
531
+ entries: String(segments.length),
532
+ note: `this worker-level allowlist is forwarded as configured to every tenant (deployer-explicit channel); ${describeAllowlistPostures()}`,
533
+ },
534
+ });
535
+ break;
536
+ default: {
537
+ const unreachable = posture;
538
+ throw new Error(`unhandled name-list posture: ${String(unreachable)}`);
539
+ }
540
+ }
541
+ }
542
+ const present = allowlistedEnv(segments, process.env);
543
+ const resolved = [];
544
+ segments.forEach((name, i) => {
545
+ if (Object.hasOwn(present, name)) {
546
+ if (!resolved.includes(name))
547
+ resolved.push(name);
548
+ return;
549
+ }
550
+ CONFIG_WARNINGS.push({
551
+ env: knob,
552
+ raw: ENV_NAME_ALLOWLIST_WITHHELD,
553
+ kind: "entry-rejected",
554
+ detail: `entry #${i + 1} of ${segments.length}: no variable of that name in this process's environment — nothing to pass on (not echoed: a value pasted in place of a name, e.g. ${knob}=$NPM_TOKEN, can pass the name check)`,
555
+ });
556
+ });
557
+ return resolved;
558
+ }
499
559
  function assertReadDenyBuiltinsResolvable(tiers, exclude) {
500
560
  const check = (which, config) => {
501
561
  try {
@@ -802,11 +862,6 @@ export function isRosterGateArmed(configCenter) {
802
862
  function parseModelDomain() {
803
863
  const gatewayBaseUrl = firstSetEnv("MODEL_GATEWAY_BASEURL");
804
864
  const modelId = env("MODEL_ID", "");
805
- if (modelId === "") {
806
- throw new Error("MODEL_ID is required (no factory default since server 3.0.0) — set MODEL_ID=<model name your gateway serves>, " +
807
- "or configure models via the sema-registry catalog; the old silent default was an internal-only model name " +
808
- "that made every external deployment fail far from the cause (gateway 400)");
809
- }
810
865
  const explicitProvider = process.env.MODEL_PROVIDER;
811
866
  if (explicitProvider !== undefined && explicitProvider !== "") {
812
867
  const refusal = modelProviderRefusal(explicitProvider);
@@ -874,7 +929,13 @@ function parseModelDomain() {
874
929
  ...(extraBody ? { extraBody } : {}),
875
930
  };
876
931
  const cheapId = process.env.MODEL_CHEAP_ID;
877
- const cheapModel = cheapId
932
+ if (cheapId && modelId === "") {
933
+ CONFIG_NOTICES.push({
934
+ event: "model_cheap_slot_without_main",
935
+ 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." },
936
+ });
937
+ }
938
+ const cheapModel = cheapId && modelId !== ""
878
939
  ? {
879
940
  ...model,
880
941
  id: cheapId,
@@ -893,7 +954,7 @@ function parseModelDomain() {
893
954
  }
894
955
  : undefined;
895
956
  const mainAct = optFinitePositiveEnv("MODEL_AUTO_COMPACT_TOKENS");
896
- if (applyAutoCompactWindow(model, mainAct) === "removed-1m") {
957
+ if (applyAutoCompactWindow(model, mainAct) === "removed-1m" && modelId !== "") {
897
958
  CONFIG_NOTICES.push({
898
959
  event: "auto_compact_window_not_derived_1m",
899
960
  fields: {
@@ -903,7 +964,8 @@ function parseModelDomain() {
903
964
  },
904
965
  });
905
966
  }
906
- noticeAutoCompactAboveWindow("main", "MODEL_AUTO_COMPACT_TOKENS", model);
967
+ if (modelId !== "")
968
+ noticeAutoCompactAboveWindow("main", "MODEL_AUTO_COMPACT_TOKENS", model);
907
969
  if (cheapModel) {
908
970
  const cheapAct = optFinitePositiveEnv("MODEL_CHEAP_AUTO_COMPACT_TOKENS");
909
971
  if (applyAutoCompactWindow(cheapModel, cheapAct) === "removed-1m") {
@@ -919,7 +981,7 @@ function parseModelDomain() {
919
981
  }
920
982
  noticeAutoCompactAboveWindow("cheap", "MODEL_CHEAP_AUTO_COMPACT_TOKENS", cheapModel);
921
983
  }
922
- const models = { default: model, [model.id]: model };
984
+ const models = modelId === "" ? {} : { default: model, [model.id]: model };
923
985
  if (cheapModel)
924
986
  models[cheapModel.id] = cheapModel;
925
987
  const gatewayApiKey = process.env.MODEL_API_KEY;
@@ -979,6 +1041,7 @@ function parseModelDomain() {
979
1041
  models,
980
1042
  modelApiKeyEnv: {},
981
1043
  modelApiKeys: {},
1044
+ modelRefusals: {},
982
1045
  modelQuotaWeights: {},
983
1046
  tiers: {},
984
1047
  projects: {},
@@ -1650,6 +1713,7 @@ function parseOrchestrationDomain(ctx) {
1650
1713
  }
1651
1714
  return boolEnv("LSP_HOST_ENABLED", true);
1652
1715
  })();
1716
+ const lspEnvAllow = parseEnvNameAllowlist("LSP_ENV_ALLOW", singleUserTurnkey) ?? [];
1653
1717
  {
1654
1718
  const lane = process.env.REMOTE_EXEC;
1655
1719
  if (lane !== undefined && lane !== "") {
@@ -1698,7 +1762,7 @@ function parseOrchestrationDomain(ctx) {
1698
1762
  ...((n) => (n !== undefined ? { timeoutMs: n } : {}))(optNumEnvStrict("E2B_TIMEOUT_MS")),
1699
1763
  ...((n) => (n !== undefined ? { livenessMs: n } : {}))(optNumEnvStrict("E2B_LIVENESS_MS")),
1700
1764
  ...(process.env.E2B_ALLOW_NET ? { allowInternetAccess: boolEnv("E2B_ALLOW_NET", true) } : {}),
1701
- ...(process.env.E2B_SANDBOX_ENV ? { sandboxEnv: resolveEnvForward(process.env.E2B_SANDBOX_ENV) } : {}),
1765
+ ...((names) => (names !== undefined ? { sandboxEnv: allowlistedEnv(names, process.env) } : {}))(parseEnvNameAllowlist("E2B_SANDBOX_ENV", singleUserTurnkey)),
1702
1766
  ...(process.env.E2B_MOUNT_PATH ? { mountPath: process.env.E2B_MOUNT_PATH } : {}),
1703
1767
  orphanReclaim: boolEnv("E2B_ORPHAN_RECLAIM", true),
1704
1768
  }
@@ -1773,7 +1837,7 @@ function parseOrchestrationDomain(ctx) {
1773
1837
  ? { network: enumEnv("DOCKER_NETWORK", "bridge", ["none", "bridge", "host"]) }
1774
1838
  : {}),
1775
1839
  ...((n) => (n !== undefined ? { commandTimeoutMs: n } : {}))(optNumEnvStrict("DOCKER_COMMAND_TIMEOUT_MS")),
1776
- ...(process.env.DOCKER_SANDBOX_ENV ? { env: resolveEnvForward(process.env.DOCKER_SANDBOX_ENV) } : {}),
1840
+ ...((names) => (names !== undefined ? { env: allowlistedEnv(names, process.env) } : {}))(parseEnvNameAllowlist("DOCKER_SANDBOX_ENV", singleUserTurnkey)),
1777
1841
  }
1778
1842
  :
1779
1843
  process.env.REMOTE_EXEC === "device"
@@ -1844,6 +1908,7 @@ function parseOrchestrationDomain(ctx) {
1844
1908
  toolDeferLongtail: boolEnv("TOOL_DEFER_LONGTAIL", false),
1845
1909
  lspEnabled: boolEnv("LSP_ENABLED", false),
1846
1910
  lspHostEnabled,
1911
+ lspEnvAllow,
1847
1912
  workflowAgentsReadOnly: boolEnv("WORKFLOW_AGENTS_READONLY", false),
1848
1913
  imageBakes: {
1849
1914
  enabled: boolEnv("IMAGE_BAKES_ENABLED", false),
@@ -2070,7 +2135,7 @@ const STORE_GROUP_KEYS = [
2070
2135
  ];
2071
2136
  const MODEL_PLANE_GROUP_KEYS = [
2072
2137
  "gatewayBaseUrl", "gatewayApiKey", "gatewayFallbackUrls", "gatewayMaxRetries", "anthropic", "resilience", "model", "envModel", "models",
2073
- "modelApiKeyEnv", "modelApiKeys", "modelQuotaWeights", "tiers", "projects", "roles", "atModelAllowlist", "cascadeLadder", "degrade",
2138
+ "modelApiKeyEnv", "modelApiKeys", "modelRefusals", "modelQuotaWeights", "tiers", "projects", "roles", "atModelAllowlist", "cascadeLadder", "degrade",
2074
2139
  ];
2075
2140
  const APPROVAL_GROUP_KEYS = [
2076
2141
  "approvalRequire", "approvalDeny", "approvalTimeoutSec", "approvalAutoBudget", "approvalNeverAuto",
@@ -2096,7 +2161,7 @@ const ORCHESTRATION_GROUP_KEYS = [
2096
2161
  "workflowOrphanGraceMs", "workflowJournalRetentionMs", "workflowRunRetentionMs", "usageWindows", "workflowAgentsReadOnly",
2097
2162
  "workflowSizeGuideline", "backgroundAgentRetentionMs", "backgroundAgentStaleRunningMs",
2098
2163
  "backgroundAgentParkClaimStaleMs", "delegationEntryCaps", "rosterRetentionMs", "leaderRunStaleMs", "scratchpadSweepTtlMs", "sandboxPkgSource",
2099
- "sessionAutoTitle", "sessionAutoTitleSource", "selectEnvironmentTool", "envFactsEnabled", "toolDeferLongtail", "lspEnabled", "lspHostEnabled",
2164
+ "sessionAutoTitle", "sessionAutoTitleSource", "selectEnvironmentTool", "envFactsEnabled", "toolDeferLongtail", "lspEnabled", "lspHostEnabled", "lspEnvAllow",
2100
2165
  "imageBakes", "readFace", "readFaceSource", "taskWriteFaceEscalation", "readDenyPatterns", "readDenyBuiltinTiers", "readDenyBuiltinExclude",
2101
2166
  ];
2102
2167
  const LIMITS_HTTP_GROUP_KEYS = [
@@ -70,8 +70,26 @@ export interface PathAdjudication {
70
70
  export interface LiveQuestionFace {
71
71
  hasLiveContext(): boolean;
72
72
  }
73
- /** durable 轴的审批基线入参。缺席 ⇒ adjudicated allow-all 基线(见 {@link createApprovalBaselinePolicy})。 */
73
+ /**
74
+ * S-694 —— 审批基线的**席**:这条腿上「一个名单产的 `ask` 由谁来服」。闭集三形,每条腿**显式**说出自己那一形
75
+ * ({@link createApprovalBaselinePolicy} 的席参数必填、按 `kind` 穷举 —— 闭集 +1 = 编译红):
76
+ * · `durable` —— checkpoint suspend/resume 服 ask(HTTP 腿 durable 开、park 赎回腿)。AskUserQuestion 的问答门
77
+ * **只**叠在这一形上(它的 `ask` 语义就是 durable park)。
78
+ * · `live` —— Runner 的活 onAsk 席当场服 ask(run-local 的 `createLocalApprover`:TTY 问 y/N / 无 TTY 当场拒;
79
+ * core `isLiveApproverSeat(onAsk)` 为真时 `suspendAsk` 早退,ask 落回调)。三张名单与 durable 席**同一只**策略。
80
+ * · `absent` —— 没有谁服 ask ⇒ adjudicated allow-all;**只**服务零名单(HTTP 腿「durable 关 ∧ 单用户零意图」)。
81
+ * 带名单进来 = 响亮拒:名单被一条没有席的腿静默吞掉,正是 S-694 的病形。
82
+ *
83
+ * 🔴 由来(S-694):修前席是一个**可选**参数、缺席即 allow-all。run-local 调它时没传 ⇒ 三张名单一张都不进策略,
84
+ * 而这条腿其实**有**一只活 onAsk 席 —— [ref] 件一把「没有 durable 赎回腿」等同成了「ask 无处可服」。
85
+ * 席必填之后,「这条腿能不能服 ask」是每个调用点必须写出来的一句话,不再是一个忘了写就成立的缺省。
86
+ */
87
+ export type ApprovalSeat = DurableApprovalSeat | LiveApprovalSeat | AbsentApprovalSeat;
88
+ /** 席的形名(补偿 warn 等只需要「哪一形」的读者读它,不必铸整只席)。 */
89
+ export type ApprovalSeatKind = ApprovalSeat["kind"];
90
+ /** durable 席:checkpoint suspend/resume 服 ask,问答门叠在名单策略上。 */
74
91
  export interface DurableApprovalSeat {
92
+ readonly kind: "durable";
75
93
  /** 活体问答面;`undefined` ⇒ 恒 durable park 的原形门。 */
76
94
  readonly question: LiveQuestionFace | undefined;
77
95
  /** 每会话「本会话不再询问」探针(canonical toolName 键空间)。 */
@@ -79,6 +97,14 @@ export interface DurableApprovalSeat {
79
97
  /** 豁免短路时的审计钩子。 */
80
98
  readonly onExempted?: DurableAskOptions["onExempted"];
81
99
  }
100
+ /** live 席:Runner 的活 onAsk 当场服 ask(无 park、无会话豁免店)。 */
101
+ export interface LiveApprovalSeat {
102
+ readonly kind: "live";
103
+ }
104
+ /** absent 席:这条腿没有谁服 ask ⇒ adjudicated allow-all,只服务零名单。 */
105
+ export interface AbsentApprovalSeat {
106
+ readonly kind: "absent";
107
+ }
82
108
  /**
83
109
  * [ref]([ref] 案二):durable 部署上的 AskUserQuestion 门。活体面(QuestionCoordinator)缺席 ⇒ 原形
84
110
  * `createDurableQuestionPolicy()`(恒 ask ⇒ 恒 durable park)。在场 ⇒ **判决时**按活流上下文分腿:
@@ -179,8 +205,9 @@ export interface OperatorWarning {
179
205
  }
180
206
  /** {@link buildOnlySensitiveBaselineWarning} 读的座位量 —— 「这个部署到底有没有一道**真**门」。 */
181
207
  export interface GateSeatView {
182
- /** durable 审批门(checkpoint suspend/resume)是否在这条腿上真装。 */
183
- readonly durableEnabled: boolean;
208
+ /** 这条腿审批基线的**席**(与喂 {@link createApprovalBaselinePolicy} 的是同一只,S-694:此前是一个与席分家的
209
+ * `durableEnabled` 布尔,于是有活 onAsk 席的 run-local 腿被判成「没有门」)。 */
210
+ readonly approvalSeat: ApprovalSeatKind;
184
211
  /** 单用户 turnkey 的 auto-accept 基线是否适用(= 零门意图的既定姿势,不是 misconfig)。 */
185
212
  readonly singleUserAutoAcceptBaseline: boolean;
186
213
  }
@@ -192,8 +219,10 @@ export interface GateSeatView {
192
219
  * 此前是「这个部署一个门都没接」这个 misconfig 的**唯一**提示,而守卫集只挡那二十来个路径段、其余写
193
220
  * 目标照旧无裁决 —— 信号不能因为我们铺了基线就静默消失,所以由我们自己按同一判据说一次。
194
221
  *
195
- * 判据(与信号消失的条件逐字互补):守卫集在场 ∧ 两条产**真**门的腿都不在场(durable 门关 ∧ 单用户
196
- * auto-accept 基线不适用)。三个量都是部署常量 ⇒ 消费腿在 boot 期说一次,不是每任务一次。
222
+ * 判据(与信号消失的条件逐字互补):守卫集在场 ∧ 这条腿的审批基线**没有席**(`absent`)∧ 单用户
223
+ * auto-accept 基线不适用。三个量都是部署常量 ⇒ 消费腿在 boot 期说一次,不是每任务一次。
224
+ * S-694:席读的是喂审批基线的**同一只**({@link ApprovalSeat})—— `durable` / `live` 两形都有人服 ask、名单
225
+ * 进策略,「守卫集是唯一的策略」这句话在它们上面不成立。今天真发的只剩 HTTP 腿「durable 关 ∧ 多租户」那一形。
197
226
  *
198
227
  * ⚠️ 单一属主([ref] 件三):HTTP 腿与 run-local 腿共用本判据与文案。两处各写一份 = 一处改了另一处
199
228
  * 没改,而两份都长得像对的 —— 那正是本文件存在的理由。
@@ -211,18 +240,22 @@ export declare function createDeploymentGovernanceInputs(config: DeploymentGover
211
240
  * 审批基线 —— `applyRuntimeGovernance` 那个 base 的 `toolPolicy` 座([ref] 件一)。**恒非
212
241
  * `undefined`**:治理层是 tighten-only 的叠加层,没有基线可叠时它自己也产不出「门在场」这件事。
213
242
  *
214
- * 两形:
215
- * · `durable` 在场 ⇒ durable 轴 = AskUserQuestion 判决门 + F4 高危写审批门(deny/neverAuto/预算/
216
- * 会话豁免全在 `createDurableAskPolicy` 里)。gated `ask` 由 core 变成 durable checkpoint suspend。
217
- * · `durable` 缺席 ⇒ **adjudicated allow-all**(`createAllowDenyPolicy({})`):一条**在场的**、
243
+ * 三形,按席穷举(S-694,席的定义见 {@link ApprovalSeat}):
244
+ * · `durable` ⇒ AskUserQuestion 判决门 + 三张名单的审批门(deny/neverAuto/预算/会话豁免全在
245
+ * `createDurableAskPolicy` 里)。gated `ask` 由 core 变成 durable checkpoint suspend。
246
+ * · `live` ⇒ **同一只**三张名单审批门,不叠问答门。gated `ask` 由 core 交给 Runner 的活 onAsk 席
247
+ * (本口**不**做 ask→deny 映射:无人可问时的拒由那只席说,判据与文案都归它)。
248
+ * · `absent` ⇒ **adjudicated allow-all**(`createAllowDenyPolicy({})`):一条**在场的**、
218
249
  * effect-aware 的策略。CC 的信任模型是 auto-accept,但门**机制**必须在场(core 原则「机制留、默认
219
250
  * 可更宽」)—— 满足 core 的 `hasEffectAwareGate`,恢复可观测性与 hook/tighten 点,而运维照旧用
220
251
  * `AUTONOMY`/`commandPolicy` 收紧不可逆操作(由 `applyRuntimeGovernance` tighten-only 叠上)。
252
+ * 🔴 本形**只**服务零名单:名单在场却递 absent 席 = 名单被静默吞掉(S-694 的病形),响亮拒。
221
253
  *
222
254
  * ⚠️ 「零门意图才铺 allow-all」这条准入判据**不在本口**:它是消费腿的部署形判断(单用户/多租户、
223
- * 有无 checkpoint 店),属主是 `hasOperatorGateIntent`/`assertGateIntentServiceable`(src/approval.ts)。
224
- * 本口只按调用方给的形铸策略,绝不替它判「这个部署该不该有门」。
255
+ * 有无 checkpoint 店),属主是 `hasOperatorGateIntent` / `gateIntentUnserviceableReason`(src/approval.ts)。
256
+ * 本口只按调用方给的席铸策略,绝不替它判「这个部署该不该有门」;absent 臂那条拒是**席与名单自相矛盾**的
257
+ * 一致性断言(调用方已判过的事实在这里被违反),不是第二份准入判据。
225
258
  */
226
- export declare function createApprovalBaselinePolicy(config: ApprovalBaselineConfigView, durable?: DurableApprovalSeat): ToolPolicy;
259
+ export declare function createApprovalBaselinePolicy(config: ApprovalBaselineConfigView, seat: ApprovalSeat): ToolPolicy;
227
260
  export {};
228
261
  //# sourceMappingURL=deployment-governance.d.ts.map
@@ -1,6 +1,6 @@
1
1
  import { join, posix } from "node:path";
2
2
  import { FileError, StubExecutionEnv, combinePolicies, createAllowDenyPolicy, createDurableQuestionPolicy, createSensitivePathPolicy, createTranscriptIntegrityPolicy, err, isAbsoluteForFamily, isProtectedWrite, ok, pathTargetValue, protectivePathTargetOf, } from "@sema-agent/core";
3
- import { createDurableAskPolicy } from "./approval.js";
3
+ import { createDurableAskPolicy, hasApprovalListIntent } from "./approval.js";
4
4
  import { ASK_USER_QUESTION_TOOL_NAME } from "./approval-content-kind.js";
5
5
  export function createDurableQuestionGate(live) {
6
6
  if (live === undefined)
@@ -58,8 +58,19 @@ export function assertGuardPatternsUsable(config) {
58
58
  }
59
59
  }
60
60
  export function buildOnlySensitiveBaselineWarning(config, seat) {
61
- if (config.sensitiveWritePatterns.length === 0 || seat.durableEnabled || seat.singleUserAutoAcceptBaseline)
61
+ if (config.sensitiveWritePatterns.length === 0 || seat.singleUserAutoAcceptBaseline)
62
62
  return undefined;
63
+ switch (seat.approvalSeat) {
64
+ case "durable":
65
+ case "live":
66
+ return undefined;
67
+ case "absent":
68
+ break;
69
+ default: {
70
+ const unhandled = seat.approvalSeat;
71
+ throw new Error(`approval seat "${String(unhandled)}" has no arm in the only-sensitive-baseline warning`);
72
+ }
73
+ }
63
74
  return {
64
75
  event: "tool_policy_only_sensitive_baseline",
65
76
  fields: {
@@ -95,16 +106,33 @@ export function createDeploymentGovernanceInputs(config, pathAdjudication, roots
95
106
  ...(sensitivePathPolicy ? { sensitivePathPolicy } : {}),
96
107
  };
97
108
  }
98
- export function createApprovalBaselinePolicy(config, durable) {
99
- if (durable === undefined)
100
- return createAllowDenyPolicy({});
101
- return combinePolicies(createDurableQuestionGate(durable.question), createDurableAskPolicy({
109
+ export function createApprovalBaselinePolicy(config, seat) {
110
+ switch (seat.kind) {
111
+ case "durable":
112
+ return combinePolicies(createDurableQuestionGate(seat.question), createApprovalListPolicy(config, seat));
113
+ case "live":
114
+ return createApprovalListPolicy(config, {});
115
+ case "absent":
116
+ if (hasApprovalListIntent(config)) {
117
+ throw new Error("approval baseline: this leg has no approval seat (nobody can answer an ask), yet an approval list is in force " +
118
+ "(APPROVAL_REQUIRE / APPROVAL_DENY / APPROVAL_NEVER_AUTO, or the center's governance.approvalRequire) — the list would be silently ignored. " +
119
+ "The caller must hand a durable or live seat, or refuse the list before it reaches this leg.");
120
+ }
121
+ return createAllowDenyPolicy({});
122
+ default: {
123
+ const unhandled = seat;
124
+ throw new Error(`approval seat ${JSON.stringify(unhandled)} has no baseline arm`);
125
+ }
126
+ }
127
+ }
128
+ function createApprovalListPolicy(config, memory) {
129
+ return createDurableAskPolicy({
102
130
  requireApproval: config.approvalRequire,
103
131
  deny: config.approvalDeny,
104
132
  autoBudget: config.approvalAutoBudget,
105
133
  neverAuto: config.approvalNeverAuto,
106
- ...(durable.exempt ? { exempt: durable.exempt } : {}),
107
- ...(durable.onExempted ? { onExempted: durable.onExempted } : {}),
108
- }));
134
+ ...(memory.exempt ? { exempt: memory.exempt } : {}),
135
+ ...(memory.onExempted ? { onExempted: memory.onExempted } : {}),
136
+ });
109
137
  }
110
138
  //# sourceMappingURL=deployment-governance.js.map
@@ -0,0 +1,37 @@
1
+ /**
2
+ * S-699 / S-713 —— **名单型 env 旋钮**的闭集表(纯叶:零值依赖,任何层都可值引而不成装载环)。
3
+ *
4
+ * 解析规则(未设 / 形错拒启 / 姿态 / 生效名单)的全文在 `config.ts` `parseEnvNameAllowlist` 的头注;本表只装**各旋钮自己的**事实,
5
+ * 不是解析规则:
6
+ * · `example` 进拒句;
7
+ * · `multiTenant` = `REQUIRE_PRINCIPAL=true` 下名单作不作数。
8
+ * - `LSP_ENV_ALLOW` ⇒ ignored:host 腿语言服务器在多租户姿态下本来就不起,名单无处作数,设了要说出来(S-699)。
9
+ * - 两只沙箱名单 ⇒ honored:S-713 照录修前语义(任何姿态下都转发),S-715 主会话裁定维持 —— 沙箱 env 是部署方为隔离沙箱显式配的
10
+ * 带外凭据(e2b / docker 车道本来就是多租户 fleet 的主形),与 LSP 那条「本机子进程」的形不同;多租户下起服说一句。
11
+ * 表是 `Record<旋钮, …>`:闭集加一只旋钮而不补行 = 编译红。
12
+ *
13
+ * 🔴 **姿态列只在这里写一次**([ref];7.101.0 合并复审 O9):凡要对人说「多租户下哪只旋钮忽略、哪只照转发」的句子,一律取
14
+ * {@link describeAllowlistPostures} 的读数 —— 今天三处:`config_env_forwarded_multi_tenant.note`(`config.ts` 解析器)、LSP 剥除现场的
15
+ * 出路句(`observability/secret-env-scrub.ts`)、配置目录 `LSP_ENV_ALLOW` 行(`config-catalog.ts`)。修前三处各手抄一遍,表改一格它们
16
+ * 当场撒谎。为什么是纯叶:`config-catalog.ts` 被 `config.ts` 值引,表留在 `config.ts` 会成装载环。
17
+ */
18
+ export type EnvNameAllowlistKnob = "LSP_ENV_ALLOW" | "E2B_SANDBOX_ENV" | "DOCKER_SANDBOX_ENV";
19
+ export type EnvNameAllowlistPosture = {
20
+ readonly kind: "honored";
21
+ } | {
22
+ readonly kind: "ignored";
23
+ readonly why: string;
24
+ };
25
+ export type EnvNameAllowlistKnobTable = {
26
+ readonly [K in EnvNameAllowlistKnob]: {
27
+ readonly example: string;
28
+ readonly multiTenant: EnvNameAllowlistPosture;
29
+ };
30
+ };
31
+ export declare const ENV_NAME_ALLOWLIST_KNOBS: EnvNameAllowlistKnobTable;
32
+ /**
33
+ * 多租户姿态句 —— **由表派生**,不许在别处手写。形:`under REQUIRE_PRINCIPAL=true: <旋钮> is ignored; <旋钮> / <旋钮> are forwarded as configured`
34
+ * (按 {@link POSTURE_PHRASE} 的 kind 序分组、组内按表序;空组不出现)。只有旋钮名与姿态,不带任何名单内容。
35
+ */
36
+ export declare function describeAllowlistPostures(table?: EnvNameAllowlistKnobTable): string;
37
+ //# sourceMappingURL=env-name-allowlist-knobs.d.ts.map
@@ -0,0 +1,24 @@
1
+ export const ENV_NAME_ALLOWLIST_KNOBS = {
2
+ LSP_ENV_ALLOW: {
3
+ example: "LSP_ENV_ALLOW=NPM_TOKEN,CARGO_REGISTRIES_MY_REG_TOKEN",
4
+ multiTenant: { kind: "ignored", why: "the host-lane language server is not started in this posture, and the language-server env keeps the full secret strip" },
5
+ },
6
+ E2B_SANDBOX_ENV: { example: "E2B_SANDBOX_ENV=GIT_TOKEN,NPM_TOKEN", multiTenant: { kind: "honored" } },
7
+ DOCKER_SANDBOX_ENV: { example: "DOCKER_SANDBOX_ENV=GIT_TOKEN,NPM_TOKEN", multiTenant: { kind: "honored" } },
8
+ };
9
+ const POSTURE_PHRASE = {
10
+ ignored: { one: "is ignored", many: "are ignored" },
11
+ honored: { one: "is forwarded as configured", many: "are forwarded as configured" },
12
+ };
13
+ export function describeAllowlistPostures(table = ENV_NAME_ALLOWLIST_KNOBS) {
14
+ const knobs = Object.keys(table);
15
+ const groups = Object.keys(POSTURE_PHRASE).flatMap((kind) => {
16
+ const named = knobs.filter((k) => table[k].multiTenant.kind === kind);
17
+ if (named.length === 0)
18
+ return [];
19
+ const phrase = POSTURE_PHRASE[kind];
20
+ return [`${named.join(" / ")} ${named.length === 1 ? phrase.one : phrase.many}`];
21
+ });
22
+ return `under REQUIRE_PRINCIPAL=true: ${groups.join("; ")}`;
23
+ }
24
+ //# sourceMappingURL=env-name-allowlist-knobs.js.map
@@ -248,13 +248,19 @@ export type FleetFrame = {
248
248
  * durable 行压根不存在 —— 三形共同点是**没有终局可投影**,而本副本的行必然陈旧。此时造一个终态
249
249
  * 就是编:判死的属主始终是 durable 侧的 reaper。所以只陈述「这条行离场了」,不逼消费端结算。
250
250
  * (cli 侧裁量 [ref]:渲「离场」**不清行** —— 行保留、中性离场形、active 扣除、折叠排后。)
251
+ * 🔴 S-751:子代行的陈述级退场之前,对账腿先问过它的属主(bg registry 非终态 ∨ 有待决审批 ⇒ 留行);
252
+ * 本帧因此只在「静默满一周期 ∧ 属主不作保」时出现。而它**不封被退那一代**(见 {@link cycleSeq})——
253
+ * 之后同一代(或更大代)的帧到达 = 那一行其实还活着,行**复活**;更小的代照旧是前代迟到帧。
251
254
  * ADDITIVE / tolerate-absent。
252
255
  */
253
256
  removeReason?: "orphaned";
254
257
  /** [ref] §2 判死×判代合成规则:退场帧带**被退那一代**的 {@link FleetTaskRow.cycleSeq}。
255
258
  * 消费端因此能把 `retire(gen=N)` 之后的 `frame(gen=N+1)` 读成**复活**(行重生,退休不粘住新代),
256
- * 而 `retire(gen=N)` 之后的 `frame(gen=N)` 读成**前代迟到帧,忽略**。缺席同 {@link FleetTaskRow.cycleSeq}
257
- * ——「无此概念」,不是「第一代」。 */
259
+ * 而**结算级** `retire(gen=N)`(本帧无 `removeReason`)之后的 `frame(gen=N)` 读成**前代迟到帧,忽略**。
260
+ * 🔴 S-751:**陈述级**退场(`removeReason:"orphaned"`)之后的 `frame(gen=N)` = **复活**,不是迟到帧 ——
261
+ * 陈述级退场从不断言「这一代结束了」(它说的只是「本副本一个周期没听到这一行」),所以同代的下一帧
262
+ * 就是它还活着的证据(本仓 {@link FleetEventBus.removeTask} 对这一形不封代,消费端同读);更小的代照旧忽略。
263
+ * 缺席同 {@link FleetTaskRow.cycleSeq} ——「无此概念」,不是「第一代」。 */
258
264
  cycleSeq?: number;
259
265
  } | {
260
266
  type: "workflow";
@@ -452,7 +458,10 @@ export declare class FleetEventBus {
452
458
  private readonly tasks;
453
459
  private readonly workflows;
454
460
  /** [ref] / codex R1-F2:**代际墓碑** —— 已离场行的最后已知 `cycleSeq`(行本身已从 `tasks` 删掉,
455
- * 代际状态却必须活得比行久一点,否则前代迟到帧会在空 Map 上把幽灵行重铸出来)。有界 LRU。 */
461
+ * 代际状态却必须活得比行久一点,否则前代迟到帧会在空 Map 上把幽灵行重铸出来)。有界 LRU。
462
+ * 🔴 S-751:碑上多记一位 `ended` = 「**这一代结束了**」是否被断言过。离场**恒**保住代际状态(`gen` 之前的帧
463
+ * 永远是前代迟到帧 —— 与行还在 Map 里时那道闸同一个判据);只有作「这一代结束了」断言的离场(结算级 / 投影级
464
+ * 常规清场)才把 `gen` **这一代本身**也封掉。陈述级退场(`removeReason:"orphaned"`)不作这个断言 ⇒ 同代帧 = 复活。 */
456
465
  private readonly retiredGen;
457
466
  private static readonly RETIRED_GEN_MAX;
458
467
  constructor(now?: () => number);
@@ -472,7 +481,7 @@ export declare class FleetEventBus {
472
481
  /** Drop a task row (terminal + swept) + fan out the removal. Idempotent (a no-op if already gone).
473
482
  *
474
483
  * `opts`([ref],两键均 ADDITIVE):`removeReason:"orphaned"` = **陈述级退场**(没有结算语义,见
475
- * {@link FleetFrame} 的 `task_remove` 臂);`cycleSeq` = 被退那一代的代际号。发布方的常规清场
484
+ * {@link FleetFrame} 的 `task_remove` 臂;S-751 起**不封被退那一代**,同代帧 = 复活);`cycleSeq` = 被退那一代的代际号。发布方的常规清场
476
485
  * (`onTerminal`/`onChildTerminal`)不传 opts ⇒ 帧逐字节与本改动之前相同。 */
477
486
  removeTask(id: string, opts?: {
478
487
  removeReason?: "orphaned";
@@ -489,6 +498,23 @@ export declare class FleetEventBus {
489
498
  backgroundChildLaneRow(scope: string | undefined, childUuid: string): string | undefined;
490
499
  /** [ref] 让位迁移料的读面:迁移「只补缺席位」需要看得见存活行现有什么(BCE 是权威车道,不覆盖)。 */
491
500
  taskRow(id: string): Readonly<FleetTaskRow> | undefined;
501
+ /**
502
+ * S-751:一条子代行在它两只**属主账**上的键。三个 id 域互不相同,换算只有本类做得了(两种行 id 都是本文件
503
+ * 铸的,两域之间唯一的 join —— 让位登记簿 —— 也住在这里),所以换算只写这一处:
504
+ * · 行 id —— BCE 车道 = registry handle 本身(`e.taskId`);run-leg 车道 = `${runId} ${子代 task_progress taskId}`
505
+ * (`fleetRunPublisher` 的 `childId`);
506
+ * · `handle` —— core bg registry / `BackgroundAgentStore` 的行键,**只认** `DURABLE_AGENT_HANDLE_RE` 形
507
+ * (`wa*` 不在 bg 账上);
508
+ * · `sessionIds` —— 子代自己的 core session id(uuid 域)= 待决审批 `originTaskId` 的值域
509
+ * (core `AskRequest.sourceTaskId`:「the issuing task's session id」),**不是** handle。是**集合**:同一行名下
510
+ * 可以先后挂过几个子代 session(`wa*` 重试换 session、复活换 session),让位登记簿对每一个都留着 claim ——
511
+ * 只取其一(此前取最早那个)会漏掉当前这一次的卡(codex r1 F3)。
512
+ * 换不出的键就缺席 / 空(不猜):父链接行 / 顶层 run 行两者皆无;BCE 行在本车道学到子代 uuid 之前 `sessionIds` 为空。
513
+ */
514
+ childOwnerKeys(row: Pick<FleetTaskRow, "id" | "scope">): {
515
+ handle?: string;
516
+ sessionIds: readonly string[];
517
+ };
492
518
  /** Upsert a workflow row (MERGE by `id`) + fan out. */
493
519
  publishWorkflow(delta: Partial<FleetWorkflowRow> & {
494
520
  id: string;
@@ -1,5 +1,5 @@
1
1
  import { EventEmitter } from "node:events";
2
- import { deriveAgentDisplayStatus, isDelegatedAgentTerminal } from "@sema-agent/core";
2
+ import { DURABLE_AGENT_HANDLE_RE, deriveAgentDisplayStatus, isDelegatedAgentTerminal } from "@sema-agent/core";
3
3
  import { persistedPlaneOf } from "../terminal.js";
4
4
  import { recordFailOpen } from "../observability/fail-open.js";
5
5
  import { redactSecrets } from "../trace/redact.js";
@@ -74,7 +74,7 @@ export class FleetEventBus {
74
74
  return;
75
75
  if (existing === undefined && delta.retiredBy === undefined) {
76
76
  const tomb = this.retiredGen.get(delta.id);
77
- if (tomb !== undefined && delta.cycleSeq !== undefined && delta.cycleSeq <= tomb)
77
+ if (tomb !== undefined && delta.cycleSeq !== undefined && (delta.cycleSeq < tomb.gen || (tomb.ended && delta.cycleSeq === tomb.gen)))
78
78
  return;
79
79
  }
80
80
  const merged = { ...(existing ?? { id: delta.id, status: "running" }), ...delta };
@@ -97,10 +97,12 @@ export class FleetEventBus {
97
97
  if (existing !== undefined && opts?.cycleSeq !== undefined && existing.cycleSeq !== undefined && existing.cycleSeq > opts.cycleSeq)
98
98
  return;
99
99
  if (this.tasks.delete(id)) {
100
- const gen = existing?.cycleSeq ?? opts?.cycleSeq;
100
+ const prior = this.retiredGen.get(id);
101
+ const gen = existing?.cycleSeq ?? opts?.cycleSeq ?? prior?.gen;
101
102
  if (gen !== undefined) {
103
+ const ended = opts?.removeReason !== "orphaned" || (prior !== undefined && prior.gen === gen && prior.ended);
102
104
  this.retiredGen.delete(id);
103
- this.retiredGen.set(id, gen);
105
+ this.retiredGen.set(id, { gen, ended });
104
106
  if (this.retiredGen.size > FleetEventBus.RETIRED_GEN_MAX) {
105
107
  const oldest = this.retiredGen.keys().next();
106
108
  if (!oldest.done)
@@ -138,6 +140,20 @@ export class FleetEventBus {
138
140
  taskRow(id) {
139
141
  return this.tasks.get(id);
140
142
  }
143
+ childOwnerKeys(row) {
144
+ const sp = row.id.lastIndexOf(" ");
145
+ if (sp > 0) {
146
+ const sessionId = row.id.slice(sp + 1);
147
+ const claimed = this.bceClaims.get(FleetEventBus.bceKey(row.scope, sessionId));
148
+ return { sessionIds: [sessionId], ...(claimed !== undefined && DURABLE_AGENT_HANDLE_RE.test(claimed) ? { handle: claimed } : {}) };
149
+ }
150
+ const prefix = FleetEventBus.bceKey(row.scope, "");
151
+ const sessionIds = [];
152
+ for (const [k, rowId] of this.bceClaims)
153
+ if (rowId === row.id && k.startsWith(prefix))
154
+ sessionIds.push(k.slice(prefix.length));
155
+ return { sessionIds, ...(DURABLE_AGENT_HANDLE_RE.test(row.id) ? { handle: row.id } : {}) };
156
+ }
141
157
  publishWorkflow(delta) {
142
158
  const merged = { ...(this.workflows.get(delta.id) ?? { id: delta.id, status: "running" }), ...delta };
143
159
  this.workflows.set(delta.id, merged);
@@ -33,10 +33,17 @@
33
33
  * 「**静默满一个对账周期**」(本腿订阅总线自记每行的最后帧时刻):还在出帧 = 活着的证据。
34
34
  * durable 行**在场**的那三格没有这个歧义(`getRun` 命中 = 它就是一条 run 行),故照矩阵即时处置。
35
35
  *
36
- * ## 子代行(§1)
37
- * 随父:父行退休/退场时**同扫**(与 `onTerminal` 的 removal 集同姿势)。子代**不读 durable** ——
38
- * bg 子代的 durable 真源在 core 侧 registry,server 不越界重判。无父可循的孤儿子行(父行已不在 bus)
39
- * 按「bus 内滞留超一个对账周期且无新帧」陈述级退场。
36
+ * ## 子代行(§1;🔴 S-751 起改为「活性由属主说了算」)
37
+ * 子代行**不读 durable run 店**(它不是 run 行)。它的活性属主有两只,都在本仓可读 —— 读它们**不是**越界重判,
38
+ * 是问属主(S-751 之前此处写「bg 子代的 durable 真源在 core 侧 registry,server 不越界重判」,于是判据里只剩
39
+ * 「静默」这一条,而 registry 的读半场 `RunnerDeps.backgroundAgentStore` 一直就在本仓):
40
+ * ① core bg registry(`BackgroundAgentStore` 行 `status` 非终态 = `running` / `parked`);
41
+ * ② 本副本审批面的待决卡(子代发起、没人答的 ask —— 子代卡在那儿,当然不出帧)。
42
+ * **一条规则**:无父可循(父从未在 / 父本周期刚退休或退场)∧ 静默满一个对账周期 ∧ 两只属主都不作保 ⇒
43
+ * 陈述级退场。父离场**不再无条件同扫**子代(那是报案的一半病:父轮结束、子代一次工具调用 ≥60s 或审批卡
44
+ * 60s 没人答 ⇒ 活子代被扫);父离场只让子代「无父可循」,子代与既有孤儿走**同一只**判据、同一个循环。
45
+ * 同一问也挂在「durable 无行」那一格上:父链接未解析的 `a*` 行与顶层行同形(见上方收窄段),run 账不认识它
46
+ * ≠ 它死了。另一半病(陈述级退场立了代际墓碑、吞掉活子代之后的同代帧)的修在 `fleet-bus.ts` `removeTask`。
40
47
  *
41
48
  * ## workflow 行(§1)
42
49
  * 只做同款「durable 终局投影」([ref] 的 snapshot pull 腿 + reapers 的 workflow recover 腿已在场),
@@ -77,7 +84,7 @@
77
84
  * 与 `fleet-terminal-window.ts` 同一论证:两侧边界都由语义定死(比周期短 = 幽灵行仍在面板上,比周期长 =
78
85
  * 拿对账腿当历史列表),旋钮只会让部署方去调一个没有正确取值的数;fleet 面现有零旋钮,从之。
79
86
  */
80
- import { type WorkflowRun, type WorkflowRunStore } from "@sema-agent/core";
87
+ import { type BackgroundAgentRecord, type WorkflowRun, type WorkflowRunStore } from "@sema-agent/core";
81
88
  import type { Logger } from "../observability/logger.js";
82
89
  import type { Metrics } from "../observability/metrics.js";
83
90
  import { type RunRecord } from "../plugins/store-contracts.js";
@@ -111,6 +118,12 @@ export interface FleetReconcileRunPort {
111
118
  export interface FleetReconcileWorkflowPort {
112
119
  get(id: string): Promise<WorkflowRun | null | undefined>;
113
120
  }
121
+ /** S-751:core bg registry 账(`BackgroundAgentStore`)的只读窄口 —— 与 {@link FleetReconcileRunPort} 同一条
122
+ * 单写者纪律(接口上没有写方法)。生产 = `RunnerDeps.backgroundAgentStore` **同实例**(结构上就是它的 `get`
123
+ * 一面);`null` = 账上没有这一行。 */
124
+ export interface FleetReconcileAgentPort {
125
+ get(handle: string, scope: string): Promise<Pick<BackgroundAgentRecord, "status"> | null>;
126
+ }
114
127
  export interface FleetReconcilerDeps {
115
128
  bus: FleetEventBus;
116
129
  /**
@@ -130,6 +143,14 @@ export interface FleetReconcilerDeps {
130
143
  subscribeRunTerminal?: (fn: (rec: RunTerminalRecord) => void) => () => void;
131
144
  runs?: FleetReconcileRunPort;
132
145
  workflows?: FleetReconcileWorkflowPort | WorkflowRunStore;
146
+ /** S-751:子代活性的属主①(bg registry 账)。缺席 = 没有 bg 店的部署(纯内存 dev),这一问问不了。 */
147
+ agents?: FleetReconcileAgentPort;
148
+ /**
149
+ * S-751:子代活性的属主②:这只子代此刻有没有**待决**审批卡。只问是非(与 {@link isLiveOnThisReplica} 同姿势,
150
+ * 不搬 Map)。入参是 ask 发起者域(子代自己的 core session id,= 卡上的 `originTaskId`),**不是**行 id ——
151
+ * 行 id → 该域的换算只在 `FleetEventBus.childOwnerKeys`。生产 = `ToolApprovalCoordinator.hasLivePendingFrom`。
152
+ */
153
+ hasPendingAsk?: (originTaskId: string) => boolean;
133
154
  /** 本副本 id(与 `RunRecord.instanceId` 同域)。只进日志/读数 —— 两条 `running` 臂的**处置相同**
134
155
  * (都是陈述级退场),分不分得出 claim 属谁不改变行为,故它不是判据。 */
135
156
  instanceId?: string;
@@ -150,8 +171,10 @@ export interface FleetReconcileTally {
150
171
  retired: number;
151
172
  /** 陈述级退场的行数(顶层 + 孤儿子行)。 */
152
173
  orphaned: number;
153
- /** 随父同扫的子行数。 */
174
+ /** 父本周期刚离场、自己又判成孤儿而陈述级退场的子行数(S-751 前 = 随父无条件同扫的数)。 */
154
175
  childrenSwept: number;
176
+ /** S-751:静默满一周期、无父可循(或 run 账不认识),但属主作保而**留行**的子行数。 */
177
+ childrenKeptAlive: number;
155
178
  /** durable 终局 ⇒ 投影退休的 workflow 行数。 */
156
179
  workflowsRetired: number;
157
180
  /** 本周期实际发出的 durable 点读次数。 */