@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.
- package/dist/attempt-worktree.js +8 -0
- package/dist/client.js +2 -0
- package/dist/driver.js +13 -2
- package/dist/drivers.js +16 -3
- package/dist/execution-class.js +8 -2
- package/dist/execution.js +418 -72
- package/dist/preflight.js +3 -2
- package/package.json +1 -1
package/dist/attempt-worktree.js
CHANGED
|
@@ -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, {
|
|
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
|
|
153
|
-
|
|
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();
|
package/dist/execution-class.js
CHANGED
|
@@ -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
|
|
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,
|
|
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
|
|
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: {
|
|
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 =
|
|
203
|
-
|
|
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
|
|
232
|
-
if (!
|
|
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,
|
|
285
|
-
|
|
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
|
|
291
|
-
if (!
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
:
|
|
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, {
|
|
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:
|
|
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 =
|
|
478
|
-
|
|
479
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
//
|
|
593
|
-
//
|
|
594
|
-
|
|
595
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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": {
|