@miraland-labs/conduit-bridge 0.6.1 → 0.7.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.
@@ -2,8 +2,9 @@
2
2
  * F-07 slice 1: one Git worktree per attempt. The install-service source checkout is never the
3
3
  * agent cwd; retries must not inherit dirty files from a failed run.
4
4
  */
5
- import { appendFile, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
6
- import { join } from "node:path";
5
+ import { access, appendFile, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
6
+ import { constants } from "node:fs";
7
+ import { join, resolve } from "node:path";
7
8
  import { execFile } from "node:child_process";
8
9
  import { promisify } from "node:util";
9
10
  const execFileAsync = promisify(execFile);
@@ -54,6 +55,28 @@ export async function removeAttemptWorktree(sourceWorkspace, worktreePath) {
54
55
  }).catch(() => undefined);
55
56
  }
56
57
  }
58
+ /**
59
+ * F-07 safe resume: prove the interrupted attempt still owns this worktree path.
60
+ * Does not check session identity — caller requires config.sessions[taskId] separately.
61
+ */
62
+ export async function proveResumeWorktree(sourceWorkspace, attemptId, worktreePath) {
63
+ if (!worktreePath?.trim())
64
+ return false;
65
+ const expected = attemptWorktreePath(sourceWorkspace, attemptId);
66
+ if (resolve(worktreePath) !== resolve(expected))
67
+ return false;
68
+ try {
69
+ await access(worktreePath, constants.F_OK);
70
+ const { stdout } = await execFileAsync("git", ["-C", worktreePath, "rev-parse", "HEAD"], {
71
+ timeout: 15_000,
72
+ maxBuffer: 1_000_000,
73
+ });
74
+ return /^[0-9a-f]{40,64}$/i.test(stdout.trim());
75
+ }
76
+ catch {
77
+ return false;
78
+ }
79
+ }
57
80
  /** Hold a crashed attempt tree for local diagnosis; never reused as agent cwd. */
