@sema-agent/server 2.0.0 → 2.0.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.
package/dist/main.js CHANGED
@@ -1,111 +1,52 @@
1
- import { readFileSync, existsSync, realpathSync } from "node:fs";
2
- import { setLeafAdvanceListener } from "./session-leaf-bus.js";
3
- import { createHash } from "node:crypto";
1
+ import { readFileSync } from "node:fs";
4
2
  import { homedir } from "node:os";
5
3
  import { join } from "node:path";
6
- import { loadRemoteExec } from "@sema-agent/registry-core/node";
7
- import { skillContentHash } from "@sema-agent/registry-core";
8
- import { Runner, TtlSessionStore, uuidv7, FileRosterStore, FileBackgroundAgentStore, FileMailboxStore, defaultTaskRegistry, combinePolicies, createDurableQuestionPolicy, createAllowDenyPolicy, tightenTaskSpec, isThinkingLevel, QUESTION_AWAITS_RESUME, FileWorkflowRunStore, InMemoryWorkflowRunStore, workflowsCapability, NodeLspManager, createFileWorkflowScriptStore, expandTiers, NodeExecutionEnv, CenterPromptSource, FilePromptArtifactStore, FilePromptSourceStateStore, MemoryPromptArtifactStore, MemoryPromptSourceStateStore, verifyPromptArtifact, createWebFetchSummarizer, resolveTaskModel as coreResolveTaskModel } from "@sema-agent/core";
9
- import { PgMemoryEngineBackend, ensurePgMemoryEngineSchema } from "./plugins/memory-engine-pg.js";
10
- import { TiDBMemoryEngineBackend, ensureTiDBMemoryEngineSchema } from "./plugins/memory-engine-tidb.js";
11
- import { PgMemoryHistoryStore, ensurePgMemoryHistorySchema, PgMemorySyncStore, ensurePgMemorySyncSchema } from "./plugins/memory-sync-store-pg.js";
12
- import { TiDBMemoryHistoryStore, ensureTiDBMemoryHistorySchema, TiDBMemorySyncStore, ensureTiDBMemorySyncSchema } from "./plugins/memory-sync-store-tidb.js";
13
- import { resolveTaskModel, modelSupportsImages, matchCatalogModel } from "./model-select.js";
14
- import { derivePkgSourceEnv, customPkgSourceFromEnv } from "./sandbox-pkg-source.js";
4
+ import { Runner, TtlSessionStore, uuidv7, defaultTaskRegistry, combinePolicies, createDurableQuestionPolicy, createAllowDenyPolicy, workflowsCapability, createWebFetchSummarizer, resolveTaskModel as coreResolveTaskModel } from "@sema-agent/core";
15
5
  import { createSessionTitler } from "./session-titler.js";
16
- import { SessionWatchRegistry, posIntEnv } from "./session-watch.js";
17
- import { selectEnvironmentTool, SessionEnvironmentSelection } from "./capabilities/select-environment-tool.js";
18
- import { sendUserFileTool, SendUserFileEmitter, SEND_USER_FILE_MAX_BYTES, subagentSendUserFileExtraTools } from "./capabilities/send-user-file-tool.js";
19
- import { TaskEnvRegistry, createSandboxFileSend, sandboxSendLaneEnabled } from "./capabilities/sandbox-file-send.js";
20
- import { createSendUserFileIssuer } from "./plugins/send-user-file.js";
21
- import { withLedgerRecording } from "./plugins/send-file-ledger.js";
22
- import { basename, resolve } from "node:path";
23
- import { stat as fsStat, readFile as fsReadFile } from "node:fs/promises";
24
- import { acceptShellScratchpadDir, buildEnvFacts, egressForRemoteExec, ensureScratchpadDir, purgeScratchpadDir, sweepStaleScratchpads, resumeFactsForLane } from "./env-facts.js";
25
- import { normalizeSuggestNextPrompts, normalizeResilience, normalizeAttachments, normalizeResumeAtMode, resolveTaskLimits, taskAgentsSpecFragment, retainBackgroundProcessesFromBody, toolNameListFromBody, promptProfileFromBody } from "./spec-fields.js";
26
- import { createBrain, brainSummary } from "./brain.js";
27
- import { configLkgEnabled, loadConfig, logConfigDiagnostics, resolveBindHost } from "./config.js";
6
+ import { posIntEnv } from "./session-watch.js";
7
+ import { selectEnvironmentTool } from "./capabilities/select-environment-tool.js";
8
+ import { subagentSendUserFileExtraTools } from "./capabilities/send-user-file-tool.js";
9
+ import { resolve } from "node:path";
10
+ import { brainSummary } from "./brain.js";
11
+ import { loadConfig, logConfigDiagnostics, resolveBindHost } from "./config.js";
28
12
  import { drainNumEnvWarnings } from "./plugins/remote-shell.js";
29
- import { resourceSuspendOptIn } from "./resource-suspend.js";
30
- import { createSessionStore, ensureChildSessionDurableWithPromotion } from "./plugins/session-store.js";
13
+ import { ensureChildSessionDurableWithPromotion } from "./plugins/session-store.js";
31
14
  import { ForkRoutingSessionStore } from "./plugins/fork-routing-session-store.js";
32
- import { createStoreBackend, openStoreBackendWithFallback, assertCloudSnapshotBlobPosture } from "./plugins/store-backend.js";
33
- import { e2bExecutionEnvFactory } from "./plugins/remote-env-e2b.js";
34
- import { k8sExecutionEnvFactory } from "./plugins/remote-env-k8s.js";
35
- import { sshExecutionEnvFactory } from "./plugins/remote-env-ssh.js";
36
- import { adbExecutionEnvFactory } from "./plugins/remote-env-adb.js";
37
- import { hostExecutionEnvFactory, RemoteHostExecutionEnv } from "./plugins/remote-env-host.js";
38
15
  import { webSearchConfigFromEnv, createWebSearchBackend, setWebSearchBadPayloadObserver } from "./plugins/web-search.js";
39
- import { FileSchedulerBackend, defaultSchedulerStorePath } from "./plugins/scheduler-support.js";
40
- import { localDockerExecutionEnvFactory } from "./plugins/remote-env-local-docker.js";
41
- import { withWorktreeIsolation, reapOrphanWorktrees } from "./plugins/worktree-isolation.js";
42
- import { isRemoteScratchpadLane, remoteScratchpadDirFor, withRemoteScratchpad } from "./plugins/remote-scratchpad.js";
43
- import { createE2bLspManager } from "./lsp/e2b-manager.js";
44
- import { createAuthorizer, memoryEngineBackendFor, memoryEngineRemoteLanePosture, memorySpecForRequest, HttpError } from "./security.js";
45
- import { createOaApprovalPolicy, createDurableAskPolicy } from "./approval.js";
46
- import { buildSessionAuditPg, buildSessionAuditLocal, buildSessionAudit } from "./audit.js";
47
- import { makeDegenerateInstrument } from "./degenerate-instrument.js";
48
- import { PlanCacheProbe } from "./plan-cache-probe.js";
16
+ import { createAuthorizer } from "./security.js";
17
+ import { createDurableAskPolicy } from "./approval.js";
49
18
  import { loadSkills } from "./capabilities/skills.js";
50
- import { applyCenterPlugins } from "./capabilities/center-plugins.js";
51
19
  import { GiteaClient } from "./capabilities/repo-tools.js";
52
- import { buildScenarios, selectScenario, centerScenarios, mergeUserSkills, builtinScenarioDetails, centerScenarioDetails, gateScenarioRequest } from "./capabilities/scenarios.js";
53
- import { applyLongtailDefer } from "./capabilities/tool-defer.js";
54
- import { resolveCollabWorkflow, listCollabWorkflows } from "./capabilities/collab-workflows.js";
20
+ import { buildScenarios, builtinScenarioDetails } from "./capabilities/scenarios.js";
55
21
  import { createLogger } from "./observability/logger.js";
56
- import { createSighupIdleHandler } from "./sighup-idle.js";
57
22
  import { createToolTracer, createPermissionDeniedMeter } from "./observability/tool-trace.js";
58
23
  import { createRegistryJwtVerifier } from "./auth-bridge.js";
59
24
  import { createMetrics } from "./observability/metrics.js";
60
25
  import { setRedactionObserver, redactSecrets } from "./trace/redact.js";
61
26
  import { RateLimiter } from "./observability/rate-limit.js";
62
- import { createHttpServer, explicitOperator, } from "./http/server.js";
27
+ import { createHttpServer, explicitOperator } from "./http/server.js";
63
28
  import { exportMemoryScope } from "./memory-export.js";
64
29
  import { performMemorySync } from "./memory-sync.js";
65
- import { createMemorySyncRunner, createMemorySyncTransport } from "./memory-sync-client.js";
66
- import { createLeaderEndpoint } from "./leader/endpoint.js";
67
- import { createLeaderRunner } from "./leader/wire.js";
68
- import { WorkflowNotifyGate, JournalingWorkflowRunStore, FileWorkflowNotifyJournalStore, } from "./orchestration/workflow-notify-journal.js";
69
- import { FileWorkflowCompletionInbox, InMemoryWorkflowCompletionInbox, resolveCompletionRoute, resolveServedSession, } from "./orchestration/workflow-completion-inbox.js";
70
- import { buildPricing, createTracer, createSideQueryAccountant, cappedCeiling, ModelUsageTracker, PromptManifestTracker } from "./budget.js";
71
- import { CostQuota } from "./observability/cost-quota.js";
72
30
  import { startOtlpExporter } from "./observability/otel-exporter.js";
73
31
  import { HEARTBEAT_MS, backgroundAgentOutput, taskHandleOutput, taskHandleStop } from "./runs.js";
74
- import { ElicitationCoordinator } from "./elicitation.js";
75
- import { QuestionCoordinator } from "./question.js";
76
- import { ToolApprovalCoordinator } from "./tool-approval.js";
77
- import { applyEffective, mutateInPlace, logEffectiveDiff, applyCenterSkills, resolveMcpServers, mcpForScenario, restartReasons, modelPlaneChanged, planeHasActiveTiers, fetchPromptArtifact } from "./sema-registry.js";
78
- import { ensureSealedKeyStore, reportExecutionPublicKey } from "./sealed-key.js";
79
- import { createConfigProvider, raceBootFetch, BOOT_FETCH_DEFERRED } from "./config-provider.js";
80
- import { validatePromptsDomain, centerPromptProvider, centerIdentityAssembled, applyCatalogToSource, CORE_ENGINE_VERSION, withPromptArtifactBackfill } from "./capabilities/center-prompts.js";
81
- import { defaultLkgPath, defaultSkillCacheDir, saveLkg, loadLkg } from "./config-lkg.js";
82
- import { TiDBRosterStore, PgRosterStore, ensureTiDBRosterSchema, ensurePgRosterSchema } from "./plugins/roster-store-sql.js";
83
- import { TiDBTaskAttachmentStore, PgTaskAttachmentStore, ensureTiDBTaskAttachmentSchema, ensurePgTaskAttachmentSchema, bindAttachmentsForTask, materializeAttachmentsInto } from "./plugins/task-attachment-store.js";
84
- import { LocalTaskAttachmentStore } from "./plugins/local-task-attachment-store.js";
85
- import { MinioBlobBackend, SQL_BLOB_DEFAULT_MAX_BYTES } from "./plugins/blob-backend.js";
86
- import { TiDBBackgroundAgentStore, PgBackgroundAgentStore, ensureTiDBBackgroundAgentSchema, ensurePgBackgroundAgentSchema } from "./plugins/background-agent-store-sql.js";
87
- import { TiDBMailboxStore, PgMailboxStore, ensureTiDBMailboxSchema, ensurePgMailboxSchema } from "./plugins/mailbox-store-sql.js";
88
- import { createPrincipalEntitlementsClient, gateExecutionLane, scopedTokenNeedsWorker, applyObserverEnvOptIn } from "./runtime-caps-resolver.js";
89
- import { applyRuntimeGovernance, stripDelegationTools } from "./runtime-governance.js";
90
- import { parseTaskSettings, applyTaskSettings, coercePermissionMode, withPermissionMode, effectiveThinking, acceptAppendSystemPrompt, hasConstitutionAnchors, providerDropsAppend, MAX_SETTINGS_OUTPUT_STYLE_CHARS } from "./task-settings.js";
91
- import { createTaskHooks, composeHooks } from "./hooks/hook-runner.js";
32
+ import { SQL_BLOB_DEFAULT_MAX_BYTES } from "./plugins/blob-backend.js";
33
+ import { composeHooks } from "./hooks/hook-runner.js";
92
34
  import { createHookLlm } from "./hooks/hook-llm.js";
93
- import { selfOrchestrationFromBody, enableForkFromBody, workflowModelAllowlistFor, normalizeRetainSubagentSessions } from "./task-workflow.js";
94
- import { FleetUsageAccumulator, startFleetClientFromEnv } from "./fleet-client.js";
95
- import { createFleetLeaseFromEnv } from "./fleet-lease.js";
35
+ import { startFleetClientFromEnv } from "./fleet-client.js";
96
36
  import { reclaimOrphansAtBoot } from "./boot-reclaim.js";
97
- import { evictLspOnDestroy } from "./lsp-evict.js";
98
- import { makeLoadProjectMemory, makeProbeInstructionSources } from "./project-memory.js";
99
- import { cwdHonored, effectiveHostWorkspace, inProcessSingleUserLane, isValidCwd, parseAdditionalDirectories, satisfiedByProcessCwd, shellEnvMismatchCount } from "./task-cwd.js";
100
- import { resolveRequestMcp, mcpInjectionHonored } from "./task-mcp.js";
101
- import { PerTaskImageRegistry, resolveSandboxImageRef } from "./per-task-image.js";
102
- import { routeServiceTask, isIsolatedExecEnv, supPostureOverrides } from "./router/route-orchestration.js";
103
- import { createHardenedVmRunner } from "./orchestration/hardened-vm-runner.js";
104
- import { createWorkerHardenedVmRunner } from "./orchestration/hardened-vm-worker-runner.js";
105
- import { WorkflowAgentRegistry } from "./orchestration/workflow-agent-steer.js";
106
- import { SubagentSteerRegistry } from "./orchestration/subagent-steer.js";
107
- import { FleetEventBus, fleetBackgroundChildPublisher } from "./fleet/fleet-bus.js";
108
- import { createKeyResolver } from "./key-resolver.js";
37
+ import { createConfigCenterRuntime } from "./boot/config-center.js";
38
+ import { createResolveSpec } from "./boot/resolve-spec.js";
39
+ import { startReapers } from "./boot/reapers.js";
40
+ import { openStores } from "./boot/stores.js";
41
+ import { createBudgetAndTracing } from "./boot/budget-tracing.js";
42
+ import { createExecutionEnv } from "./boot/execution-env.js";
43
+ import { createWorkflowOrchestration } from "./boot/workflow-orchestration.js";
44
+ import { createLiveCoordinators } from "./boot/coordinators.js";
45
+ import { createRuntimeCaps } from "./boot/runtime-caps.js";
46
+ import { createRunnerDeps, createSharedRunnerDeps } from "./boot/runner-deps.js";
47
+ import { createSessionFaces } from "./boot/session-faces.js";
48
+ import { createLeaderFace } from "./boot/leader.js";
49
+ import { installShutdownHandlers } from "./boot/shutdown.js";
109
50
  /** Service version for GET /v1/capabilities — read from the package.json shipped next to src/ in the
110
51
  * image (Dockerfile COPYs it). Best-effort: an unreadable file reports "unknown", never crashes boot. */
111
52
  function serviceVersion() {
@@ -118,7 +59,34 @@ function serviceVersion() {
118
59
  }
119
60
  }
120
61
  /**
121
- * Service entrypoint. Wires the three planes:
62
+ * Service entrypoint. **装配次序表**(design/158 A10)。
63
+ *
64
+ * 各 boot 段已按内聚下沉到 `src/boot/*.ts` 叶子(纯搬运,逐字);本函数保留的是**顺序本身**——
65
+ * 谁先谁后是行为(env 读取时序、诊断排空点、信号注册时点、工厂装饰顺序),每个叶子的文件头
66
+ * 都写明了它那条「位置即契约」。当前次序:
67
+ *
68
+ * loadConfig → logger/metrics → 诊断排空(logConfigDiagnostics + drainNumEnvWarnings)
69
+ * → boot/config-center(sealed-key/provider/prompt-epoch 店/effective 拉取/LKG 兜底/tolerant apply)
70
+ * → boot/config-center.applyLocalRemoteExec(local 形 config.d/remote-exec.json 覆写)
71
+ * → boot/stores(StoreBackend + memory + 四个 durable 店 + sessionStore + 拒启断言)
72
+ * → boot/budget-tracing(brain/pricing/quota/tracer)
73
+ * → boot/execution-env(remote-exec 工厂链 + LSP)
74
+ * → toolTracer/deploymentHooks
75
+ * → boot/workflow-orchestration → boot/coordinators → boot/runtime-caps
76
+ * → boot/runner-deps → new Runner → hook LLM/agent → subRunner
77
+ * → runStore/anchors/approval 面 → skills → boot/config-center.applyCenterCapabilities
78
+ * (center skills/plugins/boot-LKG/mcp)→ buildScenarios → .applyCenterScenarios
79
+ * → boot/reapers → OTLP → boot/config-center.startRefreshLoop → .initKeyResolver
80
+ * → boot/session-faces → boot/leader → ServiceDeps 七组 → boot/resolve-spec
81
+ * → createHttpServer → listen → fleet client → boot/shutdown(必须最后:信号注册时点即契约)
82
+ *
83
+ * A10 尾刀(config-center):`ccEtag/effective/latestEffective/appliedPlaneEff/planeDeferredNoHandoff/
84
+ * pendingRestart/bootSkillStale/keyResolver/centerPrompts/…` 这组跨段可变状态,已整体搬进
85
+ * `boot/config-center.ts` 那只工厂的**闭包**(仍是 `let`,每一处读写的文本与次序逐字未改 ——
86
+ * codex R10–R38 的重启风暴判据全部按原样保留);main 只经窄口读:`getKeyResolver/getCenterPrompts/
87
+ * modelReady/restartState/planeDeferredState/promptSource`,与本目录既有的晚绑 getter 惯例同款。
88
+ *
89
+ * Wires the three planes:
122
90
  * Brain = external LLM gateway (createBrain)
123
91
  * Session = pluggable store (TiDB durable event-log center, or in-memory)
124
92
  * Hands = tools/MCP injected per request in resolveSpec (deployment-owned)
@@ -156,1169 +124,29 @@ async function main() {
156
124
  metrics.inc("web_search_bad_payload_total", { provider });
157
125
  logger.warn("web_search_non_array_payload", { provider });
158
126
  });
159
- // Sema registry (sema-registry): pull the effective config on boot and apply it OVER the env
160
- // defaults — env is the fallback. Unset or unreachable → keep the env-derived config. Applied BEFORE
161
- // the Runner is built so models/roles/teams take effect. (Logical config only; secrets stay in env.)
162
- let ccEtag;
163
- // Kept past the fetch so the deferred skills/mcp apply (which needs the live `skills` array + async
164
- // content lazy-pull) can run right before buildScenarios, after applyEffective handled models/roles/teams.
165
- // `effective` is the BOOT snapshot — the refresh loop never reassigns it, so restartReasons() compares each
166
- // pull against what THIS process baked in (change-detection, not presence → no auto-restart loop).
167
- let effective;
168
- // 提示词装配协议 §9.5-2 three-store split: `effective` above = the boot snapshot (diff baseline);
169
- // `latestEffective` = the last candidate that passed validation AND was applied — the explicit
170
- // last-known-good (LKG). A refresh candidate failing validation is REJECTED whole (§9.5-5): never
171
- // applied, so the live config keeps serving this LKG instead of a domain falling to schema-default/
172
- // empty. The third store (promptCandidate — a staged prompts-domain artifact awaiting activation)
173
- // lands with the prompts domain itself; `lastRejectedCandidate` is its observability forerunner and
174
- // the prompts-domain validation (core S1 schema) hooks into the same gate.
175
- let latestEffective;
176
- let lastRejectedCandidate;
177
- // codex R10 (models-tiers 窗收口): the last EffectiveConfig whose MODEL PLANE (models/roles/tiers/default) was
178
- // actually applied to the live config — undefined = env plane (no center plane applied yet). When the Runner is
179
- // tier-frozen, a refresh whose plane differs from THIS is deferred to restart (applyEffective deferModelPlane),
180
- // keeping admission and the Runner on one catalog generation through the restart window.
181
- let appliedPlaneEff;
182
- // codex R18: the persistent "deferred WITHOUT next-boot handoff" state — the one-shot warn is not enough
183
- // (the etag advances → later ticks 304 and never re-enter the defer branch). Set when a plane defers with no
184
- // restart-surviving LKG; cleared when a plane finally applies or a forced restart reason ships. Surfaced on
185
- // /health (modelPlaneDeferred) + re-warned every refresh tick (incl. 304s).
186
- let planeDeferredNoHandoff;
187
- // codex R35: STICKY live-skill application debt — the boot-baked skill table never rebuilds inside this
188
- // process, so once the live application is known incomplete the debt survives every refresh (a
189
- // same-generation 200 recomputes reasons vs boot as [] and would otherwise silently clear the signal).
190
- // Set at the boot writer (R34 arms); folded into every tick's reasons; only a restart resolves it.
191
- let bootSkillStale = false;
192
- // Sema-registry restart signal: set by the refresh loop when a pull's restart-to-apply slices
193
- // (skills/mcp/scenarios/runtime-gates) differ from boot; read by GET /health for orchestrator auto-restart.
194
- let pendingRestart;
195
- // DUAL-MODE-DESIGN §4: the config SOURCE is a backend-selectable seam — remote (sema-registry HTTP, the fleet
196
- // default) | local (.env + config.d via @sema-agent/registry-core FileConfigStore, the single-machine/TOC source).
197
- // The provider exists when SEMA_REGISTRY_URL (legacy CONFIG_CENTER_URL) is set OR CONFIG_PROVIDER=local is explicit; neither = pure-env
198
- // (byte-identical to before). The remote provider DELEGATES verbatim to fetchEffective, so the fleet path is
199
- // unchanged; applyEffective consumes either source's EffectiveConfig identically (one resolver → no drift).
200
- const cc = config.configCenter;
201
127
  // 🔴 ONE local root for BOTH the config provider (models/skills/mcp) AND the remoteExec loader (avoids a
202
128
  // split-brain — createConfigProvider defaulted to process.cwd() while loadRemoteExec used
203
129
  // AGENT_DATA_DIR, so config.d and remote-exec.json could resolve to DIFFERENT directories). run-local already
204
- // computes this once; main.ts now does too. Used below for loadRemoteExec.
130
+ // computes this once; main.ts now does too. Shared with the config-center runtime and every boot leaf below.
205
131
  const localRoot = config.configLocalDir ?? process.env.AGENT_DATA_DIR ?? join(homedir(), ".ai-agent");
206
- // Sealed-box key custody: ensure this host's X25519 keypair(s) under
207
- // <localDataRoot>/sealed-key/ (dir 0700, files 0600; SEVERAL may coexist rotation keeps old private
208
- // keys decrypting old ciphertexts) BEFORE the first effective pull, so a roster carrying
209
- // `sealedApiKey` unseals on the boot apply. The public-key report is best-effort fire-and-forget:
210
- // the center's execution-keys face is admin-gated and a worker only holds the pull token, so without
211
- // an operator-provided SEMA_REGISTRY_ADMIN_TOKEN it prints the full public key for manual
212
- // registration instead (honest posture — see reportExecutionPublicKey). Store failure is non-fatal:
213
- // sealed models then warn per-model in applyEffective (fail-loud, never a silent apiKeyEnv fallback).
214
- let sealedKeys;
215
- try {
216
- sealedKeys = await ensureSealedKeyStore(join(config.localDataRoot ?? localRoot, "sealed-key"), logger);
217
- void reportExecutionPublicKey({
218
- baseUrl: cc?.baseUrl,
219
- adminToken: process.env.SEMA_REGISTRY_ADMIN_TOKEN || process.env.REGISTRY_ADMIN_TOKEN,
220
- key: sealedKeys.latest,
221
- ...(cc?.worker ? { notes: `worker ${cc.worker}` } : {}),
222
- logger,
223
- });
224
- }
225
- catch (err) {
226
- logger.warn("sealed_key_store_unavailable", { err: String(err), note: "sealed api keys cannot be unsealed on this host — models carrying sealedApiKey will fail loud per-model" });
227
- }
228
- const configProvider = cc || config.configProvider === "local"
229
- ? createConfigProvider({
230
- ...(config.configProvider ? { provider: config.configProvider } : {}),
231
- ...(cc ? { configCenter: { baseUrl: cc.baseUrl, token: cc.token, worker: cc.worker, dryRun: cc.dryRun } } : {}),
232
- ...(config.configProvider === "local" ? { localDir: localRoot } : {}),
233
- })
234
- : undefined;
235
- // boot ready 门 latch:仅「远程 registry 部署(非 dryRun)+ 无显式 env MODEL_ID」时等 roster——
236
- // 首次 effective 落 ≥1 enabled 模型即翻 true(one-way)。其余姿势(env 模型在/local provider/dryRun/纯 env)
237
- // 恒 ready=现有部署零影响。E3 废除 workers.model 后,fleet worker 的占位模型窗口由此门 fail-closed。
238
- const modelReadyState = { ready: !(cc && !cc.dryRun) || Boolean(process.env.MODEL_ID) };
239
- const markRosterLanded = (eff) => {
240
- if (!modelReadyState.ready && (eff.models?.models ?? []).some((m) => m.enabled !== false)) {
241
- modelReadyState.ready = true;
242
- logger.info("model_roster_landed", { note: "ready gate open — billable submissions accepted" });
243
- }
244
- };
245
- // center prompts ref([1057]/[1062] 投影腿):**热采用,新任务边界生效**——core 1.316([1068] 裁 a/
246
- // [1070]d)把 provider 声明纳入 epoch artifactDigest:声明变=digest 变=下任务边界自动重 pin,进行中
247
- // 会话由 session epoch pin 保稳,resume 记账诚实(1.222 的 restart-to-apply 是 core 件落地前的正确
248
- // 过渡姿势,[1068]① 官方确认后随 1.316 采用降级为热生效——与 center「发布=新任务生效」产品语义对齐)。
249
- // 判形通过才采用(形校验=防 core-assemble-THROW 底线,非语义复检 [1057]①f);坏形=warn+沿用旧包。
250
- let centerPrompts;
251
- /** codex-3 F1:一次失败的 catalog 过渡的目标面(undefined 目标=欠 disable)。etag 已推进时 provider
252
- * 304 短路 adopt——本 marker 让每个 refresh 拍(含 304 拍)独立重试,直到过渡落地。 */
253
- let pendingCatalogFaces;
254
- const retryPendingCatalog = async (phase) => {
255
- if (!pendingCatalogFaces)
256
- return;
257
- const { target } = pendingCatalogFaces;
258
- if (await applyCatalogToSource(promptSource, target, centerPrompts, logger, phase)) {
259
- centerPrompts = target;
260
- pendingCatalogFaces = undefined;
261
- }
262
- };
263
- // e/b(catalog 轴消费面,[1236]① seam):core 的 CenterPromptSource——server 只给根路径,core 文件形
264
- // 自管 `<root>/prompt-epoch/{artifacts,source-state.json}`;boot restore=磁盘 LKG(离线重启 candidate
265
- // 存续/disabled 不复活,状态机全在 core)。RunnerDeps.promptSource 挂它;adopt 时经 applyCatalogToSource
266
- // 推进两态过渡。restore 失败=warn(candidate 缺席,catalog 轴等下一次 adopt;declaration 轴/内置照跑)。
267
- const promptEpochDir = join(config.localDataRoot ?? localRoot, "prompt-epoch");
268
- // codex-3 F2:effective 拉取是 worker 轴的(config LKG 同理 worker-keyed)——同根多 worker 进程共享
269
- // content-addressed artifacts/(by-digest,天然安全),但 **candidate 状态必须按 worker 分箱**:共写一份
270
- // source-state.json 会让 A 的撤下压掉 B 的 candidate、restart 恢复到别人 worker 的 catalog(错 worker
271
- // 的系统提示词跑任务)。encodeURIComponent 消毒(worker 名进路径)。
272
- // codex-5:无撞形分箱——缺席=字面 "global",配置了 worker= "worker-<sha256 前 16>"(哈希后天然单
273
- // 段/无 dot-segment/与 "global" 不可能同名;worker 名任意字节也安全)。
274
- const promptStateDir = join(promptEpochDir, "state", cc?.worker === undefined ? "global" : `worker-${createHash("sha256").update(cc.worker).digest("hex").slice(0, 16)}`);
275
- // codex-6 F1:File 店构造同步 mkdir——只读/不可写数据根会把「可选的 catalog 特性」变成整服务 boot
276
- // 失败。守护工厂:文件形失败 ⇒ warn + Memory 店兜底(candidate 不跨重启持久,catalog 采用仍工作;
277
- // 内置/declaration 轴完全不受影响)。
278
- // [985]b / [1285] — §9.4 by-digest 补拉组合店:local miss(本副本从未采用过该 candidate / 盘失)⇒
279
- // 从 center 按 digest 补拉 envelope ⇒ core verifyPromptArtifact 全管线(canonical 复算;**且回验
280
- // 「拿到的=要的」digest 逐位**,center 答非所问=丢弃)⇒ best-effort 回写盘(下次直命中)。pinned
281
- // resume 的 fail-loud miss 自此只剩「center 也没有」的真缺席。仅 remote 治理形(cc.baseUrl)挂补拉。
282
- const withByDigestBackfill = (inner) => cc?.baseUrl ? withPromptArtifactBackfill(inner, (d) => fetchPromptArtifact(cc.baseUrl, cc.token, d), logger) : inner;
283
- const promptSource = (() => {
284
- try {
285
- return new CenterPromptSource({
286
- store: withByDigestBackfill(new FilePromptArtifactStore(promptEpochDir)),
287
- stateStore: new FilePromptSourceStateStore(promptStateDir),
288
- engineVersion: CORE_ENGINE_VERSION,
289
- });
290
- }
291
- catch (e) {
292
- logger.warn("center_prompts_store_unwritable", { dir: promptEpochDir, err: String(e), note: "file-backed prompt-epoch store unavailable — memory fallback (catalog candidate not restart-durable); built-ins/declaration axis unaffected" });
293
- return new CenterPromptSource({ store: withByDigestBackfill(new MemoryPromptArtifactStore()), stateStore: new MemoryPromptSourceStateStore(), engineVersion: CORE_ENGINE_VERSION });
294
- }
295
- })();
296
- // codex-2 F4:只有「有配置源在治理且非 dry-run」才 restore 磁盘 candidate——provider-off/dry-run 姿势下
297
- // 旧部署遗留的 active candidate 不得继续影响新任务(publish-gate-off/compare-only 语义)。不 restore 只
298
- // 影响 candidate 面;pinned resume 的 by-digest 解析走 FilePromptArtifactStore.get(不依赖 restore),
299
- // 旧会话恢复不受损。
300
- if (configProvider && !cc?.dryRun) {
301
- try {
302
- await promptSource.restore();
303
- }
304
- catch (e) {
305
- logger.warn("center_prompts_catalog_restore_failed", { err: String(e), note: "disk LKG unavailable — catalog candidate absent until the next adopt; declaration axis and built-ins unaffected" });
306
- }
307
- }
308
- const adoptCenterPrompts = async (eff, phase) => {
309
- const raw = eff?.prompts;
310
- if (raw === undefined) {
311
- // fail-closed 语义([1057]①d):publish 闸关=key 整体不出现 ⇒ 收敛回内置提示词(不是「保持旧包」——
312
- // 闸关是显式撤下,沿用旧包会让「关闸」失效)。
313
- if (centerPrompts)
314
- logger.info("center_prompts_cleared", { phase, note: "effective carries no prompts key (publish gate off) — built-in providers resume" });
315
- const cleared = await applyCatalogToSource(promptSource, undefined, centerPrompts, logger, phase);
316
- if (cleared) {
317
- centerPrompts = undefined;
318
- pendingCatalogFaces = undefined;
319
- }
320
- else {
321
- pendingCatalogFaces = { target: undefined }; // failed disable — retried by the refresh hook (codex-3 F1)
322
- }
323
- return;
324
- }
325
- const v = validatePromptsDomain(raw);
326
- if (!v.ok) {
327
- logger.warn("center_prompts_invalid", { phase, error: v.error, kept: centerPrompts?.declarations?.packId ?? (centerPrompts?.catalog ? "(catalog)" : "(builtin)"), note: "malformed prompts face rejected (core assemble would THROW per task) — keeping the previous pack/built-ins; fix the center payload" });
328
- return;
329
- }
330
- if (centerPrompts?.identity !== v.value.identity) {
331
- // [985]a §3:axes 观测两轴共存窗;declaration 轴字段照旧,catalog 轴报 artifactDigest(内容身份)。
332
- logger.info("center_prompts_adopted", {
333
- phase,
334
- axes: v.value.axes,
335
- ...(v.value.declarations
336
- ? { packId: v.value.declarations.packId, contentDigest: v.value.declarations.contentDigest, sections: v.value.declarations.sections.length, scenarioOverrides: Object.keys(v.value.declarations.scenarioOverrides ?? {}).length }
337
- : {}),
338
- ...(v.value.catalog ? { artifactDigest: v.value.catalog.artifact.artifactDigest, catalogDigest: v.value.catalog.artifact.payload.catalogDigest, sourceRevision: v.value.catalog.sourceRevision } : {}),
339
- note: "new tasks assemble the new pack (epoch re-pins at the next task boundary); in-flight sessions stay pinned",
340
- });
341
- }
342
- const transitioned = await applyCatalogToSource(promptSource, v.value, centerPrompts, logger, phase);
343
- // codex-2 F2 + codex-3 F1:catalog 过渡失败 ⇒ 身份不推进(centerPrompts 保持 prev),且目标面记入
344
- // pendingCatalogFaces——etag 会照常推进(其他域的采用不为 prompts 的盘错扣住),此后 provider 304
345
- // 不再进本函数,重试由 refresh 拍上的 retryPendingCatalog 钩独立驱动(见 refresh 循环)。
346
- if (transitioned) {
347
- centerPrompts = v.value;
348
- pendingCatalogFaces = undefined;
349
- }
350
- else {
351
- pendingCatalogFaces = { target: v.value };
352
- }
353
- };
354
- // Boot-deferred pull (clay 2026-07-17): when the boot fetch outlives its budget, the SAME promise is
355
- // stashed here and, once the refresh tick exists (defined further down), chained to trigger an immediate
356
- // hot-apply on arrival — the server meanwhile starts on the env fallback in <1s instead of hanging up to
357
- // ~8s on a black-holed center (真机实测 5.7s = the "local server takes 5s" report).
358
- let bootConfigPending;
359
- // LKG 落盘(clay 拍 2026-07-18;config-lkg.ts 顶注=全案由):remote lane 的干净候选持久化——boot 的
360
- // 第三级来源(中心 > 盘上 LKG > env)。`lkgBooted` 标志决定 deferred continuation 的形:LKG 起服=有
361
- // 完整 boot 基线 → 到货走普通 refreshTick(候选门+hot+restartReasons 对 LKG 比较,通常零差异=无环);
362
- // 无 LKG=env 起服 → 到货走「迟到 boot」路径(deferredBootApply)。
363
- const lkgEnabled = configProvider?.kind === "remote" && configLkgEnabled();
364
- const lkgPath = process.env.CONFIG_LKG_PATH ?? defaultLkgPath(cc?.worker);
365
- // codex R17/R20: a successful LOCAL write proves nothing about the NEXT instance seeing it — a stateless pod
366
- // replaces its filesystem on restart, so treating a pod-local rename as "handoff confirmed" re-opens the R16
367
- // rolling-restart storm. Next-instance visibility is a DEPLOYMENT contract only the operator can assert, and
368
- // ONLY via the explicit knob: CONFIG_LKG_DURABLE=true declares the LKG path rides restart-surviving storage
369
- // (host disk / PVC / shared volume). CONFIG_LKG_PATH is strictly a LOCATION override (R20: it can point at an
370
- // ephemeral container path — a location says nothing about durability). Unset ⇒ the models-tiers restart
371
- // reason stays suppressed on a deferred plane (old generation keeps serving; /health modelPlaneDeferred +
372
- // per-tick warn carry the state) while normal LKG writes continue (they still help same-instance restarts).
373
- const lkgSurvivesRestart = lkgEnabled && process.env.CONFIG_LKG_DURABLE === "true";
374
- let lkgBooted = false;
375
- let bootLkgCandidate; // F7:skills 装载后才落盘
376
- // F1+F2(LKG 复审):awaited 落盘 + skill 正文预热——refresh 只落 manifest 的话,重启后 LKG-boot 在
377
- // 中心不可达时新 skill 正文缓存缺失=软跳过(静默缺 skill)。预热=枚举 enabled manifest,把缺盘的正文
378
- // 拉齐进 content-addressed 缓存(失败=warn 软降级,与 boot 的 skill_failed 同语义等级——skill 拉取
379
- // 失败从不升级为配置拒绝);await 保证 restart 信号可见前 LKG+正文已落盘。
380
- /** Returns TRUE iff the candidate is durably persisted for the NEXT boot (codex R16: the FORCED models-tiers
381
- * restart reason must never fire without a confirmed handoff — LKG disabled/unwritable + over-budget boot
382
- * pulls would otherwise turn the deferral into a rolling-restart storm: every restarted process boots the env
383
- * plane again, defers again, re-signals). Change-DETECTED reasons still ride regardless (their loop is the
384
- * pre-existing, documented no-LKG residual). */
385
- // codex R37 loop-breaker proof: TRUE iff the LAST persistLkgDurable call hash-verified EVERY enabled skill
386
- // body on disk. This is the "a restart can actually repair the live skill table" proof the NON-durable lane
387
- // requires before publishing a skills restart — a successful manifest pull only proves the CONFIG endpoint
388
- // works, not the SEPARATE body fetches; signaling on manifest-success alone lets a persistent body failure
389
- // (404 / hash mismatch / timeout) restart-storm a stateless deployment (every replacement process fails the
390
- // same body, signals again, is restarted again). With the proof, a storm cannot form: a boot that fails the
391
- // body lacks the proof and only retains visible debt (no signal) until a later verify succeeds.
392
- let lastPrewarmBodiesVerified = false;
393
- const persistLkgDurable = async (effectiveObj, etag) => {
394
- lastPrewarmBodiesVerified = false;
395
- // codex R21: the LOCAL provider's candidate IS the next-boot source (config.d on the same disk boot reads
396
- // BEFORE Runner construction) — the handoff is inherent, no LKG needed. Without this arm a polled config.d
397
- // edit that defers the plane (tier activation / tier-frozen change) could never authorize its own restart
398
- // (lkgEnabled is remote-only, so even CONFIG_LKG_DURABLE couldn't help) — stuck in modelPlaneDeferred forever.
399
- // codex R28 requalified: this TRUE covers the MODEL PLANE (what the deferral gate adjudicates). Skills are
400
- // NOT part of the local handoff because the local lane does not consume a center-style skills domain AT ALL
401
- // yet — the only boot consumer (applyCenterSkills) requires config.configCenter (remote HTTP), absent on
402
- // CONFIG_PROVIDER=local; bodies live inline in config.d (LocalConfigProvider.fetchSkillContent) but no boot
403
- // path reads them. Pre-existing local-lane gap (predates this train, nothing lost across restart that was
404
- // ever applied); wiring boot skills through the ConfigProvider seam is the tracked follow-up (board).
405
- if (configProvider?.kind === "local")
406
- return true;
407
- if (!lkgEnabled)
408
- return false;
409
- try {
410
- if (effectiveObj.skills && config.configCenter) {
411
- await applyCenterSkills([], effectiveObj.skills, config.configCenter.baseUrl, config.configCenter.token, logger, undefined, defaultSkillCacheDir());
412
- }
413
- // codex R25/R26: skill BODIES are part of the handoff contract, and completeness gates PUBLICATION —
414
- // applyCenterSkills swallows fetch/cache-write failures, so awaiting it proves nothing; verify every
415
- // ENABLED manifest hash on disk (content-true) BEFORE the atomic LKG replace (R26: verifying after left
416
- // an incomplete candidate as the boot-visible LKG — any crash/manual restart with the center down then
417
- // boots that manifest, silently skips the uncached skills, and never re-signals because the manifest is
418
- // the new boot baseline). On any miss/corruption the PREVIOUS good LKG stays in place; the per-tick
419
- // retained-candidate retry re-attempts prewarm + verify + publish.
420
- // Residual seam (honest, partial): the skill cache roots at SEMA_CONFIG_DIR while lkgPath can point
421
- // elsewhere — CONFIG_LKG_DURABLE's contract requires BOTH on restart-surviving storage.
422
- const enabledSkills = (effectiveObj.skills?.skills ?? []).filter((m) => m.enabled !== false);
423
- const cacheDir = defaultSkillCacheDir();
424
- for (const m of enabledSkills) {
425
- const hex = m.contentHash.replace(/^sha256:/, "").toLowerCase();
426
- if (!/^[0-9a-f]{64}$/.test(hex))
427
- return false;
428
- try {
429
- const text = await fsReadFile(join(cacheDir, hex), "utf8");
430
- if (`sha256:${createHash("sha256").update(text, "utf8").digest("hex")}` !== `sha256:${hex}`) {
431
- logger.warn("config_lkg_skill_cache_incomplete", { skill: m.name, hash: m.contentHash, note: "cached body fails its hash — LKG NOT replaced, handoff NOT confirmed (the previous good LKG keeps serving offline boots)" });
432
- return false;
433
- }
434
- }
435
- catch {
436
- logger.warn("config_lkg_skill_cache_incomplete", { skill: m.name, hash: m.contentHash, note: "enabled skill body not in the cache — LKG NOT replaced, handoff NOT confirmed" });
437
- return false;
438
- }
439
- }
440
- lastPrewarmBodiesVerified = true; // every enabled body verified on disk — the repair proof stands even if the LKG write below fails
441
- await saveLkg(lkgPath, cc?.worker, effectiveObj, etag);
442
- // codex R17: TRUE = next-BOOT visibility, not merely a successful local rename — gated on the operator's
443
- // restart-surviving-storage declaration (lkgSurvivesRestart above). A stateless pod's local write still
444
- // helps same-instance restarts but must not authorize a forced restart.
445
- return lkgSurvivesRestart;
446
- }
447
- catch (err) {
448
- logger.warn("config_lkg_save_failed", { path: lkgPath, err: String(err), note: "durable persist failed — restart signal proceeds anyway (a dead disk + dead center double-fault can re-loop; fix the disk)" });
449
- return false;
450
- }
451
- };
452
- if (configProvider) {
453
- const dryRun = cc?.dryRun ?? false; // dry-run posture is remote-only (a local source has nothing to compare against)
454
- // LKG 读腿(boot 超窗与硬失败两个入口共用):盘上有验证过的干净候选 → 当 boot 配置整套应用
455
- // (skills/mcp 进 buildScenarios——这正是 env 兜底给不了的),etag 落位 → refresh 首拍 304 或正常增量。
456
- const bootFromLkg = async (why) => {
457
- if (!lkgEnabled || dryRun)
458
- return null;
459
- const read = await loadLkg(lkgPath, cc?.worker);
460
- if (!read.lkg) {
461
- if (read.reason !== "absent")
462
- logger.warn("config_lkg_unusable", { path: lkgPath, reason: read.reason, note: "ignoring the on-disk LKG — env fallback serves; the cache rewrites on the next clean candidate" });
463
- return null;
464
- }
465
- logger.info("config_loaded_lkg", { path: lkgPath, savedAt: read.lkg.savedAt, why, note: "booting from the persisted last-known-good (all restart-to-apply faces included) — the live pull continues in the background and reconciles on arrival" });
466
- return { effective: read.lkg.effective, ...(read.lkg.etag !== undefined ? { etag: read.lkg.etag } : {}) };
467
- };
468
- const bootApplyLkgInline = async (lkgR) => {
469
- applyEffective(config, lkgR.effective, logger, { sealedKeys });
470
- await adoptCenterPrompts(lkgR.effective, "boot-lkg");
471
- markRosterLanded(lkgR.effective);
472
- effective = lkgR.effective;
473
- appliedPlaneEff = lkgR.effective; // model plane applied pre-Runner — this IS the Runner's generation
474
- latestEffective = lkgR.effective;
475
- if (lkgR.etag !== undefined)
476
- ccEtag = lkgR.etag;
477
- const cfgModels = (lkgR.effective.models?.models ?? []).filter((m) => m.enabled !== false).length;
478
- logger.info("config_loaded", { source: "lkg", version: lkgR.effective.version, worker: cc?.worker ?? "(global)", models: cfgModels });
479
- };
480
- try {
481
- const bootFetch = configProvider.fetchEffective(undefined);
482
- const raced = await raceBootFetch(bootFetch, config.configBootFetchBudgetMs);
483
- let lkgFallback = null;
484
- if (raced === BOOT_FETCH_DEFERRED) {
485
- bootConfigPending = bootFetch;
486
- bootFetch.catch(() => { }); // settled in the background — failure handling lives on the refresh cadence
487
- lkgFallback = await bootFromLkg("boot fetch deferred (budget exceeded)");
488
- lkgBooted = lkgFallback !== null;
489
- logger.warn("config_boot_fetch_deferred", {
490
- source: configProvider.kind,
491
- budgetMs: config.configBootFetchBudgetMs,
492
- lkg: lkgBooted,
493
- note: lkgBooted
494
- ? "center slow — booting from the persisted LKG; the pull continues in the background and reconciles via the refresh path on arrival"
495
- : "center slow/unreachable and no usable LKG — serving on the env fallback; the pull continues in the background (late-boot adoption on arrival). Raise CONFIG_BOOT_FETCH_BUDGET_MS to block boot instead.",
496
- });
497
- }
498
- // F4(LKG 复审):LKG 载荷的 apply 用专属 catch——走主分支会让 applyEffective 半程炸落到外层
499
- // catch 的「config_source_unreachable_using_env」误导日志(中心明明只是慢)。坏 LKG=点名+env 兜底
500
- // (in-place mutate 的混态风险与 live 坏载荷同级,浅域判形在 loadLkg 已挡结构性坏形)。
501
- if (raced === BOOT_FETCH_DEFERRED && lkgFallback) {
502
- try {
503
- await bootApplyLkgInline(lkgFallback);
504
- }
505
- catch (applyErr) {
506
- lkgBooted = false;
507
- logger.warn("config_lkg_apply_failed", { err: String(applyErr), note: "persisted LKG failed to apply — env fallback serves; the cache rewrites on the next clean candidate" });
508
- }
509
- }
510
- const r = raced === BOOT_FETCH_DEFERRED ? null : raced;
511
- if (r) {
512
- // [898] tolerant read(registry-core 0.10.12,local lane):catalog 域坏文件不再连坐整包回落 env——
513
- // 坏域按 default 落+per-domain warn 点名(文件路径在 error 文案里),好域照常生效。gate 域坏文件
514
- // tolerant 仍 throw(READ 容错不放宽 gate fail-loud)⇒ 走下面既有整包 catch。
515
- // §9.5-5 boot 与 refresh 的分工(codex F1:重启不得洗白坏候选):boot 照旧 tolerant APPLY(无 LKG
516
- // 可保,带着好域起服务),但一个带错误的候选 ①不推进 ccEtag——refresh 首 poll 拿到同一棵树,候选门
517
- // 当场整拒并持续 warn 到修复;②不写入 latestEffective——LKG 永远只指向完全干净的候选,拒绝日志的
518
- // lkgVersion 也诚实(boot 带错=「(env)」)。markRosterLanded 保留:models 域自身干净才可能开门
519
- // (坏 models 域 tolerant 落空=enabled 0=门不动),其他域的错误不构成扣住就绪门的理由。
520
- const bootPromptsRaw = r.effective.prompts;
521
- const bootPromptsOk = bootPromptsRaw === undefined || validatePromptsDomain(bootPromptsRaw).ok;
522
- // 坏 prompts 面同 domainErrors 计入 not-clean(F6 boot 半场):etag 不进 → refresh 首拍同树整拒持续
523
- // warn;LKG 不指向带坏 prompts 的候选。adoptCenterPrompts 自身会拒采用+点名。
524
- const bootClean = (r.domainErrors ?? []).length === 0 && bootPromptsOk;
525
- if (bootClean)
526
- ccEtag = r.etag;
527
- for (const de of r.domainErrors ?? [])
528
- logger.warn("config_domain_invalid", { domain: de.domain, error: de.error, note: "tolerated at BOOT (corrupt file → schema defaults; dangling ref → reported as-is); the refresh path rejects such a candidate whole and keeps the LKG (§9.5-5)" });
529
- if (dryRun) {
530
- logEffectiveDiff(config, r.effective, logger); // compare-only: log the would-be changes, keep env
531
- }
532
- else {
533
- applyEffective(config, r.effective, logger, { sealedKeys }); // sealedKeys: unseal sealedApiKey → in-memory key pipeline
534
- await adoptCenterPrompts(r.effective, "boot"); // [1057] additive prompts key(判形采用;缺席=内置)
535
- markRosterLanded(r.effective); // boot pull 即落 roster 的常态:门在启动窗内就打开
536
- effective = r.effective; // skills/mcp applied below, before buildScenarios (restart-to-apply)
537
- appliedPlaneEff = r.effective; // model plane applied pre-Runner — this IS the Runner's generation
538
- if (bootClean)
539
- latestEffective = r.effective; // LKG seed (§9.5-2): only a fully clean candidate may become the LKG
540
- // F7(LKG 复审三轮):boot 写点**延后**到 center skills 装载(applyCenterSkills+盘缓存)之后——
541
- // 先落 LKG 会让「新 skill 的 manifest 可见但正文没进缓存」窗口存在(此窗内崩+中心断=离线 boot
542
- // 静默缺 skill)。这里只记 candidate,真正落盘在 skills 装载后(main 后段)。
543
- if (bootClean && !lkgBooted)
544
- bootLkgCandidate = { effective: r.effective, etag: r.etag };
545
- // [875]b:带上模型数与(local lane)真实目录来源——复验实录:CONFIG_LOCAL_DIR 误配(只设
546
- // LOCAL_DATA_ROOT)时 config.d 静默读不到、0 模型且无告警,排障只能靠猜。
547
- const cfgModels = (r.effective.models?.models ?? []).filter((m) => m.enabled !== false).length;
548
- const cfgDir = configProvider.kind === "local" ? { dir: configProvider.root } : {};
549
- logger.info("config_loaded", { source: configProvider.kind, version: r.effective.version, worker: cc?.worker ?? "(global)", models: cfgModels, ...cfgDir });
550
- if (configProvider.kind === "local" && cfgModels === 0)
551
- logger.warn("config_local_no_models", { ...cfgDir, hint: "config.d/models.json 空或 CONFIG_LOCAL_DIR 指错目录(选目录旋钮是 CONFIG_LOCAL_DIR,不是 LOCAL_DATA_ROOT)— 模型继续走 env 兜底" });
552
- }
553
- }
554
- }
555
- catch (err) {
556
- logger.warn("config_source_unreachable_using_env", { source: configProvider.kind, err: String(err) });
557
- // 硬失败(fast-fail RST 等,非超窗)同样值得 LKG:上一进程的干净候选好过纯 env。
558
- const lkgFallback = await bootFromLkg(`boot fetch failed: ${String(err).slice(0, 120)}`);
559
- if (lkgFallback) {
560
- lkgBooted = true;
561
- try {
562
- await bootApplyLkgInline(lkgFallback);
563
- }
564
- catch (applyErr) {
565
- lkgBooted = false;
566
- logger.warn("config_lkg_apply_failed", { err: String(applyErr), note: "persisted LKG failed to apply — env fallback serves" });
567
- }
568
- }
569
- }
570
- }
571
- if (!modelReadyState.ready)
572
- logger.warn("model_roster_pending", { note: "registry deployment without an env model and no roster yet — billable submissions 503 until the first effective pull lands models" });
573
- // DUAL-MODE §4/§5 (center #1): in LOCAL config mode, source remoteExec from the SHARED
574
- // `config.d/remote-exec.json` via the registry `loadRemoteExec` — the SAME file + zod validation that the
575
- // client's `agent-cli config` writes, so client doctor and the engine read one source of truth (zero drift).
576
- // Semantics (center, deliberate): file MISSING → undefined → keep the env/host default (config.ts already
577
- // defaults host in local mode); file PRESENT → it wins; file empty/corrupt → the loader throws → fail-closed
578
- // (an empty file silently falling back to host would be an isolation downgrade if the user meant local-docker).
579
- if (configProvider?.kind === "local") {
580
- try {
581
- const fileRx = await loadRemoteExec(localRoot); // SAME root as the config provider above (no split-brain)
582
- if (fileRx) {
583
- // 🔴 center contract: the package RemoteExecSpec is the CONTRACT form (env-NAME refs: apiKeyEnv/tokenEnv/…);
584
- // our config.remoteExec is the RESOLVED form (values). `host` carries NO secrets so it maps 1:1 (contract
585
- // `workdir` ≡ our `workspaceBase`). The ISOLATED lanes (e2b/k8s/ssh/adb) need a NAME→value resolution
586
- // before use — a follow-on; until then they are sourced from REMOTE_EXEC env, so a file-source isolated
587
- // arm is ignored (NOT cast verbatim — that would feed an env-NAME where a value is expected).
588
- if (fileRx.provider === "host") {
589
- config.remoteExec = {
590
- provider: "host",
591
- ...(fileRx.workdir ? { workspaceBase: fileRx.workdir } : {}),
592
- ...(fileRx.commandTimeoutMs != null ? { commandTimeoutMs: fileRx.commandTimeoutMs } : {}),
593
- };
594
- logger.info("remote_exec_from_file", { provider: "host", root: localRoot });
595
- }
596
- else if (fileRx.provider === "local-docker") {
597
- // local-docker carries NO secret env-NAMEs (image/memory/cpus/network/workdir/mountPath only), so it
598
- // maps 1:1 from the contract form — no NAME→value resolution needed (unlike e2b/k8s/ssh). The contract
599
- // `workdir` is the in-container workspace ROOT ≡ our `mountPath`. DOCKER_HOST/secret env stay in the
600
- // worker's .env (the adapter reads its own process.env via the env-forward, not the file).
601
- config.remoteExec = {
602
- provider: "local-docker",
603
- image: fileRx.image,
604
- ...(fileRx.mountPath ? { mountPath: fileRx.mountPath } : fileRx.workdir ? { mountPath: fileRx.workdir } : {}),
605
- ...(fileRx.memory ? { memory: fileRx.memory } : {}),
606
- ...(fileRx.cpus != null ? { cpus: fileRx.cpus } : {}),
607
- ...(fileRx.network ? { network: fileRx.network } : {}),
608
- };
609
- logger.info("remote_exec_from_file", { provider: "local-docker", root: localRoot, image: fileRx.image });
610
- }
611
- else {
612
- logger.warn("remote_exec_file_isolated_lane_unwired", { provider: fileRx.provider, note: "e2b/k8s/ssh/adb are sourced from REMOTE_EXEC env until file-source NAME→value resolution lands (center field map)" });
613
- }
614
- }
615
- }
616
- catch (err) {
617
- logger.error("remote_exec_file_invalid", { root: localRoot, err: String(err) });
618
- throw err; // fail-closed (center semantics): a corrupt remote-exec.json must not boot a wrong/weaker lane
619
- }
620
- }
621
- // One shared SQL store backend for L1 + L2 (TiDB/MySQL or PostgreSQL per DB_BACKEND). Owned here.
622
- let backend;
623
- let storeBackendDegraded = false; // S5: auto-probe degraded this replica to in-memory (surfaced via gauge + /health)
624
- // `auto` probes a configured DB and degrades to in-memory if it's unreachable (local/intranet binaries:
625
- // "use my DB if I can reach it, else memory"). Explicit `tidb` fails fast instead — silently dropping
626
- // persistence the operator asked for would lose runs. (`tidb` here = "the SQL DB"; DB_BACKEND picks the engine.)
627
- const wantDb = config.sessionBackend === "tidb" ||
628
- config.dbBackend === "local" || // local (clay 2026-06-25): always build the DB-less in-memory/file StoreBackend
629
- (config.sessionBackend === "auto" && !!(config.tidb || config.pg));
630
- if (wantDb) {
631
- // 构造 + ensureSchema 同罩一层降级臂(2026-07-28 修):原先 try 只罩 ensureSchema,而 local 形真正
632
- // 会抛的是构造里的 mkdir + 数据根 BootLock ——「裸 boot 不得拒启」那条口径对 local 从未生效过。
633
- // 降级/fail-loud 的判据与理由见 openStoreBackendWithFallback 顶注。
634
- const opened = await openStoreBackendWithFallback(config, logger);
635
- backend = opened.backend;
636
- storeBackendDegraded = opened.degraded;
637
- }
638
- // S5 review LOW-1: always render the series (0 = healthy) — gauge absence is indistinguishable from
639
- // "old build without this metric", which breaks `== 0`-style alert rules.
640
- metrics.setGauge("store_backend_degraded", storeBackendDegraded ? 1 : 0);
641
- // S21 (SILENT-FALLBACK P1): MINIO_* partially set silently falls back to SQL-blob snapshots. Warn on the
642
- // partial config and always render which backend blobs actually use.
643
- {
644
- const minioReq = ["MINIO_ENDPOINT", "MINIO_ACCESS_KEY", "MINIO_SECRET_KEY"];
645
- const present = minioReq.filter((v) => (process.env[v] ?? "") !== "");
646
- if (present.length > 0 && !config.snapshotBlobStore) {
647
- logger.warn("snapshot_blob_store_partial_config_fallback_sql", { present, missing: minioReq.filter((v) => !present.includes(v)) });
648
- }
649
- metrics.setGauge("snapshot_blob_backend", 1, { backend: config.snapshotBlobStore ? "minio" : "sql" });
650
- }
651
- // Resolve `auto` to the concrete backend the rest of the wiring understands (createSessionStore,
652
- // runStore, the startup summary). A DB-backed L2 with no backend degrades to in-memory the same way.
653
- if (config.sessionBackend === "auto")
654
- config.sessionBackend = backend ? "tidb" : "memory";
655
- // local backend: route the session store through backend.session() (LocalSessionStore — the §0.5 OwnerAware twin),
656
- // NOT the bare TtlSessionStore the "memory" path returns; "tidb" here means "the backend's durable store" generically.
657
- if (config.dbBackend === "local" && backend)
658
- config.sessionBackend = "tidb";
659
- // Long-term memory (design/138 S1, clay 2026-07-08): the injection-first file-based memory ENGINE is the
660
- // only memory plane — `RunnerDeps.memoryBackend` is core's switch (materialize → session file ops →
661
- // harvest; no remember/recall tools). SINGLE-USER TURNKEY ONLY: the file basement has no tenant
662
- // isolation, so a multi-tenant deployment (requirePrincipal) gets `undefined` = memory dark, fail-closed
663
- // (memoryEngineBackendFor + memoryScopeFor enforce the same gate). The legacy MemoryStore plane
664
- // (MEMORY_BACKEND/EMBEDDING_*) was dropped without migration; `scripts/drop-memory-tables.sql` drops the table.
665
- // S3-TOB(设计 §1.3,后经改判与边界重切):backend 选择器——file=现状(单用户/host);pg|tidb=
666
- // DB durable 真身(零卷主档:memory/ 文件面=materialize 的 ephemeral 工作副本,harvest 回 DB=持久化
667
- // 时点)。DB backend 是多租户点亮的唯一门(显式 opt-in);选了 DB 但池不可用/方言不匹配=fail-loud 拒启
668
- // (no-stopgap:绝不静默降 File/dark)。
669
- let memoryEngine;
670
- // 142-S2.5:per-(scope,peer) 同步游标面(sync_cursors)——POST /v1/memory/sync/:scope 的持久化半场。
671
- // 只在 DB memory plane 上点亮(file 形态路由 501,不接);与 entry plane 同池同方言。
672
- let memorySyncCursors;
673
- if (config.memoryEngineBackend !== "file" && config.memoryEngineEnabled) {
674
- const dialect = config.memoryEngineBackend;
675
- // 142-S5.1 production history sink: every applied patch appends one
676
- // agent_memory_engine_history row via the backend's optional history opts. The wrapper's ONLY
677
- // job is the metrics leg — count lost rows BEFORE rethrowing (the backend swallows + warns with
678
- // its own cumulative count; the audit line's breakage must itself be auditable).
679
- const countedHistorySink = (store) => ({
680
- appendHistory: async (rows) => {
681
- try {
682
- await store.appendHistory(rows);
683
- }
684
- catch (err) {
685
- metrics.inc("memory_history_append_failed_total", { backend: dialect }, rows.length);
686
- throw err;
687
- }
688
- },
689
- });
690
- if (dialect === "pg") {
691
- const pool = backend?.pgPool();
692
- if (!pool)
693
- throw new Error("MEMORY_ENGINE_BACKEND=pg requires DB_BACKEND=pg (the memory engine binds the same PG pool) — refusing to start half-configured");
694
- const q = async (text, params) => { const r = await pool.query(text, params); return { rows: r.rows }; };
695
- await ensurePgMemoryEngineSchema(q);
696
- await ensurePgMemoryHistorySchema(q);
697
- await ensurePgMemorySyncSchema(q); // 142-S2.5: sync_cursors(+push_queue)同池 ensure,幂等
698
- memorySyncCursors = new PgMemorySyncStore(q);
699
- const pgMem = new PgMemoryEngineBackend(q, { history: countedHistorySink(new PgMemoryHistoryStore(q)) });
700
- // 工作面根:DB 形态下 memory/ 目录=per-worker ephemeral 物化区(丢了重建,控制面同);
701
- // 复用 localDataRoot 下独立子树,绝不与 File backend 的持久 memory/ 混写。
702
- memoryEngine = { backend: pgMem, root: join(config.localDataRoot ?? localRoot, "memory-work") };
703
- }
704
- else {
705
- const pool = backend?.mysqlPool();
706
- if (!pool)
707
- throw new Error("MEMORY_ENGINE_BACKEND=tidb requires DB_BACKEND=mysql/tidb — refusing to start half-configured");
708
- await ensureTiDBMemoryEngineSchema(pool);
709
- await ensureTiDBMemoryHistorySchema(pool);
710
- await ensureTiDBMemorySyncSchema(pool); // 142-S2.5: sync_cursors(+push_queue)同池 ensure,幂等
711
- memorySyncCursors = new TiDBMemorySyncStore(pool);
712
- memoryEngine = { backend: new TiDBMemoryEngineBackend(pool, { history: countedHistorySink(new TiDBMemoryHistoryStore(pool)) }), root: join(config.localDataRoot ?? localRoot, "memory-work") };
713
- }
714
- logger.info("memory_engine_enabled", { enabled: true, backend: dialect, multiTenant: config.requirePrincipal === true, workRoot: memoryEngine.root });
715
- }
716
- else {
717
- memoryEngine = memoryEngineBackendFor(config);
718
- // [1845]②(cli 桌面撞坑;壳侧此前同坑已各自修过——两个宿主都踩=第三个宿主还会踩):操作员用
719
- // LOCAL_DATA_ROOT 明确要了隔离数据根,MEMORY_ENGINE_DIR 却缺省 ⇒ file 形 memory engine 落 core
720
- // 默认链(AGENT_DATA_DIR → ~/.ai-agent)= **静默共享全机 memory 库**。不改行为(单机用户可能就要
721
- // 共享),只把「你现在在共享」说出来。
722
- if (memoryEngine && process.env.LOCAL_DATA_ROOT && !config.memoryEngineDir) {
723
- logger.warn("memory_engine_dir_defaulted", {
724
- root: memoryEngine.root,
725
- hint: "LOCAL_DATA_ROOT is set but MEMORY_ENGINE_DIR is not — the memory engine is sharing the machine-wide default; set MEMORY_ENGINE_DIR to isolate it under your data root",
726
- });
727
- }
728
- }
729
- // RosterStore(agent-team S1 server 半场,[1070]① 提货单;core 1.316 `RunnerDeps.rosterStore`):
730
- // 具名子 agent 持久名册。形态跟 StoreBackend:tidb/pg=SQL twins(语义三条+真双库验证,
731
- // roster-store-sql.ts 顶注);local=core FileRosterStore(dataRoot 下 roster.json,core 自带原子写/
732
- // 损坏安全);无 backend(纯内存 dev)=不挂(core 用活注册表,跨 run 指针面自然缺席——诚实)。
733
- let rosterStore;
734
- {
735
- const mysqlPool = backend?.mysqlPool?.();
736
- const pgPool = backend?.pgPool?.();
737
- if (pgPool) {
738
- const q = async (text, params) => { const r = await pgPool.query(text, params); return { rows: r.rows }; };
739
- await ensurePgRosterSchema(q);
740
- rosterStore = new PgRosterStore(q);
741
- }
742
- else if (mysqlPool) {
743
- await ensureTiDBRosterSchema(mysqlPool);
744
- rosterStore = new TiDBRosterStore(mysqlPool);
745
- }
746
- else if (backend?.kind === "local") {
747
- rosterStore = new FileRosterStore(join(config.localDataRoot ?? localRoot, "roster.json"));
748
- }
749
- if (rosterStore)
750
- logger.info("roster_store_enabled", { backend: pgPool ? "pg" : mysqlPool ? "tidb" : "file" });
751
- }
752
- // BackgroundAgentStore(design/151 S1 server 半场,[1503] 提货单;core 1.364 durable background agents):
753
- // 后台子代 `a*` 行的 durable 执行记录——settle 后转录 session 不再 eager release(TaskOutput/
754
- // AgentTranscript 完成后照读全程),清理移交 reapDurableAgents(reaper 区)。形态跟 roster:
755
- // tidb/pg=SQL twins(真双库验证,background-agent-store-sql.ts 顶注);local=core
756
- // FileBackgroundAgentStore(dataRoot 下 background-agents/,core 自带 ledger+snapshot 原子写);
757
- // 无 backend(纯内存 dev)=不挂(core 契约:store 不接线=pre-151 逐字节等价——单进程 live 注册表
758
- // 已覆盖读面,诚实缺席)。⚠️ 同实例双点挂载:RunnerDeps.backgroundAgentStore(读半场)+
759
- // ScenarioDeps→SubagentToolOptions.background.agentStore(写半场)都用这一个引用——engine 无法核对
760
- // 配对,半接=静默死特性(RB-37①)。
761
- let backgroundAgentStore;
762
- {
763
- const mysqlPool = backend?.mysqlPool?.();
764
- const pgPool = backend?.pgPool?.();
765
- if (pgPool) {
766
- const q = async (text, params) => { const r = await pgPool.query(text, params); return { rows: r.rows }; };
767
- await ensurePgBackgroundAgentSchema(q);
768
- backgroundAgentStore = new PgBackgroundAgentStore(q);
769
- }
770
- else if (mysqlPool) {
771
- await ensureTiDBBackgroundAgentSchema(mysqlPool);
772
- backgroundAgentStore = new TiDBBackgroundAgentStore(mysqlPool);
773
- }
774
- else if (backend?.kind === "local") {
775
- // core 1.368([1516]②)listScopes 落地:file 实现自带枚举——1.248 拍的「local+多租户 reap 缺口」
776
- // warn-once 已撤(reaper 腿现在真枚举,不再假设单 scope)。
777
- backgroundAgentStore = new FileBackgroundAgentStore(config.localDataRoot ?? localRoot);
778
- }
779
- if (backgroundAgentStore)
780
- logger.info("background_agent_store_enabled", { backend: pgPool ? "pg" : mysqlPool ? "tidb" : "file" });
781
- }
782
- // D-1 通用文件上传(clay 拍 2026-07-27):独立附件 store(不骑 snapshot blob 面——那套的 orphan GC
783
- // 会把非 manifest 引用的 bytes 误收;生命周期也不同:附件跟 task/session 绑定)。形态跟 StoreBackend
784
- // 三态:tidb/pg=SQL twins(真双库验证)、local=文件店;无 backend(纯内存 dev)=不挂 ⇒ 路由 501。
785
- // 四半场:①上传/取回/删除(http/server.ts 路由)②提交时绑定+objective 告知+host lane 物化
786
- // (resolveSpec 内,下方)③远程 lane env 建立时物化(executionEnvFactory 包装,下方)④生命周期
787
- // (E21 purge 级联 + 未绑定 TTL reaper)。
788
- let taskAttachmentStore;
789
- {
790
- const mysqlPool = backend?.mysqlPool?.();
791
- const pgPool = backend?.pgPool?.();
792
- if (pgPool || mysqlPool) {
793
- // clay 裁(2026-07-27):**云形态对象存储必配**——附件字节本体进 MinIO(SQL 只存 meta/门/生命周期)。
794
- // 复用快照 lane 的同一 MinIO 部署配置(MINIO_ENDPOINT/ACCESS/SECRET,零重映射),但**附件专属
795
- // keyPrefix**:对象键=sha256,与快照 blob 同 sha 不同生命周期,同名字空间会互删。未配 ⇒ 附件面
796
- // 不接线(路由 501)+ error 级日志——这是部署配置错误,不静默降级回「字节进 DB」(该形已被裁掉)。
797
- const minio = config.snapshotBlobStore;
798
- if (!minio) {
799
- logger.error("attachments_disabled_object_store_required", {
800
- hint: "cloud deployments must configure object storage (MINIO_ENDPOINT/MINIO_ACCESS_KEY/MINIO_SECRET_KEY) — attachment routes will 501 until it is set",
801
- });
802
- }
803
- else {
804
- const bytes = new MinioBlobBackend({ ...minio, keyPrefix: `${minio.keyPrefix ?? ""}attachments/` });
805
- if (pgPool) {
806
- const q = async (text, params) => { const r = await pgPool.query(text, params); return { rows: r.rows }; };
807
- await ensurePgTaskAttachmentSchema(q);
808
- taskAttachmentStore = new PgTaskAttachmentStore(q, bytes);
809
- }
810
- else {
811
- await ensureTiDBTaskAttachmentSchema(mysqlPool);
812
- taskAttachmentStore = new TiDBTaskAttachmentStore(mysqlPool, bytes);
813
- }
814
- }
815
- }
816
- else if (backend?.kind === "local") {
817
- taskAttachmentStore = new LocalTaskAttachmentStore(config.localDataRoot ?? localRoot);
818
- }
819
- if (taskAttachmentStore)
820
- logger.info("task_attachment_store_enabled", { backend: pgPool ? "pg+minio" : mysqlPool ? "tidb+minio" : "file", maxBytes: config.attachmentMaxBytes });
821
- }
822
- // S3c(core 1.374 行为车,[1531]):teammate 信箱——SendMessage 对 SETTLED named teammate 的 tier-3
823
- // 懒复活链(durable 行解析→claim-CAS→mailbox append→reviveSpawn→attach 屏障后 ack)。激活三件套=
824
- // RunnerDeps.mailboxStore + backgroundAgentStore 同挂 + delegation 工具在场,三缺一=1.373 文本字节
825
- // 不变(诚实拒)。形态同上:tidb/pg=SQL twins(mailbox-store-sql.ts,发车复审八修顶注);local=
826
- // core FileMailboxStore;纯内存 dev=不挂。回执句族(details.error 枚举)wire 透传零改动。
827
- let mailboxStore;
828
- {
829
- const mysqlPool = backend?.mysqlPool?.();
830
- const pgPool = backend?.pgPool?.();
831
- if (pgPool) {
832
- await ensurePgMailboxSchema(async (text, params) => pgPool.query(text, params));
833
- mailboxStore = new PgMailboxStore(pgPool);
834
- }
835
- else if (mysqlPool) {
836
- await ensureTiDBMailboxSchema(mysqlPool);
837
- mailboxStore = new TiDBMailboxStore(mysqlPool);
838
- }
839
- else if (backend?.kind === "local") {
840
- mailboxStore = new FileMailboxStore(config.localDataRoot ?? localRoot);
841
- }
842
- if (mailboxStore)
843
- logger.info("mailbox_store_enabled", { backend: pgPool ? "pg" : mysqlPool ? "tidb" : "file" });
844
- }
845
- // 142-S5 §1.4: the per-scope export READ face lights up only on the DB memory plane (the multi-tenant
846
- // truth lives in the scope-partitioned tables; the file posture exports by copying the memory dir, so
847
- // the route 501s honestly there). History-table WRITE wiring: inside the backend construction above
848
- // (S5.1, opts.history — the write point lives in the handed-over backend code).
849
- const memoryExportBackend = config.memoryEngineBackend !== "file" ? memoryEngine?.backend : undefined;
850
- const memoryLane = memoryEngineRemoteLanePosture(config); // N0: worker/sandbox file-plane split posture
851
- if (config.memoryEngineBackend === "file" || !config.memoryEngineEnabled) {
852
- logger.info("memory_engine_enabled", memoryEngine
853
- ? { enabled: true, backend: "file", dir: memoryEngine.root }
854
- : {
855
- enabled: false,
856
- // 复审 F-11:按最终门结果报因,多租户 file=隔离判据,其余照旧。
857
- reason: config.requirePrincipal === true ? "multi-tenant on the FILE backend (no tenant isolation; set MEMORY_ENGINE_BACKEND=pg|tidb to light up)"
858
- : !config.memoryEngineEnabled ? "MEMORY_ENGINE=off"
859
- : `remote exec lane "${memoryLane?.lane}" (worker/sandbox file planes split; MEMORY_ENGINE_REMOTE_LANE=allow overrides)`,
860
- });
861
- }
862
- // N0 boot warn: loud in BOTH postures — "dark" so an upgrade that silently turns memory off is visible,
863
- // "forced" so an operator override states what it depends on (harvest only sees the WORKER fs).
864
- if (memoryLane && config.memoryEngineEnabled && config.requirePrincipal !== true) {
865
- logger.warn("memory_engine_remote_lane", memoryLane.posture === "dark"
866
- ? { lane: memoryLane.lane, effect: "memory dark (fail-closed): the file engine works the worker's local fs while this lane routes model file tools to the sandbox fs — sandbox writes are never harvested. Set MEMORY_ENGINE_REMOTE_LANE=allow ONLY if both are one fs." }
867
- : { lane: memoryLane.lane, effect: "MEMORY_ENGINE_REMOTE_LANE=allow: memory engine ON over a remote lane — harvest only sees files landing on the WORKER fs; verify the lane really shares it." });
868
- }
869
- // 142-S2.5-W1: TOC 同步 client 腿——只在 file memory 形态接线(loadConfig 已拒 DB backend
870
- // 上的 MEMORY_SYNC_*,这条分支到不了)。boot 后 fire-and-forget 一轮(失败 warn 不阻断——纯本地现状
871
- // 是安全降级面);之后 harvest 真有 patch 落地时再触发(onMemoryHarvestReport 站点,inflight 节流)。
872
- // 引擎 dark(多租户/MEMORY_ENGINE=off/remote lane)⇒ 无本地盘可同步:warn 不 throw(dark 的三个成因
873
- // 各有自己的 loud 日志在上方,这里补“sync 因此没跑”这半句,operator 可见不半配)。
874
- let memorySyncRunner;
875
- if (config.memorySync) {
876
- if (memoryEngine) {
877
- memorySyncRunner = createMemorySyncRunner({
878
- backend: memoryEngine.backend,
879
- scope: config.memorySync.scope,
880
- memoryRoot: memoryEngine.root,
881
- transport: createMemorySyncTransport({ url: config.memorySync.url, token: config.memorySync.token }),
882
- log: logger,
883
- // S2.5 分批(core 1.284):未设=不分批 wire 字节不变;设了=续轮 loop 至收敛(MAX_SYNC_ROUNDS 警戒)。
884
- ...(config.memorySync.maxPushEntries !== undefined ? { maxPushEntries: config.memorySync.maxPushEntries } : {}),
885
- ...(config.memorySync.maxPullEntries !== undefined ? { maxPullEntries: config.memorySync.maxPullEntries } : {}),
886
- });
887
- logger.info("memory_sync_enabled", { url: config.memorySync.url, scope: config.memorySync.scope, cursorPath: memorySyncRunner.cursorPath });
888
- memorySyncRunner.trigger("boot");
889
- }
890
- else {
891
- logger.warn("memory_sync_configured_but_memory_dark", { note: "MEMORY_SYNC_URL is set but the file memory engine is dark (multi-tenant / MEMORY_ENGINE=off / remote exec lane) — no sync rounds will run" });
892
- }
893
- }
894
- const sessionStore = createSessionStore(config, backend, metrics); // S25: stale-affinity evict fingerprint
895
- // S6 startup guard: multi-tenant isolation needs an owner-aware (TiDB) store. Refuse to start with
896
- // REQUIRE_PRINCIPAL on an in-memory store that can't enforce session ownership.
897
- if (config.requirePrincipal && !sessionStore.ownerOf) {
898
- throw new Error("REQUIRE_PRINCIPAL=true needs an owner-aware session store (SESSION_BACKEND=tidb); " +
899
- "the in-memory store cannot enforce session ownership.");
900
- }
901
- // The local in-memory backend HAS an ownerOf (LocalSessionStore), so the guard above passes — but its owner map is
902
- // process-local + lost on restart, so it cannot DURABLY enforce multi-tenant ownership (post-restart a session id is
903
- // re-claimable by whoever attaches first). Refuse REQUIRE_PRINCIPAL on it: a real multi-tenant
904
- // boundary needs the durable DB backend; single-user local runs with REQUIRE_PRINCIPAL=false (the principal may
905
- // still ride for memory-scoping). The file-backed follow-on (durable owners) can revisit this.
906
- if (config.requirePrincipal && backend?.kind === "local") {
907
- throw new Error("REQUIRE_PRINCIPAL=true is not supported on the local file backend (DB_BACKEND=local): session/run CONTENT is " +
908
- "durable there, but OWNER attribution is process-local and lost on restart (store-backend.ts §0.5 — durable " +
909
- "owners = P1), so it cannot durably enforce multi-tenant session ownership. Use a SQL backend " +
910
- "(DB_BACKEND=mysql|pg) for multi-tenant, or run local single-user with REQUIRE_PRINCIPAL=false (a BFF may " +
911
- "still inject x-agent-principal per request for memory scoping and audit attribution).");
912
- }
913
- // (c)(clay 拍 a+c,2026-07-27)云形快照 blob 姿势门:mysql|pg 后端缺 MinIO ⇒ fail-loud(bytes-in-DB
914
- // 撞包墙已两役实证);SNAPSHOT_BLOB_ALLOW_SQL_BYTES=true 显式逃生(单机/测试台,吃 (a) 的 per-blob 帽)。
915
- if (backend)
916
- assertCloudSnapshotBlobPosture(backend.kind, config);
917
- // Posture warning (audit B, security.ts): an owner-aware store without REQUIRE_PRINCIPAL means the
918
- // principal layer is optional per request. Owned sessions are still protected (the authorizer rejects
919
- // anonymous/mismatched attach), but new headerless submissions create anonymous sessions any token
920
- // holder can attach to — a multi-tenant deployment should set REQUIRE_PRINCIPAL=true.
921
- if (!config.requirePrincipal && sessionStore.ownerOf) {
922
- logger.warn("principal_optional", {
923
- note: "owner-aware session store with REQUIRE_PRINCIPAL=false — owned sessions are protected, but headerless callers can create/share anonymous sessions; set REQUIRE_PRINCIPAL=true for multi-tenant",
924
- });
925
- }
926
- metrics.dynamicGauge("sessions_warm", "Sessions held in the warm cache", () => sessionStore.size);
927
- // 1.38 cross-replica circuit-breaker state: shared via TiDB when a pool exists AND the breaker is
928
- // enabled; otherwise core's per-process Map (single replica / no DB / breaker off). Refresh loop is
929
- // unref'd so it never holds the process open.
930
- const breakerState = backend?.breaker && config.resilience.circuitBreaker
931
- ? backend
932
- .breaker((streak) => {
933
- // LOW (SILENT-FALLBACK P1): cross-replica breaker write-through failures were a bare swallow.
934
- metrics.setGauge("counter_flush_fail_streak", streak, { table: "circuit_breaker", kind: "write_through" });
935
- if (streak > 0)
936
- metrics.inc("breaker_writethrough_failed_total", { backend: backend.kind });
937
- if (streak === 3)
938
- logger.warn("breaker_writethrough_degraded", { streak });
939
- else if (streak === 0)
940
- logger.info("breaker_writethrough_recovered", {});
941
- })
942
- .startRefresh()
943
- : undefined; // local omits breaker() → core's per-process Map
944
- const brain = createBrain(config, { breakerState });
945
- // ③ Cost + ④ trace + ⑤ budget (1.37): inject per-model pricing (authoritative costMicroUsd), bridge
946
- // core's content-free trace events to /metrics, and accumulate per-principal spend into the cost
947
- // quota (the tracer reads the principal ALS so council/team sub-task cost is attributed too).
948
- const pricing = buildPricing(config.models);
949
- // S9 (SILENT-FALLBACK P0-e): write-behind flush/refresh failures previously vanished in a bare catch —
950
- // the fleet silently degrades to per-replica counting (soft limits stop being fleet-wide). Every failure
951
- // ticks the counter + streak gauge; warn once per episode (streak hits 3), info on the recovery edge.
952
- const counterDegradeHook = (info) => {
953
- metrics.setGauge("counter_flush_fail_streak", info.streak, { table: info.table, kind: info.kind });
954
- if (info.streak > 0)
955
- metrics.inc("counter_flush_failed_total", { table: info.table, kind: info.kind });
956
- if (info.streak === 3)
957
- logger.warn("counter_flush_degraded", { table: info.table, kind: info.kind, streak: info.streak, ...(info.error ? { error: info.error } : {}) });
958
- else if (info.streak === 0 && info.prevStreak >= 3)
959
- logger.info("counter_flush_recovered", { table: info.table, kind: info.kind, prevStreak: info.prevStreak });
960
- };
961
- // Per-principal cumulative cost quota: TiDB-backed (cross-replica, atomic write-behind) when a pool
962
- // exists, else in-memory per-replica (single-replica / no DB). Both satisfy QuotaTracker. (design/30 P0-1)
963
- const costQuota = config.maxPrincipalCostUsd > 0
964
- ? backend?.costQuota // local omits costQuota() → in-memory per-process CostQuota (single-replica)
965
- ? backend.costQuota(Math.round(config.maxPrincipalCostUsd * 1e6), config.costQuotaWindowSec * 1000, counterDegradeHook).startRefresh()
966
- : new CostQuota(Math.round(config.maxPrincipalCostUsd * 1e6), config.costQuotaWindowSec * 1000)
967
- : undefined;
968
- // E8 (shell-host contract): per-task × per-model usage accumulator, fed by the tracer and drained by the durable run
969
- // lifecycle into `model_usage` events (the `TaskStats.modelUsage` echo). One instance, shared tracer↔server.
970
- const modelUsageTracker = new ModelUsageTracker();
971
- // [998]② web 观测切片 A 数据面:prompt.assembled 的 manifest 经 tracer 记录、由 durable run 生命周期
972
- // drain 成 `prompt_assembled` 事件(turns/stream 可读)。与 modelUsageTracker 同款 register 篱笆/生命周期。
973
- const promptManifestTracker = new PromptManifestTracker();
974
- // fleet 用量批报的 principal×model 窗口累计器 — 仅当 fleet 面可能启用(center lane 配置了)才建,
975
- // 否则 tracer 走 undefined = 零行为(非 fleet 部署不多攒一份内存)。真正的启动门在 startFleetClientFromEnv。
976
- const fleetUsage = config.configCenter && process.env.FLEET_ADVERTISE_ADDRESS ? new FleetUsageAccumulator() : undefined;
977
- // fleet lease 消费(worker 侧第三接线,D4 AP 模式):协商式 —— center lane 配齐即启(lease 是出站消费面,
978
- // 不需要 FLEET_ADVERTISE_ADDRESS);非 lease-enforced principal 一次 409 负缓存后走既有本地 CostQuota,
979
- // 单用户(无 principal)提交门早退 —— 两者零行为变化。
980
- const fleetLease = createFleetLeaseFromEnv(config, { logger, metrics });
981
- // weight-at-burn 回调:读 config.modelQuotaWeights(registry hot-apply 的活表;env lane 空 ⇒ 1 兜底)。
982
- const tracer = createTracer(metrics, costQuota, modelUsageTracker, fleetUsage, fleetLease, (m) => config.modelQuotaWeights[m] ?? 1, promptManifestTracker);
983
- // [1469] side-query 记账 seam:与 tracer 同一装配点、同一组 sink 实例(codex R3——sideQuery 不发
984
- // brain.call,四路 sink 必须由 server 半场喂,两臂同语义见 createSideQueryAccountant 头注)。
985
- const sideQueryAccounting = createSideQueryAccountant(metrics, costQuota, fleetUsage, fleetLease, (m) => config.modelQuotaWeights[m] ?? 1);
986
- // Durable offload store (core 1.47/1.49): large tool results survive a cross-replica wake. Without a
987
- // pool, core's task-scoped in-memory default applies (graceful: cross-wake fetch misses → preview stands).
988
- const toolResultStore = backend?.toolResult ? backend.toolResult() : undefined; // tidb/pg = SQL twins; local = core's FileToolResultStore (core 1.219 — restart-durable refs); no backend → core's in-memory default
989
- // E6 durable SessionPolicyStore — operator-tightened per-session tool rules core reads at prepare-time (subtract-only).
990
- // Present on every backend (incl local = core's InMemorySessionPolicyStore); undefined only on the env-only/no-backend
991
- // worker → feature OFF (core reads no rules). Wired into the PRIMARY Runner below + the PUT/GET policy route + E21 purge.
992
- const sessionPolicyStore = backend ? backend.sessionPolicy() : undefined;
993
- // E19 rewind-files — working-tree snapshot/restore keyed by entryId (the same anchor as E18), + the 2c artifact-transfer
994
- // store. core AUTOMATICALLY snapshots each completed turn + restores on resumeAt when `spec.rewindFiles` is set, for ANY
995
- // ExecutionEnv when this store is wired (gate-split 1.134.0 removed the isRemoteExecutionEnv skip → host/local-mode
996
- // + e2b/k8s/ssh/adb/local-docker all active). Durable on tidb/pg (cross-replica + 2c), local = core's
997
- // InMemoryFileSnapshotStore. Wired into the PRIMARY Runner + the `rewindFiles` cap + E21 purge.
998
- const fileSnapshotStore = backend ? backend.fileSnapshot() : undefined;
999
- // [805]③a / [806]④: E19-fix observability — core 1.287 captureManifest TOLERATES enumerate→read-window
1000
- // vanished files (skip, not fail) and reports the count ONLY in its return value; the SQL twins expose it via
1001
- // this optional sink. Metric = the live face of "the fix is absorbing real races" (a warn would be noise — the
1002
- // skip is the CORRECT behavior). GAP (honest): the local lane uses core's own store, which drops the count
1003
- // internally — surfacing it there needs a core seam (noted on the board, not faked here).
1004
- if (fileSnapshotStore && "onSkippedVanished" in fileSnapshotStore) {
1005
- fileSnapshotStore.onSkippedVanished = (count) => metrics.inc("snapshot_skipped_vanished_total", {}, count);
1006
- }
1007
- // design/48 v1b: deployment-level remote execution(部署级路由 + 懒汉连接). When
1008
- // REMOTE_EXEC=e2b, this deployment is a "code-agent worker" — each task's hand runs in a per-task E2B VM,
1009
- // provisioned lazily on first hand use (a plan-only task pays zero VM cost). Unset → in-process stub env.
1010
- // design/48 v1b + design/61: pick the remote-exec backend by provider (peer adapters). SSH/ADB target REAL
1011
- // systems (not isolated/suspendable) — the leader's autonomy + the design/37 gate + HITL run accordingly.
1012
- // §7 P0.5 per-task sandbox image: the trusted-control-plane bridge from resolveSpec (resolves the requested
1013
- // profile → digest with the caller's principal, fail-closed) to the k8s factory (applies it per-pod). See
1014
- // per-task-image.ts + resolveSpec below. Worker-global default image is the fallback when no profile is requested.
1015
- const perTaskImage = new PerTaskImageRegistry();
1016
- // RFC A2: session-keyed environment selection (PROFILE intent, never a digest) written by the model-facing
1017
- // SelectEnvironment tool; resolveSpec re-resolves it FAIL-CLOSED per task (same re-admit as a body profile).
1018
- const sessionEnvSelection = new SessionEnvironmentSelection();
1019
- // [#40 / TOC cwd seam] per-session launch dir → the host lane's agent workspace (resolveSpec registers it gated by
1020
- // cwdHonored; the host factory reads it by ctx.sessionId). Only ever written for the single-user host lane.
1021
- // BOUNDED (avoid unbounded growth over the persistent local engine's lifetime): an LRU cap; the shell
1022
- // re-sends cwd on every request, so evicting a stale session is harmless (it re-registers on next use).
1023
- const MAX_CWD_SESSIONS = 4096;
1024
- const perSessionCwd = new Map();
1025
- const setSessionCwd = (sid, cwd) => {
1026
- perSessionCwd.delete(sid); // re-insert at the tail = most-recently-used
1027
- perSessionCwd.set(sid, cwd);
1028
- if (perSessionCwd.size > MAX_CWD_SESSIONS)
1029
- perSessionCwd.delete(perSessionCwd.keys().next().value); // evict oldest
1030
- };
1031
- // [R-survey / TOC shellEnv seam, core PLAN批注] per-session `settings.env` → the host lane's agent shell env
1032
- // (resolveSpec registers it gated by cwdHonored — single-user host lane only; the host factory merges it by
1033
- // ctx.sessionId). design/107 "env = capability axis". Same LRU bound + re-send-on-every-request semantics as cwd.
1034
- const perSessionShellEnv = new Map();
1035
- const setSessionShellEnv = (sid, env) => {
1036
- perSessionShellEnv.delete(sid);
1037
- perSessionShellEnv.set(sid, env);
1038
- if (perSessionShellEnv.size > MAX_CWD_SESSIONS)
1039
- perSessionShellEnv.delete(perSessionShellEnv.keys().next().value);
1040
- };
1041
- let executionEnvFactory;
1042
- // SVC-3 worktree isolation: the reaper (defined far below) reuses ONE long-lived git base env + repoRoot to
1043
- // `git worktree prune` crash-orphaned worktrees. Holders are populated when the wrapper is wired (host lane).
1044
- let worktreeReap;
1045
- if (config.remoteExec?.provider === "e2b") {
1046
- executionEnvFactory = e2bExecutionEnvFactory({
1047
- apiKey: config.remoteExec.apiKey,
1048
- ...(config.remoteExec.template ? { template: config.remoteExec.template } : {}),
1049
- ...(config.remoteExec.timeoutMs != null ? { timeoutMs: config.remoteExec.timeoutMs } : {}),
1050
- ...(config.remoteExec.livenessMs != null ? { livenessMs: config.remoteExec.livenessMs } : {}),
1051
- ...(config.remoteExec.allowInternetAccess != null ? { allowInternetAccess: config.remoteExec.allowInternetAccess } : {}),
1052
- // [1452]/[1454] CWD-A: workspace 根透传(E2B_MOUNT_PATH)——缺省仍 /home/user(adapter 默认)。
1053
- ...(config.remoteExec.mountPath ? { mountPath: config.remoteExec.mountPath } : {}),
1054
- // RFC B5: region package-source env set rides the operator-trusted sandboxEnv — ENV-FIRST, the derived
1055
- // ecosystem overrides (pip/uv/npm/go/rustup/flutter/…) beat the image's baked CN ENV on existing images,
1056
- // no rebake needed. Explicit E2B_SANDBOX_ENV keys win over derived on collision (deliberate override).
1057
- ...(() => {
1058
- const merged = { ...derivePkgSourceEnv(config.sandboxPkgSource, customPkgSourceFromEnv()), ...(config.remoteExec.sandboxEnv ?? {}) };
1059
- return Object.keys(merged).length > 0 ? { sandboxEnv: merged } : {};
1060
- })(),
1061
- logger,
1062
- metrics,
1063
- });
1064
- }
1065
- else if (config.remoteExec?.provider === "k8s") {
1066
- const k8sCfg = {
1067
- image: config.remoteExec.image,
1068
- ...(config.remoteExec.apiUrl ? { apiUrl: config.remoteExec.apiUrl } : {}),
1069
- ...(config.remoteExec.token ? { token: config.remoteExec.token } : {}),
1070
- ...(config.remoteExec.caCert ? { caCert: config.remoteExec.caCert } : {}),
1071
- ...(config.remoteExec.insecureTls ? { insecureTls: true } : {}),
1072
- ...(config.remoteExec.namespace ? { namespace: config.remoteExec.namespace } : {}),
1073
- ...(config.remoteExec.runtimeClass != null ? { runtimeClass: config.remoteExec.runtimeClass } : {}),
1074
- ...(config.remoteExec.mountPath ? { mountPath: config.remoteExec.mountPath } : {}),
1075
- ...(config.remoteExec.timeoutMs != null ? { timeoutMs: config.remoteExec.timeoutMs } : {}),
1076
- // Resource-profile knobs. config.ts reads K8S_MEMORY/K8S_CPU; this passthrough was
1077
- // missing, so the adapter silently stayed on its 2Gi default (Kata VM = default_memory 2G + limit 2G = 4G
1078
- // MemTotal — task02b run9/10 integration gradle OOM'd there even after the env was set).
1079
- ...(config.remoteExec.memory ? { memory: config.remoteExec.memory } : {}),
1080
- ...(config.remoteExec.cpu ? { cpu: config.remoteExec.cpu } : {}),
1081
- ...(config.remoteExec.s3Snapshot ? { s3Snapshot: config.remoteExec.s3Snapshot } : {}),
1082
- // RFC B5: region package-source env set → pod container env (operator-trusted, never task-controlled).
1083
- // ENV-FIRST: pod env beats image ENV and reaches every exec — the derived ecosystem overrides switch
1084
- // region on existing images; the SEMA_PKG_SOURCE marker drives the in-image hook (file-bound pieces).
1085
- ...(() => {
1086
- const derived = derivePkgSourceEnv(config.sandboxPkgSource, customPkgSourceFromEnv());
1087
- return Object.keys(derived).length > 0 ? { podEnv: derived } : {};
1088
- })(),
1089
- };
1090
- // §7 P0.5: per-pod image override. resolveSpec resolved the requested profile→digest (with the caller's
1091
- // principal, fail-closed) and registered it by sessionId — the ONLY identifier stable across the factory ctx
1092
- // on every path (adversarial-review round-2: /v1/runs mints its own durable taskId that CLOBBERS spec.taskId,
1093
- // so taskId-keying misses on the primary path; sessionId survives). get() is non-removing (the factory may be
1094
- // invoked >once per logical task). Absent ⇒ the worker-global k8sCfg.image. Building the factory per task is a
1095
- // cheap closure alloc; the ref is immutable (repo@digest). podSpecPatch can NOT carry the image (hard invariant).
1096
- executionEnvFactory = (ctx) => k8sExecutionEnvFactory({ ...k8sCfg, image: perTaskImage.get(ctx.sessionId) ?? k8sCfg.image })(ctx);
1097
- }
1098
- else if (config.remoteExec?.provider === "ssh") {
1099
- executionEnvFactory = sshExecutionEnvFactory({
1100
- host: config.remoteExec.host,
1101
- username: config.remoteExec.username,
1102
- privateKey: config.remoteExec.privateKey,
1103
- ...(config.remoteExec.port != null ? { port: config.remoteExec.port } : {}),
1104
- ...(config.remoteExec.mountPath ? { mountPath: config.remoteExec.mountPath } : {}),
1105
- });
1106
- }
1107
- else if (config.remoteExec?.provider === "adb") {
1108
- executionEnvFactory = adbExecutionEnvFactory({
1109
- serial: config.remoteExec.serial,
1110
- ...(config.remoteExec.adbPath ? { adbPath: config.remoteExec.adbPath } : {}),
1111
- ...(config.remoteExec.mountPath ? { mountPath: config.remoteExec.mountPath } : {}),
1112
- });
1113
- }
1114
- else if (config.remoteExec?.provider === "host") {
1115
- // DUAL-MODE §5: the TOC `host` lane — run on THIS machine, no container (isolation=none). Fan-out still
1116
- // works (bounded by one box). Secrets are env-NAMEs the host resolves from its own process.env.
1117
- {
1118
- // R7 self-wake: inject the self-wake SchedulerCapability backend on the single-user TOC host lane
1119
- // (opt-in SCHEDULER_ENABLED). `hasScheduler(env)` then mounts CronCreate/CronDelete/CronList(旧名 CronCancel、Sleep 已 design/136 撤除——L8 注释订正,且挂载在 core prepare-task 非 createHandsToolkit); the TOC shell
1120
- // daemon reads the SAME ~/.sema/scheduled_tasks.json store (registry-core /node binding) and fires due intents.
1121
- // Multi-tenant (requirePrincipal) routes scheduling to center, NOT this host daemon → gate it off there. ONE
1122
- // backend instance at boot → every host task shares the same store file.
1123
- const hostScheduler = config.schedulerEnabled && config.requirePrincipal !== true
1124
- ? new FileSchedulerBackend({
1125
- ...(config.schedulerStorePath ? { storePath: config.schedulerStorePath } : {}),
1126
- // [1009]② host-signal: the spawning shell knows whether a resident daemon will honor session
1127
- // wakeups — SCHEDULER_SESSION_WAKEUP=false flips the capability off at CONSTRUCTION (instance-
1128
- // lifetime snapshot, core TOCTOU 契约) so core's ScheduleWakeup refuses up front.
1129
- ...(config.schedulerSessionWakeup === false ? { caps: { supportsSessionWakeup: false } } : {}),
1130
- })
1131
- : undefined;
1132
- if (hostScheduler)
1133
- logger.info(`R7: self-wake scheduler enabled (host lane), store=${config.schedulerStorePath ?? defaultSchedulerStorePath()}`);
1134
- const hostCfg = {
1135
- ...(config.remoteExec.workspaceBase ? { workspaceBase: config.remoteExec.workspaceBase } : {}),
1136
- ...(config.remoteExec.commandTimeoutMs != null ? { commandTimeoutMs: config.remoteExec.commandTimeoutMs } : {}),
1137
- ...(hostScheduler ? { scheduler: hostScheduler } : {}),
1138
- // design/103 background shell: single-user host lane only (parity with scheduler/cwd). Multi-tenant host
1139
- // lane (which shouldn't exist — host runs on the worker's own box) → INERT (run_in_background/etc. don't mount).
1140
- backgroundShell: config.requirePrincipal !== true,
1141
- };
1142
- // [#40 / TOC cwd seam] per-request workspace: if resolveSpec registered a caller `cwd` for this session (gated by
1143
- // cwdHonored — single-user host lane only), run the agent VERBATIM in that dir (persistent, never deleted — the
1144
- // user's project). Else the boot-time host config (ephemeral random subdir). Mirrors the k8s perTaskImage wrap.
1145
- // core 1.219 `ctx.parentCwd` (dogfood finding: sub-agents landed in an EMPTY sandbox): a DELEGATED child
1146
- // (workflow ctx.agent / Task tool) now roots at its PARENT's working dir — CC parity — via core's TRUSTED
1147
- // RunInternals channel. Precedence in effectiveHostWorkspace: session cwd → parentCwd (unless the child asked
1148
- // for isolation:"worktree" — that wrapper wins below) → ephemeral. Container lanes never consult this.
1149
- executionEnvFactory = (ctx) => {
1150
- const cwd = effectiveHostWorkspace(perSessionCwd.get(ctx.sessionId), ctx);
1151
- const shellEnv = perSessionShellEnv.get(ctx.sessionId);
1152
- const env = hostExecutionEnvFactory({
1153
- ...hostCfg,
1154
- ...(cwd ? { workspaceDir: cwd } : {}),
1155
- ...(shellEnv ? { env: shellEnv } : {}), // R-survey: per-task shell env (merged UNDER per-command options.env by the host adapter)
1156
- })(ctx);
1157
- // core 1.191: an EPHEMERAL host workspace (no honored `cwd` → a per-task `sema-host-<id>` dir destroy()
1158
- // rm's) evicts its LSP servers at task-end so a busy server doesn't accumulate one heavy language server per
1159
- // task. cwdHonored (persistent project) is NOT evicted — its stable root stays warm across turns (CC-parity);
1160
- // worktree/fan-out is bounded by maxSessions=16. Only wraps when a NodeLspManager is actually active.
1161
- return lspManager && !cwd ? evictLspOnDestroy(env, lspManager) : env;
1162
- };
1163
- }
1164
- }
1165
- else if (config.remoteExec?.provider === "local-docker") {
1166
- // DUAL-MODE §5: the TOC `local-docker` lane — a per-task container on THIS machine's docker daemon
1167
- // (isolation:true, suspendable:false). The base image comes ENTIRELY from config (no docker.io default —
1168
- // domestic-images iron rule). Secrets are resolved env-NAME→value here (config.ts already did the forward).
1169
- executionEnvFactory = localDockerExecutionEnvFactory({
1170
- image: config.remoteExec.image,
1171
- ...(config.remoteExec.mountPath ? { mountPath: config.remoteExec.mountPath } : {}),
1172
- ...(config.remoteExec.dockerPath ? { dockerPath: config.remoteExec.dockerPath } : {}),
1173
- ...(config.remoteExec.dockerHost ? { dockerHost: config.remoteExec.dockerHost } : {}),
1174
- ...(config.remoteExec.memory ? { memory: config.remoteExec.memory } : {}),
1175
- ...(config.remoteExec.cpus != null ? { cpus: config.remoteExec.cpus } : {}),
1176
- ...(config.remoteExec.network ? { network: config.remoteExec.network } : {}),
1177
- ...(config.remoteExec.commandTimeoutMs != null ? { commandTimeoutMs: config.remoteExec.commandTimeoutMs } : {}),
1178
- ...(config.remoteExec.env ? { env: config.remoteExec.env } : {}),
1179
- });
1180
- }
1181
- // [848] remote scratchpad: decorate the REMOTE sandbox lanes (e2b/k8s/local-docker/ssh) so each env
1182
- // lazily `mkdir -p`s `/tmp/scratchpad/<sessionId>` on its FIRST exec — never at factory time (e2b is
1183
- // lazy-VM; a factory-time exec would force-boot the VM). adb is skipped (no standard /tmp on Android);
1184
- // host keeps its worker-local scratchpad (ensureScratchpadDir at the envFacts consumer). The envFacts
1185
- // advertisement below uses the SAME remoteScratchpadDirFor rule, so fact and mkdir can never disagree.
1186
- if (executionEnvFactory && isRemoteScratchpadLane(config.remoteExec?.provider)) {
1187
- executionEnvFactory = withRemoteScratchpad(executionEnvFactory, logger);
1188
- }
1189
- // SVC-3 (design/97 CORE-6): wrap the factory with git-worktree isolation. When core marks an agent
1190
- // `isolation:"worktree"` (TRUSTED RunInternals — never a TaskSpec), the wrapper mints a per-agent detached
1191
- // worktree under the operator-trusted repoRoot; otherwise it passes the base env through untouched. v1
1192
- // covers the `host` lane (the TOC fan-out lane). Other lanes would need a worktree-ROOTED REMOTE env from
1193
- // `rootEnvAt` (a follow-on; a host/NodeExecutionEnv worktree is not durable-suspendable — see the plugin
1194
- // header) — they are left unwrapped here, isolation:"worktree" is then a no-op for them (the base env wins).
1195
- if (config.worktreeIsolation && executionEnvFactory) {
1196
- // M4 (adversarial-review): a mis-wired WORKTREE_REPO_ROOT must fail LOUD at boot, not per-task at runtime. The
1197
- // root must exist AND be a git repo (`.git` dir or worktree-link file). On a bad root, log an error + LEAVE
1198
- // isolation OFF (the factory unwrapped) rather than wiring a factory that throws on every isolated agent.
1199
- const wtRoot = config.worktreeIsolation.repoRoot;
1200
- const wtValid = existsSync(wtRoot) && existsSync(join(wtRoot, ".git"));
1201
- if (config.remoteExec?.provider === "host" && !wtValid) {
1202
- logger.error("worktree_isolation_disabled_bad_repo_root", {
1203
- repoRoot: wtRoot,
1204
- reason: existsSync(wtRoot) ? "not a git repository (no .git)" : "path does not exist",
1205
- note: "WORKTREE_ISOLATION_ENABLED is set but WORKTREE_REPO_ROOT is not a usable git repo — isolation left OFF; isolation:'worktree' agents run on the shared base env. Fix the path or unset the flag.",
1206
- });
1207
- }
1208
- else if (config.remoteExec?.provider === "host") {
1209
- // The SHARED base env that runs `git worktree add/remove/prune` (cwd:repoRoot is passed explicitly by
1210
- // core on every git call, so this env's own root is irrelevant — it just needs `exec` + git on PATH).
1211
- // ONE long-lived env per deployment; the reaper reuses it. `inheritEnv:"all"` so the operator's git
1212
- // config/credentials are visible (this is the operator's own machine + own repo, the TOC posture).
1213
- // systematic-audit (gate-fail-direction): every worktree-lane RemoteHostExecutionEnv carries the SAME
1214
- // background-shell gate as the primary host factory (main.ts ~442) — not the env's `?? true` default. The
1215
- // git-base env is git-ops only (agents run on rootEnvAt below), but gating it keeps the policy uniform / future-proof.
1216
- const gitBaseEnv = new RemoteHostExecutionEnv({ workspaceDir: config.worktreeIsolation.repoRoot, inheritEnv: "all", backgroundShell: config.requirePrincipal !== true });
1217
- executionEnvFactory = withWorktreeIsolation(executionEnvFactory, {
1218
- repoRoot: config.worktreeIsolation.repoRoot,
1219
- ...(config.worktreeIsolation.allowedRoots ? { allowedRoots: config.worktreeIsolation.allowedRoots } : {}),
1220
- ...(config.worktreeIsolation.commit ? { commit: config.worktreeIsolation.commit } : {}),
1221
- baseEnvForGit: gitBaseEnv,
1222
- // Host-lane worktree-rooted env: a host adapter in PERSISTENT-DIR mode — its own destroy() does NOT
1223
- // rm the dir (so core's `git worktree remove` owns teardown), and it keeps the host adapter's secret
1224
- // scrub + timeout semantics (vs a bare NodeExecutionEnv). inheritEnv defaults to "scrub" (model-driven).
1225
- rootEnvAt: (dir) => {
1226
- const env = new RemoteHostExecutionEnv({ workspaceDir: dir, backgroundShell: config.requirePrincipal !== true });
1227
- // A worktree root is per-agent + ephemeral (core's `git worktree remove` reclaims it at
1228
- // task-end), so evict its LSP servers DETERMINISTICALLY too — not just via the maxSessions LRU backstop. The
1229
- // worktree dir IS the LSP cache root (env.cwd), so evict(env.cwd) on destroy hits it. (`lspManager` is declared
1230
- // below but this closure only runs per-task, well after it's initialized — same forward-ref as the host factory.)
1231
- return lspManager ? evictLspOnDestroy(env, lspManager) : env;
1232
- },
1233
- logger,
1234
- });
1235
- worktreeReap = () => reapOrphanWorktrees(gitBaseEnv, config.worktreeIsolation.repoRoot, logger);
1236
- logger.info("worktree_isolation_enabled", { provider: "host", repoRoot: config.worktreeIsolation.repoRoot });
1237
- // BOOT recovery sweep: reap any worktrees orphaned by a crash BEFORE this process started (the periodic
1238
- // reaper would otherwise wait a full interval). Best-effort, fire-and-forget; never blocks boot.
1239
- void worktreeReap();
1240
- }
1241
- else {
1242
- // Not a no-op-silent: an operator who set WORKTREE_ISOLATION_ENABLED on a non-host lane should know it
1243
- // doesn't take effect yet (the worktree-rooted REMOTE env is a follow-on). Fail-loud-ish via a warning.
1244
- logger.warn("worktree_isolation_unsupported_lane", {
1245
- provider: config.remoteExec?.provider,
1246
- note: "git-worktree isolation v1 covers only the 'host' lane — isolation:'worktree' is a no-op here (the base env is used). Worktree-rooted remote envs are a follow-on.",
1247
- });
1248
- }
1249
- }
1250
- // SendUserFile v2(clay 拍 2026-07-14,Plan B 直传):沙箱 lane 的文件源需要「工具执行时拿到当前
1251
- // 任务的 env」——core 把 per-task env 关在 prepare-task 闭包里(ToolExecuteContext 无 env 面),但
1252
- // 工厂是我们装配的、工厂 ctx 自带 taskId,包一层登记 taskId→env(destroy 时注销)即可,零 core 改动
1253
- // (withWorktreeIsolation 同款包装先例)。lane 门+租户门=sandboxSendLaneEnabled(判定依据在其 doc):
1254
- // e2b/k8s=一任务一沙箱(沙箱文件系统=租户边界)任意租户可用;ssh(后补 2026-07-14)=同一 exec 契约
1255
- // (timeout 秒×1000/Result shape 同形)直传链零适配,但 peer=一台跨任务共享的常驻真实主机
1256
- // (sshExecutionEnvFactory 忽略 ctx、destroy=断连不删文件、isolation:false)→「沙箱 scoped 读=租户
1257
- // 隔离」不成立,与 host 同门单用户 only(requirePrincipal!==true 恒关多租户,不留口子)。
1258
- // 其余 lane 不包(零开销);签发面未配同样不包。
1259
- const sendUserFileTaskEnvs = (() => {
1260
- const provider = config.remoteExec?.provider;
1261
- if (!config.sendUserFile || !executionEnvFactory || !sandboxSendLaneEnabled(provider, config.requirePrincipal))
1262
- return undefined;
1263
- const registry = new TaskEnvRegistry();
1264
- executionEnvFactory = registry.wrapFactory(executionEnvFactory);
1265
- return registry;
1266
- })();
1267
- // D-1 附件物化包装(半场③):env 每次建立(fresh + resume/重建沙箱)都把该 session 绑定的附件写进
1268
- // 工作目录 `attachments/`。全 lane 统一(host/e2b/k8s/ssh/adb 都走 factory;ExecutionEnv.writeFile
1269
- // 双向通用);路径与 objective 告知共用 materializedRelPaths(确定性,两处不漂)。**fail-loud**:
1270
- // objective 已向模型宣告文件在场,静默缺文件=模型按幻影文件行动,比任务失败更糟 ⇒ 写失败即抛
1271
- // (env 建立失败,任务带明确错误)。链尾最外层:worktree 隔离等内层先定 cwd,附件落在最终 cwd。
1272
- if (taskAttachmentStore && executionEnvFactory) {
1273
- const inner = executionEnvFactory;
1274
- const attStore = taskAttachmentStore;
1275
- executionEnvFactory = async (ctx) => {
1276
- const env = await inner(ctx);
1277
- const n = await materializeAttachmentsInto(env, attStore, ctx.sessionId);
1278
- if (n > 0)
1279
- logger.info("attachments_materialized", { sessionId: ctx.sessionId, count: n });
1280
- return env;
1281
- };
1282
- }
1283
- if (executionEnvFactory) {
1284
- // k8s (Kata) is isolated; it becomes WORKSPACE-suspendable when an S3 snapshot store is configured
1285
- // (tar→S3→fresh pod restore — files durable, in-VM memory not); e2b is fully suspendable.
1286
- const isolated = config.remoteExec.provider === "e2b" ||
1287
- config.remoteExec.provider === "k8s" ||
1288
- config.remoteExec.provider === "local-docker"; // a container is a real OS-level isolation boundary
1289
- const suspendable = config.remoteExec.provider === "e2b" ||
1290
- (config.remoteExec.provider === "k8s" && !!config.remoteExec.s3Snapshot);
1291
- // Out-of-band sandbox env (e2b): log the KEY NAMES only (never the secret values) so ops can confirm
1292
- // injection is active without leaking the credential.
1293
- const sandboxEnvKeys = config.remoteExec.provider === "e2b" ? Object.keys(config.remoteExec.sandboxEnv ?? {}) : [];
1294
- logger.info("remote_exec_enabled", { provider: config.remoteExec.provider, isolated, suspendable, ...(sandboxEnvKeys.length > 0 ? { sandboxEnvKeys } : {}) });
1295
- if (!isolated)
1296
- logger.warn("remote_exec_real_system", { provider: config.remoteExec.provider, note: "non-isolated target — actions are permanent; rely on the policy gate + HITL (design/61 §5)" });
1297
- }
1298
- // LSP sidecar (design/64 §13.1, the 1.86.2 seam): ONE stateless manager on RunnerDeps — core passes each
1299
- // task's env into sessionFor at the tool mount point, so the manager reaches the SAME sandbox the agent edits
1300
- // (no sessionId registry / per-task construction). Opt-in (LSP_ENABLED — the SANDBOX lane's knob; the host lane
1301
- // below has its own, LSP_HOST_ENABLED, because its default is the opposite); with the baked `sema-code-lsp`
1302
- // template the first call is fast, without it the language server installs on first use (slow once per sandbox).
1303
- // k8s lane (gate#2 alignment): same manager/bridge over the pod network — `ws://podIP:port` instead of E2B's
1304
- // public wss proxy, so it needs an IN-CLUSTER worker and a sandbox image with node + the language servers baked
1305
- // (no on-the-fly npm path on Kata; a miss degrades the lsp tool gracefully, same as E2B).
1306
- const lspProvider = config.remoteExec?.provider;
1307
- const lspManager = (lspProvider === "e2b" || lspProvider === "k8s") && config.lspEnabled
1308
- ? createE2bLspManager({ log: (event, fields) => logger.info(event, fields), scheme: lspProvider === "k8s" ? "ws" : "wss" })
1309
- : // TOC local LSP (core 1.190): the host lane runs on THIS machine, so core's `NodeLspManager` spawns the
1310
- // language server as a LOCAL child_process over stdio (CC `services/lsp` parity). Its default resolveRoot uses
1311
- // `env.cwd` — and core passes each task's executionEnv (the per-agent WORKTREE env, so the server roots in the
1312
- // worktree not the base repo) into sessionFor. host ONLY: ssh/adb target another host/device and local-docker a
1313
- // container, none of which a local child_process can reach (those keep the E2B-style bridge / stay degraded).
1314
- // 🔒 `requirePrincipal !== true` gate = uniform with the sibling host powers (scheduler/backgroundShell/
1315
- // loadProjectMemory): the host lane is single-user by design; a (discouraged) multi-tenant host fails SAFE to
1316
- // grep/read rather than fanning a per-tenant×language×root pool of heavy language servers (double-review B).
1317
- lspProvider === "host" && config.lspHostEnabled && config.requirePrincipal !== true
1318
- ? new NodeLspManager({ log: (event, fields) => logger.info(event, fields) })
1319
- : undefined;
1320
- if (lspManager)
1321
- logger.info("lsp_enabled", { provider: lspProvider });
132
+ // design/158 A10 尾刀:config-center 的 boot 半场(sealed-key 托管 provider prompt-epoch
133
+ // effective 拉取 / LKG 兜底 / tolerant apply)搬到 src/boot/config-center.ts。该段与 refresh 循环共享的
134
+ // 12 个跨段可变 `let` 现在是那只工厂的闭包私有字段(每一处读写的文本与次序逐字保留,见该文件头注);
135
+ // main 只经窄口读:getKeyResolver/getCenterPrompts/modelReady/restartState/planeDeferredState/promptSource。
136
+ // ⚠️ 位置即契约:必须在 openStores 之前 —— models/roles/teams 要在 Runner/持久层建起来之前应用进 config。
137
+ const configCenter = await createConfigCenterRuntime({ config, logger, metrics, localRoot });
138
+ // design/158 A10 尾刀:local 形的 config.d/remote-exec.json 覆写搬到 boot/config-center.ts(逐字)。
139
+ // ⚠️ 位置即契约:就地改写 config.remoteExec,必须早于 createExecutionEnv 这个消费点。
140
+ await configCenter.applyLocalRemoteExec();
141
+ // design/158 A10:持久层装配搬到 src/boot/stores.ts(逐字)。⚠️ 该段就地归一 `config.sessionBackend`
142
+ // 且承载三条 fail-loud 拒启断言 —— 位置即契约,理由见该文件头注。
143
+ const { backend, storeBackendDegraded, memoryEngine, memorySyncCursors, rosterStore, backgroundAgentStore, taskAttachmentStore, mailboxStore, memoryExportBackend, memorySyncRunner, sessionStore, breakerState, } = await openStores({ config, logger, metrics, localRoot });
144
+ // design/158 A10:计费/追踪/预算装配搬到 src/boot/budget-tracing.ts(逐字;tracer 与 side-query 同 sink 实例的
145
+ // 「同段构造」契约见该文件头注)。
146
+ const { brain, pricing, counterDegradeHook, costQuota, modelUsageTracker, promptManifestTracker, fleetUsage, fleetLease, tracer, sideQueryAccounting, toolResultStore, sessionPolicyStore, fileSnapshotStore, } = createBudgetAndTracing({ config, logger, metrics, backend, breakerState });
147
+ // design/158 A10:执行环境装配搬到 src/boot/execution-env.ts(逐字)
148
+ // ⚠️ 工厂装饰顺序=行为(scratchpad → worktree → SendUserFile 登记 → 附件物化最外层),见该文件头注。
149
+ const { perTaskImage, sessionEnvSelection, perSessionCwd, setSessionCwd, setSessionShellEnv, executionEnvFactory, worktreeReap, sendUserFileTaskEnvs, lspManager, } = createExecutionEnv({ config, logger, metrics, taskAttachmentStore });
1322
150
  // prove-before-scale stopgap: a no-op tool tracer (logs every tool call incl. resumed legs that
1323
151
  // /events hides) so we can diagnose single-agent coding behaviour. Off unless TOOL_TRACE=true.
1324
152
  const toolTracer = config.toolTrace ? createToolTracer(logger) : undefined;
@@ -1330,564 +158,22 @@ async function main() {
1330
158
  // void permissionDenied slot); this composite is what every hooks site below uses as the deployment
1331
159
  // baseline, so a task-hooks fold (resolveSpec) shadows neither.
1332
160
  const deploymentHooks = toolTracer ? composeHooks(createPermissionDeniedMeter(metrics), toolTracer) : createPermissionDeniedMeter(metrics);
1333
- // S8 / SVC-1 workflow run store (design/97 S1b): records LLM-authored BACKGROUND workflow
1334
- // runs for the /v1/workflows list+detail view (live subscribe is in-process via subscribeWorkflow,
1335
- // store-independent). Only present when self-orchestration is enabled (else no runs to record).
1336
- //
1337
- // SVC-1: the DEFAULT is now `FileWorkflowRunStore` (durable, crash-safe ledger) NOT InMemory so a
1338
- // background workflow's record SURVIVES a replica restart, which is the prerequisite for the at-least-once
1339
- // completion notify below to re-derive a run's terminal state after a crash. InMemory stays available only for
1340
- // an explicit ephemeral opt-out (WORKFLOW_RUN_STORE=memory); a durable cross-replica TiDB/PG WorkflowRunStore
1341
- // is a clean follow-on port (the workflowRunStoreContract makes it a drop-in like the run-store).
1342
- // P1 (fleet failover, 2026-07-05): `auto` (the default) prefers the SQL backend's cross-replica twin when
1343
- // present a failover-landed session sees the run's history + its pending completion push on ANY replica.
1344
- // Explicit WORKFLOW_RUN_STORE=file/memory still wins (single-box File posture unchanged: local backend has
1345
- // no workflowRun() so auto falls to File there).
1346
- const sqlWorkflowRunStore = config.workflowRunStoreBackend === "auto" ? backend?.workflowRun?.() : undefined;
1347
- const baseWorkflowRunStore = config.selfOrchestrationEnabled
1348
- ? config.workflowRunStoreBackend === "memory"
1349
- ? new InMemoryWorkflowRunStore()
1350
- : (sqlWorkflowRunStore ?? new FileWorkflowRunStore(join(config.localDataRoot ?? localRoot, "workflows")))
1351
- : undefined;
1352
- // SVC-1 at-least-once completion-notify TRUST GATE (the half that is service's, [[core-service-boundary]]):
1353
- // core's `run_workflow` fires an in-process at-MOST-once notify on terminal — a crash between terminal and the
1354
- // receiver loses it. The gate makes it at-LEAST-once: a durable notify-journal records every STARTED run
1355
- // (observed via the JournalingWorkflowRunStore decorator's `put`), the LIVE notify path delivers-then-acks, and
1356
- // a BOOT recovery sweep re-derives every un-acked run's terminal state from the run store + re-delivers (the
1357
- // receiver is idempotent on runId). The default journal is the zero-dependency crash-safe File ledger; with no
1358
- // run store (self-orchestration off) there's nothing to journal, so the gate is absent.
1359
- // 1.108 review fix (lens③ HIGH): the journal FOLLOWS the run-store axis like the inbox — a SQL run store +
1360
- // inbox with a replica-LOCAL File journal meant a replica that died holding an un-acked notify stranded it
1361
- // forever (no surviving replica could recover it: at-least-once silently degraded to at-most-once across
1362
- // replica death — the exact fleet-failover gap P1 exists to close).
1363
- const workflowNotifyJournal = config.selfOrchestrationEnabled && config.workflowRunStoreBackend !== "memory"
1364
- ? (sqlWorkflowRunStore ? backend.notifyJournal() : new FileWorkflowNotifyJournalStore(join(config.localDataRoot ?? localRoot, "workflows")))
1365
- : undefined;
1366
- // P1 ①②: the async-workflow COMPLETION INBOX — the push half. A finished background
1367
- // workflow's completion is enqueued here keyed by the ORIGINATING session, and drained + emitted as a
1368
- // `workflow_complete` out-of-band SSE frame when that session next opens a stream (see server.ts). File-backed
1369
- // (crash-safe) when self-orchestration is on with a durable store; the ephemeral opt-out uses in-memory.
1370
- // P1: the inbox FOLLOWS the run-store choice (one axis, no split-brain: a SQL run record with a File inbox
1371
- // would re-open the cross-replica double-push the fence rows exist to close).
1372
- const workflowCompletionInbox = config.selfOrchestrationEnabled
1373
- ? config.workflowRunStoreBackend === "memory"
1374
- ? new InMemoryWorkflowCompletionInbox((msg, meta) => logger.warn(msg, meta))
1375
- : (sqlWorkflowRunStore ? backend.completionInbox((msg, meta) => logger.warn(msg, meta)) : new FileWorkflowCompletionInbox(join(config.localDataRoot ?? localRoot, "workflows"), (msg, meta) => logger.warn(msg, meta)))
1376
- : undefined;
1377
- // The deployment's REAL completion delivery: log + meter, THEN push into the originating session's inbox so the
1378
- // model that launched the workflow learns it finished (the `WorkflowStatus` poll is the deterministic floor;
1379
- // this is the proactive push). The gate guards this so it fires at-least-once + at-most-once-per-run-steady.
1380
- // `runStore` (below) is resolved at CALL time (post-boot) — a forward reference into the same boot scope.
1381
- const deliverWorkflowCompletion = async (p) => {
1382
- logger.info("workflow_completed", {
1383
- runId: p.runId,
1384
- status: p.status,
1385
- ...(p.sourceTaskId ? { sourceTaskId: p.sourceTaskId } : {}),
1386
- });
1387
- metrics.inc("workflow_runs_total", { status: p.status });
1388
- // Routing (core 1.208): session = payload's `originatingSessionId` (lookup-free); owner = run-row
1389
- // owner → payload `principal` (= VERIFIED spec.principal, the 1.55 F-fix invariant — covers the sync/resume
1390
- // legs whose sourceTaskId is a sessionId and misses getRun) → multi-tenant fail-closed /
1391
- // single-user null. Full rationale on `resolveCompletionRoute` (tested there).
1392
- if (!workflowCompletionInbox)
1393
- return;
1394
- try {
1395
- const route = await resolveCompletionRoute(p, runStore ? (id) => runStore.getRun(id) : undefined, config.requirePrincipal === true);
1396
- if (!route)
1397
- return; // no route / unverifiable owner → the WorkflowStatus poll floor covers it
1398
- // `p.summary` is HUMAN-READABLE at the source since core 1.232 (completed lane = bounded
1399
- // one-liner with name/elapsed/agents + a TaskOutput pointer; failed lane = the bounded redacted error)
1400
- // — pass through verbatim, single-sourced (the 1.101.0 service-side re-wrap was superseded by core's
1401
- // own half and removed to avoid two drifting formats).
1402
- await workflowCompletionInbox.enqueue({
1403
- sessionId: route.sessionId,
1404
- owner: route.owner,
1405
- runId: p.runId,
1406
- status: p.status,
1407
- summary: p.summary,
1408
- enqueuedAt: Date.now(),
1409
- });
1410
- }
1411
- catch (err) {
1412
- logger.warn("workflow_completion_enqueue_failed", { runId: p.runId, err: String(err) });
1413
- }
1414
- };
1415
- const workflowNotifyGate = baseWorkflowRunStore && workflowNotifyJournal
1416
- ? new WorkflowNotifyGate(workflowNotifyJournal, baseWorkflowRunStore, deliverWorkflowCompletion, {
1417
- onError: (stage, runId, err) => logger.warn("workflow_notify_failed", { stage, runId, err: String(err) }),
1418
- })
1419
- : undefined;
1420
- // MF-Fleet (data contract): the in-process fleet aggregation bus backing GET /v1/fleet/stream. Created HERE
1421
- // (before the workflow run store) so the JournalingWorkflowRunStore can publish workflow rows to it. Always
1422
- // present (lightweight); the run (POST /v1/runs) + workflow lifecycle publish per-row deltas. Replica-local.
1423
- const fleetBus = new FleetEventBus();
1424
- // The store handed to core (+ the /v1/workflows reader): ALWAYS wrapped when a run store exists ([1262]
1425
- // fleet decoupling) — the wrap publishes the workflow's MF-Fleet row on each put/update (the write-observation
1426
- // point the shell's /workflows panel lives on), and ADDITIONALLY journals the start-time `put` when the notify
1427
- // gate is active (durable backends). The old `baseWorkflowRunStore && workflowNotifyGate` guard silently
1428
- // dropped the FLEET half on the memory backend (gate needs a durable journal; fleet needs neither) — clay's
1429
- // workflow panel went empty on exactly that shape.
1430
- const workflowRunStore = baseWorkflowRunStore
1431
- ? new JournalingWorkflowRunStore(baseWorkflowRunStore, workflowNotifyGate, fleetBus)
1432
- : undefined;
1433
- // SVC-2 (core CORE-7/CORE-9 Part A): the durable resume journal. core 1.145.0 added `RunnerDeps.workflowJournalStore`
1434
- // (twin of workflowRunStore) + auto-wires it into the run_workflow tool's startWorkflow(RunWorkflowOptions.journalStore);
1435
- // the tool's `resumeFromRunId` input then replays the longest unchanged prefix. tidb/pg = durable cross-replica resume;
1436
- // local = core's InMemory (single-process — in-process resume only, not restart-durable). Only when self-orchestration
1437
- // is on AND a backend is present (the load-bearing journal needs a real store).
1438
- const workflowJournalStore = config.selfOrchestrationEnabled && backend ? backend.workflowJournal() : undefined;
1439
- // design/73 §1 consumption sink (see RunnerDeps.onTaskOutcome below). Present whenever a StoreBackend is —
1440
- // NOT gated on self-orchestration (goal-mode/harness emissions are orthogonal to workflows).
1441
- const outcomeSink = backend?.outcomeSink();
1442
- // SVC-5 (CORE-9 Part B): the process-local registry of STEERABLE workflow-agent handles live on THIS replica,
1443
- // backing POST /v1/workflows/:id/agents/:label/steer. core 1.145.0 added `RunnerDeps.onWorkflowAgentSpawn` (the
1444
- // opt-in deployment handle-sink): when set, the run_workflow tool's `agent()` runs steerable + emits the handle
1445
- // HERE (the handle is a core object handed only to the trusted deployment sink — it NEVER enters the script/runner,
1446
- // so the script-sandbox host-context membrane stays closed). We register by runId+label; the steer route looks it up + calls
1447
- // handle.steer (core fences via CORE-5) behind our steer-in redaction + owner gate.
1448
- const workflowAgentRegistry = config.selfOrchestrationEnabled ? new WorkflowAgentRegistry() : undefined;
1449
- // C2 (core 1.219): the replica-local registry of STEERABLE Task-subagent handles, backing
1450
- // POST /v1/runs/:runId/subagents/:target/steer. Core emits a handle to the per-run `onSubagentSpawn` sink
1451
- // (wired in each run leg) the moment a SYNC delegation spawns; the handle never reaches the model. Always
1452
- // present (lightweight Map) — the sink is only threaded where a durable runId exists to address it by.
1453
- const subagentSteerRegistry = new SubagentSteerRegistry();
1454
- // E23 (shell-host contract): inbound MCP elicitation coordinator (live-only HITL). Present ONLY when MCP_ELICITATION_ENABLED
1455
- // — absent ⇒ onElicit is not wired ⇒ core advertises no elicitation capability to any server (fail-closed). Shared
1456
- // by the runner (the onElicit seam) and the HTTP layer (the respond route + the per-run ALS context wraps).
1457
- const elicitation = config.mcpElicitation.enabled ? new ElicitationCoordinator(config.mcpElicitation.throttle) : undefined;
1458
- // §4④: AskUserQuestion LIVE-stream HITL coordinator — the sibling of `elicitation`. Present ⇒ wired onto
1459
- // `RunnerDeps.onQuestion` (core mounts the AskUserQuestion tool + routes live asks here) AND consumed by the HTTP layer
1460
- // (the `POST /v1/questions/:id/respond` route + the per-run ALS context wraps on the streaming legs). Absent ⇒ core
1461
- // mounts the tool with the headless default (a run never hangs; the model just can't get a live answer).
1462
- const question = config.askQuestionEnabled ? new QuestionCoordinator() : undefined;
1463
- // [816]/[820]②: the live tool-approval HITL coordinator — the sibling of `question` on core's `RunnerDeps.onAsk`
1464
- // seam (1.290 sync-ask leg). Present ⇒ a policy `ask` on a LIVE streaming leg becomes a `tool_approval` frame the
1465
- // shell renders as the CC three-choice card, answered via POST /v1/tool-approvals/:id/respond.
1466
- // [1535] 正名(旧文「background/workflow ⇒ headless auto-deny stands」已过时):宿主 sync 腿现同时装
1467
- // `spec.onAsk = boundAsk(ctx)`(server.ts 装配点)——core 继承链把闭包冻给委派子代,**bg/嵌套子代的
1468
- // ask 浮到宿主 live 流**(带 sourceTaskId);真正无宿主流的腿(durable-submit/headless resume)才留
1469
- // 「unavailable → durable park;无 park 设施 core fail-closed deny」。durable 部署 G1 语义不变。
1470
- const toolApproval = config.toolApprovalEnabled ? new ToolApprovalCoordinator() : undefined;
1471
- // [1.294 G1] durable 姿势必须在 runnerDeps 字面量之前可判(onAsk 的「在场性」本身就是 core suspendAsk 的
1472
- // 分路条件,不能再靠闭包惰性读)——判据与 checkpointStore 的构造条件同源(backend?.checkpoint 存在 ∧
1473
- // DURABLE_APPROVAL;store 本体已上移至 subRunner 构造前,[1535] 断点② 双 Runner 挂载)。
1474
- const durableEnabled = backend?.checkpoint !== undefined && config.durableApproval === true;
1475
- // SendUserFile(真 CC 契约,clay 2026-07-14)两种 lane 形态,其余缺席=诚实(工具不进 roster):
1476
- // - host lane:本地盘读+服务端上传,【单用户 only】——host 多租户=任意路径读→公网 URL 的 exfil 面,
1477
- // clay 拍永久关死,不留口子。路径=绝对或相对 process.cwd(用户目录语义,与 CC 一致)。
1478
- // - e2b/k8s lane(v2,Plan B 直传):沙箱内 stat+`curl -T` 打 server 预签的单对象 PUT(字节不中转、
1479
- // 凭据不进沙箱);沙箱自身文件系统=租户边界,故任意租户可用。
1480
- // - ssh lane(后补 2026-07-14):同一直传链,但 peer=跨任务共享的真实主机(非租户边界)→
1481
- // 【单用户 only】,租户门在 sandboxSendLaneEnabled(多租户下注册表不建,工具诚实缺席)。
1482
- // adb/local-docker 仍后补。
1483
- // 签发面缺 S3_PUBLIC_ENDPOINT 时 issue-time fail-loud(工具在但用即报修法);100MiB/文件帽在各 send 面。
1484
- const sendUserFileEmitter = (() => {
1485
- if (!config.sendUserFile)
1486
- return undefined;
1487
- if (sendUserFileTaskEnvs)
1488
- return new SendUserFileEmitter(); // e2b/k8s/ssh(单用户):注册表已就位(lane+租户+签发面判定)
1489
- // unset REMOTE_EXEC(in-process/TOC 本地形态)有意归入 host 语义:exec 与文件都在本机,单用户门
1490
- // 同样把关(codex 复审提出收紧到显式 "host";判定=不收——unset 单机+配了 S3 是合法 dev/TOC 场景,
1491
- // 且与 1.187.0 已发行为一致;真正的红线是 requirePrincipal 多租户,这里恒关)。
1492
- const provider = config.remoteExec?.provider ?? "host";
1493
- if (provider === "host" && config.requirePrincipal !== true)
1494
- return new SendUserFileEmitter();
1495
- return undefined;
1496
- })();
1497
- // SendUserFile 账本(多租户治理面,[768]②a 后半):key 里的 scope 段是哈希(URL 不泄 principal),
1498
- // 按租户列/撤销的 scope↔对象映射记在 server 侧账本(四后端孪生,sendfile_link / JSONL)。有 backend 即建
1499
- // (签发面也配了才有写点);无 backend(env-only worker)= 无账本 = 不记账不 501 治理面(诚实缺席,
1500
- // capabilities.sendUserFileLedger=false)。
1501
- const sendFileLedger = config.sendUserFile && backend ? backend.sendFileLedger() : undefined;
1502
- const sendUserFileToolSpec = (() => {
1503
- if (!sendUserFileEmitter || !config.sendUserFile)
1504
- return undefined;
1505
- const issuer = createSendUserFileIssuer(config.sendUserFile);
1506
- // 两条 lane 的 send 都把 ctx.principal(core VERIFIED)传给签发面 → key 带哈希 scope 段 + link 携原文 scope。
1507
- const rawSend = sendUserFileTaskEnvs
1508
- ? createSandboxFileSend({
1509
- registry: sendUserFileTaskEnvs,
1510
- prepare: (filename, scope) => issuer.prepareDirectUpload(filename, undefined, scope),
1511
- maxBytes: SEND_USER_FILE_MAX_BYTES,
1512
- })
1513
- : async (p, sendCtx) => {
1514
- const resolved = resolve(p);
1515
- const st = await fsStat(resolved).catch(() => undefined);
1516
- if (!st || !st.isFile())
1517
- throw new Error(`file not found (or not a regular file): ${p} — ls first; absolute paths are safest`);
1518
- if (st.size > SEND_USER_FILE_MAX_BYTES)
1519
- throw new Error(`file exceeds the ${Math.floor(SEND_USER_FILE_MAX_BYTES / (1024 * 1024))} MiB per-file cap (${st.size} bytes)`);
1520
- const bytes = await fsReadFile(resolved);
1521
- // 读后复验(codex 复审 MED):stat→read 间文件可增长——上传的是读到的字节,帽必须按真实字节数把关。
1522
- if (bytes.byteLength > SEND_USER_FILE_MAX_BYTES)
1523
- throw new Error(`file exceeds the ${Math.floor(SEND_USER_FILE_MAX_BYTES / (1024 * 1024))} MiB per-file cap (${bytes.byteLength} bytes)`);
1524
- return issuer.issue(bytes, basename(resolved), undefined, sendCtx.principal);
1525
- };
1526
- // 账本写点(两 lane 同语义):上传+verify 成功后、file_link 帧前记账;记账失败=该文件 fail-loud +
1527
- // best-effort 删已传对象(治理面必须有行;删失败 warn)。无账本(env-only)= 直通,行为与 1.189 一致。
1528
- const send = sendFileLedger
1529
- ? withLedgerRecording(rawSend, {
1530
- ledger: sendFileLedger,
1531
- deleteObject: (bucket, key) => issuer.deleteObject(bucket, key),
1532
- warn: (msg, meta) => logger.warn(msg, meta),
1533
- })
1534
- : rawSend;
1535
- return sendUserFileTool({ send, emitter: sendUserFileEmitter });
1536
- })();
1537
- // design/99 §K (core 1.157 `RunnerDeps.runtimeCapsResolver`) — the ENFORCE last-link of the
1538
- // three-stage workflows gate. core calls it once per task at prepare time with `spec.principal`; we resolve the
1539
- // per-principal entitlement from center's `GET /api/config/effective?principal=` (cached + fail-closed). Wired
1540
- // ONLY when a center is configured AND not in dry-run: in dry-run the service pulls+logs but does NOT APPLY
1541
- // center config, so it must NOT ENFORCE center's per-principal caps either (enforcing would deny workflows = a
1542
- // real behavior change, contradicting observe-only). Absent ⇒ core sees `undefined` ⇒ no per-principal
1543
- // restriction (the deployment-level capability + toolPolicy gates still govern).
1544
- //
1545
- // 🔐 TRUST DEPENDENCY: the gate keys off `spec.principal` (= resolveSpec's `auth.principal`). F-fix (2026-07-01,
1546
- // core-steered; contract-compliance per core 1.187's hardened `TaskSpec.principal` doc — it MUST be the
1547
- // cryptographically VERIFIED identity, never a spoofable header): `auth.principal` now comes from
1548
- // `verifiedPrincipal` (security.ts). On a GATED door that's `principalFrom` (the BFF already verified the header;
1549
- // `gatedPrincipal === principalFrom` there); on a DIRECT door (`directDoorActive`) it's the verified
1550
- // `x-approval-principal-token` JWT `sub`, NEVER the spoofable header. So `spec.principal` is ALWAYS the verified
1551
- // identity — governance (this gate), cost, and isolation key off it safely on BOTH postures. The same verified
1552
- // identity also fans out to the non-authorizer owner/cost sites that don't run createAuthorizer
1553
- // (runOwnerOk / quotaExceeded / leader / idemKey — they call `gatedPrincipal` directly). The invariant:
1554
- // spec.principal AND every principal-keyed security decision are VERIFIED. (Was a real direct-door spoof hole
1555
- // before this fix; latent until a multi-tenant direct door ships, but it is the only wall against it.)
1556
- // wpt_ scoped-token: RESOLVED (center ruling, sema-registry `aeb4979`). The earlier 1.14.0 guard
1557
- // (skip the resolver on a `wpt_` token, because center 403'd it for `?principal=`) is GONE — center now lets a
1558
- // worker-scoped `wpt_<self>` resolve its OWN worker's principal caps when we pass `?worker=<self>` (caps-only,
1559
- // never others' config). So we ALWAYS wire the resolver (configCenter + !dryRun) and pass `worker` =
1560
- // `SEMA_REGISTRY_WORKER` — it authorizes the wpt_ path and is harmless on a full token (caps are worker-
1561
- // independent). Per-principal workflow gating now truly works on the scoped-token topology (no more silent
1562
- // fleet-wide deny). (A wpt_ with no worker still 403s → fail-closed deny, the correct degrade.)
1563
- // MISCONFIG diagnostic (review MEDIUM): a wpt_<self> token with NO SEMA_REGISTRY_WORKER will 403 every caps
1564
- // fetch → fail-closed DENY ALL workflows (correct degrade, but otherwise only per-task warns). One-shot BOOT
1565
- // warning so it's diagnosable. (The orchestrator normally injects the worker alongside a wpt_ — hand-misconfig.)
1566
- if (config.configCenter && !config.configCenter.dryRun && scopedTokenNeedsWorker(config.configCenter.token, config.configCenter.worker)) {
1567
- logger.warn("runtime_caps_scoped_token_no_worker", {
1568
- reason: "SEMA_REGISTRY_TOKEN is a worker-scoped wpt_ token but SEMA_REGISTRY_WORKER is unset → center 403s per-principal caps → ALL workflow self-orchestration will be fail-closed denied. Set SEMA_REGISTRY_WORKER (the orchestrator normally injects it) or use the full SERVICE_PULL_TOKEN.",
1569
- });
1570
- }
1571
- // ONE per-principal caps client serves two faces off the same fetch/cache — `resolveRuntimeCaps`
1572
- // (core's seam, fail-closed) and `scenarioRuling` (resolveSpec's scenario gate, fail-open; center attaches the
1573
- // resolved {scenario, allowlist} to the caps body, single semantic source in center resolve-scenario.ts).
1574
- const principalCaps = config.configCenter && !config.configCenter.dryRun
1575
- ? createPrincipalEntitlementsClient({
1576
- baseUrl: config.configCenter.baseUrl,
1577
- token: config.configCenter.token,
1578
- ...(config.configCenter.worker ? { worker: config.configCenter.worker } : {}),
1579
- onError: (err, principal) => logger.warn("runtime_caps_resolve_failed", { principal, err: String(err) }),
1580
- })
1581
- : undefined;
1582
- // 「entitlement resolver wired」语义源(enableForkFromBody / selfOrchestrationFromBody 的多租户 fail-close
1583
- // 判别):只有 center 背书的 caps client 算数 —— 下面的 env observer 基线不是 entitlement 源,不得改变它。
1584
- const centerRuntimeCapsResolver = principalCaps?.resolveRuntimeCaps;
1585
- // observer 开闸线 env 半场:EXPERIMENTAL_OBSERVER_AGENTS=true → 单用户部署把
1586
- // allowObservers:true 作部署基线合成(center caps 带键则 center 赢);多租户不认 env(boot warn 一次,
1587
- // 行为零变)。center caps 的 allowObservers 键本身在 toCoreRuntimeCaps 宽读透传(缺键=core 默认 OFF)。
1588
- const runtimeCapsResolver = applyObserverEnvOptIn(centerRuntimeCapsResolver, {
1589
- experimentalObserverAgents: config.experimentalObserverAgents,
1590
- requirePrincipal: config.requirePrincipal,
1591
- warn: (msg, fields) => logger.warn(msg, fields),
161
+ // design/158 A10:workflow 编排面搬到 src/boot/workflow-orchestration.ts(逐字;构造次序契约见该文件头注)
162
+ const { sqlWorkflowRunStore, workflowNotifyJournal, workflowCompletionInbox, deliverWorkflowCompletion, workflowNotifyGate, fleetBus, workflowRunStore, workflowJournalStore, outcomeSink, workflowAgentRegistry, subagentSteerRegistry, } = createWorkflowOrchestration({ config, logger, metrics, localRoot, backend, getRunStore: () => runStore });
163
+ // design/158 A10:活体协调器 + SendUserFile 工具面搬到 src/boot/coordinators.ts(逐字;durableEnabled
164
+ // 「必须早于 runnerDeps 求值」次序契约见该文件头注)。
165
+ const { elicitation, question, toolApproval, durableEnabled, sendUserFileEmitter, sendFileLedger, sendUserFileToolSpec } = createLiveCoordinators({ config, logger, backend, sendUserFileTaskEnvs });
166
+ // design/158 A10:per-principal caps 段搬到 src/boot/runtime-caps.ts(逐字)。
167
+ const { principalCaps, centerRuntimeCapsResolver, runtimeCapsResolver } = createRuntimeCaps({ config, logger });
168
+ // design/158 A10:RunnerDeps 装配段搬到 src/boot/runner-deps.ts(逐字;runStore 晚绑改取值,见该文件头注)
169
+ const runnerDeps = createRunnerDeps({
170
+ config, logger, metrics, localRoot, promptSource: configCenter.promptSource, rosterStore, backgroundAgentStore, mailboxStore, brain,
171
+ pricing, tracer, outcomeSink, elicitation, question, toolApproval, sessionStore, memoryEngine,
172
+ memorySyncRunner, toolResultStore, sessionPolicyStore, runtimeCapsResolver, fileSnapshotStore,
173
+ executionEnvFactory, lspManager, fleetBus, deploymentHooks, workflowRunStore, workflowJournalStore,
174
+ workflowAgentRegistry, workflowNotifyGate, workflowCompletionInbox, deliverWorkflowCompletion,
175
+ getRunStore: () => runStore,
1592
176
  });
1593
- // S8 (design/98) + SVC-1: mount core's `run_workflow` under the hardened-vm sandbox when
1594
- // enabled (default OFF → core fail-closed, tool not mounted, zero impact). The tool STARTS a workflow and
1595
- // returns its runId IMMEDIATELY (the background-run half — the caller never blocks); the workflow runs to
1596
- // terminal in the background and fires core's in-process completion notify. Workflow-spawned children inherit
1597
- // a CONSERVATIVE baseline (handsReadOnly — read-only by default; loosen deliberately); the script may pick
1598
- // ONLY allow-listed model names (empty ⇒ it cannot pick → the workflow's default role).
1599
- //
1600
- // design/158 S4:三处装配点里**唯一**一组多键条件段 —— 提成带类型标注的中间 const 再整体展开进
1601
- // runnerDeps。标注位 `Pick<RunnerDeps, …>` 本身触发 TS 多余属性检查(键名写错 / 键根本不属于
1602
- // RunnerDeps ⇒ 编译期红),所以这不是把旁路挪个地方;组内可选键一律写成显式
1603
- // `k: cond ? expr : undefined`,不再用 `...(cond ? { k } : {})`。
1604
- const workflowModelAllowlist = workflowModelAllowlistFor(config);
1605
- const selfOrchestrationDeps = config.selfOrchestrationEnabled
1606
- ? {
1607
- // worker_thread isolation (heap cap + terminate) when opted in (multi-tenant DoS hardening), else
1608
- // the faster in-process runner. Both run the same conformance-validated + probe-hardened membrane.
1609
- workflowScriptRunner: config.selfOrchestrationWorkerIsolation
1610
- ? createWorkerHardenedVmRunner()
1611
- : createHardenedVmRunner(),
1612
- workflowRunStore: workflowRunStore ? workflowRunStore : undefined,
1613
- // SVC-2 (CORE-9 Part A): durable resume journal — core auto-wires it into the tool's startWorkflow, the
1614
- // `resumeFromRunId` tool input then replays the unchanged prefix (cross-replica when tidb/pg-backed). The
1615
- // store enforces scope in its WHERE (CORE-9 audit BLOCKER: a cross-tenant resumeFromRunId → empty → live).
1616
- workflowJournalStore: workflowJournalStore ? workflowJournalStore : undefined,
1617
- // 切片 1.5 / design/140 §6: the named-workflow registry seam — a COMPOSITE store.
1618
- // persist/load = core's file store under `<localDataRoot>/workflow-scripts` (the CC scriptPath-iterate
1619
- // face: every invocation's script lands on disk, the model edits + re-invokes with {scriptPath};
1620
- // containment is the file store's contract). resolveName consults the collab projection FIRST (center
1621
- // collab templates as `{TEAM_DISCUSSION_SCRIPT, defaultArgs}` entries; a collab id equal to a built-in
1622
- // name SHADOWS it — §6 1c) and falls back to the file store's `<name>.js` face (an operator-saved
1623
- // script is a named workflow too). list() = collab entries only (the file store deliberately doesn't
1624
- // enumerate — its dir mixes per-run scripts with saved names, core contract note); sync, so the
1625
- // entries reach the statically-built Workflow tool card.
1626
- workflowScriptStore: (() => {
1627
- const fileStore = createFileWorkflowScriptStore(join(config.localDataRoot ?? localRoot, "workflow-scripts"));
1628
- return {
1629
- // core 1.366 (B-4) scope 分区一致性标:persist/load 直转 core file 实现(per-scope 子目录,
1630
- // 真分区)——标是诚实声明;缺标=core 工具读侧拒/写侧跳(编译期必填字面量即为此设计)。
1631
- scopePartitioned: true,
1632
- persist: fileStore.persist.bind(fileStore),
1633
- load: fileStore.load.bind(fileStore),
1634
- resolveName: (name) => resolveCollabWorkflow(name) ?? fileStore.resolveName?.(name),
1635
- list: () => listCollabWorkflows(),
1636
- };
1637
- })(),
1638
- // SVC-5 (CORE-9 Part B): the opt-in steer handle-sink. When set, the tool's `agent()` runs STEERABLE and
1639
- // emits the live handle HERE (never into the script/runner — the host-context membrane stays closed). We
1640
- // register it by runId+label so POST /v1/workflows/:id/agents/:label/steer can route a steer to it, and
1641
- // unregister when the agent settles (result() settles on stream completion; a stale handle would only
1642
- // 409 `steering.not_running` anyway — this just bounds the map). A steered agent is NOT journaled (CORE-9.1
1643
- // limitation: its resume re-runs live), which is correct + documented.
1644
- onWorkflowAgentSpawn: workflowAgentRegistry
1645
- ? (handle) => {
1646
- const unregister = workflowAgentRegistry.register(handle);
1647
- void Promise.resolve(handle.result()).then(unregister, unregister);
1648
- }
1649
- : undefined,
1650
- // workflowSizeGuideline: advisory size guidance injected into the
1651
- // Workflow tool card (core Wvs, CC 206 verbatim). Absent/unrestricted = byte-compat card.
1652
- workflowLimits: config.workflowSizeGuideline ? { sizeGuideline: config.workflowSizeGuideline } : undefined,
1653
- workflowGovernanceBaseline: {
1654
- // [824]① clay 拍 A 案(workflow 权限全面 CC parity):默认基线撤 handsReadOnly 钳 —— 主 LLM 与
1655
- // 子 agent 同 root 同信任域,主 LLM 本就能写这棵树([816] ask 门照管),单独钳子 agent 的安全增益≈0
1656
- // (只防绕路不防直路的门不是边界);实测产品代价=[814]A 死锁(写型 workflow agent 永远只读)。并发
1657
- // 写互踩按 CC 同姿用建议解决(worktree 契约话术+design/111 advisory),不用强制钳。要保守的 TOB
1658
- // 部署自己开 WORKFLOW_AGENTS_READONLY=true(config.workflowAgentsReadOnly)加回旧钳。
1659
- base: config.workflowAgentsReadOnly ? { handsReadOnly: true } : {},
1660
- // [824]②/[826]-core 1.291 worktree seam(TOB 旋钮回归腿):旋钮开启时 worktreeBase 配成
1661
- // overlay 形状 —— core 对 `agent({isolation:"worktree"})` 做 base 浅合并(非整体替换,
1662
- // workflow-primitives effectiveBaseline:{...base,...worktreeBase}),{handsReadOnly:false} 语义=
1663
- // 「只放写,其余基线全保留」。效果:保守部署里非隔离子 agent 仍只读,进了自有 worktree 的
1664
- // agent 恢复可写([814]A 写型 workflow 在 TOB 形态下不死锁)。A 案默认(旋钮 off)不配 worktreeBase。
1665
- worktreeBase: config.workflowAgentsReadOnly ? { handsReadOnly: false } : undefined,
1666
- // Model allowlist for the script's `agent({model})` picks: explicit
1667
- // SELF_ORCHESTRATION_MODELS wins; single-user defaults to the deployment's own catalog; multi-tenant
1668
- // keeps core's fail-closed empty default. Decision + rationale in workflowModelAllowlistFor.
1669
- workflowModelAllowlist: workflowModelAllowlist ? workflowModelAllowlist : undefined,
1670
- },
1671
- // SVC-1 at-least-once notify: route core's terminal notify THROUGH the gate (deliver-then-ack +
1672
- // journal dedup) instead of straight to log/meter, so a crash mid-delivery re-delivers on the next boot
1673
- // recovery sweep. When the gate is absent (memory backend / no journal) fall back to the bare
1674
- // log+meter delivery (at-most-once, the prior behavior — honest about the lost-on-crash window).
1675
- workflowCompletionNotifier: {
1676
- // ⚠️ design/158 S4 有意保留的展开:两臂**都非空**(要么 gate 造的 notifier,要么裸 notify 兜底),
1677
- // 不是「条件键」形——它合并的是同一必填键 `notify` 的两个来源,两来源都有类型,不构成
1678
- // 多余属性检查旁路(缺 notify 反而当场编译红)。
1679
- ...(workflowNotifyGate
1680
- ? workflowNotifyGate.buildNotifier()
1681
- : { notify: (input) => deliverWorkflowCompletion(input) }),
1682
- // core 1.232 (the CORE half of the poll-then-also-notify dedup): pollWorkflow fires this
1683
- // ONCE when the ORIGINATING session polls the run to a real terminal (timeout/cross-session polls
1684
- // don't) — drop that (session, runId)'s pending inbox entry + arm the served fence, so the push
1685
- // that would duplicate what the model just read is dead even across a shell restart. Session
1686
- // attribution mirrors resolveCompletionRoute's session half (payload id, else the run row).
1687
- ackServed: async (input) => {
1688
- if (!workflowCompletionInbox)
1689
- return;
1690
- try {
1691
- const sid = await resolveServedSession(input, runStore ? (id) => runStore.getRun(id) : undefined);
1692
- if (sid) {
1693
- await workflowCompletionInbox.markTerminalServed(sid, input.runId);
1694
- // The THIRD way an inbox entry disappears (besides the three stream-open drains
1695
- // and the owner-mismatch drop) — the model itself polled the run to terminal, so the push is
1696
- // suppressed. An "enqueue → ack within ms, but the shell showed nothing" report with THIS line
1697
- // means the completion went to the MODEL in-process (core injected it), not onto any stream.
1698
- logger.info("workflow_complete_ack_served", { route: "poll-served", sessionId: sid, runId: input.runId });
1699
- }
1700
- }
1701
- catch (err) {
1702
- logger.warn("workflow_ack_served_failed", { runId: input.runId, err: String(err) });
1703
- }
1704
- },
1705
- },
1706
- }
1707
- : {};
1708
- const runnerDeps = {
1709
- // e/b([985]):center catalog 轴的 core 消费面——candidate/pinned-digest 解析走这里(prepare-task
1710
- // dist:session_start 优先 candidate、resume 按 digest 解析、miss=fail-loud prompt_snapshot_unavailable)。
1711
- promptSource,
1712
- // [1070]① agent-team S1:持久名册 seam(具名 spawn advisory 写入;SendMessage 活注册表 miss 后咨询)。
1713
- rosterStore: rosterStore ? rosterStore : undefined,
1714
- // core 1.364 durable bg agents 读半场(写半场=scenarioDeps.backgroundAgentStore 同实例,组装区注释)。
1715
- backgroundAgentStore: backgroundAgentStore ? backgroundAgentStore : undefined,
1716
- // S3c tier-3 懒复活(core 1.374):SendMessage 链内消费,与 backgroundAgentStore 同实例配套挂载。
1717
- mailboxStore: mailboxStore ? mailboxStore : undefined,
1718
- brain,
1719
- models: config.models,
1720
- roles: config.roles,
1721
- // core 1.265: the active tier table (tier words + CC aliases → catalog keys, expandTiers at Runner
1722
- // construction; empty = INERT by core contract). Same mutateInPlace reference applyEffective fills — a
1723
- // refresh-time tier change is restart-to-apply, same tier as models.
1724
- tiers: config.tiers,
1725
- pricing,
1726
- tracer,
1727
- // [931]① clay 拍(core 1.300 BREAKING:缺省不署 Co-Authored-By,署名=产品身份资产归部署):
1728
- // branded 形态(local provider = Sema 产品线,scenarios brandIdentity 同判据)commit 尾注接 Sema 署名;
1729
- // 非 brand 部署维持 core 新缺省(不署)。seam=RunnerDeps.hands.commitCoAuthor。
1730
- hands: config.configProvider === "local" ? { commitCoAuthor: "Sema <noreply@vivi-ai.com>" } : undefined,
1731
- // design/73 §1 (core 1.226 seam, clay 拍 2026-07-04 接): consume mechanical TaskOutcome facts into the
1732
- // outcome ledger — tidb/pg = SQL rows (coreOutcomeToLedgerRow mapping + verbatim `core_outcome` JSON so
1733
- // red-line ② oracleHadRedRun survives lossless), local = owner-only JSONL. Read-only v1: records facts,
1734
- // drives NO policy (§7.4 backtest gate stands). Fire-and-forget — a sink failure logs and never touches
1735
- // the run (core's emitTaskOutcome swallow-guards too). Emitters today: core runGoal terminal + any
1736
- // harness with a REAL mechanical oracle calling runner.emitTaskOutcome; plain runTask never auto-emits.
1737
- onTaskOutcome: outcomeSink
1738
- ? (o) => {
1739
- metrics.inc("task_outcomes_total", { status: o.status, green: String(o.oracle?.green ?? "unknown") });
1740
- void outcomeSink.recordCore(o).catch((err) => logger.warn("task_outcome_record_failed", { runId: o.runId, err: String(err) }));
1741
- }
1742
- : undefined,
1743
- // E23: the live-only inbound-elicitation seam. core invokes it only for servers that opted in via
1744
- // McpServerSpec.elicitation (default OFF) AND only when this is wired — both must hold (doubly fail-closed).
1745
- onElicit: elicitation ? elicitation.elicit : undefined,
1746
- // §4④: the live AskUserQuestion seam. core mounts the tool when onQuestion is present + routes each ask here; the
1747
- // DURABLE leg's spec.onQuestion (QUESTION_AWAITS_RESUME) OVERRIDES this per-task so a disconnected-human ask suspends.
1748
- onQuestion: question ? question.question : undefined,
1749
- // [816]/[820]②: the live tool-approval seam (core `resolveAsk` — `spec.onAsk ?? deps.onAsk`). ALS-routed like
1750
- // onQuestion: a leg wrapped by the coordinator's runWithContext reaches the human.
1751
- //
1752
- // [879] G1 终态(core 1.295 OnAsk 三值化):恒 wire。回调逐 ask 时刻判活人——ALS 附着腿 ⇒ 同步三选卡;
1753
- // 无附着/卡送达失败 ⇒ 返 "unavailable",core 以 approverUnavailable 回路把该 ask 交回 suspendAsk 走
1754
- // durable park(prepare-task 分路带 `approverUnavailable !== true` 豁免位,dist 亲读)——park 与 live 卡
1755
- // 两全,1.199 的「durable 部署不 wire deps.onAsk」止血撤除。无 park 设施的部署 core 自己 fail-closed
1756
- // deny(resolveAsk 的 unavailable 文案),与旧姿势同向。
1757
- onAsk: toolApproval
1758
- ? (req, signal) => toolApproval.ask(req, signal)
1759
- : undefined,
1760
- // [822]② auto permission mode, deployment half: RunnerDeps.autoMode is the operator TRUST face of core's
1761
- // auto-mode classifier (1.276/1.277) — wiring it alone arms NOTHING (core requires the per-principal entitlement
1762
- // runtimeCaps.autoMode === true from center, fail-closed dark by default; see runtime-caps-resolver.ts). With
1763
- // both present, core screens every policy `ask` through the classifier (allow safe / deny hostile / leave the
1764
- // rest on the ask path → this bridge or durable park). Defaults-only config (core's rules/window); breaker-open
1765
- // is surfaced for observability.
1766
- autoMode: {
1767
- onBreakerOpen: (info) => {
1768
- metrics.inc("auto_mode_breaker_open_total");
1769
- logger.warn("auto_mode_breaker_open", { consecutiveFailures: info.consecutiveFailures, lastCause: info.lastCause });
1770
- },
1771
- },
1772
- sessionStore,
1773
- // design/138 S1: the memory-engine switch — when present (single-user + engine on), core replaces the
1774
- // legacy memory path WHOLESALE per task (materialize → session file ops → harvest; no remember/recall
1775
- // tools). `memoryEngineDir` = the resolved config root (core derives the B3 control plane beside the
1776
- // memory dir). Absent (multi-tenant / MEMORY_ENGINE=off) ⇒ no deps.memoryBackend ⇒ memory dark.
1777
- memoryBackend: memoryEngine ? memoryEngine.backend : undefined,
1778
- memoryEngineDir: memoryEngine ? memoryEngine.root : undefined,
1779
- // S3-TOB 复审 F-9(operator 可观测底座):harvest 报告 → metrics(拒收/incident/patch 计数从此可见;
1780
- // core swallow-guard 保证 throwing consumer 不伤边界)。
1781
- onMemoryHarvestReport: memoryEngine
1782
- ? (report, info) => {
1783
- metrics.inc("memory_harvest_total", { ok: String(report.ok), phase: info.phase, incident: report.incident?.kind ?? "none" });
1784
- if (report.patches)
1785
- metrics.inc("memory_harvest_patches_total", { phase: info.phase }, (report.patches.add ?? 0) + (report.patches.update ?? 0));
1786
- if (report.incident)
1787
- logger.warn("memory_harvest_incident", { kind: report.incident.kind, phase: info.phase });
1788
- // 142-S2.5-W1: 成功 harvest 真有 patch 落地 = 本地记忆变了 ⇒ fire-and-forget 一轮同步
1789
- // (trigger 自带 inflight 节流:上一轮在飞则跳过,漏掉的变更下一轮全量补上)。
1790
- if (memorySyncRunner && report.ok && (report.patches?.add ?? 0) + (report.patches?.update ?? 0) > 0)
1791
- memorySyncRunner.trigger("harvest");
1792
- }
1793
- : undefined,
1794
- toolResultStore,
1795
- // E6: operator-tightened session tool rules — core folds them into the ToolPolicy FIRST (subtract-only) for tasks
1796
- // carrying a sessionId (a delegated subagent has none → inherits no rules). Opt-in: undefined ⇒ no rules read.
1797
- sessionPolicyStore: sessionPolicyStore ? sessionPolicyStore : undefined,
1798
- // design/99 §K: per-principal runtime entitlements (allowWorkflows / forceDurableGate) resolved from center.
1799
- // Opt-in: undefined ⇒ NO per-principal restriction (tighten-only; deployment default governs). See above.
1800
- runtimeCapsResolver: runtimeCapsResolver ? runtimeCapsResolver : undefined,
1801
- // E19: working-tree snapshot/restore for rewind (+ the 2c artifact store). core snapshots each completed turn +
1802
- // restores on resumeAt when spec.rewindFiles is set, for ANY env when this store is wired (gate-split 1.134.0).
1803
- fileSnapshotStore: fileSnapshotStore ? fileSnapshotStore : undefined,
1804
- executionEnvFactory: executionEnvFactory ? executionEnvFactory : undefined,
1805
- lspManager: lspManager ? lspManager : undefined,
1806
- // design/129-B (core 1.240.0): the PROCESS-LEVEL background-child observer — spawn/tick/terminal
1807
- // for every bg delegation child, never dying with a leg. Feeds the fleet rows (launch 即有行, session-scoped
1808
- // children stay visible past the turn — the 缺口① fix) + the bg_notification frame on the
1809
- // always-open fleet stream (缺口②: idle completion delivery is immediate). Wired once per process.
1810
- onBackgroundChildEvent: fleetBackgroundChildPublisher(fleetBus, (msg, fields) => logger.info(msg, fields)),
1811
- // design/113 C4: inject CLAUDE.md + git narrative so a chat opens project-aware (like CC). The helper reads cwd via
1812
- // local fs/execFile — only meaningful when cwd is LOCAL (host/file lane); it stats the dir and returns null on a
1813
- // remote-sandbox cwd, so this is safe to wire generally, but we gate it to single-user (cwdHonored posture — the
1814
- // same lane where reading the local project for the caller isn't a confused-deputy hole) + the opt-out kill-switch.
1815
- loadProjectMemory: config.requirePrincipal !== true && config.projectMemoryEnabled ? makeLoadProjectMemory({ logger }) : undefined,
1816
- // core 1.302 instructions-change lane (板 [952]②): re-fingerprint the declared instruction file each turn so a
1817
- // mid-run edit surfaces as a tail attachment. STRICTLY host lane — on a remote-sandbox lane the instruction file
1818
- // lives in the container; a local re-read would fingerprint the wrong tree, so the probe is not wired there.
1819
- probeInstructionSources: config.remoteExec?.provider === "host" && config.requirePrincipal !== true && config.projectMemoryEnabled
1820
- ? makeProbeInstructionSources()
1821
- : undefined,
1822
- hooks: deploymentHooks,
1823
- ...selfOrchestrationDeps, // S8 self-orchestration 全家桶(成组条件段;类型标注在上方 const)
1824
- // F8: surface best-effort compaction failures + the 1.22 prompt-cache low-hit warning (an
1825
- // unstable/poisoned prefix tanking cost) instead of silently swallowing them.
1826
- onError: (err, ctx) => {
1827
- // "degraded" (1.40) is an operational event, NOT a failure — the task still completes (on a cheaper
1828
- // model). Log it as a warning so it doesn't pollute error rates/alerts. The degraded_total metric
1829
- // comes from the tracer's task.degraded event.
1830
- if (ctx.phase === "degraded") {
1831
- logger.warn("task_degraded", { sessionId: ctx.sessionId, info: String(err) });
1832
- return;
1833
- }
1834
- // "prompt-constitution" (core 1.243): a stableSystem provider returned an ALREADY-
1835
- // assembled prompt (constitution anchor found); core passed it through un-doubled. The task is safe
1836
- // (guard un-doubles), but the provider is pre-1.243-shaped and should be upgraded to return only the
1837
- // role base. Warn — paired with the tracer's prompt_constitution_total{mode="provider-assembled"}.
1838
- if (ctx.phase === "prompt-constitution") {
1839
- logger.warn("prompt_provider_needs_upgrade", { sessionId: ctx.sessionId, info: String(err) });
1840
- return;
1841
- }
1842
- // "rewind" (E19 per-turn file snapshot): best-effort — a failed snapshot only makes that one turn
1843
- // non-rewindable; the task itself is unaffected. `too_large` is the EXPECTED shape on a big working
1844
- // tree (TOC user opening a 20G folder: enumerate hits the 256MB bound EVERY turn → an error-level
1845
- // line per turn reads like the run is broken). Downgrade to warn + count by code so a real store
1846
- // fault (enumerate_failed on a readable tree, blob-write errors) still stands out in the metric.
1847
- if (ctx.phase === "rewind") {
1848
- const code = /\((\w+)\)/.exec(String(err))?.[1] ?? "unknown";
1849
- logger.warn("rewind_snapshot_failed", { sessionId: ctx.sessionId, code, info: String(err) });
1850
- metrics.inc("rewind_snapshot_failed_total", { code });
1851
- return;
1852
- }
1853
- // "memory" (1.62, design/41): a best-effort post-task consolidation pass failed. The task already
1854
- // completed and its notes are safely appended (just not reconciled this round) — warn, fail-open,
1855
- // don't pollute error rates. (Consolidation cost still lands in model_cost_micro_usd via the tracer.)
1856
- if (ctx.phase === "memory") {
1857
- logger.warn("memory_consolidation_failed", { sessionId: ctx.sessionId, info: String(err) });
1858
- return;
1859
- }
1860
- // "mcp" (1.68): a broken MCP server was SKIPPED (fail-open) — the task still ran without
1861
- // that server's tools. Surface it as a warning + metric so a misconfigured server is observable, not silent.
1862
- if (ctx.phase === "mcp") {
1863
- logger.warn("mcp_server_unavailable", { sessionId: ctx.sessionId, info: String(err) });
1864
- metrics.inc("mcp_server_unavailable_total");
1865
- return;
1866
- }
1867
- // "prompt-cache" (1.22 low-hit + design/31 break detector): since core 1.89 the detector passes its
1868
- // root cause as ctx.classification — "server-or-ttl" is usually benign in agentic tasks (slow tools ⇒
1869
- // 5min+ request gaps expire provider caches) → warn; the prefix-bug causes
1870
- // (model-switch/tool-schema/tool-set/system-prefix) stay errors that warrant attention.
1871
- if (ctx.phase === "prompt-cache") {
1872
- metrics.inc("prompt_cache_low_hit_total");
1873
- const fields = { sessionId: ctx.sessionId, ...(ctx.classification ? { classification: ctx.classification } : {}), err: String(err) };
1874
- if (ctx.classification === "server-or-ttl")
1875
- logger.warn("prompt_cache_break", fields);
1876
- else
1877
- logger.error("prompt_cache_break", fields);
1878
- return;
1879
- }
1880
- // "config" (core 1.300 [931]②): assembly-time advisories — e.g. toolPolicy 名单池审计
1881
- // (config.toolpolicy.unmatched_names:名单里写了实挂宇宙不存在的工具名=typo 探测,enforcement
1882
- // 不变)。观测性提示,warn+metric,不进 error 告警面。
1883
- if (ctx.phase === "config") {
1884
- logger.warn("runner_config_advisory", { sessionId: ctx.sessionId, info: String(err) });
1885
- metrics.inc("runner_config_advisory_total");
1886
- return;
1887
- }
1888
- logger.error("runner_error", { phase: ctx.phase, sessionId: ctx.sessionId, err: String(err) });
1889
- },
1890
- };
1891
177
  const runner = new Runner(runnerDeps);
1892
178
  // codex R10: TRUE ⇒ the Runner just froze a PRIVATE tier-expanded catalog copy (core runtask.js constructor,
1893
179
  // dist-read) — in-place model-plane mutation no longer reaches it, so refresh-time plane changes must be
@@ -1906,8 +192,9 @@ async function main() {
1906
192
  // hookModelFor/hookLlm 抽到 src/hooks/hook-llm.ts(可注入 fetch/keyResolver ⇒ 可单测):api 形分派
1907
193
  // (openai /chat/completions 与 anthropic /v1/messages 双腿)、缺省 baseUrl 按 api 分家、per-model key
1908
194
  // 恒胜 + foreign-no-key 连 prompt 都不发的 fail-closed 纪律,全在该模块内(注释含 audit 原文)。
1909
- // getKeyResolver 取函数:keyResolver 是下方 let,registry 热应用会整个换引用,这里必须每次现取。
1910
- const { hookModelFor, hookLlm } = createHookLlm({ config, getKeyResolver: () => keyResolver, metrics });
195
+ // getKeyResolver 取函数:keyResolver 住在 config-center 运行时的闭包里,registry 热应用会整个换引用,
196
+ // 这里必须每次现取。
197
+ const { hookModelFor, hookLlm } = createHookLlm({ config, getKeyResolver: () => configCenter.getKeyResolver(), metrics });
1911
198
  // hook agent 的 session 绝不进 durable store——每次 runTask 会写 session_meta 且无人
1912
199
  // 清理(reaper 只扫 staging orphan),cap=4/事件 × 长会话=无主行堆积。专用 Runner 覆盖 sessionStore 为
1913
200
  // 进程内 TTL 店(1h 短 TTL,hook 子代理无 resume 语义,行随进程/TTL 消失);其余 deps 原样共享。
@@ -2007,32 +294,30 @@ async function main() {
2007
294
  // 否决 park,cli 真机 14ms~2s expired 即此)。
2008
295
  const subRunnerSessions = new ForkRoutingSessionStore(sessionStore, new TtlSessionStore({ defaultTtlDays: 1 / 24 }));
2009
296
  const subRunner = new Runner({
2010
- brain,
2011
- models: config.models,
2012
- roles: config.roles,
2013
- tiers: config.tiers,
2014
- pricing,
2015
- tracer,
2016
- promptSource,
2017
- sessionStore: subRunnerSessions,
2018
- // A7/S4 余量(design/158):core RunnerDeps 消费端对以下键均无在场性判定(无 "k" in deps /
2019
- // Object.keys(deps) 分支——S4 已逐键核过全量 RunnerDeps 消费面),故显式可选键(undefined 与
2020
- // 缺席同义)替代条件 spread,行为等价、可读性与主 runnerDeps 装配点(1687 行)同款。
2021
- executionEnvFactory: executionEnvFactory ? executionEnvFactory : undefined,
2022
- lspManager: lspManager ? lspManager : undefined,
2023
- backgroundAgentStore: backgroundAgentStore ? backgroundAgentStore : undefined,
2024
- mailboxStore: mailboxStore ? mailboxStore : undefined,
2025
- rosterStore: rosterStore ? rosterStore : undefined,
297
+ // [1543]§三族A 结构性根治(design/158 冲刺尾件):与主 runnerDeps 重复的 ~15 键统一走
298
+ // createSharedRunnerDeps 展开——「双点挂载」从人工纪律变结构性保证(新共享键漏配其一=不可能,
299
+ // 基座只有一份)。差异键在展开后显式列出,每个都有为何不同的理由(见 boot/runner-deps.ts 头注)。
300
+ ...createSharedRunnerDeps({
301
+ config,
302
+ brain,
303
+ pricing,
304
+ tracer,
305
+ promptSource: configCenter.promptSource,
306
+ executionEnvFactory,
307
+ lspManager,
308
+ backgroundAgentStore,
309
+ mailboxStore,
310
+ rosterStore,
311
+ deploymentHooks,
312
+ toolResultStore,
313
+ sessionPolicyStore,
314
+ }),
315
+ // ── 以下为 subRunner 差异键(不在共享基座;逐个有因)──────────────────────────────────────
316
+ sessionStore: subRunnerSessions, // 子代转录=私有短 TTL fork 路由店,生命周期异于宿主 durable 店
2026
317
  onBackgroundChildEvent: runnerDeps.onBackgroundChildEvent ? runnerDeps.onBackgroundChildEvent : undefined,
2027
318
  loadProjectMemory: runnerDeps.loadProjectMemory ? runnerDeps.loadProjectMemory : undefined,
2028
319
  probeInstructionSources: runnerDeps.probeInstructionSources ? runnerDeps.probeInstructionSources : undefined,
2029
- hooks: deploymentHooks,
2030
320
  onError: runnerDeps.onError ? runnerDeps.onError : undefined,
2031
- toolResultStore,
2032
- // sessionPolicyStore:挂载无害(tighten-only 空查);如实注(复审 F5)——子代 sessionId 是新铸
2033
- // uuid,E6 规则按宿主 id 键存 → 子代查自己恒空;规则真到子代靠 inheritedGate.ancestorRules(本就
2034
- // 通)。真实受益面 = retained child 自己 session 被显式 PUT 过规则的边角。
2035
- sessionPolicyStore: sessionPolicyStore ? sessionPolicyStore : undefined,
2036
321
  // checkpointStore = 子代**执行面**的 park 设施([1582] cli TiDB 真验撞获缺口,[1584] core 定谳
2037
322
  // 接线步):core prepare-task `spec.checkpointStore ?? deps.checkpointStore` 里 spec 侧永不被
2038
323
  // subagent 设置,子代 suspendAsk 全靠这里——缺席 = 具名 bg 子代的 gated ask 只能 deny+continue,
@@ -2183,80 +468,10 @@ async function main() {
2183
468
  // Capability layer: scenario → {tools (+ subagent roster), prompt, skills}. Heavy bits (skills,
2184
469
  // git client) are built ONCE here; per request we only select a scenario and bind coordinates.
2185
470
  let skills = loadSkills(config.skillsDir);
2186
- // Sema-registry B1 (restart-to-apply, baked into scenarios below): overlay center skills (lazy-fetched by
2187
- // hash + sha256-verified) over the image baseline, center-wins-by-name; resolve center MCP servers' env-NAME
2188
- // refs to real values from this service's env. Both empty/unset → unchanged (image SKILLS_DIR, no MCP).
2189
- if (effective?.skills && config.configCenter) {
2190
- skills = await applyCenterSkills(skills, effective.skills, config.configCenter.baseUrl, config.configCenter.token, logger, undefined, configLkgEnabled() ? defaultSkillCacheDir() : undefined);
2191
- }
2192
- // plugins 域消费半场(契约 [1361]②/[1362]/[1366]①;2026-07-28 开工车):把 center 声明的插件引用
2193
- // 物化(https-only+ALLOW_HOSTS 纵深复验/sha 钉扎/隔离 clone/LKG)并装载其 skills —— **在 center 直发
2194
- // skills 之后**:撞名让位判据(收紧④,plugin 让位)以此刻的 skills 集为准。失败单条不连坐,永不拒启。
2195
- if (effective?.plugins && config.configCenter) {
2196
- const pluginOut = await applyCenterPlugins(skills, effective, {
2197
- cacheRoot: join(config.localDataRoot ?? join(homedir(), ".ai-agent"), "plugin-cache"),
2198
- allowHosts: config.pluginsAllowHosts,
2199
- logger,
2200
- }).catch((err) => {
2201
- logger.warn("center_plugins_apply_failed", { error: err instanceof Error ? err.message : String(err) });
2202
- return undefined;
2203
- });
2204
- if (pluginOut) {
2205
- skills = pluginOut.skills;
2206
- if (pluginOut.failures.length + pluginOut.letGo.length + pluginOut.lkgUsed.length > 0) {
2207
- logger.info("center_plugins_applied", { failures: pluginOut.failures, letGo: pluginOut.letGo, lkgUsed: pluginOut.lkgUsed });
2208
- }
2209
- }
2210
- }
2211
- // F7:boot 的 LKG 落盘点。codex R26:走同一只 persistLkgDurable(完整性门在前)——此前裸 saveLkg 会把
2212
- // applyCenterSkills 静默吞掉失败的「缺正文 manifest」发布成 boot 可见 LKG(离线重启=静默缺 skill 且不再
2213
- // 有信号收敛)。校验失败=不落盘(warn 已在函数内),下一个干净候选/refresh 重试重写。
2214
- if (bootLkgCandidate && lkgEnabled) {
2215
- const bootPublished = await persistLkgDurable(bootLkgCandidate.effective, bootLkgCandidate.etag).catch((err) => {
2216
- logger.warn("config_lkg_save_failed", { path: lkgPath, err: String(err), note: "boot LKG persist failed — retained for the per-tick retry" });
2217
- return false;
2218
- });
2219
- // codex R30: a failed/incomplete boot publication must enter the SAME retained-candidate retry lane as the
2220
- // refresh path — ccEtag has already advanced, so the next ticks 304 and nothing else would ever retry; a
2221
- // restart during a center outage would then boot stale/env config instead of the generation this process
2222
- // already applied. blocked=[] — everything WAS applied at boot, this is a publication-only retry (promotion
2223
- // with no withheld reasons publishes NO restart signal, it just completes the LKG and clears the state).
2224
- {
2225
- // codex R32/R34: verify the boot's LIVE application UNCONDITIONALLY — applyCenterSkills skips an
2226
- // unfetchable body (this process may run skill-stale / on an older same-name built-in), and the LKG
2227
- // prewarm inside persistLkgDurable can succeed where the live fetch failed (bootPublished true, LKG
2228
- // complete, process still stale). Any live miss ⇒ a skills restart: published immediately when the
2229
- // handoff already stands, or carried in blocked for the 304-gated promotion when it doesn't.
2230
- const enabledManifest = (bootLkgCandidate.effective.skills?.skills ?? []).filter((m) => m.enabled !== false);
2231
- // codex R33: the FULL runtime projection must match, not just name+body — a same-body built-in retained
2232
- // after a failed center fetch can carry different scenarios/description (e.g. global exposure where the
2233
- // manifest restricts to one scenario), and the scenario bundles baked right after are then stale.
2234
- const canonScenarios = (a) => [...(a ?? [])].sort().join("\u0000");
2235
- const liveSkillMiss = enabledManifest.some((m) => !skills.some((sk) => sk.spec.name === m.name &&
2236
- skillContentHash(sk.spec.content) === m.contentHash &&
2237
- sk.spec.description === m.description &&
2238
- canonScenarios(sk.scenarios) === canonScenarios(m.scenarios)));
2239
- if (liveSkillMiss)
2240
- bootSkillStale = true; // codex R35: sticky — survives same-generation 200 recomputes
2241
- if (liveSkillMiss && (bootPublished || (!lkgSurvivesRestart && lastPrewarmBodiesVerified))) {
2242
- // Signal NOW only on the arms where a restart PROVABLY repairs (codex R34/R36/R37):
2243
- // · durable + published — the complete LKG (manifest + hash-verified bodies) is the handoff;
2244
- // · NON-durable + body proof — the prewarm inside persistLkgDurable just hash-verified every enabled
2245
- // body on disk, so the miss was a TRANSIENT live-application failure and a restarted process
2246
- // re-pulls successfully. WITHOUT the proof (persistent 404/hash mismatch) publishing here would
2247
- // restart-storm a stateless deployment (codex R37) — that case falls to the retained-debt arm
2248
- // below: visible on /health, retried per confirming 304, promoted only once the proof holds.
2249
- pendingRestart = { restartRequired: true, reasons: ["skills"], version: bootLkgCandidate.effective.version, since: Date.now() };
2250
- logger.warn("config_boot_skill_stale_restart", { version: bootLkgCandidate.effective.version, durableHandoff: bootPublished, note: bootPublished ? "live skill application incomplete but the durable LKG carries the full candidate — skills restart published (the next boot reads the complete LKG)" : "live skill application incomplete on a non-durable deployment but every enabled body is verified in the cache — skills restart published (transient failure proven repaired; the restarted process re-pulls live)" });
2251
- }
2252
- else if (liveSkillMiss || (!bootPublished && lkgSurvivesRestart)) {
2253
- planeDeferredNoHandoff = { version: bootLkgCandidate.effective.version, since: Date.now(), blocked: liveSkillMiss ? ["skills"] : [], candidate: bootLkgCandidate.effective, ...(bootLkgCandidate.etag !== undefined ? { candidateEtag: bootLkgCandidate.etag } : {}) };
2254
- logger.warn("config_lkg_boot_publication_retained", { version: bootLkgCandidate.effective.version, liveSkillMiss, durableDeclared: lkgSurvivesRestart, note: (lkgSurvivesRestart ? "boot LKG publication incomplete on a durable-declared deployment — candidate retained; the refresh loop retries after each confirming 304" : "live skill application incomplete on a non-durable deployment and the body cache could not be verified — skills debt retained WITHOUT a restart signal (codex R37: signaling now could restart-storm; each confirming 304 re-verifies and publishes once the bodies prove fetchable)") + (liveSkillMiss && lkgSurvivesRestart ? "; this process runs skill-stale, promotion will publish a skills restart" : "") });
2255
- }
2256
- }
2257
- }
2258
- if (effective?.mcp)
2259
- config.mcpServers = resolveMcpServers(effective.mcp, logger);
471
+ // design/158 A10 尾刀:center skills overlay + plugins 消费 + boot LKG 落盘/skill-stale 判定 + mcp
472
+ // 解析搬到 boot/config-center.ts(逐字)。⚠️ 位置即契约:loadSkills 之后、buildScenarios 之前 ——
473
+ // LKG 落盘点必须晚于 skill 正文装载(F7/codex R26),plugins 让位判据要求 plugins 晚于 center 直发 skills。
474
+ skills = await configCenter.applyCenterCapabilities(skills);
2260
475
  const repoClient = config.gitApiBaseUrl ? new GiteaClient(config.gitApiBaseUrl, config.gitApiToken) : undefined;
2261
476
  // CC-parity: deployment-injected WebSearch backend (the leg core leaves open). Absent WEB_SEARCH_PROVIDER →
2262
477
  // undefined → the default scenario doesn't assemble the WebSearch tool. The API key stays in the backend closure.
@@ -2369,243 +584,23 @@ async function main() {
2369
584
  // center details 随 overlay 同判定源盖同名——source 语义与 selectScenario 实际取用永一致(约定①)。
2370
585
  const scenarioDetails = builtinScenarioDetails(scenarios, scenarioDeps);
2371
586
  const builtinScenarioNames = Object.keys(scenarioDetails); // overlay 前捕获(六内建名)
2372
- // Center-declared scenarios (composition-as-config): overlay over the built-ins, center wins — it MAY
2373
- // shadow a built-in name (usability first), so shadows are logged loudly here for audit.
2374
- // Restart-to-apply like skills/mcp (this runs once at boot; a refresh carrying scenarios only signals).
2375
- if (effective?.scenarios) {
2376
- const { overlay, shadows } = centerScenarios(scenarioDeps, effective.scenarios.scenarios, Object.keys(scenarios), logger);
2377
- Object.assign(scenarios, overlay);
2378
- Object.assign(scenarioDetails, centerScenarioDetails(effective.scenarios.scenarios, builtinScenarioNames));
2379
- if (Object.keys(overlay).length > 0)
2380
- logger.info("sema_registry_scenarios", { scenarios: Object.keys(overlay), ...(shadows.length > 0 ? { shadowsBuiltin: shadows } : {}) });
2381
- }
587
+ // design/158 A10 尾刀:center 声明场景 overlay 搬到 boot/config-center.ts(逐字)。⚠️ 位置即契约:
588
+ // 必须在内建 details + 六内建名捕获之后 —— 探针要打纯内建工厂,不是被 center 顶掉的。
589
+ configCenter.applyCenterScenarios({ scenarioDeps, scenarios, scenarioDetails, builtinScenarioNames });
2382
590
  logger.info("capabilities", { skills: skills.length, scenarios: Object.keys(scenarios), repoTools: Boolean(repoClient) });
2383
591
  // Background reapers: recover runs whose instance died, expire never-answered approvals, sweep
2384
592
  // rate windows. Unref'd so they never hold the process open.
2385
593
  // design/80 D-D (SLA-timer): the deny-sweep reuses the server's resumeCheckpoint, so it is wired in AFTER
2386
594
  // createHttpServer returns it (below) via this holder — the reaper is defined before the server.
2387
595
  let runDenySweep;
2388
- // S7 (SILENT-FALLBACK P0-d): the sweeps' return counts were discarded — an instance death that batch-fails
2389
- // N orphans was indistinguishable from organic failures. Count + log ONLY when a sweep flipped rows (>0),
2390
- // so healthy ticks stay silent. Tolerant of void-returning stores (typeof guard).
2391
- const reapCount = (metric, labels) => (n) => {
2392
- if (typeof n === "number" && n > 0) {
2393
- metrics.inc(metric, labels, n);
2394
- logger.info("reaper_swept", { metric, ...labels, count: n });
2395
- }
2396
- };
2397
- let wfRunReapInFlight = false; // 1.108 review (lens③): serialize the all-scopes retention sweep across ticks
2398
- let attachmentSweepInFlight = false; // 复审 F1:附件孤儿 sweep 的重入守卫(同上)
2399
- let bgAgentReapInFlight = false; // core 1.364: same serialization for the durable bg-agent joint reap (per-scope serial loop)
2400
- // core 1.364([1503] 提货单③):retention **只走** reapDurableAgents(联合 reap:条件删赢了才 release
2401
- // 转录 session;裸 store.reap 会 strand 转录)。staleRunning 翻转在 core 编排内先行(store.reap 一步)。
2402
- // scope 枚举:core 1.368 `BackgroundAgentStore.listScopes?()`([1516]② 交付,SQL twins/File 双实现
2403
- // 都带)——1.248 拍的「file 腿只扫 default」假设已撤;无该方法的第三方 store 仍回落 "default"(可选
2404
- // 接口成员,诚实回落)。
2405
- // sessions=subRunner.sessions —— [1522] 裁定件(③ 显式裁定形,双店真相考据后拍):bg 子代转录会话
2406
- // 是**双店**(ForkRoutingSessionStore):①非 fork 子代铸在 transient 店(TtlSessionStore,进程内
2407
- // 1h TTL)——release=内存删=**真正终结可寻址性**(转录本就不 durable;跨实例读面读的是行内
2408
- // finalOutput,非转录);②fork 子代铸在 host durable 店——release=lease 释放,durable 转录行**有意
2409
- // 保留**,留存归 session 留存策略/E21 purge 生命周期管(行先删可接受:行是执行记录,转录是会话资产,
2410
- // 两者生命周期本就不同)。core [1522] 复审只见 durable 腿判 HIGH——按其契约句「做不到终结可寻址性
2411
- // 宁可不传」,transient 类做得到、fork 类是显式裁定,维持传入。sessionsReleased 计数=「store 侧
2412
- // release 被调次数」(transient=真删,durable=lease 释放),非「durable 转录处置数」——如实注。
2413
- // 复审 F4 如实注:条件删的赢者若非写者副本,fork-routing 对未知 id 回落 transient=本地 no-op,写者
2414
- // 进程内 pinned 转录滞留到自身 TTL/进程退出——sessionsReleased 计数偏低是诚实读数,非缺陷。
2415
- const reapBgAgents = backgroundAgentStore
2416
- ? async () => {
2417
- const store = backgroundAgentStore;
2418
- const scopes = await (store.listScopes?.() ?? Promise.resolve(["default"]));
2419
- let rows = 0, sessions = 0, skipped = 0, failedScopes = 0;
2420
- for (const scope of scopes) {
2421
- // [1522] LOW3:per-scope 错误隔离——一个 scope 抛(坏 record_json/权限)不饿死后续 scope;
2422
- // 失败响亮(warn+计数),下 tick 重试。
2423
- try {
2424
- // core 1.382([1561] 提货单④,design/153 parked 状态机对账):先对账 parked 行(checkpoint
2425
- // expired/missing → 行诚实翻 failed;stale claim → 回滚 parked),再让下面既有的
2426
- // reapDurableAgents 按自然节奏处置(刚翻 failed 的行本 tick 不会立刻被 maxAge 删——它是
2427
- // "刚失败"的新行,不是"失败很久"的老行)。**走 registry 面**(裂脑 fence 内建于
2428
- // TaskRegistry.reconcileParkedAgents 内部,不裸调 store 面——那是无活实例场景专用,core
2429
- // [1561]④ 措辞)。仅在 checkpointStore 真在场时跑(同 [1561]①②「同车必接」判据——没有
2430
- // checkpointStore 就不可能有真正 parked 的行,调用本身没有意义)。
2431
- // 🔴 [1575] F1 修(cli 复查,红先行确认):`opts.staleClaimMaxAgeMs` 必须显式传——core 把
2432
- // 整个 stale-claim 清算块(claimer 崩死回滚 + resolved-未-finalize 诚实翻 failed)门在
2433
- // `staleMs !== undefined` 上,缺席不是"跳过 stale-claim 这一小步",是**整块永不运行**
2434
- // (此前漏传,`bg_agents_park_reconciled_rolledback_total` 恒 0,人已做出的审批决定会被
2435
- // 静默丢弃——见 config.ts backgroundAgentParkClaimStaleMs 顶注)。
2436
- if (checkpointStore) {
2437
- const pr = await defaultTaskRegistry.reconcileParkedAgents({ agentStore: store, checkpointStore }, scope, Date.now(), { staleClaimMaxAgeMs: config.backgroundAgentParkClaimStaleMs });
2438
- if (pr.failed > 0 || pr.rolledBack > 0) {
2439
- metrics.inc("bg_agents_park_reconciled_failed_total", {}, pr.failed);
2440
- metrics.inc("bg_agents_park_reconciled_rolledback_total", {}, pr.rolledBack);
2441
- logger.info("reaper_swept", { metric: "bg_agents_park_reconciled", scope, failed: pr.failed, rolledBack: pr.rolledBack });
2442
- }
2443
- }
2444
- // deps.mailbox(core 1.374,[1533]②):行删联动 F-13——agent 行 reap 时同 drop 其信箱
2445
- // (盒生命周期随行终结;不挂=信箱行无 retention 无界涨)。
2446
- const r = await defaultTaskRegistry.reapDurableAgents(scope, { store, sessions: subRunner.sessions, ...(mailboxStore ? { mailbox: mailboxStore } : {}) }, {
2447
- maxAgeMs: config.backgroundAgentRetentionMs,
2448
- staleRunningMaxAgeMs: config.backgroundAgentStaleRunningMs,
2449
- });
2450
- rows += r.rowsReaped;
2451
- sessions += r.sessionsReleased;
2452
- skipped += r.skippedNoSessions;
2453
- }
2454
- catch (err) {
2455
- failedScopes++;
2456
- logger.warn("bg_agent_reap_scope_failed", { scope, err: err instanceof Error ? err.message : String(err) });
2457
- }
2458
- }
2459
- if (failedScopes > 0)
2460
- metrics.inc("bg_agent_reap_scope_failures_total", {}, failedScopes);
2461
- if (rows > 0 || sessions > 0) {
2462
- metrics.inc("bg_agents_reaped_total", {}, rows);
2463
- logger.info("reaper_swept", { metric: "bg_agents_reaped_total", count: rows, sessionsReleased: sessions, skippedNoSessions: skipped });
2464
- }
2465
- }
2466
- : undefined;
2467
- // S10 (SILENT-FALLBACK P1): the write-behind counters bucket on LOCAL floor(now/windowMs) — replica clock
2468
- // skew vs the DB splits a fleet window into disjoint buckets (soft-limit leak) with zero visibility. Probe
2469
- // the DB clock each reaper tick; the gauge is the fleet-wide skew fingerprint (can be negative).
2470
- const probeClockSkew = async () => {
2471
- // Probe-ok gauge: a failing probe froze the skew gauge at its last value with zero signal —
2472
- // "probe ran, skew=N" vs "probe failing for 30 min" were indistinguishable.
2473
- try {
2474
- const dbMs = await backend?.dbNowMs?.();
2475
- if (typeof dbMs === "number" && Number.isFinite(dbMs)) {
2476
- metrics.setGauge("fleet_counter_bucket_skew_ms", dbMs - Date.now());
2477
- metrics.setGauge("fleet_clock_probe_ok", 1);
2478
- }
2479
- }
2480
- catch {
2481
- metrics.setGauge("fleet_clock_probe_ok", 0);
2482
- }
2483
- };
2484
- const reaper = setInterval(() => {
2485
- void probeClockSkew().catch(() => undefined); // S10
2486
- void runStore?.reapStale(config.runStaleSec * 1000).then(reapCount("runs_reaped_total", { kind: "stale" })).catch(() => undefined);
2487
- // Durable F4 (design/45) + design/80 §3 inv#3 crash-safe backstop: CAS-expire checkpoints past their
2488
- // deadline OR their absolute terminal_at backstop (≈ deny), then fail the suspended run rows whose
2489
- // checkpoint was thereby expired (release task_active = unlock the session). These run EVERY tick,
2490
- // REGARDLESS of APPROVAL_TIMEOUT_SEC — terminal_at (stamped at put, never before an explicit deadline)
2491
- // bounds even a NULL-deadline pending checkpoint, so the backstop is the always-on safety net (it was
2492
- // inert when nested under the approvalTimeoutSec>0 guard — adversarial finding). The run-row half is
2493
- // checkpoint-STATE-driven (not a uniform timer) so it aligns with the per-row terminal_at. Global +
2494
- // idempotent across replicas, no election.
2495
- if (checkpointStore) {
2496
- // 轴A #5 注释落档(1.254):core CheckpointStore.reap 契约把弃置臂的 unpin 义务派给部署 reaper——
2497
- // 真 pin 只存在于 in-memory TtlSessionStore;本部署 durable checkpoint 恒配 durable session 后端
2498
- // (pin=no-op),deny-sweep 走 resumeCheckpoint=core 内部 unpin ✓。SESSION_BACKEND=memory+durable
2499
- // checkpoint 的 niche dev 组合下 abort-expire 臂会把被钉会话泄到进程终——显式接受,不为 dev 形加腿。
2500
- void checkpointStore.reapExpired(Date.now()).then(reapCount("checkpoints_reaped_total", {})).catch(() => undefined);
2501
- void runStore?.failSuspendedWithExpiredCheckpoint().then(reapCount("runs_reaped_total", { kind: "expired_checkpoint" })).catch(() => undefined);
2502
- // D-D SLA-timer: resolve-DENY human/irreversible_ask gates past their deadline (graceful — the model
2503
- // continues with the denial), vs the abort reapExpired gives the other kinds. Bounded per tick.
2504
- void runDenySweep?.(Date.now()).catch(() => undefined);
2505
- }
2506
- // The finer, OPT-IN per-approval TTL sweeps (APPROVAL_TIMEOUT_SEC): the F4 poll-gate store + the
2507
- // time-based suspended-run reaper. Stay gated — reapSuspended with a 0 TTL would nuke ALL suspended rows
2508
- // (cutoff = now). The absolute backstop above is the safety floor; this is the operator-chosen deadline.
2509
- if (config.approvalTimeoutSec > 0) {
2510
- void approvalStore?.expireStale(config.approvalTimeoutSec * 1000).catch(() => undefined);
2511
- void runStore?.reapSuspended(config.approvalTimeoutSec * 1000).then(reapCount("runs_reaped_total", { kind: "suspended" })).catch(() => undefined);
2512
- }
2513
- // GC checkpoint_ctx rows whose checkpoint is gone (bound to the checkpoint lifecycle).
2514
- void checkpointStore?.reapCtx(Date.now() - config.runStaleSec * 1000).catch(() => undefined);
2515
- rateLimiter?.sweep();
2516
- if (costQuota instanceof CostQuota)
2517
- costQuota.reap(); // TiDB variant self-reaps in its flush loop
2518
- void toolResultStore?.reapOlderThan?.(Date.now() - config.toolResultTtlSec * 1000)?.catch(() => undefined); // optional extra: SQL twins only (the local FileToolResultStore persists like transcripts)
2519
- // 2c session-sync: GLOBALLY GC grace-window orphan blobs (standalone /sync/blobs PUTs that were never
2520
- // imported are otherwise collected only on reap()/deleteBySession(), which never fire for a never-imported scope).
2521
- // Bounds the standalone-PUT orphan-blob exhaustion (review finding) to the grace window. Durable twins only (local
2522
- // omits the seam → optional-chained no-op).
2523
- void fileSnapshotStore?.sweepOrphanBlobs?.().catch(() => undefined);
2524
- // D-1 附件 TTL:上传后从未被任何 task 引用(session_id NULL)且超过 attachmentUnboundTtlMs 的行收割
2525
- // (绑定行不在此收——随会话 E21 级联删)。best-effort,与其余 reaper 腿同姿。
2526
- void taskAttachmentStore?.reapUnbound(Date.now() - config.attachmentUnboundTtlMs).then(reapCount("attachments_reaped_total", {})).catch(() => undefined);
2527
- // D-1 孤儿**对象**彻底 GC(clay 拍 2026-07-28):对象存储 × meta 行对账,grace 默认 1h(上传先行窗
2528
- // 保护;`ATTACHMENT_ORPHAN_GRACE_MS=0` 关掉本腿——同 tick 邻居都有旋钮,复审 F10)。
2529
- // 列举失败=本轮 warn 跳过(「列不出来」绝不当「没有孤儿」),下轮再试。
2530
- // 🔴 复审 F1:必须 in-flight 守卫——本腿时长随对象总数增长(20 万对象实测秒级、百万级可达 30s),
2531
- // 无守卫时超过 tick 间隔即逐 tick 叠加(实测叠 6 层),同时压 LIST/SQL/DELETE 三面。邻居
2532
- // wfRunReap/bgAgentReap 都有同款守卫。
2533
- if (config.attachmentOrphanGraceMs > 0 && !attachmentSweepInFlight && taskAttachmentStore?.sweepOrphanObjects) {
2534
- attachmentSweepInFlight = true;
2535
- void taskAttachmentStore
2536
- .sweepOrphanObjects(config.attachmentOrphanGraceMs)
2537
- .then((n) => { if (n > 0) {
2538
- metrics.inc("attachment_orphan_objects_swept_total", {}, n);
2539
- logger.info("attachment_orphan_objects_swept", { removed: n });
2540
- } })
2541
- .catch((err) => logger.warn("attachment_orphan_sweep_failed", { err: String(err) }))
2542
- .finally(() => { attachmentSweepInFlight = false; });
2543
- }
2544
- // P1d-β 2c session-sync: GC ABANDONED staged imports — staging-id session_event rows (`%#stg-%`) with NO
2545
- // session_meta whose oldest row is older than the grace window (an in-flight stream stays fresh → never reaped).
2546
- // Bounds the orphan-staging-row growth (a Phase B that opened a staging then never committed). Durable session
2547
- // stores only (the local backend stages in memory → no durable rows; the seam is absent → optional-chained no-op).
2548
- void backend?.session()?.sweepStagingSessions?.().catch(() => undefined);
2549
- // Bake-runner backstop (§P2.7): fail any `running` bake whose lease went stale (the runner crashed mid-build),
2550
- // append a synthetic terminal `done{failed}` so SSE readers settle, and force-release the single-flight lease.
2551
- // staleMs (≈3× the 30s heartbeat) bounds a healthy slow build so it is never wrongly reaped.
2552
- void imageBakes?.reapStaleBakes(config.imageBakes.staleMs).then(reapCount("bakes_reaped_total", {})).catch(() => undefined);
2553
- // SVC-3 worktree isolation: deregister worktrees orphaned by a process crash (the Runner never reached
2554
- // destroy → `git worktree remove` never ran). `git worktree prune` cleans registrations whose dirs are
2555
- // already gone. Best-effort, userland (core ships no post-kill Runner hook); never throws. Unset = no-op.
2556
- void worktreeReap?.();
2557
- // SVC-1 (adversarial-review HIGH): PERIODIC notify-recovery sweep (not just at boot) — re-delivers a terminal
2558
- // run whose in-process notify was lost, AND finalizes-as-abandoned a `running` run orphaned past the grace
2559
- // window (core never resumes/reaps a prior `running` row, so nothing else would). Idempotent; best-effort.
2560
- void workflowNotifyGate?.recover({ orphanGraceMs: config.workflowOrphanGraceMs }).catch(() => undefined);
2561
- // SVC-2 (adversarial-review HIGH): time-based GC of the workflow_journal table (the heaviest, TaskResult-bearing
2562
- // one) — the per-run deleteByRun has no run-store reap hook, so this bounded sweep is what stops unbounded
2563
- // growth. A resume of a journal older than the retention window re-runs live (resume is an optimization).
2564
- void workflowJournalStore?.reapExpired?.(Date.now(), config.workflowJournalRetentionMs).catch(() => undefined);
2565
- // Retention (WorkflowRunStore never auto-purges, reap is explicit):
2566
- // age out TERMINAL workflow_run rows across ALL scopes. SQL twins only (reapAllScopes is their DISTINCT-scope
2567
- // extension; the contract itself has no cross-scope enumeration, and the File store keeps the transcripts-like
2568
- // keep-everything posture). Running rows are untouched — the orphan-grace sweep above owns those.
2569
- // In-flight guard (1.108 review, lens③): the sweep enumerates EVERY scope serially — under a short
2570
- // REAP_INTERVAL_SEC + many scopes, overlapping sweeps would pile up on the pool. One at a time.
2571
- if (!wfRunReapInFlight) {
2572
- const sweep = sqlWorkflowRunStore?.reapAllScopes?.(Date.now(), { maxAgeMs: config.workflowRunRetentionMs });
2573
- if (sweep) {
2574
- wfRunReapInFlight = true;
2575
- void sweep.catch(() => undefined).finally(() => (wfRunReapInFlight = false));
2576
- }
2577
- // 1.108: SQL notify-journal retention rides the same knob — ACKED rows are pure history (pending rows are
2578
- // the recovery backlog and are NEVER reaped; the orphan-grace sweep retires a stuck pending run).
2579
- void workflowNotifyJournal
2580
- ?.reapAcked?.(Date.now() - config.workflowRunRetentionMs)
2581
- .catch(() => undefined);
2582
- }
2583
- // core 1.364 durable bg-agent joint reap(定义在 interval 上方,契约注释在彼)。in-flight 守卫同
2584
- // wfRun sweep(per-scope 串行循环,短 tick + 多 scope 下不叠罗汉)。
2585
- if (reapBgAgents && !bgAgentReapInFlight) {
2586
- bgAgentReapInFlight = true;
2587
- void reapBgAgents().catch(() => undefined).finally(() => (bgAgentReapInFlight = false));
2588
- }
2589
- // [1522] MED2:agent_roster TTL 清理(core RB-23②③ 派给部署的半场)——SQL twins 扩展面
2590
- // (reapOlderThan,duck probe;File/Memory 店 core 自带 maxAgeMs,无此面=no-op)。
2591
- void rosterStore
2592
- ?.reapOlderThan?.(Date.now() - config.rosterRetentionMs)
2593
- .then(reapCount("roster_rows_reaped_total", {}))
2594
- .catch(() => undefined);
2595
- // 修8(三路复审 absorb-2,接线 (b)):periodic scratchpad sweep — 无 E21 purge 兜到的孤儿目录(session 从未
2596
- // DELETE、purge 当次失败、local 后端无 purge coordinator)按 mtime 过期回收。SCRATCHPAD_SWEEP_TTL_MS
2597
- // (default 7d,0=禁用)。activeSessionIds 不传:活跃判据的诚实边界在 env-facts.ts 的 sweep 文档——TTL 7d
2598
- // 远大于任何在飞任务;一个 7 天零写入的 scratchpad 被回收是可接受的(它本就是临时区,fact 文案即如此宣示)。
2599
- if (config.scratchpadSweepTtlMs > 0) {
2600
- void sweepStaleScratchpads(config.localDataRoot ?? localRoot, { olderThanMs: config.scratchpadSweepTtlMs })
2601
- .then((removed) => {
2602
- if (removed > 0)
2603
- logger.info("scratchpads_swept", { removed });
2604
- })
2605
- .catch(() => undefined);
2606
- }
2607
- }, config.reapIntervalSec * 1000);
2608
- reaper.unref?.();
596
+ // design/158 A10:reaper 段搬到 src/boot/reapers.ts(逐字;deny-sweep 晚绑改取值,见该文件头注)
597
+ const reaper = startReapers({
598
+ config, logger, metrics, localRoot, backend, subRunner, runStore, checkpointStore, approvalStore,
599
+ rateLimiter, costQuota, toolResultStore, fileSnapshotStore, taskAttachmentStore, imageBakes, worktreeReap,
600
+ workflowNotifyGate, workflowJournalStore, sqlWorkflowRunStore, workflowNotifyJournal, rosterStore,
601
+ backgroundAgentStore, mailboxStore,
602
+ getRunDenySweep: () => runDenySweep,
603
+ });
2609
604
  // Optional OTLP/HTTP metrics export (1.37). Periodically pushes the registry to an OTel collector;
2610
605
  // best-effort (a collector outage is logged, never affects serving). /metrics stays available too.
2611
606
  const otelExporter = config.otel
@@ -2614,592 +609,20 @@ async function main() {
2614
609
  onError: (err) => logger.warn("otel_export_failed", { err: String(err) }),
2615
610
  })
2616
611
  : undefined;
2617
- // Config refresh: re-pull with the ETag; teams hot-reload via the registry. A models/roles
2618
- // change updates config but the live Runner keeps its startup set logged so ops restart to apply.
2619
- // 提示词装配协议 §9.5-1/-3: the loop goes through the ConfigProvider seam (never the raw HTTP fetch —
2620
- // one resolver, no remote/local drift) and runs for the LOCAL provider too: config.d edits are picked
2621
- // up by the same 60s poll (the local provider's etag = the store's raws hash, so an unchanged tree is
2622
- // a cheap null and a corrupt↔fixed flip bumps the version). Local mode previously read config.d
2623
- // exactly once at boot.
2624
- if (configProvider) {
2625
- const ccRef = config.configCenter;
2626
- // Single-flight (codex F2): an async tick body under setInterval has no serialization — a slow read
2627
- // (degraded disk/远端) outliving the 60s interval would let a LATER tick apply a newer candidate and
2628
- // the stalled OLDER read then complete and roll the config (and ccEtag) back. Skip the tick while one
2629
- // is in flight; the flag flips in `finally`, so a throw never wedges the loop shut.
2630
- let refreshInFlight = false;
2631
- const refreshTick = async (prefetched) => {
2632
- if (refreshInFlight)
2633
- return;
2634
- refreshInFlight = true;
2635
- try {
2636
- // codex-3/4 F1:欠账的 catalog 过渡在**每个真拍**开头无条件重试——与 fetch 成败/候选好坏解耦
2637
- // (304、fetch 抛错、候选被整拒的拍此前全都跳过重试=盘错恢复后过渡仍卡死)。次序安全:本拍
2638
- // 若随后采用了更新候选,adopt 自会覆盖/清空 pending(先重试旧账、后采新目标,永不倒退)。
2639
- await retryPendingCatalog("refresh-retry");
2640
- // codex R18: the stuck-deferred state must not vanish behind the advanced etag — re-warn + count on
2641
- // EVERY tick (incl. 304s and fetch-error ticks) while it persists; /health carries the same state.
2642
- // The RECOVERY (persist retry + promotion) moved BELOW the fetch (codex R29): promoting before
2643
- // adjudicating the current source generation could publish a SUPERSEDED candidate N as the
2644
- // boot-visible LKG while N+1 exists — a restart in that window resurrects withdrawn config.
2645
- if (planeDeferredNoHandoff) {
2646
- metrics.inc("models_tiers_plane_deferred_stuck_total");
2647
- logger.warn("models_tiers_deferred_no_handoff", { version: planeDeferredNoHandoff.version, since: planeDeferredNoHandoff.since, note: "candidate still retained without a next-boot handoff — mount restart-surviving LKG storage + CONFIG_LKG_DURABLE=true, or restart manually after ensuring the candidate is re-pulled at boot" });
2648
- }
2649
- // F3(LKG 复审):LKG-boot 的 deferred 到货把 unconditional 结果直接喂进来——丢弃它再按 LKG 的
2650
- // etag 条件拉,会在「center 重建且 etag 值撞旧」的场景永远 304=陈旧配置钉死;消费原始结果则
2651
- // 应用后 ccEtag=真实新值,陈旧面消除(顺带省一次拉取)。
2652
- const r = prefetched !== undefined ? prefetched : await configProvider.fetchEffective(ccEtag);
2653
- // codex R23/R29 recovery lane: a 304 (r === null) CONFIRMS the retained candidate's generation is
2654
- // still the source's current one (its etag advanced when it was retained) — only then is it safe to
2655
- // retry persistence and promote the withheld restart signal. A fetch that returned a NEWER candidate
2656
- // takes the normal branches below (which replace/supersede the retained state); a fetch that THREW
2657
- // never reaches here (freshness unknown ⇒ no promotion this tick). Retry only where promotion is
2658
- // possible (durable-declared lane; R37 adds the non-durable skills-debt lane, whose retry doubles as
2659
- // the repair proof) — elsewhere a per-tick write could never promote and just burns IO.
2660
- if (r === null && planeDeferredNoHandoff) {
2661
- // Retry where promotion is possible: the durable-declared lane (full handoff), and — codex R37 —
2662
- // the NON-durable skills-debt lane, where the retry's body prewarm+verify is the repair PROOF that
2663
- // gates the withheld skills restart (see lastPrewarmBodiesVerified).
2664
- const skillsDebt = (planeDeferredNoHandoff.blocked ?? []).includes("skills");
2665
- const promoted = lkgSurvivesRestart || skillsDebt ? await persistLkgDurable(planeDeferredNoHandoff.candidate, planeDeferredNoHandoff.candidateEtag) : false;
2666
- // codex R37 proof-gated promotion (non-durable lane): every enabled body now hash-verifies on disk
2667
- // ⇒ the center + body endpoint are BOTH live, a restarted process re-pulls successfully — publish
2668
- // the skills restart exactly now, never on manifest-success alone (restart-storm breaker: a boot
2669
- // that fails the body again lacks the proof and returns to visible-debt, not to signaling).
2670
- // models-tiers never rides this arm (R16: a FORCED plane restart requires a durable handoff), so a
2671
- // retained candidate that ALSO defers the plane keeps everything withheld until durability exists.
2672
- const skillsProofPromotion = !promoted && !lkgSurvivesRestart && skillsDebt && lastPrewarmBodiesVerified && planeDeferredNoHandoff.planeDeferred !== true;
2673
- if (skillsProofPromotion) {
2674
- pendingRestart = { restartRequired: true, reasons: ["skills"], version: planeDeferredNoHandoff.version, since: Date.now() };
2675
- logger.info("config_boot_skill_debt_promoted", { version: planeDeferredNoHandoff.version, note: "skill bodies verified on a source-confirmed-current candidate — the withheld skills restart is now published (non-durable lane, repair proven)" });
2676
- planeDeferredNoHandoff = undefined;
2677
- }
2678
- else if (promoted) {
2679
- // models-tiers joins only when the PLANE was deferred (codex R27: a skill-only retained candidate
2680
- // must not manufacture a plane reason on promotion). codex R30: an EMPTY promotion (boot
2681
- // publication retry — nothing was withheld, everything already applied) publishes NO restart
2682
- // signal; it just completes the LKG and clears the state.
2683
- const promotedReasons = [...new Set([...(planeDeferredNoHandoff.blocked ?? []), ...(planeDeferredNoHandoff.planeDeferred ? ["models-tiers"] : [])])];
2684
- if (promotedReasons.length > 0) {
2685
- pendingRestart = { restartRequired: true, reasons: promotedReasons, version: planeDeferredNoHandoff.version, since: Date.now() };
2686
- }
2687
- logger.info("models_tiers_deferred_promoted", { version: planeDeferredNoHandoff.version, reasons: promotedReasons, note: promotedReasons.length > 0 ? "handoff persistence recovered on a source-confirmed-current candidate — the withheld restart signal is now published" : "boot publication retry completed — LKG now durable, no restart needed (nothing was withheld)" });
2688
- planeDeferredNoHandoff = undefined;
2689
- }
2690
- }
2691
- if (r) {
2692
- // §9.5-5 candidate gate: ANY invalid domain rejects the WHOLE candidate — the live config
2693
- // keeps serving the LKG (`latestEffective`) instead of that domain falling to schema-default/
2694
- // empty. ccEtag is deliberately NOT advanced: the bad tree stays visible (re-judged each poll),
2695
- // and fixing the file bumps the version so the next poll lands. The dedupe key is the SORTED
2696
- // de-duplicated domain set (codex F3: the collector's ordering isn't guaranteed, a raw join
2697
- // would re-warn every poll), and the first warn carries the full per-domain error detail —
2698
- // that's the actionable diagnosis, the key only mutes repeats. The prompts-domain artifact
2699
- // validation (core S1) hooks into this same gate when it ships.
2700
- // F6(二轮复审):prompts 面判形进候选门——坏 pack(重复 id/空数组/坏信封)若只在 boot 验,
2701
- // refresh 会带着它推进 etag+发 restart 信号,重启后 boot 拒之回内置=好包 A 被坏包 B 顶掉。
2702
- // 这里与 domainErrors 同门:坏形=整拒候选(etag 不进,LKG/现用包 A 继续服务,持续 warn 到修复)。
2703
- const promptsRaw = r.effective.prompts;
2704
- const promptsGate = promptsRaw !== undefined ? validatePromptsDomain(promptsRaw) : { ok: true };
2705
- const badDomains = [
2706
- ...new Set([...(r.domainErrors ?? []).map((de) => de.domain), ...(promptsGate.ok ? [] : ["prompts"])]),
2707
- ]
2708
- .sort()
2709
- .join(",");
2710
- if (badDomains) {
2711
- if (lastRejectedCandidate?.version !== r.effective.version || lastRejectedCandidate.domains !== badDomains) {
2712
- lastRejectedCandidate = { version: r.effective.version, domains: badDomains, at: Date.now() };
2713
- logger.warn("config_candidate_rejected", {
2714
- version: r.effective.version,
2715
- domains: badDomains,
2716
- errors: [
2717
- ...(r.domainErrors ?? []).map((de) => ({ domain: de.domain, error: de.error })),
2718
- ...(promptsGate.ok ? [] : [{ domain: "prompts", error: promptsGate.error }]),
2719
- ],
2720
- lkgVersion: latestEffective?.version ?? "(env)",
2721
- note: "candidate rejected whole (§9.5-5) — live config keeps the last-known-good; fix the named files to unblock",
2722
- });
2723
- }
2724
- return;
2725
- }
2726
- lastRejectedCandidate = undefined;
2727
- // ccEtag advances at the END of each branch (codex lens-1 HIGH): advancing before apply meant
2728
- // an applyEffective throw mid-way left a MIXED-generation live config AND a cached etag — the
2729
- // next poll 304'd, so the failed candidate was never retried. Now a throw keeps the old etag,
2730
- // the next poll re-fetches the same candidate, and the (idempotent) re-apply self-heals.
2731
- if (ccRef?.dryRun) {
2732
- logEffectiveDiff(config, r.effective, logger); // compare-only: surface ongoing center changes, apply nothing
2733
- ccEtag = r.etag;
2734
- }
2735
- else {
2736
- // refresh: teams + runtime governance + MODELS/ROLES/ROSTER now hot. teamsOnly skips
2737
- // only the boot-only runtime GATES (rate/cost/approval). applyEffective mutates config.models/roles
2738
- // IN PLACE (the Runner's live ref) — but `pricing` (separate object the Runner also reads live) and
2739
- // `keyResolver` (closes over modelApiKeyEnv) are ours to refresh.
2740
- // codex R10: hot-apply of the MODEL PLANE holds ONLY when the Runner shares our map (tier-less at
2741
- // construction). A tier-frozen Runner keeps a private expanded copy — hot-applying a changed plane
2742
- // would split admission from execution (same-key retarget silently routes/bills the stale Model
2743
- // object through the restart window). Defer the plane; the models-tiers slice below signals the
2744
- // orchestrator; the restarted process applies it at boot BEFORE Runner construction.
2745
- // codex R12: defer also when the CANDIDATE activates tiers on a tier-less-booted Runner — it never
2746
- // re-runs expandTiers, so hot-applying the arriving tier table would let tier words pass the
2747
- // expanded admission gate while core throws "Unknown model ref" until restart. Hot-apply is safe
2748
- // only when BOTH generations are tier-less.
2749
- const planeDeferred = (runnerTierFrozen || planeHasActiveTiers(r.effective)) && modelPlaneChanged(appliedPlaneEff, r.effective);
2750
- applyEffective(config, r.effective, logger, { teamsOnly: true, sealedKeys, ...(planeDeferred ? { deferModelPlane: true } : {}) });
2751
- if (planeDeferred) {
2752
- logger.warn("models_tiers_plane_deferred", { version: r.effective.version, note: "tier-frozen Runner: the changed model plane (models/roles/tiers/default) is NOT hot-applied — admission stays on the Runner's generation; restart applies the new plane (models-tiers restart signal rides /health)" });
2753
- }
2754
- else {
2755
- appliedPlaneEff = r.effective;
2756
- planeDeferredNoHandoff = undefined; // plane landed — the stuck state (if any) is resolved
2757
- }
2758
- await adoptCenterPrompts(r.effective, "refresh"); // 热采用(core 1.316 声明轴:新任务边界自动重 pin,见 ref 注释)
2759
- // codex R13: readiness may open ONLY off an APPLIED plane — a deferred candidate's roster is not
2760
- // installed (config.models still the old/placeholder generation), so opening the billable gate on
2761
- // it would route/bill the wrong model until restart. Deferred ⇒ gate stays as-is; the restarted
2762
- // process applies the candidate pre-Runner and opens it at boot.
2763
- if (!planeDeferred)
2764
- markRosterLanded(r.effective); // boot pull 失败/未发布时,refresh 落 roster 同样开门
2765
- mutateInPlace(pricing, buildPricing(config.models)); // hot: cost/model changes; Runner reads this.deps.pricing live
2766
- keyResolver = createKeyResolver(config.modelApiKeyEnv, process.env, config.modelApiKeys); // hot: per-model key add/remove/change (env-ref + sealed)
2767
- // skills/mcp/scenarios/runtime-gates are baked into the live process at boot (buildScenarios / boot
2768
- // middleware) — a refresh carrying a DIFFERENT value can't hot-apply, only a restart re-reads them.
2769
- // We compare against the BOOT snapshot (`effective`), NOT presence: an orchestrator auto-restarts on
2770
- // a non-empty `reasons` WITHOUT a restart loop (same diff every 60s is idempotent, not a fresh trigger).
2771
- // Sticky `since`: keep the first-detected timestamp while `reasons` is unchanged; reset it when the set
2772
- // changes; CLEAR the signal when a later pull reverts to boot (reasons empty) — restart no longer needed.
2773
- // F2(LKG 复审):落盘 **await 且先于** restart 信号可见——orchestrator 见信号即杀进程,
2774
- // fire-and-forget 的 rename 可能没完成 → 下一进程读旧 LKG → 同差异重现 = 本功能要消的环。
2775
- // 顺序=先持久化(含 skill 正文预热,F1)再发信号;持久化失败=warn+照发(不发=配置永不生效;
2776
- // 「盘坏+中心挂」双故障下环重现,接受并点名)。
2777
- const lkgPersisted = await persistLkgDurable(r.effective, r.etag);
2778
- const reasons = restartReasons(effective, r.effective);
2779
- // codex R35: the sticky boot skill debt rides EVERY tick's reasons — change-detection vs boot is
2780
- // blind to it (the stale table IS the boot baseline). Under a no-handoff deferral it folds into
2781
- // blocked (promoted later); otherwise it keeps pendingRestart alive until the restart happens.
2782
- // codex R38: change-DETECTED skills (manifest actually changed) is distinguished from the sticky
2783
- // rider — the former keeps the pre-existing documented posture (rides regardless; the restart
2784
- // pulls the NEW generation live), the latter is proof-gated below on the non-durable lane.
2785
- const skillsChangeDetected = reasons.includes("skills");
2786
- if (bootSkillStale && !skillsChangeDetected)
2787
- reasons.push("skills");
2788
- // codex R15/R16/R19: a DEFERRED plane is adjudicated by HANDOFF STATUS alone — independent of
2789
- // whether change-detection already produced the reason (R19: an already-present models-tiers with
2790
- // no handoff sailed through = the exact storm R16 closed). With a confirmed next-boot handoff the
2791
- // reason is ensured present (R15: env-baseline blind spot) and the stuck state clears; without one
2792
- // the reason is SUPPRESSED (a restart the next process cannot satisfy — over-budget boot pull + no
2793
- // LKG — is a rolling-restart storm) and the persistent stuck state + per-tick warn carry the
2794
- // observability instead. Non-plane reasons (skills/mcp/…) keep riding either way.
2795
- if (planeDeferred) {
2796
- if (lkgPersisted) {
2797
- if (!reasons.includes("models-tiers"))
2798
- reasons.push("models-tiers");
2799
- planeDeferredNoHandoff = undefined; // handoff exists — the restart signal carries observability
2800
- }
2801
- else {
2802
- // codex R22: suppress the candidate's ENTIRE restart signal, not just models-tiers — a
2803
- // co-occurring skills/mcp reason would still restart the worker, and with no handoff the
2804
- // replacement can't land ANY of this candidate (same over-budget pull) = the same storm.
2805
- // The blocked reasons stay observable (state + warn) and promote once a handoff exists.
2806
- const blocked = reasons.splice(0, reasons.length);
2807
- planeDeferredNoHandoff = { version: r.effective.version, since: planeDeferredNoHandoff?.since ?? Date.now(), ...(blocked.length > 0 ? { blocked } : {}), planeDeferred: true, candidate: r.effective, ...(r.etag !== undefined ? { candidateEtag: r.etag } : {}) };
2808
- logger.warn("models_tiers_deferred_no_handoff", { version: r.effective.version, blockedReasons: blocked, note: "plane deferred but no NEXT-BOOT handoff (LKG disabled/unwritable, or storage not declared restart-surviving — set CONFIG_LKG_DURABLE=true) — the WHOLE restart signal is withheld (it would loop); the old generation keeps serving" });
2809
- }
2810
- }
2811
- else if (lkgSurvivesRestart && !lkgPersisted) {
2812
- // codex R27/R31: retention is NOT conditional on restart reasons — a hot-only candidate
2813
- // (autonomy/prompts/teams/tier-less models) with a failed publication would otherwise advance
2814
- // the etag and never retry (304s), and a restart during a center outage would roll back to the
2815
- // older LKG (weaker governance). blocked may be [] = publication-only recovery (R30 promotion
2816
- // publishes no signal for an empty set).
2817
- // codex R27: a DURABLE-DECLARED deployment whose candidate publication is incomplete (skill-body
2818
- // verify failed → LKG not replaced) must not signal restart off that candidate either — the next
2819
- // boot would land the OLD LKG and re-signal while the center stays unavailable. Retain + withhold
2820
- // + per-tick retry-promote, same machinery as the deferred plane (no models-tiers added on
2821
- // promotion — the plane was NOT deferred here). Non-declared deployments keep the pre-existing
2822
- // documented posture (restart converges off a live boot pull when the center is healthy).
2823
- const blocked = reasons.splice(0, reasons.length);
2824
- planeDeferredNoHandoff = { version: r.effective.version, since: planeDeferredNoHandoff?.since ?? Date.now(), blocked, candidate: r.effective, ...(r.etag !== undefined ? { candidateEtag: r.etag } : {}) };
2825
- logger.warn("restart_candidate_publication_incomplete", { version: r.effective.version, blockedReasons: blocked, note: "durable LKG declared but the candidate's publication is incomplete (skill bodies unverified) — restart signal withheld; retried every tick until the cache completes" });
2826
- }
2827
- else if (!planeDeferred && !lkgSurvivesRestart && bootSkillStale && !skillsChangeDetected && !lastPrewarmBodiesVerified) {
2828
- // codex R38: the NON-durable lane's sticky rider must clear the SAME proof gate as the boot/304
2829
- // arms (R37) — a same-generation 200 (center omits/rotates ETags or ignores conditionals) lands
2830
- // here with reasons=[skills] purely from the sticky debt, and publishing it unproven re-opens
2831
- // the restart storm R37 closed (every replacement fails the same body, 200s again, signals
2832
- // again). persistLkgDurable just ran for THIS candidate, so the proof flag is current: unproven
2833
- // ⇒ strip the rider from the publishable set and retain it as visible debt (the same
2834
- // planeDeferredNoHandoff machinery the 304 recovery lane re-verifies and proof-promotes).
2835
- // Proven or durable-handoff ⇒ the rider rides as before. Change-DETECTED skills is exempt
2836
- // (pre-existing posture — the restart pulls the NEW generation, a different repair claim).
2837
- const i = reasons.indexOf("skills");
2838
- if (i >= 0) {
2839
- reasons.splice(i, 1);
2840
- planeDeferredNoHandoff = { version: r.effective.version, since: planeDeferredNoHandoff?.since ?? Date.now(), blocked: ["skills"], candidate: r.effective, ...(r.etag !== undefined ? { candidateEtag: r.etag } : {}) };
2841
- logger.warn("config_boot_skill_debt_withheld", { version: r.effective.version, note: "boot skill debt still unproven on a non-durable deployment (body cache does not verify) — skills restart withheld this tick; re-verified each tick and published once the bodies prove fetchable" });
2842
- }
2843
- }
2844
- if (reasons.length === 0) {
2845
- pendingRestart = undefined;
2846
- }
2847
- else if (!pendingRestart || pendingRestart.reasons.join(",") !== reasons.join(",")) {
2848
- pendingRestart = { restartRequired: true, reasons, version: r.effective.version, since: Date.now() };
2849
- }
2850
- else {
2851
- pendingRestart = { ...pendingRestart, version: r.effective.version };
2852
- }
2853
- logger.info("sema_registry_refreshed", {
2854
- version: r.effective.version,
2855
- // hot-applied: teams + runtime governance (autonomy/commandPolicy) + MODELS/ROLES/ROSTER (+pricing
2856
- // +per-model keys). Still restart-to-apply: skills/mcp/scenarios + runtime GATES (rate/cost/approval).
2857
- note: "teams + runtime governance + models/roles/roster hot-applied; skills/mcp/runtime-gates/scenarios are restart-to-apply",
2858
- // Structured signal: orchestrator consumes the SAME object via GET /health.restart.
2859
- ...(pendingRestart ? { restartRequired: true, restartReasons: pendingRestart.reasons } : {}),
2860
- });
2861
- // BOTH the LKG and the etag advance ONLY after the whole apply/pricing/keyResolver/restart
2862
- // chain succeeded (codex lens-3): if any step above throws, latestEffective keeps pointing at
2863
- // the last FULLY-applied candidate and the etag stays put, so the next poll re-fetches and
2864
- // re-applies the same candidate (idempotent self-heal) — the LKG never names a half-applied one.
2865
- latestEffective = r.effective; // LKG advance (§9.5-2): candidate validated + fully applied
2866
- ccEtag = r.etag;
2867
- }
2868
- }
2869
- }
2870
- catch (err) {
2871
- logger.warn("sema_registry_refresh_failed", { err: String(err), note: "etag NOT advanced — the same candidate is re-fetched and re-applied next poll" });
2872
- }
2873
- finally {
2874
- refreshInFlight = false;
2875
- }
2876
- };
2877
- const ccTimer = setInterval(() => void refreshTick(), 60_000);
2878
- ccTimer.unref?.();
2879
- // Boot-deferred continuation(二轮复审 F5 改形):到货结果按「迟到的 boot」处理,而不是转普通 tick——
2880
- // 普通 tick 的 restartReasons(undefined, r) 会把 prompts/skills 面全判为差异 → restart → 中心持续慢时
2881
- // 下一进程又 deferred → 无限重启环。迟到 boot:①候选门同 F6(坏形=拒,cadence 重判);②热面
2882
- // teamsOnly apply(boot-only gates/skills/mcp 已按 env 建,迟到不可应用——诚实);③prompts adopt
2883
- // (进程刚起、会话面≈空,失真面⊆重启路径,且 adopt 后 prompts 不再贡献 restart 理由=prompts 无环);
2884
- // ④一次性诚实 restart 信号=剩余 restart-to-apply 面(skills/mcp/scenarios/gates)非空才发;⑤基线
2885
- // `effective`/LKG/etag 落位 → 后续 cadence 与到货值比较,不重复触发。⚠️ 残余环面(诚实记账):center
2886
- // 发布了 skills/mcp 且中心**持续**慢于 boot 预算时,每个新进程仍会为真缺失的 skills 面发一次 restart
2887
- // ——完整解=LKG 落盘(boot 直读上个进程的干净候选),已报 clay 裁量。
2888
- const deferredBootApply = async (r) => {
2889
- if (!r)
2890
- return;
2891
- // F6(LKG 复审三轮):与 cadence tick 同一 single-flight 车道——deferred 的 skill 预热可能拖过 60s,
2892
- // 期间 cadence 落了更新的候选 B,A 迟到完成再覆盖盘/信号=回滚。inFlight 时直接让行(丢弃 deferred
2893
- // 结果无损:cadence 已经/即将拉到更新的),持有 flag 至信号发布后。
2894
- if (refreshInFlight)
2895
- return;
2896
- refreshInFlight = true;
2897
- try {
2898
- await deferredBootApplyInner(r);
2899
- }
2900
- finally {
2901
- refreshInFlight = false;
2902
- }
2903
- };
2904
- const deferredBootApplyInner = async (r) => {
2905
- const promptsRaw = r.effective.prompts;
2906
- const promptsGate = promptsRaw !== undefined ? validatePromptsDomain(promptsRaw) : { ok: true };
2907
- if ((r.domainErrors ?? []).length > 0 || !promptsGate.ok) {
2908
- logger.warn("config_boot_deferred_candidate_rejected", {
2909
- version: r.effective.version,
2910
- errors: [...(r.domainErrors ?? []).map((de) => ({ domain: de.domain, error: de.error })), ...(promptsGate.ok ? [] : [{ domain: "prompts", error: promptsGate.error }])],
2911
- note: "late boot pull carried an invalid candidate — env fallback keeps serving; the refresh cadence re-judges the same tree (etag not advanced)",
2912
- });
2913
- return;
2914
- }
2915
- if (ccRef?.dryRun) {
2916
- logEffectiveDiff(config, r.effective, logger);
2917
- ccEtag = r.etag;
2918
- return;
2919
- }
2920
- // codex R10 (deferred-boot twin of the refresh-lane guard): the Runner was built from the ENV plane before
2921
- // this late arrival — if it froze a tier-expanded copy, the arriving center plane must not hot-apply
2922
- // (admission/Runner split). Tier-less env boot (the common deferred-boot shape) keeps true hot-apply.
2923
- const planeDeferredLate = (runnerTierFrozen || planeHasActiveTiers(r.effective)) && modelPlaneChanged(appliedPlaneEff, r.effective);
2924
- applyEffective(config, r.effective, logger, { teamsOnly: true, sealedKeys, ...(planeDeferredLate ? { deferModelPlane: true } : {}) });
2925
- if (planeDeferredLate)
2926
- logger.warn("models_tiers_plane_deferred", { version: r.effective.version, note: "tier-frozen Runner (env tiers): the late-boot center model plane is NOT hot-applied — restart applies it" });
2927
- else {
2928
- appliedPlaneEff = r.effective;
2929
- planeDeferredNoHandoff = undefined;
2930
- }
2931
- await adoptCenterPrompts(r.effective, "boot-deferred");
2932
- if (!planeDeferredLate)
2933
- markRosterLanded(r.effective); // codex R13: same guard as the refresh lane — never open readiness off an unapplied plane
2934
- mutateInPlace(pricing, buildPricing(config.models));
2935
- keyResolver = createKeyResolver(config.modelApiKeyEnv, process.env, config.modelApiKeys);
2936
- effective = r.effective; // restart 比较基线=到货值(cadence 不再重复触发)
2937
- latestEffective = r.effective;
2938
- ccEtag = r.etag;
2939
- const lkgPersistedLate = await persistLkgDurable(r.effective, r.etag); // F2 parity:信号可见前落盘+skill 正文预热
2940
- const reasons = restartReasons(undefined, r.effective); // (prompts 不在 restart slices——热采用已在上方 adopt)
2941
- // codex R15 (late-boot twin): tiered env boot + tier-less late candidate ⇒ deferral fires but BOTH slice
2942
- // fingerprints reduce to null (undefined baseline, tier-less candidate) — no models-tiers reason, plane
2943
- // deferred forever. Deferral is the ground truth; force the reason so the orchestrator restarts and the
2944
- // next process lands the candidate at boot. codex R16: forced ONLY with a confirmed LKG handoff — without
2945
- // it the restarted process re-enters this exact lane (env plane, late candidate) = restart storm; keep
2946
- // serving the old generation and warn instead.
2947
- // codex R19 (late-boot twin): handoff status decides, regardless of whether change-detection already
2948
- // produced the reason — an active-tier candidate over an env boot deterministically carries models-tiers
2949
- // from restartReasons(undefined, candidate), and letting it through with no handoff is the R16 storm.
2950
- if (planeDeferredLate) {
2951
- if (lkgPersistedLate) {
2952
- if (!reasons.includes("models-tiers"))
2953
- reasons.push("models-tiers");
2954
- planeDeferredNoHandoff = undefined;
2955
- }
2956
- else {
2957
- // codex R22 (late-boot twin): withhold the WHOLE signal — co-occurring skills/mcp reasons would
2958
- // restart into the same over-budget/no-LKG lane and loop.
2959
- const blocked = reasons.splice(0, reasons.length);
2960
- planeDeferredNoHandoff = { version: r.effective.version, since: planeDeferredNoHandoff?.since ?? Date.now(), ...(blocked.length > 0 ? { blocked } : {}), planeDeferred: true, candidate: r.effective, ...(r.etag !== undefined ? { candidateEtag: r.etag } : {}) };
2961
- logger.warn("models_tiers_deferred_no_handoff", { version: r.effective.version, blockedReasons: blocked, note: "late-boot plane deferred but no NEXT-BOOT handoff (see CONFIG_LKG_DURABLE) — the WHOLE restart signal is withheld (it would loop); old generation keeps serving" });
2962
- }
2963
- }
2964
- else if (planeDeferredLate === false && lkgSurvivesRestart && !lkgPersistedLate) {
2965
- // codex R31 (late-boot twin): retain failed publications even with zero restart reasons.
2966
- // codex R27 (late-boot twin): durable-declared + incomplete publication ⇒ retain + withhold + retry.
2967
- const blocked = reasons.splice(0, reasons.length);
2968
- planeDeferredNoHandoff = { version: r.effective.version, since: planeDeferredNoHandoff?.since ?? Date.now(), blocked, candidate: r.effective, ...(r.etag !== undefined ? { candidateEtag: r.etag } : {}) };
2969
- logger.warn("restart_candidate_publication_incomplete", { version: r.effective.version, blockedReasons: blocked, note: "late-boot candidate publication incomplete on a durable-declared deployment — restart signal withheld; retried every tick" });
2970
- }
2971
- if (reasons.length > 0)
2972
- pendingRestart = { restartRequired: true, reasons, version: r.effective.version, since: Date.now() };
2973
- logger.info("config_loaded_deferred", { source: configProvider.kind, version: r.effective.version, models: (r.effective.models?.models ?? []).filter((m) => m.enabled !== false).length, ...(reasons.length > 0 ? { restartRequired: true, restartReasons: reasons } : {}) });
2974
- };
2975
- // LKG 起服的进程有完整 boot 基线 → 到货走普通 tick(候选门+hot+对 LKG 的 restartReasons,通常
2976
- // 零差异=无信号无环);env 起服(无 LKG)才走「迟到 boot」路径。
2977
- if (bootConfigPending)
2978
- void bootConfigPending.then((r) => (lkgBooted ? refreshTick(r) : deferredBootApply(r)), () => { });
2979
- }
2980
- // Per-model API key (sema-registry `apiKeyEnv`): resolve each model's own upstream key per brain call /
2981
- // cascade rung. undefined when no per-model keys are configured → spec field stays unset (core default).
2982
- // `let` (not const): rebuilt on a sema-registry refresh so per-model key ADDITIONS hot-apply too (the
2983
- // resolver is undefined when no per-model keys exist, so in-place mutation alone wouldn't cover empty→non-empty).
2984
- let keyResolver = createKeyResolver(config.modelApiKeyEnv, process.env, config.modelApiKeys);
2985
- // PAIR-REVIEW F-6:Bearer-only 网关 × per-model key 组合死角的 boot 响亮告警——core 的 per-call auth 是
2986
- // **整体替换**语义(1.345:per-call apiKey 在场即剥构造期 Authorization,x-api-key 成唯一凭据),而
2987
- // key-resolver 只会产 { apiKey } 形;部署若以 ANTHROPIC_AUTH_TOKEN(Bearer)直连此类网关,则每个配了
2988
- // per-model key 的模型必 401 且无从自诊。headers 形支持候 core/registry additive 席位(跨仓件);此前
2989
- // 先把组合不可用讲清楚。只 warn 不拒:混合网关(两种头都收)是存在的,fail-open+可诊断。
2990
- if (process.env.ANTHROPIC_AUTH_TOKEN && !process.env.ANTHROPIC_API_KEY && (Object.keys(config.modelApiKeys).length > 0 || Object.keys(config.modelApiKeyEnv).length > 0)) {
2991
- logger.warn("per_model_key_bearer_gateway_combo", {
2992
- perModelKeys: Object.keys(config.modelApiKeys).length + Object.keys(config.modelApiKeyEnv).length,
2993
- note: "ANTHROPIC_AUTH_TOKEN (Bearer) deployment with per-model keys: core's per-call auth REPLACES the Authorization header with x-api-key for those models — a Bearer-only gateway will 401 them. Remove the per-model key for such models, or use a gateway accepting x-api-key.",
2994
- });
2995
- }
2996
- // Audit回溯 + degenerate-output instrument are TiDB-specific raw-SQL consumers (outside the store
2997
- // abstraction); they run only on the TiDB backend (the mysql2 pool), and are a no-op on PG until ported.
2998
- const mysqlPool = backend?.mysqlPool();
2999
- const auditPgPool = backend?.pgPool(); // the audit face now has a PG twin — web session area lights up on DB_BACKEND=pg
3000
- const auditLocalRoot = backend?.kind === "local" ? (config.localDataRoot ?? localRoot) : undefined; // the File leg
3001
- const ownerAware = sessionStore;
3002
- const sessionAudit = mysqlPool || auditPgPool || auditLocalRoot
3003
- ? async (sessionId) => {
3004
- const audit = mysqlPool
3005
- ? await buildSessionAudit(mysqlPool, sessionId)
3006
- : auditPgPool
3007
- ? await buildSessionAuditPg(auditPgPool, sessionId)
3008
- : await buildSessionAuditLocal(auditLocalRoot, sessionId);
3009
- if (!audit)
3010
- return undefined;
3011
- return { ...audit, owner: (await ownerAware.ownerOf?.(sessionId)) ?? null };
3012
- }
3013
- : undefined;
3014
- // §0.5 E21 — DELETE /v1/sessions/:id purge coordinator. Deletes the session abstraction's CONVERSATION HISTORY
3015
- // (sessionStorage.deleteSession → session_meta/session_event) PLUS the service-owned operational rows for that
3016
- // session: the runs ledger (runStore.deleteBySession → task_run/task_active/task_event, owner-guarded), the
3017
- // durable checkpoints + resume-ctx (checkpointStore.deleteBySession, present only under DURABLE_APPROVAL), and
3018
- // offloaded tool results (toolResultStore.deleteBySession). Wired only when the session store can actually
3019
- // delete (a DB backend).
3020
- //
3021
- // 🔴 ORDERING (adversarial-review HIGH — privacy/right-to-delete + silent-success): the session HISTORY delete
3022
- // (session_meta) is the `ownerOf` gate the DELETE route consults to decide idempotency. It MUST be the LAST
3023
- // committed leg. If a privacy-relevant child leg (runs/checkpoint/tool-result) is deleted AFTER the meta row is
3024
- // gone and then FAILS, the idempotent retry sees `ownerOf===undefined` (history already gone) → the route
3025
- // short-circuits to 200 {deleted:false} and NEVER re-purges → those child rows are stranded while the API
3026
- // reports success. So we purge the child legs FIRST and PROPAGATE any error (do NOT swallow): on a child
3027
- // failure the history delete is SKIPPED, `session_meta` survives, the DELETE returns 500, and the idempotent
3028
- // retry's `ownerOf` still resolves → it re-runs the FULL purge to convergence. `deleted` is computed from the
3029
- // FINAL committed result (history-deleted || runsRemoved>0 — the privacy-relevant data that actually existed).
3030
- // [1196] session-watch registry(SSE 订阅共享探针面)。谓词与 /head 探针同门(getLeafId+ownerOf);
3031
- // env 旋钮经 posIntEnv 有界校验;getLeafId 箭头包一层=天然绑定宿主(SESSION_CACHE_TTL_SEC=0 裸类实例)。
3032
- const sessionWatchRegistry = ownerAware.getHead && ownerAware.ownerOf
3033
- ? new SessionWatchRegistry(async (sid) => (await ownerAware.getHead(sid)) ?? { owner: undefined, leafId: null }, {
3034
- hotMs: posIntEnv(process.env.SESSION_EVENTS_HOT_MS, 200),
3035
- warmMs: posIntEnv(process.env.SESSION_EVENTS_WARM_MS, 2000),
3036
- maxWatchedSessions: posIntEnv(process.env.SESSION_EVENTS_MAX_PROBES, 512, 100_000),
3037
- })
3038
- : undefined;
3039
- // S2 fast path([1208]③ 兑现):同副本 append 落点(tidb/pg persist post-commit)经 leaf-bus 直推
3040
- // notifyLocal——单副本部署零延迟;owner 不随 bus 传(写点无廉价 owner 读),探针围栏仍是租户权威
3041
- // (session-leaf-bus 契约注)。local lane 无写钩,探针道照旧。
3042
- setLeafAdvanceListener(sessionWatchRegistry ? (sid, leaf, owner, seq) => sessionWatchRegistry.notifyLocal(sid, leaf, owner, seq) : undefined);
3043
- const purgeSession = ownerAware.deleteSession
3044
- ? async (sessionId, owner) => {
3045
- // Runs-ledger FIRST: it transactionally re-asserts the no-active-run invariant (FOR UPDATE on task_active,
3046
- // serializing against a concurrent createRun). If a run claimed the session in the route's check→purge
3047
- // window it returns `{ active }` and deletes nothing → we bubble it up so the route 409s, BEFORE touching
3048
- // any other table (no partial purge of a live session). Each leg propagates its error so a child failure
3049
- // aborts the purge BEFORE the history-delete gate is removed (keeping the idempotent retry convergent).
3050
- const runs = runStore ? await runStore.deleteBySession(sessionId, owner) : { removed: 0 };
3051
- if ("active" in runs)
3052
- return { active: runs.active };
3053
- // checkpoint rows carry no owner column → owner-guarded via a session_meta.owner sub-select, so they MUST
3054
- // run while session_meta still exists (before the history delete below); tool_result is keyed only by
3055
- // `ref` so it stays route-guarded (see its deleteBySession doc).
3056
- if (checkpointStore)
3057
- await checkpointStore.deleteBySession(sessionId, owner);
3058
- if (toolResultStore)
3059
- await toolResultStore.deleteBySession?.(sessionId); // optional extra: SQL twins only (local file store has no per-session purge index; E21 purge is a durable-backend contract)
3060
- // E18 resume-at anchors are per-session privacy-relevant metadata → purge ALL of them with the session, scoped
3061
- // by session_id ALONE (the route already proved session ownership at the DELETE gate). A per-ROW owner guard
3062
- // here would LEAK: an anchor's owner is the per-run submitting principal, which diverges from the canonical
3063
- // session owner when an anonymous session is later attached by a principal (REQUIRE_PRINCIPAL=false) — those
3064
- // mixed-owner rows would survive the delete (a right-to-delete violation reported as success).
3065
- if (resumeAnchorStore)
3066
- await resumeAnchorStore.deleteBySession(sessionId);
3067
- // Approval exemptions are per-session operator decisions → purge with the session (same
3068
- // session_id-alone scoping rationale as the anchors above).
3069
- if (approvalExemptionStore)
3070
- await approvalExemptionStore.deleteBySession(sessionId);
3071
- // E6 session-policy rows are per-session operator rules → purge them too, scoped by session_id (the route
3072
- // owner-gated the session). Since core 1.423 the seam is on the INTERFACE (optional) and core's File/InMemory
3073
- // stores carry it too — so this fires on EVERY backend now. (The old note here claimed local rules were
3074
- // "process-ephemeral anyway" — false for the file-backed local store, whose policy rows survive restarts;
3075
- // that right-to-delete hole is what [1796]§三 → core 1.423 closed.)
3076
- if (sessionPolicyStore?.deleteBySession)
3077
- await sessionPolicyStore.deleteBySession(sessionId);
3078
- // D-1 附件随会话删(E21 级联;session_id 单键 scoping,与 anchors 同理由——路由已证会话所有权)。
3079
- if (taskAttachmentStore)
3080
- await taskAttachmentStore.deleteBySession(sessionId);
3081
- // E19 rewind-files snapshots are per-session working-tree state → purge them too (scoped by sessionId; the
3082
- // durable twins carry deleteBySession, local InMemory omits it → optional no-op on local).
3083
- if (fileSnapshotStore?.deleteBySession)
3084
- await fileSnapshotStore.deleteBySession(sessionId);
3085
- // P1 ①②: drop any pending async-workflow completions for this session (else they orphan when a later
3086
- // tenant reclaims the sessionId). Best-effort + non-fatal — a leftover entry is ALSO gated by the
3087
- // drain-time owner check, so a purge failure never leaks; it must not abort the session delete.
3088
- // `owner` scopes the purge fence (1.80): only the DELETED session's own late completions are fenced —
3089
- // a new tenant legitimately re-claiming this sessionId within the window keeps its push (review MED).
3090
- if (workflowCompletionInbox)
3091
- await workflowCompletionInbox.purge(sessionId, owner ?? null).catch((err) => logger.warn("workflow_completion_inbox_purge_failed", { sessionId, err: String(err) }));
3092
- // 修8(三路复审 absorb-2,接线 (a)):the session's scratchpad dir ([820]③, ensureScratchpadDir) rides
3093
- // the session lifecycle — purge it with the session. purgeScratchpadDir SELF-SWALLOWS (env-facts.ts
3094
- // contract: cleanup never faults the delete path; the periodic sweep below is the convergence backstop),
3095
- // so it cannot strand the E21 ordering invariant. Same root the ensure used (config.localDataRoot ?? localRoot).
3096
- await purgeScratchpadDir(config.localDataRoot ?? localRoot, sessionId);
3097
- // design/129 (core 1.239): reap this session's SESSION-scoped background children — they
3098
- // deliberately outlive turns (backgroundScope:"session"), so the session's DELETE is their lifecycle
3099
- // end (core's parent-teardown reap skips them by design; without this they run to their forced timeout).
3100
- // Sync + replica-local (the registry is in-process); best-effort — a reap of 0 on the wrong replica is
3101
- // covered by core's forced child-timeout cap.
3102
- // [1892]{core} 跨仓待办:scope 轴必传——core canAccess 对缺省 scope fail-closed(拒绝非通配,
3103
- // 1.441 复审①正过极性),不传 = 四类后台任务在会话删除时恒 0 回收(潜伏至今,core 复审顺手
3104
- // 核出)。铸值同源 runs.ts:25 契约:core 按 `spec.principal ?? "default"` 注册 ⇒ 此处
3105
- // owner(会话主)?? "default" 对齐;错 scope 不越租户由 canAccess 保证(session-reap-scope 钉)。
3106
- try {
3107
- const reaped = defaultTaskRegistry.reapSessionBackground(sessionId, owner ?? "default");
3108
- if (reaped > 0)
3109
- logger.info("session_background_reaped", { sessionId, reaped });
3110
- }
3111
- catch (err) {
3112
- logger.warn("session_background_reap_failed", { sessionId, err: String(err) });
3113
- }
3114
- // History/meta LAST: once committed, the `ownerOf` gate is gone and the delete is durably complete. The
3115
- // owner is threaded into the SQL guard (session_meta.owner) for data-layer defense-in-depth.
3116
- const historyDeleted = await ownerAware.deleteSession(sessionId, owner);
3117
- // [1196] 三轮复审(租户轮转围栏,同副本半场):session 删除即终结其 SSE 订阅者+watch 条目——
3118
- // 旧 owner 的活流不得跨越 delete/reclaim 边界收新租户的 head;跨副本半场=路由心跳期 owner 复核。
3119
- sessionWatchRegistry?.dropSession(sessionId);
3120
- return { deleted: historyDeleted || runs.removed > 0 };
3121
- }
3122
- : undefined;
3123
- // Degenerate-output a/b instrument: needs the durable session log to inspect turn history (design/39
3124
- // ② gating data). Only on the TiDB backend (mysql2 pool); env-only / PG deploys get no-op.
3125
- const instrumentDegenerate = mysqlPool ? makeDegenerateInstrument(mysqlPool, metrics, logger) : undefined;
3126
- // Plan-cache recurrence probe (core design/42, INSTRUMENT-FIRST): pure in-memory, no backend needed —
3127
- // counts per-scope task-objective recurrence so core can decide whether to build plan caching.
3128
- const planCacheProbe = new PlanCacheProbe(metrics, logger);
3129
- // v2 leader endpoint (design/50 + design/68): wire when LEADER_ENABLED + an isolated remote-exec backend
3130
- // (E2B or k8s/Kata — SSH/ADB are single-worker real-system backends, not fan-out targets). Default off →
3131
- // zero prod impact. 🔴 a real run also needs `git` on the host PATH + a durable remote with write creds.
3132
- // F2(a) factory mode (preferred) = the service's executionEnvFactory + MinIO self-upload diff-out; the k8s
3133
- // lane REQUIRES it (no static k8s mode). E2B without MinIO falls back to the static compat mode.
3134
- const leaderMinio = process.env.MINIO_ENDPOINT && process.env.MINIO_ACCESS_KEY && process.env.MINIO_SECRET_KEY
3135
- ? {
3136
- s3: {
3137
- endpoint: process.env.MINIO_ENDPOINT,
3138
- bucket: process.env.MINIO_BUCKET ?? "workspaces",
3139
- accessKey: process.env.MINIO_ACCESS_KEY,
3140
- secretKey: process.env.MINIO_SECRET_KEY,
3141
- ...(process.env.MINIO_REGION ? { region: process.env.MINIO_REGION } : {}),
3142
- },
3143
- }
3144
- : {};
3145
- const leaderProvider = config.remoteExec?.provider;
3146
- // DUAL-MODE §5: orchestration is an ENGINE capability, not fleet-only — the TOC `host` lane runs leader fan-out
3147
- // bounded by ONE box (isolation=none, NON-durable: no snapshot, so the durable sub-worker suspend block below is
3148
- // skipped — host workers run to completion in-process-adjacent). e2b/k8s keep their isolated/suspendable posture.
3149
- const leaderEndpoint = config.leaderEnabled &&
3150
- (leaderProvider === "e2b" ||
3151
- (leaderProvider === "k8s" && "s3" in leaderMinio && executionEnvFactory) ||
3152
- (leaderProvider === "host" && executionEnvFactory))
3153
- ? createLeaderEndpoint(createLeaderRunner({
3154
- brain, models: config.models, roles: config.roles, pricing, logger,
3155
- fanoutEnabled: config.leaderFanoutEnabled,
3156
- // Route the single-vs-fanout classification on the cheap model (deepseek-v4-flash): the heavy
3157
- // reasoning worker model returns empty ~2/3 of the time on the route prompt → silent collapse to
3158
- // single (observed 2026-06-14). MODEL_ROUTER_ID overrides; else the cheap model; else default.
3159
- ...((process.env.MODEL_ROUTER_ID || process.env.MODEL_CHEAP_ID)
3160
- ? { routerModel: (process.env.MODEL_ROUTER_ID || process.env.MODEL_CHEAP_ID) }
3161
- : {}),
3162
- ...(leaderProvider === "e2b" ? { e2bApiKey: config.remoteExec.apiKey } : {}),
3163
- ...(executionEnvFactory ? { envFactory: executionEnvFactory } : {}),
3164
- ...leaderMinio,
3165
- // Durable offload store for sub-worker tool results — without it core REFUSES the durable
3166
- // suspend (InMemory offload would not survive a cross-replica resume) and the gated call is
3167
- // denied via the unwired onAsk fallback → the worker fails instead of suspending (drill c3).
3168
- ...(toolResultStore ? { toolResultStore } : {}),
3169
- // k8s lane: align the wire's workspace with the adapter's durable-snapshot root (/workspace) —
3170
- // the E2B default (/home/user) is OUTSIDE the k8s suspend tar, so a suspended sub-worker's repo
3171
- // would silently vanish on resume (drill c3: 105-byte empty snapshots).
3172
- ...(leaderProvider === "k8s" ? { workspace: "/workspace" } : {}),
3173
- // Same store as the main runner: a suspended sub-worker's session must be readable on /decide
3174
- // resume (sub-runner-local in-memory sessions die with "Entry <leafId> not found" on resume).
3175
- sessionStore,
3176
- // design/68 C4: sub-workers can durably suspend when the service runs durable approvals.
3177
- ...(checkpointStore && config.approvalRequire.length > 0
3178
- ? {
3179
- durable: {
3180
- checkpointStore,
3181
- requireApproval: config.approvalRequire,
3182
- deny: config.approvalDeny,
3183
- // design/80: the leader's orchestrated sub-agents adopt the SAME supervisor loop as the
3184
- // top-level agent — the D-E auto-budget (auto-approve NORMAL asks up to the budget, then
3185
- // escalate) + the never-auto safety set. Without these a sub-agent's gated ask always
3186
- // suspends-to-human, defeating the budget circuit-breaker for the supervised team.
3187
- autoBudget: config.approvalAutoBudget,
3188
- neverAuto: config.approvalNeverAuto,
3189
- ...(config.approvalTimeoutSec > 0 ? { ttlMs: config.approvalTimeoutSec * 1000 } : {}),
3190
- },
3191
- }
3192
- : {}),
3193
- }), {
3194
- logger,
3195
- // SDK 全量核查(2026-07-24,黑板 GD 组)撞获:wire.ts:368 的 runLeader 实际要求这五个字段全部
3196
- // 非空,此前同步门只查 objective,不完整请求会先拿到 202 再在后台必然失败——同步前移,让错误
3197
- // 尽早暴露而不是靠后续 GET 才发现。
3198
- requiredFields: ["objective", "durableRemote", "testCmd", "seedCmd", "baseSha"],
3199
- })
3200
- : undefined;
3201
- if (leaderEndpoint)
3202
- logger.info("leader_endpoint_enabled", { provider: leaderProvider, mode: "s3" in leaderMinio ? "factory" : "static" });
612
+ // design/158 A10 尾刀:60s refresh 循环 + boot-deferred 到货续接搬到 boot/config-center.ts(逐字)。
613
+ // ⚠️ 位置即契约:必须在 new Runner 之后 —— runnerTierFrozen 与构造函数读的是同一 config 代际。
614
+ configCenter.startRefreshLoop({ runnerTierFrozen, pricing });
615
+ // design/158 A10 尾刀:per-model key resolver 首建(+ Bearer 网关组合告警)搬到 boot/config-center.ts。
616
+ // ⚠️ 位置即契约:首建读的必须是 boot apply **之后**的 config.modelApiKeyEnv/modelApiKeys。
617
+ configCenter.initKeyResolver();
618
+ // design/158 A10:session 只读/删除面搬到 src/boot/session-faces.ts(逐字;watch→purge 次序即契约,见该文件头注)。
619
+ const { ownerAware, sessionAudit, sessionWatchRegistry, purgeSession, instrumentDegenerate, planCacheProbe } = createSessionFaces({
620
+ config, logger, metrics, localRoot, backend, sessionStore, runStore, checkpointStore, toolResultStore,
621
+ resumeAnchorStore, approvalExemptionStore, sessionPolicyStore, taskAttachmentStore, fileSnapshotStore,
622
+ workflowCompletionInbox,
623
+ });
624
+ // design/158 A10:leader 段搬到 src/boot/leader.ts(逐字)。
625
+ const leaderEndpoint = createLeaderFace({ config, logger, brain, pricing, executionEnvFactory, toolResultStore, sessionStore, checkpointStore });
3203
626
  // Graceful drain: shared mutable state — SIGTERM flips `draining` (shutdown below), createHttpServer
3204
627
  // assigns `inflight` (live leg count on this instance), /health + the submit 503 gate read it.
3205
628
  const drainState = { draining: false };
@@ -3331,7 +754,7 @@ async function main() {
3331
754
  // coerce 成 LABEL "tidb"(泛指 durable),pg 部署上误读成 SQL-TiDB 带偏排障。回显有效 store(与 listening
3332
755
  // 日志 session 标签同款诚实修)。
3333
756
  sessionStoreLabel: config.sessionBackend === "tidb" && backend ? `durable(${backend.kind})` : config.sessionBackend,
3334
- modelReady: () => modelReadyState.ready, // boot ready 门:roster 未落地=计费提交 503+health ready:false
757
+ modelReady: () => configCenter.modelReady(), // boot ready 门:roster 未落地=计费提交 503+health ready:false
3335
758
  scenarioDetails, // 场景详情只读面(内建+center 同判定源)
3336
759
  workflowsCapable, // ① core split: capabilities.workflows = engine-can (workflowsCapability); workflowsList = store (below)
3337
760
  instanceId,
@@ -3346,8 +769,8 @@ async function main() {
3346
769
  },
3347
770
  // center: orchestrator polls GET /health for this — set by the sema-registry refresh loop when a
3348
771
  // pull's restart-to-apply slices differ from boot. A live getter (not a snapshot) so /health always reads current.
3349
- restartState: () => pendingRestart,
3350
- planeDeferredState: () => planeDeferredNoHandoff, // codex R18: /health.modelPlaneDeferred (stuck-deferred plane, no handoff)
772
+ restartState: () => configCenter.restartState(),
773
+ planeDeferredState: () => configCenter.planeDeferredState(), // codex R18: /health.modelPlaneDeferred (stuck-deferred plane, no handoff)
3351
774
  // shared drain state — SIGTERM flips `draining`, createServer assigns `inflight`, /health mirrors it.
3352
775
  drainState,
3353
776
  storeDegraded: storeBackendDegraded, // S5: /health twin of the store_backend_degraded gauge
@@ -3366,974 +789,17 @@ async function main() {
3366
789
  // 帽只在 watch 面在场时才有意义(缺席时路由 501,读不到它)——保持与原条件段同门,不单独接线。
3367
790
  sessionEventsMaxConnections: sessionWatchRegistry ? posIntEnv(process.env.SESSION_EVENTS_MAX_CONNS, 256, 100_000) : undefined,
3368
791
  };
3369
- // Deployment-owned mapping. Tools/prompt/skills come from the selected scenario (assembled once,
3370
- // bound per request); identity/session/policy stay server-side. Never taken from the request body.
3371
- // design/158 A8:从 createHttpServer 实参里提出来(原地占该字面量 1,093 行中的 967 行)——
3372
- // 类型标注 `ServiceDeps["resolveSpec"]` 顶替了原来的上下文推导,签名不变。
3373
- const resolveSpec = async (body, _req, auth, opts) => {
3374
- // per-principal scenario governance (assignment-as-default; an explicit
3375
- // body.scenario is bounded to the allowlist — outside ⇒ typed 400 `scenario_not_allowed`). The ruling
3376
- // {scenario, allowlist} is center-RESOLVED and rides the caps view (TTL-cached, same fetch as runtimeCaps);
3377
- // no center / dry-run / anonymous / older center / caps blip ⇒ undefined ⇒ legacy body-or-default chain.
3378
- const requested = gateScenarioRequest(await principalCaps?.scenarioRuling(auth?.principal), body.scenario, config.defaultScenario);
3379
- // per-principal execution-lane policy — this worker's lane is a
3380
- // PROCESS identity (REMOTE_EXEC boot wiring, no per-task switch), so admission is the gate: lane ∉
3381
- // allowedLanes ⇒ typed 403 `execution_lane_not_allowed`. Same caps car as the scenario ruling (zero extra
3382
- // RTT); fail-open + audit on blip. "in-process" = fleet's REMOTE_EXEC-unset core stub lane
3383
- // (named distinctly from the explicit `host` lane — same machine, different posture).
3384
- gateExecutionLane(await principalCaps?.executionRuling(auth?.principal), config.remoteExec?.provider ?? "in-process");
3385
- // Resolve an unknown name to "default" so the WHOLE bundle stays coherent — tools/prompt AND
3386
- // skills/mcp fall back together. (selectScenario already falls back for tools/prompt; without
3387
- // resolving here, skills/mcp would still be filtered by the raw unknown name → any skill/MCP tagged
3388
- // ["default"] silently drops. council finding.)
3389
- const scenarioName = scenarios[requested] ? requested : "default";
3390
- // [891] deprecation(一版过渡窗):"autonomous" 已改名 "code"(persona 同一,唯一差别=alias 仍
3391
- // 定死 finalVerification:true 保存量行为)。warn 不 400——TB harness/存量部署平滑迁移。
3392
- if (scenarioName === "autonomous")
3393
- logger.warn("scenario_autonomous_deprecated", { alias: "code", note: "renamed by [891]; alias keeps finalVerification pinned — pass scenario:'code' (+ explicit finalVerification if wanted)" });
3394
- const cap = selectScenario(scenarios, scenarioName)(body, auth?.principal);
3395
- // codex R3/R4 (appendSystemPrompt 案): ONE center-pack snapshot per request — the append-less gate below
3396
- // AND the provider pick (promptProvider field) both read THIS capture, never the mutable centerPrompts ref
3397
- // again (R4 TOCTOU: a refresh between the two reads could make the final provider assembled while the gate
3398
- // judged safe, restoring the in-core silent drop).
3399
- const centerDecls = centerPrompts?.declarations;
3400
- // TRUE when the EFFECTIVE assembly for this request takes core's pass-through arm (no core/role.append
3401
- // section ⇒ any append rider/outputStyle silently discarded in core). codex R9: PROVIDER-AWARE — mirrors
3402
- // core's actual triggers (stableBlocks identity decls / stableSystem OUTPUT carrying the anchors), because
3403
- // a raw body.systemPrompt anchor heuristic false-positives on default-provider scenarios (unbranded
3404
- // default / scan: no provider ⇒ core defaultPromptProvider retains role.append and the rider mounts even
3405
- // with an anchored systemPrompt). center-wins mirrors the provider pick below (same snapshot, same
3406
- // scenario derivation — principal ruling + defaultScenario already folded into scenarioName above).
3407
- const appendLessPack = centerDecls
3408
- ? centerIdentityAssembled(centerDecls, scenarioName, hasConstitutionAnchors)
3409
- : providerDropsAppend(cap.promptProvider, typeof body.systemPrompt === "string" ? body.systemPrompt : undefined);
3410
- // Accepted rider (defensive resume mirror inside; fresh malformed/over-cap shapes already 400'd in
3411
- // prepareSpec). Hoisted to a const so the R14 combined-cap check below can see the accepted length.
3412
- const acceptedAppend = acceptAppendSystemPrompt(body.appendSystemPrompt, (detail) => logger.warn("append_system_prompt_dropped", { detail, sessionId: auth?.sessionId ?? null }), appendLessPack);
3413
- // @-model (1.24) + explicit picker: an end user may pick a CONFIGURED model via the CLI
3414
- // `/model` picker / `run --model` flag (body.model) OR an inline `@name` in the objective. resolveTaskModel
3415
- // gates BOTH against the catalog allow-list (config.models keys) — pick WHICH model, never inject one — with
3416
- // body.model winning over @mention winning over `default`. Returns the objective with the @mention stripped.
3417
- const objective = typeof body.objective === "string" ? body.objective : "";
3418
- // TaskRequest.settings (client per-request SemaSettings stamp). Parsed DEFENSIVELY off the untrusted body
3419
- // (every field shape-checked; `bypassPermissions`/unknown modes dropped). `env`/`hooks` are recorded as
3420
- // received-but-deferred (never silent-dropped). Folded into the spec TIGHTEN-ONLY after applyRuntimeGovernance.
3421
- const parsedSettings = parseTaskSettings(body.settings);
3422
- // codex R4 (appendSystemPrompt 案) fresh-leg fail-loud: a NEW submit whose append payload (top-level rider
3423
- // or settings.outputStyle) meets an append-less pack 400s HERE — inside resolveSpec, where the effective
3424
- // scenario (principal ruling + defaultScenario) and the center-pack snapshot are the SAME ones the provider
3425
- // pick uses (an HTTP-side gate could disagree with the actual assembly). Resume legs (opts.leg absent)
3426
- // keep the drop+warn mirror below — a 4xx would brick a pre-1.243 stored task.
3427
- if (opts?.leg === "fresh" && appendLessPack) {
3428
- const riderPresent = typeof body.appendSystemPrompt === "string" && body.appendSystemPrompt.length > 0;
3429
- if (riderPresent || parsedSettings.settings?.outputStyle) {
3430
- throw new HttpError(400, `${riderPresent ? "appendSystemPrompt" : "settings.outputStyle"} is not supported when the effective prompt is already assembled (constitution anchors in systemPrompt, or a center assembled-identity pack for this scenario): the assembler's pass-through path cannot mount the append slot — fold it into the assembled prompt instead`);
3431
- }
3432
- }
3433
- if (parsedSettings.deferred.length > 0) {
3434
- // No-silent-drop: a caller SENT env/hooks but this version does not wire them on remote (need a core seam /
3435
- // a remote hook-runner — see task-settings.ts + capabilities). Log so the gap is visible, never silent.
3436
- logger.warn("task_settings_deferred", { fields: parsedSettings.deferred, sessionId: auth?.sessionId ?? null });
3437
- }
3438
- // [#40 / TOC cwd seam] register the caller's launch dir so the HOST factory runs the agent there (read by
3439
- // ctx.sessionId). 🔒 GATED: only the single-user host lane (cwdHonored) honors it; on any other lane / multi-tenant
3440
- // a sent cwd is IGNORED (logged once, never silently — the shell learns via capabilities.projectContext). isValidCwd
3441
- // re-checked here too (RESUME re-runs resolveSpec WITHOUT the prepareSpec 400 validation). Re-applies on resume.
3442
- // [851]P3a 信号真实化:cwdHonored 只认显式 REMOTE_EXEC=host;provider 未设的 in-process 单用户 lane 没有
3443
- // shell hands(core prepare-task:无 factory/executionEnv ⇒ StubExecutionEnv,亲读 dist 坐实),但进程级
3444
- // 消费者(hooks 命令、C4 projectContext 本地读取)仍看引擎自身的 cwd/env——壳把引擎 spawn 在用户目录/用户
3445
- // 环境时,请求的 override 往往「已被进程状态满足」,此前每任务打 "ignored" warn 与实际状态矛盾(飞轮双平台
3446
- // 观测)。改法:已满足 ⇒ debug;真失配 ⇒ 保留 warn 且带 lane 字段(值与 run_local_start exec 口径一致:
3447
- // provider ?? "in-process")。真 per-session honor 之路仍是 REMOTE_EXEC=host(host factory 消费
3448
- // perSessionCwd/perSessionShellEnv)。
3449
- // ── D-1 附件绑定半场(②):TaskRequest.attachments = 上传句柄数组 ──
3450
- // fresh 腿硬验证(未知/他人 id → 400 typed;超数 → 400)——「引用了不存在的附件」必须在提交时
3451
- // 拒,不能等 env 建立时才炸。resume 腿降级(acceptedAppend 同姿):缺行 warn+跳过,余下照常
3452
- // (4xx 会砖存量 task;记录不可变 ⇒ 在场行的 notice 字节恒稳)。绑定=把行钉到 auth.sessionId
3453
- // (authorizer 恒解析出一个;绑定幂等,重复提交/resume 重绑同值)。
3454
- let attachmentNotice;
3455
- {
3456
- const reqAtt = body.attachmentIds;
3457
- if (reqAtt !== undefined) {
3458
- if (!Array.isArray(reqAtt) || !reqAtt.every((x) => typeof x === "string" && x.length > 0 && x.length <= 64)) {
3459
- throw new HttpError(400, "`attachmentIds` must be an array of attachment ids (strings)");
3460
- }
3461
- if (reqAtt.length > 16)
3462
- throw new HttpError(400, "`attachmentIds` exceeds the per-task limit (16)");
3463
- if (reqAtt.length > 0) {
3464
- if (!taskAttachmentStore)
3465
- throw new HttpError(501, "attachmentIds require a store backend (DB_BACKEND=tidb|pg|local)");
3466
- if (!auth?.sessionId)
3467
- throw new HttpError(400, "`attachmentIds` requires a session-resolving deployment (no authorizer session)");
3468
- const sid = auth.sessionId;
3469
- const r = await bindAttachmentsForTask({
3470
- store: taskAttachmentStore,
3471
- owner: auth?.principal ?? "default",
3472
- ids: reqAtt,
3473
- sessionId: sid,
3474
- leg: opts?.leg === "fresh" ? "fresh" : "resume",
3475
- nowMs: Date.now(),
3476
- onMissing: (id) => {
3477
- if (opts?.leg === "fresh")
3478
- throw new HttpError(400, `attachment not found: ${id}`);
3479
- logger.warn("attachment_missing_on_resume", { sessionId: sid, id });
3480
- },
3481
- });
3482
- attachmentNotice = r.notice;
3483
- }
3484
- }
3485
- }
3486
- const execLane = config.remoteExec?.provider ?? "in-process";
3487
- if (typeof body.cwd === "string" && body.cwd.length > 0 && auth?.sessionId) {
3488
- if (cwdHonored(config) && isValidCwd(body.cwd))
3489
- setSessionCwd(auth.sessionId, body.cwd);
3490
- else if (inProcessSingleUserLane(config) && isValidCwd(body.cwd) && satisfiedByProcessCwd(body.cwd, realpathSync))
3491
- logger.debug("task_cwd_inherited", { lane: execLane, sessionId: auth.sessionId });
3492
- else
3493
- logger.warn("task_cwd_ignored", { honored: cwdHonored(config), lane: execLane, sessionId: auth.sessionId });
3494
- }
3495
- // [R-survey / TOC shellEnv seam] register the caller's `settings.env` so the HOST factory injects it into the
3496
- // agent's shell env. 🔒 GATED identically to cwd (single-user host lane only — design/107 env=capability axis);
3497
- // on any cloud/multi-tenant lane a sent env is IGNORED (logged once, never silent). Re-applies on resume.
3498
- if (parsedSettings.settings?.shellEnv && auth?.sessionId) {
3499
- if (cwdHonored(config))
3500
- setSessionShellEnv(auth.sessionId, parsedSettings.settings.shellEnv);
3501
- else if (inProcessSingleUserLane(config)) {
3502
- // 只记失配键「数量」,绝不记键值(秘密类)与键名(名字本身可带敏感语义)——shellEnvMismatchCount 文档同款红线。
3503
- const requested = parsedSettings.settings.shellEnv;
3504
- const mismatched = shellEnvMismatchCount(requested, process.env);
3505
- if (mismatched === 0)
3506
- logger.debug("task_shell_env_inherited", { lane: execLane, keys: Object.keys(requested).length, sessionId: auth.sessionId });
3507
- else
3508
- logger.warn("task_shell_env_ignored", { honored: false, lane: execLane, mismatchedKeys: mismatched, sessionId: auth.sessionId });
3509
- }
3510
- else
3511
- logger.warn("task_shell_env_ignored", { honored: false, lane: execLane, sessionId: auth.sessionId });
3512
- }
3513
- // hooks hook-runner(阶段一):settings.hooks(契约校验过)→ core TaskSpec.hooks 进程内回调。
3514
- // 🔒 hook 命令跑在 WORKER HOST(CC 语义:hooks 跑在引擎所在机,哪怕工具在远端沙箱)= design/107 class ②
3515
- // 能力授予 → 单用户闸 `requirePrincipal !== true`,与 scheduler/backgroundShell/lspHost/MCP 注入同一姿势;
3516
- // 多租户 lane 收到只警告忽略(`task_hooks_ignored`,capabilities.taskSettings.hooks 同步 advertise),绝不执行。
3517
- // Re-applies on resume(从 stored body 重解析,幂等)。
3518
- const taskHooks = (() => {
3519
- const hc = parsedSettings.settings?.hooks;
3520
- if (!hc)
3521
- return undefined;
3522
- if (config.requirePrincipal === true) {
3523
- logger.warn("task_hooks_ignored", { honored: false, sessionId: auth?.sessionId ?? null });
3524
- return undefined;
3525
- }
3526
- // payload 的 permission_mode 用【生效】模式——body.permissionMode(显式请求)优先于
3527
- // settings.permissions.defaultMode(与 spec 的 withPermissionMode 同一优先序),否则钩子读到的模式与
3528
- // 实际裁决模式不一致(如 body=plan 时钩子仍看到 default)。
3529
- const effMode = coercePermissionMode(body.permissionMode) ?? parsedSettings.settings?.permissions?.defaultMode;
3530
- return createTaskHooks(hc, {
3531
- logger,
3532
- sessionId: auth?.sessionId ?? "",
3533
- cwd: (auth?.sessionId ? perSessionCwd.get(auth.sessionId) : undefined) ?? process.cwd(),
3534
- ...(effMode ? { permissionMode: effMode } : {}),
3535
- // 用户变量通道(hook 进程 env + http $NAME 插值源):同请求的 settings.env——hookEnv 绝不读
3536
- // 裸 process.env 的非 allowlist 键(服务密钥不可被 allowedEnvVars 点名)。
3537
- ...(parsedSettings.settings?.shellEnv ? { shellEnv: parsedSettings.settings.shellEnv } : {}),
3538
- // 阶段三b:prompt/agent 条目的模型载体(boot 组装,见 hookLlm/hookAgent 定义处的影响范围说明)。
3539
- hookLlm,
3540
- hookAgent,
3541
- // cli [1786] 点名的观测位:一次 hook 判定**未能完成**时,往常开的 fleet 流推一帧。
3542
- // ⚠️ **纯 observe**(不拦、不续跑、不注入)——`additionalContext` 那条通道会让这一轮不结束,
3543
- // 所以不能拿它当"提示一下"用(见 hook-runner 折叠处旁注)。
3544
- // ⚠️ 带 ownerScope/ownerSessionId 供流侧做 **fail-CLOSED** 过滤(与 bg_notification 同一姿势):
3545
- // 一条关于别人会话的通知落到这个壳上,会让用户以为是自己这轮出了问题。
3546
- onHookNotice: (n) => fleetBus.publishHookNotice({
3547
- ...n,
3548
- ...(auth?.principal ? { ownerScope: auth.principal } : {}),
3549
- ...(auth?.sessionId ? { ownerSessionId: auth.sessionId } : {}),
3550
- }),
3551
- // asyncRewake wake 管道(契约:exit 2 唤醒模型):session→本副本活流 steer(hookWakeBus,server
3552
- // 启动装 deliver)。stderr 过 redactSecrets 再注入(hook 进程可能回显 env 密钥;与 trace 出口同纪律)。
3553
- // 无 session(匿名一次性任务)不给 wake——没有可稳定寻址的流。
3554
- ...(auth?.sessionId
3555
- ? {
3556
- wake: async (text) => hookWakeBus.deliver
3557
- ? // provenance 围栏:注入内容标明来源=asyncRewake hook 的运行时输出,
3558
- // 模型按 hook 反馈对待而非操作者指令(trusted:false 在 server 侧同批落定)。
3559
- hookWakeBus.deliver(auth.sessionId, `[hook asyncRewake] ${redactSecrets(text)}`)
3560
- : false,
3561
- }
3562
- : {}),
3563
- });
3564
- })();
3565
- // design/119 (CC --add-dir parity, core 1.215 `TaskSpec.additionalDirectories`): extra host dirs the FILE tools
3566
- // may access beyond the containment root. 🔒 GATED identically to cwd/shellEnv (single-user host lane only —
3567
- // these are the caller's own-machine paths; a cloud/multi-tenant lane must not let a caller widen the file-tool
3568
- // containment onto operator/other-tenant host paths). Unlike cwd/shellEnv (registered per-session for the host
3569
- // FACTORY to read at env construction), additionalDirectories is a plain TaskSpec field core reads directly, so
3570
- // it rides onto the spec below. Ignored (logged, never silent) off the host lane. Re-applies on resume (from body).
3571
- const rawAddDirs = body.additionalDirectories;
3572
- const additionalDirectories = cwdHonored(config) ? parseAdditionalDirectories(rawAddDirs) : undefined;
3573
- if (rawAddDirs !== undefined && !cwdHonored(config)) {
3574
- logger.warn("task_additional_directories_ignored", { honored: false, sessionId: auth?.sessionId ?? null });
3575
- }
3576
- // body.model (explicit /model picker or run --model) WINS over settings.model (the config default); both gate
3577
- // against the catalog allow-list via resolveTaskModel (a settings.model not in the catalog is ignored, never injected).
3578
- // 🔴 TOB 档位联跑曾 FAIL(坐标级):the gate must see the TIER-EXPANDED catalog — expandTiers
3579
- // runs inside core's Runner on a PRIVATE deps copy (runtask.ts), so gating on bare config.models silently
3580
- // degraded every tier word (pro/flash/@mention/CC alias) to default BEFORE the Runner could resolve it.
3581
- // Expand per request (cheap: empty tiers returns the same reference; non-empty = one small spread) so the
3582
- // gate's key set is exactly what a freshly-constructed Runner resolves — tier words, degrade chains, CC
3583
- // aliases, catalog-SHADOW all from core's ONE implementation. Window note: tiers are restart-to-apply at
3584
- // the Runner; a refresh-time tier change makes this gate briefly AHEAD of the old Runner snapshot (a new
3585
- // tier word then fails loud in core instead of silently degrading — honest during the restart window).
3586
- const wireCatalog = expandTiers(config.models, config.tiers) ?? config.models;
3587
- const picked = resolveTaskModel(body.model ?? parsedSettings.settings?.model, objective, wireCatalog);
3588
- // [865]② 降级永远可见:fresh submit 的未知 body.model 已在 HTTP 门 400(不会到这);走到这的未知 ref
3589
- // 只剩 RESUME 重放(模型事后被移出目录)与 settings.model(lenient 文档面)——落 default 可跑,但必须
3590
- // 有声(session 中途换模型是最恶性的上下文污染路径,静默=病灶本体)。
3591
- if (picked.unknownExplicit !== undefined) {
3592
- metrics.inc("task_model_unknown_fallback_total");
3593
- logger.warn("task_model_unknown_fallback", { requested: picked.unknownExplicit.slice(0, 120), fallback: picked.model, sessionId: auth?.sessionId ?? null });
3594
- }
3595
- // no-vision images = PLACEHOLDER, not 422(会话可继续的 CC 心智). The old
3596
- // fail-loud 422 here BLOCKED core's own degradation: since core 1.233.1 a serving model whose `input`
3597
- // has no "image" gets image parts degraded to a bounded text placeholder ("[image omitted: …]"), per
3598
- // CURRENT model (degrade chains stay correct) and re-applied on every history replay — so a previously
3599
- // poisoned session self-heals. We pin ≥1.240.1, so pass the images through and let core do exactly that.
3600
- // The GET /v1/models vision flag stays advertised (the shell's composer-warning half). A model
3601
- // that never declared `input` keeps sending images verbatim (deliberate default — never wrongly strip
3602
- // for a vision-capable deployment that skipped the metadata; same posture).
3603
- // S6 (SILENT-FALLBACK P0-c): images headed for an EXPLICITLY text-only model — core (>=1.233.1) degrades
3604
- // them to bounded text placeholders per current model (the pass-through posture above). That modality drop
3605
- // was invisible fleet-side; count + warn at submit. modelSupportsImages is false-safe (undeclared input →
3606
- // true → no signal), matching core's "never wrongly strip" posture — no false positives for verbatim
3607
- // pass-through models. ⚠️ sema-registry lane caveat (review LOW-2): the roster maps `vision?: boolean` to
3608
- // an EXPLICIT input list (sema-registry.ts) — a vision-capable roster model that FORGOT the flag becomes
3609
- // declared text-only, so this signal fires AND core really does degrade its images (signal matches
3610
- // behavior; the fix for that footgun is the roster entry, not this counter). The per-run durable event rides core's vision.placeholder trace kind once we
3611
- // consume >=1.245 (the authoritative replacement-time signal), not this submit-time predictor.
3612
- if (Array.isArray(body.images) && body.images.length > 0 && !modelSupportsImages(wireCatalog[picked.model])) {
3613
- metrics.inc("images_omitted_total", { model: picked.model });
3614
- logger.warn("images_omitted_no_vision_model", { model: picked.model, count: body.images.length });
3615
- }
3616
- // E18 resume-at: the shell sends body.resumeAt as the E2 message eventId; core's TaskSpec.resumeAt takes the
3617
- // PERSISTED SessionTreeEntry.id, NOT the eventId ("a deployment that holds eventIds owns the eventId→entryId
3618
- // map" — core never persists eventIds). Resolve eventId→entryId via the anchor store HERE, 4xx-ing BEFORE the
3619
- // run on an UNKNOWN anchor (never hand core an unresolvable id). A KNOWN anchor whose entry was later compacted
3620
- // away surfaces as a failed run RESULT (resume_at.not_found) — 4xx on the sync path, the run result on async (the
3621
- // async contract is poll-the-result). RESUME re-runs resolveSpec from the stored body — the resume paths strip
3622
- // resumeAt before re-resolving (else core would reject resumeAt + a durable resume), so this branch only fires on
3623
- // a fresh submit. NEVER silent-drop (mirrors the body.model silent-drop bug class).
3624
- let resumeAtEntryId;
3625
- if (typeof body.resumeAt === "string" && body.resumeAt.length > 0) {
3626
- if (!auth?.sessionId)
3627
- throw new HttpError(422, "resumeAt requires a session to branch (resume_at.no_session)");
3628
- // 501 gated on the SAME pair the `resumeAt` capability advertises (anchor store AND getLeafId) — capture is a
3629
- // no-op without getLeafId, so a getLeafId-less backend has a permanently-empty map; report "not available"
3630
- // (honest 501) rather than a misleading 404. Keeps "capability says yes ⟺ the route resolves" by construction.
3631
- if (!resumeAnchorStore || !ownerAware.getLeafId)
3632
- throw new HttpError(501, "resume-at is not available on this worker (no session-store backend for the anchor map)");
3633
- resumeAtEntryId = await resumeAnchorStore.resolve(auth.sessionId, body.resumeAt, auth.principal ?? null);
3634
- if (resumeAtEntryId === undefined)
3635
- throw new HttpError(404, "resumeAt: no such message in this session (resume_at.unknown_event)");
3636
- }
3637
- // R8 code-only rewind (CC "Restore code" mode, core 1.166 `rewindFilesTo`): restore the working tree to a prior
3638
- // USER message's snapshot WITHOUT forking the conversation. Resolve the SAME handle (the prompt's taskId) → entryId
3639
- // via the SAME anchor store. Honored ONLY when resumeAt is ABSENT (core's contract: the two are mutually exclusive —
3640
- // with resumeAt set, that branch's own rewindFiles governs); so we only resolve it on the code-only path. Same
3641
- // fail-loud discipline as resumeAt (501 no-store / 404 unknown anchor — never silent-drop).
3642
- let rewindFilesToEntryId;
3643
- if (resumeAtEntryId === undefined && typeof body.rewindFilesTo === "string" && body.rewindFilesTo.length > 0) {
3644
- if (!auth?.sessionId)
3645
- throw new HttpError(422, "rewindFilesTo requires a session (rewind_files_to.no_session)");
3646
- if (!resumeAnchorStore || !ownerAware.getLeafId)
3647
- throw new HttpError(501, "rewind-files-to is not available on this worker (no session-store backend for the anchor map)");
3648
- rewindFilesToEntryId = await resumeAnchorStore.resolve(auth.sessionId, body.rewindFilesTo, auth.principal ?? null);
3649
- if (rewindFilesToEntryId === undefined)
3650
- throw new HttpError(404, "rewindFilesTo: no such message in this session (rewind_files_to.unknown_event)");
3651
- }
3652
- // 142-S4 defaultScopes 种子:projectId → center 项目登记簿(config.projects,registry 喂)→ 该项目
3653
- // 声明的默认记忆 scope 词表(memory.scopes 额外 READ 层;写路由仍钉在派生 scope — memorySpecForRequest)。
3654
- // projectId 优先取 auth.resolvedProjectId(authorizer 的 PROJECT_ID_REGEX 422 门通过后回传);RESUME 腿
3655
- // 的 auth 从 checkpoint 重建、无此字段 → 回落持久化 body.projectId(原始提交已过形状门,与 memoryWrite
3656
- // 的 rides-the-persisted-body 姿势一致),让种子在 resume 腿等价重放。查表 miss(未登记/已下架)= 无种子,
3657
- // 不 fail(登记簿是加性配置面,不是准入门)。
3658
- const s4ProjectId = auth?.resolvedProjectId ?? (typeof body.projectId === "string" && body.projectId ? body.projectId : undefined);
3659
- const s4DefaultScopes = s4ProjectId ? config.projects[s4ProjectId]?.defaultScopes : undefined;
3660
- const spec = {
3661
- // D-1:附件告知随 objective 进 durable 流(只有名字/mime/尺寸——**内容永不进流**,这正是
3662
- // 「拼进 objective 是伪方案」的账要划清的线;文件名已消毒为安全字符集,无注入面)。
3663
- objective: attachmentNotice ? `${picked.cleanedObjective}\n\n${attachmentNotice}` : picked.cleanedObjective,
3664
- // Decoupling seam: a trusted client (token-holder) injects its OWN business/UX system prompt
3665
- // (persona, UI markers like ::ISSUE_FORM::, workflow rules). The service stays business-agnostic
3666
- // — integrators evolve their UX here, not by changing scenario prompts in this repo. core's
3667
- // stableSystem appends it after the scenario base, before <user_memory>.
3668
- systemPrompt: typeof body.systemPrompt === "string" ? body.systemPrompt : undefined,
3669
- // [1476] R1 / [1478] R2: top-level append rider → core TaskSpec.appendSystemPrompt (stable block after the
3670
- // scenario base + systemPrompt, before the volatile tail). settings.outputStyle composes AFTER this via
3671
- // applyTaskSettings (`append\n\nstyle`) — byte-stable order: knowledge block always first, style always
3672
- // second (pinned by test; order flapping would shred the prefix cache). Resume re-enters here off the
3673
- // persisted body, so the rider re-applies on resumed legs like systemPrompt does — DEFENSIVELY (codex F2):
3674
- // the resume families skip prepareSpec's 400 gate and a pre-1.243 store kept unknown keys verbatim, so an
3675
- // over-cap legacy value is dropped+warned here instead of becoming active prompt content.
3676
- appendSystemPrompt: acceptedAppend,
3677
- // sessionId comes from `auth` (ownership-checked / principal-derived), NEVER the body — the
3678
- // authorizer always resolves one. (No `?? body.sessionId` fallback: that would re-open the
3679
- // body-as-capability hole S6 closed if a deployment ran without an authorizer.)
3680
- sessionId: auth?.sessionId,
3681
- // The authenticated end-user principal (design/62, core 1.78). Read-only on the TaskSpec — core injects
3682
- // it into a consumer MCP's `principalHeader` per-task so the MCP can enforce per-user RBAC (oa-mcp). It
3683
- // comes from the auth channel (never the body/model), so the model can't substitute another identity.
3684
- principal: auth?.principal,
3685
- images: body.images,
3686
- // design/112 C1: pass the client/gateway-supplied user context through to core (localizes the env block's date
3687
- // to the user's zone + surfaces who the agent acts for). HTTP-validated + capped at submit (prepareSpec); on a
3688
- // resume the body was already validated at its original submit. core re-validates timeZone (invalid → UTC) and
3689
- // SANITIZES userEmail (inlineUntrusted: folds newlines, neutralizes <system-reminder>, caps 160cp — no injection;
3690
- // double-review-confirmed). PICK only the two fields core reads — don't passthrough stray keys (a caller could
3691
- // otherwise bloat the persisted run body with an uncapped junk field; core ignores them but the body stays tight).
3692
- ...(body.clientContext ? { clientContext: { timeZone: body.clientContext.timeZone, userEmail: body.clientContext.userEmail } } : {}),
3693
- // Structured output (CC --json-schema): a JSON Schema constraining the model's FINAL
3694
- // answer. core injects a built-in `submit_output` tool from it + surfaces the validated object as
3695
- // TaskResult.structuredOutput. HTTP layer shape+size-validated it (prepareSpec); core does deep validation.
3696
- // RESUME re-runs resolveSpec ⇒ the structured-output constraint re-applies on resumed legs too.
3697
- // 历史复审轴B #5(1.254):compactionModel 已接([1479]①)而 design/145 配套旋钮 clampTolerance
3698
- // 够不着=不对称。窄收单键(0..1 数值;其余 compaction 键仍是操作方轴,有意不开)。
3699
- ...(() => {
3700
- const ct = body.compaction?.clampTolerance;
3701
- return typeof ct === "number" && Number.isFinite(ct) && ct >= 0 && ct <= 1 ? { compaction: { clampTolerance: ct } } : {};
3702
- })(),
3703
- outputSchema: body.outputSchema,
3704
- // 历史复审轴A #2(1.254):outputRetries 与 outputSchema 是成对的 caller 面旋钮(retry-on-invalid
3705
- // 轮数),此前只接了 schema 半边。窄校验:1..10 整数,schema 在场才有意义(core 无 schema 时忽略)。
3706
- ...(typeof body.outputRetries === "number" && Number.isFinite(body.outputRetries) && body.outputRetries >= 1
3707
- ? { outputRetries: Math.min(10, Math.floor(body.outputRetries)) }
3708
- : {}),
3709
- // E18 resume-at: the resolved persistent SessionTreeEntry.id (from the eventId the shell sent). core branches
3710
- // the session at this prior entry before running the new objective ("rewind to this message, ask differently").
3711
- resumeAt: resumeAtEntryId,
3712
- // [833] rewind exclusive mode (core 1.292): "before" branches at the target's PARENT (excludes the target —
3713
- // "remove this prompt and everything after it"); absent/"at" = core default, zero regression. Defensive
3714
- // normalize (RESUME re-enters resolveSpec without the HTTP 400 gate): only the two enum values ride, and only
3715
- // WITH a resolved resumeAt (core ignores the field otherwise; keep the persisted spec tight). core's "before"
3716
- // edge rejections (resume_at.before_target_not_user / resume_at.before_root_unsupported /
3717
- // rewind_snapshot.unresolvable) surface as failed-result errorCodes mapped to 4xx by resumeAtHttpStatus,
3718
- // errorCode passed through UNCHANGED for the shell to render.
3719
- resumeAtMode: normalizeResumeAtMode(body.resumeAtMode, resumeAtEntryId !== undefined),
3720
- // design/114 Phase3 (reuse-path warm-resume): require the session to already exist — core's acquire fails loud
3721
- // (`resume.session_not_found`) on a genuinely-missing/purged session instead of silently starting a fresh one.
3722
- // The service authorizer ALSO stops pre-registering a missing id under this flag (security.ts), so the store's
3723
- // fail-loud actually fires (else the authorizer's claim-create would materialize the session first). Rides onto
3724
- // resume legs (resolveSpec re-runs); a resumed session exists, so it's satisfied there.
3725
- requireExistingSession: body.requireExistingSession === true ? true : undefined,
3726
- // §4 (Fork): CC `/fork` — core 1.257 (design/136 BREAKING) retired the standalone Fork tool; a fork is now
3727
- // Agent(subagent_type:"fork") gated by `enableFork`. 🔴 core FLIPPED enableFork to opt-OUT (undefined = available),
3728
- // so enableForkFromBody now returns an EXPLICIT boolean (never undefined) — false where the service denies, else
3729
- // fail-OPEN. DEFAULT ON single-user turnkey (clay 2026-07-01); per-task opt-OUT via body.enableFork:false;
3730
- // multi-tenant honored ONLY with a per-principal entitlement resolver wired (core enforces `allowFork`), else
3731
- // explicit false (fail-closed). core still requires a fork-capable store (`hasSessionFork`; TOC file backend
3732
- // satisfies it via LocalSessionStore.fork), else inert.
3733
- // NB: `centerRuntimeCapsResolver`(非合成后的 runtimeCapsResolver)—— env observer 基线不是
3734
- // center 背书的 entitlement 源,多租户 fail-close 判别只认 center caps client。
3735
- enableFork: enableForkFromBody(body, config, Boolean(centerRuntimeCapsResolver)),
3736
- // E12 (shell-host contract): opt-in post-completion prompt suggestions. core runs ONE extra fire-and-forget LLM pass
3737
- // after the task completes; the service surfaces the strings (redacted, UNTRUSTED UI-only) as a `suggestions`
3738
- // event. Defensive normalize (RESUME re-runs resolveSpec without the HTTP 400 validation). OFF ⇒ zero extra LLM.
3739
- suggestNextPrompts: normalizeSuggestNextPrompts(body.suggestNextPrompts),
3740
- // E19 (shell-host contract): opt-in working-tree rewind. core snapshots each completed turn + restores the files on a
3741
- // resumeAt branch, for any env when fileSnapshotStore is wired (gate-split 1.134.0). OFF ⇒ no snapshot.
3742
- rewindFiles: body.rewindFiles === true ? true : undefined,
3743
- // R8 code-only (core 1.166): the resolved target entry id for "Restore code" — core restores the working tree to
3744
- // its snapshot WITHOUT branching the session. Mutually exclusive with resumeAt (only set on the code-only path).
3745
- rewindFilesTo: rewindFilesToEntryId,
3746
- // design/119 (CC --add-dir): extra host dirs the FILE tools may access (core canonicalizes each into the
3747
- // containment allowlist + lists them in the `# Environment` block). Single-user host lane only (gated above).
3748
- additionalDirectories,
3749
- // EnterPlanMode (core 1.167): MODEL-DRIVEN plan — `enablePlanMode:true` alone (writable
3750
- // start, NO handsReadOnly) auto-mounts `enter_plan_mode`/`present_plan` so the model can self-enter read-only
3751
- // plan AT ANY TIME it judges a task needs planning. 🆕 DEFAULT ON (clay: resident; PLAN_MODE_ENABLED=false opts
3752
- // out). USER-driven plan (permissionMode=plan) ADDS handsReadOnly on top via applyTaskSettings (which also sets
3753
- // enablePlanMode, idempotent). Additive — widens nothing; the model self-selects when to use it.
3754
- enablePlanMode: config.planModeEnabled ? true : undefined,
3755
- // Workflow super-set unlock (same shape as the rank-1 roster gap): per-task activation of the
3756
- // LLM-authored workflow engine (core `run_workflow` + the workflow.ts agent/parallel/pipeline orchestration).
3757
- // The engine is BUILT but the HTTP API never mapped a body field to `spec.selfOrchestration`, so run_workflow
3758
- // was per-task unreachable (capabilities advertised `workflows:true` but the LLM could never call it). Gated on
3759
- // the DEPLOYMENT enabling it (`selfOrchestrationEnabled` → workflowScriptRunner/stores wired; else a set flag is
3760
- // a harmless no-op core fail-closes). 🔒 Multi-tenant entitlement is NOT blanket-closed here (unlike mcp, which
3761
- // has no engine entitlement) — core's `runtimeCapsResolver` enforces `allowWorkflows` (the third-stage
3762
- // per-principal cap) ON THE ENGINE, so a multi-tenant principal WITHOUT the entitlement gets run_workflow
3763
- // fail-closed by core (no resolver ⇒ fail-closed). Single-user honors directly. Three-gate: engine-can ∧
3764
- // center-may(allowWorkflows) ∧ shell-show. (Logic + gates documented in `selfOrchestrationFromBody`.) 🔒 The
3765
- // multi-tenant floor: pass whether an entitlement RESOLVER is wired — core's allowWorkflows is TIGHTEN-ONLY
3766
- // (no resolver ⇒ fail-OPEN per-principal), so the helper fail-closes multi-tenant when none is present.
3767
- // L2 ultracode (design/111): OR the preset's selfOrchestration:true into the body intent, passed THROUGH the
3768
- // existing gate — it grants nothing a raw body.selfOrchestration:true couldn't (multi-tenant still fail-closes
3769
- // without an entitlement resolver). The preset never bypasses the闸.
3770
- selfOrchestration: selfOrchestrationFromBody({ selfOrchestration: body.selfOrchestration === true || parsedSettings.settings?.ultracode === true }, config, Boolean(centerRuntimeCapsResolver)),
3771
- // C1 (core 1.219, subagent viewing pane): opt-in widening of the forward sink from
3772
- // task_progress-only to a delegated child's live CONTENT events (text_delta/reasoning_delta/tool_start/
3773
- // tool_end, each carrying `parentToolCallId` attribution). Purely a RENDER channel — core never merges the
3774
- // child stream into the parent's model context. The service's forward-sink consumers redact per §E1 (shared
3775
- // builders); default OFF = prior progress-only behavior. Strict `=== true` (never a truthy coercion).
3776
- forwardSubagentEvents: body.forwardSubagentEvents === true ? true : undefined,
3777
- // design/122 (core 1.225): opt-in retention of SETTLED sub-agent sessions so the operator/
3778
- // shell can REVIVE them (POST /v1/runs/:id/subagents/:target/resume). Defensive normalize + clamp
3779
- // (RESUME re-runs resolveSpec without HTTP validation): boolean passes; an object's ttlMs/max are clamped
3780
- // to sane ceilings (retention pins live sessions in memory — a caller must not turn the knob into a
3781
- // resource hold; core's own defaults are 30min/16, the run-scoped ledger releases everything at parent
3782
- // end either way). Malformed values fall to undefined = OFF (core default), never a throw.
3783
- retainSubagentSessions: normalizeRetainSubagentSessions(body.retainSubagentSessions),
3784
- // [876] per-task custom subagents (core 1.295 TaskSpec.agents — CC `.claude/agents/` parity): the shell's
3785
- // resolved AgentDefinition[] rides the spec verbatim (model already a real name/Model object — no alias
3786
- // translation here). 🔒 TOB tenant gate + defensive per-item normalize live in taskAgentsFromBody
3787
- // (spec-fields.ts): multi-tenant is fail-closed (warn task_agents_ignored — no per-tenant caps face yet,
3788
- // capabilities.taskAgents advertises false); RESUME replays skip the HTTP whitelist 400 → illegal items
3789
- // DROP+warn per entry (task_agent_dropped), never a bricked resume. Absent/empty ⇒ no key (byte-compat).
3790
- // [1.211 codex L] 内联闭包换 spec-fields 真出口(taskAgentsSpecFragment)——测试直接 import 调真
3791
- // fold,SOURCE PIN 收敛为「resolveSpec 内恰一次调用点」单锚(文本重写同构表达式的假绿面就此关)。
3792
- ...taskAgentsSpecFragment(body.agents, config.requirePrincipal, (event, fields) => logger.warn(event, { ...fields, sessionId: auth?.sessionId ?? null })),
3793
- // [854]② (core 1.295): opt-out of task-end session-background reaping ("keep my dev server running").
3794
- // Resource-residency grant on the host lane → single-user gate like backgroundShell/hooks (multi-tenant
3795
- // warns task_retain_bg_ignored + ignores; capability advertises false). Only literal `true` rides (false =
3796
- // core default, key omitted); non-boolean is 400 at submit, defensively dropped on a resume replay.
3797
- ...(retainBackgroundProcessesFromBody(body.retainBackgroundProcesses, config.requirePrincipal, (event, fields) => logger.warn(event, { ...fields, sessionId: auth?.sessionId ?? null })) === true
3798
- ? { retainBackgroundProcesses: true }
3799
- : {}),
3800
- // [1052]② (core 1.314): per-run 工具面收窄两位——excludeTools=roster 真卸载,deferTools=延迟披露
3801
- // (cli「Workflow 默认开不暴露」承载位)。tighten-only(core 继承 union 不变量),无租户门;defensive
3802
- // normalize(resume 重放),提交面 400 在 HTTP 门。
3803
- ...(() => { const v = toolNameListFromBody(body.excludeTools); return v ? { excludeTools: v } : {}; })(),
3804
- ...(() => { const v = toolNameListFromBody(body.deferTools); return v ? { deferTools: v } : {}; })(),
3805
- // [1144]/[1146] (core 1.328 R2): promptProfile 双形轴直通——纯呈现轴无租户门(deferTools 姿势);
3806
- // 缺省不挂键(引擎缺省 simple);非法值由 HTTP 门 400,resume 重放 defensive DROP。
3807
- ...(() => { const v = promptProfileFromBody(body.promptProfile); return v ? { promptProfile: v } : {}; })(),
3808
- // [922]①/[915]② (core 1.296): interactiveTools 三态旋钮直通——boolean 上 spec(壳 -p 恒 stamp false=
3809
- // A3 类「headless 提问 park」根治腿;plan 显式 true 恒赢),undefined=core 自动判据(送达面探测)。
3810
- // per-run 工具面选择无跨租户面 ⇒ 无租户门;非 boolean 由 HTTP 门 400,resume 重放 typeof 检查天然 DROP。
3811
- ...(typeof body.interactiveTools === "boolean"
3812
- ? { interactiveTools: body.interactiveTools }
3813
- : {}),
3814
- // design/131 (core 1.246): per-task resilience INTENT flags. allowDegrade/allowFailover are
3815
- // caller-facing (a bench/eval run wants true failure shapes); bypassBreaker is operator-only (normalizer
3816
- // drops it for non-operators — it punches through a SHARED breaker). All-absent = byte-compat.
3817
- resilience: normalizeResilience(body.resilience, explicitOperator(auth?.principal, config.operatorPrincipals)),
3818
- // design/132 (core 1.249): one-shot end-game verification nudge ("re-run the final artifact through
3819
- // its real entrypoint before finishing"). OPT-IN by core's own judgment (default ON lost the evidence case:
3820
- // +1 turn on every interactive write task) — the AUTONOMY caller declares it (harness/scheduler lanes).
3821
- // [849] scenario-declared leg: the `autonomous` scenario pins it in its bundle (cap.finalVerification —
3822
- // picking that scenario IS the autonomy declaration, so this is not "inferring from scenario"); OR-folded
3823
- // with the caller's explicit flag — a scenario can only ADD the closing verification turn, never strip a
3824
- // caller's request. Children do not inherit (core semantics).
3825
- finalVerification: cap.finalVerification === true || body.finalVerification === true ? true : undefined,
3826
- // design/133 (core 1.251): turn-boundary attachment reminders (todo/changed-files/plan-mode),
3827
- // literal-true unions — the normalizer drops `false`/garbage keys instead of forwarding them (core contract:
3828
- // "off = delete the key, never send false"). Resume-safe by construction: the persisted body is full JSON
3829
- // and this is the single translation point, so this line IS the whitelist inclusion.
3830
- attachments: normalizeAttachments(body.attachments),
3831
- // hooks(阶段一,gate 见上面 taskHooks 装配):core 是整槽覆盖(`spec.hooks ?? deps.hooks`,
3832
- // runtask gateBaseline 注释点名),task hooks 一挂会 shadow deps.hooks 上的 TOOL_TRACE 观测 → composeHooks
3833
- // 把部署基线折进来(部署槽先跑=观测看到真实执行)。无 task hooks 时不挂字段,deps 路径原样。
3834
- ...(taskHooks ? { hooks: composeHooks(deploymentHooks, taskHooks) } : {}),
3835
- model: picked.model,
3836
- // [1479]① compactionModel (design/145 cheap compaction gear): catalog-gated to the SAME expanded view as
3837
- // body.model (canonical name form). resolveSpec is the AUTHORITY (codex R5 — the prepareSpec 400 is only
3838
- // a fast-fail UX layer; the catalog hot-refreshes in place, so a ref valid at the HTTP gate can be gone
3839
- // after the authorize/resolve awaits): unknown on a FRESH leg → 400 fail-loud (the documented guarantee —
3840
- // never a silent price change); unknown on a RESUME replay → drop + warn (core's resolveModel THROWS on
3841
- // an unknown compactionModel, which would brick the resume; the fallback is the summarize role / main
3842
- // model, the pre-field behavior).
3843
- // Window note (codex R6, requalified): the resolveSpec→Runner handoff shares spec.model's DOCUMENTED
3844
- // restart-window class ([865] / the wireCatalog note above) — the Runner resolves the canonical string
3845
- // against ITS OWN constructor-time expanded snapshot, so a hot refresh between here and prepare can
3846
- // desync the two views. Inside that window the failure mode is core's resolveModel TYPED THROW
3847
- // ("Unknown model ref", roles.js dist-read) = a fail-loud task error, never a silent price change —
3848
- // the same honest posture the sibling field ships with. Binding a Model OBJECT here instead would
3849
- // diverge from spec.model's string-intent contract and freeze catalog bytes into the persisted body.
3850
- ...((() => {
3851
- if (typeof body.compactionModel !== "string" || body.compactionModel.length === 0)
3852
- return {};
3853
- const cm = matchCatalogModel(body.compactionModel, wireCatalog);
3854
- if (cm === undefined) {
3855
- if (opts?.leg === "fresh") {
3856
- throw new HttpError(400, `unknown compactionModel "${body.compactionModel.slice(0, 120)}" — not in the configured catalog (name, tier word, or id; a catalog refresh may have removed it mid-request)`);
3857
- }
3858
- metrics.inc("task_model_unknown_fallback_total");
3859
- logger.warn("compaction_model_unknown_fallback", { requested: body.compactionModel.slice(0, 120), sessionId: auth?.sessionId ?? null });
3860
- return {};
3861
- }
3862
- return { compactionModel: cm };
3863
- })()),
3864
- // E7 (shell-host contract): reasoning-effort selection threaded to core's ThinkingLevel. Defensive
3865
- // isThinkingLevel guard — RESUME re-runs resolveSpec WITHOUT the HTTP-layer 400 validation, so an invalid/
3866
- // absent value falls through to the resolved role's default thinking (core's RoleSpec.thinking), never throws.
3867
- // L2 ultracode (design/111): the preset FLOORS thinking at the ultra tier (= max, via core resolveReasoningProfile);
3868
- // awareness rides for free (core composes it from thinking∈{xhigh,max}). Else the explicit reasoningEffort.
3869
- thinking: effectiveThinking(body.reasoningEffort, parsedSettings.settings?.ultracode === true),
3870
- // Per-model auth (sema-registry apiKeyEnv): core calls this per brain call / cascade rung so each
3871
- // model uses its own upstream key; a model without one falls back to the gateway key. undefined
3872
- // when no per-model keys are configured → unchanged single-key behavior.
3873
- getApiKeyAndHeaders: keyResolver,
3874
- // [854]④ per-request 配速:body.limits.{timeoutSec,maxOutputTokens,maxTurns} 现在被收下(核对上游
3875
- // TB2.0 实测诉求;旧姿势「body limits 一律忽略」作废)。合成规则在 resolveTaskLimits(spec-fields.ts):
3876
- // - body.timeoutSec 给了就用 body(caller 显式配速,可低于内建墙;已被可选 TASK_TIMEOUT_MAX_SEC 封顶);
3877
- // - body 缺席保持既有姿势 = tenancy 墙钟(单用户 turnkey 无墙 / 多租 2400s、大任务 3600s,
3878
- // clay 2026-07-04 make-real 教训①)+ env TASK_TIMEOUT_SEC 只抬不降(taskWallClockSec 内 Math.max);
3879
- // - maxOutputTokens/maxTurns 直透传(可选 TASK_MAX_OUTPUT_TOKENS_MAX / TASK_MAX_TURNS_MAX 同款封顶)。
3880
- // resume 重放持久化 body 不过 HTTP 400 门 → normalizeLimits defensive(0/负/垃圾按键 DROP,不 throw)。
3881
- ...(() => {
3882
- const limits = resolveTaskLimits(body.limits, taskLimitCaps, taskTimeoutSec, config.requirePrincipal, body.council === true || body.debate === true || scenarioName === "team");
3883
- // [1301]③ config catalog server 半场:env 封顶不再是「五层五值互不知情」的暗手——每个真在场
3884
- // 的运营方旋钮以 configOverrides 声明进 spec(advisory,core 折进 config.assembled 的
3885
- // overrideReasons;「谁设的顶」变成读帧不考古)。只声明 SET 了的键(缺省不设=不污染帧)。
3886
- const declarations = [];
3887
- if (taskLimitCaps.timeoutSec !== undefined)
3888
- declarations.push({ key: "server.limits.timeoutSecCap", value: String(taskLimitCaps.timeoutSec), reason: "env TASK_TIMEOUT_MAX_SEC (operator ceiling on caller limits.timeoutSec)" });
3889
- if (taskLimitCaps.maxOutputTokens !== undefined)
3890
- declarations.push({ key: "server.limits.maxOutputTokensCap", value: String(taskLimitCaps.maxOutputTokens), reason: "env TASK_MAX_OUTPUT_TOKENS_MAX (operator ceiling)" });
3891
- if (taskLimitCaps.maxTurns !== undefined)
3892
- declarations.push({ key: "server.limits.maxTurnsCap", value: String(taskLimitCaps.maxTurns), reason: "env TASK_MAX_TURNS_MAX (operator ceiling)" });
3893
- // PAIR-REVIEW F-4:budget 族与墙钟基值的声明补齐——[1301]③ 首批只做了 limits 三键,而
3894
- // MAX_TASK_COST_USD/MAX_TASK_TOKENS 在 caller 缺席时**直接成为** spec 值(cappedCeiling),core
3895
- // 便记 provenance:"spec"=归因谎报(「谁设的 5 刀」重回考古)。同姿势:只声明 SET 了的键,
3896
- // server 命名空间,绝不重复 core 目录键。
3897
- if (config.maxTaskCostUsd > 0)
3898
- declarations.push({ key: "server.budget.maxCostUsdCap", value: String(config.maxTaskCostUsd), reason: "env MAX_TASK_COST_USD (operator ceiling; becomes the effective value when the caller sends none)" });
3899
- if (config.maxTaskTokens > 0)
3900
- declarations.push({ key: "server.budget.maxTokensCap", value: String(config.maxTaskTokens), reason: "env MAX_TASK_TOKENS_MAX (operator ceiling; becomes the effective value when the caller sends none)" });
3901
- // F-4 附:tenancy 墙钟基值(taskWallClockSec 在 multi-tenant 时注入 2400/3600 基值——同为
3902
- // 「server 设的顶」,声明其来源;single-user 无墙=不声明)。
3903
- if (config.requirePrincipal === true)
3904
- declarations.push({ key: "server.limits.wallClockBaseSec", value: "2400/3600", reason: "tenancy wall-clock base (multi-tenant; big tasks 3600) — TASK_TIMEOUT_SEC raises, never shrinks" });
3905
- // codex F1:core 目录自有键(limits.timeoutSec 等)**绝不重复声明**——core 已按真 provenance 发
3906
- // 该字段,叠一条 host-declared 字符串值=同键双条矛盾帧(by-key 投影抹掉权威来源)。server 只
3907
- // 声明自己命名空间的键;来源语义(caller vs 墙钟)由 timeoutSec 的 spec provenance + 上面的
3908
- // Cap 声明组合可读。
3909
- return { ...(limits !== undefined ? { limits } : {}), ...(declarations.length > 0 ? { configOverrides: declarations } : {}) };
3910
- })(),
3911
- // design/129: the TOC/interactive posture (single-user turnkey — the shell's
3912
- // session lane) defaults background children to SESSION scope = CC Backgrounded semantics (a bg Agent/
3913
- // Fork outlives the turn; completion arrives via the durable-inbox push). Multi-tenant / CI / workflow
3914
- // deployments keep core's "task" default (no orphans burning tokens). Caller-trusted spec field
3915
- // (systemPrompt tier), NEVER read from the request body. Same tenancy predicate as the wall clock above.
3916
- ...(config.requirePrincipal !== true ? { backgroundScope: "session" } : {}),
3917
- // ⑤ Per-task budget gate (1.37): honor a caller's requested ceiling but CAP it to the operator
3918
- // ceiling (a request can ask for less, never more). core fails the task with errorCode budget.*
3919
- // when crossed. budgetStreamCancel defaults true when maxCostUsd is set.
3920
- maxCostUsd: cappedCeiling(body.maxCostUsd, config.maxTaskCostUsd),
3921
- maxTokens: cappedCeiling(body.maxTokens, config.maxTaskTokens),
3922
- // 1.40 near-budget degradation: only meaningful with a cost ceiling (the fraction is of it).
3923
- // When this task has one, switch to the cheaper model at atCostFraction instead of hard-failing.
3924
- // vision precheck (adversarial-review finding): DROP degrade for an image-carrying task when the
3925
- // degrade TARGET can't read images (toSupportsImages=false). The precheck only sees the picked model, not the
3926
- // external degrade target — so without this a runtime degrade would send the images to a text-only gateway (the
3927
- // opaque 400 the precheck prevents). The task keeps its vision-capable main model; near-budget it hard-fails on
3928
- // cost instead of image-failing. MODEL_DEGRADE_TO_VISION=true opts back in.
3929
- degrade: (() => {
3930
- if (!config.degrade || cappedCeiling(body.maxCostUsd, config.maxTaskCostUsd) === undefined)
3931
- return undefined;
3932
- if (Array.isArray(body.images) && body.images.length > 0 && !config.degrade.toSupportsImages) {
3933
- // S14 (SILENT-FALLBACK P0-f): this drop was undetectable — the task keeps its vision-capable main
3934
- // model and near-budget it hard-fails on cost instead of degrading (rationale above). Surface it.
3935
- metrics.inc("degrade_dropped_total", { reason: "vision_target" });
3936
- logger.warn("degrade_dropped", { reason: "vision_target", model: picked.model, images: body.images.length });
3937
- return undefined;
3938
- }
3939
- return { to: config.degrade.to, atCostFraction: config.degrade.atCostFraction };
3940
- })(),
3941
- // Long-term memory (design/138 S1): enabled when the memory ENGINE is wired AND a scope was derived
3942
- // (single-user only — multi-tenant derives none, memory dark). MF-30 PAUSE (option B, per-request —
3943
- // clay 2026-06-27 confirmed with core): `body.memoryWrite:false` makes THIS run read-only over memory
3944
- // (`writeScope:null` — the engine materializes/reads but harvest commits nothing). Per-request (not a
3945
- // stored flag) → no new per-session state; the shell carries the toggle. RESUME re-runs resolveSpec
3946
- // from the persisted body, so a paused run stays paused across resume legs.
3947
- memory: memoryEngine ? memorySpecForRequest(auth?.memoryScope, body.memoryWrite, s4DefaultScopes) : undefined,
3948
- // Scenario-provided capabilities (e.g. code-review = repo tools + reviewer subagents + prompt).
3949
- // RFC A2: the SelectEnvironment tool rides after the scenario's tools (spec.tools is ADDITIVE to core's
3950
- // built-in roster — prepare-task mounts first-party tools separately). Only when the image chain is live.
3951
- tools: ((base) => {
3952
- const extra = [...(selectEnvTool ? [selectEnvTool] : []), ...(sendUserFileToolSpec ? [sendUserFileToolSpec] : [])];
3953
- const merged = extra.length > 0 ? [...(base ?? []), ...extra] : base;
3954
- // [803]④ defer face (EXPERIMENTAL, default OFF): long-tail first-party tools get per-tool defer:true
3955
- // (assessment + why NOT deferMode:"auto"/SendUserFile: src/capabilities/tool-defer.ts header).
3956
- return merged && config.toolDeferLongtail ? applyLongtailDefer(merged, true) : merged;
3957
- })(cap.tools),
3958
- // Personalization: the caller's own skills ride after the scenario's; scenario
3959
- // wins on a name clash (mergeUserSkills — the security baseline can't be shadowed). HTTP layer
3960
- // already validated shape + caps (validateUserSkills).
3961
- skills: mergeUserSkills(cap.skills, body.skills, logger),
3962
- // Sema-registry MCP servers (B1) filtered to this scenario (env-NAME refs already resolved at startup),
3963
- // PLUS the caller's gated per-request MCP (R3 — TOC local `.mcp.json`). resolveRequestMcp honors body.mcpServers
3964
- // on any SINGLE-USER deployment (`requirePrincipal!==true` — the requester is the super-admin of their own
3965
- // worker; the stdio MCP runs on the worker, not the exec env, so the lane is irrelevant → seamless TOC↔cloud);
3966
- // a multi-tenant deployment returns the baseline unchanged (gate closed → ignored, advertised via
3967
- // capabilities.mcpInjection). Baseline wins on a name clash (a caller can ADD a server, never SHADOW a configured one).
3968
- mcp: resolveRequestMcp(mcpForScenario(config.mcpServers, scenarioName), body.mcpServers, config, logger),
3969
- // center prompts 投影([1057]①f):center 下发的场景终形(overrides[s] ?? 基线)赢过内置 provider
3970
- // (center-wins,scenario overlay 同先例);无 pack/该场景空声明 ⇒ 内置照旧。声明数组逐字喂 typed
3971
- // 钩子(contentHash 透传,core 1.315 校验入 manifest);packId 归因在采用日志(center_prompts_adopted)。
3972
- // codex R4:读请求级快照 centerDecls(与 append-less 门同一份),不再回读活 ref——刷新竞态下门与
3973
- // provider 才不会各看一版(TOCTOU:门判安全、pick 到已组装包=core 侧静默丢 rider 复活)。
3974
- promptProvider: centerDecls ? centerPromptProvider(centerDecls, scenarioName) : cap.promptProvider,
3975
- // F4: high-risk write approval gate. DURABLE (design/45, opt-in): the gated `ask` SUSPENDS the task
3976
- // (durable checkpoint, resumable on any replica). Else the legacy POLL gate: the call waits on a
3977
- // durable pending row any instance can decide via /v1/approvals, releasing at the task deadline.
3978
- // Durable ask (TC-5.4, core 1.95): in durable mode an AskUserQuestion call suspends like an F4 gate —
3979
- // the question policy adjudicates it `ask`, the operator answers out-of-band, and the resume carries
3980
- // the QuestionAnswer (server.ts `body.answer` → onQuestion closure). Suspend side mounts the tool with
3981
- // QUESTION_AWAITS_RESUME (it must never run pre-suspend; reaching it = wiring bug, typed throw).
3982
- toolPolicy: durableEnabled
3983
- ? combinePolicies(createDurableQuestionPolicy(), createDurableAskPolicy({
3984
- requireApproval: config.approvalRequire, deny: config.approvalDeny, autoBudget: config.approvalAutoBudget, neverAuto: config.approvalNeverAuto,
3985
- // The probe key is the CONTINUED session (auth.sessionId — the same id that keys the
3986
- // durable checkpoint /decide route). A fresh session (no body.sessionId) has no exemptions by
3987
- // construction; fail-closed inside the policy on a store error.
3988
- ...(approvalExemptionStore && auth?.sessionId
3989
- ? {
3990
- exempt: (toolName) => approvalExemptionStore.has(auth.sessionId, toolName),
3991
- onExempted: (toolName, rawToolName) => logger.info("approval_exempted", { sessionId: auth.sessionId, toolName, rawToolName }),
3992
- }
3993
- : {}),
3994
- }))
3995
- : approvalEnabled && approvalStore
3996
- ? createOaApprovalPolicy({
3997
- store: approvalStore,
3998
- requireApproval: config.approvalRequire,
3999
- deny: config.approvalDeny,
4000
- pollMs: config.approvalPollMs,
4001
- context: () => ({ sessionId: auth?.sessionId ?? null, owner: auth?.principal ?? null }),
4002
- neverAuto: config.approvalNeverAuto, // workflow audit 2026-07-13: the ordering lock must hold on the OA leg too
4003
- // Same probe on the legacy poll gate (sessionId threaded via ctx here).
4004
- ...(approvalExemptionStore
4005
- ? {
4006
- exempt: (sessionId, toolName) => approvalExemptionStore.has(sessionId, toolName),
4007
- onExempted: (sessionId, toolName) => logger.info("approval_exempted", { sessionId, toolName }),
4008
- }
4009
- : {}),
4010
- })
4011
- : singleUserAutoAcceptBaseline
4012
- // 🔴 (live finding: single-user host-lane Bash ran UNGATED, no adjudication): a
4013
- // single-user turnkey worker with NO expressed gate intent previously left `toolPolicy` UNDEFINED →
4014
- // core's write-capable hand tools mount UNADJUDICATED (core error-logs it every task; host lane has NO
4015
- // sandbox to fall back on). CC's trust model is auto-accept, but the GATE MECHANISM must be PRESENT
4016
- // (core 原则: "机制留、默认可更宽 auto-accept"). So wire an adjudicated auto-accept BASELINE
4017
- // (`createAllowDenyPolicy({})` = a present, effect-aware policy that allows all): satisfies core's
4018
- // `hasEffectAwareGate`, restores observability + a hook/tighten point, and the operator STILL tightens
4019
- // irreversible ops via `AUTONOMY` / `commandPolicy` (layered TIGHTEN-ONLY by applyRuntimeGovernance).
4020
- // Interactive approval routed to the shell HITL = follow-on. 🔴 `singleUserAutoAcceptBaseline` requires
4021
- // ZERO gate intent — a single-user op who SET approval flags but wired no store falls to `undefined`
4022
- // (core warns = real misconfig, not masked); multi-tenant likewise stays `undefined` (approval required).
4023
- ? createAllowDenyPolicy({})
4024
- : undefined,
4025
- // Durable suspend needs both the store (here, per-task) and the opt-in scope key (multi-tenant =
4026
- // principal; "_" when auth is off). core suspends on a policy `ask` only when these are present.
4027
- ...(durableEnabled
4028
- ? {
4029
- checkpointStore,
4030
- durableApproval: {
4031
- scope: auth?.principal ?? "_",
4032
- ...(config.approvalTimeoutSec > 0 ? { ttlMs: config.approvalTimeoutSec * 1000 } : {}),
4033
- },
4034
- // design/80 seam #2: opt this task into resource/preempt durable-suspend (design/74 third state)
4035
- // when RESOURCE_SUSPEND=true — a budget/turns/walltime limit OR a scheduler preempt durably SUSPENDS
4036
- // (resumable) instead of failing. SAME scope key as durableApproval (the principal). The decision
4037
- // (incl. the 🔴 verify/cascade EXCLUSION that keeps eligibility aligned with the preemptSignal wiring,
4038
- // so an inner suspend can't orphan a VM+checkpoint) lives in the pure, unit-tested `resourceSuspendOptIn`.
4039
- ...((rs) => (rs ? { resourceSuspend: rs } : {}))(resourceSuspendOptIn({
4040
- enabled: config.resourceSuspend,
4041
- ttlSec: config.resourceSuspendTtlSec,
4042
- isVerify: body.verify === true,
4043
- isCascade: body.cascade === true,
4044
- scope: auth?.principal ?? "_",
4045
- })),
4046
- onQuestion: QUESTION_AWAITS_RESUME,
4047
- }
4048
- : {}),
4049
- };
4050
- // Runtime governance "second baton" (center §10): compile the operator's autonomy + commandPolicy onto the
4051
- // spec TIGHTEN-ONLY. commandPolicy layers via combinePolicies onto the approval/durable baseline above (NOT a
4052
- // bare overwrite — TRAP #1), autonomy expands to handsReadOnly/shellGate (TRAP #2). Applied here so it also
4053
- // re-applies identically on the resume paths (which rebuild via resolveSpec) — core requires shellGate/
4054
- // toolPolicy be re-supplied on resume (omitting shellGate would leave a resumed run's bash UNGATED).
4055
- // tightenTaskSpec THROWS on a loosening misconfig (fail-loud).
4056
- //
4057
- // 🔴 RESUME uses LIVE config (not a per-task frozen snapshot), IDENTICAL to how the approval baseline above
4058
- // reads live `config.approvalRequire`. So if an operator LOOSENS the fleet governance while a task is
4059
- // suspended, the resumed task picks up the looser policy (governance stays per-call tighten-only, so a
4060
- // mid-suspend TIGHTENING is honored; a loosening is an explicit operator action). Freezing deployment policy
4061
- // per-task across suspend is a larger, separate decision that must cover the approval baseline too — tracked
4062
- // for center (adversarial-review HIGH-1), deliberately NOT a governance-only partial freeze here (that would
4063
- // be an inconsistent false-safety: approval would still be live).
4064
- const governedBase = applyRuntimeGovernance(spec, { autonomy: config.autonomy, commandPolicy: config.commandPolicy });
4065
- // Fold the client's per-request settings stamp onto the governed spec, TIGHTEN-ONLY (deny-wins) — AFTER
4066
- // the approval baseline + operator governance, so the order is deployment ⊇ operator ⊇ client (each can only
4067
- // narrow). tightenTaskSpec THROWS if the client settings would LOOSEN a stricter baseline → a 422 client error
4068
- // (a settings stamp is tighten-only by contract; never a silent weakening). Re-applies idempotently on resume.
4069
- let governed = governedBase;
4070
- // R4: fold a LIGHT top-level `body.permissionMode` onto the parsed settings (the explicit
4071
- // per-turn intent WINS over a bundle `defaultMode`) → the SAME tighten-only governance path. Post-[816] all
4072
- // five CC modes are honored as gate-SHAPE choices (see coercePermissionMode/deriveSettingsPolicy): `plan` ⇒
4073
- // read-only hands + present_plan; `default`/`auto` ⇒ the fs-write ask gate; `acceptEdits` ⇒ the cwd-domain
4074
- // variant; `bypassPermissions` ⇒ no mode gate (the deployment baseline is composed above and untouchable).
4075
- const bodyMode = coercePermissionMode(body.permissionMode);
4076
- let effectiveSettings = bodyMode ? withPermissionMode(parsedSettings.settings, bodyMode) : parsedSettings.settings;
4077
- // codex R3 second bypass: settings.outputStyle ALSO lands in spec.appendSystemPrompt (applyTaskSettings
4078
- // fold) — on an append-less pack core would discard it silently, exactly like the top-level rider. Same
4079
- // disposition: fresh submits 400 at the HTTP gate; here (the resume mirror + any leg the gate didn't see)
4080
- // strip + warn. Spread-copy, never mutate parsedSettings (it may be consulted elsewhere).
4081
- if (appendLessPack && effectiveSettings?.outputStyle) {
4082
- logger.warn("output_style_dropped", { detail: "the effective prompt pack cannot mount the append slot (already-assembled systemPrompt or center assembled-identity declaration)", sessionId: auth?.sessionId ?? null });
4083
- const { outputStyle: _dropped, ...rest } = effectiveSettings;
4084
- effectiveSettings = Object.keys(rest).length > 0 ? rest : undefined;
4085
- }
4086
- // codex R14 resume mirror of the aggregate cap: both carriers fold into ONE spec field — a pre-cap stored
4087
- // body can be individually valid but combined over the bound (fresh submits 400'd this in prepareSpec).
4088
- // Drop the STYLE (it composes second; the knowledge rider keeps priority) + warn, never silently oversize.
4089
- if (effectiveSettings?.outputStyle && acceptedAppend && acceptedAppend.length + 2 + effectiveSettings.outputStyle.length > MAX_SETTINGS_OUTPUT_STYLE_CHARS) {
4090
- logger.warn("output_style_dropped", { detail: `combined append carriers exceed the ${MAX_SETTINGS_OUTPUT_STYLE_CHARS} cap (rider ${acceptedAppend.length} + style ${effectiveSettings.outputStyle.length}) — style dropped (pre-cap stored body on a resume leg?)`, sessionId: auth?.sessionId ?? null });
4091
- const { outputStyle: _dropped2, ...rest2 } = effectiveSettings;
4092
- effectiveSettings = Object.keys(rest2).length > 0 ? rest2 : undefined;
4093
- }
4094
- // [820]③ scratchpadDir, hoisted from the envFacts block below so the SAME per-session dir feeds BOTH the
4095
- // `# Environment` fact and the fs-write gate's exemptDirs (single-source path via scratchpadDirFor inside
4096
- // ensureScratchpadDir). HOST-semantics lanes only + session required — rationale at the envFacts consumer.
4097
- const hostSemanticsLane = config.remoteExec === undefined || config.remoteExec.provider === "host";
4098
- // G3([816]③/[820]③,[1840]§四):壳供 body.scratchpadDir 优先——壳(CC 形)有自己的 per-session
4099
- // scratchpad 约定路径,接受后事实/豁免/根围栏三处同源指向壳的目录(壳侧提示词与 server 写门不再
4100
- // 两个 scratchpad)。验收门 fail-closed(多租户/远程 lane/浅路径全拒收,warn 后回落自算)——
4101
- // 规则与理由集中在 acceptShellScratchpadDir。
4102
- const shellScratchpad = await acceptShellScratchpadDir(body.scratchpadDir, {
4103
- requirePrincipal: config.requirePrincipal,
4104
- hostSemanticsLane,
4105
- warn: (msg, meta) => logger.warn(msg, { ...(meta ?? {}), sessionId: auth?.sessionId ?? null }),
4106
- });
4107
- const scratchpadDir = shellScratchpad ??
4108
- (hostSemanticsLane && auth?.sessionId ? await ensureScratchpadDir(config.localDataRoot ?? localRoot, auth.sessionId) : undefined);
4109
- if (effectiveSettings) {
4110
- // [816]/[820]① fs-write gate wiring — HOST-semantics lanes only: core's createFsWriteGatePolicy
4111
- // canonicalizes每一个 target/dir 走给定 env 的真实 fs(exists/canonicalPath/readLink,dist 亲读),所以
4112
- // env 必须就是 hand 工具真正写的那块盘。e2b/k8s/ssh/adb/local-docker 的沙箱 env 由 core 在 spec 之后
4113
- // 才铸(executionEnvFactory),这里给 worker 本机 env 会拿错误的 fs 裁决(symlink/exists 全答错)——
4114
- // 沙箱 lane 诚实不挂(gate=undefined ⇒ derive 回落 base 规则,pre-[816] 行为)。
4115
- // NodeExecutionEnv 构造是纯字段赋值(不 spawn)。
4116
- //
4117
- // 修5(三路复审 W4,cwd 基准漂移):gate 的 cwd 基准=与 hands 真跑处同源,分三形:
4118
- // · session 注册过 cwd(cwdHonored 已在注册处把关)⇒ 用它——与 host factory 的 effectiveHostWorkspace
4119
- // 第一优先级同源(顶层 HTTP 任务无 parentCwd,resolver 即 session cwd)。
4120
- // · `host` factory lane 且未注册 ⇒ hands 落在 factory 才铸的 EPHEMERAL per-task 目录(spec 期不可知)。
4121
- // 此前回退 process.cwd()(服务自身启动目录)——acceptEdits 会把服务目录当 auto-allow 域、多任务共享
4122
- // 进程时判定漂移(W4)。改为 fail-safe 哨兵:一个从不创建的目录 ⇒ acceptDirs/相对路径 canonicalize
4123
- // 失败即 ask(fs-write-gate-policy dist 亲读:dir.ok=false 跳过、target 解析失败=ask);绝对路径裁决
4124
- // (hand 工具的书面契约形)与 scratchpad exempt 均不受影响。
4125
- // · provider 未设(in-process host / run-local)⇒ hands 就在本进程 cwd 跑(无 factory),process.cwd()
4126
- // 正是真工作目录,保留。
4127
- const fsWriteGate = hostSemanticsLane
4128
- ? (() => {
4129
- const sessionCwd = auth?.sessionId ? effectiveHostWorkspace(perSessionCwd.get(auth.sessionId), {}) : undefined;
4130
- const gateCwd = sessionCwd ??
4131
- (config.remoteExec === undefined
4132
- ? process.cwd()
4133
- : join(config.localDataRoot ?? localRoot, "fs-write-gate-unrooted")); // never created — see above
4134
- return {
4135
- env: new NodeExecutionEnv({ cwd: gateCwd }),
4136
- cwd: gateCwd,
4137
- ...(scratchpadDir ? { scratchpadDir } : {}),
4138
- // ③ (core 1.295) sensitive-path write deny set:组合进 gate 腿的同一 combinePolicies 折叠
4139
- // (deny 恒赢 —— 豁免/acceptDirs 越不过;集合取舍 core 成文,server 只透传 config 旋钮:
4140
- // 缺省 = core RECOMMENDED_SENSITIVE_PATTERNS,SENSITIVE_WRITE_PATTERNS 显式替换/off 关闭)。
4141
- ...(config.sensitiveWritePatterns.length > 0 ? { sensitivePatterns: config.sensitiveWritePatterns } : {}),
4142
- // [1557]§四 opt-in(cli[1555]②「Bash echo > file 绕写门」缺口的部署侧补丁):MANUAL_MODE_SHELL_GATE
4143
- // 未设置时 config.manualModeShellGate 缺席,本行不传字段——deriveSettingsPolicy 的 default/
4144
- // auto/acceptEdits 分支照旧不碰 TaskSpec.shellGate,零行为变化。
4145
- ...(config.manualModeShellGate ? { shellGate: config.manualModeShellGate } : {}),
4146
- // [841]① / core 1.294 exemption seam:gate 产 ask 前查同一只 approval_exemption 店(与 decide
4147
- // remember="session" 的授予、ask 政策层探针同店同 canonical toolName 键空间)——「本会话不再询问」
4148
- // 对 fs-write 门同 turn 即时生效(父+继承子任务,ask 路径不再重入)。has() reject ⇒ core 按
4149
- // 未豁免处理(fail-closed 照 ask),与店契约的调用方纪律一致;命中打 info 审计(对齐
4150
- // approval_exempted 的短路留痕,不记路径,路径留在 core 的 allow message 里)。
4151
- ...(approvalExemptionStore && auth?.sessionId
4152
- ? {
4153
- isExempt: async (toolName) => {
4154
- const hit = await approvalExemptionStore.has(auth.sessionId, toolName);
4155
- if (hit)
4156
- logger.info("fs_write_gate_exempted", { toolName, sessionId: auth.sessionId });
4157
- return hit;
4158
- },
4159
- }
4160
- : {}),
4161
- };
4162
- })()
4163
- : undefined;
4164
- // [1248]②/codex F2 — the workflow ask leg's session-exemption probe, on EVERY lane (unlike fsWriteGate:
4165
- // the workflow gate is name-keyed, not fs-adjudicated). Same store + canonical toolName key space as the
4166
- // fs-gate probe — one remember="session" grant serves both; without it the durable lane re-parks every
4167
- // later run_workflow call after an operator already granted "don't ask again this session".
4168
- const workflowGate = approvalExemptionStore && auth?.sessionId
4169
- ? {
4170
- isExempt: async (toolName) => {
4171
- const hit = await approvalExemptionStore.has(auth.sessionId, toolName);
4172
- if (hit)
4173
- logger.info("workflow_gate_exempted", { toolName, sessionId: auth.sessionId });
4174
- return hit;
4175
- },
4176
- }
4177
- : undefined;
4178
- try {
4179
- governed = applyTaskSettings(governedBase, effectiveSettings, fsWriteGate, workflowGate);
4180
- }
4181
- catch (e) {
4182
- throw new HttpError(422, `settings are tighten-only and cannot loosen the deployment policy: ${e.message}`);
4183
- }
4184
- }
4185
- // subagent-hands follow-on (adversarial-review finding): core does NOT propagate
4186
- // handsReadOnly/shellGate to a delegated subagent (SubagentToolOptions has NO read-only knob; subagent.js's child
4187
- // runTask omits it). So a read-only (permissionMode=plan) parent that mounts the Task tool would let the CHILD get
4188
- // WRITABLE hands — now reachable because subRunner has an executionEnvFactory (the hands fix). That defeats plan
4189
- // mode's read-only contract (a within-tenant gap, not a cross-tenant breach). Until core propagates it (relayed),
4190
- // strip the delegation tools when this task is hands-read-only — a read-only run delegates nothing writable.
4191
- // `stripDelegationTools` (runtime-governance.ts, tested there) matches the subagent tool ("Agent", core 1.202
4192
- // canonical; legacy alias "Task") + Fork in CANONICAL space — the pre-1.202 raw `!== "Task"` filter here
4193
- // silently stopped matching when core renamed the tool (fail-open: a plan-mode parent could delegate a
4194
- // WRITABLE-hands child).
4195
- if (governed.handsReadOnly === true && Array.isArray(governed.tools)) {
4196
- governed = { ...governed, tools: stripDelegationTools(governed.tools) };
4197
- }
4198
- // §7 P0.5 per-task sandbox image(用户按需选像): the caller sends a PROFILE
4199
- // (intent) — NEVER a digest (a sha256 is enumerable + caller-unbound → trusting a caller digest is fail-OPEN;
4200
- // a user could pass another tenant's digest). The TRUSTED control plane resolves profile→digest with the
4201
- // caller's principal, FAIL-CLOSED (same visibility the /v1/images/select admission uses), and registers it by
4202
- // sessionId for the k8s factory to apply per-pod. RESUME re-runs resolveSpec ⇒ re-resolves LIVE (no stale
4203
- // frozen digest), consistent with the governance live-config discipline above.
4204
- // RFC A2: a session-level SelectEnvironment binding (PROFILE intent, tool-written) is the fallback when the
4205
- // request body carries no explicit profile. Body wins (an explicit caller choice for THIS task); either way
4206
- // the profile is re-resolved FAIL-CLOSED right here — a session binding never skips re-admission, and the
4207
- // F3 boundary contract holds structurally (binding is only read at task boundaries, children inherit
4208
- // the registered ref at spawn).
4209
- const bodyProfile = typeof body.sandboxImageProfile === "string" && body.sandboxImageProfile.length > 0 ? body.sandboxImageProfile : undefined;
4210
- // cascade/verify mirror the HTTP-layer v1 reject (server.ts): those flows strip/replace the session, so a
4211
- // session-level binding would not reach the rungs/verifier sub-run — silently mixed environments. A body
4212
- // profile on cascade/verify is already 400-rejected up front; the session fallback simply does not apply.
4213
- const taskImageProfile = bodyProfile ?? (body.cascade === true || body.verify === true ? undefined : sessionEnvSelection.get(auth?.sessionId));
4214
- let resolvedImageCaps; // RFC A1: rides into envFacts
4215
- if (taskImageProfile !== undefined) {
4216
- if (config.remoteExec?.provider !== "k8s") {
4217
- throw new HttpError(400, "sandboxImageProfile is only supported on the k8s sandbox backend");
4218
- }
4219
- if (!imageIndex) {
4220
- throw new HttpError(400, "sandboxImageProfile requires the image index (sema-registry backend not configured)");
4221
- }
4222
- if (!auth?.sessionId) {
4223
- throw new HttpError(400, "sandboxImageProfile requires a resolved session");
4224
- }
4225
- const capsNeeded = Array.isArray(body.capabilitiesNeeded)
4226
- ? body.capabilitiesNeeded.filter((c) => typeof c === "string")
4227
- : undefined;
4228
- const resolved = await resolveSandboxImageRef({
4229
- profile: taskImageProfile,
4230
- ...(capsNeeded && capsNeeded.length > 0 ? { capabilitiesNeeded: capsNeeded } : {}),
4231
- // principal comes from the auth channel (trusted header / verified JWT), NEVER the body — so a caller
4232
- // cannot widen its own visibility. explicitOperator (NOT isOperator): an empty OPERATOR_PRINCIPALS must
4233
- // yield operator=false here — isOperator([],p)=true-for-all would let any caller resolve tenant-scoped
4234
- // images on an operator-less deployment (adversarial-review HIGH-1; the bake/direct-door boot guards do
4235
- // NOT cover per-task image selection).
4236
- viewer: { operator: explicitOperator(auth.principal, config.operatorPrincipals), tenantId: auth.principal ?? null },
4237
- index: imageIndex,
4238
- });
4239
- if (!resolved.ok)
4240
- throw new HttpError(resolved.status, resolved.message);
4241
- // Register by sessionId — the factory reads it by ctx.sessionId (stable on every path; see the factory
4242
- // wrapper). RESUME re-runs resolveSpec ⇒ re-resolves LIVE + re-registers (no stale frozen digest).
4243
- // ⚠️ KNOWN narrow edge (NOT a security breach): two CONCURRENT requests for the SAME session (same owner —
4244
- // session ownership is enforced) race this set(); a request the run-claim later rejects (409) can leave its
4245
- // image bound for the active run. Both images are the SAME principal's own admitted images, so the worst
4246
- // case is the user's run using the user's other selected image (a correctness glitch, not cross-tenant).
4247
- // Hardening (register-after-claim, keyed by the durable run id) needs the registry threaded into the server
4248
- // claim path — tracked as a follow-up; cascade/verify are rejected with a profile (see prepareSpec) because
4249
- // they strip/replace the session and would silently fall back to the default image.
4250
- perTaskImage.set(auth.sessionId, resolved.ref);
4251
- resolvedImageCaps = resolved.capabilities;
4252
- }
4253
- // RFC A1 (core 1.240.0 `TaskSpec.envFacts`): compose the deployment-trusted sandbox facts
4254
- // (profile/capabilities from the fail-closed resolution above, region pkgSource, exec-lane egress posture)
4255
- // and ride them on the spec — core renders the `# Environment` block (sanitized+bounded, egress=none gets
4256
- // the "downloads will fail" caveat). Facts only ride when known; nothing known ⇒ no field ⇒ block unchanged.
4257
- if (config.envFactsEnabled) {
4258
- // pkgSource is a FACT only where the derivePkgSourceEnv injection actually lands (the e2b/k8s sandbox
4259
- // lanes — main.ts wiring above). host/ssh/adb/run-local get NO injection, so with the global default
4260
- // (1.180.0) an unconditional read would tell the model "official sources" about an environment we never
4261
- // touched (workflow audit 2026-07-13: honest-facts posture, same as egress's "don't declare what you
4262
- // don't know").
4263
- const pkgSourceLane = config.remoteExec?.provider === "e2b" || config.remoteExec?.provider === "k8s";
4264
- // [820]③ scratchpadDir (core field + prompt chain in since 1.257.3; this fill was the missing half).
4265
- // HOST-semantics lanes only (provider unset = in-process host, or explicit "host"): the advertised path
4266
- // must be REAL to the executing hands — on e2b/k8s/ssh/adb/local-docker the tools run off this box, so a
4267
- // worker-local path would be a lie (same honest-facts axis as pkgSource above; the remote lanes get their
4268
- // IN-SANDBOX path via the [848] branch below instead). Session-scoped dir under
4269
- // localDataRoot, mkdir'd at the HOISTED compute above the settings fold (fail → fact omitted) — the SAME
4270
- // dir now feeds createFsWriteGatePolicy's exemptDirs ([820]① landed at core 1.290, wired in the
4271
- // applyTaskSettings gate above). No sessionId (adhoc no-session task) ⇒ no per-session home ⇒ fact
4272
- // omitted. Lifecycle/reaping: see ensureScratchpadDir docs (deferred).
4273
- // [848] remote lanes' counterpart: on e2b/k8s/local-docker/ssh the withRemoteScratchpad-decorated
4274
- // factory `mkdir -p`s exactly this path on the env's first exec, so advertising it is honest — with
4275
- // the one best-effort caveat (a failed mkdir leaves the advertised path absent; see the decorator
4276
- // header). Mutually exclusive with the host-lane fact above by construction (hostSemanticsLane owns
4277
- // scratchpadDir; this branch is remote lanes only), and it applies the decorator's OWN validity rule
4278
- // (remoteScratchpadDirFor: absent/unsafe sessionId ⇒ no decoration ⇒ no fact). adb stays factless.
4279
- const remoteScratchpadDir = !hostSemanticsLane && isRemoteScratchpadLane(config.remoteExec?.provider)
4280
- ? remoteScratchpadDirFor(auth?.sessionId)
4281
- : undefined;
4282
- const factScratchpadDir = scratchpadDir ?? remoteScratchpadDir;
4283
- const facts = buildEnvFacts({
4284
- profile: taskImageProfile,
4285
- capabilities: resolvedImageCaps,
4286
- ...(pkgSourceLane ? { pkgSource: config.sandboxPkgSource } : {}),
4287
- egress: egressForRemoteExec(config.remoteExec),
4288
- ...(factScratchpadDir ? { scratchpadDir: factScratchpadDir } : {}),
4289
- // [1467]②(core 1.360)durable resume 铸句真值:按车道能力面,单真源 resumeFactsForLane
4290
- // (e2b=双 preserved/k8s=按 s3Snapshot 有效能力分叉/ssh·adb·host=scratch preserved/
4291
- // local-docker=不声明)。
4292
- resumeFacts: resumeFactsForLane(config.remoteExec?.provider, config.remoteExec?.provider === "k8s" ? { k8sSnapshot: Boolean(config.remoteExec.s3Snapshot) } : undefined),
4293
- });
4294
- if (facts)
4295
- governed = { ...governed, envFacts: facts };
4296
- }
4297
- // [1339] scratchpad 写门失效真因:提示词(envFacts.scratchpadDir)和 policy 层豁免(exemptDirs)都
4298
- // 接了,但 core fs 工具的**根围栏**(resolveKey:taskRootPath+additionalDirectories)在 policy 之前
4299
- // 短路——Write/Edit 到 scratchpad 直接 path_not_in_root,exemptDirs 根本没被问到(Bash 无根围栏,
4300
- // 反而能写=口径矛盾还教模型绕门)。修:host 车道把同一 scratchpadDir 折进 spec.additionalDirectories
4301
- // (server 自算的 per-session 路径,非租户输入,不走 cwdHonored 门限;compute 时已 mkdir,core 的
4302
- // canonicalPath 必过)。remote 车道暂不折:in-sandbox 路径由 [848] 装饰器首 exec 才 mkdir,prepare 期
4303
- // canonicalize 会失败被 core 静默跳过——那半场随 core 排序案另行。
4304
- // codex R3(M2):**独立于 envFactsEnabled 门**——SANDBOX_ENV_FACTS=false 只关事实展示,写门围栏
4305
- // 豁免是行为契约,关展示不得顺带关围栏(否则该配置下 [1339] 原病复发)。
4306
- if (scratchpadDir !== undefined) {
4307
- const dirs = governed.additionalDirectories ?? [];
4308
- if (!dirs.includes(scratchpadDir))
4309
- governed = { ...governed, additionalDirectories: [...dirs, scratchpadDir] };
4310
- }
4311
- // A value router (S1 verdict §6.3 cash-out, src/router/route-orchestration.ts): auto-decide this task's
4312
- // orchestration. CONSERVATIVE + DETERMINISTIC — default single; escalate to the SUP prevention posture only
4313
- // on a capability-danger signal (write/exec on a NON-isolated env — an isolated sandbox CONTAINS the harm,
4314
- // so isolated tasks stay single, matching the verdict's default). caller-explicit team (council/debate/team)
4315
- // is honored, never auto-produced. Flag-gated (ROUTER_ENABLED) — ships dark, enable after live validation.
4316
- if (config.routerEnabled) {
4317
- // Resolve isolation from provider AND runtimeClass — a k8s worker on plain runc is NOT VM-isolated
4318
- // (review HIGH); fail-closed for runc/empty/unknown.
4319
- const isolatedExecEnv = isIsolatedExecEnv(config.remoteExec?.provider, config.remoteExec?.provider === "k8s" ? config.remoteExec.runtimeClass : undefined);
4320
- const decision = routeServiceTask({
4321
- isolatedExecEnv,
4322
- hasExecutionEnv: config.remoteExec != null,
4323
- ...(config.autonomy ? { autonomy: config.autonomy } : {}),
4324
- explicitTeam: body.council === true || body.debate === true || scenarioName === "team",
4325
- });
4326
- logger.info("orchestration_routed", { mode: decision.mode, reason: decision.reason, sessionId: auth?.sessionId ?? null });
4327
- if (decision.mode === "supervisor") {
4328
- // SUP prevention posture: gate every bash (shellGate:"always") AND the direct write hand tools
4329
- // (edit_file/write_file via toolPolicy) — the verdict's gate-deny on capability-danger. tighten-only
4330
- // (composes with the governance/approval baseline; never loosens). The worker's existing approval
4331
- // machinery (durable/poll) enforces the suspend — same precondition as autonomy "ask".
4332
- return tightenTaskSpec(governed, supPostureOverrides());
4333
- }
4334
- }
4335
- return governed;
4336
- };
792
+ // design/158 A10:resolveSpec 段搬到 src/boot/resolve-spec.ts(逐字;两处活引用改取值,见该文件头注)。
793
+ const resolveSpec = createResolveSpec({
794
+ config, logger, metrics, localRoot, scenarios, principalCaps, centerRuntimeCapsResolver,
795
+ getCenterPrompts: () => configCenter.getCenterPrompts(),
796
+ getKeyResolver: () => configCenter.getKeyResolver(),
797
+ taskAttachmentStore, perSessionCwd, setSessionCwd, setSessionShellEnv,
798
+ hookLlm, hookAgent, fleetBus, hookWakeBus, resumeAnchorStore, ownerAware, taskLimitCaps, taskTimeoutSec,
799
+ selectEnvTool, sendUserFileToolSpec, memoryEngine, durableEnabled, approvalExemptionStore, approvalEnabled,
800
+ approvalStore, singleUserAutoAcceptBaseline, checkpointStore, deploymentHooks, imageIndex, perTaskImage,
801
+ sessionEnvSelection,
802
+ });
4337
803
  const server = createHttpServer({ runner, config, resolveSpec, stores, coordinators, seams, observability, governance, deployment, knobs });
4338
804
  // D-D SLA-timer: wire the server's deny-sweep into the reaper holder declared above (the reaper is defined
4339
805
  // before the server, so it calls through this late-bound reference).
@@ -4403,113 +869,12 @@ async function main() {
4403
869
  .then((tally) => logger.info("workflow_notify_recovered", tally))
4404
870
  .catch((err) => logger.warn("workflow_notify_recover_failed", { err: String(err) }));
4405
871
  }
4406
- let closing = false;
4407
- const hardShutdown = () => {
4408
- if (closing)
4409
- return;
4410
- closing = true;
4411
- clearInterval(reaper);
4412
- otelExporter?.stop();
4413
- breakerState?.stop();
4414
- if (costQuota && "stop" in costQuota)
4415
- costQuota.stop(); // the DB-backed quota/limiter (TiDB|PG) have a refresh loop to stop; the in-memory ones don't
4416
- if (rateLimiter && "stop" in rateLimiter)
4417
- rateLimiter.stop();
4418
- runner.sessions.dispose();
4419
- subRunner.sessions.dispose();
4420
- // TOC local LSP: NodeLspManager holds LOCAL child-process language servers — kill them on shutdown so a
4421
- // restart doesn't orphan a fleet of stdio servers. dispose() isn't on the LspServerManager interface (only the
4422
- // concrete managers have it), so probe it; the e2b/k8s bridge manager is a harmless no-op if it lacks one.
4423
- void lspManager?.dispose?.();
4424
- // SVC-1: release the File journal's append handle; the SQL twins hold no fd (probe — close isn't on the seam).
4425
- void workflowNotifyJournal?.close?.();
4426
- // core 1.270.1:hardShutdown 终点(drain 已完,in-flight 不再被破坏)全量收割 session 驻留
4427
- // bg bash(detached 进程组,引擎死后 reparent PID 1 残留=实测;retain-declared 例外在原语内)。
4428
- // TOB 容器形态同样调=无害且显式;SIGKILL 路径无钩=已知不可救记档。
4429
- try {
4430
- const reaped = defaultTaskRegistry.reapAllSessionBackground();
4431
- if (reaped > 0)
4432
- logger.info("shutdown_background_reaped", { reaped });
4433
- }
4434
- catch (err) {
4435
- logger.warn("shutdown_background_reap_failed", { err: String(err) });
4436
- }
4437
- void fleetClient?.stop(); // stop heartbeat + flush final usage window + best-effort DELETE deregistration
4438
- server.close(() => {
4439
- void (backend ? backend.close() : Promise.resolve()).finally(() => process.exit(0));
4440
- });
4441
- // 对抗复查 B-4(HIGH):the exit above only fires after backend.close() SETTLES — a wedged pool teardown
4442
- // (dead DB, hung socket) kept the "force-terminated" process alive indefinitely (the 10s closeAllConnections
4443
- // below unblocks server.close(), but nothing bounded backend.close). FINAL deadline: whatever is still
4444
- // holding after 15s, exit anyway (durable state is already flushed by then — checkpoint/terminal writes
4445
- // happen before hardShutdown; the pool teardown is best-effort cleanup, not correctness).
4446
- const finalExit = setTimeout(() => { logger.warn("hard_shutdown_final_deadline", { afterMs: 15_000 }); process.exit(0); }, 15_000);
4447
- finalExit.unref?.();
4448
- // `server.close()` waits for every connection to end — an open SSE stream (/v1/tasks/stream, /events) would
4449
- // otherwise block it forever, so pool.end()/exit never run and the process hangs until SIGKILL. Drop idle
4450
- // keep-alives now, then force-terminate any lingering connections after a grace period so `close()` can
4451
- // resolve and shutdown completes. The timer is unref'd but still fires while the server holds connections.
4452
- server.closeIdleConnections?.();
4453
- const forceClose = setTimeout(() => server.closeAllConnections?.(), 10_000);
4454
- forceClose.unref?.();
4455
- };
4456
- // SIGTERM = graceful DRAIN — flip `draining` (new billable submits 503+Retry-After, /health carries
4457
- // draining:true for readiness摘流), then wait for this instance's in-flight legs (live streams + bg/resume) to
4458
- // finish before the hard shutdown. Bounded by DRAIN_GRACE_MS (default 10min — an interactive turn is minutes;
4459
- // the previous behavior was a 10s hard-cut that killed long turns). A SECOND SIGTERM or SIGINT (dev Ctrl-C)
4460
- // skips the wait — k8s sends SIGKILL after terminationGracePeriodSeconds regardless, so the escape hatch is free.
4461
- let draining = false;
4462
- const drainThenShutdown = () => {
4463
- if (closing)
4464
- return;
4465
- if (draining) {
4466
- logger.info("drain_second_signal_hard_stop", {});
4467
- hardShutdown();
4468
- return;
4469
- }
4470
- draining = true;
4471
- drainState.draining = true;
4472
- drainState.since = Date.now();
4473
- void fleetClient?.announceNow(); // draining 翻转即刻再 announce — 第一时间自摘流,比任何观测都快
4474
- const inflight = drainState.inflight?.() ?? 0;
4475
- logger.info("draining_started", { inflight, graceMs: config.drainGraceMs });
4476
- if (inflight === 0) {
4477
- hardShutdown();
4478
- return;
4479
- }
4480
- // 修3(三路复审 B3-2 时钟):drain elapsed/截止计算走 performance.now() 单调域(挂钟跳变不得吞掉/拉长
4481
- // drainGraceMs 窗);对外报告的 drainState.since(/health drainingSince)保留 epoch(Date.now)。
4482
- const t0 = performance.now();
4483
- // NOT unref'd on purpose — this timer IS the shutdown driver (the open connections keep the loop alive anyway).
4484
- const tick = setInterval(() => {
4485
- const n = drainState.inflight?.() ?? 0;
4486
- const elapsed = Math.round(performance.now() - t0);
4487
- if (n === 0 || elapsed >= config.drainGraceMs) {
4488
- clearInterval(tick);
4489
- logger.info("drain_complete", { inflight: n, elapsedMs: elapsed, timedOut: n > 0 });
4490
- hardShutdown();
4491
- }
4492
- }, 1_000);
4493
- };
4494
- process.on("SIGTERM", drainThenShutdown);
4495
- process.on("SIGINT", hardShutdown);
4496
- // SIGHUP(壳窗口关闭把信号打到整个进程组)≠「没人在用这只引擎」:共享引擎形态(同 config 多壳会话,
4497
- // 引擎按 T11 防孤儿拍骑在首壳进程组里)下 peer 会话还活着——无条件 drain 会把 peer mid-turn 斩掉(499)
4498
- // 且窗内新提交 503(B3,壳侧 [771]② 取证)。改 hup-pending 空闲窗:不翻 draining(peer 零感知),
4499
- // inflight()(bg/resume legs + live streams)连续 sighupIdleGraceMs 为 0 才 hardShutdown,任何在飞 leg
4500
- // 重置窗口。真孤儿(最后一窗关掉)有界自灭=T11 保留;第二个 SIGHUP 升级走既有 drain 路径(硬梯子)。
4501
- // session 驻留 bash 的全量收割仍挂 hardShutdown 终点(reapAllSessionBackground)。
4502
- process.on("SIGHUP", createSighupIdleHandler({
4503
- inflight: () => drainState.inflight?.() ?? 0,
4504
- // ⚠️ 时基契约(三路复审修3):lastActivityAt 由 server.ts 以 performance.now() 单调域打点,与
4505
- // sighup-idle.ts 内部的 `now` seam(缺省 performance.now)同域——不得混入 Date.now 值。
4506
- lastActivityAt: () => drainState.lastActivityAt?.() ?? 0,
4507
- isStopped: () => closing || draining,
4508
- escalate: drainThenShutdown,
4509
- shutdown: hardShutdown,
4510
- idleGraceMs: config.sighupIdleGraceMs,
4511
- log: (event, fields) => logger.info(event, fields),
4512
- }));
872
+ // design/158 A10:收尾段搬到 src/boot/shutdown.ts(逐字)。⚠️ 调用点必须留在 listen 之后 —— 位置即契约,
873
+ // 理由(信号注册时点/三信号相对次序/clearInterval 先于 server.close)见该文件头注。
874
+ installShutdownHandlers({
875
+ config, logger, server, reaper, otelExporter, breakerState, costQuota, rateLimiter,
876
+ runner, subRunner, lspManager, workflowNotifyJournal, fleetClient, backend, drainState,
877
+ });
4513
878
  }
4514
879
  void main().catch((err) => {
4515
880
  createLogger().error("fatal", { err: String(err) });