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