@miraland-labs/conduit-bridge 0.12.4 → 0.13.0

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.
@@ -56,6 +56,14 @@ export async function createAttemptWorktree(input) {
56
56
  return path;
57
57
  }
58
58
  export async function removeAttemptWorktree(sourceWorkspace, worktreePath) {
59
+ // Settled repair releases are returned on every assignment poll until the local tree is gone.
60
+ // Make that at-least-once signal cheap and idempotent instead of invoking Git on a missing path.
61
+ try {
62
+ await access(worktreePath, constants.F_OK);
63
+ }
64
+ catch {
65
+ return;
66
+ }
59
67
  try {
60
68
  await execFileAsync("git", ["-C", sourceWorkspace, "worktree", "remove", "--force", worktreePath], { timeout: 60_000, maxBuffer: 2_000_000 });
61
69
  }
package/dist/client.js CHANGED
@@ -48,6 +48,8 @@ export class ConduitClient {
48
48
  leaseExpiresAt: String(data.lease_expires_at),
49
49
  phase: "claimed",
50
50
  ...(extras?.driverId ? { driverId: extras.driverId } : {}),
51
+ ...(extras?.executionKind ? { executionKind: extras.executionKind } : {}),
52
+ ...(extras?.sourceAttemptId ? { sourceAttemptId: extras.sourceAttemptId } : {}),
51
53
  };
52
54
  this.config.activeAttempts[taskId] = active;
53
55
  await this.persistRuntime(this.config);
package/dist/driver.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { existsSync } from "node:fs";
3
- import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { mkdir, readFile, rm, rmdir, writeFile } from "node:fs/promises";
4
4
  import { join } from "node:path";
5
5
  import { z } from "zod";
6
6
  import { deniedCommands, executionClassPromptRules, parsePiJsonl, projectClaude, projectCoarseMode, projectCodex, projectCursor, projectKiroTools, projectPi, requireStampedExecutionClass, } from "./execution-class.js";
@@ -334,6 +334,7 @@ export const claudeCodeDriver = {
334
334
  const projected = projectClaude(executionClass, {
335
335
  grants: input.grants,
336
336
  verificationCommands: input.verificationCommands,
337
+ diagnosis: input.workRole === "diagnose",
337
338
  });
338
339
  // Fail closed: omitting --allowedTools would leave only the deny-list and
339
340
  // broaden the agent beyond the grant contract (privileged grants map to none).
@@ -456,7 +457,10 @@ export const codexDriver = {
456
457
  };
457
458
  }
458
459
  const executionClass = resolveRunClass(input);
459
- const projected = projectCodex(executionClass, { capabilities: input.capabilities });
460
+ const projected = projectCodex(executionClass, {
461
+ capabilities: input.capabilities,
462
+ diagnosis: input.workRole === "diagnose",
463
+ });
460
464
  // Prompt via stdin avoids ARG_MAX limits on large assignment contracts.
461
465
  const { code, stdout, stderr } = await execute(input.executable ?? resolveCodexExecutable(), codexExecArgs({ ...input, networkAccess: projected.networkAccess }, projected.sandbox), input.workspace, input.timeoutMs ?? 20 * 60_000, fuelSource === "conduit" ? input.fuel : undefined, fuelSource, input.prompt);
462
466
  const parsed = parseCodexJsonl(stdout);
@@ -588,6 +592,7 @@ export const cursorDriver = {
588
592
  grants: input.grants,
589
593
  verificationCommands: input.verificationCommands,
590
594
  capabilities: input.capabilities ?? [],
595
+ diagnosis: input.workRole === "diagnose",
591
596
  });
592
597
  const configured = await withCursorPermissions(input.workspace, { allow: projected.allow, deny: projected.deny }, () => execute(executable, cursorRunArgs({ ...input, trustWorkspace, executionClass, force: projected.force }), input.workspace, input.timeoutMs ?? 20 * 60_000, undefined, "local"));
593
598
  const { code, stdout, stderr } = configured;