58
81
  export async function quarantineAttemptWorktree(sourceWorkspace, worktreePath, attemptId) {
59
82
  const stamp = new Date().toISOString().replace(/[:.]/g, "-");
package/dist/cli.js CHANGED
@@ -6,13 +6,13 @@ 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, 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, 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";
13
13
  import { buildWorkspaceBrief } from "./brief.js";
14
14
  import { ensureCheckout } from "./checkout.js";
15
- import { executeNextAssignment, renewLeases } from "./execution.js";
15
+ import { pumpExecutionSlots, renewLeases } from "./execution.js";
16
16
  import { installRunnerService, uninstallRunnerService } from "./service.js";
17
17
  const [command] = process.argv.slice(2);
18
18
  /** Read our own package version so every runner start logs exactly which build is live. */
@@ -91,10 +91,11 @@ async function join() {
91
91
  const operatorName = values.operator?.trim() || userInfo().username;
92
92
  const machineName = values.machine?.trim() || suggestMachineName(hostname(), installationId);
93
93
  const capabilities = values.capability?.map((item) => item.trim()).filter(Boolean) ?? ["implement"];
94
- if (values.capacity && Number(values.capacity) !== BRIDGE_LEASE_CAPACITY) {
95
- throw new Error("Conduit Bridge currently runs one assignment at a time; --capacity must be 1");
94
+ const requestedCapacity = values.capacity ? Number(values.capacity) : BRIDGE_LEASE_CAPACITY;
95
+ if (!Number.isInteger(requestedCapacity) || requestedCapacity < 1 || requestedCapacity > BRIDGE_MAX_LEASE_CAPACITY) {
96
+ throw new Error(`--capacity must be an integer from 1 to ${BRIDGE_MAX_LEASE_CAPACITY} (concurrent attempt slots)`);
96
97
  }
97
- const leaseCapacity = BRIDGE_LEASE_CAPACITY;
98
+ const leaseCapacity = clampLeaseCapacity(requestedCapacity);
98
99
  console.log("\nRequesting a Conduit connection:");
99
100
  console.log(`Operator: ${operatorName}`);
100
101
  console.log(`Computer: ${machineName}`);
@@ -193,7 +194,7 @@ async function finishConnection(baseUrl, data, fuelSource) {
193
194
  runnerKey: String(data.runner_secret),
194
195
  capabilities: data.capabilities,
195
196
  grants: data.grants,
196
- leaseCapacity: Number(data.lease_capacity),
197
+ leaseCapacity: clampLeaseCapacity(data.lease_capacity),
197
198
  activeAttempts: {},
198
199
  fuelSource: resolvedFuel,
199
200
  ...(Object.keys(fuel).length ? { fuel } : {}),
@@ -326,24 +327,28 @@ async function runner() {
326
327
  if (!driver || !workspace) {
327
328
  console.warn(`WARNING: heartbeat only — no work will execute. Use: ${bridgeUsage("runner", "--agent", AGENT_PLACEHOLDER, "--workspace", "<repo>")}`);
328
329
  }
329
- console.log(`Conduit runner (bridge v${bridgeVersion()}) connected to ${config.baseUrl}${driver ? ` — executing via ${driver.name} in ${workspace}` : " — heartbeat only (no --agent)"} (fuel: ${fuelLabel})`);
330
+ console.log(`Conduit runner (bridge v${bridgeVersion()}) connected to ${config.baseUrl}${driver ? ` — executing via ${driver.name} in ${workspace}` : " — heartbeat only (no --agent)"} (fuel: ${fuelLabel}; slots: ${config.leaseCapacity})`);
331
+ const running = new Map();
330
332
  for (;;) {
331
- let executed = false;
333
+ let progressed = false;
332
334
  try {
333
335
  await heartbeat(client, config, workspace ? await buildWorkspaceBrief(workspace).catch(() => brief) : null);
334
336
  await renewLeases(client, config);
335
337
  if (driver && workspace) {
336
- executed = await executeNextAssignment(client, config, driver, workspace, brief, timeoutMs, {
338
+ progressed = await pumpExecutionSlots(client, config, driver, workspace, brief, timeoutMs, {
337
339
  heartbeat: async () => heartbeat(client, config, await buildWorkspaceBrief(workspace).catch(() => brief)),
338
340
  heartbeatIntervalMs: intervalMs,
339
- });
341
+ }, running);
340
342
  }
341
343
  }
342
344
  catch (error) {
343
345
  console.error(`Runner cycle failed; retrying: ${redactSecrets(error instanceof Error ? error.message : "unknown error")}`);
344
346
  }
345
- if (values.once && executed)
347
+ if (values.once && progressed) {
348
+ if (running.size)
349
+ await Promise.allSettled([...running.values()]);
346
350
  return;
351
+ }
347
352
  await new Promise((resolveSleep) => setTimeout(resolveSleep, intervalMs));
348
353
  }
349
354
  }
package/dist/config.js CHANGED
@@ -2,7 +2,16 @@ import { chmod, mkdir, readFile, unlink, writeFile } 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
+ /** Default when join omits --capacity. */
5
6
  export const BRIDGE_LEASE_CAPACITY = 1;
7
+ /** Hard ceiling for truthful concurrent Bridge slots (one agent + worktree each). */
8
+ export const BRIDGE_MAX_LEASE_CAPACITY = 4;
9
+ export function clampLeaseCapacity(value) {
10
+ const n = typeof value === "number" ? value : Number(value);
11
+ if (!Number.isInteger(n) || n < 1)
12
+ return BRIDGE_LEASE_CAPACITY;
13
+ return Math.min(n, BRIDGE_MAX_LEASE_CAPACITY);
14
+ }
6
15
  const directory = process.env.CONDUIT_BRIDGE_CONFIG_DIR?.trim()
7
16
  ? resolve(process.env.CONDUIT_BRIDGE_CONFIG_DIR)
8
17
  : join(homedir(), ".config", "conduit");
@@ -39,9 +48,8 @@ export async function loadConfigIfPresent() {
39
48
  active.phase ??= "agent_running";
40
49
  if (config.fuelSource !== "local" && config.fuelSource !== "conduit")
41
50
  config.fuelSource = "conduit";
42
- // The built-in Bridge runs one agent process synchronously. Keep legacy
43
- // configurations truthful until isolated concurrent slot workers exist.
44
- config.leaseCapacity = BRIDGE_LEASE_CAPACITY;
51
+ // Clamp to what concurrent slot workers can run (1..BRIDGE_MAX_LEASE_CAPACITY).
52
+ config.leaseCapacity = clampLeaseCapacity(config.leaseCapacity ?? BRIDGE_LEASE_CAPACITY);
45
53
  return config;
46
54
  }
47
55
  export async function saveConfig(config) {
package/dist/execution.js CHANGED
@@ -3,7 +3,7 @@ import { z } from "zod";
3
3
  import { ConduitRequestError } from "./client.js";
4
4
  import { redactSecrets } from "./config.js";
5
5
  import { agentReportTemplate, buildAssignmentPrompt, evidenceKinds, parseAgentReport, pickModelCandidate, tierForRisk } from "./driver.js";
6
- import { attemptWorktreePath, createAttemptWorktree, quarantineAttemptWorktree, removeAttemptWorktree, } from "./attempt-worktree.js";
6
+ import { attemptWorktreePath, createAttemptWorktree, proveResumeWorktree, quarantineAttemptWorktree, removeAttemptWorktree, } from "./attempt-worktree.js";
7
7
  import { buildWorkspaceBrief, isBaseCommitAncestor, normalizeRepositoryUrl } from "./brief.js";
8
8
  const assignmentSchema = z.object({
9
9
  id: z.string().uuid(),
@@ -66,13 +66,22 @@ export async function renewLeases(client, config) {
66
66
  }
67
67
  }
68
68
  }
69
- export async function recoverActiveAttempt(client, config, driver, workspace, brief, timeoutMs, supervision) {
70
- const active = Object.values(config.activeAttempts)[0];
69
+ export async function recoverActiveAttempt(client, config, driver, workspace, brief, timeoutMs, supervision, taskId) {
70
+ const active = taskId ? config.activeAttempts[taskId] : Object.values(config.activeAttempts)[0];
71
71
  if (!active)
72
72
  return false;
73
73
  if (active.phase === "agent_running") {
74
- // F-07: never resume a dirty shared checkout. Quarantine/remove the attempt worktree first.
75
74
  const worktree = active.worktreePath ?? attemptWorktreePath(workspace, active.attemptId);
75
+ const sessionId = config.sessions?.[active.taskId]?.trim() || "";
76
+ // F-07 safe resume: only when session + worktree identity are both proven. Otherwise interrupt.
77
+ if (sessionId && await proveResumeWorktree(workspace, active.attemptId, worktree)) {
78
+ console.log(`Resuming interrupted attempt ${active.attemptId} with proven worktree and session.`);
79
+ await runClaimedAssignment(client, config, driver, workspace, brief, active.taskId, timeoutMs, supervision, {
80
+ existingWorktree: worktree,
81
+ forceResumeSessionId: sessionId,
82
+ });
83
+ return true;
84
+ }
76
85
  await quarantineAttemptWorktree(workspace, worktree, active.attemptId).catch(() => removeAttemptWorktree(workspace, worktree).catch(() => undefined));
77
86
  await client.updateAttempt(active.taskId, { worktreePath: undefined });
78
87
  await queueTerminal(client, active.taskId, {
@@ -92,17 +101,51 @@ export async function recoverActiveAttempt(client, config, driver, workspace, br
92
101
  await runClaimedAssignment(client, config, driver, workspace, brief, active.taskId, timeoutMs, supervision);
93
102
  return true;
94
103
  }
95
- export async function executeNextAssignment(client, config, driver, workspace, brief, timeoutMs, supervision) {
96
- if (Object.keys(config.activeAttempts).length)
97
- return recoverActiveAttempt(client, config, driver, workspace, brief, timeoutMs, supervision);
104
+ /**
105
+ * F-07 multi-slot: keep up to leaseCapacity concurrent agent runs (each with its own worktree).
106
+ * `running` is owned by the runner loop and must outlive a single pump call.
107
+ */
108
+ export async function pumpExecutionSlots(client, config, driver, workspace, brief, timeoutMs, supervision, running) {
109
+ let progressed = running.size > 0;
110
+ for (const id of Object.keys(config.activeAttempts)) {
111
+ if (running.has(id))
112
+ continue;
113
+ progressed = true;
114
+ const slot = recoverActiveAttempt(client, config, driver, workspace, brief, timeoutMs, supervision, id)
115
+ .catch((error) => {
116
+ console.error(`Slot recovery failed: ${redactSecrets(error instanceof Error ? error.message : "unknown")}`);
117
+ })
118
+ .then(() => undefined)
119
+ .finally(() => { running.delete(id); });
120
+ running.set(id, slot);
121
+ }
122
+ while (running.size < config.leaseCapacity) {
123
+ const taskId = await claimNextAssignment(client, config, workspace, brief);
124
+ if (!taskId)
125
+ break;
126
+ progressed = true;
127
+ const slot = runClaimedAssignment(client, config, driver, workspace, brief, taskId, timeoutMs, supervision)
128
+ .catch((error) => {
129
+ console.error(`Slot execution failed: ${redactSecrets(error instanceof Error ? error.message : "unknown")}`);
130
+ })
131
+ .then(() => undefined)
132
+ .finally(() => { running.delete(taskId); });
133
+ running.set(taskId, slot);
134
+ }
135
+ return progressed;
136
+ }
137
+ /** Claim one assignment when under capacity; does not start the agent (multi-slot pump does). */
138
+ export async function claimNextAssignment(client, config, workspace, brief) {
139
+ if (Object.keys(config.activeAttempts).length >= config.leaseCapacity)
140
+ return null;
98
141
  const data = await client.request("/runner/v1/assignments");
99
142
  const assignments = z.array(assignmentSchema).parse(data.assignments ?? []);
100
143
  const assignment = assignments[0];
101
144
  if (!assignment)
102
- return false;
145
+ return null;
103
146
  if (assignment.execution_mode === "human") {
104
147
  console.log(`Human takeover assignment ${assignment.id} — use Bridge MCP tools to claim and submit (agent runner skips).`);
105
- return false;
148
+ return null;
106
149
  }
107
150
  const liveBrief = await buildWorkspaceBrief(workspace).catch(() => brief);
108
151
  const liveRepository = liveBrief?.repository ? normalizeRepositoryUrl(liveBrief.repository) : null;
@@ -122,14 +165,22 @@ export async function executeNextAssignment(client, config, driver, workspace, b
122
165
  body: JSON.stringify({ attempt_id: assignment.attempt_id, reason: rejection, idempotency_key: `bridge:preflight-reject:${assignment.attempt_id}:${rejection}` }),
123
166
  });
124
167
  console.error(`Assignment ${assignment.id} rejected before claim: ${rejection}`);
125
- return true;
168
+ return null;
126
169
  }
127
170
  console.log(`Claiming assignment ${assignment.id} (attempt ${assignment.attempt_id})`);
128
171
  await client.claim(assignment.id, assignment.attempt_id);
129
- await runClaimedAssignment(client, config, driver, workspace, brief, assignment.id, timeoutMs, supervision);
172
+ return assignment.id;
173
+ }
174
+ export async function executeNextAssignment(client, config, driver, workspace, brief, timeoutMs, supervision) {
175
+ if (Object.keys(config.activeAttempts).length)
176
+ return recoverActiveAttempt(client, config, driver, workspace, brief, timeoutMs, supervision);
177
+ const taskId = await claimNextAssignment(client, config, workspace, brief);
178
+ if (!taskId)
179
+ return false;
180
+ await runClaimedAssignment(client, config, driver, workspace, brief, taskId, timeoutMs, supervision);
130
181
  return true;
131
182
  }
132
- async function runClaimedAssignment(client, config, driver, workspace, brief, taskId, timeoutMs, supervision) {
183
+ async function runClaimedAssignment(client, config, driver, workspace, brief, taskId, timeoutMs, supervision, options = {}) {
133
184
  const active = client.attempt(taskId);
134
185
  const detail = await client.request(`/runner/v1/tasks/${taskId}`);
135
186
  const task = taskDetailSchema.parse(detail.task);
@@ -157,27 +208,39 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
157
208
  return;
158
209
  }
159
210
  let attemptWorkspace;
160
- try {
161
- attemptWorkspace = await createAttemptWorktree({
162
- sourceWorkspace: workspace,
163
- attemptId: active.attemptId,
164
- startCommit,
165
- });
211
+ if (options.existingWorktree) {
212
+ attemptWorkspace = options.existingWorktree;
166
213
  }
167
- catch (error) {
168
- const code = error instanceof Error ? error.message : "worktree_create_failed";
169
- const message = code === "source_workspace_dirty"
170
- ? "Source workspace has uncommitted changes; refuse to start an isolated attempt"
171
- : `Unable to create attempt worktree: ${code}`;
172
- await queueTerminal(client, taskId, { action: "fail", body: { error: message, retryable: true, idempotency_key: `bridge:worktree:${active.attemptId}:${code}` } });
173
- console.error(`Assignment ${taskId} worktree create failed: ${redactSecrets(message)}`);
174
- return;
214
+ else {
215
+ try {
216
+ attemptWorkspace = await createAttemptWorktree({
217
+ sourceWorkspace: workspace,
218
+ attemptId: active.attemptId,
219
+ startCommit,
220
+ });
221
+ }
222
+ catch (error) {
223
+ const code = error instanceof Error ? error.message : "worktree_create_failed";
224
+ const message = code === "source_workspace_dirty"
225
+ ? "Source workspace has uncommitted changes; refuse to start an isolated attempt"
226
+ : `Unable to create attempt worktree: ${code}`;
227
+ await queueTerminal(client, taskId, { action: "fail", body: { error: message, retryable: true, idempotency_key: `bridge:worktree:${active.attemptId}:${code}` } });
228
+ console.error(`Assignment ${taskId} worktree create failed: ${redactSecrets(message)}`);
229
+ return;
230
+ }
231
+ await client.updateAttempt(taskId, { worktreePath: attemptWorkspace });
175
232
  }
176
- await client.updateAttempt(taskId, { worktreePath: attemptWorkspace });
177
233
  // Same list the driver hands to the permission contract, so the prompt states exactly what is
178
234
  // executable rather than leaving the agent to guess and hit rejections.
179
235
  const prompt = buildAssignmentPrompt({ taskId, objective: task.objective, spec, grants, workspace: attemptWorkspace, currentHead: startCommit, reworkFeedback, workPackage, verificationCommands: liveBrief?.verification ?? [] });
180
- await client.attemptRequest(taskId, "progress", { phase: "changing", message: `Starting ${driver.name} for this assignment${reworkFeedback ? " with review feedback" : ""}.`, idempotency_key: `bridge:progress:${active.attemptId}:start` });
236
+ const resuming = Boolean(options.forceResumeSessionId);
237
+ await client.attemptRequest(taskId, "progress", {
238
+ phase: "changing",
239
+ message: resuming
240
+ ? `Resuming ${driver.name} after Bridge restart with proven session and worktree.`
241
+ : `Starting ${driver.name} for this assignment${reworkFeedback ? " with review feedback" : ""}.`,
242
+ idempotency_key: resuming ? `bridge:progress:${active.attemptId}:resume` : `bridge:progress:${active.attemptId}:start`,
243
+ });
181
244
  const fuelSource = config.fuelSource === "local" ? "local" : "conduit";
182
245
  let fuel;
183
246
  if (fuelSource === "conduit") {
@@ -193,10 +256,12 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
193
256
  // that was chosen. updateAttempt alone only moves local runner state, which the database never sees.
194
257
  await client.attemptRequest(taskId, "progress", {
195
258
  phase: "agent_running",
196
- message: `Started ${driver.name}${selection.model ? ` on ${selection.model}` : " on its CLI default"}.`,
197
- idempotency_key: `bridge:progress:${active.attemptId}:agent-start`,
259
+ message: resuming
260
+ ? `Resumed ${driver.name}${selection.model ? ` on ${selection.model}` : " on its CLI default"}.`
261
+ : `Started ${driver.name}${selection.model ? ` on ${selection.model}` : " on its CLI default"}.`,
262
+ idempotency_key: resuming ? `bridge:progress:${active.attemptId}:agent-resume` : `bridge:progress:${active.attemptId}:agent-start`,
198
263
  });
199
- await client.updateAttempt(taskId, { phase: "agent_running", fuelMode: fuelSource });
264
+ await client.updateAttempt(taskId, { phase: "agent_running", fuelMode: fuelSource, worktreePath: attemptWorkspace });
200
265
  const renewTimer = setInterval(() => { void renewLeases(client, config).catch((error) => console.error(`Lease renewal failed: ${redactSecrets(error instanceof Error ? error.message : "unknown")}`)); }, 60_000);
201
266
  let heartbeatRunning = false;
202
267
  const heartbeatTimer = supervision ? setInterval(() => {
@@ -208,15 +273,17 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
208
273
  .finally(() => { heartbeatRunning = false; });
209
274
  }, supervision.heartbeatIntervalMs) : null;
210
275
  try {
276
+ // Resume for (1) Bridge restart with proven session+worktree, or (2) review rework.
277
+ // A fresh attempt after a failed delivery must start clean: resuming a session whose last
278
+ // turn already "finished" makes the agent reply conversationally without the report block.
279
+ const resumeSessionId = options.forceResumeSessionId
280
+ ?? (reworkFeedback ? config.sessions?.[taskId] : undefined);
211
281
  const result = await driver.run({
212
282
  prompt,
213
283
  workspace: attemptWorkspace,
214
284
  grants,
215
285
  verificationCommands: liveBrief?.verification ?? [],
216
- // Resume only for review rework, where continuing the prior conversation is the point.
217
- // A fresh attempt after a failed delivery must start clean: resuming a session whose last
218
- // turn already "finished" makes the agent reply conversationally without the report block.
219
- resumeSessionId: reworkFeedback ? config.sessions?.[taskId] : undefined,
286
+ resumeSessionId,
220
287
  timeoutMs,
221
288
  model: selection.model,
222
289
  fuel,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miraland-labs/conduit-bridge",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "description": "Conduit Bridge CLI — join, connect, and run Claude Code / Codex / Cursor / OpenCode / Kiro / Antigravity agents for a Conduit organization",
5
5
  "type": "module",
6
6
  "bin": {