@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.
Files changed (81) hide show
  1. package/CONDUCTOR.md +75 -0
  2. package/README.md +2 -2
  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 +1 -0
  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/merge-ledger.js +6 -4
  20. package/build/conductor/pr-ci-producer.js +17 -2
  21. package/build/conductor/producer-ledger.js +1 -1
  22. package/build/conductor/store.js +161 -18
  23. package/build/conductor/supervisor-merge.js +32 -5
  24. package/build/conductor/taxonomy.js +8 -0
  25. package/build/conductor/tools.js +28 -6
  26. package/build/conductor/worker-ledger-cli.js +244 -0
  27. package/build/conductor-bin.js +1884 -6917
  28. package/build/doctor.js +8 -0
  29. package/build/executor/cli.js +229 -0
  30. package/build/executor/credentials.js +65 -0
  31. package/build/executor/deps.js +117 -0
  32. package/build/executor/env.js +79 -0
  33. package/build/executor/heartbeat.js +59 -0
  34. package/build/executor/http-client.js +131 -0
  35. package/build/executor/index.js +10 -0
  36. package/build/executor/job-errors.js +55 -0
  37. package/build/executor/job-log-registry.js +110 -0
  38. package/build/executor/job-runner.js +688 -0
  39. package/build/executor/job-types.js +60 -0
  40. package/build/executor/merge-job.js +155 -0
  41. package/build/executor/observation.js +123 -0
  42. package/build/executor/permissions.js +79 -0
  43. package/build/executor/preflight.js +144 -0
  44. package/build/executor/process.js +81 -0
  45. package/build/executor/prompt-spec.js +235 -0
  46. package/build/executor/results.js +134 -0
  47. package/build/executor/resume-pre-spawn.js +179 -0
  48. package/build/executor/runner.js +98 -0
  49. package/build/executor/terminal-mutation.js +34 -0
  50. package/build/executor/test-clock.js +109 -0
  51. package/build/executor/types.js +18 -0
  52. package/build/executor/verdict-artifact.js +53 -0
  53. package/build/executor/viewer-tabs.js +78 -0
  54. package/build/executor/watch-cli.js +113 -0
  55. package/build/executor/worker-command.js +106 -0
  56. package/build/executor/worker-finalization.js +97 -0
  57. package/build/executor/worker-log.js +92 -0
  58. package/build/executor/worktree-gc.js +134 -0
  59. package/build/executor/worktree-inspection.js +86 -0
  60. package/build/executor/worktree.js +103 -0
  61. package/build/index.js +11222 -8544
  62. package/build/mcp-invoke.js +19 -3
  63. package/build/mcp-provisioning.js +31 -25
  64. package/build/mcp-registration-doctor.js +27 -7
  65. package/build/mcp-server-invocation.js +152 -0
  66. package/build/pipelines.generated.js +1 -1
  67. package/build/readme.generated.js +1 -1
  68. package/build/sfcc/reads-site-preference.js +52 -19
  69. package/build/start-tickets-conductor.js +25 -93
  70. package/build/start-tickets-prereqs.js +152 -1
  71. package/build/start-tickets.js +96 -158
  72. package/build/version.generated.js +1 -1
  73. package/build/visual-diff-worker.js +313 -0
  74. package/build/visual-diff.js +632 -0
  75. package/build/worktree-core.js +202 -0
  76. package/package.json +8 -4
  77. package/public/css/main.min.css +39 -0
  78. package/public/css/main.min.css.map +1 -1
  79. package/public/js/main.min.js +7924 -1
  80. package/public/js/main.min.js.map +1 -1
  81. 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
+ }