@bridge_gpt/mcp-server 0.2.18 → 0.2.19
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/CONDUCTOR.md +75 -0
- package/README.md +2 -2
- package/build/agent-capabilities/probe-context.js +13 -3
- package/build/agent-capabilities/probes.js +262 -11
- package/build/agent-capabilities/reporter.js +1 -0
- package/build/agents.generated.js +1 -1
- package/build/backend-warnings.js +44 -0
- package/build/claude-settings.js +129 -0
- package/build/commands.generated.js +1 -0
- package/build/conductor/bridge-api-client.js +7 -7
- package/build/conductor/cli.js +65 -12
- package/build/conductor/deny-enforcement-preflight.js +96 -0
- package/build/conductor/doctor.js +183 -2
- package/build/conductor/epic-reconcile.js +9 -1
- package/build/conductor/epic-runtime.js +403 -43
- package/build/conductor/epic-state.js +7 -0
- package/build/conductor/errors.js +115 -3
- package/build/conductor/event-accessors.js +28 -10
- package/build/conductor/merge-ledger.js +6 -4
- package/build/conductor/pr-ci-producer.js +17 -2
- package/build/conductor/producer-ledger.js +1 -1
- package/build/conductor/store.js +161 -18
- package/build/conductor/supervisor-merge.js +32 -5
- package/build/conductor/taxonomy.js +8 -0
- package/build/conductor/tools.js +28 -6
- package/build/conductor/worker-ledger-cli.js +244 -0
- package/build/conductor-bin.js +1884 -6917
- package/build/doctor.js +8 -0
- package/build/executor/cli.js +229 -0
- package/build/executor/credentials.js +65 -0
- package/build/executor/deps.js +117 -0
- package/build/executor/env.js +79 -0
- package/build/executor/heartbeat.js +59 -0
- package/build/executor/http-client.js +131 -0
- package/build/executor/index.js +10 -0
- package/build/executor/job-errors.js +55 -0
- package/build/executor/job-log-registry.js +110 -0
- package/build/executor/job-runner.js +688 -0
- package/build/executor/job-types.js +60 -0
- package/build/executor/merge-job.js +155 -0
- package/build/executor/observation.js +123 -0
- package/build/executor/permissions.js +79 -0
- package/build/executor/preflight.js +144 -0
- package/build/executor/process.js +81 -0
- package/build/executor/prompt-spec.js +235 -0
- package/build/executor/results.js +134 -0
- package/build/executor/resume-pre-spawn.js +179 -0
- package/build/executor/runner.js +98 -0
- package/build/executor/terminal-mutation.js +34 -0
- package/build/executor/test-clock.js +109 -0
- package/build/executor/types.js +18 -0
- package/build/executor/verdict-artifact.js +53 -0
- package/build/executor/viewer-tabs.js +78 -0
- package/build/executor/watch-cli.js +113 -0
- package/build/executor/worker-command.js +106 -0
- package/build/executor/worker-finalization.js +97 -0
- package/build/executor/worker-log.js +92 -0
- package/build/executor/worktree-gc.js +134 -0
- package/build/executor/worktree-inspection.js +86 -0
- package/build/executor/worktree.js +103 -0
- package/build/index.js +11222 -8544
- package/build/mcp-invoke.js +19 -3
- package/build/mcp-provisioning.js +31 -25
- package/build/mcp-registration-doctor.js +27 -7
- package/build/mcp-server-invocation.js +152 -0
- package/build/pipelines.generated.js +1 -1
- package/build/readme.generated.js +1 -1
- package/build/sfcc/reads-site-preference.js +52 -19
- package/build/start-tickets-conductor.js +25 -93
- package/build/start-tickets-prereqs.js +152 -1
- package/build/start-tickets.js +96 -158
- package/build/version.generated.js +1 -1
- package/build/visual-diff-worker.js +313 -0
- package/build/visual-diff.js +632 -0
- package/build/worktree-core.js +202 -0
- package/package.json +8 -4
- package/public/css/main.min.css +39 -0
- package/public/css/main.min.css.map +1 -1
- package/public/js/main.min.js +7924 -1
- package/public/js/main.min.js.map +1 -1
- package/smoke-test/SMOKE-TEST.md +2 -1
|
@@ -0,0 +1,688 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claimed-job execution engine (BAPI-534 core, BAPI-535 job behaviors; TDD §7/§8/§9).
|
|
3
|
+
*
|
|
4
|
+
* A single claim-owned path runs the `smoke` no-op acceptance job (the acceptance
|
|
5
|
+
* spine — no real worker LLM), the deterministic `merge` job (no worktree / no
|
|
6
|
+
* worker), and real headless spawn jobs (`implement`, `resume`, `spec_review`,
|
|
7
|
+
* plus the recovery jobs `remediate`/`ci_fix`/`rebase`; see `job-types.ts`).
|
|
8
|
+
* The claim response's `claim_token` + `repo_name` are used unchanged for every
|
|
9
|
+
* heartbeat/complete/fail. Once ownership is abandoned (stale-claim or dead-man),
|
|
10
|
+
* the executor stops touching the worktree and never reports success.
|
|
11
|
+
*
|
|
12
|
+
* BAPI-535 additions: `merge` (deterministic local `gh`), `resume` pre-spawn
|
|
13
|
+
* protocol (preserve-work-by-construction), verdict-artifact reading for
|
|
14
|
+
* `spec_review`, plus per-job worker-log tee, watch registry, and optional
|
|
15
|
+
* read-only viewer tabs wired around every spawned worker.
|
|
16
|
+
*/
|
|
17
|
+
import os from "node:os";
|
|
18
|
+
import { buildExecutorWorkerEnv } from "./env.js";
|
|
19
|
+
import { runHeartbeatLoop } from "./heartbeat.js";
|
|
20
|
+
import { isExecutorNamedError, toExecutorFailure, secretFreeErrorMessage, MissingVerdictArtifact, } from "./job-errors.js";
|
|
21
|
+
import { buildConductorMergeAccessForExecutorJob, buildDefaultMergeLocalDeps, runExecutorMergeJob, } from "./merge-job.js";
|
|
22
|
+
import { createObservationState } from "./observation.js";
|
|
23
|
+
import { provisionExecutorDenyLayer } from "./permissions.js";
|
|
24
|
+
import { runProcessWithTimeout } from "./process.js";
|
|
25
|
+
import { prepareResumeSpawn } from "./resume-pre-spawn.js";
|
|
26
|
+
import { renderPromptSpecPrompt } from "./prompt-spec.js";
|
|
27
|
+
import { buildGenericSuccessResult, buildSmokeResult, commitResidue, readCompletionArtifacts, resolveJobTimeoutSeconds, } from "./results.js";
|
|
28
|
+
import { sendTerminalMutationWithRetry } from "./terminal-mutation.js";
|
|
29
|
+
import { isVerdictJobType, readVerdictArtifact } from "./verdict-artifact.js";
|
|
30
|
+
import { createWorkerLogTee, closeWorkerLogTee, teeAsyncIterable, } from "./worker-log.js";
|
|
31
|
+
import { registerExecutorJobLog, markExecutorJobLogFinished, } from "./job-log-registry.js";
|
|
32
|
+
import { executorViewerTabsEnabled, openExecutorViewerTab } from "./viewer-tabs.js";
|
|
33
|
+
import { isRecoveryJobType, isSpawnJobType } from "./job-types.js";
|
|
34
|
+
import { validateWorkerFinalization } from "./worker-finalization.js";
|
|
35
|
+
import { ensureExecutorWorktree } from "./worktree.js";
|
|
36
|
+
import { buildClaudeExecutorArgv, CLAUDE_EXECUTABLE, resolveExecutorModelAlias, resolveExecutorPrompt, } from "./worker-command.js";
|
|
37
|
+
import { collectGitTelemetry } from "./observation.js";
|
|
38
|
+
/** Default runtime for the no-op smoke process (ms). */
|
|
39
|
+
const DEFAULT_SMOKE_DURATION_MS = 100;
|
|
40
|
+
/** ProcessClassification maps 1:1 onto the wire ExecutorClassification. */
|
|
41
|
+
function toWireClassification(c) {
|
|
42
|
+
return c;
|
|
43
|
+
}
|
|
44
|
+
function classificationToErrorKind(c) {
|
|
45
|
+
switch (c) {
|
|
46
|
+
case "timeout":
|
|
47
|
+
return "Timeout";
|
|
48
|
+
case "killed":
|
|
49
|
+
return "Killed";
|
|
50
|
+
default:
|
|
51
|
+
return "WorkerCrashed";
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function boundedError(err) {
|
|
55
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
56
|
+
return message.slice(0, 200);
|
|
57
|
+
}
|
|
58
|
+
function nowIso(deps) {
|
|
59
|
+
return new Date(deps.now()).toISOString();
|
|
60
|
+
}
|
|
61
|
+
/** Build the injected resume pre-spawn seams from executor deps/options. */
|
|
62
|
+
function buildResumeSeams(deps, options) {
|
|
63
|
+
return {
|
|
64
|
+
runCommand: deps.runCommand,
|
|
65
|
+
platform: deps.platform,
|
|
66
|
+
env: deps.env,
|
|
67
|
+
cwd: deps.cwd,
|
|
68
|
+
worktrunkBinary: options.worktrunkBinary,
|
|
69
|
+
baseBranch: options.baseBranch,
|
|
70
|
+
pathExists: async (p) => {
|
|
71
|
+
try {
|
|
72
|
+
await deps.stat(p);
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
/** Build the local job-log registry deps (secret-free) from executor deps. */
|
|
82
|
+
function buildJobLogRegistryDeps(deps) {
|
|
83
|
+
return {
|
|
84
|
+
readFile: deps.readFile,
|
|
85
|
+
writeFile: deps.writeFile,
|
|
86
|
+
mkdir: deps.mkdir,
|
|
87
|
+
env: deps.env,
|
|
88
|
+
tmpdir: () => os.tmpdir(),
|
|
89
|
+
platform: deps.platform,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
/** Default merge dispatch: resolve Bridge API access, then run the deterministic merge. */
|
|
93
|
+
async function defaultRunMergeForClaimed(job, deps, _options) {
|
|
94
|
+
const accessResult = await buildConductorMergeAccessForExecutorJob({
|
|
95
|
+
env: deps.env,
|
|
96
|
+
cwd: deps.cwd,
|
|
97
|
+
homedir: deps.homedir,
|
|
98
|
+
platform: deps.platform,
|
|
99
|
+
readFile: deps.readFile,
|
|
100
|
+
stat: deps.stat,
|
|
101
|
+
});
|
|
102
|
+
if (!accessResult.ok) {
|
|
103
|
+
return {
|
|
104
|
+
ok: false,
|
|
105
|
+
failure: {
|
|
106
|
+
error_kind: "MergeAccessUnavailable",
|
|
107
|
+
error_message: accessResult.error,
|
|
108
|
+
classification: "crashed",
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
return runExecutorMergeJob(job, {
|
|
113
|
+
access: accessResult.access,
|
|
114
|
+
localMergeDeps: buildDefaultMergeLocalDeps(deps),
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
/** An in-memory owned process for the no-op `smoke` acceptance job. */
|
|
118
|
+
function createNoopProcess(deps, durationMs, message) {
|
|
119
|
+
let killResolve = null;
|
|
120
|
+
const killed = new Promise((res) => {
|
|
121
|
+
killResolve = res;
|
|
122
|
+
});
|
|
123
|
+
let done = false;
|
|
124
|
+
async function* stdout() {
|
|
125
|
+
if (message)
|
|
126
|
+
yield message;
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
pid: undefined,
|
|
130
|
+
stdout: stdout(),
|
|
131
|
+
stderr: null,
|
|
132
|
+
async wait() {
|
|
133
|
+
const natural = deps
|
|
134
|
+
.sleep(durationMs)
|
|
135
|
+
.then(() => ({ exitCode: 0, signal: null }));
|
|
136
|
+
const result = await Promise.race([natural, killed]);
|
|
137
|
+
done = true;
|
|
138
|
+
return result;
|
|
139
|
+
},
|
|
140
|
+
kill(signal) {
|
|
141
|
+
if (done)
|
|
142
|
+
return;
|
|
143
|
+
killResolve?.({ exitCode: null, signal });
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
/** Run the process and heartbeat loop concurrently; return the process outcome. */
|
|
148
|
+
async function superviseProcess(params) {
|
|
149
|
+
const isDoneRef = { value: false };
|
|
150
|
+
const hb = runHeartbeatLoop({
|
|
151
|
+
job: params.job,
|
|
152
|
+
httpClient: params.httpClient,
|
|
153
|
+
options: params.options,
|
|
154
|
+
deps: params.deps,
|
|
155
|
+
ownership: params.ownership,
|
|
156
|
+
proc: params.proc,
|
|
157
|
+
observation: params.observation,
|
|
158
|
+
collectTelemetry: params.collectTelemetry,
|
|
159
|
+
isDone: () => isDoneRef.value,
|
|
160
|
+
});
|
|
161
|
+
const result = await runProcessWithTimeout(params.proc, params.timeoutSeconds, params.deps, {
|
|
162
|
+
termGraceMs: params.options.termGraceMs,
|
|
163
|
+
onStdout: (chunk) => params.observation.recordStdout(chunk),
|
|
164
|
+
});
|
|
165
|
+
isDoneRef.value = true;
|
|
166
|
+
await hb;
|
|
167
|
+
params.observation.setExitCode(result.exitCode);
|
|
168
|
+
return result;
|
|
169
|
+
}
|
|
170
|
+
function terminalToRunResult(terminal, successStatus) {
|
|
171
|
+
switch (terminal.outcome) {
|
|
172
|
+
case "success":
|
|
173
|
+
return { status: successStatus };
|
|
174
|
+
case "invalid_job_result":
|
|
175
|
+
return { status: "failed", reason: "invalid_job_result" };
|
|
176
|
+
case "stale_claim":
|
|
177
|
+
return { status: "abandoned", reason: "stale_claim" };
|
|
178
|
+
case "abandoned":
|
|
179
|
+
return { status: "abandoned", reason: "deadman" };
|
|
180
|
+
case "skipped":
|
|
181
|
+
return { status: "abandoned", reason: "already_abandoned" };
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
/** Dispatch and run a single claimed job. */
|
|
185
|
+
export async function runClaimedJob(job, httpClient, options, deps, _report, seams = {}) {
|
|
186
|
+
const ownership = {
|
|
187
|
+
abandoned: false,
|
|
188
|
+
lastSuccessfulHeartbeatAt: deps.now(),
|
|
189
|
+
};
|
|
190
|
+
const observation = createObservationState({ now: deps.now }, { advisoryParserEnabled: options.advisoryParserEnabled });
|
|
191
|
+
// `merge` is a deterministic executor action: dispatched BEFORE any worktree
|
|
192
|
+
// ensure/recreate or worker-spawn logic — it ensures no worktree and spawns no
|
|
193
|
+
// worker (TDD §5/§9).
|
|
194
|
+
if (job.job_type === "merge") {
|
|
195
|
+
return runMergeJob(job, httpClient, options, deps, ownership, seams);
|
|
196
|
+
}
|
|
197
|
+
if (job.job_type === "smoke") {
|
|
198
|
+
return runSmokeJob(job, httpClient, options, deps, ownership, observation);
|
|
199
|
+
}
|
|
200
|
+
// `implement`/`resume`/`spec_review` and the recovery jobs (`remediate`/
|
|
201
|
+
// `ci_fix`/`rebase`) all run through the real headless-spawn path — dispatched
|
|
202
|
+
// via the shared `isSpawnJobType` policy (`job-types.ts`) AFTER the dedicated
|
|
203
|
+
// `merge` and `smoke` branches so those keep their bespoke handling.
|
|
204
|
+
if (isSpawnJobType(job.job_type)) {
|
|
205
|
+
return runSpawnJob(job, httpClient, options, deps, ownership, observation, seams);
|
|
206
|
+
}
|
|
207
|
+
// Unsupported job type — fail server-side; never spawn.
|
|
208
|
+
await httpClient.fail(job, {
|
|
209
|
+
error_kind: "ContractError.UnsupportedJobType",
|
|
210
|
+
error_message: `unsupported job_type '${job.job_type}' in executor T3b`,
|
|
211
|
+
classification: "crashed",
|
|
212
|
+
});
|
|
213
|
+
return { status: "failed", reason: "unsupported_job_type" };
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Run a deterministic `merge` job: NO worktree ensured, NO worker spawned. The
|
|
217
|
+
* local merge executor's outcome drives `/complete` (success) or `/fail`
|
|
218
|
+
* (deterministic failure); stale/retry/invalid outcomes reuse T3a handling.
|
|
219
|
+
*/
|
|
220
|
+
async function runMergeJob(job, httpClient, options, deps, ownership, seams) {
|
|
221
|
+
const runMerge = seams.runMerge ?? defaultRunMergeForClaimed;
|
|
222
|
+
let outcome;
|
|
223
|
+
try {
|
|
224
|
+
outcome = await runMerge(job, deps, options);
|
|
225
|
+
}
|
|
226
|
+
catch (err) {
|
|
227
|
+
const terminal = await sendTerminalMutationWithRetry({
|
|
228
|
+
kind: "fail",
|
|
229
|
+
send: () => httpClient.fail(job, {
|
|
230
|
+
error_kind: "MergeError",
|
|
231
|
+
error_message: secretFreeErrorMessage(err),
|
|
232
|
+
classification: "crashed",
|
|
233
|
+
}),
|
|
234
|
+
deps,
|
|
235
|
+
options,
|
|
236
|
+
ownership,
|
|
237
|
+
log: deps.log,
|
|
238
|
+
});
|
|
239
|
+
return terminalToRunResult(terminal, "failed");
|
|
240
|
+
}
|
|
241
|
+
if (outcome.ok) {
|
|
242
|
+
const completion = {
|
|
243
|
+
job_type: "merge",
|
|
244
|
+
exit_code: 0,
|
|
245
|
+
classification: "clean_exit",
|
|
246
|
+
result: outcome.result,
|
|
247
|
+
};
|
|
248
|
+
const terminal = await sendTerminalMutationWithRetry({
|
|
249
|
+
kind: "complete",
|
|
250
|
+
send: () => httpClient.complete(job, completion),
|
|
251
|
+
deps,
|
|
252
|
+
options,
|
|
253
|
+
ownership,
|
|
254
|
+
log: deps.log,
|
|
255
|
+
});
|
|
256
|
+
return terminalToRunResult(terminal, "completed");
|
|
257
|
+
}
|
|
258
|
+
const terminal = await sendTerminalMutationWithRetry({
|
|
259
|
+
kind: "fail",
|
|
260
|
+
send: () => httpClient.fail(job, outcome.failure),
|
|
261
|
+
deps,
|
|
262
|
+
options,
|
|
263
|
+
ownership,
|
|
264
|
+
log: deps.log,
|
|
265
|
+
});
|
|
266
|
+
return terminalToRunResult(terminal, "failed");
|
|
267
|
+
}
|
|
268
|
+
async function runSmokeJob(job, httpClient, options, deps, ownership, observation) {
|
|
269
|
+
const timeout = resolveJobTimeoutSeconds(job, options.defaultJobTimeoutSeconds);
|
|
270
|
+
if (!timeout.ok) {
|
|
271
|
+
// Consistent with runSpawnJob: a timeout-contract failure fails the job
|
|
272
|
+
// rather than silently substituting a default.
|
|
273
|
+
await httpClient.fail(job, {
|
|
274
|
+
error_kind: "ContractError.Timeout",
|
|
275
|
+
error_message: timeout.error,
|
|
276
|
+
classification: "crashed",
|
|
277
|
+
});
|
|
278
|
+
return { status: "failed", reason: "timeout_contract" };
|
|
279
|
+
}
|
|
280
|
+
const timeoutSeconds = timeout.timeoutSeconds;
|
|
281
|
+
const payload = job.payload && typeof job.payload === "object" ? job.payload : {};
|
|
282
|
+
const durationMs = typeof payload.duration_ms === "number" && payload.duration_ms > 0
|
|
283
|
+
? payload.duration_ms
|
|
284
|
+
: DEFAULT_SMOKE_DURATION_MS;
|
|
285
|
+
const message = typeof payload.message === "string" ? payload.message : "smoke ok";
|
|
286
|
+
const proc = createNoopProcess(deps, durationMs, message);
|
|
287
|
+
const procResult = await superviseProcess({
|
|
288
|
+
job,
|
|
289
|
+
httpClient,
|
|
290
|
+
options,
|
|
291
|
+
deps,
|
|
292
|
+
ownership,
|
|
293
|
+
observation,
|
|
294
|
+
proc,
|
|
295
|
+
timeoutSeconds,
|
|
296
|
+
collectTelemetry: async () => ({}),
|
|
297
|
+
});
|
|
298
|
+
if (ownership.abandoned) {
|
|
299
|
+
return { status: "abandoned", reason: ownership.abandonReason };
|
|
300
|
+
}
|
|
301
|
+
const evidence = {
|
|
302
|
+
message,
|
|
303
|
+
exit_code: procResult.exitCode,
|
|
304
|
+
classification: procResult.classification,
|
|
305
|
+
};
|
|
306
|
+
const result = buildSmokeResult(evidence, procResult.classification === "clean_exit" ? "pass" : "fail");
|
|
307
|
+
const completion = {
|
|
308
|
+
job_type: "smoke",
|
|
309
|
+
exit_code: procResult.exitCode ?? 0,
|
|
310
|
+
classification: toWireClassification(procResult.classification),
|
|
311
|
+
result,
|
|
312
|
+
telemetry: observation.snapshot(),
|
|
313
|
+
};
|
|
314
|
+
const terminal = await sendTerminalMutationWithRetry({
|
|
315
|
+
kind: "complete",
|
|
316
|
+
send: () => httpClient.complete(job, completion),
|
|
317
|
+
deps,
|
|
318
|
+
options,
|
|
319
|
+
ownership,
|
|
320
|
+
log: deps.log,
|
|
321
|
+
});
|
|
322
|
+
return terminalToRunResult(terminal, "completed");
|
|
323
|
+
}
|
|
324
|
+
async function prepareSpawn(job, httpClient, options, deps, seams) {
|
|
325
|
+
if (job.job_type === "resume") {
|
|
326
|
+
const prepareResume = seams.prepareResumeSpawn ?? prepareResumeSpawn;
|
|
327
|
+
try {
|
|
328
|
+
const prepared = await prepareResume(job, options, buildResumeSeams(deps, options));
|
|
329
|
+
return {
|
|
330
|
+
ok: true,
|
|
331
|
+
worktreePath: prepared.worktreePath,
|
|
332
|
+
branch: prepared.branch,
|
|
333
|
+
prompt: prepared.prompt,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
catch (err) {
|
|
337
|
+
const failure = isExecutorNamedError(err)
|
|
338
|
+
? toExecutorFailure(err)
|
|
339
|
+
: {
|
|
340
|
+
error_kind: "ResumeError",
|
|
341
|
+
error_message: secretFreeErrorMessage(err),
|
|
342
|
+
classification: "crashed",
|
|
343
|
+
};
|
|
344
|
+
await httpClient.fail(job, failure);
|
|
345
|
+
return {
|
|
346
|
+
ok: false,
|
|
347
|
+
result: {
|
|
348
|
+
status: "failed",
|
|
349
|
+
reason: isExecutorNamedError(err) ? err.errorKind : "resume_prepare_failed",
|
|
350
|
+
},
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
// Resolve an explicit `payload.prompt` or the `/implement-ticket <KEY> --auto`
|
|
355
|
+
// synthesis (implement + recovery). `spec_review` matches neither — it is
|
|
356
|
+
// resolved AFTER worktree preparation from its structured `payload.prompt_spec`
|
|
357
|
+
// (rendering `{{GIT_LOG}}` needs the prepared worktree), so a non-ok result
|
|
358
|
+
// here is not yet a failure when a `prompt_spec` is present to render.
|
|
359
|
+
const explicit = resolveExecutorPrompt(job);
|
|
360
|
+
const payload = job.payload && typeof job.payload === "object"
|
|
361
|
+
? job.payload
|
|
362
|
+
: {};
|
|
363
|
+
const hasPromptSpec = payload.prompt_spec !== undefined && payload.prompt_spec !== null;
|
|
364
|
+
// Fail fast — BEFORE any worktree work — when there is neither a usable
|
|
365
|
+
// explicit/synthesized prompt nor a structured `prompt_spec` to render, so a
|
|
366
|
+
// genuinely prompt-less job never creates a worktree it cannot use.
|
|
367
|
+
if (!explicit.ok && !hasPromptSpec) {
|
|
368
|
+
await httpClient.fail(job, {
|
|
369
|
+
error_kind: "ContractError.Prompt",
|
|
370
|
+
error_message: explicit.error,
|
|
371
|
+
classification: "crashed",
|
|
372
|
+
});
|
|
373
|
+
return { ok: false, result: { status: "failed", reason: "prompt_contract" } };
|
|
374
|
+
}
|
|
375
|
+
const ensureWorktree = seams.ensureWorktree ?? ensureExecutorWorktree;
|
|
376
|
+
// Recovery jobs (`remediate`/`ci_fix`/`rebase`) continue on the ticket's
|
|
377
|
+
// existing bound branch/PR (it carries the implement commit), so they reuse it
|
|
378
|
+
// from `origin/<branch>` with the F7 stale-branch guard OFF. `implement` and
|
|
379
|
+
// `spec_review` stay fresh-off-base with the guard ON. `resume` never reaches
|
|
380
|
+
// here — it has its own preservation protocol above.
|
|
381
|
+
const reuseExistingBranch = isRecoveryJobType(job.job_type);
|
|
382
|
+
const wt = await ensureWorktree(job, options, deps, { reuseExistingBranch });
|
|
383
|
+
if (!wt.ok) {
|
|
384
|
+
await httpClient.fail(job, {
|
|
385
|
+
error_kind: "WorktreeError",
|
|
386
|
+
error_message: wt.error,
|
|
387
|
+
classification: "crashed",
|
|
388
|
+
});
|
|
389
|
+
return { ok: false, result: { status: "failed", reason: "worktree_create_failed" } };
|
|
390
|
+
}
|
|
391
|
+
// Explicit/synthesized prompt wins verbatim, preserving existing behavior.
|
|
392
|
+
if (explicit.ok) {
|
|
393
|
+
return { ok: true, worktreePath: wt.worktreePath, branch: wt.branch, prompt: explicit.prompt };
|
|
394
|
+
}
|
|
395
|
+
// No explicit/synthesized prompt: render the structured `payload.prompt_spec`
|
|
396
|
+
// against the prepared worktree (the `spec_review` path). A malformed spec
|
|
397
|
+
// fails loud with `ContractError.Prompt` so we `/fail` rather than spawn a
|
|
398
|
+
// worker with an empty prompt. Error messages are secret-free by construction.
|
|
399
|
+
const renderPrompt = seams.renderPromptSpecPrompt ?? renderPromptSpecPrompt;
|
|
400
|
+
try {
|
|
401
|
+
const rendered = await renderPrompt(job, {
|
|
402
|
+
worktreePath: wt.worktreePath,
|
|
403
|
+
workBranch: wt.branch,
|
|
404
|
+
baseBranch: options.baseBranch,
|
|
405
|
+
runCommand: deps.runCommand,
|
|
406
|
+
});
|
|
407
|
+
return { ok: true, worktreePath: wt.worktreePath, branch: wt.branch, prompt: rendered };
|
|
408
|
+
}
|
|
409
|
+
catch (err) {
|
|
410
|
+
const failure = isExecutorNamedError(err)
|
|
411
|
+
? toExecutorFailure(err)
|
|
412
|
+
: {
|
|
413
|
+
error_kind: "ContractError.Prompt",
|
|
414
|
+
error_message: secretFreeErrorMessage(err),
|
|
415
|
+
classification: "crashed",
|
|
416
|
+
};
|
|
417
|
+
await httpClient.fail(job, failure);
|
|
418
|
+
return { ok: false, result: { status: "failed", reason: "prompt_contract" } };
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
async function runSpawnJob(job, httpClient, options, deps, ownership, observation, seams) {
|
|
422
|
+
// --- Worktree + prompt (resume pre-spawn protocol or standard) -------
|
|
423
|
+
const prep = await prepareSpawn(job, httpClient, options, deps, seams);
|
|
424
|
+
if (!prep.ok)
|
|
425
|
+
return prep.result;
|
|
426
|
+
const { worktreePath, branch, prompt } = prep;
|
|
427
|
+
// --- Timeout contract ------------------------------------------------
|
|
428
|
+
const timeout = resolveJobTimeoutSeconds(job, options.defaultJobTimeoutSeconds);
|
|
429
|
+
if (!timeout.ok) {
|
|
430
|
+
await httpClient.fail(job, {
|
|
431
|
+
error_kind: "ContractError.Timeout",
|
|
432
|
+
error_message: timeout.error,
|
|
433
|
+
classification: "crashed",
|
|
434
|
+
});
|
|
435
|
+
return { status: "failed", reason: "timeout_contract" };
|
|
436
|
+
}
|
|
437
|
+
// --- Deny layer (fail-open) ------------------------------------------
|
|
438
|
+
const deny = await provisionExecutorDenyLayer(worktreePath, { baseBranch: options.baseBranch }, {
|
|
439
|
+
readFile: deps.readFile,
|
|
440
|
+
writeFile: deps.writeFile,
|
|
441
|
+
mkdir: deps.mkdir,
|
|
442
|
+
homedir: deps.homedir,
|
|
443
|
+
});
|
|
444
|
+
if (!deny.ok)
|
|
445
|
+
deps.errorLog(deny.warning);
|
|
446
|
+
// --- Worker log tee + watch registry + viewer tab (BAPI-535 §8) ------
|
|
447
|
+
const registryDeps = buildJobLogRegistryDeps(deps);
|
|
448
|
+
const markFinished = seams.markExecutorJobLogFinished ?? markExecutorJobLogFinished;
|
|
449
|
+
const createTee = seams.createWorkerLogTee ?? createWorkerLogTee;
|
|
450
|
+
let tee = null;
|
|
451
|
+
try {
|
|
452
|
+
tee = await createTee(worktreePath, {
|
|
453
|
+
mkdir: deps.mkdir,
|
|
454
|
+
writeFile: deps.writeFile,
|
|
455
|
+
appendFile: deps.appendFile,
|
|
456
|
+
platform: deps.platform,
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
catch {
|
|
460
|
+
tee = null;
|
|
461
|
+
}
|
|
462
|
+
if (tee) {
|
|
463
|
+
const registerLog = seams.registerExecutorJobLog ?? registerExecutorJobLog;
|
|
464
|
+
try {
|
|
465
|
+
await registerLog({
|
|
466
|
+
job_id: job.id,
|
|
467
|
+
repo_name: job.repo_name,
|
|
468
|
+
job_type: job.job_type,
|
|
469
|
+
ticket_key: job.ticket_key ?? undefined,
|
|
470
|
+
worktree_path: worktreePath,
|
|
471
|
+
log_path: tee.logPath,
|
|
472
|
+
started_at: nowIso(deps),
|
|
473
|
+
}, registryDeps);
|
|
474
|
+
}
|
|
475
|
+
catch {
|
|
476
|
+
/* registry is a visibility nicety — never fail the job */
|
|
477
|
+
}
|
|
478
|
+
if (executorViewerTabsEnabled(deps.platform, deps.env)) {
|
|
479
|
+
const openViewer = seams.openExecutorViewerTab ?? openExecutorViewerTab;
|
|
480
|
+
try {
|
|
481
|
+
const viewer = await openViewer({
|
|
482
|
+
platform: deps.platform,
|
|
483
|
+
env: deps.env,
|
|
484
|
+
workerLogPath: tee.logPath,
|
|
485
|
+
worktreePath,
|
|
486
|
+
ticketKey: job.ticket_key ?? undefined,
|
|
487
|
+
runCommand: deps.runCommand,
|
|
488
|
+
cwd: deps.cwd,
|
|
489
|
+
});
|
|
490
|
+
if (viewer.status === "failed") {
|
|
491
|
+
deps.errorLog(`executor viewer tab failed (non-fatal): ${viewer.reason}`);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
catch (err) {
|
|
495
|
+
deps.errorLog(`executor viewer tab error (non-fatal): ${secretFreeErrorMessage(err)}`);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
const finalizeRegistry = async () => {
|
|
500
|
+
if (!tee)
|
|
501
|
+
return;
|
|
502
|
+
try {
|
|
503
|
+
await markFinished(job.id, nowIso(deps), registryDeps);
|
|
504
|
+
}
|
|
505
|
+
catch {
|
|
506
|
+
/* non-fatal */
|
|
507
|
+
}
|
|
508
|
+
};
|
|
509
|
+
// --- Spawn -----------------------------------------------------------
|
|
510
|
+
const alias = resolveExecutorModelAlias(job.payload);
|
|
511
|
+
const argv = buildClaudeExecutorArgv(prompt, alias);
|
|
512
|
+
const env = buildExecutorWorkerEnv(deps.env);
|
|
513
|
+
let proc;
|
|
514
|
+
try {
|
|
515
|
+
proc = deps.spawnProcess(CLAUDE_EXECUTABLE, argv, { cwd: worktreePath, env });
|
|
516
|
+
}
|
|
517
|
+
catch (err) {
|
|
518
|
+
await closeWorkerLogTee(tee);
|
|
519
|
+
await finalizeRegistry();
|
|
520
|
+
await httpClient.fail(job, {
|
|
521
|
+
error_kind: "SpawnError",
|
|
522
|
+
error_message: `failed to spawn worker: ${boundedError(err)}`,
|
|
523
|
+
classification: "crashed",
|
|
524
|
+
});
|
|
525
|
+
return { status: "failed", reason: "spawn_failed" };
|
|
526
|
+
}
|
|
527
|
+
// Tee stdout/stderr to the worker log WITHOUT changing what the runner sees.
|
|
528
|
+
const supervisedProc = tee
|
|
529
|
+
? {
|
|
530
|
+
...proc,
|
|
531
|
+
stdout: proc.stdout ? teeAsyncIterable(proc.stdout, tee, "stdout") : null,
|
|
532
|
+
stderr: proc.stderr ? teeAsyncIterable(proc.stderr, tee, "stderr") : null,
|
|
533
|
+
}
|
|
534
|
+
: proc;
|
|
535
|
+
const collectTelemetry = () => collectGitTelemetry({ runCommand: deps.runCommand, now: deps.now }, worktreePath, options.baseBranch);
|
|
536
|
+
const procResult = await superviseProcess({
|
|
537
|
+
job,
|
|
538
|
+
httpClient,
|
|
539
|
+
options,
|
|
540
|
+
deps,
|
|
541
|
+
ownership,
|
|
542
|
+
observation,
|
|
543
|
+
proc: supervisedProc,
|
|
544
|
+
timeoutSeconds: timeout.timeoutSeconds,
|
|
545
|
+
collectTelemetry,
|
|
546
|
+
});
|
|
547
|
+
// Final telemetry snapshot from the worktree.
|
|
548
|
+
observation.setGitTelemetry(await collectTelemetry());
|
|
549
|
+
// Finalize the log tee WITHOUT signaling the worker (it has already exited).
|
|
550
|
+
await closeWorkerLogTee(tee);
|
|
551
|
+
if (ownership.abandoned) {
|
|
552
|
+
await finalizeRegistry();
|
|
553
|
+
return { status: "abandoned", reason: ownership.abandonReason };
|
|
554
|
+
}
|
|
555
|
+
const git = observation.git();
|
|
556
|
+
if (procResult.classification === "clean_exit") {
|
|
557
|
+
// Verdict jobs (`spec_review`) forward the fixed-path artifact verbatim; a
|
|
558
|
+
// missing/invalid artifact is a fail-loud `MissingVerdictArtifact`.
|
|
559
|
+
if (isVerdictJobType(job.job_type)) {
|
|
560
|
+
const readArtifact = seams.readVerdictArtifact ?? readVerdictArtifact;
|
|
561
|
+
let result;
|
|
562
|
+
try {
|
|
563
|
+
result = await readArtifact(worktreePath, { readFile: deps.readFile, platform: deps.platform });
|
|
564
|
+
}
|
|
565
|
+
catch (err) {
|
|
566
|
+
const failure = isExecutorNamedError(err)
|
|
567
|
+
? toExecutorFailure(err)
|
|
568
|
+
: {
|
|
569
|
+
error_kind: MissingVerdictArtifact,
|
|
570
|
+
error_message: secretFreeErrorMessage(err),
|
|
571
|
+
classification: "crashed",
|
|
572
|
+
};
|
|
573
|
+
await finalizeRegistry();
|
|
574
|
+
const terminal = await sendTerminalMutationWithRetry({
|
|
575
|
+
kind: "fail",
|
|
576
|
+
send: () => httpClient.fail(job, { ...failure, ...commitResidue(git), telemetry: observation.snapshot() }),
|
|
577
|
+
deps,
|
|
578
|
+
options,
|
|
579
|
+
ownership,
|
|
580
|
+
log: deps.log,
|
|
581
|
+
});
|
|
582
|
+
return terminalToRunResult(terminal, "failed");
|
|
583
|
+
}
|
|
584
|
+
// BAPI-528 Wave 3 M2: verdict jobs (`spec_review`) also produce a
|
|
585
|
+
// human-readable critique at `<worktree>/.conductor/critique.md`. Read it
|
|
586
|
+
// AFTER the mandatory verdict (result.json) so a missing critique never
|
|
587
|
+
// masks a MissingVerdictArtifact — `readCompletionArtifacts` is fail-open
|
|
588
|
+
// and returns undefined when there is no critique (e.g. smoke), so
|
|
589
|
+
// verdict-only jobs still complete. Without this the critique was written
|
|
590
|
+
// by the worker but never posted, orphaning the W2-6 server-side persist.
|
|
591
|
+
const artifacts = await readCompletionArtifacts(worktreePath, deps);
|
|
592
|
+
const completion = {
|
|
593
|
+
job_type: job.job_type,
|
|
594
|
+
exit_code: procResult.exitCode ?? 0,
|
|
595
|
+
classification: "clean_exit",
|
|
596
|
+
result,
|
|
597
|
+
artifacts,
|
|
598
|
+
...commitResidue(git),
|
|
599
|
+
telemetry: observation.snapshot(),
|
|
600
|
+
};
|
|
601
|
+
const terminal = await sendTerminalMutationWithRetry({
|
|
602
|
+
kind: "complete",
|
|
603
|
+
send: () => httpClient.complete(job, completion),
|
|
604
|
+
deps,
|
|
605
|
+
options,
|
|
606
|
+
ownership,
|
|
607
|
+
log: deps.log,
|
|
608
|
+
});
|
|
609
|
+
await finalizeRegistry();
|
|
610
|
+
return terminalToRunResult(terminal, "completed");
|
|
611
|
+
}
|
|
612
|
+
// Non-verdict spawn jobs (`implement`, `resume`, and the recovery jobs
|
|
613
|
+
// `remediate`/`ci_fix`/`rebase`) use the generic success-result construction;
|
|
614
|
+
// the verdict-artifact path above stays limited to verdict jobs like
|
|
615
|
+
// `spec_review`. Recovery jobs complete through this same generic envelope.
|
|
616
|
+
const artifacts = await readCompletionArtifacts(worktreePath, deps);
|
|
617
|
+
const result = buildGenericSuccessResult({
|
|
618
|
+
summary: `${job.job_type} ${job.ticket_key ?? ""} completed`.trim(),
|
|
619
|
+
branch,
|
|
620
|
+
headSha: git.last_commit_sha,
|
|
621
|
+
localCommitCount: git.local_commit_count,
|
|
622
|
+
});
|
|
623
|
+
// BAPI-551: a worker can exit `clean_exit` yet strand its implementation if
|
|
624
|
+
// its push never landed on origin and no PR was opened (e.g. it backgrounded
|
|
625
|
+
// a slow push and returned early). Convert that into a fail-loud executor
|
|
626
|
+
// failure rather than silently reporting success.
|
|
627
|
+
const finalization = await validateWorkerFinalization({
|
|
628
|
+
job,
|
|
629
|
+
branch,
|
|
630
|
+
worktreePath,
|
|
631
|
+
result,
|
|
632
|
+
runCommand: deps.runCommand,
|
|
633
|
+
headSha: git.last_commit_sha,
|
|
634
|
+
});
|
|
635
|
+
if (!finalization.ok) {
|
|
636
|
+
await finalizeRegistry();
|
|
637
|
+
const terminal = await sendTerminalMutationWithRetry({
|
|
638
|
+
kind: "fail",
|
|
639
|
+
send: () => httpClient.fail(job, {
|
|
640
|
+
...finalization.failure,
|
|
641
|
+
...commitResidue(git),
|
|
642
|
+
telemetry: observation.snapshot(),
|
|
643
|
+
}),
|
|
644
|
+
deps,
|
|
645
|
+
options,
|
|
646
|
+
ownership,
|
|
647
|
+
log: deps.log,
|
|
648
|
+
});
|
|
649
|
+
return terminalToRunResult(terminal, "failed");
|
|
650
|
+
}
|
|
651
|
+
const completion = {
|
|
652
|
+
job_type: job.job_type,
|
|
653
|
+
exit_code: procResult.exitCode ?? 0,
|
|
654
|
+
classification: "clean_exit",
|
|
655
|
+
result,
|
|
656
|
+
artifacts,
|
|
657
|
+
...commitResidue(git),
|
|
658
|
+
telemetry: observation.snapshot(),
|
|
659
|
+
};
|
|
660
|
+
const terminal = await sendTerminalMutationWithRetry({
|
|
661
|
+
kind: "complete",
|
|
662
|
+
send: () => httpClient.complete(job, completion),
|
|
663
|
+
deps,
|
|
664
|
+
options,
|
|
665
|
+
ownership,
|
|
666
|
+
log: deps.log,
|
|
667
|
+
});
|
|
668
|
+
await finalizeRegistry();
|
|
669
|
+
return terminalToRunResult(terminal, "completed");
|
|
670
|
+
}
|
|
671
|
+
// crashed / timeout / killed → fail
|
|
672
|
+
await finalizeRegistry();
|
|
673
|
+
const terminal = await sendTerminalMutationWithRetry({
|
|
674
|
+
kind: "fail",
|
|
675
|
+
send: () => httpClient.fail(job, {
|
|
676
|
+
error_kind: classificationToErrorKind(procResult.classification),
|
|
677
|
+
error_message: `worker exited (${procResult.classification}, exit_code=${procResult.exitCode})`,
|
|
678
|
+
classification: toWireClassification(procResult.classification),
|
|
679
|
+
...commitResidue(git),
|
|
680
|
+
telemetry: observation.snapshot(),
|
|
681
|
+
}),
|
|
682
|
+
deps,
|
|
683
|
+
options,
|
|
684
|
+
ownership,
|
|
685
|
+
log: deps.log,
|
|
686
|
+
});
|
|
687
|
+
return terminalToRunResult(terminal, "failed");
|
|
688
|
+
}
|