@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.
Files changed (100) hide show
  1. package/CONDUCTOR.md +75 -0
  2. package/README.md +8 -5
  3. package/build/agent-capabilities/probe-context.js +13 -3
  4. package/build/agent-capabilities/probes.js +262 -11
  5. package/build/agent-capabilities/reporter.js +1 -0
  6. package/build/agents.generated.js +1 -1
  7. package/build/backend-warnings.js +44 -0
  8. package/build/claude-settings.js +129 -0
  9. package/build/commands.generated.js +5 -3
  10. package/build/conductor/bridge-api-client.js +7 -7
  11. package/build/conductor/cli.js +65 -12
  12. package/build/conductor/deny-enforcement-preflight.js +96 -0
  13. package/build/conductor/doctor.js +183 -2
  14. package/build/conductor/epic-reconcile.js +9 -1
  15. package/build/conductor/epic-runtime.js +403 -43
  16. package/build/conductor/epic-state.js +7 -0
  17. package/build/conductor/errors.js +115 -3
  18. package/build/conductor/event-accessors.js +28 -10
  19. package/build/conductor/local-merge.js +458 -95
  20. package/build/conductor/merge-ledger.js +6 -4
  21. package/build/conductor/pr-ci-producer.js +17 -2
  22. package/build/conductor/producer-ledger.js +1 -1
  23. package/build/conductor/store.js +161 -18
  24. package/build/conductor/supervisor-merge.js +32 -5
  25. package/build/conductor/taxonomy.js +8 -0
  26. package/build/conductor/tools.js +28 -6
  27. package/build/conductor/worker-ledger-cli.js +244 -0
  28. package/build/conductor-bin.js +1884 -6917
  29. package/build/doctor.js +8 -0
  30. package/build/estimate-epic.js +84 -0
  31. package/build/executor/cli.js +229 -0
  32. package/build/executor/credentials.js +65 -0
  33. package/build/executor/deps.js +117 -0
  34. package/build/executor/env.js +79 -0
  35. package/build/executor/heartbeat.js +59 -0
  36. package/build/executor/http-client.js +131 -0
  37. package/build/executor/index.js +10 -0
  38. package/build/executor/job-errors.js +55 -0
  39. package/build/executor/job-log-registry.js +110 -0
  40. package/build/executor/job-runner.js +822 -0
  41. package/build/executor/job-types.js +60 -0
  42. package/build/executor/merge-job.js +229 -0
  43. package/build/executor/observation.js +123 -0
  44. package/build/executor/permissions.js +79 -0
  45. package/build/executor/preflight.js +144 -0
  46. package/build/executor/process.js +81 -0
  47. package/build/executor/prompt-spec.js +235 -0
  48. package/build/executor/results.js +134 -0
  49. package/build/executor/resume-pre-spawn.js +179 -0
  50. package/build/executor/runner.js +98 -0
  51. package/build/executor/terminal-mutation.js +34 -0
  52. package/build/executor/test-clock.js +109 -0
  53. package/build/executor/types.js +18 -0
  54. package/build/executor/verdict-artifact.js +53 -0
  55. package/build/executor/viewer-tabs.js +78 -0
  56. package/build/executor/watch-cli.js +113 -0
  57. package/build/executor/worker-command.js +106 -0
  58. package/build/executor/worker-finalization.js +177 -0
  59. package/build/executor/worker-log.js +92 -0
  60. package/build/executor/worktree-gc.js +134 -0
  61. package/build/executor/worktree-inspection.js +86 -0
  62. package/build/executor/worktree.js +103 -0
  63. package/build/index.js +14534 -10416
  64. package/build/mcp-invoke.js +19 -3
  65. package/build/mcp-provisioning.js +31 -25
  66. package/build/mcp-registration-doctor.js +27 -7
  67. package/build/mcp-server-invocation.js +152 -0
  68. package/build/pipelines.generated.js +16 -20
  69. package/build/readme.generated.js +1 -1
  70. package/build/sfcc/client.js +192 -50
  71. package/build/sfcc/ocapi-write-faults.js +94 -0
  72. package/build/sfcc/permissions.js +7 -22
  73. package/build/sfcc/reads-site-preference.js +52 -19
  74. package/build/sfcc/register.js +9 -0
  75. package/build/sfcc/write-grants.js +80 -0
  76. package/build/sfcc/write-guard.js +39 -0
  77. package/build/sfcc/write-result.js +47 -0
  78. package/build/sfcc/write-tool-common.js +85 -0
  79. package/build/sfcc/writes-custom-object-def.js +141 -0
  80. package/build/sfcc/writes-object-attribute-payloads.js +97 -0
  81. package/build/sfcc/writes-site-preference-payloads.js +59 -0
  82. package/build/sfcc/writes-site-preference.js +96 -0
  83. package/build/sfcc/writes-system-object-payloads.js +213 -0
  84. package/build/sfcc/writes-system-object.js +348 -0
  85. package/build/sfcc/writes.js +66 -0
  86. package/build/start-tickets-conductor.js +25 -93
  87. package/build/start-tickets-prereqs.js +152 -1
  88. package/build/start-tickets.js +96 -158
  89. package/build/version.generated.js +1 -1
  90. package/build/visual-diff-worker.js +313 -0
  91. package/build/visual-diff.js +632 -0
  92. package/build/worktree-core.js +202 -0
  93. package/package.json +8 -4
  94. package/pipelines/idea-to-ticket.json +7 -0
  95. package/pipelines/review-ticket.json +5 -18
  96. package/public/css/main.min.css +1612 -107
  97. package/public/css/main.min.css.map +1 -1
  98. package/public/js/main.min.js +10267 -1
  99. package/public/js/main.min.js.map +1 -1
  100. 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
+ }