@miraland-labs/conduit-bridge 0.12.4 → 0.13.1

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";
@@ -152,7 +152,7 @@ export function extractAgentReportJsonText(text) {
152
152
  const fromUnclosed = unclosed?.[1]?.trim();
153
153
  if (fromUnclosed)
154
154
  return fromUnclosed;
155
- const start = text.lastIndexOf("{");
155
+ const start = text.indexOf("{");
156
156
  if (start < 0)
157
157
  throw new Error("Agent did not emit the required structured report");
158
158
  return text.slice(start).trim();
@@ -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
@@ -1,14 +1,39 @@
1
+ import { spawn } from "node:child_process";
1
2
  import { createHash } from "node:crypto";
2
3
  import { z } from "zod";
3
4
  import { ConduitRequestError } from "./client.js";
4
5
  import { redactSecrets } from "./config.js";
5
- import { DRIVERS, agentReportTemplate, buildAssignmentPrompt, evidenceKinds, fuelEndpoint, normalizeEvidenceDigest, parseAgentReport, pickModelCandidate, requireStampedExecutionClass, tierForRisk } from "./driver.js";
6
+ import { DRIVERS, agentReportTemplate, buildAssignmentPrompt, evidenceKinds, extractAgentReportJsonText, fuelEndpoint, normalizeEvidenceDigest, parseAgentReport, parseJsonObjectCandidate, pickModelCandidate, requireStampedExecutionClass, tierForRisk } from "./driver.js";
6
7
  import { assertClassFloor } from "./execution-class.js";
7
- import { pickDriverForClaim, resolveDriverFuel } from "./drivers.js";
8
+ import { pickDriverForClaim, resolveDriverFuel, supportsReadOnlyDiagnosis } from "./drivers.js";
8
9
  import { attemptWorktreePath, createAttemptWorktree, proveResumeWorktree, quarantineAttemptWorktree, removeAttemptWorktree, } from "./attempt-worktree.js";
9
10
  import { buildWorkspaceBrief, ensureCommitAvailable, normalizeRepositoryUrl, resolveAttemptStartCommit } from "./brief.js";
10
11
  import { ensureDeliveryPullRequest } from "./ensure-pull-request.js";
11
12
  import { captureVerificationFailure, ensureTestEvidence } from "./ensure-test-evidence.js";
13
+ /** Keep a Mac awake only while an assignment is active; display sleep remains allowed. */
14
+ export function startIdleSleepGuard(options = {}) {
15
+ if ((options.platform ?? process.platform) !== "darwin")
16
+ return () => undefined;
17
+ const launch = options.launch ?? ((command, args) => spawn(command, args, { stdio: "ignore" }));
18
+ let guard;
19
+ try {
20
+ // -w ties the assertion to the runner as a crash-safe ceiling; normal completion kills it sooner.
21
+ guard = launch("caffeinate", ["-i", "-w", String(options.pid ?? process.pid)]);
22
+ }
23
+ catch (error) {
24
+ console.error(`Idle-sleep guard unavailable: ${redactSecrets(error instanceof Error ? error.message : "unknown")}`);
25
+ return () => undefined;
26
+ }
27
+ guard.once("error", (error) => console.error(`Idle-sleep guard unavailable: ${redactSecrets(error.message)}`));
28
+ guard.unref();
29
+ let released = false;
30
+ return () => {
31
+ if (released)
32
+ return;
33
+ released = true;
34
+ guard.kill("SIGTERM");
35
+ };
36
+ }
12
37
  /** Feedback text for changes_requested summaries (plain string or `{ feedback }`). */
13
38
  function changesRequestedFeedback(summary) {
14
39
  if (!summary)
@@ -62,10 +87,13 @@ const assignmentSchema = z.object({
62
87
  id: z.string().uuid(),
63
88
  attempt_id: z.string().uuid(),
64
89
  execution_mode: z.enum(["agent", "human"]).optional().default("agent"),
90
+ execution_kind: z.enum(["authoring", "diagnosis"]).optional().default("authoring"),
91
+ source_attempt_id: z.string().uuid().nullable().optional().default(null),
65
92
  repository_fingerprint: z.string().nullable().optional().default(null),
66
93
  requested_base_commit: z.string().nullable().optional().default(null),
67
94
  claimed_head: z.string().nullable().optional().default(null),
68
95
  });
96
+ const releasedWorktreeAttemptIdsSchema = z.array(z.string().uuid()).max(50);
69
97
  const taskDetailSchema = z.object({
70
98
  objective: z.string(),
71
99
  project_id: z.string().uuid(),
@@ -74,6 +102,8 @@ const taskDetailSchema = z.object({
74
102
  delivery_state: z.string(),
75
103
  delivery_summary: z.string().nullable(),
76
104
  execution_mode: z.enum(["agent", "human"]).optional().default("agent"),
105
+ execution_kind: z.enum(["authoring", "diagnosis"]).optional().default("authoring"),
106
+ source_attempt_id: z.string().uuid().nullable().optional().default(null),
77
107
  });
78
108
  const taskSpecSchema = z.object({
79
109
  goal: z.string().optional(), scope: z.array(z.string()).optional(), boundaries: z.array(z.string()).optional(),
@@ -94,6 +124,7 @@ const executionContractSchema = z.object({
94
124
  repository_fingerprint: z.string().nullable().optional().default(null),
95
125
  requested_base_commit: z.string().nullable().optional().default(null),
96
126
  claimed_head: z.string().nullable().optional().default(null),
127
+ source_attempt_id: z.string().uuid().nullable().optional().default(null),
97
128
  });
98
129
  const workPackageSchema = z.object({
99
130
  work_role: z.string().nullable().optional(),
@@ -111,8 +142,104 @@ const workPackageSchema = z.object({
111
142
  }).optional(),
112
143
  decisions: z.array(z.object({ question: z.string(), decision: z.string() })).optional(),
113
144
  rework_feedback: z.string().nullable().optional(),
145
+ diagnostic_context: z.object({
146
+ source_attempt_id: z.string().uuid(),
147
+ failure_output: z.string().min(1).max(12_000),
148
+ }).optional(),
114
149
  working_language: z.enum(["en", "zh"]).optional(),
115
150
  }).nullable().optional();
151
+ const diagnosticRepairBriefSchema = z.object({
152
+ root_cause: z.string().trim().min(1).max(2_000),
153
+ wrong_approach: z.string().trim().max(2_000).default(""),
154
+ fix_direction: z.string().trim().min(1).max(4_000),
155
+ do_not_touch: z.array(z.string().trim().min(1).max(400)).max(10).default([]),
156
+ confident: z.boolean().default(true),
157
+ });
158
+ function diagnosticText(value, max) {
159
+ if (typeof value === "string")
160
+ return value.trim().slice(0, max);
161
+ if (!Array.isArray(value))
162
+ return undefined;
163
+ const parts = value.filter((item) => typeof item === "string")
164
+ .map((item) => item.trim()).filter(Boolean);
165
+ if (!parts.length)
166
+ return undefined;
167
+ return (parts.length === 1 ? parts[0] : parts.map((part, index) => `${index + 1}. ${part}`).join("\n"))
168
+ .slice(0, max);
169
+ }
170
+ /** Parse the diagnostic agent's final JSON with the same field-shape tolerance as the CP. */
171
+ export function parseDiagnosticRepairBrief(text) {
172
+ let raw = parseJsonObjectCandidate(extractAgentReportJsonText(text));
173
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
174
+ throw new Error("Diagnostic agent did not emit a RepairBrief object");
175
+ }
176
+ let obj = { ...raw };
177
+ const keys = Object.keys(obj);
178
+ if (keys.length === 1 && obj[keys[0]] && typeof obj[keys[0]] === "object"
179
+ && !Array.isArray(obj[keys[0]]) && "fix_direction" in obj[keys[0]]) {
180
+ obj = { ...obj[keys[0]] };
181
+ }
182
+ for (const [field, max] of [["root_cause", 2_000], ["wrong_approach", 2_000], ["fix_direction", 4_000]]) {
183
+ const value = diagnosticText(obj[field], max);
184
+ if (value !== undefined)
185
+ obj[field] = value;
186
+ }
187
+ if (obj.wrong_approach == null)
188
+ obj.wrong_approach = "";
189
+ obj.do_not_touch = Array.isArray(obj.do_not_touch)
190
+ ? obj.do_not_touch.filter((item) => typeof item === "string")
191
+ .map((item) => item.trim().slice(0, 400)).filter(Boolean).slice(0, 10)
192
+ : typeof obj.do_not_touch === "string" && obj.do_not_touch.trim()
193
+ ? [obj.do_not_touch.trim().slice(0, 400)]
194
+ : [];
195
+ if (typeof obj.confident === "string") {
196
+ const token = obj.confident.trim().toLowerCase();
197
+ if (token === "true" || token === "yes")
198
+ obj.confident = true;
199
+ else if (token === "false" || token === "no")
200
+ obj.confident = false;
201
+ }
202
+ const parsed = diagnosticRepairBriefSchema.safeParse(obj);
203
+ if (!parsed.success) {
204
+ const issue = parsed.error.issues[0];
205
+ throw new Error(`Diagnostic RepairBrief is invalid${issue?.path.length ? ` at ${issue.path.join(".")}` : ""}: ${issue?.message ?? "unknown shape"}`);
206
+ }
207
+ return parsed.data;
208
+ }
209
+ /** Prompt for Option C: IDE-depth understanding in the retained tree, with write authority absent. */
210
+ export function buildDiagnosticPrompt(input) {
211
+ const pkg = input.workPackage;
212
+ const goal = pkg?.goal ?? input.spec.goal ?? input.objective;
213
+ const scope = pkg?.scope?.length ? pkg.scope : input.spec.scope ?? [];
214
+ const boundaries = pkg?.boundaries?.length ? pkg.boundaries : input.spec.boundaries ?? [];
215
+ const acceptance = pkg?.acceptance?.length ? pkg.acceptance : input.spec.acceptance ?? [];
216
+ const changeScope = pkg?.change_scope?.length ? pkg.change_scope : input.spec.change_scope ?? [];
217
+ const decisions = pkg?.decisions ?? [];
218
+ return [
219
+ "You are Conductor's read-only diagnosis lane. Diagnose the exact failed authoring run in the current retained worktree.",
220
+ "Inspect the actual code, git diff, nearby callers, and relevant tests. Run only the supplied project verification commands when useful.",
221
+ "You have repo_read and test_run only. Do not edit, create, delete, format, commit, branch, push, install, or change any file.",
222
+ "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.",
223
+ "The RepairBrief crosses to the control plane: name files, symbols, and fix steps, but do not quote secrets or copy file bodies into it.",
224
+ "If the worktree does not support a grounded diagnosis, set confident to false; never guess.",
225
+ "",
226
+ `TASK ${input.taskId}`,
227
+ `GOAL\n${goal}`,
228
+ ...(scope.length ? ["", `APPROVED SCOPE\n${scope.map((item) => `- ${item}`).join("\n")}`] : []),
229
+ ...(boundaries.length ? ["", `BOUNDARIES\n${boundaries.map((item) => `- ${item}`).join("\n")}`] : []),
230
+ ...(acceptance.length ? ["", `APPROVED ACCEPTANCE CRITERIA\n${acceptance.map((item) => `- ${item}`).join("\n")}`] : []),
231
+ ...(changeScope.length ? ["", `APPROVED CHANGE SCOPE\n${changeScope.map((item) => `- ${item}`).join("\n")}`] : []),
232
+ ...(decisions.length ? ["", `OWNER DECISIONS\n${decisions.map((item) => `- ${item.question}: ${item.decision}`).join("\n")}`] : []),
233
+ ...(input.verificationCommands.length ? ["", `BOUNDED VERIFICATION COMMANDS\n${input.verificationCommands.map((item) => `- ${item}`).join("\n")}`] : []),
234
+ "",
235
+ "WITNESSED FAILURE OUTPUT",
236
+ redactSecrets(input.failureOutput),
237
+ "",
238
+ "Return only one fenced ```json object with exactly this shape:",
239
+ '{"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}',
240
+ "root_cause, wrong_approach, and fix_direction are strings, not arrays. Close the fence and add no prose after it.",
241
+ ].join("\n");
242
+ }
116
243
  export async function renewLeases(client, config) {
117
244
  for (const active of Object.values(config.activeAttempts)) {
118
245
  if (Date.parse(active.leaseExpiresAt) - Date.now() < 120_000) {
@@ -142,6 +269,26 @@ export async function recoverActiveAttempt(client, config, driver, workspace, br
142
269
  const active = taskId ? config.activeAttempts[taskId] : Object.values(config.activeAttempts)[0];
143
270
  if (!active)
144
271
  return false;
272
+ // A terminal payload is already complete and replay-safe. Replaying it must not depend on the
273
+ // driver that produced it still being installed or online after a Bridge restart.
274
+ if (active.phase === "terminal_pending") {
275
+ const response = await flushTerminal(client, active.taskId);
276
+ const diagnosis = active.executionKind === "diagnosis";
277
+ const worktreeOwner = diagnosis ? active.sourceAttemptId : active.attemptId;
278
+ const worktree = active.worktreePath ?? (worktreeOwner ? attemptWorktreePath(workspace, worktreeOwner) : "");
279
+ if (diagnosis && !retainDiagnosticWorktree(response) && worktree) {
280
+ await removeAttemptWorktree(workspace, worktree).catch(() => undefined);
281
+ }
282
+ else if (!diagnosis && response.retain_worktree !== true && worktree) {
283
+ if (active.terminal?.action === "fail") {
284
+ await quarantineAttemptWorktree(workspace, worktree, active.attemptId).catch(() => removeAttemptWorktree(workspace, worktree).catch(() => undefined));
285
+ }
286
+ else {
287
+ await removeAttemptWorktree(workspace, worktree).catch(() => undefined);
288
+ }
289
+ }
290
+ return true;
291
+ }
145
292
  const laneDriver = resolveAttemptDriver(config, active, driver);
146
293
  if (!laneDriver) {
147
294
  console.error(`No driver for attempt ${active.attemptId} (driverId=${active.driverId ?? "unset"})`);
@@ -153,10 +300,12 @@ export async function recoverActiveAttempt(client, config, driver, workspace, br
153
300
  await client.updateAttempt(active.taskId, { driverId: assigned });
154
301
  }
155
302
  if (active.phase === "agent_running") {
156
- const worktree = active.worktreePath ?? attemptWorktreePath(workspace, active.attemptId);
303
+ const diagnosis = active.executionKind === "diagnosis";
304
+ const worktreeOwner = diagnosis ? active.sourceAttemptId : active.attemptId;
305
+ const worktree = active.worktreePath ?? (worktreeOwner ? attemptWorktreePath(workspace, worktreeOwner) : "");
157
306
  const sessionId = config.sessions?.[active.taskId]?.trim() || "";
158
307
  // F-07 safe resume: only when session + worktree identity are both proven. Otherwise interrupt.
159
- if (sessionId && await proveResumeWorktree(workspace, active.attemptId, worktree)) {
308
+ if (worktreeOwner && sessionId && await proveResumeWorktree(workspace, worktreeOwner, worktree)) {
160
309
  console.log(`Resuming interrupted attempt ${active.attemptId} with proven worktree and session.`);
161
310
  await runClaimedAssignment(client, config, laneDriver, workspace, brief, active.taskId, timeoutMs, supervision, {
162
311
  existingWorktree: worktree,
@@ -164,22 +313,29 @@ export async function recoverActiveAttempt(client, config, driver, workspace, br
164
313
  });
165
314
  return true;
166
315
  }
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, {
316
+ const response = await queueTerminal(client, active.taskId, {
170
317
  action: "fail",
171
- body: { error: "Bridge restarted during agent execution; Conduit may retry this work safely.", idempotency_key: `bridge:restart:${active.attemptId}` },
318
+ body: {
319
+ error: diagnosis
320
+ ? "Bridge restarted during read-only diagnosis; Conduit may retry diagnosis safely."
321
+ : "Bridge restarted during agent execution; Conduit may retry this work safely.",
322
+ idempotency_key: `bridge:restart:${active.attemptId}`,
323
+ },
172
324
  });
325
+ if (diagnosis && !retainDiagnosticWorktree(response) && worktree) {
326
+ await removeAttemptWorktree(workspace, worktree).catch(() => undefined);
327
+ }
328
+ else if (!diagnosis && response.retain_worktree !== true && worktree) {
329
+ // Do not quarantine before the terminal response: Conduit may need this exact tree for a
330
+ // pinned diagnosis even when Bridge restarted before it could preserve the agent session.
331
+ await quarantineAttemptWorktree(workspace, worktree, active.attemptId).catch(() => removeAttemptWorktree(workspace, worktree).catch(() => undefined));
332
+ }
173
333
  return true;
174
334
  }
175
335
  if (client.attempt(active.taskId).phase === "agent_finished") {
176
336
  await submitFinishedDelivery(client, active.taskId);
177
337
  return true;
178
338
  }
179
- if (client.attempt(active.taskId).phase === "terminal_pending") {
180
- await flushTerminal(client, active.taskId);
181
- return true;
182
- }
183
339
  await runClaimedAssignment(client, config, laneDriver, workspace, brief, active.taskId, timeoutMs, supervision);
184
340
  return true;
185
341
  }
@@ -199,8 +355,10 @@ export async function pumpExecutionSlots(client, config, workspace, brief, timeo
199
355
  if (running.has(id))
200
356
  continue;
201
357
  const active = config.activeAttempts[id];
202
- const laneDriver = resolveAttemptDriver(config, active, fallbackDriver);
203
- if (!laneDriver) {
358
+ const laneDriver = active.phase === "terminal_pending"
359
+ ? fallbackDriver
360
+ : resolveAttemptDriver(config, active, fallbackDriver);
361
+ if (!laneDriver && active.phase !== "terminal_pending") {
204
362
  console.error(`Slot recovery skipped for ${id}: no driver lane`);
205
363
  continue;
206
364
  }
@@ -228,9 +386,12 @@ export async function pumpExecutionSlots(client, config, workspace, brief, timeo
228
386
  }
229
387
  if (!laneDriver || !driverId)
230
388
  break;
231
- const taskId = await claimNextAssignment(client, config, workspace, brief, driverId);
232
- if (!taskId)
389
+ const claimed = await claimNextAssignment(client, config, workspace, brief, driverId, processOnlineIds);
390
+ if (!claimed)
233
391
  break;
392
+ const taskId = claimed.taskId;
393
+ driverId = claimed.driverId ?? driverId;
394
+ laneDriver = driverId ? DRIVERS[driverId] ?? laneDriver : laneDriver;
234
395
  progressed = true;
235
396
  console.log(`Executing ${taskId} via ${laneDriver.name}`);
236
397
  const slot = runClaimedAssignment(client, config, laneDriver, workspace, brief, taskId, timeoutMs, supervision)
@@ -244,10 +405,17 @@ export async function pumpExecutionSlots(client, config, workspace, brief, timeo
244
405
  return progressed;
245
406
  }
246
407
  /** 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) {
408
+ export async function claimNextAssignment(client, config, workspace, brief, driverId, processOnlineIds) {
248
409
  if (Object.keys(config.activeAttempts).length >= config.leaseCapacity)
249
410
  return null;
250
411
  const data = await client.request("/runner/v1/assignments");
412
+ const releasedWorktrees = releasedWorktreeAttemptIdsSchema.parse(data.released_worktree_attempt_ids ?? []);
413
+ for (const attemptId of releasedWorktrees) {
414
+ const worktree = attemptWorktreePath(workspace, attemptId);
415
+ await removeAttemptWorktree(workspace, worktree)
416
+ .then(() => console.log(`Released settled diagnostic worktree ${worktree}`))
417
+ .catch((error) => console.error(`Diagnostic worktree release failed: ${redactSecrets(error instanceof Error ? error.message : "unknown")}`));
418
+ }
251
419
  const assignments = z.array(assignmentSchema).parse(data.assignments ?? []);
252
420
  const assignment = assignments[0];
253
421
  if (!assignment)
@@ -256,18 +424,25 @@ export async function claimNextAssignment(client, config, workspace, brief, driv
256
424
  console.log(`Human takeover assignment ${assignment.id} — use Bridge MCP tools to claim and submit (agent runner skips).`);
257
425
  return null;
258
426
  }
427
+ let selectedDriverId = driverId ?? null;
428
+ if (assignment.execution_kind === "diagnosis" && selectedDriverId && !supportsReadOnlyDiagnosis(selectedDriverId)) {
429
+ // Prefer a lane that can honour repo_read + test_run without silently widening to repo_write.
430
+ // If none is online, claim on the selected lane so runClaimedAssignment can create a durable,
431
+ // owner-safe Hold and refund the diagnostic slot instead of leaving an expiring dispatch loop.
432
+ selectedDriverId = pickDriverForClaim(config, processOnlineIds, supportsReadOnlyDiagnosis) ?? selectedDriverId;
433
+ }
259
434
  const liveBrief = await buildWorkspaceBrief(workspace).catch(() => brief);
260
435
  const liveRepository = liveBrief?.repository ? normalizeRepositoryUrl(liveBrief.repository) : null;
261
436
  let rejection = null;
262
437
  if (assignment.repository_fingerprint && liveRepository !== assignment.repository_fingerprint)
263
438
  rejection = "workspace_repository_mismatch";
264
- else if (assignment.repository_fingerprint && assignment.claimed_head && liveBrief?.base_commit !== assignment.claimed_head) {
439
+ else if (assignment.execution_kind === "authoring" && assignment.repository_fingerprint && assignment.claimed_head && liveBrief?.base_commit !== assignment.claimed_head) {
265
440
  // Rework / advanced base may claim a head that differs from the source checkout HEAD.
266
441
  const reachable = await ensureCommitAvailable(workspace, assignment.claimed_head).catch(() => false);
267
442
  if (!reachable)
268
443
  rejection = "workspace_head_changed";
269
444
  }
270
- if (!rejection && assignment.requested_base_commit && assignment.claimed_head) {
445
+ if (!rejection && assignment.execution_kind === "authoring" && assignment.requested_base_commit && assignment.claimed_head) {
271
446
  const startHead = await resolveAttemptStartCommit(workspace, assignment.requested_base_commit, assignment.claimed_head);
272
447
  if (!startHead)
273
448
  rejection = "base_not_ancestor";
@@ -280,17 +455,21 @@ export async function claimNextAssignment(client, config, workspace, brief, driv
280
455
  console.error(`Assignment ${assignment.id} rejected before claim: ${rejection}`);
281
456
  return null;
282
457
  }
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;
458
+ console.log(`Claiming ${assignment.execution_kind} assignment ${assignment.id} (attempt ${assignment.attempt_id})`);
459
+ await client.claim(assignment.id, assignment.attempt_id, {
460
+ ...(selectedDriverId ? { driverId: selectedDriverId } : {}),
461
+ executionKind: assignment.execution_kind,
462
+ ...(assignment.source_attempt_id ? { sourceAttemptId: assignment.source_attempt_id } : {}),
463
+ });
464
+ return { taskId: assignment.id, driverId: selectedDriverId };
286
465
  }
287
466
  export async function executeNextAssignment(client, config, driver, workspace, brief, timeoutMs, supervision) {
288
467
  if (Object.keys(config.activeAttempts).length)
289
468
  return recoverActiveAttempt(client, config, driver, workspace, brief, timeoutMs, supervision);
290
- const taskId = await claimNextAssignment(client, config, workspace, brief);
291
- if (!taskId)
469
+ const claimed = await claimNextAssignment(client, config, workspace, brief);
470
+ if (!claimed)
292
471
  return false;
293
- await runClaimedAssignment(client, config, driver, workspace, brief, taskId, timeoutMs, supervision);
472
+ await runClaimedAssignment(client, config, driver, workspace, brief, claimed.taskId, timeoutMs, supervision);
294
473
  return true;
295
474
  }
296
475
  async function runClaimedAssignment(client, config, driver, workspace, brief, taskId, timeoutMs, supervision, options = {}) {
@@ -299,12 +478,21 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
299
478
  const task = taskDetailSchema.parse(detail.task);
300
479
  const executionContract = executionContractSchema.parse(detail.execution_contract ?? {});
301
480
  const workPackage = workPackageSchema.parse(detail.work_package) ?? null;
481
+ const executionKind = task.execution_kind;
482
+ const diagnosis = executionKind === "diagnosis";
483
+ const sourceAttemptId = task.source_attempt_id ?? executionContract.source_attempt_id ?? active.sourceAttemptId ?? null;
484
+ if (active.executionKind !== executionKind || active.sourceAttemptId !== (sourceAttemptId ?? undefined)) {
485
+ await client.updateAttempt(taskId, {
486
+ executionKind,
487
+ sourceAttemptId: sourceAttemptId ?? undefined,
488
+ });
489
+ }
302
490
  const parsedSpec = parseTaskSpec(task.spec_json);
303
491
  const deliverable = workPackage?.deliverable ?? parsedSpec.deliverable;
304
492
  const spec = { ...parsedSpec, deliverable };
305
- const artifactDelivery = deliverable === "artifact";
493
+ const artifactDelivery = !diagnosis && deliverable === "artifact";
306
494
  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;
495
+ const reworkFeedback = !diagnosis && task.delivery_state === "changes_requested" ? changesRequestedFeedback(task.delivery_summary) : null;
308
496
  // Recompile current state at claim time — earlier packages may have moved the repo.
309
497
  const liveBrief = await buildWorkspaceBrief(workspace).catch(() => brief);
310
498
  const liveRepository = liveBrief?.repository ? normalizeRepositoryUrl(liveBrief.repository) : null;
@@ -313,37 +501,83 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
313
501
  console.error(`Assignment ${taskId} preflight failed: Workspace repository changed after dispatch`);
314
502
  return;
315
503
  }
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}` } });
504
+ const startCommit = executionContract.claimed_head ?? liveBrief?.base_commit ?? null;
505
+ let worktreeStart = startCommit ?? "retained-worktree";
506
+ let attemptWorkspace;
507
+ let deliverySubmitted = false;
508
+ // A diagnostic terminal defaults to retention. If its lease disappears, deleting the only copy
509
+ // of the failed code would make the durable retry mechanically impossible.
510
+ let retainAttemptWorktree = diagnosis;
511
+ if (diagnosis) {
512
+ if (!sourceAttemptId) {
513
+ await queueTerminal(client, taskId, {
514
+ action: "fail",
515
+ body: {
516
+ failure: {
517
+ code: "repair_diagnosis_source_missing",
518
+ class: "platform",
519
+ disposition: "stop",
520
+ responsible_party: "conduit",
521
+ message: "Conductor could not locate the failed run for read-only diagnosis.",
522
+ next_action: "No compiler constraints are needed from the owner; inspect Conduit repair history before retrying the task.",
523
+ diagnostic_detail: "Diagnostic assignment omitted source_attempt_id.",
524
+ },
525
+ error: "Diagnostic assignment omitted source_attempt_id",
526
+ retryable: false,
527
+ idempotency_key: `bridge:diagnosis-source-missing:${active.attemptId}`,
528
+ },
529
+ });
329
530
  return;
330
531
  }
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`);
532
+ const retained = options.existingWorktree ?? attemptWorktreePath(workspace, sourceAttemptId);
533
+ if (!(await proveResumeWorktree(workspace, sourceAttemptId, retained))) {
534
+ await queueTerminal(client, taskId, {
535
+ action: "fail",
536
+ body: {
537
+ failure: {
538
+ code: "repair_diagnosis_worktree_missing",
539
+ class: "platform",
540
+ disposition: "stop",
541
+ responsible_party: "conduit",
542
+ message: "Conductor could not access the retained failed run for read-only diagnosis.",
543
+ next_action: "No compiler constraints are needed from the owner; inspect Conduit repair history before retrying the task.",
544
+ diagnostic_detail: `Retained worktree for source attempt ${sourceAttemptId} is unavailable.`,
545
+ },
546
+ error: "Retained failed worktree is unavailable for read-only diagnosis",
547
+ retryable: false,
548
+ idempotency_key: `bridge:diagnosis-worktree-missing:${active.attemptId}`,
549
+ },
550
+ });
338
551
  return;
339
552
  }
553
+ attemptWorkspace = retained;
554
+ worktreeStart = executionContract.claimed_head ?? "retained-worktree";
555
+ await client.updateAttempt(taskId, { worktreePath: attemptWorkspace, executionKind, sourceAttemptId });
340
556
  }
341
- let attemptWorkspace;
342
- let deliverySubmitted = false;
343
- if (options.existingWorktree) {
557
+ else if (options.existingWorktree) {
344
558
  attemptWorkspace = options.existingWorktree;
345
559
  }
346
560
  else {
561
+ if (!startCommit) {
562
+ await queueTerminal(client, taskId, { action: "fail", body: { error: "No start commit for attempt worktree", retryable: true, idempotency_key: `bridge:no-start-commit:${active.attemptId}` } });
563
+ return;
564
+ }
565
+ if (executionContract.requested_base_commit) {
566
+ const resolved = await resolveAttemptStartCommit(workspace, executionContract.requested_base_commit, startCommit);
567
+ if (!resolved) {
568
+ 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}` } });
569
+ return;
570
+ }
571
+ worktreeStart = resolved;
572
+ }
573
+ else if (liveBrief?.base_commit && liveBrief.base_commit !== startCommit) {
574
+ const reachable = await ensureCommitAvailable(workspace, startCommit).catch(() => false);
575
+ if (!reachable) {
576
+ 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}` } });
577
+ console.error(`Assignment ${taskId} preflight failed: Delivered head is not available in this workspace`);
578
+ return;
579
+ }
580
+ }
347
581
  try {
348
582
  attemptWorkspace = await createAttemptWorktree({
349
583
  sourceWorkspace: workspace,
@@ -362,6 +596,16 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
362
596
  }
363
597
  await client.updateAttempt(taskId, { worktreePath: attemptWorkspace });
364
598
  }
599
+ // Diagnosis reads manifests and verification commands from the failed tree itself. The source
600
+ // checkout may be clean but stale relative to edits that caused the failure.
601
+ const attemptBrief = diagnosis
602
+ ? await buildWorkspaceBrief(attemptWorkspace).catch(() => liveBrief)
603
+ : liveBrief;
604
+ const removeReleasedDiagnosticWorktree = async (response) => {
605
+ if (diagnosis && !retainDiagnosticWorktree(response)) {
606
+ await removeAttemptWorktree(workspace, attemptWorkspace).catch(() => undefined);
607
+ }
608
+ };
365
609
  // Stamped class is the source of truth (Bridge 0.11+). Do not re-derive from grants.
366
610
  let executionClass;
367
611
  try {
@@ -372,10 +616,11 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
372
616
  console.error(`Assignment ${taskId}: ${detail}`);
373
617
  // Non-retryable: control plane restamps on dispatch/claim. Infinite retry here burned path-4
374
618
  // attempt budget when a pre-inversion task row still lacked the stamp.
375
- await queueTerminal(client, taskId, {
619
+ const response = await queueTerminal(client, taskId, {
376
620
  action: "fail",
377
621
  body: { error: detail, retryable: false, idempotency_key: `bridge:fail:${active.attemptId}:missing-class` },
378
622
  });
623
+ await removeReleasedDiagnosticWorktree(response);
379
624
  return;
380
625
  }
381
626
  // An artifact deliverable that is not publish_artifact cannot write/publish — fail closed.
@@ -393,19 +638,68 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
393
638
  if (!floor.ok) {
394
639
  const detail = `Class floor unmet: ${floor.reason}`;
395
640
  console.error(`Assignment ${taskId}: ${detail}`);
396
- await queueTerminal(client, taskId, {
641
+ const response = await queueTerminal(client, taskId, {
397
642
  action: "fail",
398
643
  body: { error: detail, retryable: false, idempotency_key: `bridge:fail:${active.attemptId}:class-floor` },
399
644
  });
645
+ await removeReleasedDiagnosticWorktree(response);
646
+ return;
647
+ }
648
+ const builtInDriverId = Object.entries(DRIVERS).find(([, candidate]) => candidate === driver)?.[0] ?? null;
649
+ if (diagnosis && builtInDriverId && !supportsReadOnlyDiagnosis(builtInDriverId)) {
650
+ const detail = `${driver.name} cannot combine bounded test execution with enforced read-only repository access.`;
651
+ const response = await queueTerminal(client, taskId, {
652
+ action: "fail",
653
+ body: {
654
+ failure: {
655
+ code: "repair_diagnosis_driver_read_only_unsupported",
656
+ class: "environment",
657
+ disposition: "hold",
658
+ responsible_party: "computer_operator",
659
+ message: "Conductor is waiting for a diagnostic lane that can enforce read-only access.",
660
+ next_action: "Bring a Claude Code, Codex, or Cursor lane online on this computer, then Recheck. Do not author compiler constraints for the agent.",
661
+ diagnostic_detail: detail,
662
+ },
663
+ error: detail,
664
+ retryable: false,
665
+ idempotency_key: `bridge:diagnosis-driver-authority:${active.attemptId}`,
666
+ },
667
+ });
668
+ await removeReleasedDiagnosticWorktree(response);
669
+ console.error(`Assignment ${taskId} diagnosis held: ${detail}`);
670
+ return;
671
+ }
672
+ const diagnosticFailure = workPackage?.diagnostic_context?.failure_output;
673
+ if (diagnosis && !diagnosticFailure) {
674
+ const response = await queueTerminal(client, taskId, {
675
+ action: "fail",
676
+ body: {
677
+ error: "Diagnostic assignment is missing witnessed failure output",
678
+ retryable: true,
679
+ idempotency_key: `bridge:diagnosis-context-missing:${active.attemptId}`,
680
+ },
681
+ });
682
+ await removeReleasedDiagnosticWorktree(response);
400
683
  return;
401
684
  }
402
- const prompt = buildAssignmentPrompt({ taskId, objective: task.objective, spec, grants, workspace: attemptWorkspace, currentHead: worktreeStart, reworkFeedback, workPackage, verificationCommands: liveBrief?.verification ?? [], executionClass });
685
+ const prompt = diagnosis
686
+ ? buildDiagnosticPrompt({
687
+ taskId,
688
+ objective: task.objective,
689
+ spec,
690
+ workPackage,
691
+ failureOutput: diagnosticFailure,
692
+ verificationCommands: attemptBrief?.verification ?? [],
693
+ })
694
+ : buildAssignmentPrompt({ taskId, objective: task.objective, spec, grants, workspace: attemptWorkspace, currentHead: worktreeStart, reworkFeedback, workPackage, verificationCommands: attemptBrief?.verification ?? [], executionClass });
403
695
  const resuming = Boolean(options.forceResumeSessionId);
404
696
  await client.attemptRequest(taskId, "progress", {
405
- phase: "changing",
697
+ phase: diagnosis ? "inspecting" : "changing",
406
698
  message: resuming
407
699
  ? `Resuming ${driver.name} after Bridge restart with proven session and worktree.`
408
- : `Starting ${driver.name} for this assignment${reworkFeedback ? " with review feedback" : ""}.`,
700
+ : diagnosis
701
+ ? `Starting ${driver.name} read-only diagnosis in the retained failed worktree.`
702
+ : `Starting ${driver.name} for this assignment${reworkFeedback ? " with review feedback" : ""}.`,
409
703
  idempotency_key: resuming ? `bridge:progress:${active.attemptId}:resume` : `bridge:progress:${active.attemptId}:start`,
410
704
  });
411
705
  const driverId = active.driverId
@@ -433,7 +727,15 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
433
727
  : `Started ${driver.name}${selection.model ? ` on ${selection.model}` : " on its CLI default"}.`,
434
728
  idempotency_key: resuming ? `bridge:progress:${active.attemptId}:agent-resume` : `bridge:progress:${active.attemptId}:agent-start`,
435
729
  });
436
- await client.updateAttempt(taskId, { phase: "agent_running", fuelMode: fuelSource, worktreePath: attemptWorkspace, driverId });
730
+ await client.updateAttempt(taskId, {
731
+ phase: "agent_running",
732
+ fuelMode: fuelSource,
733
+ worktreePath: attemptWorkspace,
734
+ driverId,
735
+ executionKind,
736
+ sourceAttemptId: sourceAttemptId ?? undefined,
737
+ });
738
+ const releaseIdleSleep = startIdleSleepGuard();
437
739
  const renewTimer = setInterval(() => { void renewLeases(client, config).catch((error) => console.error(`Lease renewal failed: ${redactSecrets(error instanceof Error ? error.message : "unknown")}`)); }, 60_000);
438
740
  let heartbeatRunning = false;
439
741
  const heartbeatTimer = supervision ? setInterval(() => {
@@ -455,9 +757,9 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
455
757
  workspace: attemptWorkspace,
456
758
  grants,
457
759
  capabilities: spec.required_capabilities ?? [],
458
- verificationCommands: liveBrief?.verification ?? [],
760
+ verificationCommands: attemptBrief?.verification ?? [],
459
761
  deliverable,
460
- workRole: workPackage?.work_role ?? spec.work_role,
762
+ workRole: diagnosis ? "diagnose" : workPackage?.work_role ?? spec.work_role,
461
763
  executionClass,
462
764
  resumeSessionId,
463
765
  timeoutMs,
@@ -467,6 +769,50 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
467
769
  });
468
770
  if (result.sessionId)
469
771
  config.sessions = { ...config.sessions, [taskId]: result.sessionId };
772
+ if (diagnosis) {
773
+ if (result.status === "failed") {
774
+ const message = result.error ?? "Read-only diagnosis failed";
775
+ const response = await queueTerminal(client, taskId, {
776
+ action: "fail",
777
+ body: {
778
+ error: message,
779
+ retryable: retryableAgentFailure(message),
780
+ idempotency_key: `bridge:diagnosis-fail:${active.attemptId}`,
781
+ },
782
+ });
783
+ retainAttemptWorktree = retainDiagnosticWorktree(response);
784
+ console.error(`Assignment ${taskId} diagnosis failed: ${redactSecrets(message)}`);
785
+ return;
786
+ }
787
+ let repairBrief;
788
+ try {
789
+ repairBrief = parseDiagnosticRepairBrief(result.resultText ?? "");
790
+ }
791
+ catch (error) {
792
+ const message = error instanceof Error ? error.message : "Diagnostic RepairBrief was invalid";
793
+ const response = await queueTerminal(client, taskId, {
794
+ action: "fail",
795
+ body: {
796
+ error: message,
797
+ retryable: true,
798
+ idempotency_key: `bridge:diagnosis-invalid:${active.attemptId}`,
799
+ },
800
+ });
801
+ retainAttemptWorktree = retainDiagnosticWorktree(response);
802
+ console.error(`Assignment ${taskId} diagnosis returned no usable brief: ${redactSecrets(message)}`);
803
+ return;
804
+ }
805
+ const response = await queueTerminal(client, taskId, {
806
+ action: "complete",
807
+ body: {
808
+ repair_brief: repairBrief,
809
+ idempotency_key: `bridge:diagnosis-complete:${active.attemptId}`,
810
+ },
811
+ });
812
+ retainAttemptWorktree = retainDiagnosticWorktree(response);
813
+ console.log(`Assignment ${taskId} returned a grounded Conductor RepairBrief.`);
814
+ return;
815
+ }
470
816
  if (result.status === "failed") {
471
817
  const agentMessage = result.error ?? "Agent execution failed";
472
818
  // The agent verifies inside its own tool loop, so its compiler/test output never reaches
@@ -474,16 +820,22 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
474
820
  // reason", leaving Conductor nothing to diagnose. Re-run the project's bounded verification
475
821
  // in the attempt worktree to recover the real errors. Best-effort: keep the agent's own
476
822
  // 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
- });
823
+ const verificationDetail = grants.includes("test_run")
824
+ ? await captureVerificationFailure({
825
+ workspace: attemptWorkspace,
826
+ verificationCommands: attemptBrief?.verification ?? [],
827
+ })
828
+ : null;
481
829
  const message = verificationDetail
482
830
  ? `Verification failed after the agent run.\n${verificationDetail}`
483
831
  : agentMessage;
484
832
  // Retryability stays keyed to what the agent reported; a recovered verification log describes
485
833
  // 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}` } });
834
+ // Keep the tree until the replay-safe terminal response explicitly says whether diagnosis was
835
+ // queued. A network fault here must not delete the evidence before Bridge can replay terminal.
836
+ retainAttemptWorktree = true;
837
+ const response = await queueTerminal(client, taskId, { action: "fail", body: { error: message, retryable: retryableAgentFailure(agentMessage), idempotency_key: `bridge:fail:${active.attemptId}` } });
838
+ retainAttemptWorktree = response.retain_worktree === true;
487
839
  console.error(`Assignment ${taskId} failed: ${redactSecrets(message)}`);
488
840
  return;
489
841
  }
@@ -583,16 +935,25 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
583
935
  report,
584
936
  spec,
585
937
  grants,
586
- verificationCommands: liveBrief?.verification ?? [],
938
+ verificationCommands: attemptBrief?.verification ?? [],
587
939
  });
588
940
  validateDeliveryReport(report, spec, grants);
589
941
  }
590
942
  catch (error) {
591
943
  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, {
944
+ // Bridge's own bounded verification can reject an otherwise completed agent run here. That is
945
+ // the same verify-class pre-delivery failure as the driver-failed path above: preserve the
946
+ // exact tree and send the witness marker that arms Conductor diagnosis. Treating it as a
947
+ // delivery-envelope defect would stop before Invariant 23 ever ran.
948
+ const verificationFailed = /^Agent report: Verification failed \(/.test(message);
949
+ const classified = verificationFailed
950
+ ? { retryable: true, error: `Verification failed after the agent run.\n${message}` }
951
+ // classifyFinalizeFailure: forge transport → retryable; contract/environment/unknown → fail
952
+ // closed (unknown prefixed as "Bridge finalize interrupted", not laundered as contract).
953
+ : classifyFinalizeFailure(message);
954
+ if (verificationFailed)
955
+ retainAttemptWorktree = true;
956
+ const response = await queueTerminal(client, taskId, {
596
957
  action: "fail",
597
958
  body: {
598
959
  error: classified.error,
@@ -600,6 +961,8 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
600
961
  idempotency_key: `bridge:invalid-delivery:${active.attemptId}`,
601
962
  },
602
963
  });
964
+ if (verificationFailed)
965
+ retainAttemptWorktree = response.retain_worktree === true;
603
966
  console.error(`Assignment ${taskId} could not produce a valid Delivery: ${redactSecrets(classified.error)}`);
604
967
  const replyTail = reportText.slice(-8_000);
605
968
  console.error(`Assignment ${taskId} agent reply tail (${reportText.length} chars total, redacted): ${redactSecrets(replyTail) || "<empty>"}`);
@@ -611,10 +974,14 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
611
974
  console.log(`Assignment ${taskId} delivered for review and acceptance.`);
612
975
  }
613
976
  finally {
977
+ releaseIdleSleep();
614
978
  clearInterval(renewTimer);
615
979
  if (heartbeatTimer)
616
980
  clearInterval(heartbeatTimer);
617
- if (artifactDelivery && !deliverySubmitted) {
981
+ if (retainAttemptWorktree) {
982
+ console.log(`Retained failed-attempt worktree for Conductor diagnosis at ${attemptWorkspace}`);
983
+ }
984
+ else if (artifactDelivery && !deliverySubmitted) {
618
985
  console.error(`Artifact delivery was not submitted; retained generated files at ${attemptWorkspace}`);
619
986
  }
620
987
  else {
@@ -883,22 +1250,28 @@ function pathMatchesScope(path, scope) {
883
1250
  }
884
1251
  return path === normalized;
885
1252
  }
1253
+ function retainDiagnosticWorktree(response) {
1254
+ // Lease expiry is reconciled by the CP into another diagnostic attempt. The old terminal cannot
1255
+ // receive that decision, so fail safe by retaining the only copy of the failed code.
1256
+ return response.retain_worktree === true || response.status === "invalid_lease";
1257
+ }
886
1258
  async function queueTerminal(client, taskId, terminal) {
887
1259
  await client.updateAttempt(taskId, { phase: "terminal_pending", terminal });
888
- await flushTerminal(client, taskId);
1260
+ return flushTerminal(client, taskId);
889
1261
  }
890
1262
  export async function flushTerminal(client, taskId) {
891
1263
  const active = client.attempt(taskId);
892
1264
  if (!active.terminal)
893
1265
  throw new Error("Pending terminal operation is missing its replay payload");
894
1266
  try {
895
- await client.attemptRequest(taskId, active.terminal.action, active.terminal.body);
1267
+ const response = await client.attemptRequest(taskId, active.terminal.action, active.terminal.body);
896
1268
  await client.clearAttempt(taskId);
1269
+ return response;
897
1270
  }
898
1271
  catch (error) {
899
1272
  if (error instanceof ConduitRequestError && error.code === "invalid_lease") {
900
1273
  await client.clearAttempt(taskId);
901
- return;
1274
+ return { accepted: false, status: "invalid_lease" };
902
1275
  }
903
1276
  throw error;
904
1277
  }
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.1",
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": {