@bridge_gpt/mcp-server 0.2.41 → 0.2.43
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 +330 -191
- package/build/agent-capabilities/cli.js +2 -1
- package/build/agent-launchers/claude-executor-adapter.js +17 -4
- package/build/agents.generated.js +2 -2
- package/build/claude-review-workflow.js +510 -45
- package/build/claude-user-config-doctor.js +42 -11
- package/build/cli-release.js +2 -1
- package/build/commands.generated.js +6 -5
- package/build/conduct-epic/bridge-client.js +354 -113
- package/build/conduct-epic/checkpoint-store.js +17 -0
- package/build/conduct-epic/cli.js +947 -99
- package/build/conduct-epic/cut-protocol.js +327 -0
- package/build/conduct-epic/spawn.js +14 -2
- package/build/conductor/bridge-api-client.js +148 -1
- package/build/conductor/cli.js +109 -1
- package/build/conductor/doctor.js +101 -16
- package/build/conductor/epic-reconcile.js +72 -19
- package/build/conductor/epic-runtime.js +15 -3
- package/build/conductor/errors.js +47 -0
- package/build/conductor/git-hooks.js +205 -11
- package/build/conductor/install-doctor.js +230 -1
- package/build/conductor/local-merge.js +130 -28
- package/build/conductor/recovery-cli.js +313 -0
- package/build/conductor/recovery-operations.js +219 -0
- package/build/conductor/tools.js +32 -3
- package/build/conductor/worker-ledger-cli.js +27 -1
- package/build/conductor-bin.js +20 -16
- package/build/credentials-cli.js +3 -2
- package/build/docs.generated.js +2 -1
- package/build/doctor.js +120 -44
- package/build/drive-epic.js +375 -0
- package/build/executor/cli.js +48 -1
- package/build/executor/env.js +21 -0
- package/build/executor/http-client.js +71 -3
- package/build/executor/index-scope.js +39 -0
- package/build/executor/job-errors.js +9 -0
- package/build/executor/job-log-registry.js +69 -0
- package/build/executor/job-runner.js +198 -29
- package/build/executor/live-worker-registry.js +83 -0
- package/build/executor/observation.js +259 -6
- package/build/executor/platform.js +147 -3
- package/build/executor/process.js +58 -14
- package/build/executor/runner.js +454 -48
- package/build/executor/test-clock.js +3 -2
- package/build/executor/worker-finalization.js +233 -56
- package/build/executor/worktree.js +8 -1
- package/build/index-scope-contract.js +96 -0
- package/build/index.js +2277 -270
- package/build/init.js +83 -22
- package/build/install-bridge-conductor.js +323 -14
- package/build/install-bridge.js +225 -47
- package/build/install-doctor.js +23 -9
- package/build/install-reexec.js +2 -1
- package/build/launcher-config-inspection.js +83 -22
- package/build/mcp-host-config.js +331 -67
- package/build/mcp-host-targets.js +45 -21
- package/build/mcp-identity.js +92 -0
- package/build/mcp-install-state.js +94 -1
- package/build/mcp-invoke.js +2 -1
- package/build/mcp-provisioning.js +45 -12
- package/build/mcp-registration-doctor.js +35 -13
- package/build/mcp-server-invocation.js +4 -2
- package/build/merge-pull-request.js +208 -9
- package/build/pipelines.generated.js +305 -15
- package/build/plane/cli.js +73 -7
- package/build/plane/defaults.js +18 -5
- package/build/plane/manifest.js +90 -0
- package/build/plane/preflight.js +100 -10
- package/build/plane/shutdown.js +71 -3
- package/build/plane/test-fakes.js +9 -1
- package/build/readme.generated.js +1 -1
- package/build/regression-check.js +3 -2
- package/build/review-tickets.js +8 -7
- package/build/run-unit-tests-launcher.js +149 -6
- package/build/schedule-run.js +3 -2
- package/build/setup-epic.js +531 -82
- package/build/sfcc/tool-wrapper.js +15 -0
- package/build/start-tickets-prereqs.js +11 -6
- package/build/start-tickets.js +91 -85
- package/build/update-check.js +3 -2
- package/build/upgrade-advice.js +2 -1
- package/build/upgrade-cli.js +50 -18
- package/build/version.generated.js +2 -1
- package/build/worktree-core.js +31 -17
- package/docs/CONDUCTOR.md +22 -0
- package/docs/install/mcp-tool-integrations.md +19 -3
- package/package.json +2 -2
- package/pipelines/greenfield-setup.json +286 -0
|
@@ -82,6 +82,75 @@ export async function markExecutorJobLogFinished(jobId, finishedAt, deps) {
|
|
|
82
82
|
const filePath = jobLogRecordPath(stateDir, jobId, deps.platform ?? process.platform);
|
|
83
83
|
await deps.writeFile(filePath, JSON.stringify(normalizeRecord(updated), null, 2));
|
|
84
84
|
}
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// Active worker logs (BAPI-828)
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
/**
|
|
89
|
+
* In-memory map of job id → worker-log path for jobs whose tee is currently open.
|
|
90
|
+
*
|
|
91
|
+
* DELIBERATELY SEPARATE FROM THE PERSISTED RECORD ABOVE, and deliberately not
|
|
92
|
+
* derived from it. The persisted record is a visibility nicety for a SEPARATE
|
|
93
|
+
* `executor watch` process, it is written with best-effort error swallowing, and
|
|
94
|
+
* it keeps its row after `finished_at` is stamped. This map answers a different
|
|
95
|
+
* question — "which worker logs can be appended to right now, in THIS process" —
|
|
96
|
+
* and the suspend diagnostic depends on that answer being correct. Deriving it
|
|
97
|
+
* from the persisted record would mean a failed registry write silently disables
|
|
98
|
+
* suspend annotation for a worker whose tee is perfectly healthy.
|
|
99
|
+
*
|
|
100
|
+
* Process-scoped module state rather than runner-scoped, unlike the live worker
|
|
101
|
+
* registry: entries are keyed by server-minted job id, so two concurrent
|
|
102
|
+
* `runExecutor` invocations in one process cannot collide, and each value is a
|
|
103
|
+
* path only its own job ever deactivates.
|
|
104
|
+
*
|
|
105
|
+
* SECRET-FREE: a filesystem path inside the job's own worktree, nothing else.
|
|
106
|
+
*/
|
|
107
|
+
const activeJobLogPaths = new Map();
|
|
108
|
+
/**
|
|
109
|
+
* Mark a job's worker log as open and appendable.
|
|
110
|
+
*
|
|
111
|
+
* Called once the tee EXISTS, before any registry persistence — see the map's
|
|
112
|
+
* docstring for why the two are not chained. Re-activating the same job id simply
|
|
113
|
+
* overwrites the path.
|
|
114
|
+
*/
|
|
115
|
+
export function activateJobLog(jobId, logPath) {
|
|
116
|
+
if (typeof logPath !== "string" || logPath.length === 0)
|
|
117
|
+
return;
|
|
118
|
+
activeJobLogPaths.set(jobId, logPath);
|
|
119
|
+
}
|
|
120
|
+
/** Stop appending to a job's worker log. Idempotent. */
|
|
121
|
+
export function deactivateJobLog(jobId) {
|
|
122
|
+
activeJobLogPaths.delete(jobId);
|
|
123
|
+
}
|
|
124
|
+
/** The distinct currently-appendable log paths (a snapshot, safe to iterate). */
|
|
125
|
+
export function activeJobLogPathsSnapshot() {
|
|
126
|
+
return [...new Set(activeJobLogPaths.values())];
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Append one diagnostic line to every currently active worker log.
|
|
130
|
+
*
|
|
131
|
+
* FAIL-OPEN PER PATH. Each append is contained independently, so one missing,
|
|
132
|
+
* deleted, or unwritable log cannot suppress the diagnostic for the others and
|
|
133
|
+
* cannot propagate an error into the executor's poll loop — this is annotation,
|
|
134
|
+
* never correctness.
|
|
135
|
+
*
|
|
136
|
+
* Paths are DEDUPLICATED (two jobs can legitimately name the same log during a
|
|
137
|
+
* re-entry window) and exactly one trailing newline is written, whether or not
|
|
138
|
+
* the caller supplied one.
|
|
139
|
+
*/
|
|
140
|
+
export async function appendToActiveJobLogs(line, deps) {
|
|
141
|
+
const paths = activeJobLogPathsSnapshot();
|
|
142
|
+
if (paths.length === 0)
|
|
143
|
+
return;
|
|
144
|
+
const payload = `${line.replace(/\n+$/, "")}\n`;
|
|
145
|
+
await Promise.all(paths.map(async (logPath) => {
|
|
146
|
+
try {
|
|
147
|
+
await deps.appendFile(logPath, payload);
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
/* one unwritable log never blocks the others, or the executor */
|
|
151
|
+
}
|
|
152
|
+
}));
|
|
153
|
+
}
|
|
85
154
|
/**
|
|
86
155
|
* Load and validate a record for `executor watch <job>`. Returns a structured
|
|
87
156
|
* missing/invalid result rather than throwing raw filesystem/JSON errors.
|
|
@@ -24,7 +24,7 @@ import { getWorktreeMcpRegistrationTargets, normalizeWorktreePathForRegistration
|
|
|
24
24
|
import { resolveExecutorAgentAdapter } from "../agent-launchers/executor-adapter-registry.js";
|
|
25
25
|
import { redactAdapterDiagnostic } from "../agent-launchers/executor-adapter.js";
|
|
26
26
|
import { resolveExecutorAgentId } from "./agent-identity.js";
|
|
27
|
-
import { runProcessWithTimeout } from "./process.js";
|
|
27
|
+
import { createProcessTerminationController, runProcessWithTimeout } from "./process.js";
|
|
28
28
|
import { prepareResumeSpawn } from "./resume-pre-spawn.js";
|
|
29
29
|
import { renderPromptSpecPrompt } from "./prompt-spec.js";
|
|
30
30
|
import { buildGenericSuccessResult, buildSmokeResult, commitResidue, readCompletionArtifacts, resolveJobTimeoutSeconds, } from "./results.js";
|
|
@@ -33,15 +33,17 @@ import { removeStaleVerdictArtifacts } from "./stale-artifacts.js";
|
|
|
33
33
|
import { isVerdictJobType, readVerdictArtifact } from "./verdict-artifact.js";
|
|
34
34
|
import { createWorkerLogTee, closeWorkerLogTee, distinctWorkerLogSessionIds, formatWorkerLogSessionInvariantWarning, teeAsyncIterable, } from "./worker-log.js";
|
|
35
35
|
import { acquireExecutorWorktreeLock, } from "./worktree-lock.js";
|
|
36
|
-
import { registerExecutorJobLog, markExecutorJobLogFinished, } from "./job-log-registry.js";
|
|
36
|
+
import { activateJobLog, deactivateJobLog, registerExecutorJobLog, markExecutorJobLogFinished, } from "./job-log-registry.js";
|
|
37
37
|
import { executorViewerTabsEnabled, openExecutorViewerTab } from "./viewer-tabs.js";
|
|
38
38
|
import { isImplementationStyleJobType, isRecoveryJobType, isSpawnJobType } from "./job-types.js";
|
|
39
39
|
import { resolveExecutorJobBaseBranch } from "./base-branch.js";
|
|
40
|
+
import { resolveExecutorJobIndexScope } from "./index-scope.js";
|
|
40
41
|
import { validateWorkerFinalization } from "./worker-finalization.js";
|
|
41
42
|
import { ensureExecutorWorktree } from "./worktree.js";
|
|
42
43
|
import { buildPrBaseContractLaunchInstruction } from "../pr-base-contract.js";
|
|
43
44
|
import { resolveExecutorPrompt, resolveWorkerPermissionPosture, } from "./worker-command.js";
|
|
44
|
-
import { collectGitTelemetry, collectRemoteTrackingSha, createMcpSurfaceObserver, evaluatePreSpawnGitVerification, normalizeMcpServerNames, } from "./observation.js";
|
|
45
|
+
import { collectGitTelemetry, collectRemoteTrackingSha, createMcpSurfaceObserver, evaluatePreSpawnGitVerification, formatWorkerRateLimitAdvisory, normalizeMcpServerNames, } from "./observation.js";
|
|
46
|
+
import { isRecognizedServerName, MCP_SERVER_NAME } from "../mcp-identity.js";
|
|
45
47
|
/** Default runtime for the no-op smoke process (ms). */
|
|
46
48
|
const DEFAULT_SMOKE_DURATION_MS = 100;
|
|
47
49
|
/** ProcessClassification maps 1:1 onto the wire ExecutorClassification. */
|
|
@@ -249,7 +251,7 @@ async function provisionMcpForPreparedSpawn(job, branch, worktreePath, deps, sea
|
|
|
249
251
|
* Only server NAMES are read. The command, args, and env of each registration are
|
|
250
252
|
* never retained, so nothing that could carry a secret leaves this function.
|
|
251
253
|
*/
|
|
252
|
-
export async function verifyRequiredWorktreeMcpRegistration(worktreePath, deps, requiredServerName =
|
|
254
|
+
export async function verifyRequiredWorktreeMcpRegistration(worktreePath, deps, requiredServerName = MCP_SERVER_NAME) {
|
|
253
255
|
// Normalize through the SAME path semantics the provisioner used to write the
|
|
254
256
|
// file, so the absolute path handed to `--mcp-config` is the one that actually
|
|
255
257
|
// exists on disk rather than a differently-spelled equivalent.
|
|
@@ -275,7 +277,17 @@ export async function verifyRequiredWorktreeMcpRegistration(worktreePath, deps,
|
|
|
275
277
|
return { ok: false, message: REQUIRED_MCP_REGISTRATION_MISSING_MESSAGE };
|
|
276
278
|
}
|
|
277
279
|
const names = normalizeMcpServerNames(Object.keys(servers));
|
|
278
|
-
|
|
280
|
+
// BAPI-807: when the required name is Bridge's OWN registration, a
|
|
281
|
+
// permanently-supported legacy key satisfies it. A worktree provisioned
|
|
282
|
+
// before the rename (or re-entered from one) registers `bridge-api`, and
|
|
283
|
+
// refusing to spawn into it would turn the rename into an outage for exactly
|
|
284
|
+
// the users the legacy compatibility exists to protect. A non-Bridge
|
|
285
|
+
// `requiredServerName` (a future adapter's own runtime) is matched exactly,
|
|
286
|
+
// as before.
|
|
287
|
+
const satisfied = requiredServerName === MCP_SERVER_NAME
|
|
288
|
+
? names.some((name) => isRecognizedServerName(name))
|
|
289
|
+
: names.includes(requiredServerName);
|
|
290
|
+
if (!satisfied) {
|
|
279
291
|
return { ok: false, message: REQUIRED_MCP_REGISTRATION_MISSING_MESSAGE };
|
|
280
292
|
}
|
|
281
293
|
return {
|
|
@@ -289,12 +301,6 @@ export async function verifyRequiredWorktreeMcpRegistration(worktreePath, deps,
|
|
|
289
301
|
return { ok: false, message: REQUIRED_MCP_REGISTRATION_MISSING_MESSAGE };
|
|
290
302
|
}
|
|
291
303
|
}
|
|
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
|
-
*/
|
|
297
|
-
const REQUIRED_MCP_SERVER_NAME = "bridge-api";
|
|
298
304
|
/**
|
|
299
305
|
* Refuse a spawn shape that is not internally consistent (BAPI-781).
|
|
300
306
|
*
|
|
@@ -336,7 +342,7 @@ function assertUsableSpawnShape(shape, adapter) {
|
|
|
336
342
|
}
|
|
337
343
|
}
|
|
338
344
|
/** Fixed, secret-free refusal text (no path, no file contents). */
|
|
339
|
-
const REQUIRED_MCP_REGISTRATION_MISSING_MESSAGE =
|
|
345
|
+
const REQUIRED_MCP_REGISTRATION_MISSING_MESSAGE = `the required '${MCP_SERVER_NAME}' MCP registration is missing or unreadable in the prepared ` +
|
|
340
346
|
"worktree; a worker loads MCP servers strictly from that file, so refusing to spawn a " +
|
|
341
347
|
"worker that would run without Bridge tools";
|
|
342
348
|
/**
|
|
@@ -471,6 +477,9 @@ async function superviseProcess(params) {
|
|
|
471
477
|
const mcpSurface = params.mcpSurface;
|
|
472
478
|
const result = await runProcessWithTimeout(params.proc, params.timeoutSeconds, params.deps, {
|
|
473
479
|
termGraceMs: params.options.termGraceMs,
|
|
480
|
+
...(params.terminationController
|
|
481
|
+
? { terminationController: params.terminationController }
|
|
482
|
+
: {}),
|
|
474
483
|
onStdout: (chunk) => params.observation.recordStdout(chunk),
|
|
475
484
|
...(mcpSurface
|
|
476
485
|
? {
|
|
@@ -503,12 +512,33 @@ function terminalToRunResult(terminal, successStatus) {
|
|
|
503
512
|
}
|
|
504
513
|
}
|
|
505
514
|
/** Dispatch and run a single claimed job. */
|
|
506
|
-
export async function runClaimedJob(job, httpClient, options, deps, _report, seams = {}
|
|
515
|
+
export async function runClaimedJob(job, httpClient, options, deps, _report, seams = {},
|
|
516
|
+
/**
|
|
517
|
+
* BAPI-828: the runner-scoped control object, carrying the live worker
|
|
518
|
+
* registry this job's worker registers with.
|
|
519
|
+
*
|
|
520
|
+
* A SEVENTH positional parameter, appended after the existing seams argument so
|
|
521
|
+
* every current caller — the runner's default `runJob`, the many tests that
|
|
522
|
+
* invoke this directly with four to six arguments — keeps compiling unchanged.
|
|
523
|
+
* Absent means no executor-level shutdown control, which is exactly right for a
|
|
524
|
+
* caller that has no executor loop around it.
|
|
525
|
+
*/
|
|
526
|
+
control) {
|
|
507
527
|
const ownership = {
|
|
508
528
|
abandoned: false,
|
|
509
529
|
lastSuccessfulHeartbeatAt: deps.now(),
|
|
510
530
|
};
|
|
511
|
-
const observation = createObservationState({ now: deps.now }, {
|
|
531
|
+
const observation = createObservationState({ now: deps.now }, {
|
|
532
|
+
advisoryParserEnabled: options.advisoryParserEnabled,
|
|
533
|
+
// BAPI-828: ONE line per job, from the per-job observation's first-wins
|
|
534
|
+
// retention rather than from a counter here. A worker nearing a weekly
|
|
535
|
+
// ceiling repeats the same event on every turn, and the operator needs the
|
|
536
|
+
// fact once. Emitting is all this does — no claim, retry, lease, or
|
|
537
|
+
// classification decision reads it.
|
|
538
|
+
onWorkerRateLimitAdvisory: (advisory) => {
|
|
539
|
+
deps.errorLog(formatWorkerRateLimitAdvisory(advisory));
|
|
540
|
+
},
|
|
541
|
+
});
|
|
512
542
|
// `merge` is a deterministic executor action: dispatched BEFORE any worktree
|
|
513
543
|
// ensure/recreate or worker-spawn logic — it ensures no worktree and spawns no
|
|
514
544
|
// worker (TDD §5/§9).
|
|
@@ -530,7 +560,7 @@ export async function runClaimedJob(job, httpClient, options, deps, _report, sea
|
|
|
530
560
|
// are stateless with respect to the host, which is why the whole
|
|
531
561
|
// establish/dispose lifetime this branch used to manage simply stopped
|
|
532
562
|
// existing rather than moving somewhere else.
|
|
533
|
-
return await runSpawnJob(job, httpClient, options, deps, ownership, observation, seams);
|
|
563
|
+
return await runSpawnJob(job, httpClient, options, deps, ownership, observation, seams, control);
|
|
534
564
|
}
|
|
535
565
|
// Unsupported job type — fail server-side; never spawn.
|
|
536
566
|
await httpClient.fail(job, {
|
|
@@ -787,6 +817,35 @@ async function runSmokeJob(job, httpClient, options, deps, ownership, observatio
|
|
|
787
817
|
});
|
|
788
818
|
return terminalToRunResult(terminal, "completed");
|
|
789
819
|
}
|
|
820
|
+
/**
|
|
821
|
+
* BAPI-862: the payload key the reconciler stamps with the implement gate's last
|
|
822
|
+
* observation reason code. Server-minted and opaque to the executor beyond the
|
|
823
|
+
* closed set below — the executor never derives, defaults, or infers it.
|
|
824
|
+
*/
|
|
825
|
+
const IMPLEMENT_LAST_OBSERVATION_KEY = "implement_last_observation_reason";
|
|
826
|
+
/**
|
|
827
|
+
* The one observation reason code that changes the stale-branch guard's advice.
|
|
828
|
+
* Matches `PR_ATTACH_PENDING_REASON_CODE` in the reconciler; the two strings are
|
|
829
|
+
* one vocabulary on purpose, so an operator greps a single term across the job
|
|
830
|
+
* row, the gate observation, and the executor refusal.
|
|
831
|
+
*/
|
|
832
|
+
const PR_NOT_ATTACHED_REASON_CODE = "pr_not_attached";
|
|
833
|
+
/**
|
|
834
|
+
* Map the job payload's reconciliation observation onto the guard's narrow
|
|
835
|
+
* classification. Anything absent, malformed, or unrecognized degrades to
|
|
836
|
+
* `"unknown"` — the pre-BAPI-862 delete/rebase guidance. That direction is
|
|
837
|
+
* deliberate: the softened message asserts the branch holds finished work, and
|
|
838
|
+
* asserting that on a guess would talk an operator OUT of cleaning up a genuinely
|
|
839
|
+
* stale branch.
|
|
840
|
+
*/
|
|
841
|
+
function resolveStaleBranchClassification(payload) {
|
|
842
|
+
if (!payload || typeof payload !== "object")
|
|
843
|
+
return "unknown";
|
|
844
|
+
const raw = payload[IMPLEMENT_LAST_OBSERVATION_KEY];
|
|
845
|
+
if (typeof raw !== "string")
|
|
846
|
+
return "unknown";
|
|
847
|
+
return raw.trim() === PR_NOT_ATTACHED_REASON_CODE ? "succeeded_pr_not_attached" : "unknown";
|
|
848
|
+
}
|
|
790
849
|
async function prepareSpawn(job, httpClient, options, deps, seams) {
|
|
791
850
|
if (job.job_type === "resume") {
|
|
792
851
|
const prepareResume = seams.prepareResumeSpawn ?? prepareResumeSpawn;
|
|
@@ -853,7 +912,14 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
|
|
|
853
912
|
// `spec_review` stay fresh-off-base with the guard ON. `resume` never reaches
|
|
854
913
|
// here — it has its own preservation protocol above.
|
|
855
914
|
const reuseExistingBranch = isRecoveryJobType(job.job_type);
|
|
856
|
-
const wt = await ensureWorktree(job, options, deps, {
|
|
915
|
+
const wt = await ensureWorktree(job, options, deps, {
|
|
916
|
+
reuseExistingBranch,
|
|
917
|
+
// BAPI-862: hand the guard the reconciler's own reading of why this branch
|
|
918
|
+
// may already carry commits. Read from the SERVER-minted payload field and
|
|
919
|
+
// validated against a closed set — never inferred locally, and never taken
|
|
920
|
+
// from worker output (R14 rules 3 and 4).
|
|
921
|
+
staleBranchClassification: resolveStaleBranchClassification(job.payload),
|
|
922
|
+
});
|
|
857
923
|
if (!wt.ok) {
|
|
858
924
|
await httpClient.fail(job, {
|
|
859
925
|
error_kind: "WorktreeError",
|
|
@@ -915,7 +981,7 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
|
|
|
915
981
|
* returns BEFORE either the deny layer or MCP provisioning runs — neither ever
|
|
916
982
|
* touches a worktree that will not become a runnable worker.
|
|
917
983
|
*/
|
|
918
|
-
async function runSpawnJob(job, httpClient, options, deps, ownership, observation, seams) {
|
|
984
|
+
async function runSpawnJob(job, httpClient, options, deps, ownership, observation, seams, control) {
|
|
919
985
|
// --- Per-job base branch (BAPI-586) ----------------------------------
|
|
920
986
|
// Resolve the effective logical base BEFORE any side effect: the persisted
|
|
921
987
|
// run base (`payload.base_branch`) is authoritative, falling back to the
|
|
@@ -971,6 +1037,23 @@ async function runSpawnJob(job, httpClient, options, deps, ownership, observatio
|
|
|
971
1037
|
}
|
|
972
1038
|
const effectiveBaseBranch = baseResolution.baseBranch;
|
|
973
1039
|
const jobOptions = { ...options, baseBranch: effectiveBaseBranch };
|
|
1040
|
+
// --- Declared index scope (BAPI-844) ---------------------------------
|
|
1041
|
+
// Resolved here, alongside the base branch and for the same reason: a job that
|
|
1042
|
+
// declares a scope it cannot be routed by must fail before a worktree is cut, a
|
|
1043
|
+
// deny layer is written, MCP is provisioned, a log is opened, or a process is
|
|
1044
|
+
// spawned. An ABSENT declaration is the ordinary unscoped job and adds nothing
|
|
1045
|
+
// anywhere downstream. The error text names the field only — the declared value
|
|
1046
|
+
// is opaque and never reaches `/fail`.
|
|
1047
|
+
const indexScopeResolution = resolveExecutorJobIndexScope(job);
|
|
1048
|
+
if (!indexScopeResolution.ok) {
|
|
1049
|
+
await httpClient.fail(job, {
|
|
1050
|
+
error_kind: "ContractError.IndexScope",
|
|
1051
|
+
error_message: indexScopeResolution.error,
|
|
1052
|
+
classification: "crashed",
|
|
1053
|
+
});
|
|
1054
|
+
return { status: "failed", reason: "index_scope_contract" };
|
|
1055
|
+
}
|
|
1056
|
+
const indexScope = indexScopeResolution.indexScope;
|
|
974
1057
|
// --- Permission posture (BAPI-725) -----------------------------------
|
|
975
1058
|
// Resolved alongside the base branch, BEFORE any side effect, for the same
|
|
976
1059
|
// reason: an unsupported posture is a contract failure, and failing it here
|
|
@@ -1032,6 +1115,12 @@ async function runSpawnJob(job, httpClient, options, deps, ownership, observatio
|
|
|
1032
1115
|
});
|
|
1033
1116
|
return { status: "failed", reason: "worktree_busy" };
|
|
1034
1117
|
}
|
|
1118
|
+
// BAPI-828: the post-spawn lifecycle handles this job's worker owns, filled in
|
|
1119
|
+
// by `runPreparedSpawn` as each resource comes into existence and unwound in
|
|
1120
|
+
// the `finally` below. A shared mutable record rather than return values,
|
|
1121
|
+
// because the resources must be released even when `runPreparedSpawn` throws —
|
|
1122
|
+
// which is exactly the path that has no return value to inspect.
|
|
1123
|
+
const spawnLifecycle = {};
|
|
1035
1124
|
try {
|
|
1036
1125
|
// --- Stale verdict artifacts (BAPI-814/D1) --------------------------
|
|
1037
1126
|
// Inside the lock and BEFORE the worker spawns, on the SHARED attempt path,
|
|
@@ -1078,14 +1167,31 @@ async function runSpawnJob(job, httpClient, options, deps, ownership, observatio
|
|
|
1078
1167
|
branch,
|
|
1079
1168
|
prep,
|
|
1080
1169
|
effectiveBaseBranch,
|
|
1170
|
+
indexScope,
|
|
1081
1171
|
permissionPosture,
|
|
1082
1172
|
adapter,
|
|
1173
|
+
control,
|
|
1174
|
+
lifecycle: spawnLifecycle,
|
|
1083
1175
|
});
|
|
1084
1176
|
}
|
|
1085
1177
|
finally {
|
|
1086
|
-
//
|
|
1087
|
-
//
|
|
1088
|
-
//
|
|
1178
|
+
// BAPI-828 — cleanup ORDER is the contract, and it runs strictly after
|
|
1179
|
+
// `runPreparedSpawn` has settled (normally or by throwing), which means
|
|
1180
|
+
// strictly after process supervision has settled:
|
|
1181
|
+
//
|
|
1182
|
+
// 1. Deregister the worker. It is no longer live, so a shutdown arriving
|
|
1183
|
+
// now must not try to signal it — the registry should only ever hold
|
|
1184
|
+
// children that might still be running.
|
|
1185
|
+
// 2. Close and deactivate the worker log. Idempotent: the terminal branches
|
|
1186
|
+
// inside `runPreparedSpawn` already close it on their way out, and this
|
|
1187
|
+
// is the backstop for the exceptional paths that do not.
|
|
1188
|
+
// 3. Release the worktree lock — LAST, and still through the same
|
|
1189
|
+
// ownership-checked `release()` as before this ticket. The worktree must
|
|
1190
|
+
// never be advertised as free while this job's worker could still be
|
|
1191
|
+
// touching it, and the two steps above are what establish that it cannot.
|
|
1192
|
+
spawnLifecycle.deregisterLiveWorker?.();
|
|
1193
|
+
spawnLifecycle.deregisterLiveWorker = undefined;
|
|
1194
|
+
await spawnLifecycle.closeWorkerLog?.();
|
|
1089
1195
|
await worktreeLock.release();
|
|
1090
1196
|
}
|
|
1091
1197
|
}
|
|
@@ -1100,7 +1206,7 @@ async function runSpawnJob(job, httpClient, options, deps, ownership, observatio
|
|
|
1100
1206
|
* rather than a 500-line indentation nobody can verify by eye.
|
|
1101
1207
|
*/
|
|
1102
1208
|
async function runPreparedSpawn(params) {
|
|
1103
|
-
const { job, httpClient, options, jobOptions, deps, ownership, observation, seams, worktreePath, branch, prep, effectiveBaseBranch, permissionPosture, adapter, } = params;
|
|
1209
|
+
const { job, httpClient, options, jobOptions, deps, ownership, observation, seams, worktreePath, branch, prep, effectiveBaseBranch, indexScope, permissionPosture, adapter, control, lifecycle, } = params;
|
|
1104
1210
|
// BAPI-586: PR-producing spawn jobs (implement/resume/remediate/ci_fix/rebase)
|
|
1105
1211
|
// are told to open the PR against the injected run base. Verdict-only
|
|
1106
1212
|
// `spec_review` produces no PR, so it is excluded from the instruction while
|
|
@@ -1247,7 +1353,31 @@ async function runPreparedSpawn(params) {
|
|
|
1247
1353
|
catch {
|
|
1248
1354
|
tee = null;
|
|
1249
1355
|
}
|
|
1356
|
+
// BAPI-828 — idempotent worker-log closure, defined the moment a tee could
|
|
1357
|
+
// exist. Every terminal branch below calls this instead of `closeWorkerLogTee`
|
|
1358
|
+
// directly, and `runSpawnJob`'s `finally` calls it once more as a backstop for
|
|
1359
|
+
// the paths that throw. Before this ticket the close was open-coded at eleven
|
|
1360
|
+
// separate return sites, which is why the active-log deactivation is folded in
|
|
1361
|
+
// here rather than added as a twelfth thing each branch had to remember.
|
|
1362
|
+
let workerLogClosed = false;
|
|
1363
|
+
const closeWorkerLog = async () => {
|
|
1364
|
+
if (workerLogClosed)
|
|
1365
|
+
return;
|
|
1366
|
+
workerLogClosed = true;
|
|
1367
|
+
// Deactivate FIRST: once the tee is closing, an append from the suspend
|
|
1368
|
+
// diagnostic would be writing to a log nobody is reading any more.
|
|
1369
|
+
deactivateJobLog(job.id);
|
|
1370
|
+
await closeWorkerLogTee(tee);
|
|
1371
|
+
};
|
|
1372
|
+
lifecycle.closeWorkerLog = closeWorkerLog;
|
|
1250
1373
|
if (tee) {
|
|
1374
|
+
// BAPI-828 — mark the log appendable as soon as the tee EXISTS, and
|
|
1375
|
+
// deliberately BEFORE the watch-registry persistence below. The two are
|
|
1376
|
+
// independent on purpose: `registerLog` swallows its own failures (it is a
|
|
1377
|
+
// visibility nicety for a separate `executor watch` process), and chaining
|
|
1378
|
+
// the active marker to it would mean a failed registry write silently
|
|
1379
|
+
// disables suspend annotation for a worker whose tee is perfectly healthy.
|
|
1380
|
+
activateJobLog(job.id, tee.logPath);
|
|
1251
1381
|
const registerLog = seams.registerExecutorJobLog ?? registerExecutorJobLog;
|
|
1252
1382
|
try {
|
|
1253
1383
|
await registerLog({
|
|
@@ -1312,9 +1442,16 @@ async function runPreparedSpawn(params) {
|
|
|
1312
1442
|
//
|
|
1313
1443
|
// BAPI-586: the validated run base travels in as BAPI_BASE_BRANCH so the
|
|
1314
1444
|
// worker can target it via `gh pr create --base "$BAPI_BASE_BRANCH"`.
|
|
1445
|
+
//
|
|
1446
|
+
// BAPI-844: the validated declared index scope travels the same way, as an
|
|
1447
|
+
// explicit option rather than through the environment this process happens to
|
|
1448
|
+
// hold — the executor's own `BAPI_INDEX_SCOPE`, if an operator exported one,
|
|
1449
|
+
// is denied at the env builder and cannot reach `spawnShape.env`. It stays out
|
|
1450
|
+
// of `argv` entirely: it is a routing declaration, not a command-line input,
|
|
1451
|
+
// and argv is world-readable via `ps`.
|
|
1315
1452
|
const invocation = adapter.headlessInvocation;
|
|
1316
1453
|
if (invocation?.supported !== true) {
|
|
1317
|
-
await
|
|
1454
|
+
await closeWorkerLog();
|
|
1318
1455
|
await finalizeRegistry();
|
|
1319
1456
|
await httpClient.fail(job, {
|
|
1320
1457
|
error_kind: ExecutorAdapterUnavailable,
|
|
@@ -1332,6 +1469,7 @@ async function runPreparedSpawn(params) {
|
|
|
1332
1469
|
posture: permissionPosture,
|
|
1333
1470
|
mcpConfigPath: mcpContract.configPath,
|
|
1334
1471
|
effectiveBaseBranch,
|
|
1472
|
+
indexScope,
|
|
1335
1473
|
parentEnv: deps.env,
|
|
1336
1474
|
});
|
|
1337
1475
|
assertUsableSpawnShape(spawnShape, adapter);
|
|
@@ -1342,7 +1480,7 @@ async function runPreparedSpawn(params) {
|
|
|
1342
1480
|
// a missing argv from Claude's flags is precisely the defaulting this
|
|
1343
1481
|
// extraction removes, and it would spawn a real worker under a shape nobody
|
|
1344
1482
|
// declared.
|
|
1345
|
-
await
|
|
1483
|
+
await closeWorkerLog();
|
|
1346
1484
|
await finalizeRegistry();
|
|
1347
1485
|
await httpClient.fail(job, {
|
|
1348
1486
|
error_kind: ExecutorAdapterUnavailable,
|
|
@@ -1360,7 +1498,7 @@ async function runPreparedSpawn(params) {
|
|
|
1360
1498
|
// certify its own containment.
|
|
1361
1499
|
const initParsing = adapter.mcpInitParsing;
|
|
1362
1500
|
if (initParsing?.supported !== true) {
|
|
1363
|
-
await
|
|
1501
|
+
await closeWorkerLog();
|
|
1364
1502
|
await finalizeRegistry();
|
|
1365
1503
|
await httpClient.fail(job, {
|
|
1366
1504
|
error_kind: ExecutorAdapterUnavailable,
|
|
@@ -1378,7 +1516,7 @@ async function runPreparedSpawn(params) {
|
|
|
1378
1516
|
});
|
|
1379
1517
|
}
|
|
1380
1518
|
catch (err) {
|
|
1381
|
-
await
|
|
1519
|
+
await closeWorkerLog();
|
|
1382
1520
|
await finalizeRegistry();
|
|
1383
1521
|
// Redact through the adapter's declared secret names before bounding. A
|
|
1384
1522
|
// spawn error can echo the environment it failed to apply, and that
|
|
@@ -1394,6 +1532,22 @@ async function runPreparedSpawn(params) {
|
|
|
1394
1532
|
});
|
|
1395
1533
|
return { status: "failed", reason: "spawn_failed" };
|
|
1396
1534
|
}
|
|
1535
|
+
// --- Live worker registration (BAPI-828) ------------------------------
|
|
1536
|
+
// Immediately after the ONE successful `deps.spawnProcess` above, and strictly
|
|
1537
|
+
// before any asynchronous supervision begins. That ordering is the whole
|
|
1538
|
+
// guarantee: between the spawn returning and supervision starting there is real
|
|
1539
|
+
// async work (the tee wrapper, the telemetry closures), and a `SIGTERM` landing
|
|
1540
|
+
// in that window used to find a live child that nothing owned.
|
|
1541
|
+
//
|
|
1542
|
+
// The controller is created against the RAW `proc`, not the tee-wrapped view
|
|
1543
|
+
// below: signals go to the actual child, while the wrapper exists only to
|
|
1544
|
+
// duplicate its output.
|
|
1545
|
+
const terminationController = createProcessTerminationController(proc, deps, jobOptions.termGraceMs);
|
|
1546
|
+
// Registration may terminate the worker SYNCHRONOUSLY when shutdown was already
|
|
1547
|
+
// requested — the sticky case, where the signal arrived while this job was still
|
|
1548
|
+
// preparing its worktree. That is intended: the child is signalled here, and
|
|
1549
|
+
// supervision below still runs normally to observe its exit and report it.
|
|
1550
|
+
lifecycle.deregisterLiveWorker = control?.liveWorkers.register(terminationController);
|
|
1397
1551
|
// Tee stdout/stderr to the worker log WITHOUT changing what the runner sees.
|
|
1398
1552
|
const supervisedProc = tee
|
|
1399
1553
|
? {
|
|
@@ -1419,6 +1573,10 @@ async function runPreparedSpawn(params) {
|
|
|
1419
1573
|
collectTelemetry,
|
|
1420
1574
|
collectRemoteMarker,
|
|
1421
1575
|
mcpSurface,
|
|
1576
|
+
// The SAME controller the registry holds, so an executor shutdown, a timeout,
|
|
1577
|
+
// and an MCP containment refusal all converge on one `SIGTERM` and one grace
|
|
1578
|
+
// timer for this child (BAPI-828).
|
|
1579
|
+
terminationController,
|
|
1422
1580
|
});
|
|
1423
1581
|
// --- Adapter lifecycle (EXPLICIT, BAPI-781) ---------------------------
|
|
1424
1582
|
// Consumed rather than assumed. Claude declares `{ kind: "none" }` and so
|
|
@@ -1458,7 +1616,7 @@ async function runPreparedSpawn(params) {
|
|
|
1458
1616
|
// `killed`) would be actively wrong. Ownership is marked abandoned only AFTER
|
|
1459
1617
|
// the terminal mutation is attempted, so the report goes out under a live claim.
|
|
1460
1618
|
if (procResult.serverStopRequested) {
|
|
1461
|
-
await
|
|
1619
|
+
await closeWorkerLog();
|
|
1462
1620
|
await finalizeRegistry();
|
|
1463
1621
|
await httpClient.fail(job, {
|
|
1464
1622
|
error_kind: "ServerStopRequested",
|
|
@@ -1472,7 +1630,7 @@ async function runPreparedSpawn(params) {
|
|
|
1472
1630
|
return { status: "abandoned", reason: "server_stop" };
|
|
1473
1631
|
}
|
|
1474
1632
|
// Finalize the log tee WITHOUT signaling the worker (it has already exited).
|
|
1475
|
-
await
|
|
1633
|
+
await closeWorkerLog();
|
|
1476
1634
|
// --- Worker-log session invariant (ADVISORY, BAPI-793) ----------------
|
|
1477
1635
|
// "`.conductor/worker.log` for any completed job contains exactly one
|
|
1478
1636
|
// `session_id`" — the cheap, durable invariant from the ticket that would have
|
|
@@ -1621,7 +1779,7 @@ async function runPreparedSpawn(params) {
|
|
|
1621
1779
|
// the verdict-artifact path above stays limited to verdict jobs like
|
|
1622
1780
|
// `spec_review`. Recovery jobs complete through this same generic envelope.
|
|
1623
1781
|
const artifacts = await readCompletionArtifacts(worktreePath, deps);
|
|
1624
|
-
const
|
|
1782
|
+
const genericResult = buildGenericSuccessResult({
|
|
1625
1783
|
summary: `${job.job_type} ${job.ticket_key ?? ""} completed`.trim(),
|
|
1626
1784
|
branch,
|
|
1627
1785
|
headSha: git.last_commit_sha,
|
|
@@ -1635,7 +1793,7 @@ async function runPreparedSpawn(params) {
|
|
|
1635
1793
|
job,
|
|
1636
1794
|
branch,
|
|
1637
1795
|
worktreePath,
|
|
1638
|
-
result,
|
|
1796
|
+
result: genericResult,
|
|
1639
1797
|
runCommand: deps.runCommand,
|
|
1640
1798
|
headSha: git.last_commit_sha,
|
|
1641
1799
|
expectedBaseBranch: effectiveBaseBranch,
|
|
@@ -1656,6 +1814,17 @@ async function runPreparedSpawn(params) {
|
|
|
1656
1814
|
});
|
|
1657
1815
|
return terminalToRunResult(terminal, "failed");
|
|
1658
1816
|
}
|
|
1817
|
+
// BAPI-862: attach the pull request the EXECUTOR verified. `pr_url` was never
|
|
1818
|
+
// populated on this path before — `buildGenericSuccessResult` has no PR field
|
|
1819
|
+
// at all — which is why job 1397's stored `result.pr_url` was null even though
|
|
1820
|
+
// the reconciler's whole implement gate is waiting on that identity. The value
|
|
1821
|
+
// comes strictly from the authoritative `gh` observation finalization just
|
|
1822
|
+
// made; worker prose is never admissible here (R14 rule 3). It is omitted
|
|
1823
|
+
// rather than set to null when finalization had no PR to verify (a
|
|
1824
|
+
// non-implementation job), so the field's presence always means "verified".
|
|
1825
|
+
const result = typeof finalization.prUrl === "string"
|
|
1826
|
+
? { ...genericResult, pr_url: finalization.prUrl }
|
|
1827
|
+
: genericResult;
|
|
1659
1828
|
const completion = {
|
|
1660
1829
|
job_type: job.job_type,
|
|
1661
1830
|
exit_code: procResult.exitCode ?? 0,
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runner-scoped live worker registry (BAPI-828).
|
|
3
|
+
*
|
|
4
|
+
* WHAT THIS FIXES. Before this module the executor had no handle on its own
|
|
5
|
+
* children. `SIGTERM` to the executor killed the executor and left the Claude
|
|
6
|
+
* worker it had spawned running, still holding the worktree, still committing
|
|
7
|
+
* into it, with the worktree lock left behind by a process that no longer
|
|
8
|
+
* existed. The operator runbook's answer was to run `pkill -TERM -P <executor
|
|
9
|
+
* pid>` BEFORE stopping the executor — a manual step that is exactly as
|
|
10
|
+
* reliable as remembering it.
|
|
11
|
+
*
|
|
12
|
+
* SCOPE IS PER `runExecutor` INVOCATION, NOT MODULE-GLOBAL. A module-level set
|
|
13
|
+
* would outlive the invocation that filled it, so a second `runExecutor` call in
|
|
14
|
+
* the same process (the `--once` path, an embedded runner, a test) would inherit
|
|
15
|
+
* another run's children and could terminate them. The registry is created by
|
|
16
|
+
* `runExecutor` and travels to each dispatched job through
|
|
17
|
+
* {@link ExecutorRunControl}; when the invocation returns, the registry is
|
|
18
|
+
* garbage, which is the correct lifetime.
|
|
19
|
+
*
|
|
20
|
+
* SHUTDOWN IS STICKY, AND THAT IS THE POINT. A signal can arrive while a claimed
|
|
21
|
+
* job is still preparing its worktree — seconds before it spawns anything. A
|
|
22
|
+
* plain "terminate everyone registered right now" would miss that worker
|
|
23
|
+
* entirely and leave precisely the orphan this module exists to prevent. So the
|
|
24
|
+
* shutdown flag persists: a controller registered AFTER shutdown was requested
|
|
25
|
+
* is terminated immediately, on registration.
|
|
26
|
+
*
|
|
27
|
+
* TERMINATION IS REQUESTED EXACTLY ONCE PER CONTROLLER. The controllers
|
|
28
|
+
* themselves are idempotent, but the registry does not lean on that — it tracks
|
|
29
|
+
* what it has already asked for, so repeated or mixed `SIGTERM`/`SIGINT` cannot
|
|
30
|
+
* produce a second request even against a controller implementation that would
|
|
31
|
+
* have honored one.
|
|
32
|
+
*
|
|
33
|
+
* SECRET-FREE. The registry holds termination controllers and nothing else — no
|
|
34
|
+
* job payload, no claim token, no argv, no environment.
|
|
35
|
+
*/
|
|
36
|
+
/** Create an empty, runner-scoped live worker registry. */
|
|
37
|
+
export function createLiveWorkerRegistry() {
|
|
38
|
+
const live = new Set();
|
|
39
|
+
const alreadyRequested = new Set();
|
|
40
|
+
let shutdownRequested = false;
|
|
41
|
+
const terminateOnce = (controller) => {
|
|
42
|
+
if (alreadyRequested.has(controller))
|
|
43
|
+
return;
|
|
44
|
+
alreadyRequested.add(controller);
|
|
45
|
+
try {
|
|
46
|
+
controller.requestTermination();
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// Fail-open, and deliberately silent. A controller that throws is a defect
|
|
50
|
+
// in one worker's teardown; letting it escape would abort the shutdown
|
|
51
|
+
// drain and strand every OTHER live worker — turning one bad teardown into
|
|
52
|
+
// the exact orphan situation this registry prevents.
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
return {
|
|
56
|
+
register(controller) {
|
|
57
|
+
live.add(controller);
|
|
58
|
+
if (shutdownRequested)
|
|
59
|
+
terminateOnce(controller);
|
|
60
|
+
return () => {
|
|
61
|
+
live.delete(controller);
|
|
62
|
+
};
|
|
63
|
+
},
|
|
64
|
+
deregister(controller) {
|
|
65
|
+
live.delete(controller);
|
|
66
|
+
},
|
|
67
|
+
requestShutdown() {
|
|
68
|
+
shutdownRequested = true;
|
|
69
|
+
// Snapshot before iterating: `terminateOnce` cannot mutate `live`, but a
|
|
70
|
+
// controller's own termination path may synchronously reach code that
|
|
71
|
+
// deregisters, and mutating a Set mid-iteration is not a behavior to rely
|
|
72
|
+
// on.
|
|
73
|
+
for (const controller of [...live])
|
|
74
|
+
terminateOnce(controller);
|
|
75
|
+
},
|
|
76
|
+
isShutdownRequested() {
|
|
77
|
+
return shutdownRequested;
|
|
78
|
+
},
|
|
79
|
+
size() {
|
|
80
|
+
return live.size;
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|