@@ -602,6 +607,7 @@ export const cursorDriver = {
602
607
  async function withCursorPermissions(workspace, permissions, run) {
603
608
  const configDir = join(workspace, ".cursor");
604
609
  const configPath = join(configDir, "cli.json");
610
+ const configDirExisted = existsSync(configDir);
605
611
  let previous = null;
606
612
  try {
607
613
  previous = await readFile(configPath, "utf8");
@@ -621,6 +627,11 @@ async function withCursorPermissions(workspace, permissions, run) {
621
627
  await rm(configPath, { force: true });
622
628
  else
623
629
  await writeFile(configPath, previous, "utf8");
630
+ // Do not leave control-plane permission scaffolding in a worktree that had no Cursor config.
631
+ // rmdir removes only an empty directory, preserving anything the operator already had or the
632
+ // CLI legitimately created during the run.
633
+ if (!configDirExisted)
634
+ await rmdir(configDir).catch(() => undefined);
624
635
  }
625
636
  }
626
637
  /**
package/dist/drivers.js CHANGED
@@ -16,6 +16,12 @@ const LABEL_TO_DRIVER = {
16
16
  Antigravity: "antigravity",
17
17
  };
18
18
  const LOCAL_FUEL_ONLY_DRIVERS = new Set(["cursor", "pi", "kiro", "antigravity"]);
19
+ /**
20
+ * Drivers that can combine repository reads with bounded verification while denying source edits.
21
+ * Coarse build/bash modes are not enough for Invariant 23: a diagnosis carries `test_run`, but it
22
+ * must never acquire `repo_write` as an implementation detail of the selected lane.
23
+ */
24
+ const READ_ONLY_DIAGNOSIS_DRIVERS = new Set(["claude-code", "codex", "cursor"]);
19
25
  export function isSupportedDriverId(id) {
20
26
  return Object.prototype.hasOwnProperty.call(DRIVERS, id);
21
27
  }
@@ -25,6 +31,9 @@ export function driverLabel(id) {
25
31
  export function localFuelOnlyDriver(id) {
26
32
  return LOCAL_FUEL_ONLY_DRIVERS.has(id);
27
33
  }
34
+ export function supportsReadOnlyDiagnosis(id) {
35
+ return READ_ONLY_DIAGNOSIS_DRIVERS.has(id);
36
+ }
28
37
  /** Map PATH probe labels to unique driver ids. */
29
38
  export function driverIdsFromDetectedLabels(labels) {
30
39
  const ids = new Set();
@@ -148,9 +157,13 @@ export function driversHeartbeatReport(config, activeAttempts, processOnlineIds)
148
157
  * Pick an online driver for a new claim: least loaded among online (shared machine pool).
149
158
  * `processOnlineIds` overrides config online lanes (tests / harness).
150
159
  */
151
- export function pickDriverForClaim(config, processOnlineIds) {
152
- const online = (processOnlineIds?.length ? processOnlineIds : onlineDriverIds(config))
153
- .filter((id) => isSupportedDriverId(id));
160
+ export function pickDriverForClaim(config, processOnlineIds, eligible = () => true) {
161
+ const candidates = processOnlineIds === undefined || processOnlineIds === null
162
+ ? onlineDriverIds(config)
163
+ : processOnlineIds;
164
+ const online = candidates
165
+ .filter((id) => isSupportedDriverId(id))
166
+ .filter(eligible);
154
167
  if (!online.length)
155
168
  return null;
156
169
  const load = new Map();
@@ -126,8 +126,13 @@ export function projectCursor(executionClass, input) {
126
126
  ? landCommands.map((command) => `Shell(${command})`)
127
127
  : []),
128
128
  ];
129
- if (executionClass === "observe" || executionClass === "observe_network") {
129
+ if (executionClass === "observe" || executionClass === "observe_network" || input.diagnosis) {
130
130
  deny.push("Shell(*)", "Write(**)");
131
+ if (input.diagnosis) {
132
+ // Re-open only the bounded verification commands below. Diagnosis may run checks, never an
133
+ // arbitrary shell or Cursor's write tool.
134
+ deny.splice(deny.indexOf("Shell(*)"), 1);
135
+ }
131
136
  }
132
137
  const allow = [];
133
138
  if (allowsExternalFetch(executionClass, capabilities))
@@ -175,6 +180,7 @@ export function projectClaude(executionClass, input) {
175
180
  return {
176
181
  allowedTools: [
177
182
  "Read",
183
+ ...(input.diagnosis ? ["Glob", "Grep"] : []),
178
184
  ...verificationCommands.filter(isBoundedVerificationCommand).map((command) => `Bash(${command}:*)`),
179
185
  ...fetchTools,
180
186
  ],
@@ -207,7 +213,7 @@ export function projectCodex(executionClass, input) {
207
213
  const networkAccess = allowsExternalFetch(executionClass, capabilities);
208
214
  // Read-only is the sandbox for the one class that may not write; everything else needs the
209
215
  // workspace. Network is the shared predicate, so Codex cannot drift from Cursor and Claude again.
210
- if (executionClass === "observe")
216
+ if (executionClass === "observe" || input.diagnosis)
211
217
  return { sandbox: "read-only", networkAccess };
212
218
  return { sandbox: "workspace-write", networkAccess };
213
219
  }
package/dist/execution.js CHANGED
@@ -2,9 +2,9 @@ import { createHash } from "node:crypto";
2
2
  import { z } from "zod";
3
3
  import { ConduitRequestError } from "./client.js";
4
4
  import { redactSecrets } from "./config.js";
5
- import { DRIVERS, agentReportTemplate, buildAssignmentPrompt, evidenceKinds, fuelEndpoint, normalizeEvidenceDigest, parseAgentReport, pickModelCandidate, requireStampedExecutionClass, tierForRisk } from "./driver.js";
5
+ import { DRIVERS, agentReportTemplate, buildAssignmentPrompt, evidenceKinds, extractAgentReportJsonText, fuelEndpoint, normalizeEvidenceDigest, parseAgentReport, parseJsonObjectCandidate, pickModelCandidate, requireStampedExecutionClass, tierForRisk } from "./driver.js";
6
6
  import { assertClassFloor } from "./execution-class.js";
7
- import { pickDriverForClaim, resolveDriverFuel } from "./drivers.js";
7
+ import { pickDriverForClaim, resolveDriverFuel, supportsReadOnlyDiagnosis } from "./drivers.js";
8
8
  import { attemptWorktreePath, createAttemptWorktree, proveResumeWorktree, quarantineAttemptWorktree, removeAttemptWorktree, } from "./attempt-worktree.js";
9
9
  import { buildWorkspaceBrief, ensureCommitAvailable, normalizeRepositoryUrl, resolveAttemptStartCommit } from "./brief.js";
10
10
  import { ensureDeliveryPullRequest } from "./ensure-pull-request.js";
@@ -62,10 +62,13 @@ const assignmentSchema = z.object({
62
62
  id: z.string().uuid(),
63
63
  attempt_id: z.string().uuid(),
64
64
  execution_mode: z.enum(["agent", "human"]).optional().default("agent"),
65
+ execution_kind: z.enum(["authoring", "diagnosis"]).optional().default("authoring"),
66
+ source_attempt_id: z.string().uuid().nullable().optional().default(null),
65
67
  repository_fingerprint: z.string().nullable().optional().default(null),
66
68
  requested_base_commit: z.string().nullable().optional().default(null),
67
69
  claimed_head: z.string().nullable().optional().default(null),
68
70
  });
71
+ const releasedWorktreeAttemptIdsSchema = z.array(z.string().uuid()).max(50);
69
72
  const taskDetailSchema = z.object({
70
73
  objective: z.string(),
71
74
  project_id: z.string().uuid(),
@@ -74,6 +77,8 @@ const taskDetailSchema = z.object({
74
77
  delivery_state: z.string(),
75
78
  delivery_summary: z.string().nullable(),
76
79
  execution_mode: z.enum(["agent", "human"]).optional().default("agent"),
80
+ execution_kind: z.enum(["authoring", "diagnosis"]).optional().default("authoring"),
81
+ source_attempt_id: z.string().uuid().nullable().optional().default(null),
77
82
  });
78
83
  const taskSpecSchema = z.object({
79
84
  goal: z.string().optional(), scope: z.array(z.string()).optional(), boundaries: z.array(z.string()).optional(),
@@ -94,6 +99,7 @@ const executionContractSchema = z.object({
94
99
  repository_fingerprint: z.string().nullable().optional().default(null),
95
100
  requested_base_commit: z.string().nullable().optional().default(null),
96
101
  claimed_head: z.string().nullable().optional().default(null),
102
+ source_attempt_id: z.string().uuid().nullable().optional().default(null),
97
103
  });
98
104
  const workPackageSchema = z.object({
99
105
  work_role: z.string().nullable().optional(),
@@ -111,8 +117,104 @@ const workPackageSchema = z.object({
111
117
  }).optional(),
112
118
  decisions: z.array(z.object({ question: z.string(), decision: z.string() })).optional(),
113
119
  rework_feedback: z.string().nullable().optional(),
120
+ diagnostic_context: z.object({
121
+ source_attempt_id: z.string().uuid(),
122
+ failure_output: z.string().min(1).max(12_000),
123
+ }).optional(),
114
124
  working_language: z.enum(["en", "zh"]).optional(),
115
125
  }).nullable().optional();
126
+ const diagnosticRepairBriefSchema = z.object({
127
+ root_cause: z.string().trim().min(1).max(2_000),
128
+ wrong_approach: z.string().trim().max(2_000).default(""),
129
+ fix_direction: z.string().trim().min(1).max(4_000),
130
+ do_not_touch: z.array(z.string().trim().min(1).max(400)).max(10).default([]),
131
+ confident: z.boolean().default(true),
132
+ });
133
+ function diagnosticText(value, max) {
134
+ if (typeof value === "string")
135
+ return value.trim().slice(0, max);
136
+ if (!Array.isArray(value))
137
+ return undefined;
138
+ const parts = value.filter((item) => typeof item === "string")
139
+ .map((item) => item.trim()).filter(Boolean);
140
+ if (!parts.length)
141
+ return undefined;
142
+ return (parts.length === 1 ? parts[0] : parts.map((part, index) => `${index + 1}. ${part}`).join("\n"))
143
+ .slice(0, max);
144
+ }
145
+ /** Parse the diagnostic agent's final JSON with the same field-shape tolerance as the CP. */
146
+ export function parseDiagnosticRepairBrief(text) {
147
+ let raw = parseJsonObjectCandidate(extractAgentReportJsonText(text));
148
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
149
+ throw new Error("Diagnostic agent did not emit a RepairBrief object");
150
+ }
151
+ let obj = { ...raw };
152
+ const keys = Object.keys(obj);
153
+ if (keys.length === 1 && obj[keys[0]] && typeof obj[keys[0]] === "object"
154
+ && !Array.isArray(obj[keys[0]]) && "fix_direction" in obj[keys[0]]) {
155
+ obj = { ...obj[keys[0]] };
156
+ }
157
+ for (const [field, max] of [["root_cause", 2_000], ["wrong_approach", 2_000], ["fix_direction", 4_000]]) {
158
+ const value = diagnosticText(obj[field], max);
159
+ if (value !== undefined)
160
+ obj[field] = value;
161
+ }
162
+ if (obj.wrong_approach == null)
163
+ obj.wrong_approach = "";
164
+ obj.do_not_touch = Array.isArray(obj.do_not_touch)
165
+ ? obj.do_not_touch.filter((item) => typeof item === "string")
166
+ .map((item) => item.trim().slice(0, 400)).filter(Boolean).slice(0, 10)
167
+ : typeof obj.do_not_touch === "string" && obj.do_not_touch.trim()
168
+ ? [obj.do_not_touch.trim().slice(0, 400)]
169
+ : [];
170
+ if (typeof obj.confident === "string") {
171
+ const token = obj.confident.trim().toLowerCase();
172
+ if (token === "true" || token === "yes")
173
+ obj.confident = true;
174
+ else if (token === "false" || token === "no")
175
+ obj.confident = false;
176
+ }
177
+ const parsed = diagnosticRepairBriefSchema.safeParse(obj);
178
+ if (!parsed.success) {
179
+ const issue = parsed.error.issues[0];
180
+ throw new Error(`Diagnostic RepairBrief is invalid${issue?.path.length ? ` at ${issue.path.join(".")}` : ""}: ${issue?.message ?? "unknown shape"}`);
181
+ }
182
+ return parsed.data;
183
+ }
184
+ /** Prompt for Option C: IDE-depth understanding in the retained tree, with write authority absent. */
185
+ export function buildDiagnosticPrompt(input) {
186
+ const pkg = input.workPackage;
187
+ const goal = pkg?.goal ?? input.spec.goal ?? input.objective;
188
+ const scope = pkg?.scope?.length ? pkg.scope : input.spec.scope ?? [];
189
+ const boundaries = pkg?.boundaries?.length ? pkg.boundaries : input.spec.boundaries ?? [];
190
+ const acceptance = pkg?.acceptance?.length ? pkg.acceptance : input.spec.acceptance ?? [];
191
+ const changeScope = pkg?.change_scope?.length ? pkg.change_scope : input.spec.change_scope ?? [];
192
+ const decisions = pkg?.decisions ?? [];
193
+ return [
194
+ "You are Conductor's read-only diagnosis lane. Diagnose the exact failed authoring run in the current retained worktree.",
195
+ "Inspect the actual code, git diff, nearby callers, and relevant tests. Run only the supplied project verification commands when useful.",
196
+ "You have repo_read and test_run only. Do not edit, create, delete, format, commit, branch, push, install, or change any file.",
197
+ "Do not propose owner-authored compiler constraints and do not return a Delivery Packet. Your only deliverable is a grounded RepairBrief for the next authoring agent.",
198
+ "The RepairBrief crosses to the control plane: name files, symbols, and fix steps, but do not quote secrets or copy file bodies into it.",
199
+ "If the worktree does not support a grounded diagnosis, set confident to false; never guess.",
200
+ "",
201
+ `TASK ${input.taskId}`,
202
+ `GOAL\n${goal}`,
203
+ ...(scope.length ? ["", `APPROVED SCOPE\n${scope.map((item) => `- ${item}`).join("\n")}`] : []),
204
+ ...(boundaries.length ? ["", `BOUNDARIES\n${boundaries.map((item) => `- ${item}`).join("\n")}`] : []),
205
+ ...(acceptance.length ? ["", `APPROVED ACCEPTANCE CRITERIA\n${acceptance.map((item) => `- ${item}`).join("\n")}`] : []),
206
+ ...(changeScope.length ? ["", `APPROVED CHANGE SCOPE\n${changeScope.map((item) => `- ${item}`).join("\n")}`] : []),
207
+ ...(decisions.length ? ["", `OWNER DECISIONS\n${decisions.map((item) => `- ${item.question}: ${item.decision}`).join("\n")}`] : []),
208
+ ...(input.verificationCommands.length ? ["", `BOUNDED VERIFICATION COMMANDS\n${input.verificationCommands.map((item) => `- ${item}`).join("\n")}`] : []),
209
+ "",
210
+ "WITNESSED FAILURE OUTPUT",
211
+ redactSecrets(input.failureOutput),
212
+ "",
213
+ "Return only one fenced ```json object with exactly this shape:",
214
+ '{"root_cause":"specific cause grounded in files/symbols","wrong_approach":"what the failed run did wrong","fix_direction":"ordered fix instructions for the authoring agent","do_not_touch":["out-of-scope paths or behavior"],"confident":true}',
215
+ "root_cause, wrong_approach, and fix_direction are strings, not arrays. Close the fence and add no prose after it.",
216
+ ].join("\n");
217
+ }
116
218
  export async function renewLeases(client, config) {
117
219
  for (const active of Object.values(config.activeAttempts)) {
118
220
  if (Date.parse(active.leaseExpiresAt) - Date.now() < 120_000) {
@@ -142,6 +244,26 @@ export async function recoverActiveAttempt(client, config, driver, workspace, br
142
244
  const active = taskId ? config.activeAttempts[taskId] : Object.values(config.activeAttempts)[0];
143
245
  if (!active)
144
246
  return false;
247
+ // A terminal payload is already complete and replay-safe. Replaying it must not depend on the
248
+ // driver that produced it still being installed or online after a Bridge restart.
249
+ if (active.phase === "terminal_pending") {
250
+ const response = await flushTerminal(client, active.taskId);
251
+ const diagnosis = active.executionKind === "diagnosis";
252
+ const worktreeOwner = diagnosis ? active.sourceAttemptId : active.attemptId;
253
+ const worktree = active.worktreePath ?? (worktreeOwner ? attemptWorktreePath(workspace, worktreeOwner) : "");
254
+ if (diagnosis && !retainDiagnosticWorktree(response) && worktree) {
255
+ await removeAttemptWorktree(workspace, worktree).catch(() => undefined);
256
+ }
257
+ else if (!diagnosis && response.retain_worktree !== true && worktree) {
258
+ if (active.terminal?.action === "fail") {
259
+ await quarantineAttemptWorktree(workspace, worktree, active.attemptId).catch(() => removeAttemptWorktree(workspace, worktree).catch(() => undefined));
260
+ }
261
+ else {
262
+ await removeAttemptWorktree(workspace, worktree).catch(() => undefined);
263
+ }
264
+ }
265
+ return true;
266
+ }
145
267
  const laneDriver = resolveAttemptDriver(config, active, driver);
146
268
  if (!laneDriver) {
147
269
  console.error(`No driver for attempt ${active.attemptId} (driverId=${active.driverId ?? "unset"})`);
@@ -153,10 +275,12 @@ export async function recoverActiveAttempt(client, config, driver, workspace, br
153
275
  await client.updateAttempt(active.taskId, { driverId: assigned });
154
276
  }
155
277
  if (active.phase === "agent_running") {
156
- const worktree = active.worktreePath ?? attemptWorktreePath(workspace, active.attemptId);
278
+ const diagnosis = active.executionKind === "diagnosis";
279
+ const worktreeOwner = diagnosis ? active.sourceAttemptId : active.attemptId;
280
+ const worktree = active.worktreePath ?? (worktreeOwner ? attemptWorktreePath(workspace, worktreeOwner) : "");
157
281
  const sessionId = config.sessions?.[active.taskId]?.trim() || "";
158
282
  // F-07 safe resume: only when session + worktree identity are both proven. Otherwise interrupt.
159
- if (sessionId && await proveResumeWorktree(workspace, active.attemptId, worktree)) {
283
+ if (worktreeOwner && sessionId && await proveResumeWorktree(workspace, worktreeOwner, worktree)) {
160
284
  console.log(`Resuming interrupted attempt ${active.attemptId} with proven worktree and session.`);
161
285
  await runClaimedAssignment(client, config, laneDriver, workspace, brief, active.taskId, timeoutMs, supervision, {
162
286
  existingWorktree: worktree,
@@ -164,22 +288,29 @@ export async function recoverActiveAttempt(client, config, driver, workspace, br
164
288
  });
165
289
  return true;
166
290
  }
167
- await quarantineAttemptWorktree(workspace, worktree, active.attemptId).catch(() => removeAttemptWorktree(workspace, worktree).catch(() => undefined));
168
- await client.updateAttempt(active.taskId, { worktreePath: undefined });
169
- await queueTerminal(client, active.taskId, {
291
+ const response = await queueTerminal(client, active.taskId, {
170
292
  action: "fail",
171
- body: { error: "Bridge restarted during agent execution; Conduit may retry this work safely.", idempotency_key: `bridge:restart:${active.attemptId}` },
293
+ body: {
294
+ error: diagnosis
295
+ ? "Bridge restarted during read-only diagnosis; Conduit may retry diagnosis safely."
296
+ : "Bridge restarted during agent execution; Conduit may retry this work safely.",
297
+ idempotency_key: `bridge:restart:${active.attemptId}`,
298
+ },
172
299
  });
300
+ if (diagnosis && !retainDiagnosticWorktree(response) && worktree) {
301
+ await removeAttemptWorktree(workspace, worktree).catch(() => undefined);
302
+ }
303
+ else if (!diagnosis && response.retain_worktree !== true && worktree) {
304
+ // Do not quarantine before the terminal response: Conduit may need this exact tree for a
305
+ // pinned diagnosis even when Bridge restarted before it could preserve the agent session.
306
+ await quarantineAttemptWorktree(workspace, worktree, active.attemptId).catch(() => removeAttemptWorktree(workspace, worktree).catch(() => undefined));
307
+ }
173
308
  return true;
174
309
  }
175
310
  if (client.attempt(active.taskId).phase === "agent_finished") {
176
311
  await submitFinishedDelivery(client, active.taskId);
177
312
  return true;
178
313
  }
179
- if (client.attempt(active.taskId).phase === "terminal_pending") {
180
- await flushTerminal(client, active.taskId);
181
- return true;
182
- }
183
314
  await runClaimedAssignment(client, config, laneDriver, workspace, brief, active.taskId, timeoutMs, supervision);
184
315
  return true;
185
316
  }
@@ -199,8 +330,10 @@ export async function pumpExecutionSlots(client, config, workspace, brief, timeo
199
330
  if (running.has(id))
200
331
  continue;
201
332
  const active = config.activeAttempts[id];
202
- const laneDriver = resolveAttemptDriver(config, active, fallbackDriver);
203
- if (!laneDriver) {
333
+ const laneDriver = active.phase === "terminal_pending"
334
+ ? fallbackDriver
335
+ : resolveAttemptDriver(config, active, fallbackDriver);
336
+ if (!laneDriver && active.phase !== "terminal_pending") {
204
337
  console.error(`Slot recovery skipped for ${id}: no driver lane`);
205
338
  continue;
206
339
  }
@@ -228,9 +361,12 @@ export async function pumpExecutionSlots(client, config, workspace, brief, timeo
228
361
  }
229
362
  if (!laneDriver || !driverId)
230
363
  break;
231
- const taskId = await claimNextAssignment(client, config, workspace, brief, driverId);
232
- if (!taskId)
364
+ const claimed = await claimNextAssignment(client, config, workspace, brief, driverId, processOnlineIds);
365
+ if (!claimed)
233
366
  break;
367
+ const taskId = claimed.taskId;
368
+ driverId = claimed.driverId ?? driverId;
369
+ laneDriver = driverId ? DRIVERS[driverId] ?? laneDriver : laneDriver;
234
370
  progressed = true;
235
371
  console.log(`Executing ${taskId} via ${laneDriver.name}`);
236
372
  const slot = runClaimedAssignment(client, config, laneDriver, workspace, brief, taskId, timeoutMs, supervision)
@@ -244,10 +380,17 @@ export async function pumpExecutionSlots(client, config, workspace, brief, timeo
244
380
  return progressed;
245
381
  }
246
382
  /** Claim one assignment when under capacity; does not start the agent (multi-slot pump does). */
247
- export async function claimNextAssignment(client, config, workspace, brief, driverId) {
383
+ export async function claimNextAssignment(client, config, workspace, brief, driverId, processOnlineIds) {
248
384
  if (Object.keys(config.activeAttempts).length >= config.leaseCapacity)
249
385
  return null;
250
386
  const data = await client.request("/runner/v1/assignments");
387
+ const releasedWorktrees = releasedWorktreeAttemptIdsSchema.parse(data.released_worktree_attempt_ids ?? []);
388
+ for (const attemptId of releasedWorktrees) {
389
+ const worktree = attemptWorktreePath(workspace, attemptId);
390
+ await removeAttemptWorktree(workspace, worktree)
391
+ .then(() => console.log(`Released settled diagnostic worktree ${worktree}`))
392
+ .catch((error) => console.error(`Diagnostic worktree release failed: ${redactSecrets(error instanceof Error ? error.message : "unknown")}`));
393
+ }
251
394
  const assignments = z.array(assignmentSchema).parse(data.assignments ?? []);
252
395
  const assignment = assignments[0];
253
396
  if (!assignment)
@@ -256,18 +399,25 @@ export async function claimNextAssignment(client, config, workspace, brief, driv
256
399
  console.log(`Human takeover assignment ${assignment.id} — use Bridge MCP tools to claim and submit (agent runner skips).`);
257
400
  return null;
258
401
  }
402
+ let selectedDriverId = driverId ?? null;
403
+ if (assignment.execution_kind === "diagnosis" && selectedDriverId && !supportsReadOnlyDiagnosis(selectedDriverId)) {
404
+ // Prefer a lane that can honour repo_read + test_run without silently widening to repo_write.
405
+ // If none is online, claim on the selected lane so runClaimedAssignment can create a durable,
406
+ // owner-safe Hold and refund the diagnostic slot instead of leaving an expiring dispatch loop.
407
+ selectedDriverId = pickDriverForClaim(config, processOnlineIds, supportsReadOnlyDiagnosis) ?? selectedDriverId;
408
+ }
259
409
  const liveBrief = await buildWorkspaceBrief(workspace).catch(() => brief);
260
410
  const liveRepository = liveBrief?.repository ? normalizeRepositoryUrl(liveBrief.repository) : null;
261
411
  let rejection = null;
262
412
  if (assignment.repository_fingerprint && liveRepository !== assignment.repository_fingerprint)
263
413
  rejection = "workspace_repository_mismatch";
264
- else if (assignment.repository_fingerprint && assignment.claimed_head && liveBrief?.base_commit !== assignment.claimed_head) {
414
+ else if (assignment.execution_kind === "authoring" && assignment.repository_fingerprint && assignment.claimed_head && liveBrief?.base_commit !== assignment.claimed_head) {
265
415
  // Rework / advanced base may claim a head that differs from the source checkout HEAD.
266
416
  const reachable = await ensureCommitAvailable(workspace, assignment.claimed_head).catch(() => false);
267
417
  if (!reachable)
268
418
  rejection = "workspace_head_changed";
269
419
  }
270
- if (!rejection && assignment.requested_base_commit && assignment.claimed_head) {
420
+ if (!rejection && assignment.execution_kind === "authoring" && assignment.requested_base_commit && assignment.claimed_head) {
271
421
  const startHead = await resolveAttemptStartCommit(workspace, assignment.requested_base_commit, assignment.claimed_head);
272
422
  if (!startHead)
273
423
  rejection = "base_not_ancestor";
@@ -280,17 +430,21 @@ export async function claimNextAssignment(client, config, workspace, brief, driv
280
430
  console.error(`Assignment ${assignment.id} rejected before claim: ${rejection}`);
281
431
  return null;
282
432
  }
283
- console.log(`Claiming assignment ${assignment.id} (attempt ${assignment.attempt_id})`);
284
- await client.claim(assignment.id, assignment.attempt_id, driverId ? { driverId } : undefined);
285
- return assignment.id;
433
+ console.log(`Claiming ${assignment.execution_kind} assignment ${assignment.id} (attempt ${assignment.attempt_id})`);
434
+ await client.claim(assignment.id, assignment.attempt_id, {
435
+ ...(selectedDriverId ? { driverId: selectedDriverId } : {}),
436
+ executionKind: assignment.execution_kind,
437
+ ...(assignment.source_attempt_id ? { sourceAttemptId: assignment.source_attempt_id } : {}),
438
+ });
439
+ return { taskId: assignment.id, driverId: selectedDriverId };
286
440
  }
287
441
  export async function executeNextAssignment(client, config, driver, workspace, brief, timeoutMs, supervision) {
288
442
  if (Object.keys(config.activeAttempts).length)
289
443
  return recoverActiveAttempt(client, config, driver, workspace, brief, timeoutMs, supervision);
290
- const taskId = await claimNextAssignment(client, config, workspace, brief);
291
- if (!taskId)
444
+ const claimed = await claimNextAssignment(client, config, workspace, brief);
445
+ if (!claimed)
292
446
  return false;
293
- await runClaimedAssignment(client, config, driver, workspace, brief, taskId, timeoutMs, supervision);
447
+ await runClaimedAssignment(client, config, driver, workspace, brief, claimed.taskId, timeoutMs, supervision);
294
448
  return true;
295
449
  }
296
450
  async function runClaimedAssignment(client, config, driver, workspace, brief, taskId, timeoutMs, supervision, options = {}) {
@@ -299,12 +453,21 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
299
453
  const task = taskDetailSchema.parse(detail.task);
300
454
  const executionContract = executionContractSchema.parse(detail.execution_contract ?? {});
301
455
  const workPackage = workPackageSchema.parse(detail.work_package) ?? null;
456
+ const executionKind = task.execution_kind;
457
+ const diagnosis = executionKind === "diagnosis";
458
+ const sourceAttemptId = task.source_attempt_id ?? executionContract.source_attempt_id ?? active.sourceAttemptId ?? null;
459
+ if (active.executionKind !== executionKind || active.sourceAttemptId !== (sourceAttemptId ?? undefined)) {
460
+ await client.updateAttempt(taskId, {
461
+ executionKind,
462
+ sourceAttemptId: sourceAttemptId ?? undefined,
463
+ });
464
+ }
302
465
  const parsedSpec = parseTaskSpec(task.spec_json);
303
466
  const deliverable = workPackage?.deliverable ?? parsedSpec.deliverable;
304
467
  const spec = { ...parsedSpec, deliverable };
305
- const artifactDelivery = deliverable === "artifact";
468
+ const artifactDelivery = !diagnosis && deliverable === "artifact";
306
469
  const grants = z.array(z.string()).parse(task.grants_json ? JSON.parse(task.grants_json) : []);
307
- const reworkFeedback = task.delivery_state === "changes_requested" ? changesRequestedFeedback(task.delivery_summary) : null;
470
+ const reworkFeedback = !diagnosis && task.delivery_state === "changes_requested" ? changesRequestedFeedback(task.delivery_summary) : null;
308
471
  // Recompile current state at claim time — earlier packages may have moved the repo.
309
472
  const liveBrief = await buildWorkspaceBrief(workspace).catch(() => brief);
310
473
  const liveRepository = liveBrief?.repository ? normalizeRepositoryUrl(liveBrief.repository) : null;
@@ -313,37 +476,83 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
313
476
  console.error(`Assignment ${taskId} preflight failed: Workspace repository changed after dispatch`);
314
477
  return;
315
478
  }
316
- const startCommit = (() => {
317
- const claimed = executionContract.claimed_head ?? liveBrief?.base_commit ?? null;
318
- return claimed;
319
- })();
320
- if (!startCommit) {
321
- await queueTerminal(client, taskId, { action: "fail", body: { error: "No start commit for attempt worktree", retryable: true, idempotency_key: `bridge:no-start-commit:${active.attemptId}` } });
322
- return;
323
- }
324
- let worktreeStart = startCommit;
325
- if (executionContract.requested_base_commit) {
326
- const resolved = await resolveAttemptStartCommit(workspace, executionContract.requested_base_commit, startCommit);
327
- if (!resolved) {
328
- await queueTerminal(client, taskId, { action: "fail", body: { error: "Required base commit is not available in this workspace", retryable: true, idempotency_key: `bridge:base-not-ancestor:${active.attemptId}` } });
479
+ const startCommit = executionContract.claimed_head ?? liveBrief?.base_commit ?? null;
480
+ let worktreeStart = startCommit ?? "retained-worktree";
481
+ let attemptWorkspace;
482
+ let deliverySubmitted = false;
483
+ // A diagnostic terminal defaults to retention. If its lease disappears, deleting the only copy
484
+ // of the failed code would make the durable retry mechanically impossible.
485
+ let retainAttemptWorktree = diagnosis;
486
+ if (diagnosis) {
487
+ if (!sourceAttemptId) {
488
+ await queueTerminal(client, taskId, {
489
+ action: "fail",
490
+ body: {
491
+ failure: {
492
+ code: "repair_diagnosis_source_missing",
493
+ class: "platform",
494
+ disposition: "stop",
495
+ responsible_party: "conduit",
496
+ message: "Conductor could not locate the failed run for read-only diagnosis.",
497
+ next_action: "No compiler constraints are needed from the owner; inspect Conduit repair history before retrying the task.",
498
+ diagnostic_detail: "Diagnostic assignment omitted source_attempt_id.",
499
+ },
500
+ error: "Diagnostic assignment omitted source_attempt_id",
501
+ retryable: false,
502
+ idempotency_key: `bridge:diagnosis-source-missing:${active.attemptId}`,
503
+ },
504
+ });
329
505
  return;
330
506
  }
331
- worktreeStart = resolved;
332
- }
333
- else if (liveBrief?.base_commit && liveBrief.base_commit !== startCommit) {
334
- const reachable = await ensureCommitAvailable(workspace, startCommit).catch(() => false);
335
- if (!reachable) {
336
- await queueTerminal(client, taskId, { action: "fail", body: { error: "Delivered head is not available in this workspace", retryable: true, idempotency_key: `bridge:workspace-mismatch:${active.attemptId}` } });
337
- console.error(`Assignment ${taskId} preflight failed: Delivered head is not available in this workspace`);
507
+ const retained = options.existingWorktree ?? attemptWorktreePath(workspace, sourceAttemptId);
508
+ if (!(await proveResumeWorktree(workspace, sourceAttemptId, retained))) {
509
+ await queueTerminal(client, taskId, {
510
+ action: "fail",
511
+ body: {
512
+ failure: {
513
+ code: "repair_diagnosis_worktree_missing",
514
+ class: "platform",
515
+ disposition: "stop",
516
+ responsible_party: "conduit",
517
+ message: "Conductor could not access the retained failed run for read-only diagnosis.",
518
+ next_action: "No compiler constraints are needed from the owner; inspect Conduit repair history before retrying the task.",
519
+ diagnostic_detail: `Retained worktree for source attempt ${sourceAttemptId} is unavailable.`,
520
+ },
521
+ error: "Retained failed worktree is unavailable for read-only diagnosis",
522
+ retryable: false,
523
+ idempotency_key: `bridge:diagnosis-worktree-missing:${active.attemptId}`,
524
+ },
525
+ });
338
526
  return;
339
527
  }
528
+ attemptWorkspace = retained;
529
+ worktreeStart = executionContract.claimed_head ?? "retained-worktree";
530
+ await client.updateAttempt(taskId, { worktreePath: attemptWorkspace, executionKind, sourceAttemptId });
340
531
  }
341
- let attemptWorkspace;
342
- let deliverySubmitted = false;
343
- if (options.existingWorktree) {
532
+ else if (options.existingWorktree) {
344
533
  attemptWorkspace = options.existingWorktree;
345
534
  }
346
535
  else {
536
+ if (!startCommit) {
537
+ await queueTerminal(client, taskId, { action: "fail", body: { error: "No start commit for attempt worktree", retryable: true, idempotency_key: `bridge:no-start-commit:${active.attemptId}` } });
538
+ return;
539
+ }
540
+ if (executionContract.requested_base_commit) {
541
+ const resolved = await resolveAttemptStartCommit(workspace, executionContract.requested_base_commit, startCommit);
542
+ if (!resolved) {
543
+ await queueTerminal(client, taskId, { action: "fail", body: { error: "Required base commit is not available in this workspace", retryable: true, idempotency_key: `bridge:base-not-ancestor:${active.attemptId}` } });
544
+ return;
545
+ }
546
+ worktreeStart = resolved;
547
+ }
548
+ else if (liveBrief?.base_commit && liveBrief.base_commit !== startCommit) {
549
+ const reachable = await ensureCommitAvailable(workspace, startCommit).catch(() => false);
550
+ if (!reachable) {
551
+ await queueTerminal(client, taskId, { action: "fail", body: { error: "Delivered head is not available in this workspace", retryable: true, idempotency_key: `bridge:workspace-mismatch:${active.attemptId}` } });
552
+ console.error(`Assignment ${taskId} preflight failed: Delivered head is not available in this workspace`);
553
+ return;
554
+ }
555
+ }
347
556
  try {
348
557
  attemptWorkspace = await createAttemptWorktree({
349
558
  sourceWorkspace: workspace,
@@ -362,6 +571,16 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
362
571
  }
363
572
  await client.updateAttempt(taskId, { worktreePath: attemptWorkspace });
364
573
  }
574
+ // Diagnosis reads manifests and verification commands from the failed tree itself. The source
575
+ // checkout may be clean but stale relative to edits that caused the failure.
576
+ const attemptBrief = diagnosis
577
+ ? await buildWorkspaceBrief(attemptWorkspace).catch(() => liveBrief)
578
+ : liveBrief;
579
+ const removeReleasedDiagnosticWorktree = async (response) => {
580
+ if (diagnosis && !retainDiagnosticWorktree(response)) {
581
+ await removeAttemptWorktree(workspace, attemptWorkspace).catch(() => undefined);
582
+ }
583
+ };
365
584
  // Stamped class is the source of truth (Bridge 0.11+). Do not re-derive from grants.
366
585
  let executionClass;
367
586
  try {
@@ -372,10 +591,11 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
372
591
  console.error(`Assignment ${taskId}: ${detail}`);
373
592
  // Non-retryable: control plane restamps on dispatch/claim. Infinite retry here burned path-4
374
593
  // attempt budget when a pre-inversion task row still lacked the stamp.
375
- await queueTerminal(client, taskId, {
594
+ const response = await queueTerminal(client, taskId, {
376
595
  action: "fail",
377
596
  body: { error: detail, retryable: false, idempotency_key: `bridge:fail:${active.attemptId}:missing-class` },
378
597
  });
598
+ await removeReleasedDiagnosticWorktree(response);
379
599
  return;
380
600
  }
381
601
  // An artifact deliverable that is not publish_artifact cannot write/publish — fail closed.
@@ -393,19 +613,68 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
393
613
  if (!floor.ok) {
394
614
  const detail = `Class floor unmet: ${floor.reason}`;
395
615
  console.error(`Assignment ${taskId}: ${detail}`);
396
- await queueTerminal(client, taskId, {
616
+ const response = await queueTerminal(client, taskId, {
397
617
  action: "fail",
398
618
  body: { error: detail, retryable: false, idempotency_key: `bridge:fail:${active.attemptId}:class-floor` },
399
619
  });
620
+ await removeReleasedDiagnosticWorktree(response);
621
+ return;
622
+ }
623
+ const builtInDriverId = Object.entries(DRIVERS).find(([, candidate]) => candidate === driver)?.[0] ?? null;
624
+ if (diagnosis && builtInDriverId && !supportsReadOnlyDiagnosis(builtInDriverId)) {
625
+ const detail = `${driver.name} cannot combine bounded test execution with enforced read-only repository access.`;
626
+ const response = await queueTerminal(client, taskId, {
627
+ action: "fail",
628
+ body: {
629
+ failure: {
630
+ code: "repair_diagnosis_driver_read_only_unsupported",
631
+ class: "environment",
632
+ disposition: "hold",
633
+ responsible_party: "computer_operator",
634
+ message: "Conductor is waiting for a diagnostic lane that can enforce read-only access.",
635
+ next_action: "Bring a Claude Code, Codex, or Cursor lane online on this computer, then Recheck. Do not author compiler constraints for the agent.",
636
+ diagnostic_detail: detail,
637
+ },
638
+ error: detail,
639
+ retryable: false,
640
+ idempotency_key: `bridge:diagnosis-driver-authority:${active.attemptId}`,
641
+ },
642
+ });
643
+ await removeReleasedDiagnosticWorktree(response);
644
+ console.error(`Assignment ${taskId} diagnosis held: ${detail}`);
645
+ return;
646
+ }
647
+ const diagnosticFailure = workPackage?.diagnostic_context?.failure_output;
648
+ if (diagnosis && !diagnosticFailure) {
649
+ const response = await queueTerminal(client, taskId, {
650
+ action: "fail",
651
+ body: {
652
+ error: "Diagnostic assignment is missing witnessed failure output",
653
+ retryable: true,
654
+ idempotency_key: `bridge:diagnosis-context-missing:${active.attemptId}`,
655
+ },
656
+ });
657
+ await removeReleasedDiagnosticWorktree(response);
400
658
  return;
401
659
  }
402
- const prompt = buildAssignmentPrompt({ taskId, objective: task.objective, spec, grants, workspace: attemptWorkspace, currentHead: worktreeStart, reworkFeedback, workPackage, verificationCommands: liveBrief?.verification ?? [], executionClass });
660
+ const prompt = diagnosis
661
+ ? buildDiagnosticPrompt({
662
+ taskId,
663
+ objective: task.objective,
664
+ spec,
665
+ workPackage,
666
+ failureOutput: diagnosticFailure,
667
+ verificationCommands: attemptBrief?.verification ?? [],
668
+ })
669
+ : buildAssignmentPrompt({ taskId, objective: task.objective, spec, grants, workspace: attemptWorkspace, currentHead: worktreeStart, reworkFeedback, workPackage, verificationCommands: attemptBrief?.verification ?? [], executionClass });
403
670
  const resuming = Boolean(options.forceResumeSessionId);
404
671
  await client.attemptRequest(taskId, "progress", {
405
- phase: "changing",
672
+ phase: diagnosis ? "inspecting" : "changing",
406
673
  message: resuming
407
674
  ? `Resuming ${driver.name} after Bridge restart with proven session and worktree.`
408
- : `Starting ${driver.name} for this assignment${reworkFeedback ? " with review feedback" : ""}.`,
675
+ : diagnosis
676
+ ? `Starting ${driver.name} read-only diagnosis in the retained failed worktree.`
677
+ : `Starting ${driver.name} for this assignment${reworkFeedback ? " with review feedback" : ""}.`,
409
678
  idempotency_key: resuming ? `bridge:progress:${active.attemptId}:resume` : `bridge:progress:${active.attemptId}:start`,
410
679
  });
411
680
  const driverId = active.driverId
@@ -433,7 +702,14 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
433
702
  : `Started ${driver.name}${selection.model ? ` on ${selection.model}` : " on its CLI default"}.`,
434
703
  idempotency_key: resuming ? `bridge:progress:${active.attemptId}:agent-resume` : `bridge:progress:${active.attemptId}:agent-start`,
435
704
  });
436
- await client.updateAttempt(taskId, { phase: "agent_running", fuelMode: fuelSource, worktreePath: attemptWorkspace, driverId });
705
+ await client.updateAttempt(taskId, {
706
+ phase: "agent_running",
707
+ fuelMode: fuelSource,
708
+ worktreePath: attemptWorkspace,
709
+ driverId,
710
+ executionKind,
711
+ sourceAttemptId: sourceAttemptId ?? undefined,
712
+ });
437
713
  const renewTimer = setInterval(() => { void renewLeases(client, config).catch((error) => console.error(`Lease renewal failed: ${redactSecrets(error instanceof Error ? error.message : "unknown")}`)); }, 60_000);
438
714
  let heartbeatRunning = false;
439
715
  const heartbeatTimer = supervision ? setInterval(() => {
@@ -455,9 +731,9 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
455
731
  workspace: attemptWorkspace,
456
732
  grants,
457
733
  capabilities: spec.required_capabilities ?? [],
458
- verificationCommands: liveBrief?.verification ?? [],
734
+ verificationCommands: attemptBrief?.verification ?? [],
459
735
  deliverable,
460
- workRole: workPackage?.work_role ?? spec.work_role,
736
+ workRole: diagnosis ? "diagnose" : workPackage?.work_role ?? spec.work_role,
461
737
  executionClass,
462
738
  resumeSessionId,
463
739
  timeoutMs,
@@ -467,6 +743,50 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
467
743
  });
468
744
  if (result.sessionId)
469
745
  config.sessions = { ...config.sessions, [taskId]: result.sessionId };
746
+ if (diagnosis) {
747
+ if (result.status === "failed") {
748
+ const message = result.error ?? "Read-only diagnosis failed";
749
+ const response = await queueTerminal(client, taskId, {
750
+ action: "fail",
751
+ body: {
752
+ error: message,
753
+ retryable: retryableAgentFailure(message),
754
+ idempotency_key: `bridge:diagnosis-fail:${active.attemptId}`,
755
+ },
756
+ });
757
+ retainAttemptWorktree = retainDiagnosticWorktree(response);
758
+ console.error(`Assignment ${taskId} diagnosis failed: ${redactSecrets(message)}`);
759
+ return;
760
+ }
761
+ let repairBrief;
762
+ try {
763
+ repairBrief = parseDiagnosticRepairBrief(result.resultText ?? "");
764
+ }
765
+ catch (error) {
766
+ const message = error instanceof Error ? error.message : "Diagnostic RepairBrief was invalid";
767
+ const response = await queueTerminal(client, taskId, {
768
+ action: "fail",
769
+ body: {
770
+ error: message,
771
+ retryable: true,
772
+ idempotency_key: `bridge:diagnosis-invalid:${active.attemptId}`,
773
+ },
774
+ });
775
+ retainAttemptWorktree = retainDiagnosticWorktree(response);
776
+ console.error(`Assignment ${taskId} diagnosis returned no usable brief: ${redactSecrets(message)}`);
777
+ return;
778
+ }
779
+ const response = await queueTerminal(client, taskId, {
780
+ action: "complete",
781
+ body: {
782
+ repair_brief: repairBrief,
783
+ idempotency_key: `bridge:diagnosis-complete:${active.attemptId}`,
784
+ },
785
+ });
786
+ retainAttemptWorktree = retainDiagnosticWorktree(response);
787
+ console.log(`Assignment ${taskId} returned a grounded Conductor RepairBrief.`);
788
+ return;
789
+ }
470
790
  if (result.status === "failed") {
471
791
  const agentMessage = result.error ?? "Agent execution failed";
472
792
  // The agent verifies inside its own tool loop, so its compiler/test output never reaches
@@ -474,16 +794,22 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
474
794
  // reason", leaving Conductor nothing to diagnose. Re-run the project's bounded verification
475
795
  // in the attempt worktree to recover the real errors. Best-effort: keep the agent's own
476
796
  // message when there is no command, the command cannot run, or the tree actually verifies.
477
- const verificationDetail = await captureVerificationFailure({
478
- workspace: attemptWorkspace,
479
- verificationCommands: liveBrief?.verification ?? [],
480
- });
797
+ const verificationDetail = grants.includes("test_run")
798
+ ? await captureVerificationFailure({
799
+ workspace: attemptWorkspace,
800
+ verificationCommands: attemptBrief?.verification ?? [],
801
+ })
802
+ : null;
481
803
  const message = verificationDetail
482
804
  ? `Verification failed after the agent run.\n${verificationDetail}`
483
805
  : agentMessage;
484
806
  // Retryability stays keyed to what the agent reported; a recovered verification log describes
485
807
  // the same run and must not silently reclassify an unretryable failure.
486
- await queueTerminal(client, taskId, { action: "fail", body: { error: message, retryable: retryableAgentFailure(agentMessage), idempotency_key: `bridge:fail:${active.attemptId}` } });
808
+ // Keep the tree until the replay-safe terminal response explicitly says whether diagnosis was
809
+ // queued. A network fault here must not delete the evidence before Bridge can replay terminal.
810
+ retainAttemptWorktree = true;
811
+ const response = await queueTerminal(client, taskId, { action: "fail", body: { error: message, retryable: retryableAgentFailure(agentMessage), idempotency_key: `bridge:fail:${active.attemptId}` } });
812
+ retainAttemptWorktree = response.retain_worktree === true;
487
813
  console.error(`Assignment ${taskId} failed: ${redactSecrets(message)}`);
488
814
  return;
489
815
  }
@@ -583,16 +909,25 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
583
909
  report,
584
910
  spec,
585
911
  grants,
586
- verificationCommands: liveBrief?.verification ?? [],
912
+ verificationCommands: attemptBrief?.verification ?? [],
587
913
  });
588
914
  validateDeliveryReport(report, spec, grants);
589
915
  }
590
916
  catch (error) {
591
917
  const message = error instanceof Error ? error.message : "Agent delivery report was invalid";
592
- // classifyFinalizeFailure: forge transport retryable; contract/environment/unknown fail closed
593
- // (unknown prefixed as "Bridge finalize interrupted", not laundered as contract).
594
- const classified = classifyFinalizeFailure(message);
595
- await queueTerminal(client, taskId, {
918
+ // Bridge's own bounded verification can reject an otherwise completed agent run here. That is
919
+ // the same verify-class pre-delivery failure as the driver-failed path above: preserve the
920
+ // exact tree and send the witness marker that arms Conductor diagnosis. Treating it as a
921
+ // delivery-envelope defect would stop before Invariant 23 ever ran.
922
+ const verificationFailed = /^Agent report: Verification failed \(/.test(message);
923
+ const classified = verificationFailed
924
+ ? { retryable: true, error: `Verification failed after the agent run.\n${message}` }
925
+ // classifyFinalizeFailure: forge transport → retryable; contract/environment/unknown → fail
926
+ // closed (unknown prefixed as "Bridge finalize interrupted", not laundered as contract).
927
+ : classifyFinalizeFailure(message);
928
+ if (verificationFailed)
929
+ retainAttemptWorktree = true;
930
+ const response = await queueTerminal(client, taskId, {
596
931
  action: "fail",
597
932
  body: {
598
933
  error: classified.error,
@@ -600,6 +935,8 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
600
935
  idempotency_key: `bridge:invalid-delivery:${active.attemptId}`,
601
936
  },
602
937
  });
938
+ if (verificationFailed)
939
+ retainAttemptWorktree = response.retain_worktree === true;
603
940
  console.error(`Assignment ${taskId} could not produce a valid Delivery: ${redactSecrets(classified.error)}`);
604
941
  const replyTail = reportText.slice(-8_000);
605
942
  console.error(`Assignment ${taskId} agent reply tail (${reportText.length} chars total, redacted): ${redactSecrets(replyTail) || "<empty>"}`);
@@ -614,7 +951,10 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
614
951
  clearInterval(renewTimer);
615
952
  if (heartbeatTimer)
616
953
  clearInterval(heartbeatTimer);
617
- if (artifactDelivery && !deliverySubmitted) {
954
+ if (retainAttemptWorktree) {
955
+ console.log(`Retained failed-attempt worktree for Conductor diagnosis at ${attemptWorkspace}`);
956
+ }
957
+ else if (artifactDelivery && !deliverySubmitted) {
618
958
  console.error(`Artifact delivery was not submitted; retained generated files at ${attemptWorkspace}`);
619
959
  }
620
960
  else {
@@ -883,22 +1223,28 @@ function pathMatchesScope(path, scope) {
883
1223
  }
884
1224
  return path === normalized;
885
1225
  }
1226
+ function retainDiagnosticWorktree(response) {
1227
+ // Lease expiry is reconciled by the CP into another diagnostic attempt. The old terminal cannot
1228
+ // receive that decision, so fail safe by retaining the only copy of the failed code.
1229
+ return response.retain_worktree === true || response.status === "invalid_lease";
1230
+ }
886
1231
  async function queueTerminal(client, taskId, terminal) {
887
1232
  await client.updateAttempt(taskId, { phase: "terminal_pending", terminal });
888
- await flushTerminal(client, taskId);
1233
+ return flushTerminal(client, taskId);
889
1234
  }
890
1235
  export async function flushTerminal(client, taskId) {
891
1236
  const active = client.attempt(taskId);
892
1237
  if (!active.terminal)
893
1238
  throw new Error("Pending terminal operation is missing its replay payload");
894
1239
  try {
895
- await client.attemptRequest(taskId, active.terminal.action, active.terminal.body);
1240
+ const response = await client.attemptRequest(taskId, active.terminal.action, active.terminal.body);
896
1241
  await client.clearAttempt(taskId);
1242
+ return response;
897
1243
  }
898
1244
  catch (error) {
899
1245
  if (error instanceof ConduitRequestError && error.code === "invalid_lease") {
900
1246
  await client.clearAttempt(taskId);
901
- return;
1247
+ return { accepted: false, status: "invalid_lease" };
902
1248
  }
903
1249
  throw error;
904
1250
  }
package/dist/preflight.js CHANGED
@@ -3,7 +3,8 @@ import { promisify } from "node:util";
3
3
  import { buildWorkspaceBrief, normalizeRepositoryUrl } from "./brief.js";
4
4
  import { hasAntigravityLogin, hasClaudeLogin, hasOpenAiLogin, hasOpenCodeLogin, resolveCodexExecutable, } from "./driver.js";
5
5
  import { localFuelOnlyDriver, onlineDriverIds, resolveDriverFuel } from "./drivers.js";
6
- export const BRIDGE_PROTOCOL_VERSION = 2;
6
+ /** Protocol 3: read-only diagnosis can reuse a retained failed-attempt worktree. */
7
+ export const BRIDGE_PROTOCOL_VERSION = 3;
7
8
  const execFileAsync = promisify(execFile);
8
9
  async function defaultCommandRunner(command, args, cwd) {
9
10
  try {
@@ -110,7 +111,7 @@ export async function cachedBridgePreflight(input) {
110
111
  cached = { key, at: Date.now(), report };
111
112
  return report;
112
113
  }
113
- /** Heartbeat-only / missing workspace still reports protocol 2 preflight (not ready). */
114
+ /** Heartbeat-only / missing workspace still reports the current protocol preflight (not ready). */
114
115
  export function unavailableWorkspacePreflight(input) {
115
116
  const online = input.processOnlineIds?.length ? input.processOnlineIds : onlineDriverIds(input.config);
116
117
  const issues = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miraland-labs/conduit-bridge",
3
- "version": "0.12.4",
3
+ "version": "0.13.0",
4
4
  "description": "Conduit Bridge CLI — join, connect, disconnect, multi-driver lanes, and run Claude Code / Codex / Cursor / OpenCode / Pi / Kiro / Antigravity agents for a Conduit organization",
5
5
  "type": "module",
6
6
  "bin": {