@bridge_gpt/mcp-server 0.2.18 → 0.2.20
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 +8 -5
- 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 +5 -3
- 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/local-merge.js +458 -95
- 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/estimate-epic.js +84 -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 +822 -0
- package/build/executor/job-types.js +60 -0
- package/build/executor/merge-job.js +229 -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 +177 -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 +14534 -10416
- 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 +16 -20
- package/build/readme.generated.js +1 -1
- package/build/sfcc/client.js +192 -50
- package/build/sfcc/ocapi-write-faults.js +94 -0
- package/build/sfcc/permissions.js +7 -22
- package/build/sfcc/reads-site-preference.js +52 -19
- package/build/sfcc/register.js +9 -0
- package/build/sfcc/write-grants.js +80 -0
- package/build/sfcc/write-guard.js +39 -0
- package/build/sfcc/write-result.js +47 -0
- package/build/sfcc/write-tool-common.js +85 -0
- package/build/sfcc/writes-custom-object-def.js +141 -0
- package/build/sfcc/writes-object-attribute-payloads.js +97 -0
- package/build/sfcc/writes-site-preference-payloads.js +59 -0
- package/build/sfcc/writes-site-preference.js +96 -0
- package/build/sfcc/writes-system-object-payloads.js +213 -0
- package/build/sfcc/writes-system-object.js +348 -0
- package/build/sfcc/writes.js +66 -0
- 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/pipelines/idea-to-ticket.json +7 -0
- package/pipelines/review-ticket.json +5 -18
- package/public/css/main.min.css +1612 -107
- package/public/css/main.min.css.map +1 -1
- package/public/js/main.min.js +10267 -1
- package/public/js/main.min.js.map +1 -1
- package/smoke-test/SMOKE-TEST.md +2 -1
|
@@ -0,0 +1,822 @@
|
|
|
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, MERGE_RETRYABLE, MERGE_FAILED, } 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
|
+
/**
|
|
93
|
+
* Default merge dispatch: resolve Bridge API access, then run the deterministic
|
|
94
|
+
* merge. Exported for unit tests (access-resolution failure → retryable). The
|
|
95
|
+
* `controls.signal` (overall-timeout abort) is threaded into local merge deps
|
|
96
|
+
* without changing the local `gh` credential model.
|
|
97
|
+
*/
|
|
98
|
+
export async function defaultRunMergeForClaimed(job, deps, _options, controls) {
|
|
99
|
+
const accessResult = await buildConductorMergeAccessForExecutorJob({
|
|
100
|
+
env: deps.env,
|
|
101
|
+
cwd: deps.cwd,
|
|
102
|
+
homedir: deps.homedir,
|
|
103
|
+
platform: deps.platform,
|
|
104
|
+
readFile: deps.readFile,
|
|
105
|
+
stat: deps.stat,
|
|
106
|
+
});
|
|
107
|
+
if (!accessResult.ok) {
|
|
108
|
+
// Access resolution feeds only the executor's read-only CI re-poll; a missing
|
|
109
|
+
// or temporarily-unavailable Bridge API access is transient infrastructure, so
|
|
110
|
+
// it is RETRYABLE (BAPI-572). The local `gh` credential model is unchanged.
|
|
111
|
+
return {
|
|
112
|
+
ok: false,
|
|
113
|
+
failure: {
|
|
114
|
+
error_kind: MERGE_RETRYABLE,
|
|
115
|
+
error_message: accessResult.error,
|
|
116
|
+
classification: "crashed",
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
return runExecutorMergeJob(job, {
|
|
121
|
+
access: accessResult.access,
|
|
122
|
+
// Thread the overall-timeout abort signal into local merge deps WITHOUT
|
|
123
|
+
// changing the local `gh` credential model (the signal is not a credential).
|
|
124
|
+
localMergeDeps: buildDefaultMergeLocalDeps(deps, controls?.signal),
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
/** An in-memory owned process for the no-op `smoke` acceptance job. */
|
|
128
|
+
function createNoopProcess(deps, durationMs, message) {
|
|
129
|
+
let killResolve = null;
|
|
130
|
+
const killed = new Promise((res) => {
|
|
131
|
+
killResolve = res;
|
|
132
|
+
});
|
|
133
|
+
let done = false;
|
|
134
|
+
async function* stdout() {
|
|
135
|
+
if (message)
|
|
136
|
+
yield message;
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
pid: undefined,
|
|
140
|
+
stdout: stdout(),
|
|
141
|
+
stderr: null,
|
|
142
|
+
async wait() {
|
|
143
|
+
const natural = deps
|
|
144
|
+
.sleep(durationMs)
|
|
145
|
+
.then(() => ({ exitCode: 0, signal: null }));
|
|
146
|
+
const result = await Promise.race([natural, killed]);
|
|
147
|
+
done = true;
|
|
148
|
+
return result;
|
|
149
|
+
},
|
|
150
|
+
kill(signal) {
|
|
151
|
+
if (done)
|
|
152
|
+
return;
|
|
153
|
+
killResolve?.({ exitCode: null, signal });
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
/** Run the process and heartbeat loop concurrently; return the process outcome. */
|
|
158
|
+
async function superviseProcess(params) {
|
|
159
|
+
const isDoneRef = { value: false };
|
|
160
|
+
const hb = runHeartbeatLoop({
|
|
161
|
+
job: params.job,
|
|
162
|
+
httpClient: params.httpClient,
|
|
163
|
+
options: params.options,
|
|
164
|
+
deps: params.deps,
|
|
165
|
+
ownership: params.ownership,
|
|
166
|
+
proc: params.proc,
|
|
167
|
+
observation: params.observation,
|
|
168
|
+
collectTelemetry: params.collectTelemetry,
|
|
169
|
+
isDone: () => isDoneRef.value,
|
|
170
|
+
});
|
|
171
|
+
const result = await runProcessWithTimeout(params.proc, params.timeoutSeconds, params.deps, {
|
|
172
|
+
termGraceMs: params.options.termGraceMs,
|
|
173
|
+
onStdout: (chunk) => params.observation.recordStdout(chunk),
|
|
174
|
+
});
|
|
175
|
+
isDoneRef.value = true;
|
|
176
|
+
await hb;
|
|
177
|
+
params.observation.setExitCode(result.exitCode);
|
|
178
|
+
return result;
|
|
179
|
+
}
|
|
180
|
+
function terminalToRunResult(terminal, successStatus) {
|
|
181
|
+
switch (terminal.outcome) {
|
|
182
|
+
case "success":
|
|
183
|
+
return { status: successStatus };
|
|
184
|
+
case "invalid_job_result":
|
|
185
|
+
return { status: "failed", reason: "invalid_job_result" };
|
|
186
|
+
case "stale_claim":
|
|
187
|
+
return { status: "abandoned", reason: "stale_claim" };
|
|
188
|
+
case "abandoned":
|
|
189
|
+
return { status: "abandoned", reason: "deadman" };
|
|
190
|
+
case "skipped":
|
|
191
|
+
return { status: "abandoned", reason: "already_abandoned" };
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
/** Dispatch and run a single claimed job. */
|
|
195
|
+
export async function runClaimedJob(job, httpClient, options, deps, _report, seams = {}) {
|
|
196
|
+
const ownership = {
|
|
197
|
+
abandoned: false,
|
|
198
|
+
lastSuccessfulHeartbeatAt: deps.now(),
|
|
199
|
+
};
|
|
200
|
+
const observation = createObservationState({ now: deps.now }, { advisoryParserEnabled: options.advisoryParserEnabled });
|
|
201
|
+
// `merge` is a deterministic executor action: dispatched BEFORE any worktree
|
|
202
|
+
// ensure/recreate or worker-spawn logic — it ensures no worktree and spawns no
|
|
203
|
+
// worker (TDD §5/§9).
|
|
204
|
+
if (job.job_type === "merge") {
|
|
205
|
+
return runMergeJob(job, httpClient, options, deps, ownership, observation, seams);
|
|
206
|
+
}
|
|
207
|
+
if (job.job_type === "smoke") {
|
|
208
|
+
return runSmokeJob(job, httpClient, options, deps, ownership, observation);
|
|
209
|
+
}
|
|
210
|
+
// `implement`/`resume`/`spec_review` and the recovery jobs (`remediate`/
|
|
211
|
+
// `ci_fix`/`rebase`) all run through the real headless-spawn path — dispatched
|
|
212
|
+
// via the shared `isSpawnJobType` policy (`job-types.ts`) AFTER the dedicated
|
|
213
|
+
// `merge` and `smoke` branches so those keep their bespoke handling.
|
|
214
|
+
if (isSpawnJobType(job.job_type)) {
|
|
215
|
+
return runSpawnJob(job, httpClient, options, deps, ownership, observation, seams);
|
|
216
|
+
}
|
|
217
|
+
// Unsupported job type — fail server-side; never spawn.
|
|
218
|
+
await httpClient.fail(job, {
|
|
219
|
+
error_kind: "ContractError.UnsupportedJobType",
|
|
220
|
+
error_message: `unsupported job_type '${job.job_type}' in executor T3b`,
|
|
221
|
+
classification: "crashed",
|
|
222
|
+
});
|
|
223
|
+
return { status: "failed", reason: "unsupported_job_type" };
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Adapt a `runMerge(...)` promise into an {@link OwnedChildProcess} so the merge
|
|
227
|
+
* flow is supervised by the SAME heartbeat/deadman/timeout path as spawn/smoke
|
|
228
|
+
* jobs even though it spawns no worker. `wait()` resolves when the merge outcome
|
|
229
|
+
* is available (exit_code 0 on `ok`, 1 otherwise); `kill()` aborts the internal
|
|
230
|
+
* {@link AbortController} (cancelling in-flight local `gh` work) and resolves the
|
|
231
|
+
* owned process as killed. The stored outcome/error let the runner map the
|
|
232
|
+
* supervised result to `/complete` or `/fail` afterwards (killed/timeout is read
|
|
233
|
+
* from the supervised `ProcessRunResult`/`ownership`, not from this adapter).
|
|
234
|
+
*/
|
|
235
|
+
function createMergeFlowProcess(startMerge) {
|
|
236
|
+
const controller = new AbortController();
|
|
237
|
+
let outcome;
|
|
238
|
+
let error;
|
|
239
|
+
let done = false;
|
|
240
|
+
let settleWait = () => { };
|
|
241
|
+
const waitPromise = new Promise((resolve) => {
|
|
242
|
+
settleWait = resolve;
|
|
243
|
+
});
|
|
244
|
+
// Start the merge flow immediately; capture its resolution/rejection.
|
|
245
|
+
startMerge(controller.signal).then((result) => {
|
|
246
|
+
outcome = result;
|
|
247
|
+
if (!done) {
|
|
248
|
+
done = true;
|
|
249
|
+
settleWait({ exitCode: result.ok ? 0 : 1, signal: null });
|
|
250
|
+
}
|
|
251
|
+
}, (err) => {
|
|
252
|
+
error = err;
|
|
253
|
+
if (!done) {
|
|
254
|
+
done = true;
|
|
255
|
+
settleWait({ exitCode: 1, signal: null });
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
const proc = {
|
|
259
|
+
pid: undefined,
|
|
260
|
+
stdout: null,
|
|
261
|
+
stderr: null,
|
|
262
|
+
async wait() {
|
|
263
|
+
return waitPromise;
|
|
264
|
+
},
|
|
265
|
+
kill(signal) {
|
|
266
|
+
if (done)
|
|
267
|
+
return;
|
|
268
|
+
controller.abort();
|
|
269
|
+
done = true;
|
|
270
|
+
settleWait({ exitCode: null, signal });
|
|
271
|
+
},
|
|
272
|
+
};
|
|
273
|
+
return {
|
|
274
|
+
proc,
|
|
275
|
+
getOutcome: () => outcome,
|
|
276
|
+
getError: () => error,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Run a deterministic `merge` job: NO worktree ensured, NO worker spawned. Unlike
|
|
281
|
+
* the pre-BAPI-572 version, the merge flow now runs under the SHARED heartbeat/
|
|
282
|
+
* deadman/timeout supervision path (via {@link superviseProcess}) so a live merge
|
|
283
|
+
* heartbeats (the janitor never re-queues a healthy multi-minute merge) and the
|
|
284
|
+
* whole flow honors `payload.timeout_seconds`. A timeout is reported as a
|
|
285
|
+
* RETRYABLE failure and a late merge resolution after timeout/abandonment is
|
|
286
|
+
* ignored. The local merge executor's outcome otherwise drives `/complete`
|
|
287
|
+
* (success) or `/fail`; stale/retry/invalid terminal outcomes reuse T3a handling.
|
|
288
|
+
*/
|
|
289
|
+
async function runMergeJob(job, httpClient, options, deps, ownership, observation, seams) {
|
|
290
|
+
// Resolve + enforce payload.timeout_seconds BEFORE starting any merge work.
|
|
291
|
+
const timeout = resolveJobTimeoutSeconds(job, options.defaultJobTimeoutSeconds);
|
|
292
|
+
if (!timeout.ok) {
|
|
293
|
+
await httpClient.fail(job, {
|
|
294
|
+
error_kind: "ContractError.Timeout",
|
|
295
|
+
error_message: timeout.error,
|
|
296
|
+
classification: "crashed",
|
|
297
|
+
});
|
|
298
|
+
return { status: "failed", reason: "timeout_contract" };
|
|
299
|
+
}
|
|
300
|
+
const timeoutSeconds = timeout.timeoutSeconds;
|
|
301
|
+
const runMerge = seams.runMerge ?? defaultRunMergeForClaimed;
|
|
302
|
+
const flow = createMergeFlowProcess((signal) => runMerge(job, deps, options, { signal }));
|
|
303
|
+
const procResult = await superviseProcess({
|
|
304
|
+
job,
|
|
305
|
+
httpClient,
|
|
306
|
+
options,
|
|
307
|
+
deps,
|
|
308
|
+
ownership,
|
|
309
|
+
observation,
|
|
310
|
+
proc: flow.proc,
|
|
311
|
+
timeoutSeconds,
|
|
312
|
+
collectTelemetry: async () => ({}),
|
|
313
|
+
});
|
|
314
|
+
// Ownership abandonment (stale-claim / dead-man) wins: never send a terminal
|
|
315
|
+
// mutation for a claim we no longer own, and never process a late outcome.
|
|
316
|
+
if (ownership.abandoned) {
|
|
317
|
+
return { status: "abandoned", reason: ownership.abandonReason };
|
|
318
|
+
}
|
|
319
|
+
// Overall merge flow timed out: fail the active claim as RETRYABLE and ignore
|
|
320
|
+
// any later merge promise resolution (the abort has already been signalled).
|
|
321
|
+
if (procResult.classification === "timeout") {
|
|
322
|
+
const terminal = await sendTerminalMutationWithRetry({
|
|
323
|
+
kind: "fail",
|
|
324
|
+
send: () => httpClient.fail(job, {
|
|
325
|
+
error_kind: MERGE_RETRYABLE,
|
|
326
|
+
error_message: `merge flow timed out after ${timeoutSeconds} seconds`,
|
|
327
|
+
classification: "timeout",
|
|
328
|
+
telemetry: observation.snapshot(),
|
|
329
|
+
}),
|
|
330
|
+
deps,
|
|
331
|
+
options,
|
|
332
|
+
ownership,
|
|
333
|
+
log: deps.log,
|
|
334
|
+
});
|
|
335
|
+
return terminalToRunResult(terminal, "failed");
|
|
336
|
+
}
|
|
337
|
+
// A thrown error inside the merge flow maps to a bounded MergeFailed.
|
|
338
|
+
const flowError = flow.getError();
|
|
339
|
+
if (flowError !== undefined) {
|
|
340
|
+
const terminal = await sendTerminalMutationWithRetry({
|
|
341
|
+
kind: "fail",
|
|
342
|
+
send: () => httpClient.fail(job, {
|
|
343
|
+
error_kind: MERGE_FAILED,
|
|
344
|
+
error_message: secretFreeErrorMessage(flowError),
|
|
345
|
+
classification: "crashed",
|
|
346
|
+
telemetry: observation.snapshot(),
|
|
347
|
+
}),
|
|
348
|
+
deps,
|
|
349
|
+
options,
|
|
350
|
+
ownership,
|
|
351
|
+
log: deps.log,
|
|
352
|
+
});
|
|
353
|
+
return terminalToRunResult(terminal, "failed");
|
|
354
|
+
}
|
|
355
|
+
const outcome = flow.getOutcome();
|
|
356
|
+
if (outcome === undefined) {
|
|
357
|
+
// Clean process result with no stored outcome — an internal invariant break;
|
|
358
|
+
// fail loud rather than silently reporting success.
|
|
359
|
+
const terminal = await sendTerminalMutationWithRetry({
|
|
360
|
+
kind: "fail",
|
|
361
|
+
send: () => httpClient.fail(job, {
|
|
362
|
+
error_kind: MERGE_FAILED,
|
|
363
|
+
error_message: "merge flow completed without an outcome",
|
|
364
|
+
classification: "crashed",
|
|
365
|
+
telemetry: observation.snapshot(),
|
|
366
|
+
}),
|
|
367
|
+
deps,
|
|
368
|
+
options,
|
|
369
|
+
ownership,
|
|
370
|
+
log: deps.log,
|
|
371
|
+
});
|
|
372
|
+
return terminalToRunResult(terminal, "failed");
|
|
373
|
+
}
|
|
374
|
+
if (outcome.ok) {
|
|
375
|
+
const completion = {
|
|
376
|
+
job_type: "merge",
|
|
377
|
+
exit_code: 0,
|
|
378
|
+
classification: "clean_exit",
|
|
379
|
+
result: outcome.result,
|
|
380
|
+
telemetry: observation.snapshot(),
|
|
381
|
+
};
|
|
382
|
+
const terminal = await sendTerminalMutationWithRetry({
|
|
383
|
+
kind: "complete",
|
|
384
|
+
send: () => httpClient.complete(job, completion),
|
|
385
|
+
deps,
|
|
386
|
+
options,
|
|
387
|
+
ownership,
|
|
388
|
+
log: deps.log,
|
|
389
|
+
});
|
|
390
|
+
return terminalToRunResult(terminal, "completed");
|
|
391
|
+
}
|
|
392
|
+
const terminal = await sendTerminalMutationWithRetry({
|
|
393
|
+
kind: "fail",
|
|
394
|
+
send: () => httpClient.fail(job, { ...outcome.failure, telemetry: observation.snapshot() }),
|
|
395
|
+
deps,
|
|
396
|
+
options,
|
|
397
|
+
ownership,
|
|
398
|
+
log: deps.log,
|
|
399
|
+
});
|
|
400
|
+
return terminalToRunResult(terminal, "failed");
|
|
401
|
+
}
|
|
402
|
+
async function runSmokeJob(job, httpClient, options, deps, ownership, observation) {
|
|
403
|
+
const timeout = resolveJobTimeoutSeconds(job, options.defaultJobTimeoutSeconds);
|
|
404
|
+
if (!timeout.ok) {
|
|
405
|
+
// Consistent with runSpawnJob: a timeout-contract failure fails the job
|
|
406
|
+
// rather than silently substituting a default.
|
|
407
|
+
await httpClient.fail(job, {
|
|
408
|
+
error_kind: "ContractError.Timeout",
|
|
409
|
+
error_message: timeout.error,
|
|
410
|
+
classification: "crashed",
|
|
411
|
+
});
|
|
412
|
+
return { status: "failed", reason: "timeout_contract" };
|
|
413
|
+
}
|
|
414
|
+
const timeoutSeconds = timeout.timeoutSeconds;
|
|
415
|
+
const payload = job.payload && typeof job.payload === "object" ? job.payload : {};
|
|
416
|
+
const durationMs = typeof payload.duration_ms === "number" && payload.duration_ms > 0
|
|
417
|
+
? payload.duration_ms
|
|
418
|
+
: DEFAULT_SMOKE_DURATION_MS;
|
|
419
|
+
const message = typeof payload.message === "string" ? payload.message : "smoke ok";
|
|
420
|
+
const proc = createNoopProcess(deps, durationMs, message);
|
|
421
|
+
const procResult = await superviseProcess({
|
|
422
|
+
job,
|
|
423
|
+
httpClient,
|
|
424
|
+
options,
|
|
425
|
+
deps,
|
|
426
|
+
ownership,
|
|
427
|
+
observation,
|
|
428
|
+
proc,
|
|
429
|
+
timeoutSeconds,
|
|
430
|
+
collectTelemetry: async () => ({}),
|
|
431
|
+
});
|
|
432
|
+
if (ownership.abandoned) {
|
|
433
|
+
return { status: "abandoned", reason: ownership.abandonReason };
|
|
434
|
+
}
|
|
435
|
+
const evidence = {
|
|
436
|
+
message,
|
|
437
|
+
exit_code: procResult.exitCode,
|
|
438
|
+
classification: procResult.classification,
|
|
439
|
+
};
|
|
440
|
+
const result = buildSmokeResult(evidence, procResult.classification === "clean_exit" ? "pass" : "fail");
|
|
441
|
+
const completion = {
|
|
442
|
+
job_type: "smoke",
|
|
443
|
+
exit_code: procResult.exitCode ?? 0,
|
|
444
|
+
classification: toWireClassification(procResult.classification),
|
|
445
|
+
result,
|
|
446
|
+
telemetry: observation.snapshot(),
|
|
447
|
+
};
|
|
448
|
+
const terminal = await sendTerminalMutationWithRetry({
|
|
449
|
+
kind: "complete",
|
|
450
|
+
send: () => httpClient.complete(job, completion),
|
|
451
|
+
deps,
|
|
452
|
+
options,
|
|
453
|
+
ownership,
|
|
454
|
+
log: deps.log,
|
|
455
|
+
});
|
|
456
|
+
return terminalToRunResult(terminal, "completed");
|
|
457
|
+
}
|
|
458
|
+
async function prepareSpawn(job, httpClient, options, deps, seams) {
|
|
459
|
+
if (job.job_type === "resume") {
|
|
460
|
+
const prepareResume = seams.prepareResumeSpawn ?? prepareResumeSpawn;
|
|
461
|
+
try {
|
|
462
|
+
const prepared = await prepareResume(job, options, buildResumeSeams(deps, options));
|
|
463
|
+
return {
|
|
464
|
+
ok: true,
|
|
465
|
+
worktreePath: prepared.worktreePath,
|
|
466
|
+
branch: prepared.branch,
|
|
467
|
+
prompt: prepared.prompt,
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
catch (err) {
|
|
471
|
+
const failure = isExecutorNamedError(err)
|
|
472
|
+
? toExecutorFailure(err)
|
|
473
|
+
: {
|
|
474
|
+
error_kind: "ResumeError",
|
|
475
|
+
error_message: secretFreeErrorMessage(err),
|
|
476
|
+
classification: "crashed",
|
|
477
|
+
};
|
|
478
|
+
await httpClient.fail(job, failure);
|
|
479
|
+
return {
|
|
480
|
+
ok: false,
|
|
481
|
+
result: {
|
|
482
|
+
status: "failed",
|
|
483
|
+
reason: isExecutorNamedError(err) ? err.errorKind : "resume_prepare_failed",
|
|
484
|
+
},
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
// Resolve an explicit `payload.prompt` or the `/implement-ticket <KEY> --auto`
|
|
489
|
+
// synthesis (implement + recovery). `spec_review` matches neither — it is
|
|
490
|
+
// resolved AFTER worktree preparation from its structured `payload.prompt_spec`
|
|
491
|
+
// (rendering `{{GIT_LOG}}` needs the prepared worktree), so a non-ok result
|
|
492
|
+
// here is not yet a failure when a `prompt_spec` is present to render.
|
|
493
|
+
const explicit = resolveExecutorPrompt(job);
|
|
494
|
+
const payload = job.payload && typeof job.payload === "object"
|
|
495
|
+
? job.payload
|
|
496
|
+
: {};
|
|
497
|
+
const hasPromptSpec = payload.prompt_spec !== undefined && payload.prompt_spec !== null;
|
|
498
|
+
// Fail fast — BEFORE any worktree work — when there is neither a usable
|
|
499
|
+
// explicit/synthesized prompt nor a structured `prompt_spec` to render, so a
|
|
500
|
+
// genuinely prompt-less job never creates a worktree it cannot use.
|
|
501
|
+
if (!explicit.ok && !hasPromptSpec) {
|
|
502
|
+
await httpClient.fail(job, {
|
|
503
|
+
error_kind: "ContractError.Prompt",
|
|
504
|
+
error_message: explicit.error,
|
|
505
|
+
classification: "crashed",
|
|
506
|
+
});
|
|
507
|
+
return { ok: false, result: { status: "failed", reason: "prompt_contract" } };
|
|
508
|
+
}
|
|
509
|
+
const ensureWorktree = seams.ensureWorktree ?? ensureExecutorWorktree;
|
|
510
|
+
// Recovery jobs (`remediate`/`ci_fix`/`rebase`) continue on the ticket's
|
|
511
|
+
// existing bound branch/PR (it carries the implement commit), so they reuse it
|
|
512
|
+
// from `origin/<branch>` with the F7 stale-branch guard OFF. `implement` and
|
|
513
|
+
// `spec_review` stay fresh-off-base with the guard ON. `resume` never reaches
|
|
514
|
+
// here — it has its own preservation protocol above.
|
|
515
|
+
const reuseExistingBranch = isRecoveryJobType(job.job_type);
|
|
516
|
+
const wt = await ensureWorktree(job, options, deps, { reuseExistingBranch });
|
|
517
|
+
if (!wt.ok) {
|
|
518
|
+
await httpClient.fail(job, {
|
|
519
|
+
error_kind: "WorktreeError",
|
|
520
|
+
error_message: wt.error,
|
|
521
|
+
classification: "crashed",
|
|
522
|
+
});
|
|
523
|
+
return { ok: false, result: { status: "failed", reason: "worktree_create_failed" } };
|
|
524
|
+
}
|
|
525
|
+
// Explicit/synthesized prompt wins verbatim, preserving existing behavior.
|
|
526
|
+
if (explicit.ok) {
|
|
527
|
+
return { ok: true, worktreePath: wt.worktreePath, branch: wt.branch, prompt: explicit.prompt };
|
|
528
|
+
}
|
|
529
|
+
// No explicit/synthesized prompt: render the structured `payload.prompt_spec`
|
|
530
|
+
// against the prepared worktree (the `spec_review` path). A malformed spec
|
|
531
|
+
// fails loud with `ContractError.Prompt` so we `/fail` rather than spawn a
|
|
532
|
+
// worker with an empty prompt. Error messages are secret-free by construction.
|
|
533
|
+
const renderPrompt = seams.renderPromptSpecPrompt ?? renderPromptSpecPrompt;
|
|
534
|
+
try {
|
|
535
|
+
const rendered = await renderPrompt(job, {
|
|
536
|
+
worktreePath: wt.worktreePath,
|
|
537
|
+
workBranch: wt.branch,
|
|
538
|
+
baseBranch: options.baseBranch,
|
|
539
|
+
runCommand: deps.runCommand,
|
|
540
|
+
});
|
|
541
|
+
return { ok: true, worktreePath: wt.worktreePath, branch: wt.branch, prompt: rendered };
|
|
542
|
+
}
|
|
543
|
+
catch (err) {
|
|
544
|
+
const failure = isExecutorNamedError(err)
|
|
545
|
+
? toExecutorFailure(err)
|
|
546
|
+
: {
|
|
547
|
+
error_kind: "ContractError.Prompt",
|
|
548
|
+
error_message: secretFreeErrorMessage(err),
|
|
549
|
+
classification: "crashed",
|
|
550
|
+
};
|
|
551
|
+
await httpClient.fail(job, failure);
|
|
552
|
+
return { ok: false, result: { status: "failed", reason: "prompt_contract" } };
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
async function runSpawnJob(job, httpClient, options, deps, ownership, observation, seams) {
|
|
556
|
+
// --- Worktree + prompt (resume pre-spawn protocol or standard) -------
|
|
557
|
+
const prep = await prepareSpawn(job, httpClient, options, deps, seams);
|
|
558
|
+
if (!prep.ok)
|
|
559
|
+
return prep.result;
|
|
560
|
+
const { worktreePath, branch, prompt } = prep;
|
|
561
|
+
// --- Timeout contract ------------------------------------------------
|
|
562
|
+
const timeout = resolveJobTimeoutSeconds(job, options.defaultJobTimeoutSeconds);
|
|
563
|
+
if (!timeout.ok) {
|
|
564
|
+
await httpClient.fail(job, {
|
|
565
|
+
error_kind: "ContractError.Timeout",
|
|
566
|
+
error_message: timeout.error,
|
|
567
|
+
classification: "crashed",
|
|
568
|
+
});
|
|
569
|
+
return { status: "failed", reason: "timeout_contract" };
|
|
570
|
+
}
|
|
571
|
+
// --- Deny layer (fail-open) ------------------------------------------
|
|
572
|
+
const deny = await provisionExecutorDenyLayer(worktreePath, { baseBranch: options.baseBranch }, {
|
|
573
|
+
readFile: deps.readFile,
|
|
574
|
+
writeFile: deps.writeFile,
|
|
575
|
+
mkdir: deps.mkdir,
|
|
576
|
+
homedir: deps.homedir,
|
|
577
|
+
});
|
|
578
|
+
if (!deny.ok)
|
|
579
|
+
deps.errorLog(deny.warning);
|
|
580
|
+
// --- Worker log tee + watch registry + viewer tab (BAPI-535 §8) ------
|
|
581
|
+
const registryDeps = buildJobLogRegistryDeps(deps);
|
|
582
|
+
const markFinished = seams.markExecutorJobLogFinished ?? markExecutorJobLogFinished;
|
|
583
|
+
const createTee = seams.createWorkerLogTee ?? createWorkerLogTee;
|
|
584
|
+
let tee = null;
|
|
585
|
+
try {
|
|
586
|
+
tee = await createTee(worktreePath, {
|
|
587
|
+
mkdir: deps.mkdir,
|
|
588
|
+
writeFile: deps.writeFile,
|
|
589
|
+
appendFile: deps.appendFile,
|
|
590
|
+
platform: deps.platform,
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
catch {
|
|
594
|
+
tee = null;
|
|
595
|
+
}
|
|
596
|
+
if (tee) {
|
|
597
|
+
const registerLog = seams.registerExecutorJobLog ?? registerExecutorJobLog;
|
|
598
|
+
try {
|
|
599
|
+
await registerLog({
|
|
600
|
+
job_id: job.id,
|
|
601
|
+
repo_name: job.repo_name,
|
|
602
|
+
job_type: job.job_type,
|
|
603
|
+
ticket_key: job.ticket_key ?? undefined,
|
|
604
|
+
worktree_path: worktreePath,
|
|
605
|
+
log_path: tee.logPath,
|
|
606
|
+
started_at: nowIso(deps),
|
|
607
|
+
}, registryDeps);
|
|
608
|
+
}
|
|
609
|
+
catch {
|
|
610
|
+
/* registry is a visibility nicety — never fail the job */
|
|
611
|
+
}
|
|
612
|
+
if (executorViewerTabsEnabled(deps.platform, deps.env)) {
|
|
613
|
+
const openViewer = seams.openExecutorViewerTab ?? openExecutorViewerTab;
|
|
614
|
+
try {
|
|
615
|
+
const viewer = await openViewer({
|
|
616
|
+
platform: deps.platform,
|
|
617
|
+
env: deps.env,
|
|
618
|
+
workerLogPath: tee.logPath,
|
|
619
|
+
worktreePath,
|
|
620
|
+
ticketKey: job.ticket_key ?? undefined,
|
|
621
|
+
runCommand: deps.runCommand,
|
|
622
|
+
cwd: deps.cwd,
|
|
623
|
+
});
|
|
624
|
+
if (viewer.status === "failed") {
|
|
625
|
+
deps.errorLog(`executor viewer tab failed (non-fatal): ${viewer.reason}`);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
catch (err) {
|
|
629
|
+
deps.errorLog(`executor viewer tab error (non-fatal): ${secretFreeErrorMessage(err)}`);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
const finalizeRegistry = async () => {
|
|
634
|
+
if (!tee)
|
|
635
|
+
return;
|
|
636
|
+
try {
|
|
637
|
+
await markFinished(job.id, nowIso(deps), registryDeps);
|
|
638
|
+
}
|
|
639
|
+
catch {
|
|
640
|
+
/* non-fatal */
|
|
641
|
+
}
|
|
642
|
+
};
|
|
643
|
+
// --- Spawn -----------------------------------------------------------
|
|
644
|
+
const alias = resolveExecutorModelAlias(job.payload);
|
|
645
|
+
const argv = buildClaudeExecutorArgv(prompt, alias);
|
|
646
|
+
const env = buildExecutorWorkerEnv(deps.env);
|
|
647
|
+
let proc;
|
|
648
|
+
try {
|
|
649
|
+
proc = deps.spawnProcess(CLAUDE_EXECUTABLE, argv, { cwd: worktreePath, env });
|
|
650
|
+
}
|
|
651
|
+
catch (err) {
|
|
652
|
+
await closeWorkerLogTee(tee);
|
|
653
|
+
await finalizeRegistry();
|
|
654
|
+
await httpClient.fail(job, {
|
|
655
|
+
error_kind: "SpawnError",
|
|
656
|
+
error_message: `failed to spawn worker: ${boundedError(err)}`,
|
|
657
|
+
classification: "crashed",
|
|
658
|
+
});
|
|
659
|
+
return { status: "failed", reason: "spawn_failed" };
|
|
660
|
+
}
|
|
661
|
+
// Tee stdout/stderr to the worker log WITHOUT changing what the runner sees.
|
|
662
|
+
const supervisedProc = tee
|
|
663
|
+
? {
|
|
664
|
+
...proc,
|
|
665
|
+
stdout: proc.stdout ? teeAsyncIterable(proc.stdout, tee, "stdout") : null,
|
|
666
|
+
stderr: proc.stderr ? teeAsyncIterable(proc.stderr, tee, "stderr") : null,
|
|
667
|
+
}
|
|
668
|
+
: proc;
|
|
669
|
+
const collectTelemetry = () => collectGitTelemetry({ runCommand: deps.runCommand, now: deps.now }, worktreePath, options.baseBranch);
|
|
670
|
+
const procResult = await superviseProcess({
|
|
671
|
+
job,
|
|
672
|
+
httpClient,
|
|
673
|
+
options,
|
|
674
|
+
deps,
|
|
675
|
+
ownership,
|
|
676
|
+
observation,
|
|
677
|
+
proc: supervisedProc,
|
|
678
|
+
timeoutSeconds: timeout.timeoutSeconds,
|
|
679
|
+
collectTelemetry,
|
|
680
|
+
});
|
|
681
|
+
// Final telemetry snapshot from the worktree.
|
|
682
|
+
observation.setGitTelemetry(await collectTelemetry());
|
|
683
|
+
// Finalize the log tee WITHOUT signaling the worker (it has already exited).
|
|
684
|
+
await closeWorkerLogTee(tee);
|
|
685
|
+
if (ownership.abandoned) {
|
|
686
|
+
await finalizeRegistry();
|
|
687
|
+
return { status: "abandoned", reason: ownership.abandonReason };
|
|
688
|
+
}
|
|
689
|
+
const git = observation.git();
|
|
690
|
+
if (procResult.classification === "clean_exit") {
|
|
691
|
+
// Verdict jobs (`spec_review`) forward the fixed-path artifact verbatim; a
|
|
692
|
+
// missing/invalid artifact is a fail-loud `MissingVerdictArtifact`.
|
|
693
|
+
if (isVerdictJobType(job.job_type)) {
|
|
694
|
+
const readArtifact = seams.readVerdictArtifact ?? readVerdictArtifact;
|
|
695
|
+
let result;
|
|
696
|
+
try {
|
|
697
|
+
result = await readArtifact(worktreePath, { readFile: deps.readFile, platform: deps.platform });
|
|
698
|
+
}
|
|
699
|
+
catch (err) {
|
|
700
|
+
const failure = isExecutorNamedError(err)
|
|
701
|
+
? toExecutorFailure(err)
|
|
702
|
+
: {
|
|
703
|
+
error_kind: MissingVerdictArtifact,
|
|
704
|
+
error_message: secretFreeErrorMessage(err),
|
|
705
|
+
classification: "crashed",
|
|
706
|
+
};
|
|
707
|
+
await finalizeRegistry();
|
|
708
|
+
const terminal = await sendTerminalMutationWithRetry({
|
|
709
|
+
kind: "fail",
|
|
710
|
+
send: () => httpClient.fail(job, { ...failure, ...commitResidue(git), telemetry: observation.snapshot() }),
|
|
711
|
+
deps,
|
|
712
|
+
options,
|
|
713
|
+
ownership,
|
|
714
|
+
log: deps.log,
|
|
715
|
+
});
|
|
716
|
+
return terminalToRunResult(terminal, "failed");
|
|
717
|
+
}
|
|
718
|
+
// BAPI-528 Wave 3 M2: verdict jobs (`spec_review`) also produce a
|
|
719
|
+
// human-readable critique at `<worktree>/.conductor/critique.md`. Read it
|
|
720
|
+
// AFTER the mandatory verdict (result.json) so a missing critique never
|
|
721
|
+
// masks a MissingVerdictArtifact — `readCompletionArtifacts` is fail-open
|
|
722
|
+
// and returns undefined when there is no critique (e.g. smoke), so
|
|
723
|
+
// verdict-only jobs still complete. Without this the critique was written
|
|
724
|
+
// by the worker but never posted, orphaning the W2-6 server-side persist.
|
|
725
|
+
const artifacts = await readCompletionArtifacts(worktreePath, deps);
|
|
726
|
+
const completion = {
|
|
727
|
+
job_type: job.job_type,
|
|
728
|
+
exit_code: procResult.exitCode ?? 0,
|
|
729
|
+
classification: "clean_exit",
|
|
730
|
+
result,
|
|
731
|
+
artifacts,
|
|
732
|
+
...commitResidue(git),
|
|
733
|
+
telemetry: observation.snapshot(),
|
|
734
|
+
};
|
|
735
|
+
const terminal = await sendTerminalMutationWithRetry({
|
|
736
|
+
kind: "complete",
|
|
737
|
+
send: () => httpClient.complete(job, completion),
|
|
738
|
+
deps,
|
|
739
|
+
options,
|
|
740
|
+
ownership,
|
|
741
|
+
log: deps.log,
|
|
742
|
+
});
|
|
743
|
+
await finalizeRegistry();
|
|
744
|
+
return terminalToRunResult(terminal, "completed");
|
|
745
|
+
}
|
|
746
|
+
// Non-verdict spawn jobs (`implement`, `resume`, and the recovery jobs
|
|
747
|
+
// `remediate`/`ci_fix`/`rebase`) use the generic success-result construction;
|
|
748
|
+
// the verdict-artifact path above stays limited to verdict jobs like
|
|
749
|
+
// `spec_review`. Recovery jobs complete through this same generic envelope.
|
|
750
|
+
const artifacts = await readCompletionArtifacts(worktreePath, deps);
|
|
751
|
+
const result = buildGenericSuccessResult({
|
|
752
|
+
summary: `${job.job_type} ${job.ticket_key ?? ""} completed`.trim(),
|
|
753
|
+
branch,
|
|
754
|
+
headSha: git.last_commit_sha,
|
|
755
|
+
localCommitCount: git.local_commit_count,
|
|
756
|
+
});
|
|
757
|
+
// BAPI-551: a worker can exit `clean_exit` yet strand its implementation if
|
|
758
|
+
// its push never landed on origin and no PR was opened (e.g. it backgrounded
|
|
759
|
+
// a slow push and returned early). Convert that into a fail-loud executor
|
|
760
|
+
// failure rather than silently reporting success.
|
|
761
|
+
const finalization = await validateWorkerFinalization({
|
|
762
|
+
job,
|
|
763
|
+
branch,
|
|
764
|
+
worktreePath,
|
|
765
|
+
result,
|
|
766
|
+
runCommand: deps.runCommand,
|
|
767
|
+
headSha: git.last_commit_sha,
|
|
768
|
+
});
|
|
769
|
+
if (!finalization.ok) {
|
|
770
|
+
await finalizeRegistry();
|
|
771
|
+
const terminal = await sendTerminalMutationWithRetry({
|
|
772
|
+
kind: "fail",
|
|
773
|
+
send: () => httpClient.fail(job, {
|
|
774
|
+
...finalization.failure,
|
|
775
|
+
...commitResidue(git),
|
|
776
|
+
telemetry: observation.snapshot(),
|
|
777
|
+
}),
|
|
778
|
+
deps,
|
|
779
|
+
options,
|
|
780
|
+
ownership,
|
|
781
|
+
log: deps.log,
|
|
782
|
+
});
|
|
783
|
+
return terminalToRunResult(terminal, "failed");
|
|
784
|
+
}
|
|
785
|
+
const completion = {
|
|
786
|
+
job_type: job.job_type,
|
|
787
|
+
exit_code: procResult.exitCode ?? 0,
|
|
788
|
+
classification: "clean_exit",
|
|
789
|
+
result,
|
|
790
|
+
artifacts,
|
|
791
|
+
...commitResidue(git),
|
|
792
|
+
telemetry: observation.snapshot(),
|
|
793
|
+
};
|
|
794
|
+
const terminal = await sendTerminalMutationWithRetry({
|
|
795
|
+
kind: "complete",
|
|
796
|
+
send: () => httpClient.complete(job, completion),
|
|
797
|
+
deps,
|
|
798
|
+
options,
|
|
799
|
+
ownership,
|
|
800
|
+
log: deps.log,
|
|
801
|
+
});
|
|
802
|
+
await finalizeRegistry();
|
|
803
|
+
return terminalToRunResult(terminal, "completed");
|
|
804
|
+
}
|
|
805
|
+
// crashed / timeout / killed → fail
|
|
806
|
+
await finalizeRegistry();
|
|
807
|
+
const terminal = await sendTerminalMutationWithRetry({
|
|
808
|
+
kind: "fail",
|
|
809
|
+
send: () => httpClient.fail(job, {
|
|
810
|
+
error_kind: classificationToErrorKind(procResult.classification),
|
|
811
|
+
error_message: `worker exited (${procResult.classification}, exit_code=${procResult.exitCode})`,
|
|
812
|
+
classification: toWireClassification(procResult.classification),
|
|
813
|
+
...commitResidue(git),
|
|
814
|
+
telemetry: observation.snapshot(),
|
|
815
|
+
}),
|
|
816
|
+
deps,
|
|
817
|
+
options,
|
|
818
|
+
ownership,
|
|
819
|
+
log: deps.log,
|
|
820
|
+
});
|
|
821
|
+
return terminalToRunResult(terminal, "failed");
|
|
822
|
+
}
|