@miraland-labs/conduit-bridge 0.8.0 → 0.9.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/brief.js CHANGED
@@ -43,6 +43,41 @@ export async function isBaseCommitAncestor(workspace, baseCommit, headCommit) {
43
43
  throw error;
44
44
  }
45
45
  }
46
+ /** True when `sha` is a local commit object, optionally after fetching it from origin. */
47
+ export async function ensureCommitAvailable(workspace, sha) {
48
+ const present = async () => {
49
+ try {
50
+ await execFileAsync("git", ["-C", workspace, "cat-file", "-e", `${sha}^{commit}`], {
51
+ timeout: 10_000,
52
+ windowsHide: true,
53
+ });
54
+ return true;
55
+ }
56
+ catch {
57
+ return false;
58
+ }
59
+ };
60
+ if (await present())
61
+ return true;
62
+ try {
63
+ await execFileAsync("git", ["-C", workspace, "fetch", "--no-tags", "--depth=1", "origin", sha], {
64
+ timeout: 120_000,
65
+ windowsHide: true,
66
+ });
67
+ }
68
+ catch {
69
+ try {
70
+ await execFileAsync("git", ["-C", workspace, "fetch", "--no-tags", "origin"], {
71
+ timeout: 120_000,
72
+ windowsHide: true,
73
+ });
74
+ }
75
+ catch {
76
+ return false;
77
+ }
78
+ }
79
+ return present();
80
+ }
46
81
  async function gitRemoteUrl(workspace) {
47
82
  try {
48
83
  const { common } = await gitDirectories(workspace);
package/dist/cli.js CHANGED
@@ -6,7 +6,7 @@ import { spawn } from "node:child_process";
6
6
  import { readFileSync } from "node:fs";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { ConduitClient } from "./client.js";
9
- import { BRIDGE_LEASE_CAPACITY, BRIDGE_MAX_LEASE_CAPACITY, clampLeaseCapacity, clearPendingConnection, loadConfig, loadConfigIfPresent, loadOrCreateInstallationId, loadPendingConnection, redactSecrets, saveConfig, savePendingConnection, suggestMachineName, } from "./config.js";
9
+ import { BRIDGE_LEASE_CAPACITY, BRIDGE_MAX_LEASE_CAPACITY, clampLeaseCapacity, clearPendingConnection, loadConfig, loadConfigIfPresent, loadOrCreateInstallationId, loadPendingConnection, redactSecrets, saveConfig, saveConfigPrefs, savePendingConnection, suggestMachineName, } from "./config.js";
10
10
  import { runMcp } from "./mcp.js";
11
11
  import { detectInstalledClients, localFuelOnlyClients, suggestFuelSource } from "./detect.js";
12
12
  import { DRIVERS } from "./driver.js";
@@ -219,7 +219,7 @@ async function finishConnection(baseUrl, data, fuelSource) {
219
219
  }
220
220
  console.log(`Confirmed capabilities (matching): ${config.capabilities.join(", ") || "none"}`);
221
221
  console.log(`Allowed actions: ${config.grants.join(", ") || "none"}`);
222
- console.log(`Fuel source: ${config.fuelSource === "local" ? "local subscription" : "Conduit pump"}`
222
+ console.log(`Fuel source: ${config.fuelSource === "local" ? "local subscription" : "Conduit /v1 (project owner BYOK on open orgs; org credentials on invite-only)"}`
223
223
  + (fuelAutoLocal ? " (auto: only Cursor/Kiro/Antigravity detected)" : ""));
224
224
  const localOnly = localFuelOnlyClients(detected);
225
225
  if (config.fuelSource === "conduit" && localOnly.length) {
@@ -268,7 +268,7 @@ async function installService() {
268
268
  }
269
269
  config = seedDriverLanes(config, [values.agent]).config;
270
270
  config = setDriversOnline(config, [values.agent], true);
271
- await saveConfig(config);
271
+ await saveConfigPrefs(config);
272
272
  console.log(`Brought ${values.agent} online for this computer (shared capacity ${config.leaseCapacity}).`);
273
273
  }
274
274
  else if (!onlineDriverIds(config).length) {
@@ -303,7 +303,7 @@ async function fuelCommand() {
303
303
  }
304
304
  const config = await loadConfig();
305
305
  config.fuelSource = mode;
306
- await saveConfig(config);
306
+ await saveConfigPrefs(config);
307
307
  const client = new ConduitClient(config);
308
308
  await heartbeat(client, config, null);
309
309
  console.log(`Machine fuel source set to ${mode === "local" ? "local subscription" : "Conduit pump"} and reported on heartbeat.`);
@@ -313,7 +313,7 @@ async function driversCommand() {
313
313
  let config = await loadConfig();
314
314
  config = (await seedDriversFromDetection(config)).config;
315
315
  if (!sub || sub === "list") {
316
- await saveConfig(config);
316
+ await saveConfigPrefs(config);
317
317
  const lanes = listDriverLanes(config);
318
318
  if (!lanes.length) {
319
319
  console.log("No driver lanes registered. Install a supported agent CLI, then re-run this command.");
@@ -339,7 +339,7 @@ async function driversCommand() {
339
339
  }
340
340
  config = seedDriverLanes(config, ids).config;
341
341
  config = setDriversOnline(config, ids, sub === "online");
342
- await saveConfig(config);
342
+ await saveConfigPrefs(config);
343
343
  const client = new ConduitClient(config);
344
344
  await heartbeat(client, config, null).catch(() => undefined);
345
345
  console.log(`${sub === "online" ? "Online" : "Offline"}: ${ids.join(", ")}`);
@@ -354,7 +354,7 @@ async function driversCommand() {
354
354
  }
355
355
  config = seedDriverLanes(config, [id]).config;
356
356
  config = setDriverFuel(config, id, mode);
357
- await saveConfig(config);
357
+ await saveConfigPrefs(config);
358
358
  console.log(`${driverLabel(id)} lane fuel set to ${mode}`);
359
359
  return;
360
360
  }
@@ -369,10 +369,10 @@ async function runner() {
369
369
  const fuelOverride = parseFuelSource(values.fuel);
370
370
  if (fuelOverride) {
371
371
  config.fuelSource = fuelOverride;
372
- await saveConfig(config);
372
+ await saveConfigPrefs(config);
373
373
  }
374
374
  config = (await seedDriversFromDetection(config)).config;
375
- await saveConfig(config);
375
+ await saveConfigPrefs(config);
376
376
  const client = new ConduitClient(config);
377
377
  let processDriver = null;
378
378
  let processOnlineIds = null;
@@ -415,11 +415,11 @@ async function runner() {
415
415
  config.drivers = latest.drivers;
416
416
  config.fuelSource = latest.fuelSource;
417
417
  config.leaseCapacity = latest.leaseCapacity;
418
- await heartbeat(client, config, workspace ? await buildWorkspaceBrief(workspace).catch(() => brief) : null);
418
+ await heartbeat(client, config, workspace ? await buildWorkspaceBrief(workspace).catch(() => brief) : null, processOnlineIds);
419
419
  await renewLeases(client, config);
420
420
  if (workspace && (processDriver || onlineDriverIds(config).length)) {
421
421
  progressed = await pumpExecutionSlots(client, config, workspace, brief, timeoutMs, {
422
- heartbeat: async () => heartbeat(client, config, await buildWorkspaceBrief(workspace).catch(() => brief)),
422
+ heartbeat: async () => heartbeat(client, config, await buildWorkspaceBrief(workspace).catch(() => brief), processOnlineIds),
423
423
  heartbeatIntervalMs: intervalMs,
424
424
  }, running, { driver: processDriver, processOnlineIds });
425
425
  }
@@ -435,15 +435,16 @@ async function runner() {
435
435
  await new Promise((resolveSleep) => setTimeout(resolveSleep, intervalMs));
436
436
  }
437
437
  }
438
- async function heartbeat(client, config, brief) {
439
- const online = onlineDriverIds(config);
438
+ async function heartbeat(client, config, brief, processOnlineIds) {
439
+ // Process-level `--agent` override must count as online even when saved lanes are offline.
440
+ const online = processOnlineIds?.length ? processOnlineIds : onlineDriverIds(config);
440
441
  const status = heartbeatStatusForDrivers(online);
441
442
  await client.request("/runner/v1/heartbeat", { method: "POST", body: JSON.stringify({
442
443
  status,
443
444
  capabilities: config.capabilities,
444
445
  lease_capacity: config.leaseCapacity,
445
446
  fuel_source: config.fuelSource === "local" ? "local" : "conduit",
446
- drivers: driversHeartbeatReport(config, config.activeAttempts),
447
+ drivers: driversHeartbeatReport(config, config.activeAttempts, processOnlineIds),
447
448
  ...(brief ? { workspace_brief: brief } : {}),
448
449
  }) });
449
450
  }
package/dist/client.js CHANGED
@@ -1,4 +1,4 @@
1
- import { saveConfig } from "./config.js";
1
+ import { removeRuntimeAttempt, saveConfigPrefs, saveRuntime } from "./config.js";
2
2
  export class ConduitRequestError extends Error {
3
3
  status;
4
4
  code;
@@ -10,10 +10,24 @@ export class ConduitRequestError extends Error {
10
10
  }
11
11
  export class ConduitClient {
12
12
  config;
13
- persist;
14
- constructor(config, persist = saveConfig) {
13
+ persistRuntime;
14
+ persistPrefs;
15
+ removeAttempt;
16
+ constructor(config, persist) {
15
17
  this.config = config;
16
- this.persist = persist;
18
+ if (typeof persist === "function") {
19
+ this.persistRuntime = persist;
20
+ this.persistPrefs = persist;
21
+ this.removeAttempt = async (taskId) => {
22
+ delete this.config.activeAttempts[taskId];
23
+ await persist(this.config);
24
+ };
25
+ }
26
+ else {
27
+ this.persistRuntime = persist?.runtime ?? saveRuntime;
28
+ this.persistPrefs = persist?.prefs ?? saveConfigPrefs;
29
+ this.removeAttempt = persist?.removeAttempt ?? removeRuntimeAttempt;
30
+ }
17
31
  }
18
32
  async request(path, init = {}) {
19
33
  const response = await fetch(`${this.config.baseUrl}${path}`, {
@@ -25,11 +39,18 @@ export class ConduitClient {
25
39
  throw new ConduitRequestError(data.error?.message ?? `Conduit request failed (${response.status})`, response.status, data.error?.code);
26
40
  return data;
27
41
  }
28
- async claim(taskId, attemptId) {
42
+ async claim(taskId, attemptId, extras) {
29
43
  const data = await this.request(`/runner/v1/tasks/${taskId}/claim`, { method: "POST", body: JSON.stringify({ attempt_id: attemptId, idempotency_key: `bridge:claim:${attemptId}` }) });
30
- const active = { taskId, attemptId, leaseToken: String(data.lease_token), leaseExpiresAt: String(data.lease_expires_at), phase: "claimed" };
44
+ const active = {
45
+ taskId,
46
+ attemptId,
47
+ leaseToken: String(data.lease_token),
48
+ leaseExpiresAt: String(data.lease_expires_at),
49
+ phase: "claimed",
50
+ ...(extras?.driverId ? { driverId: extras.driverId } : {}),
51
+ };
31
52
  this.config.activeAttempts[taskId] = active;
32
- await this.persist(this.config);
53
+ await this.persistRuntime(this.config);
33
54
  return { ...data, lease_token: "stored by Conduit Bridge" };
34
55
  }
35
56
  attempt(taskId) {
@@ -51,12 +72,15 @@ export class ConduitClient {
51
72
  else
52
73
  Object.assign(active, { [key]: value });
53
74
  }
54
- await this.persist(this.config);
75
+ await this.persistRuntime(this.config);
55
76
  return active;
56
77
  }
57
78
  async clearAttempt(taskId) {
79
+ const active = this.config.activeAttempts[taskId];
58
80
  delete this.config.activeAttempts[taskId];
59
- await this.persist(this.config);
81
+ if (this.config.sessions)
82
+ delete this.config.sessions[taskId];
83
+ await this.removeAttempt(taskId, active?.attemptId);
60
84
  }
61
85
  /**
62
86
  * Ensure a project-scoped gateway fuel key for agent /v1 calls.
@@ -74,7 +98,7 @@ export class ConduitClient {
74
98
  gatewayKey = String(rotated.gateway_secret);
75
99
  }
76
100
  this.config.fuel = { ...this.config.fuel, [projectId]: { gatewayKey } };
77
- await this.persist(this.config);
101
+ await this.persistPrefs(this.config);
78
102
  return gatewayKey;
79
103
  }
80
104
  }
package/dist/config.js CHANGED
@@ -1,7 +1,9 @@
1
- import { chmod, mkdir, readFile, unlink, writeFile } from "node:fs/promises";
1
+ import { chmod, mkdir, open, readFile, rename, unlink, writeFile, stat } from "node:fs/promises";
2
2
  import { randomBytes } from "node:crypto";
3
3
  import { homedir } from "node:os";
4
4
  import { join, resolve } from "node:path";
5
+ /** Serialize config writes in-process so concurrent claim/phase updates cannot clobber each other. */
6
+ let saveChain = Promise.resolve();
5
7
  /** Default when join omits --capacity. */
6
8
  export const BRIDGE_LEASE_CAPACITY = 1;
7
9
  /** Hard ceiling for truthful concurrent Bridge slots (one agent + worktree each). */
@@ -16,6 +18,9 @@ const directory = process.env.CONDUIT_BRIDGE_CONFIG_DIR?.trim()
16
18
  ? resolve(process.env.CONDUIT_BRIDGE_CONFIG_DIR)
17
19
  : join(homedir(), ".config", "conduit");
18
20
  const path = join(directory, "config.json");
21
+ /** Runtime attempt/session state — separate so drivers CLI cannot clobber live claims. */
22
+ const runtimePath = join(directory, "runtime.json");
23
+ const lockPath = join(directory, "config.lock");
19
24
  const pendingPath = join(directory, "pending-connect.json");
20
25
  const installationPath = join(directory, "installation.json");
21
26
  export async function loadConfig() {
@@ -43,7 +48,10 @@ export async function loadConfigIfPresent() {
43
48
  throw new Error(`Bridge configuration is missing ${field}; run \`conduit join --url <worker-url>\` to reconnect this machine.`);
44
49
  }
45
50
  }
46
- config.activeAttempts ??= {};
51
+ const runtime = await loadRuntime();
52
+ // Prefer dedicated runtime file; fall back to legacy fields still present in config.json.
53
+ config.activeAttempts = runtime?.activeAttempts ?? config.activeAttempts ?? {};
54
+ config.sessions = runtime?.sessions ?? config.sessions;
47
55
  for (const active of Object.values(config.activeAttempts))
48
56
  active.phase ??= "agent_running";
49
57
  if (config.fuelSource !== "local" && config.fuelSource !== "conduit")
@@ -63,10 +71,175 @@ export async function loadConfigIfPresent() {
63
71
  }
64
72
  return config;
65
73
  }
66
- export async function saveConfig(config) {
74
+ async function loadRuntime() {
75
+ try {
76
+ const raw = JSON.parse(await readFile(runtimePath, "utf8"));
77
+ return {
78
+ activeAttempts: raw.activeAttempts && typeof raw.activeAttempts === "object" ? raw.activeAttempts : {},
79
+ sessions: raw.sessions && typeof raw.sessions === "object" ? raw.sessions : undefined,
80
+ tombstones: raw.tombstones && typeof raw.tombstones === "object" ? raw.tombstones : undefined,
81
+ };
82
+ }
83
+ catch {
84
+ return null;
85
+ }
86
+ }
87
+ async function withConfigLock(fn) {
67
88
  await mkdir(directory, { recursive: true, mode: 0o700 });
68
- await writeFile(path, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
69
- await chmod(path, 0o600);
89
+ const started = Date.now();
90
+ for (;;) {
91
+ try {
92
+ const handle = await open(lockPath, "wx");
93
+ try {
94
+ await handle.writeFile(`${process.pid}\n`);
95
+ return await fn();
96
+ }
97
+ finally {
98
+ await handle.close().catch(() => undefined);
99
+ await unlink(lockPath).catch(() => undefined);
100
+ }
101
+ }
102
+ catch (error) {
103
+ const code = error.code;
104
+ if (code !== "EEXIST")
105
+ throw error;
106
+ try {
107
+ const age = Date.now() - (await stat(lockPath)).mtimeMs;
108
+ if (age > 30_000)
109
+ await unlink(lockPath).catch(() => undefined);
110
+ }
111
+ catch { /* lock raced away */ }
112
+ if (Date.now() - started > 10_000)
113
+ throw new Error("Timed out waiting for Bridge config lock");
114
+ await new Promise((resolveSleep) => setTimeout(resolveSleep, 25));
115
+ }
116
+ }
117
+ }
118
+ async function writeJsonAtomic(filePath, value) {
119
+ const tmp = `${filePath}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
120
+ try {
121
+ await writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
122
+ await rename(tmp, filePath);
123
+ await chmod(filePath, 0o600);
124
+ }
125
+ catch (error) {
126
+ await unlink(tmp).catch(() => undefined);
127
+ throw error;
128
+ }
129
+ }
130
+ /** Merge snapshot onto disk without resurrecting attempts removed under lock. */
131
+ export function mergeRuntimeState(disk, snapshot) {
132
+ const tombstones = { ...(disk?.tombstones ?? {}) };
133
+ const merged = { ...(disk?.activeAttempts ?? {}) };
134
+ for (const [taskId, attempt] of Object.entries(snapshot.activeAttempts ?? {})) {
135
+ if (tombstones[taskId] === attempt.attemptId)
136
+ continue;
137
+ const onDisk = merged[taskId];
138
+ if (onDisk && onDisk.attemptId !== attempt.attemptId)
139
+ continue;
140
+ merged[taskId] = attempt;
141
+ delete tombstones[taskId];
142
+ }
143
+ const sessions = { ...(disk?.sessions ?? {}), ...(snapshot.sessions ?? {}) };
144
+ for (const taskId of Object.keys(tombstones))
145
+ delete sessions[taskId];
146
+ return {
147
+ activeAttempts: merged,
148
+ sessions: Object.keys(sessions).length ? sessions : undefined,
149
+ tombstones: Object.keys(tombstones).length ? tombstones : undefined,
150
+ };
151
+ }
152
+ /**
153
+ * Persist Bridge config. Operator prefs and runtime attempt state are written as separate files under
154
+ * a cross-process lock so `drivers online|offline` cannot erase an in-flight claim (and vice versa).
155
+ */
156
+ export async function saveConfig(config, options = {}) {
157
+ const snapshot = structuredClone(config);
158
+ const run = async () => {
159
+ await withConfigLock(async () => {
160
+ await mkdir(directory, { recursive: true, mode: 0o700 });
161
+ // One-time migrate: if runtime.json is missing, preserve legacy activeAttempts from config.json.
162
+ const existingRuntime = await loadRuntime();
163
+ if (!existingRuntime) {
164
+ let legacyAttempts = {};
165
+ let legacySessions;
166
+ try {
167
+ const legacy = JSON.parse(await readFile(path, "utf8"));
168
+ if (legacy.activeAttempts && typeof legacy.activeAttempts === "object")
169
+ legacyAttempts = legacy.activeAttempts;
170
+ if (legacy.sessions && typeof legacy.sessions === "object")
171
+ legacySessions = legacy.sessions;
172
+ }
173
+ catch { /* no prior config */ }
174
+ if (Object.keys(legacyAttempts).length || legacySessions) {
175
+ await writeJsonAtomic(runtimePath, { activeAttempts: legacyAttempts, sessions: legacySessions });
176
+ }
177
+ }
178
+ const prefs = { ...snapshot };
179
+ delete prefs.activeAttempts;
180
+ delete prefs.sessions;
181
+ const prefsForDisk = { ...prefs, activeAttempts: {}, sessions: undefined };
182
+ await writeJsonAtomic(path, prefsForDisk);
183
+ if (!options.prefsOnly) {
184
+ const disk = await loadRuntime();
185
+ await writeJsonAtomic(runtimePath, mergeRuntimeState(disk, {
186
+ activeAttempts: snapshot.activeAttempts ?? {},
187
+ sessions: snapshot.sessions,
188
+ }));
189
+ }
190
+ });
191
+ };
192
+ const next = saveChain.then(run, run);
193
+ saveChain = next.catch(() => undefined);
194
+ await next;
195
+ }
196
+ /** Operator-pref write that leaves runtime.json (claims/sessions) untouched. */
197
+ export async function saveConfigPrefs(config) {
198
+ await saveConfig(config, { prefsOnly: true });
199
+ }
200
+ /**
201
+ * Claim/session write that leaves prefs untouched and merges with on-disk attempts from other
202
+ * Bridge processes (supervisor vs MCP) instead of replacing the whole runtime snapshot.
203
+ */
204
+ export async function saveRuntime(config) {
205
+ const snapshot = structuredClone(config);
206
+ const run = async () => {
207
+ await withConfigLock(async () => {
208
+ await mkdir(directory, { recursive: true, mode: 0o700 });
209
+ const disk = await loadRuntime();
210
+ await writeJsonAtomic(runtimePath, mergeRuntimeState(disk, {
211
+ activeAttempts: snapshot.activeAttempts ?? {},
212
+ sessions: snapshot.sessions,
213
+ }));
214
+ });
215
+ };
216
+ const next = saveChain.then(run, run);
217
+ saveChain = next.catch(() => undefined);
218
+ await next;
219
+ }
220
+ /** Remove one attempt under the lock; no-op if another process already replaced the claim. */
221
+ export async function removeRuntimeAttempt(taskId, attemptId) {
222
+ const run = async () => {
223
+ await withConfigLock(async () => {
224
+ await mkdir(directory, { recursive: true, mode: 0o700 });
225
+ const disk = await loadRuntime();
226
+ if (!disk)
227
+ return;
228
+ const current = disk.activeAttempts[taskId];
229
+ if (!current)
230
+ return;
231
+ if (attemptId && current.attemptId !== attemptId)
232
+ return;
233
+ delete disk.activeAttempts[taskId];
234
+ if (disk.sessions)
235
+ delete disk.sessions[taskId];
236
+ disk.tombstones = { ...(disk.tombstones ?? {}), [taskId]: current.attemptId };
237
+ await writeJsonAtomic(runtimePath, disk);
238
+ });
239
+ };
240
+ const next = saveChain.then(run, run);
241
+ saveChain = next.catch(() => undefined);
242
+ await next;
70
243
  }
71
244
  export async function loadPendingConnection() {
72
245
  try {
package/dist/driver.js CHANGED
@@ -81,6 +81,7 @@ export function buildAssignmentPrompt(context) {
81
81
  const changeScope = packageContext?.change_scope?.length ? packageContext.change_scope : spec.change_scope;
82
82
  const evidence = packageContext?.required_evidence?.length ? packageContext.required_evidence : spec.required_evidence;
83
83
  const rework = packageContext?.rework_feedback ?? context.reworkFeedback;
84
+ const artifact = (packageContext?.deliverable ?? spec.deliverable) === "artifact";
84
85
  const lines = [
85
86
  `You are completing one delegated Conduit assignment as the ${role} role. Work only inside the current workspace.`,
86
87
  "",
@@ -99,8 +100,17 @@ export function buildAssignmentPrompt(context) {
99
100
  lines.push("", `BOUNDARIES — never violate these\n${boundaries.map((item) => `- ${item}`).join("\n")}`);
100
101
  if (acceptance?.length)
101
102
  lines.push("", `ACCEPTANCE CRITERIA — the delivery is judged against these\n${acceptance.map((item) => `- ${item}`).join("\n")}`);
102
- if (evidence?.length)
103
- lines.push("", `REQUIRED EVIDENCE\n${evidence.map((item) => `- ${item}`).join("\n")}`);
103
+ if (evidence?.length) {
104
+ lines.push("", "REQUIRED EVIDENCE");
105
+ for (const kind of evidence) {
106
+ if (kind === "test") {
107
+ lines.push("- test — include verbatim command stdout/stderr (and exit code) in evidence details; summaries alone are rejected");
108
+ }
109
+ else {
110
+ lines.push(`- ${kind}`);
111
+ }
112
+ }
113
+ }
104
114
  if (changeScope?.length)
105
115
  lines.push("", `CHANGE SCOPE — only modify paths under\n${changeScope.map((item) => `- ${item}`).join("\n")}`);
106
116
  if (spec.repository?.base_commit)
@@ -113,6 +123,7 @@ export function buildAssignmentPrompt(context) {
113
123
  if (rework)
114
124
  lines.push("", `REWORK FEEDBACK — an independent review returned this delivery; address every point\n${rework}`);
115
125
  const mustCommit = context.grants.includes("repo_write") && context.grants.includes("branch_create");
126
+ const mustOpenPr = mustCommit && context.grants.includes("pr_create") && Boolean(changeScope?.length);
116
127
  // Exactly what the driver's permission contract will allow, derived from the same source, so the
117
128
  // prompt cannot drift from the enforcement.
118
129
  const runnableCommands = [
@@ -130,8 +141,24 @@ export function buildAssignmentPrompt(context) {
130
141
  `- These are the ONLY shell commands you may run: ${runnableCommands.join("; ")}. Anything else is rejected — do not try variations, wrappers, or \`echo\`. Run the relevant ones and report their real output.`,
131
142
  ]
132
143
  : ["- You have no shell authority for this assignment. Do not attempt shell commands; verify by reading files and report what you could not verify as unknown."]), ...(mustCommit
133
- ? ["- Your working branch is already checked out. Commit your repository changes on the current branch (git add + git commit) — do not create a new branch — and report the resulting sha as head_commit. A delivery that changed files without a commit is rejected. Local commits are required; pushing is a separate authority."]
134
- : ["- You have no repository write authority for this assignment. Do not create, modify, or commit any file; a delivery reporting repository changes will be rejected. Record all findings, verification output, and conclusions in your final report instead."]), "- Never merge, deploy, push to protected branches, or touch production.", `- Hard-denied commands (all drivers): ${deniedCommands.join("; ")}.`, "- Do not invent evidence. Report unknown when you could not verify a criterion.",
144
+ ? [
145
+ "- Your working branch is already checked out. Commit your repository changes on the current branch (git add + git commit) do not create a new branch and report the resulting sha as head_commit. A delivery that changed files without a commit is rejected.",
146
+ ...(mustOpenPr
147
+ ? [
148
+ "- This assignment includes pr_create: after committing, `git push -u origin HEAD` and `gh pr create` against the repository default branch, then set pull_request_url to that PR URL. Conduit merge-on-accept lands the PR when the owner Accepts — a head_commit without a PR leaves main unchanged and breaks later packages.",
149
+ ]
150
+ : ["- You may commit locally; opening a pull request is outside this assignment's grants."]),
151
+ ]
152
+ : artifact
153
+ ? [
154
+ // repo_write without branch_create: the agent may write, but the output is not repository
155
+ // content. `.conduit/` is already in .git/info/exclude, so this path is ignored by git and
156
+ // `git status` stays clean — which is the point for a content pack, and mandatory for
157
+ // anything binary.
158
+ "- This assignment delivers an artifact, not repository content. Write your output under `.conduit/artifacts/` — that path is excluded from git, so do not commit anything and do not report a head_commit.",
159
+ "- Publish the artifact to its destination and report each output in your evidence with the published URL and a sha256 digest. An artifact nobody can fetch is not a delivery.",
160
+ ]
161
+ : ["- You have no repository write authority for this assignment. Do not create, modify, or commit any file; a delivery reporting repository changes will be rejected. Record all findings, verification output, and conclusions in your final report instead."]), "- Never merge, deploy, force-push, push to protected branches, or touch production.", `- Hard-denied commands (all drivers): ${deniedCommands.join("; ")}.`, "- Do not invent evidence. Report unknown when you could not verify a criterion.",
135
162
  // A met claim with nothing backing it is rejected server-side ("Met acceptance criteria require
136
163
  // mapped evidence"). Observed live: six criteria marked met, evidence mapped to four, whole
137
164
  // delivery lost. Say it here rather than let the agent discover it by failing.
@@ -832,7 +859,7 @@ function boundedEnvironment(fuel, fuelSource = "conduit") {
832
859
  ...(fuelSource === "local" ? LOCAL_VENDOR_ENV : [])];
833
860
  const env = Object.fromEntries(allowed.flatMap((name) => process.env[name] === undefined ? [] : [[name, process.env[name]]]));
834
861
  if (fuelSource === "conduit" && fuel) {
835
- const v1 = `${fuel.baseUrl.replace(/\/+$/, "")}/v1`;
862
+ const v1 = fuelEndpoint(fuel.baseUrl);
836
863
  env.ANTHROPIC_API_KEY = fuel.gatewayKey;
837
864
  env.ANTHROPIC_BASE_URL = v1;
838
865
  env.OPENAI_API_KEY = fuel.gatewayKey;
@@ -841,6 +868,10 @@ function boundedEnvironment(fuel, fuelSource = "conduit") {
841
868
  }
842
869
  return env;
843
870
  }
871
+ /** The `/v1` a fuelled agent is pointed at — and the one its model names are validated against. */
872
+ export function fuelEndpoint(baseUrl) {
873
+ return `${baseUrl.replace(/\/+$/, "")}/v1`;
874
+ }
844
875
  export function hasClaudeLogin(env = process.env) {
845
876
  if (env.ANTHROPIC_API_KEY || env.ANTHROPIC_AUTH_TOKEN)
846
877
  return true;
package/dist/drivers.js CHANGED
@@ -129,7 +129,8 @@ export function advertiseLeaseCapacity(approved, onlineIds) {
129
129
  return 1; // schema/heartbeat min; status standby prevents matching
130
130
  return approved;
131
131
  }
132
- export function driversHeartbeatReport(config, activeAttempts) {
132
+ export function driversHeartbeatReport(config, activeAttempts, processOnlineIds) {
133
+ const processOnline = new Set(processOnlineIds ?? []);
133
134
  const busyByDriver = new Map();
134
135
  for (const active of Object.values(activeAttempts)) {
135
136
  if (!active.driverId)
@@ -138,7 +139,7 @@ export function driversHeartbeatReport(config, activeAttempts) {
138
139
  }
139
140
  return listDriverLanes(config).map((lane) => ({
140
141
  id: lane.id,
141
- state: lane.state,
142
+ state: processOnline.has(lane.id) ? "online" : lane.state,
142
143
  busy: (busyByDriver.get(lane.id) ?? 0) > 0,
143
144
  }));
144
145
  }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * After the agent commits scoped work with pr_create, Bridge must land a GitHub PR.
3
+ * Push uses the machine's git credentials; PR creation uses the control-plane GitHub
4
+ * credential via /runner/v1/tasks/:id/open-pull-request so secrets never live on runners.
5
+ */
6
+ import { execFile } from "node:child_process";
7
+ import { promisify } from "node:util";
8
+ const execFileAsync = promisify(execFile);
9
+ export function needsPullRequest(report, spec, grants) {
10
+ return Boolean(grants.includes("pr_create")
11
+ && (spec.change_scope?.length ?? 0) > 0
12
+ && report.head_commit
13
+ && !report.pull_request_url);
14
+ }
15
+ /** True when reported SHA equals workspace HEAD (allows short SHA prefixes). */
16
+ export function isAbsoluteHttpsUrl(value) {
17
+ try {
18
+ return new URL(value).protocol === "https:";
19
+ }
20
+ catch {
21
+ return false;
22
+ }
23
+ }
24
+ export function commitsMatch(reported, actual) {
25
+ const left = reported.trim().toLowerCase();
26
+ const right = actual.trim().toLowerCase();
27
+ if (!left || !right)
28
+ return false;
29
+ return left === right || left.startsWith(right) || right.startsWith(left);
30
+ }
31
+ async function workspaceHeadCommit(workspace) {
32
+ const { stdout } = await execFileAsync("git", ["-C", workspace, "rev-parse", "HEAD"], {
33
+ timeout: 30_000,
34
+ maxBuffer: 1_000_000,
35
+ });
36
+ const head = stdout.trim().toLowerCase();
37
+ if (!/^[0-9a-f]{7,64}$/.test(head))
38
+ throw new Error("Could not determine workspace HEAD commit");
39
+ return head;
40
+ }
41
+ /**
42
+ * Bind reported head_commit to the checkout HEAD, then open a PR when still needed.
43
+ * Agent-supplied PR URLs skip creation but still require HEAD identity.
44
+ */
45
+ export async function ensureDeliveryPullRequest(input) {
46
+ const readHead = input.readHeadCommit ?? workspaceHeadCommit;
47
+ let report = input.report;
48
+ if (report.head_commit && (input.spec.change_scope?.length ?? 0) > 0) {
49
+ const head = await readHead(input.workspace);
50
+ if (!commitsMatch(report.head_commit, head)) {
51
+ throw new Error(`Delivered head_commit ${report.head_commit} does not match workspace HEAD ${head}`);
52
+ }
53
+ report = { ...report, head_commit: head };
54
+ }
55
+ if (!needsPullRequest(report, input.spec, input.grants))
56
+ return report;
57
+ await execFileAsync("git", ["-C", input.workspace, "push", "-u", "origin", "HEAD"], {
58
+ timeout: 120_000,
59
+ maxBuffer: 2_000_000,
60
+ });
61
+ const headAfterPush = await readHead(input.workspace);
62
+ if (!report.head_commit || !commitsMatch(report.head_commit, headAfterPush)) {
63
+ throw new Error(`Workspace HEAD changed during push (${report.head_commit} → ${headAfterPush})`);
64
+ }
65
+ report = { ...report, head_commit: headAfterPush };
66
+ const { stdout: branchOut } = await execFileAsync("git", ["-C", input.workspace, "branch", "--show-current"], {
67
+ timeout: 30_000,
68
+ maxBuffer: 1_000_000,
69
+ });
70
+ const headBranch = branchOut.trim();
71
+ if (!headBranch)
72
+ throw new Error("Could not determine the attempt branch for pull request creation");
73
+ const response = await input.client.attemptRequest(input.taskId, "open-pull-request", {
74
+ head_branch: headBranch,
75
+ head_commit: report.head_commit,
76
+ title: (input.title ?? "Conduit delivery").slice(0, 200),
77
+ body: [
78
+ "Opened by Conduit Bridge after the agent committed scoped delivery work.",
79
+ "",
80
+ `Head commit: ${report.head_commit}`,
81
+ ].join("\n"),
82
+ idempotency_key: `bridge:open-pr:${input.attemptId}`,
83
+ });
84
+ const pullRequestUrl = typeof response.pull_request_url === "string" ? response.pull_request_url : "";
85
+ // Any https URL: the control plane resolved which forge this project uses and parsed the response
86
+ // with that forge's own rules, so re-asserting a github.com shape here would only reject a valid
87
+ // GitLab merge request — after the branch was already pushed. The head-commit cross-check below is
88
+ // what actually proves the returned change request is this delivery's.
89
+ if (!isAbsoluteHttpsUrl(pullRequestUrl)) {
90
+ throw new Error("Control plane did not return a pull_request_url");
91
+ }
92
+ const openedHead = typeof response.head_commit === "string" ? response.head_commit : "";
93
+ const deliveredHead = report.head_commit ?? "";
94
+ if (openedHead && deliveredHead && !commitsMatch(openedHead, deliveredHead)) {
95
+ throw new Error(`Opened pull request head ${openedHead} does not match delivered commit ${deliveredHead}`);
96
+ }
97
+ return { ...report, pull_request_url: pullRequestUrl };
98
+ }
package/dist/execution.js CHANGED
@@ -2,10 +2,25 @@ 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, parseAgentReport, pickModelCandidate, tierForRisk } from "./driver.js";
5
+ import { DRIVERS, agentReportTemplate, buildAssignmentPrompt, evidenceKinds, fuelEndpoint, parseAgentReport, pickModelCandidate, tierForRisk } from "./driver.js";
6
6
  import { pickDriverForClaim, resolveDriverFuel } from "./drivers.js";
7
7
  import { attemptWorktreePath, createAttemptWorktree, proveResumeWorktree, quarantineAttemptWorktree, removeAttemptWorktree, } from "./attempt-worktree.js";
8
- import { buildWorkspaceBrief, isBaseCommitAncestor, normalizeRepositoryUrl } from "./brief.js";
8
+ import { buildWorkspaceBrief, ensureCommitAvailable, isBaseCommitAncestor, normalizeRepositoryUrl } from "./brief.js";
9
+ import { ensureDeliveryPullRequest } from "./ensure-pull-request.js";
10
+ /** Feedback text for changes_requested summaries (plain string or `{ feedback }`). */
11
+ function changesRequestedFeedback(summary) {
12
+ if (!summary)
13
+ return null;
14
+ if (!summary.startsWith("{"))
15
+ return summary;
16
+ try {
17
+ const parsed = JSON.parse(summary);
18
+ return typeof parsed.feedback === "string" && parsed.feedback.trim() ? parsed.feedback : null;
19
+ }
20
+ catch {
21
+ return null;
22
+ }
23
+ }
9
24
  const assignmentSchema = z.object({
10
25
  id: z.string().uuid(),
11
26
  attempt_id: z.string().uuid(),
@@ -70,7 +85,11 @@ export async function renewLeases(client, config) {
70
85
  function resolveAttemptDriver(config, active, fallback) {
71
86
  if (active.driverId && DRIVERS[active.driverId])
72
87
  return DRIVERS[active.driverId];
73
- return fallback ?? null;
88
+ if (fallback)
89
+ return fallback;
90
+ // Crash between claim and driverId persist: pick any free online lane so recovery is not stranded.
91
+ const picked = pickDriverForClaim(config);
92
+ return picked ? DRIVERS[picked] ?? null : null;
74
93
  }
75
94
  export async function recoverActiveAttempt(client, config, driver, workspace, brief, timeoutMs, supervision, taskId) {
76
95
  const active = taskId ? config.activeAttempts[taskId] : Object.values(config.activeAttempts)[0];
@@ -81,6 +100,11 @@ export async function recoverActiveAttempt(client, config, driver, workspace, br
81
100
  console.error(`No driver for attempt ${active.attemptId} (driverId=${active.driverId ?? "unset"})`);
82
101
  return false;
83
102
  }
103
+ if (!active.driverId) {
104
+ const assigned = Object.entries(DRIVERS).find(([, d]) => d === laneDriver)?.[0];
105
+ if (assigned)
106
+ await client.updateAttempt(active.taskId, { driverId: assigned });
107
+ }
84
108
  if (active.phase === "agent_running") {
85
109
  const worktree = active.worktreePath ?? attemptWorktreePath(workspace, active.attemptId);
86
110
  const sessionId = config.sessions?.[active.taskId]?.trim() || "";
@@ -158,10 +182,9 @@ export async function pumpExecutionSlots(client, config, workspace, brief, timeo
158
182
  }
159
183
  if (!laneDriver || !driverId)
160
184
  break;
161
- const taskId = await claimNextAssignment(client, config, workspace, brief);
185
+ const taskId = await claimNextAssignment(client, config, workspace, brief, driverId);
162
186
  if (!taskId)
163
187
  break;
164
- await client.updateAttempt(taskId, { driverId });
165
188
  progressed = true;
166
189
  console.log(`Executing ${taskId} via ${laneDriver.name}`);
167
190
  const slot = runClaimedAssignment(client, config, laneDriver, workspace, brief, taskId, timeoutMs, supervision)
@@ -175,7 +198,7 @@ export async function pumpExecutionSlots(client, config, workspace, brief, timeo
175
198
  return progressed;
176
199
  }
177
200
  /** Claim one assignment when under capacity; does not start the agent (multi-slot pump does). */
178
- export async function claimNextAssignment(client, config, workspace, brief) {
201
+ export async function claimNextAssignment(client, config, workspace, brief, driverId) {
179
202
  if (Object.keys(config.activeAttempts).length >= config.leaseCapacity)
180
203
  return null;
181
204
  const data = await client.request("/runner/v1/assignments");
@@ -192,11 +215,15 @@ export async function claimNextAssignment(client, config, workspace, brief) {
192
215
  let rejection = null;
193
216
  if (assignment.repository_fingerprint && liveRepository !== assignment.repository_fingerprint)
194
217
  rejection = "workspace_repository_mismatch";
195
- else if (assignment.repository_fingerprint && liveBrief?.base_commit !== assignment.claimed_head)
196
- rejection = "workspace_head_changed";
197
- else if (assignment.requested_base_commit && assignment.claimed_head) {
198
- const compatible = await isBaseCommitAncestor(workspace, assignment.requested_base_commit, assignment.claimed_head).catch(() => false);
199
- if (!compatible)
218
+ else if (assignment.repository_fingerprint && assignment.claimed_head && liveBrief?.base_commit !== assignment.claimed_head) {
219
+ // Rework / advanced base may claim a head that differs from the source checkout HEAD.
220
+ const reachable = await ensureCommitAvailable(workspace, assignment.claimed_head).catch(() => false);
221
+ if (!reachable)
222
+ rejection = "workspace_head_changed";
223
+ }
224
+ if (!rejection && assignment.requested_base_commit && assignment.claimed_head) {
225
+ const startHead = await resolveStartHead(workspace, assignment.requested_base_commit, assignment.claimed_head);
226
+ if (!startHead)
200
227
  rejection = "base_not_ancestor";
201
228
  }
202
229
  if (rejection) {
@@ -208,9 +235,28 @@ export async function claimNextAssignment(client, config, workspace, brief) {
208
235
  return null;
209
236
  }
210
237
  console.log(`Claiming assignment ${assignment.id} (attempt ${assignment.attempt_id})`);
211
- await client.claim(assignment.id, assignment.attempt_id);
238
+ await client.claim(assignment.id, assignment.attempt_id, driverId ? { driverId } : undefined);
212
239
  return assignment.id;
213
240
  }
241
+ /**
242
+ * When a sibling merge advances task.base_commit past the machine checkout, the required base is a
243
+ * descendant of claimed_head — not an ancestor. Fetch it and start from the advanced base instead of looping.
244
+ */
245
+ async function resolveStartHead(workspace, requestedBase, claimedHead) {
246
+ if (await isBaseCommitAncestor(workspace, requestedBase, claimedHead).catch(() => false))
247
+ return claimedHead;
248
+ await ensureCommitAvailable(workspace, requestedBase).catch(() => false);
249
+ if (await isBaseCommitAncestor(workspace, requestedBase, claimedHead).catch(() => false))
250
+ return claimedHead;
251
+ // Workspace is behind the required base (dependent package after merge).
252
+ if (await isBaseCommitAncestor(workspace, claimedHead, requestedBase).catch(() => false))
253
+ return requestedBase;
254
+ if (await ensureCommitAvailable(workspace, requestedBase).catch(() => false)
255
+ && await isBaseCommitAncestor(workspace, claimedHead, requestedBase).catch(() => false)) {
256
+ return requestedBase;
257
+ }
258
+ return null;
259
+ }
214
260
  export async function executeNextAssignment(client, config, driver, workspace, brief, timeoutMs, supervision) {
215
261
  if (Object.keys(config.activeAttempts).length)
216
262
  return recoverActiveAttempt(client, config, driver, workspace, brief, timeoutMs, supervision);
@@ -228,25 +274,40 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
228
274
  const workPackage = workPackageSchema.parse(detail.work_package) ?? null;
229
275
  const spec = parseTaskSpec(task.spec_json);
230
276
  const grants = z.array(z.string()).parse(task.grants_json ? JSON.parse(task.grants_json) : []);
231
- const reworkFeedback = task.delivery_state === "changes_requested" && task.delivery_summary && !task.delivery_summary.startsWith("{") ? task.delivery_summary : null;
277
+ const reworkFeedback = task.delivery_state === "changes_requested" ? changesRequestedFeedback(task.delivery_summary) : null;
232
278
  // Recompile current state at claim time — earlier packages may have moved the repo.
233
279
  const liveBrief = await buildWorkspaceBrief(workspace).catch(() => brief);
234
280
  const liveRepository = liveBrief?.repository ? normalizeRepositoryUrl(liveBrief.repository) : null;
235
- if (executionContract.repository_fingerprint && (liveRepository !== executionContract.repository_fingerprint
236
- || !liveBrief?.base_commit
237
- || liveBrief.base_commit !== executionContract.claimed_head)) {
238
- const message = liveRepository !== executionContract.repository_fingerprint
239
- ? "Workspace repository changed after dispatch"
240
- : "Workspace HEAD changed after dispatch";
241
- await queueTerminal(client, taskId, { action: "fail", body: { error: message, retryable: true, idempotency_key: `bridge:workspace-mismatch:${active.attemptId}` } });
242
- console.error(`Assignment ${taskId} preflight failed: ${message}`);
281
+ if (executionContract.repository_fingerprint && liveRepository !== executionContract.repository_fingerprint) {
282
+ await queueTerminal(client, taskId, { action: "fail", body: { error: "Workspace repository changed after dispatch", retryable: true, idempotency_key: `bridge:workspace-mismatch:${active.attemptId}` } });
283
+ console.error(`Assignment ${taskId} preflight failed: Workspace repository changed after dispatch`);
243
284
  return;
244
285
  }
245
- const startCommit = executionContract.claimed_head ?? liveBrief?.base_commit;
286
+ const startCommit = (() => {
287
+ const claimed = executionContract.claimed_head ?? liveBrief?.base_commit ?? null;
288
+ return claimed;
289
+ })();
246
290
  if (!startCommit) {
247
291
  await queueTerminal(client, taskId, { action: "fail", body: { error: "No start commit for attempt worktree", retryable: true, idempotency_key: `bridge:no-start-commit:${active.attemptId}` } });
248
292
  return;
249
293
  }
294
+ let worktreeStart = startCommit;
295
+ if (executionContract.requested_base_commit) {
296
+ const resolved = await resolveStartHead(workspace, executionContract.requested_base_commit, startCommit);
297
+ if (!resolved) {
298
+ 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}` } });
299
+ return;
300
+ }
301
+ worktreeStart = resolved;
302
+ }
303
+ else if (liveBrief?.base_commit && liveBrief.base_commit !== startCommit) {
304
+ const reachable = await ensureCommitAvailable(workspace, startCommit).catch(() => false);
305
+ if (!reachable) {
306
+ 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}` } });
307
+ console.error(`Assignment ${taskId} preflight failed: Delivered head is not available in this workspace`);
308
+ return;
309
+ }
310
+ }
250
311
  let attemptWorkspace;
251
312
  if (options.existingWorktree) {
252
313
  attemptWorkspace = options.existingWorktree;
@@ -256,7 +317,7 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
256
317
  attemptWorkspace = await createAttemptWorktree({
257
318
  sourceWorkspace: workspace,
258
319
  attemptId: active.attemptId,
259
- startCommit,
320
+ startCommit: worktreeStart,
260
321
  });
261
322
  }
262
323
  catch (error) {
@@ -272,7 +333,7 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
272
333
  }
273
334
  // Same list the driver hands to the permission contract, so the prompt states exactly what is
274
335
  // executable rather than leaving the agent to guess and hit rejections.
275
- const prompt = buildAssignmentPrompt({ taskId, objective: task.objective, spec, grants, workspace: attemptWorkspace, currentHead: startCommit, reworkFeedback, workPackage, verificationCommands: liveBrief?.verification ?? [] });
336
+ const prompt = buildAssignmentPrompt({ taskId, objective: task.objective, spec, grants, workspace: attemptWorkspace, currentHead: worktreeStart, reworkFeedback, workPackage, verificationCommands: liveBrief?.verification ?? [] });
276
337
  const resuming = Boolean(options.forceResumeSessionId);
277
338
  await client.attemptRequest(taskId, "progress", {
278
339
  phase: "changing",
@@ -292,7 +353,7 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
292
353
  const gatewayKey = await client.ensureFuel(task.project_id);
293
354
  fuel = { baseUrl: config.baseUrl, gatewayKey };
294
355
  }
295
- const selection = await resolveAssignmentModel(driver, config, spec, taskId, attemptWorkspace);
356
+ const selection = await resolveAssignmentModel(driver, config, spec, taskId, attemptWorkspace, fuel);
296
357
  await client.attemptRequest(taskId, "model-selection", {
297
358
  risk: selection.risk, tier: selection.tier, model: selection.model ?? null, driver: driver.name,
298
359
  ...(selection.model ? {} : { note: "cli-default" }), idempotency_key: `model:${active.attemptId}`,
@@ -399,11 +460,25 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
399
460
  }
400
461
  }
401
462
  try {
463
+ // Mechanical land path: agent may forget gh; Bridge pushes and the control plane opens the PR.
464
+ report = await ensureDeliveryPullRequest({
465
+ client,
466
+ taskId,
467
+ attemptId: active.attemptId,
468
+ workspace: attemptWorkspace,
469
+ report,
470
+ spec,
471
+ grants,
472
+ title: task.objective,
473
+ });
402
474
  validateDeliveryReport(report, spec, grants);
403
475
  }
404
476
  catch (error) {
405
477
  const message = error instanceof Error ? error.message : "Agent delivery report was invalid";
406
- await queueTerminal(client, taskId, { action: "fail", body: { error: message, retryable: false, idempotency_key: `bridge:invalid-delivery:${active.attemptId}` } });
478
+ // Missing PR tooling / credential is retryable once the environment is fixed; other contract
479
+ // failures (scope, evidence mapping) stay non-retryable.
480
+ const retryable = /pull_request|merge_request|forge|GitHub|GitLab|gh |push|credential/i.test(message);
481
+ await queueTerminal(client, taskId, { action: "fail", body: { error: message, retryable, idempotency_key: `bridge:invalid-delivery:${active.attemptId}` } });
407
482
  console.error(`Assignment ${taskId} could not produce a valid Delivery: ${redactSecrets(message)}`);
408
483
  const replyTail = reportText.slice(-8_000);
409
484
  console.error(`Assignment ${taskId} agent reply tail (${reportText.length} chars total, redacted): ${redactSecrets(replyTail) || "<empty>"}`);
@@ -458,36 +533,77 @@ async function submitFinishedDelivery(client, taskId) {
458
533
  await client.attemptRequest(taskId, "progress", { phase: "preparing_delivery", message: "Agent finished; submitting the Delivery.", idempotency_key: `bridge:progress:${active.attemptId}:delivery` });
459
534
  await queueTerminal(client, taskId, terminal);
460
535
  }
461
- /**
462
- * The live model list per driver, cached with a TTL so tier candidates are
463
- * re-validated against reality as vendors add and retire models — no config
464
- * edit or restart needed for the refresh itself.
465
- */
466
- const modelListCache = new Map();
536
+ export const modelListCache = new Map();
467
537
  const MODEL_LIST_TTL_MS = 6 * 60 * 60 * 1000;
468
- async function availableModels(driver, workspace) {
538
+ /** A failure is remembered only briefly: long enough not to re-pay it per claim, short enough that
539
+ * validation resumes soon after the cause clears. */
540
+ const MODEL_LIST_FAILURE_TTL_MS = 60 * 1000;
541
+ export async function availableModels(driver, workspace, fuel) {
542
+ // Under Conduit fuel the agent's requests go to Conduit's /v1, which resolves an organization alias
543
+ // and rejects a vendor model name outright — so the list that matters is Conduit's, not the CLI's.
544
+ // If it cannot be read, fall back to the CLI's list rather than to no validation at all.
545
+ if (fuel) {
546
+ const aliases = await cachedList(`conduit:${fuel.baseUrl}`, () => conduitAliases(fuel));
547
+ if (aliases)
548
+ return aliases;
549
+ }
469
550
  if (!driver.listModels)
470
551
  return null;
471
- const cached = modelListCache.get(driver.name);
472
- if (cached && Date.now() - cached.at < MODEL_LIST_TTL_MS)
552
+ return cachedList(driver.name, () => driver.listModels(undefined, workspace).catch(() => null));
553
+ }
554
+ export async function cachedList(key, load) {
555
+ const cached = modelListCache.get(key);
556
+ const ttl = cached?.available ? MODEL_LIST_TTL_MS : MODEL_LIST_FAILURE_TTL_MS;
557
+ if (cached && Date.now() - cached.at < ttl)
473
558
  return cached.available;
474
- const available = await driver.listModels(undefined, workspace).catch(() => null);
475
- modelListCache.set(driver.name, { at: Date.now(), available });
559
+ const available = await load();
560
+ modelListCache.set(key, { at: Date.now(), available });
476
561
  return available;
477
562
  }
478
- async function resolveAssignmentModel(driver, config, spec, taskId, workspace) {
563
+ /** The organization's enabled aliases, which is exactly what /v1 will accept as a model. */
564
+ export async function conduitAliases(fuel) {
565
+ try {
566
+ const response = await fetch(`${fuelEndpoint(fuel.baseUrl)}/models`, {
567
+ headers: { authorization: `Bearer ${fuel.gatewayKey}` },
568
+ });
569
+ if (!response.ok)
570
+ return null;
571
+ const body = await response.json();
572
+ const ids = (body.data ?? []).map((row) => row.id).filter((id) => typeof id === "string");
573
+ return ids.length ? new Set(ids) : null;
574
+ }
575
+ catch {
576
+ return null;
577
+ }
578
+ }
579
+ async function resolveAssignmentModel(driver, config, spec, taskId, workspace, fuel) {
479
580
  const tier = tierForRisk(spec.risk_level);
480
581
  const configured = config.models?.[tier];
481
582
  const risk = spec.risk_level === "low" || spec.risk_level === "medium" || spec.risk_level === "high" ? spec.risk_level : "unset";
583
+ let listed;
584
+ const list = async () => (listed === undefined ? (listed = await availableModels(driver, workspace, fuel)) : listed);
585
+ const offered = async () => {
586
+ const available = fuel ? await list() : null;
587
+ return available ? ` Conduit accepts: ${[...available].join(", ")}.` : "";
588
+ };
482
589
  if (!configured || (Array.isArray(configured) && configured.length === 0)) {
483
- console.log(`Assignment ${taskId} intelligence tier ${tier} (risk ${risk}); no model mappingCLI default`);
590
+ // Not fatal on purpose. Conduit's /v1 rejects a vendor model nameproven by `npm run
591
+ // probe:fuel` — so under Conduit fuel the CLI's own default cannot resolve. Whether every driver
592
+ // honours the injected base URL rather than its stored login is not proven per driver, so this
593
+ // says exactly what is wrong and lets the run show it rather than refusing on a half-proven chain.
594
+ const unmapped = `Assignment ${taskId} intelligence tier ${tier} (risk ${risk}); no model mapping — CLI default`;
595
+ if (fuel)
596
+ console.error(`${unmapped}, which Conduit resolves as an organization alias and rejects otherwise.${await offered()}`);
597
+ else
598
+ console.log(unmapped);
484
599
  return { risk, tier };
485
600
  }
486
601
  const candidates = Array.isArray(configured) ? configured : [configured];
487
- const { model, skipped } = pickModelCandidate(candidates, await availableModels(driver, workspace));
602
+ const { model, skipped } = pickModelCandidate(candidates, await list());
488
603
  const skippedNote = skipped.length ? ` (skipped unavailable/unsafe: ${skipped.join(", ")})` : "";
489
604
  if (!model) {
490
- console.error(`Assignment ${taskId} intelligence tier ${tier} (risk ${risk}): no configured candidate available — CLI default${skippedNote}`);
605
+ const none = `Assignment ${taskId} intelligence tier ${tier} (risk ${risk}): no configured candidate available — CLI default${skippedNote}`;
606
+ console.error(fuel ? `${none}. No candidate is a Conduit model alias.${await offered()}` : none);
491
607
  return { risk, tier };
492
608
  }
493
609
  console.log(`Assignment ${taskId} intelligence tier ${tier} (risk ${risk}) → model ${model}${skippedNote}`);
@@ -548,8 +664,16 @@ export function validateDeliveryReport(report, spec, grants = []) {
548
664
  if (!grants.includes("repo_write") && (report.changes.length > 0 || report.evidence.some((item) => item.kind === "change"))) {
549
665
  throw new Error("Agent reported repository changes without the repo_write grant");
550
666
  }
667
+ // Merge-on-accept lands GitHub PRs. Local attempt commits without a PR never reach main.
668
+ if (grants.includes("pr_create")
669
+ && (spec.change_scope?.length ?? 0) > 0
670
+ && report.head_commit
671
+ && !report.pull_request_url) {
672
+ throw new Error("Repository changes require a pull_request_url when pr_create is granted");
673
+ }
674
+ const requiredEvidence = spec.required_evidence ?? [];
551
675
  const supplied = new Set(report.evidence.map((item) => item.kind));
552
- const missing = (spec.required_evidence ?? []).filter((kind) => !supplied.has(kind));
676
+ const missing = requiredEvidence.filter((kind) => !supplied.has(kind));
553
677
  if (missing.length)
554
678
  throw new Error(`Agent report is missing required evidence: ${missing.join(", ")}`);
555
679
  const unsupported = report.acceptance_results
@@ -557,7 +681,17 @@ export function validateDeliveryReport(report, spec, grants = []) {
557
681
  .map((result) => result.criterion);
558
682
  if (unsupported.length)
559
683
  throw new Error(`Met acceptance criteria require mapped evidence: ${unsupported.join("; ")}`);
684
+ // Plan-time normalizeRequiredEvidence puts "test" on verify packages; reject summary-only details.
685
+ if (requiredEvidence.includes("test")) {
686
+ const thin = report.evidence
687
+ .filter((item) => item.kind === "test")
688
+ .filter((item) => item.details.join("\n").trim().length < TEST_EVIDENCE_DETAILS_MIN);
689
+ if (thin.length) {
690
+ throw new Error("test evidence must include verbatim command output in details (not a summary)");
691
+ }
692
+ }
560
693
  }
694
+ const TEST_EVIDENCE_DETAILS_MIN = 32;
561
695
  function referenceKind(kind) {
562
696
  if (kind === "change")
563
697
  return "commit";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miraland-labs/conduit-bridge",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Conduit Bridge CLI — join, connect, multi-driver lanes, and run Claude Code / Codex / Cursor / OpenCode / Kiro / Antigravity agents for a Conduit organization",
5
5
  "type": "module",
6
6
  "bin": {