@bridge_gpt/mcp-server 0.2.38 → 0.2.39
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/README.md +189 -14
- package/build/agent-capabilities/probe-context.js +2 -1
- package/build/agent-launchers/claude-executor-adapter.js +392 -0
- package/build/agent-launchers/executor-adapter-inspection.js +163 -0
- package/build/agent-launchers/executor-adapter-registry.js +90 -0
- package/build/agent-launchers/executor-adapter.js +136 -0
- package/build/agent-registry.js +28 -0
- package/build/agents.generated.js +1 -1
- package/build/claude-login.js +85 -0
- package/build/claude-user-config-doctor.js +59 -33
- package/build/commands.generated.js +12 -11
- package/build/conduct-epic/bridge-client.js +345 -0
- package/build/conduct-epic/checkpoint-store.js +423 -0
- package/build/conduct-epic/cli.js +1732 -0
- package/build/conduct-epic/lock.js +302 -0
- package/build/conduct-epic/pr-state.js +197 -0
- package/build/conduct-epic/spawn.js +101 -0
- package/build/conductor/bridge-api-client.js +37 -2
- package/build/conductor/doctor.js +11 -1
- package/build/conductor/install-doctor.js +184 -10
- package/build/conductor-bin.js +7 -7
- package/build/credential-store.js +10 -4
- package/build/credentials-cli.js +34 -19
- package/build/docs.generated.js +1 -1
- package/build/doctor.js +579 -88
- package/build/executor/agent-identity.js +32 -0
- package/build/executor/cli.js +50 -39
- package/build/executor/deps.js +15 -1
- package/build/executor/env.js +56 -45
- package/build/executor/index.js +9 -1
- package/build/executor/install-preflight.js +138 -0
- package/build/executor/job-errors.js +200 -0
- package/build/executor/job-runner.js +619 -268
- package/build/executor/observation.js +165 -0
- package/build/executor/permissions.js +163 -36
- package/build/executor/platform.js +54 -0
- package/build/executor/preflight.js +175 -67
- package/build/executor/process.js +39 -7
- package/build/executor/runner.js +19 -0
- package/build/executor/service-lifecycle.js +269 -0
- package/build/executor/service-unit.js +121 -12
- package/build/executor/stale-artifacts.js +70 -0
- package/build/executor/test-clock.js +188 -24
- package/build/executor/worker-command.js +22 -58
- package/build/executor/worker-log.js +82 -0
- package/build/executor/worktree-lock.js +264 -0
- package/build/index.js +527 -357
- package/build/install-bridge-conductor.js +376 -38
- package/build/install-bridge.js +414 -114
- package/build/install-doctor.js +13 -0
- package/build/install-reexec.js +5 -3
- package/build/mcp-install-state.js +130 -0
- package/build/mcp-profile.js +11 -2
- package/build/mcp-provisioning.js +15 -0
- package/build/merge-pull-request.js +562 -0
- package/build/phase-result-artifacts.js +450 -0
- package/build/pipeline-orchestrator.js +4 -0
- package/build/pipeline-utils.js +16 -0
- package/build/pipelines.generated.js +7 -7
- package/build/plane/preflight.js +18 -14
- package/build/plane/supervisor.js +8 -1
- package/build/project-root.js +34 -0
- package/build/readme.generated.js +1 -1
- package/build/run-unit-tests-launcher.js +36 -9
- package/build/setup-epic.js +57 -4
- package/build/sfcc/permissions.js +25 -6
- package/build/sfcc/reads-site-preference.js +6 -0
- package/build/sfcc/register.js +61 -23
- package/build/sfcc/registration-inventory.js +89 -0
- package/build/sfcc/setup-status.js +18 -34
- package/build/sfcc/tool-wrapper.js +294 -17
- package/build/sfcc/write-grants.js +33 -1
- package/build/sfcc/write-guard.js +41 -12
- package/build/sfcc/writes-custom-object-def.js +6 -2
- package/build/sfcc/writes-site-preference.js +6 -1
- package/build/sfcc/writes-system-object.js +11 -2
- package/build/sfcc/writes.js +13 -8
- package/build/start-tickets-prereqs.js +25 -15
- package/build/start-tickets.js +123 -21
- package/build/version.generated.js +1 -1
- package/build/worktree-core.js +9 -3
- package/docs/install/mcp-tool-integrations.md +54 -9
- package/docs/install/sfcc-integration.md +71 -24
- package/package.json +3 -3
- package/build/executor/worker-config-isolation.js +0 -287
|
@@ -14,22 +14,25 @@
|
|
|
14
14
|
* `spec_review`, plus per-job worker-log tee, watch registry, and optional
|
|
15
15
|
* read-only viewer tabs wired around every spawned worker.
|
|
16
16
|
*/
|
|
17
|
+
import { rm } from "node:fs/promises";
|
|
17
18
|
import os from "node:os";
|
|
18
|
-
import { buildExecutorWorkerEnv } from "./env.js";
|
|
19
19
|
import { runHeartbeatLoop } from "./heartbeat.js";
|
|
20
|
-
import { isExecutorNamedError, toExecutorFailure, secretFreeErrorMessage, MissingVerdictArtifact, PreSpawnVerification,
|
|
20
|
+
import { isExecutorNamedError, toExecutorFailure, secretFreeErrorMessage, MissingVerdictArtifact, PreSpawnVerification, RequiredMcpRegistration, McpSurfaceMismatch, WorkerStartupFatal, WORKER_STARTUP_FATAL_MESSAGE, formatMcpSurfaceMismatch, ClaudeNotAuthenticated, CLAUDE_NOT_AUTHENTICATED_MESSAGE, ExecutorAdapterUnavailable, ExecutorNamedError, WorktreeBusy, StaleArtifactCleanupFailed, WORKTREE_BUSY_MESSAGE, } from "./job-errors.js";
|
|
21
21
|
import { buildConductorMergeAccessForExecutorJob, buildDefaultMergeLocalDeps, runExecutorMergeJob, MERGE_RETRYABLE, MERGE_FAILED, } from "./merge-job.js";
|
|
22
22
|
import { createObservationState } from "./observation.js";
|
|
23
|
-
import { getWorktreeMcpRegistrationTargets,
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
23
|
+
import { getWorktreeMcpRegistrationTargets, normalizeWorktreePathForRegistration, provisionMcpRegistrationForWorktree, } from "../mcp-provisioning.js";
|
|
24
|
+
import { resolveExecutorAgentAdapter } from "../agent-launchers/executor-adapter-registry.js";
|
|
25
|
+
import { redactAdapterDiagnostic } from "../agent-launchers/executor-adapter.js";
|
|
26
|
+
import { resolveExecutorAgentId } from "./agent-identity.js";
|
|
26
27
|
import { runProcessWithTimeout } from "./process.js";
|
|
27
28
|
import { prepareResumeSpawn } from "./resume-pre-spawn.js";
|
|
28
29
|
import { renderPromptSpecPrompt } from "./prompt-spec.js";
|
|
29
30
|
import { buildGenericSuccessResult, buildSmokeResult, commitResidue, readCompletionArtifacts, resolveJobTimeoutSeconds, } from "./results.js";
|
|
30
31
|
import { sendTerminalMutationWithRetry } from "./terminal-mutation.js";
|
|
32
|
+
import { removeStaleVerdictArtifacts } from "./stale-artifacts.js";
|
|
31
33
|
import { isVerdictJobType, readVerdictArtifact } from "./verdict-artifact.js";
|
|
32
|
-
import { createWorkerLogTee, closeWorkerLogTee, teeAsyncIterable, } from "./worker-log.js";
|
|
34
|
+
import { createWorkerLogTee, closeWorkerLogTee, distinctWorkerLogSessionIds, formatWorkerLogSessionInvariantWarning, teeAsyncIterable, } from "./worker-log.js";
|
|
35
|
+
import { acquireExecutorWorktreeLock, } from "./worktree-lock.js";
|
|
33
36
|
import { registerExecutorJobLog, markExecutorJobLogFinished, } from "./job-log-registry.js";
|
|
34
37
|
import { executorViewerTabsEnabled, openExecutorViewerTab } from "./viewer-tabs.js";
|
|
35
38
|
import { isImplementationStyleJobType, isRecoveryJobType, isSpawnJobType } from "./job-types.js";
|
|
@@ -37,10 +40,8 @@ import { resolveExecutorJobBaseBranch } from "./base-branch.js";
|
|
|
37
40
|
import { validateWorkerFinalization } from "./worker-finalization.js";
|
|
38
41
|
import { ensureExecutorWorktree } from "./worktree.js";
|
|
39
42
|
import { buildPrBaseContractLaunchInstruction } from "../pr-base-contract.js";
|
|
40
|
-
import {
|
|
41
|
-
import { collectGitTelemetry, collectRemoteTrackingSha, evaluatePreSpawnGitVerification, } from "./observation.js";
|
|
42
|
-
import { establishWorkerConfigIsolation, formatWorkerConfigIsolationReason, } from "./worker-config-isolation.js";
|
|
43
|
-
import { resolveBapiCredentials } from "../credential-store.js";
|
|
43
|
+
import { resolveExecutorPrompt, resolveWorkerPermissionPosture, } from "./worker-command.js";
|
|
44
|
+
import { collectGitTelemetry, collectRemoteTrackingSha, createMcpSurfaceObserver, evaluatePreSpawnGitVerification, normalizeMcpServerNames, } from "./observation.js";
|
|
44
45
|
/** Default runtime for the no-op smoke process (ms). */
|
|
45
46
|
const DEFAULT_SMOKE_DURATION_MS = 100;
|
|
46
47
|
/** ProcessClassification maps 1:1 onto the wire ExecutorClassification. */
|
|
@@ -136,6 +137,71 @@ function buildExecutorMcpProvisioningDeps(deps, bridgeApiBaseUrl) {
|
|
|
136
137
|
* inside it: the fixed `catch` below would otherwise convert a confirmed
|
|
137
138
|
* integrity refusal into a logged warning and spawn the worker anyway.
|
|
138
139
|
*/
|
|
140
|
+
/**
|
|
141
|
+
* Bind the worktree lock's filesystem boundaries to the injected executor deps
|
|
142
|
+
* (BAPI-793), so the lock never reaches for a filesystem global.
|
|
143
|
+
*
|
|
144
|
+
* `writeFileExclusive` and `removeFile` are OPTIONAL on `ExecutorDeps` for
|
|
145
|
+
* source-compatibility with the many existing test deps builders. Omitting them
|
|
146
|
+
* does NOT disable the lock: the seam is simply left unset and
|
|
147
|
+
* `acquireExecutorWorktreeLock` falls back to real `node:fs/promises`, which is
|
|
148
|
+
* the safe direction — a missing seam takes a real lock rather than skipping it.
|
|
149
|
+
* `isProcessAlive` is deliberately NOT injected here; the real
|
|
150
|
+
* `process.kill(pid, 0)` probe is correct in production and tests drive it
|
|
151
|
+
* through the `acquireWorktreeLock` seam instead.
|
|
152
|
+
*/
|
|
153
|
+
function buildWorktreeLockSeams(deps) {
|
|
154
|
+
return {
|
|
155
|
+
...(deps.writeFileExclusive ? { writeFileExclusive: deps.writeFileExclusive } : {}),
|
|
156
|
+
...(deps.removeFile ? { removeFile: deps.removeFile } : {}),
|
|
157
|
+
readFile: deps.readFile,
|
|
158
|
+
mkdir: deps.mkdir,
|
|
159
|
+
platform: deps.platform,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* The stderr diagnostic for a refused spawn (BAPI-793 proposed fix #4).
|
|
164
|
+
*
|
|
165
|
+
* Bounded identity ONLY: this job, the conflicting job, the ticket, the repo,
|
|
166
|
+
* and the worktree's BASENAME. Never the claim token (a fencing credential),
|
|
167
|
+
* never the lock payload, and never the absolute worktree path (it carries an
|
|
168
|
+
* operator's home directory). An unattributable holder is reported as such
|
|
169
|
+
* rather than guessed at.
|
|
170
|
+
*/
|
|
171
|
+
function formatWorktreeBusyDiagnostic(job, worktreePath, owner) {
|
|
172
|
+
const segments = worktreePath.split(/[\\/]+/).filter((s) => s.length > 0);
|
|
173
|
+
const worktreeName = segments.length > 0 ? segments[segments.length - 1] : "unknown";
|
|
174
|
+
const holder = owner === null
|
|
175
|
+
? "an unreadable or malformed lock (refused fail-closed)"
|
|
176
|
+
: `job_id=${owner.jobId} executor_id=${owner.executorId} pid=${owner.ownerPid}`;
|
|
177
|
+
return ("executor refused to spawn: worktree already held. " +
|
|
178
|
+
`job_id=${job.id} job_type=${job.job_type} repo_name=${job.repo_name} ` +
|
|
179
|
+
`ticket_key=${job.ticket_key ?? "none"} worktree=${worktreeName} held_by=${holder}`);
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Read back a finished job's worker log and warn when more than one distinct
|
|
183
|
+
* Claude session wrote to it (BAPI-793 acceptance criterion 3).
|
|
184
|
+
*
|
|
185
|
+
* Fail-open in every direction: no tee, an unreadable log, or a log with zero
|
|
186
|
+
* parseable sessions all produce nothing. The only reportable state is a
|
|
187
|
+
* POSITIVE observation of two or more distinct sessions, which cannot happen by
|
|
188
|
+
* accident.
|
|
189
|
+
*/
|
|
190
|
+
async function reportWorkerLogSessionInvariant(tee, deps) {
|
|
191
|
+
if (!tee)
|
|
192
|
+
return;
|
|
193
|
+
let logText;
|
|
194
|
+
try {
|
|
195
|
+
logText = await deps.readFile(tee.logPath);
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const sessions = distinctWorkerLogSessionIds(logText);
|
|
201
|
+
if (sessions.length > 1) {
|
|
202
|
+
deps.errorLog(formatWorkerLogSessionInvariantWarning(sessions.length));
|
|
203
|
+
}
|
|
204
|
+
}
|
|
139
205
|
async function provisionMcpForPreparedSpawn(job, branch, worktreePath, deps, seams, bridgeApiBaseUrl) {
|
|
140
206
|
const provision = seams.provisionMcp ?? provisionMcpRegistrationForWorktree;
|
|
141
207
|
const row = {
|
|
@@ -162,95 +228,39 @@ async function provisionMcpForPreparedSpawn(job, branch, worktreePath, deps, sea
|
|
|
162
228
|
}
|
|
163
229
|
}
|
|
164
230
|
/**
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
231
|
+
* Confirm the prepared worktree's own `.mcp.json` carries the Bridge shim
|
|
232
|
+
* registration the worker needs, and capture the spawn contract from it
|
|
233
|
+
* (BAPI-731 requirement; extended by BAPI-790).
|
|
168
234
|
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
if (!mkdtemp || !chmod || !rmRecursive || !lstatPath || !tmpdir)
|
|
177
|
-
return null;
|
|
178
|
-
return {
|
|
179
|
-
platform: deps.platform,
|
|
180
|
-
env: deps.env,
|
|
181
|
-
tmpdir,
|
|
182
|
-
mkdtemp,
|
|
183
|
-
mkdir: deps.mkdir,
|
|
184
|
-
chmod,
|
|
185
|
-
rmRecursive,
|
|
186
|
-
lstatPath,
|
|
187
|
-
join: (...segments) => pathApiForProvisioningPlatform(deps.platform).join(...segments),
|
|
188
|
-
writeFile: deps.writeFile,
|
|
189
|
-
};
|
|
190
|
-
}
|
|
191
|
-
/**
|
|
192
|
-
* Resolve the repo-scoped Bridge credential to seed into the isolated store.
|
|
235
|
+
* Read-only. Verifies PRESENCE of the ADAPTER-DECLARED required server name only
|
|
236
|
+
* (BAPI-781; `bridge-api` for Claude) — it does not filter, rewrite, or narrow
|
|
237
|
+
* the registered surface, so every other valid worktree-provisioned server
|
|
238
|
+
* (including supported Tier-2 targets) is preserved exactly as provisioned AND
|
|
239
|
+
* carried into the expected set. Narrowing the expected set to the required name
|
|
240
|
+
* would make the assertion fail every spawn on a repository with Tier-2 targets
|
|
241
|
+
* provisioned.
|
|
193
242
|
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
* the operator's store.
|
|
243
|
+
* The required name is a PARAMETER rather than a module constant because it is a
|
|
244
|
+
* per-agent fact: a future adapter provisions and requires whatever its own
|
|
245
|
+
* runtime registers. Both halves of the contract still come from ONE parse of
|
|
246
|
+
* ONE file, which is what keeps the `--mcp-config` path and the asserted
|
|
247
|
+
* expected set describing the same file.
|
|
200
248
|
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
* proceeds to establish isolation with an empty store, because every job type
|
|
204
|
-
* that reaches `runSpawnJob` needs Bridge MCP tools, and a worker without them
|
|
205
|
-
* exits cleanly having done nothing (RUN-FINDINGS-2026-08-05 F2). A resolution
|
|
206
|
-
* exception is swallowed into the same null so no raw error (which could carry a
|
|
207
|
-
* path or a secret) ever reaches a diagnostic; the refusal message is a fixed
|
|
208
|
-
* constant.
|
|
249
|
+
* Only server NAMES are read. The command, args, and env of each registration are
|
|
250
|
+
* never retained, so nothing that could carry a secret leaves this function.
|
|
209
251
|
*/
|
|
210
|
-
async function
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
stat: deps.stat,
|
|
221
|
-
// Route the resolver's (secret-free) insecure-mode warning through the
|
|
222
|
-
// executor's own log seam instead of raw process stderr.
|
|
223
|
-
stderr: (message) => deps.errorLog(message),
|
|
224
|
-
});
|
|
225
|
-
if (!resolved.ok)
|
|
226
|
-
return null;
|
|
227
|
-
return { repoName: trimmed, apiKey: resolved.credentials.apiKey };
|
|
228
|
-
}
|
|
229
|
-
catch {
|
|
230
|
-
// Never let a credential-resolution error carry a secret into a diagnostic.
|
|
231
|
-
return null;
|
|
252
|
+
export async function verifyRequiredWorktreeMcpRegistration(worktreePath, deps, requiredServerName = REQUIRED_MCP_SERVER_NAME) {
|
|
253
|
+
// Normalize through the SAME path semantics the provisioner used to write the
|
|
254
|
+
// file, so the absolute path handed to `--mcp-config` is the one that actually
|
|
255
|
+
// exists on disk rather than a differently-spelled equivalent.
|
|
256
|
+
const normalized = normalizeWorktreePathForRegistration(worktreePath, {
|
|
257
|
+
platform: deps.platform,
|
|
258
|
+
cwd: deps.cwd,
|
|
259
|
+
});
|
|
260
|
+
if (!normalized.ok) {
|
|
261
|
+
return { ok: false, message: REQUIRED_MCP_REGISTRATION_MISSING_MESSAGE };
|
|
232
262
|
}
|
|
233
|
-
|
|
234
|
-
/**
|
|
235
|
-
* Fixed, secret-free refusal text for `ContractError.BridgeCredentialUnavailable`.
|
|
236
|
-
* Names both supported resolution routes and no value, path fragment, or raw
|
|
237
|
-
* error — this exact text is what `/fail` persists as `error_message`.
|
|
238
|
-
*/
|
|
239
|
-
const BRIDGE_CREDENTIAL_UNAVAILABLE_MESSAGE = "no Bridge API credential could be resolved for this job's repository, so an isolated worker " +
|
|
240
|
-
"would run without Bridge MCP tools and do nothing; set BAPI_API_KEY in the executor's " +
|
|
241
|
-
"environment or add a bapi:<repo> entry to the user-scoped credential store " +
|
|
242
|
-
"(~/.config/bridge/credentials.json)";
|
|
243
|
-
/**
|
|
244
|
-
* Confirm the prepared worktree's own `.mcp.json` actually carries the Bridge
|
|
245
|
-
* shim registration the worker needs (BAPI-731 requirement, plan step 10).
|
|
246
|
-
*
|
|
247
|
-
* Read-only. Verifies PRESENCE of the `bridge-api` server name only — it does not
|
|
248
|
-
* filter, rewrite, or narrow the registered surface, so every other valid
|
|
249
|
-
* worktree-provisioned server (including supported Tier-2 targets) is preserved
|
|
250
|
-
* exactly as provisioned.
|
|
251
|
-
*/
|
|
252
|
-
async function verifyRequiredWorktreeMcpRegistration(worktreePath, deps) {
|
|
253
|
-
const [target] = getWorktreeMcpRegistrationTargets(worktreePath, deps.platform);
|
|
263
|
+
const [target] = getWorktreeMcpRegistrationTargets(normalized.path, deps.platform);
|
|
254
264
|
if (!target) {
|
|
255
265
|
return { ok: false, message: REQUIRED_MCP_REGISTRATION_MISSING_MESSAGE };
|
|
256
266
|
}
|
|
@@ -264,8 +274,14 @@ async function verifyRequiredWorktreeMcpRegistration(worktreePath, deps) {
|
|
|
264
274
|
if (!servers || typeof servers !== "object" || Array.isArray(servers)) {
|
|
265
275
|
return { ok: false, message: REQUIRED_MCP_REGISTRATION_MISSING_MESSAGE };
|
|
266
276
|
}
|
|
267
|
-
const
|
|
268
|
-
|
|
277
|
+
const names = normalizeMcpServerNames(Object.keys(servers));
|
|
278
|
+
if (!names.includes(requiredServerName)) {
|
|
279
|
+
return { ok: false, message: REQUIRED_MCP_REGISTRATION_MISSING_MESSAGE };
|
|
280
|
+
}
|
|
281
|
+
return {
|
|
282
|
+
ok: true,
|
|
283
|
+
contract: { configPath: target.filePath, expectedServerNames: names },
|
|
284
|
+
};
|
|
269
285
|
}
|
|
270
286
|
catch {
|
|
271
287
|
// Unreadable or malformed is indistinguishable from absent for this purpose,
|
|
@@ -273,53 +289,95 @@ async function verifyRequiredWorktreeMcpRegistration(worktreePath, deps) {
|
|
|
273
289
|
return { ok: false, message: REQUIRED_MCP_REGISTRATION_MISSING_MESSAGE };
|
|
274
290
|
}
|
|
275
291
|
}
|
|
276
|
-
/**
|
|
292
|
+
/**
|
|
293
|
+
* Default required registration name, retained only as the parameter default so
|
|
294
|
+
* existing direct callers keep working. The SPAWN PATH always passes the
|
|
295
|
+
* adapter's declared name explicitly — it never relies on this default.
|
|
296
|
+
*/
|
|
277
297
|
const REQUIRED_MCP_SERVER_NAME = "bridge-api";
|
|
278
|
-
/** Fixed, secret-free refusal text (no path, no file contents). */
|
|
279
|
-
const REQUIRED_MCP_REGISTRATION_MISSING_MESSAGE = "worker config isolation active but the required 'bridge-api' MCP registration is missing or " +
|
|
280
|
-
"unreadable in the prepared worktree; an isolated worker has no operator-scoped registration to " +
|
|
281
|
-
"fall back to, so refusing to spawn a worker that would run without Bridge tools";
|
|
282
298
|
/**
|
|
283
|
-
*
|
|
284
|
-
*
|
|
285
|
-
*
|
|
286
|
-
*
|
|
287
|
-
*
|
|
288
|
-
*
|
|
299
|
+
* Refuse a spawn shape that is not internally consistent (BAPI-781).
|
|
300
|
+
*
|
|
301
|
+
* The checks are deliberately structural rather than agent-aware: a non-empty
|
|
302
|
+
* executable, a non-empty argv of strings, and a plain string-valued env. The
|
|
303
|
+
* runner cannot know what a correct argv looks like for an arbitrary agent —
|
|
304
|
+
* that is the adapter's job — but it CAN refuse a shape that could not spawn
|
|
305
|
+
* anything sane, and refusing is the only safe response. The alternative is
|
|
306
|
+
* repairing it from `process.env` or hard-coded flags, which would resurrect
|
|
307
|
+
* exactly the Claude-shaped defaulting this ticket removes.
|
|
308
|
+
*
|
|
309
|
+
* Also verifies the executable matches the adapter's DECLARED executable, so an
|
|
310
|
+
* invocation capability cannot return a different binary than the one preflight
|
|
311
|
+
* probed and doctor reported.
|
|
289
312
|
*/
|
|
290
|
-
|
|
313
|
+
function assertUsableSpawnShape(shape, adapter) {
|
|
314
|
+
const fail = (detail) => {
|
|
315
|
+
throw new ExecutorNamedError(ExecutorAdapterUnavailable, detail);
|
|
316
|
+
};
|
|
317
|
+
if (typeof shape?.executable !== "string" || shape.executable.trim().length === 0) {
|
|
318
|
+
fail("executor adapter returned an empty executable; refusing to spawn");
|
|
319
|
+
}
|
|
320
|
+
if (!Array.isArray(shape.argv) || shape.argv.length === 0) {
|
|
321
|
+
fail("executor adapter returned an empty argv; refusing to spawn");
|
|
322
|
+
}
|
|
323
|
+
if (shape.argv.some((entry) => typeof entry !== "string")) {
|
|
324
|
+
fail("executor adapter returned a non-string argv entry; refusing to spawn");
|
|
325
|
+
}
|
|
326
|
+
if (!shape.env || typeof shape.env !== "object" || Array.isArray(shape.env)) {
|
|
327
|
+
fail("executor adapter returned no worker environment; refusing to spawn");
|
|
328
|
+
}
|
|
329
|
+
if (Object.values(shape.env).some((value) => typeof value !== "string")) {
|
|
330
|
+
fail("executor adapter returned a non-string environment value; refusing to spawn");
|
|
331
|
+
}
|
|
332
|
+
const declared = adapter.executable;
|
|
333
|
+
if (declared?.supported === true && shape.executable !== declared.value.executable) {
|
|
334
|
+
fail("executor adapter returned an executable inconsistent with its declared executable; " +
|
|
335
|
+
"refusing to spawn a binary that preflight never probed");
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
/** Fixed, secret-free refusal text (no path, no file contents). */
|
|
339
|
+
const REQUIRED_MCP_REGISTRATION_MISSING_MESSAGE = "the required 'bridge-api' MCP registration is missing or unreadable in the prepared " +
|
|
340
|
+
"worktree; a worker loads MCP servers strictly from that file, so refusing to spawn a " +
|
|
341
|
+
"worker that would run without Bridge tools";
|
|
291
342
|
/**
|
|
292
|
-
* Run the
|
|
343
|
+
* Run the ADAPTER's advisory MCP inspection against a PREPARED worktree,
|
|
293
344
|
* covering all three scopes: user, the executor's main checkout (`deps.cwd`), and
|
|
294
345
|
* the concrete worktree this job will run in.
|
|
295
346
|
*
|
|
296
|
-
*
|
|
297
|
-
*
|
|
298
|
-
*
|
|
299
|
-
*
|
|
300
|
-
*
|
|
347
|
+
* ADVISORY ONLY (BAPI-790). This used to be able to refuse a job with
|
|
348
|
+
* `ContractError.McpShadowing`, because a same-named operator registration could
|
|
349
|
+
* genuinely win over the worktree's provisioned `.mcp.json` and point a worker at
|
|
350
|
+
* the wrong endpoint. It cannot now: the worker is spawned with
|
|
351
|
+
* `--strict-mcp-config`, so no operator-scoped registration is loaded at any
|
|
352
|
+
* scope, and the per-spawn init-event assertion verifies the loaded surface
|
|
353
|
+
* rather than trusting that. Every outcome — a confirmed collision, a degraded
|
|
354
|
+
* read, or an unexpected throw — is logged and the job continues.
|
|
355
|
+
*
|
|
356
|
+
* BAPI-781 routes it through the adapter, so the runner no longer imports
|
|
357
|
+
* Claude's user-config inspector. An adapter that declares no advisory
|
|
358
|
+
* capability simply produces no warnings; that is not a failure, because the
|
|
359
|
+
* capability is optional by design.
|
|
360
|
+
*
|
|
361
|
+
* Returns nothing on purpose: there is no verdict left for a caller to branch on,
|
|
362
|
+
* and a boolean nobody may act on invites someone to start acting on it again.
|
|
301
363
|
*/
|
|
302
|
-
async function
|
|
364
|
+
async function checkAdapterMcpAdvisoryForPreparedWorktree(worktreePath, deps, adapter) {
|
|
365
|
+
const advisory = adapter.advisoryMcpInspection;
|
|
366
|
+
if (advisory?.supported !== true)
|
|
367
|
+
return;
|
|
303
368
|
try {
|
|
304
|
-
const
|
|
305
|
-
const inspection = await inspect({
|
|
306
|
-
claudeConfigPath: resolveClaudeUserConfigPath(deps.homedir(), deps.platform),
|
|
369
|
+
const result = await advisory.value.inspect({
|
|
307
370
|
platform: deps.platform,
|
|
371
|
+
homedir: deps.homedir(),
|
|
308
372
|
cwd: deps.cwd,
|
|
309
|
-
mainRepositoryPath: deps.cwd,
|
|
310
373
|
worktreePath,
|
|
311
|
-
}
|
|
312
|
-
const
|
|
313
|
-
|
|
314
|
-
deps.errorLog(`Claude MCP shadowing: ${warning}`);
|
|
374
|
+
});
|
|
375
|
+
for (const warning of result.warnings) {
|
|
376
|
+
deps.errorLog(`worker MCP advisory: ${warning}`);
|
|
315
377
|
}
|
|
316
|
-
if (policy.ok)
|
|
317
|
-
return { blocked: false };
|
|
318
|
-
return { blocked: true, message: policy.refusals.join("; ") };
|
|
319
378
|
}
|
|
320
379
|
catch {
|
|
321
|
-
deps.errorLog("
|
|
322
|
-
return { blocked: false };
|
|
380
|
+
deps.errorLog("worker MCP advisory inspection did not complete; continuing without an advisory verdict");
|
|
323
381
|
}
|
|
324
382
|
}
|
|
325
383
|
/**
|
|
@@ -410,9 +468,18 @@ async function superviseProcess(params) {
|
|
|
410
468
|
collectRemoteMarker: params.collectRemoteMarker,
|
|
411
469
|
isDone: () => isDoneRef.value,
|
|
412
470
|
});
|
|
471
|
+
const mcpSurface = params.mcpSurface;
|
|
413
472
|
const result = await runProcessWithTimeout(params.proc, params.timeoutSeconds, params.deps, {
|
|
414
473
|
termGraceMs: params.options.termGraceMs,
|
|
415
474
|
onStdout: (chunk) => params.observation.recordStdout(chunk),
|
|
475
|
+
...(mcpSurface
|
|
476
|
+
? {
|
|
477
|
+
onStdoutTerminationCheck: (chunk) => {
|
|
478
|
+
mcpSurface.observeStdout(chunk);
|
|
479
|
+
return mcpSurface.shouldTerminate();
|
|
480
|
+
},
|
|
481
|
+
}
|
|
482
|
+
: {}),
|
|
416
483
|
});
|
|
417
484
|
isDoneRef.value = true;
|
|
418
485
|
const loopOutcome = await hb;
|
|
@@ -456,27 +523,14 @@ export async function runClaimedJob(job, httpClient, options, deps, _report, sea
|
|
|
456
523
|
// via the shared `isSpawnJobType` policy (`job-types.ts`) AFTER the dedicated
|
|
457
524
|
// `merge` and `smoke` branches so those keep their bespoke handling.
|
|
458
525
|
if (isSpawnJobType(job.job_type)) {
|
|
459
|
-
// BAPI-
|
|
460
|
-
//
|
|
461
|
-
//
|
|
462
|
-
//
|
|
463
|
-
//
|
|
464
|
-
//
|
|
465
|
-
//
|
|
466
|
-
|
|
467
|
-
const isolationHolder = { current: null };
|
|
468
|
-
try {
|
|
469
|
-
return await runSpawnJob(job, httpClient, options, deps, ownership, observation, seams, isolationHolder);
|
|
470
|
-
}
|
|
471
|
-
finally {
|
|
472
|
-
if (isolationHolder.current) {
|
|
473
|
-
// Best-effort by contract: a cleanup failure is logged, never allowed to
|
|
474
|
-
// replace the job outcome already determined above.
|
|
475
|
-
const diagnostic = await isolationHolder.current.dispose();
|
|
476
|
-
if (diagnostic)
|
|
477
|
-
deps.errorLog(diagnostic);
|
|
478
|
-
}
|
|
479
|
-
}
|
|
526
|
+
// BAPI-790: no per-job configuration directory is created any more, so there
|
|
527
|
+
// is nothing to dispose and no `finally` wrapper here. Worker containment is
|
|
528
|
+
// established by argv (`--strict-mcp-config --mcp-config <worktree>/.mcp.json`)
|
|
529
|
+
// and verified from the worker's own init event inside `runSpawnJob`. Both
|
|
530
|
+
// are stateless with respect to the host, which is why the whole
|
|
531
|
+
// establish/dispose lifetime this branch used to manage simply stopped
|
|
532
|
+
// existing rather than moving somewhere else.
|
|
533
|
+
return await runSpawnJob(job, httpClient, options, deps, ownership, observation, seams);
|
|
480
534
|
}
|
|
481
535
|
// Unsupported job type — fail server-side; never spawn.
|
|
482
536
|
await httpClient.fail(job, {
|
|
@@ -668,7 +722,8 @@ async function runMergeJob(job, httpClient, options, deps, ownership, observatio
|
|
|
668
722
|
* on this no-worktree, no-real-worker-LLM `createNoopProcess` path and does
|
|
669
723
|
* NOT receive MCP registration. The ratified requirement anchors MCP
|
|
670
724
|
* provisioning to "the executor's worktree-preparation path
|
|
671
|
-
* (`job-runner.ts`), alongside the existing
|
|
725
|
+
* (`job-runner.ts`), alongside the existing deny-layer provisioning call"
|
|
726
|
+
* (which BAPI-781 moved behind the adapter's `provisionWorktreeDenyLayer` hook)
|
|
672
727
|
* — that call, like the new MCP provisioning call, exists ONLY inside
|
|
673
728
|
* `runSpawnJob`, which `smoke` never reaches (see `isSpawnJobType` /
|
|
674
729
|
* `job-types.ts`, and this module's own "no real worker LLM" framing of the
|
|
@@ -860,7 +915,7 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
|
|
|
860
915
|
* returns BEFORE either the deny layer or MCP provisioning runs — neither ever
|
|
861
916
|
* touches a worktree that will not become a runnable worker.
|
|
862
917
|
*/
|
|
863
|
-
async function runSpawnJob(job, httpClient, options, deps, ownership, observation, seams
|
|
918
|
+
async function runSpawnJob(job, httpClient, options, deps, ownership, observation, seams) {
|
|
864
919
|
// --- Per-job base branch (BAPI-586) ----------------------------------
|
|
865
920
|
// Resolve the effective logical base BEFORE any side effect: the persisted
|
|
866
921
|
// run base (`payload.base_branch`) is authoritative, falling back to the
|
|
@@ -869,6 +924,42 @@ async function runSpawnJob(job, httpClient, options, deps, ownership, observatio
|
|
|
869
924
|
// job-scoped options carry that base through worktree seeding, prompt
|
|
870
925
|
// rendering, deny layer, telemetry, env, and finalization so they cannot
|
|
871
926
|
// disagree about the base.
|
|
927
|
+
// --- Executor adapter (FAIL-CLOSED, FIRST, BAPI-781) ------------------
|
|
928
|
+
// Resolved and fully validated BEFORE base-branch resolution, worktree
|
|
929
|
+
// creation, deny provisioning, MCP reads, and the spawn — i.e. before any side
|
|
930
|
+
// effect at all. Preflight already gates claiming on the same resolution, so
|
|
931
|
+
// reaching here with an unusable adapter means something changed underneath a
|
|
932
|
+
// running executor. That is exactly when a defaulting fallback would be most
|
|
933
|
+
// damaging: it would spawn a real worker under containment nobody declared,
|
|
934
|
+
// and every assertion downstream would check the default's shape and pass.
|
|
935
|
+
const agentId = resolveExecutorAgentId(options.agentId);
|
|
936
|
+
const resolveAdapter = seams.resolveAdapter ??
|
|
937
|
+
((id) => resolveExecutorAgentAdapter(id, {
|
|
938
|
+
// The adapter owns deny provisioning and advisory inspection, but it
|
|
939
|
+
// owns no I/O boundary of its own — every read/write it performs is one
|
|
940
|
+
// the runner handed it. That keeps the runner's injected filesystem the
|
|
941
|
+
// single boundary a test has to fake, and keeps an adapter from quietly
|
|
942
|
+
// acquiring filesystem access the executor did not grant.
|
|
943
|
+
claude: {
|
|
944
|
+
readFile: deps.readFile,
|
|
945
|
+
denyProvisioningDeps: {
|
|
946
|
+
readFile: deps.readFile,
|
|
947
|
+
writeFile: deps.writeFile,
|
|
948
|
+
mkdir: deps.mkdir,
|
|
949
|
+
homedir: deps.homedir,
|
|
950
|
+
},
|
|
951
|
+
},
|
|
952
|
+
}));
|
|
953
|
+
const adapterResolution = resolveAdapter(agentId);
|
|
954
|
+
if (!adapterResolution.supported) {
|
|
955
|
+
await httpClient.fail(job, {
|
|
956
|
+
error_kind: ExecutorAdapterUnavailable,
|
|
957
|
+
error_message: adapterResolution.message,
|
|
958
|
+
classification: "crashed",
|
|
959
|
+
});
|
|
960
|
+
return { status: "failed", reason: "executor_adapter_unavailable" };
|
|
961
|
+
}
|
|
962
|
+
const adapter = adapterResolution.adapter;
|
|
872
963
|
const baseResolution = resolveExecutorJobBaseBranch(job, options.baseBranch);
|
|
873
964
|
if (!baseResolution.ok) {
|
|
874
965
|
await httpClient.fail(job, {
|
|
@@ -905,6 +996,111 @@ async function runSpawnJob(job, httpClient, options, deps, ownership, observatio
|
|
|
905
996
|
if (!prep.ok)
|
|
906
997
|
return prep.result;
|
|
907
998
|
const { worktreePath, branch } = prep;
|
|
999
|
+
// --- Worktree mutual exclusion (FAIL-CLOSED, BAPI-793) ----------------
|
|
1000
|
+
// The FIRST thing that happens once a canonical worktree path exists, and
|
|
1001
|
+
// ahead of every executor-owned write to it: the deny layer, MCP
|
|
1002
|
+
// provisioning, the `worker.log` truncation, the viewer tab, and the spawn
|
|
1003
|
+
// itself all live inside the lock. `worker.log` in particular is created with
|
|
1004
|
+
// an empty write, so a refused contender that got as far as the tee would
|
|
1005
|
+
// erase the running worker's forensic log without ever spawning — which is
|
|
1006
|
+
// why refusal returns from HERE rather than anywhere downstream.
|
|
1007
|
+
//
|
|
1008
|
+
// Known residual window, stated rather than papered over: `prepareSpawn`
|
|
1009
|
+
// above already ran `ensureWorktree`, which hard-resets and cleans the tree.
|
|
1010
|
+
// Its path comes back FROM Worktrunk, so there is no path to lock before it
|
|
1011
|
+
// without duplicating Worktrunk's layout rules here — a copy that would go
|
|
1012
|
+
// wrong silently. Dispatch exclusion and same-ticket claim serialization
|
|
1013
|
+
// (the Python half of this ticket) are what shrink that window; this lock is
|
|
1014
|
+
// what makes everything after it safe.
|
|
1015
|
+
const acquireLock = seams.acquireWorktreeLock ?? acquireExecutorWorktreeLock;
|
|
1016
|
+
const worktreeLock = await acquireLock({
|
|
1017
|
+
worktreePath,
|
|
1018
|
+
jobId: job.id,
|
|
1019
|
+
claimToken: job.claim_token,
|
|
1020
|
+
executorId: options.executorId,
|
|
1021
|
+
ownerPid: process.pid,
|
|
1022
|
+
acquiredAt: nowIso(deps),
|
|
1023
|
+
}, buildWorktreeLockSeams(deps));
|
|
1024
|
+
if (!worktreeLock.acquired) {
|
|
1025
|
+
// Loud and structured, on OUR stderr, with the identity the fixed
|
|
1026
|
+
// server-bound message deliberately omits (BAPI-793 proposed fix #4).
|
|
1027
|
+
deps.errorLog(formatWorktreeBusyDiagnostic(job, worktreePath, worktreeLock.owner));
|
|
1028
|
+
await httpClient.fail(job, {
|
|
1029
|
+
error_kind: WorktreeBusy,
|
|
1030
|
+
error_message: WORKTREE_BUSY_MESSAGE,
|
|
1031
|
+
classification: "crashed",
|
|
1032
|
+
});
|
|
1033
|
+
return { status: "failed", reason: "worktree_busy" };
|
|
1034
|
+
}
|
|
1035
|
+
try {
|
|
1036
|
+
// --- Stale verdict artifacts (BAPI-814/D1) --------------------------
|
|
1037
|
+
// Inside the lock and BEFORE the worker spawns, on the SHARED attempt path,
|
|
1038
|
+
// so every job type routed through `runSpawnJob` is covered — `implement`,
|
|
1039
|
+
// `resume`, `spec_review`, and the recovery jobs. (`smoke` is deliberately
|
|
1040
|
+
// NOT here: it has its own branch, never acquires a worktree lock, and
|
|
1041
|
+
// synthesizes its verdict in process rather than reading one from disk.)
|
|
1042
|
+
//
|
|
1043
|
+
// `.conductor/` is an ignored path, so worktree preparation's `git clean -fd`
|
|
1044
|
+
// does not remove these files. Without this, a retry whose worker exits `0`
|
|
1045
|
+
// without writing a verdict would have the PREVIOUS attempt's `result.json`
|
|
1046
|
+
// read and posted verbatim as this attempt's answer.
|
|
1047
|
+
//
|
|
1048
|
+
// A failure here stops the attempt rather than continuing: spawning past a
|
|
1049
|
+
// cleanup that did not happen is the precise state being prevented. The
|
|
1050
|
+
// reason is bounded (a filename plus an error code) and reaches the operator
|
|
1051
|
+
// through the executor's normal structured failure path.
|
|
1052
|
+
const cleanupArtifacts = seams.removeStaleVerdictArtifacts ?? removeStaleVerdictArtifacts;
|
|
1053
|
+
const cleaned = await cleanupArtifacts(worktreePath, {
|
|
1054
|
+
// Same fallback direction as the worktree lock: a deps builder that omits
|
|
1055
|
+
// the optional seam gets the real removal, never a silent skip.
|
|
1056
|
+
removeFile: deps.removeFile ?? ((filePath) => rm(filePath, { force: true })),
|
|
1057
|
+
platform: deps.platform,
|
|
1058
|
+
});
|
|
1059
|
+
if (!cleaned.ok) {
|
|
1060
|
+
deps.errorLog(`[executor] job ${job.id}: pre-spawn artifact cleanup failed: ${cleaned.reason}`);
|
|
1061
|
+
await httpClient.fail(job, {
|
|
1062
|
+
error_kind: StaleArtifactCleanupFailed,
|
|
1063
|
+
error_message: cleaned.reason,
|
|
1064
|
+
classification: "crashed",
|
|
1065
|
+
});
|
|
1066
|
+
return { status: "failed", reason: "stale_artifact_cleanup" };
|
|
1067
|
+
}
|
|
1068
|
+
return await runPreparedSpawn({
|
|
1069
|
+
job,
|
|
1070
|
+
httpClient,
|
|
1071
|
+
options,
|
|
1072
|
+
jobOptions,
|
|
1073
|
+
deps,
|
|
1074
|
+
ownership,
|
|
1075
|
+
observation,
|
|
1076
|
+
seams,
|
|
1077
|
+
worktreePath,
|
|
1078
|
+
branch,
|
|
1079
|
+
prep,
|
|
1080
|
+
effectiveBaseBranch,
|
|
1081
|
+
permissionPosture,
|
|
1082
|
+
adapter,
|
|
1083
|
+
});
|
|
1084
|
+
}
|
|
1085
|
+
finally {
|
|
1086
|
+
// Released only here — after supervision has settled and every terminal
|
|
1087
|
+
// mutation above has been attempted — so the worktree is never advertised as
|
|
1088
|
+
// free while this job's worker could still be touching it.
|
|
1089
|
+
await worktreeLock.release();
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
/**
|
|
1093
|
+
* Run a spawn job whose worktree is already prepared AND exclusively locked.
|
|
1094
|
+
*
|
|
1095
|
+
* Split out of {@link runSpawnJob} for exactly one reason: every terminal branch
|
|
1096
|
+
* below — contract refusal, spawn exception, timeout, stale claim, server stop,
|
|
1097
|
+
* MCP surface mismatch, startup fatal, authentication failure, verdict-artifact
|
|
1098
|
+
* failure, finalization failure, clean completion — must run inside the lock's
|
|
1099
|
+
* `try/finally`. Expressing that as a function boundary makes it structural
|
|
1100
|
+
* rather than a 500-line indentation nobody can verify by eye.
|
|
1101
|
+
*/
|
|
1102
|
+
async function runPreparedSpawn(params) {
|
|
1103
|
+
const { job, httpClient, options, jobOptions, deps, ownership, observation, seams, worktreePath, branch, prep, effectiveBaseBranch, permissionPosture, adapter, } = params;
|
|
908
1104
|
// BAPI-586: PR-producing spawn jobs (implement/resume/remediate/ci_fix/rebase)
|
|
909
1105
|
// are told to open the PR against the injected run base. Verdict-only
|
|
910
1106
|
// `spec_review` produces no PR, so it is excluded from the instruction while
|
|
@@ -955,91 +1151,39 @@ async function runSpawnJob(job, httpClient, options, deps, ownership, observatio
|
|
|
955
1151
|
return { status: "failed", reason: "pre_spawn_verification" };
|
|
956
1152
|
}
|
|
957
1153
|
observation.setAttemptStartSha(verification.observedSha);
|
|
958
|
-
// --- Claude MCP shadow
|
|
959
|
-
//
|
|
960
|
-
//
|
|
961
|
-
//
|
|
962
|
-
//
|
|
963
|
-
//
|
|
964
|
-
//
|
|
965
|
-
//
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
//
|
|
980
|
-
// FAIL-CLOSED, unlike the fail-OPEN provisioning steps immediately below. The
|
|
981
|
-
// whole point is that a worker must never fall back to the operator's
|
|
982
|
-
// configuration: that fallback is how a worker acquired a `dbhub` handle it was
|
|
983
|
-
// never granted (BAPI-715 finding F4). "Could not isolate" therefore means
|
|
984
|
-
// "do not run", not "run with less containment".
|
|
985
|
-
const isolationEstablish = seams.establishWorkerConfigIsolation ?? establishWorkerConfigIsolation;
|
|
986
|
-
const isolationDeps = buildWorkerConfigIsolationDeps(deps);
|
|
987
|
-
// BAPI-731 gap: the isolated XDG root is where the Bridge credential store is
|
|
988
|
-
// looked up, so it must carry this repo's entry or the worker loses every
|
|
989
|
-
// Bridge MCP tool. Resolved here, outside the boundary, and scoped to one repo.
|
|
990
|
-
const seedCredential = isolationDeps
|
|
991
|
-
? await resolveSeedBridgeCredential(deps, job.repo_name)
|
|
992
|
-
: null;
|
|
993
|
-
// FAIL-CLOSED on an unresolvable credential (RUN-FINDINGS-2026-08-05 F2):
|
|
994
|
-
// establishing isolation with an EMPTY store would succeed, and the worker
|
|
995
|
-
// would spawn, find no `bapi:<repo>` entry under the redirected XDG root, lose
|
|
996
|
-
// every Bridge MCP tool, and exit cleanly having done nothing — the original
|
|
997
|
-
// F2 death, reported as success. Every job type that reaches `runSpawnJob`
|
|
998
|
-
// needs Bridge MCP (`smoke`/`merge` never come through here), so a distinct
|
|
999
|
-
// pre-spawn refusal breaks nothing legitimate and makes the cause queryable.
|
|
1000
|
-
if (isolationDeps && !seedCredential) {
|
|
1001
|
-
await httpClient.fail(job, {
|
|
1002
|
-
error_kind: BridgeCredentialUnavailable,
|
|
1003
|
-
error_message: BRIDGE_CREDENTIAL_UNAVAILABLE_MESSAGE,
|
|
1004
|
-
classification: "crashed",
|
|
1005
|
-
...commitResidue(preSpawnTelemetry),
|
|
1006
|
-
telemetry: observation.snapshot(),
|
|
1154
|
+
// --- Claude MCP shadow advisory (NON-BLOCKING, BAPI-727 -> BAPI-790) -
|
|
1155
|
+
// Still inspected, no longer blocking. Under BAPI-727 a
|
|
1156
|
+
// `projects["<this worktree>"]` entry in ~/.claude.json could silently win
|
|
1157
|
+
// over the registration provisioned two steps below, so the job had to stop
|
|
1158
|
+
// before any side effect. Strict flag-scoped loading removes that possibility
|
|
1159
|
+
// at the mechanism level, and the init-event assertion after the spawn proves
|
|
1160
|
+
// it per spawn rather than assuming it — so the collision is logged with its
|
|
1161
|
+
// remediation and the job continues.
|
|
1162
|
+
await checkAdapterMcpAdvisoryForPreparedWorktree(worktreePath, deps, adapter);
|
|
1163
|
+
// --- Deny layer (fail-open, via the adapter hook, BAPI-781) -----------
|
|
1164
|
+
// Provisioning moved behind the adapter because WHICH file carries the deny
|
|
1165
|
+
// rules is agent-specific (`.claude/settings.local.json` here). The posture is
|
|
1166
|
+
// unchanged and deliberately still fail-OPEN: a provisioning hiccup shrinks
|
|
1167
|
+
// safety margin but must not kill a job. Refusing to CLAIM on unenforced deny
|
|
1168
|
+
// remains a separate, fatal preflight concern.
|
|
1169
|
+
const denyCapability = adapter.denyEnforcement;
|
|
1170
|
+
if (denyCapability?.supported === true) {
|
|
1171
|
+
const deny = await denyCapability.value.provisionWorktreeDenyLayer({
|
|
1172
|
+
worktreePath,
|
|
1173
|
+
baseBranch: effectiveBaseBranch,
|
|
1174
|
+
homedir: deps.homedir(),
|
|
1007
1175
|
});
|
|
1008
|
-
|
|
1176
|
+
// Log on WARNING PRESENCE, not on `!ok` (BAPI-815/R2). The git-exclude
|
|
1177
|
+
// hygiene step can degrade without failing the deny layer — present-but-
|
|
1178
|
+
// unresolvable git metadata is exactly that case — and gating the log on
|
|
1179
|
+
// `!ok` is what made a total failure to ever write the exclude invisible.
|
|
1180
|
+
// The text is bounded at its source (category + worktree path), so it is
|
|
1181
|
+
// emitted verbatim through the executor's existing stderr diagnostic
|
|
1182
|
+
// channel. It never becomes a pre-spawn failure: the posture stays
|
|
1183
|
+
// fail-OPEN and the job continues to the spawn boundary.
|
|
1184
|
+
if (deny.warning)
|
|
1185
|
+
deps.errorLog(deny.warning);
|
|
1009
1186
|
}
|
|
1010
|
-
const isolationResult = isolationDeps
|
|
1011
|
-
? await isolationEstablish(isolationDeps, {
|
|
1012
|
-
...(seedCredential ? { bridgeCredential: seedCredential } : {}),
|
|
1013
|
-
})
|
|
1014
|
-
: {
|
|
1015
|
-
ok: false,
|
|
1016
|
-
platform: String(deps.platform),
|
|
1017
|
-
reason: "missing-filesystem-dependency",
|
|
1018
|
-
message: formatWorkerConfigIsolationReason(String(deps.platform), "missing-filesystem-dependency"),
|
|
1019
|
-
};
|
|
1020
|
-
if (!isolationResult.ok) {
|
|
1021
|
-
await httpClient.fail(job, {
|
|
1022
|
-
error_kind: WorkerConfigIsolation,
|
|
1023
|
-
error_message: isolationResult.message,
|
|
1024
|
-
classification: "crashed",
|
|
1025
|
-
...commitResidue(preSpawnTelemetry),
|
|
1026
|
-
telemetry: observation.snapshot(),
|
|
1027
|
-
});
|
|
1028
|
-
return { status: "failed", reason: "worker_config_isolation" };
|
|
1029
|
-
}
|
|
1030
|
-
const isolation = isolationResult.isolation;
|
|
1031
|
-
// Hand the handle to the caller's `finally` so it is disposed on EVERY exit
|
|
1032
|
-
// path from here on, including ones that never reach the spawn below.
|
|
1033
|
-
isolationHolder.current = isolation;
|
|
1034
|
-
// --- Deny layer (fail-open) ------------------------------------------
|
|
1035
|
-
const deny = await provisionExecutorDenyLayer(worktreePath, { baseBranch: effectiveBaseBranch }, {
|
|
1036
|
-
readFile: deps.readFile,
|
|
1037
|
-
writeFile: deps.writeFile,
|
|
1038
|
-
mkdir: deps.mkdir,
|
|
1039
|
-
homedir: deps.homedir,
|
|
1040
|
-
});
|
|
1041
|
-
if (!deny.ok)
|
|
1042
|
-
deps.errorLog(deny.warning);
|
|
1043
1187
|
// --- MCP registration + trust pre-approval (fail-open, BAPI-724) -----
|
|
1044
1188
|
// Unconditional for every job type that reaches this shared spawn path
|
|
1045
1189
|
// (implement/resume/spec_review/remediate/ci_fix/rebase) — no job-type
|
|
@@ -1048,27 +1192,45 @@ async function runSpawnJob(job, httpClient, options, deps, ownership, observatio
|
|
|
1048
1192
|
// runtime options to the generated registration, so the worker's MCP server
|
|
1049
1193
|
// targets the same Bridge API this executor claimed the job from.
|
|
1050
1194
|
await provisionMcpForPreparedSpawn(job, branch, worktreePath, deps, seams, options.resolvedBaseUrl);
|
|
1051
|
-
// --- Required Bridge MCP registration (FAIL-CLOSED
|
|
1195
|
+
// --- Required Bridge MCP registration (FAIL-CLOSED) -------------------
|
|
1052
1196
|
// Provisioning itself stays fail-OPEN: a skipped or malformed Tier-2 target is
|
|
1053
|
-
// a warning, and every valid worktree-provisioned server is kept.
|
|
1054
|
-
//
|
|
1055
|
-
//
|
|
1056
|
-
//
|
|
1057
|
-
// to
|
|
1058
|
-
//
|
|
1059
|
-
//
|
|
1197
|
+
// a warning, and every valid worktree-provisioned server is kept. The REQUIRED
|
|
1198
|
+
// `bridge-api` shim registration is fail-CLOSED, and under strict loading it
|
|
1199
|
+
// has to be: the worker is launched with `--mcp-config <this file>`, so if the
|
|
1200
|
+
// file is absent or carries no Bridge shim there is no operator-scoped
|
|
1201
|
+
// registration to fall back to — the worker would run silently toolless and
|
|
1202
|
+
// produce confidently wrong work.
|
|
1203
|
+
//
|
|
1204
|
+
// This is also where the spawn's MCP contract is captured (BAPI-790). The same
|
|
1205
|
+
// parse yields the absolute path passed to `--mcp-config` AND the expected
|
|
1206
|
+
// server names the init-event assertion checks, so the two can never describe
|
|
1207
|
+
// different files.
|
|
1208
|
+
//
|
|
1209
|
+
// BAPI-781: the required server NAME comes from the adapter's declared MCP
|
|
1210
|
+
// scoping capability, not from a module constant, so a future agent requires
|
|
1211
|
+
// whatever its own runtime registers.
|
|
1060
1212
|
const verifyRegistration = seams.verifyRequiredWorktreeMcpRegistration ?? verifyRequiredWorktreeMcpRegistration;
|
|
1061
|
-
const
|
|
1213
|
+
const mcpScoping = adapter.mcpScoping;
|
|
1214
|
+
if (mcpScoping?.supported !== true) {
|
|
1215
|
+
await httpClient.fail(job, {
|
|
1216
|
+
error_kind: ExecutorAdapterUnavailable,
|
|
1217
|
+
error_message: "executor adapter declares no MCP scoping capability; refusing to spawn",
|
|
1218
|
+
classification: "crashed",
|
|
1219
|
+
});
|
|
1220
|
+
return { status: "failed", reason: "executor_adapter_unavailable" };
|
|
1221
|
+
}
|
|
1222
|
+
const registration = await verifyRegistration(worktreePath, deps, mcpScoping.value.requiredServerName);
|
|
1062
1223
|
if (!registration.ok) {
|
|
1063
1224
|
await httpClient.fail(job, {
|
|
1064
|
-
error_kind:
|
|
1225
|
+
error_kind: RequiredMcpRegistration,
|
|
1065
1226
|
error_message: registration.message,
|
|
1066
1227
|
classification: "crashed",
|
|
1067
1228
|
...commitResidue(preSpawnTelemetry),
|
|
1068
1229
|
telemetry: observation.snapshot(),
|
|
1069
1230
|
});
|
|
1070
|
-
return { status: "failed", reason: "
|
|
1231
|
+
return { status: "failed", reason: "required_mcp_registration" };
|
|
1071
1232
|
}
|
|
1233
|
+
const mcpContract = registration.contract;
|
|
1072
1234
|
// --- Worker log tee + watch registry + viewer tab (BAPI-535 §8) ------
|
|
1073
1235
|
const registryDeps = buildJobLogRegistryDeps(deps);
|
|
1074
1236
|
const markFinished = seams.markExecutorJobLogFinished ?? markExecutorJobLogFinished;
|
|
@@ -1133,35 +1295,101 @@ async function runSpawnJob(job, httpClient, options, deps, ownership, observatio
|
|
|
1133
1295
|
}
|
|
1134
1296
|
};
|
|
1135
1297
|
// --- Spawn -----------------------------------------------------------
|
|
1136
|
-
|
|
1137
|
-
//
|
|
1138
|
-
//
|
|
1139
|
-
//
|
|
1140
|
-
|
|
1141
|
-
// BAPI-
|
|
1142
|
-
//
|
|
1143
|
-
//
|
|
1144
|
-
//
|
|
1145
|
-
//
|
|
1146
|
-
//
|
|
1147
|
-
//
|
|
1148
|
-
//
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1298
|
+
// ONE complete spawn shape, requested from the adapter (BAPI-781). The runner
|
|
1299
|
+
// no longer knows Claude's flags, Claude's model policy, or Claude's
|
|
1300
|
+
// credential passthrough — it knows only that an adapter returns
|
|
1301
|
+
// `{ executable, argv, env }`.
|
|
1302
|
+
//
|
|
1303
|
+
// BAPI-725: the posture was resolved at the top of this function — after the
|
|
1304
|
+
// deny layer has already been provisioned above, which is what keeps
|
|
1305
|
+
// `acceptEdits` from being a relaxation of what the worker may do.
|
|
1306
|
+
//
|
|
1307
|
+
// BAPI-790: the argv carries `--strict-mcp-config --mcp-config <path>`, and the
|
|
1308
|
+
// path is the one the registration check just VERIFIED — not a path rebuilt
|
|
1309
|
+
// here. Shape construction is structurally downstream of that check, so a
|
|
1310
|
+
// worker can never be launched pointing at a file that was not confirmed to
|
|
1311
|
+
// exist and to register the required shim.
|
|
1312
|
+
//
|
|
1313
|
+
// BAPI-586: the validated run base travels in as BAPI_BASE_BRANCH so the
|
|
1314
|
+
// worker can target it via `gh pr create --base "$BAPI_BASE_BRANCH"`.
|
|
1315
|
+
const invocation = adapter.headlessInvocation;
|
|
1316
|
+
if (invocation?.supported !== true) {
|
|
1317
|
+
await closeWorkerLogTee(tee);
|
|
1318
|
+
await finalizeRegistry();
|
|
1319
|
+
await httpClient.fail(job, {
|
|
1320
|
+
error_kind: ExecutorAdapterUnavailable,
|
|
1321
|
+
error_message: "executor adapter declares no headless invocation capability",
|
|
1322
|
+
classification: "crashed",
|
|
1323
|
+
});
|
|
1324
|
+
return { status: "failed", reason: "executor_adapter_unavailable" };
|
|
1325
|
+
}
|
|
1326
|
+
let spawnShape;
|
|
1327
|
+
try {
|
|
1328
|
+
const alias = invocation.value.resolveModelAlias(job.payload);
|
|
1329
|
+
spawnShape = invocation.value.buildSpawnShape({
|
|
1330
|
+
prompt,
|
|
1331
|
+
modelAlias: alias,
|
|
1332
|
+
posture: permissionPosture,
|
|
1333
|
+
mcpConfigPath: mcpContract.configPath,
|
|
1334
|
+
effectiveBaseBranch,
|
|
1335
|
+
parentEnv: deps.env,
|
|
1336
|
+
});
|
|
1337
|
+
assertUsableSpawnShape(spawnShape, adapter);
|
|
1338
|
+
}
|
|
1339
|
+
catch (err) {
|
|
1340
|
+
// A spawn shape the adapter could not produce — or produced incompletely —
|
|
1341
|
+
// is NOT repaired here. Filling a missing executable from a host default or
|
|
1342
|
+
// a missing argv from Claude's flags is precisely the defaulting this
|
|
1343
|
+
// extraction removes, and it would spawn a real worker under a shape nobody
|
|
1344
|
+
// declared.
|
|
1345
|
+
await closeWorkerLogTee(tee);
|
|
1346
|
+
await finalizeRegistry();
|
|
1347
|
+
await httpClient.fail(job, {
|
|
1348
|
+
error_kind: ExecutorAdapterUnavailable,
|
|
1349
|
+
error_message: isExecutorNamedError(err)
|
|
1350
|
+
? err.errorMessage
|
|
1351
|
+
: `executor adapter produced an unusable spawn shape: ${boundedError(err)}`,
|
|
1352
|
+
classification: "crashed",
|
|
1353
|
+
});
|
|
1354
|
+
return { status: "failed", reason: "executor_adapter_unavailable" };
|
|
1355
|
+
}
|
|
1356
|
+
// The per-spawn MCP surface assertion (BAPI-790). Built from the SAME verified
|
|
1357
|
+
// contract that produced the argv path, so the expected set and the loaded file
|
|
1358
|
+
// are the same file by construction. The PARSER is the adapter's; the
|
|
1359
|
+
// expected-versus-observed comparison stays here, so an adapter can never
|
|
1360
|
+
// certify its own containment.
|
|
1361
|
+
const initParsing = adapter.mcpInitParsing;
|
|
1362
|
+
if (initParsing?.supported !== true) {
|
|
1363
|
+
await closeWorkerLogTee(tee);
|
|
1364
|
+
await finalizeRegistry();
|
|
1365
|
+
await httpClient.fail(job, {
|
|
1366
|
+
error_kind: ExecutorAdapterUnavailable,
|
|
1367
|
+
error_message: "executor adapter declares no MCP init-event parser",
|
|
1368
|
+
classification: "crashed",
|
|
1369
|
+
});
|
|
1370
|
+
return { status: "failed", reason: "executor_adapter_unavailable" };
|
|
1371
|
+
}
|
|
1372
|
+
const mcpSurface = createMcpSurfaceObserver(mcpContract.expectedServerNames, (line) => initParsing.value.parseInitEvent(line));
|
|
1155
1373
|
let proc;
|
|
1156
1374
|
try {
|
|
1157
|
-
proc = deps.spawnProcess(
|
|
1375
|
+
proc = deps.spawnProcess(spawnShape.executable, spawnShape.argv, {
|
|
1376
|
+
cwd: worktreePath,
|
|
1377
|
+
env: spawnShape.env,
|
|
1378
|
+
});
|
|
1158
1379
|
}
|
|
1159
1380
|
catch (err) {
|
|
1160
1381
|
await closeWorkerLogTee(tee);
|
|
1161
1382
|
await finalizeRegistry();
|
|
1383
|
+
// Redact through the adapter's declared secret names before bounding. A
|
|
1384
|
+
// spawn error can echo the environment it failed to apply, and that
|
|
1385
|
+
// environment may carry the operator's forwarded token.
|
|
1386
|
+
const redactionCapability = adapter.redaction;
|
|
1387
|
+
const raw = `failed to spawn worker: ${boundedError(err)}`;
|
|
1162
1388
|
await httpClient.fail(job, {
|
|
1163
1389
|
error_kind: "SpawnError",
|
|
1164
|
-
error_message:
|
|
1390
|
+
error_message: redactionCapability?.supported === true
|
|
1391
|
+
? redactAdapterDiagnostic(raw, deps.env, redactionCapability.value)
|
|
1392
|
+
: raw,
|
|
1165
1393
|
classification: "crashed",
|
|
1166
1394
|
});
|
|
1167
1395
|
return { status: "failed", reason: "spawn_failed" };
|
|
@@ -1190,7 +1418,30 @@ async function runSpawnJob(job, httpClient, options, deps, ownership, observatio
|
|
|
1190
1418
|
timeoutSeconds: timeout.timeoutSeconds,
|
|
1191
1419
|
collectTelemetry,
|
|
1192
1420
|
collectRemoteMarker,
|
|
1421
|
+
mcpSurface,
|
|
1193
1422
|
});
|
|
1423
|
+
// --- Adapter lifecycle (EXPLICIT, BAPI-781) ---------------------------
|
|
1424
|
+
// Consumed rather than assumed. Claude declares `{ kind: "none" }` and so
|
|
1425
|
+
// nothing runs here — which is correct, because BAPI-790 deleted the per-job
|
|
1426
|
+
// configuration directory that used to need cleanup and orphan sweeping. The
|
|
1427
|
+
// declaration is still READ, because "no work to do" and "nobody said" must
|
|
1428
|
+
// reach different code: an absent lifecycle was already refused by preflight
|
|
1429
|
+
// and by capability validation, and treating absence as a silent no-op here
|
|
1430
|
+
// would undo both.
|
|
1431
|
+
const lifecycleCapability = adapter.lifecycle;
|
|
1432
|
+
if (lifecycleCapability?.supported === true) {
|
|
1433
|
+
const declaration = lifecycleCapability.value.declaration;
|
|
1434
|
+
if (declaration.kind === "managed") {
|
|
1435
|
+
try {
|
|
1436
|
+
await declaration.cleanupAfterJob({ worktreePath });
|
|
1437
|
+
}
|
|
1438
|
+
catch (err) {
|
|
1439
|
+
// Cleanup is best-effort: a failure here must not turn a completed job
|
|
1440
|
+
// into a failed one, and the worker's real outcome is already decided.
|
|
1441
|
+
deps.errorLog(`adapter lifecycle cleanup failed (non-fatal): ${boundedError(err)}`);
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1194
1445
|
// Final telemetry snapshot from the worktree.
|
|
1195
1446
|
const finalTelemetry = await collectTelemetry();
|
|
1196
1447
|
observation.setGitTelemetry(finalTelemetry);
|
|
@@ -1222,10 +1473,92 @@ async function runSpawnJob(job, httpClient, options, deps, ownership, observatio
|
|
|
1222
1473
|
}
|
|
1223
1474
|
// Finalize the log tee WITHOUT signaling the worker (it has already exited).
|
|
1224
1475
|
await closeWorkerLogTee(tee);
|
|
1476
|
+
// --- Worker-log session invariant (ADVISORY, BAPI-793) ----------------
|
|
1477
|
+
// "`.conductor/worker.log` for any completed job contains exactly one
|
|
1478
|
+
// `session_id`" — the cheap, durable invariant from the ticket that would have
|
|
1479
|
+
// caught the original incident on the first job rather than after three
|
|
1480
|
+
// misdiagnosed failures.
|
|
1481
|
+
//
|
|
1482
|
+
// Advisory ON PURPOSE. The lock above is the enforcement; this is the detector
|
|
1483
|
+
// that proves the lock held. Failing an otherwise-successful job because of an
|
|
1484
|
+
// observation about its log file would discard real work over a forensic
|
|
1485
|
+
// artifact — and a violation here means the lock was bypassed, which is a fact
|
|
1486
|
+
// about the host, not about the worker's output.
|
|
1487
|
+
await reportWorkerLogSessionInvariant(tee, deps);
|
|
1225
1488
|
if (ownership.abandoned) {
|
|
1226
1489
|
await finalizeRegistry();
|
|
1227
1490
|
return { status: "abandoned", reason: ownership.abandonReason };
|
|
1228
1491
|
}
|
|
1492
|
+
// --- MCP surface containment verdict (FAIL-CLOSED, BAPI-790) ----------
|
|
1493
|
+
// Placed AFTER the server-stop and abandonment checks and BEFORE every
|
|
1494
|
+
// classification branch below. That ordering is the whole precedence rule:
|
|
1495
|
+
//
|
|
1496
|
+
// - a deliberate server cancellation and a lost claim still win, because
|
|
1497
|
+
// reporting under a dead claim (or overriding an explicit cancel) would be
|
|
1498
|
+
// wrong regardless of what the worker's MCP surface looked like;
|
|
1499
|
+
// - but a containment failure outranks the generic `killed`/`crashed`
|
|
1500
|
+
// classification, because the worker was terminated BY US, on purpose, and
|
|
1501
|
+
// labeling that a worker fault would hide the actual cause and make the
|
|
1502
|
+
// condition unqueryable in `executor_jobs.error_kind`.
|
|
1503
|
+
//
|
|
1504
|
+
// The surface verdict is also consulted for a worker that finished on its own:
|
|
1505
|
+
// an assertion mismatch observed moments before a fast exit is still a
|
|
1506
|
+
// containment failure, not a successful run.
|
|
1507
|
+
const surfaceVerdict = mcpSurface.verdict();
|
|
1508
|
+
if (surfaceVerdict.kind === "mismatch") {
|
|
1509
|
+
await finalizeRegistry();
|
|
1510
|
+
await httpClient.fail(job, {
|
|
1511
|
+
error_kind: McpSurfaceMismatch,
|
|
1512
|
+
error_message: formatMcpSurfaceMismatch(surfaceVerdict.expected, surfaceVerdict.observed),
|
|
1513
|
+
classification: "crashed",
|
|
1514
|
+
...commitResidue(finalTelemetry),
|
|
1515
|
+
telemetry: observation.snapshot(),
|
|
1516
|
+
});
|
|
1517
|
+
return { status: "failed", reason: "mcp_surface_mismatch" };
|
|
1518
|
+
}
|
|
1519
|
+
// --- Fatal startup, before any MCP surface was reported (BAPI-790) ----
|
|
1520
|
+
// A worker that exits non-zero having produced NO stream-json output at all
|
|
1521
|
+
// did not fail during work — it failed to start. Under strict MCP loading the
|
|
1522
|
+
// known cause is an enterprise `managed-mcp.json` policy rejecting the strict
|
|
1523
|
+
// flags, so this gets its own queryable kind instead of a generic
|
|
1524
|
+
// `WorkerCrashed`. The signature is deliberately structural (no output + a
|
|
1525
|
+
// non-zero exit) rather than a stderr text match: the enterprise fatal shape
|
|
1526
|
+
// has never been measured on a host available to this repository (inventory
|
|
1527
|
+
// Finding 6), and a guessed marker would be a fabricated diagnosis. Nothing
|
|
1528
|
+
// here retries without the strict flags — a fallback would silently restore
|
|
1529
|
+
// full inheritance on exactly the hosts with the strictest policy.
|
|
1530
|
+
//
|
|
1531
|
+
// A worker that produced output but no init event is NOT this case; it is the
|
|
1532
|
+
// advisory warning below.
|
|
1533
|
+
if (surfaceVerdict.kind === "absent" &&
|
|
1534
|
+
procResult.classification === "crashed" &&
|
|
1535
|
+
procResult.stdoutExcerpt.trim().length === 0) {
|
|
1536
|
+
await finalizeRegistry();
|
|
1537
|
+
await httpClient.fail(job, {
|
|
1538
|
+
error_kind: WorkerStartupFatal,
|
|
1539
|
+
error_message: WORKER_STARTUP_FATAL_MESSAGE,
|
|
1540
|
+
classification: "crashed",
|
|
1541
|
+
...commitResidue(finalTelemetry),
|
|
1542
|
+
telemetry: observation.snapshot(),
|
|
1543
|
+
});
|
|
1544
|
+
return { status: "failed", reason: "worker_startup_fatal" };
|
|
1545
|
+
}
|
|
1546
|
+
if (surfaceVerdict.kind === "absent") {
|
|
1547
|
+
// Warn, never fail. The CLI's event shape is not ours to pin, and an
|
|
1548
|
+
// observability gap must not become a new way for jobs to die — that is the
|
|
1549
|
+
// opposite trade from the mismatch above, and both are deliberate.
|
|
1550
|
+
//
|
|
1551
|
+
// The two reasons are reported distinctly because they point at different
|
|
1552
|
+
// things to go look at: `no-init-event` at the worker/CLI, `oversized-line`
|
|
1553
|
+
// at this executor's own buffering bound.
|
|
1554
|
+
deps.errorLog(surfaceVerdict.reason === "oversized-line"
|
|
1555
|
+
? "worker MCP surface could not be verified: a stream-json line exceeded the observer's " +
|
|
1556
|
+
"buffering bound and was discarded, so a system/init event may have been missed; " +
|
|
1557
|
+
"the job continues, but containment was not confirmed for this spawn"
|
|
1558
|
+
: "worker MCP surface could not be verified: no system/init event was observed in the " +
|
|
1559
|
+
"worker's stream-json output; the job continues, but containment was not confirmed " +
|
|
1560
|
+
"for this spawn");
|
|
1561
|
+
}
|
|
1229
1562
|
const git = observation.git();
|
|
1230
1563
|
if (procResult.classification === "clean_exit") {
|
|
1231
1564
|
// Verdict jobs (`spec_review`) forward the fixed-path artifact verbatim; a
|
|
@@ -1344,12 +1677,30 @@ async function runSpawnJob(job, httpClient, options, deps, ownership, observatio
|
|
|
1344
1677
|
return terminalToRunResult(terminal, "completed");
|
|
1345
1678
|
}
|
|
1346
1679
|
// crashed / timeout / killed → fail
|
|
1680
|
+
//
|
|
1681
|
+
// BAPI-791: a recognized agent authentication failure is checked FIRST and,
|
|
1682
|
+
// when matched, wins over the generic classification below — it is reported as
|
|
1683
|
+
// `ContractError.ClaudeNotAuthenticated` instead of a generic `WorkerCrashed`,
|
|
1684
|
+
// even though the underlying process classification is still `crashed`. There
|
|
1685
|
+
// is no separate probe spawn and no retry: this is the SAME real worker spawn
|
|
1686
|
+
// that just ran, classified after the fact from its own captured output.
|
|
1687
|
+
//
|
|
1688
|
+
// BAPI-781: DETECTION is the adapter's (it recognizes its own runtime's output
|
|
1689
|
+
// shape); the ERROR KIND and the operator-facing message stay executor-owned,
|
|
1690
|
+
// so an adapter cannot invent its own failure vocabulary. The adapter returns
|
|
1691
|
+
// a verdict and a reason code only — never the matched line — so nothing from
|
|
1692
|
+
// the worker's stream reaches `/fail` through this path.
|
|
1347
1693
|
await finalizeRegistry();
|
|
1694
|
+
const authDetection = adapter.authFailureDetection;
|
|
1695
|
+
const authFailure = authDetection?.supported === true &&
|
|
1696
|
+
authDetection.value.classify(procResult.stdoutExcerpt).notAuthenticated;
|
|
1348
1697
|
const terminal = await sendTerminalMutationWithRetry({
|
|
1349
1698
|
kind: "fail",
|
|
1350
1699
|
send: () => httpClient.fail(job, {
|
|
1351
|
-
error_kind: classificationToErrorKind(procResult.classification),
|
|
1352
|
-
error_message:
|
|
1700
|
+
error_kind: authFailure ? ClaudeNotAuthenticated : classificationToErrorKind(procResult.classification),
|
|
1701
|
+
error_message: authFailure
|
|
1702
|
+
? CLAUDE_NOT_AUTHENTICATED_MESSAGE
|
|
1703
|
+
: `worker exited (${procResult.classification}, exit_code=${procResult.exitCode})`,
|
|
1353
1704
|
classification: toWireClassification(procResult.classification),
|
|
1354
1705
|
...commitResidue(git),
|
|
1355
1706
|
telemetry: observation.snapshot(),
|