@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 +35 -0
- package/dist/cli.js +15 -14
- package/dist/client.js +34 -10
- package/dist/config.js +178 -5
- package/dist/driver.js +36 -5
- package/dist/drivers.js +3 -2
- package/dist/ensure-pull-request.js +98 -0
- package/dist/execution.js +176 -42
- package/package.json +1 -1
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
372
|
+
await saveConfigPrefs(config);
|
|
373
373
|
}
|
|
374
374
|
config = (await seedDriversFromDetection(config)).config;
|
|
375
|
-
await
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
14
|
-
|
|
13
|
+
persistRuntime;
|
|
14
|
+
persistPrefs;
|
|
15
|
+
removeAttempt;
|
|
16
|
+
constructor(config, persist) {
|
|
15
17
|
this.config = config;
|
|
16
|
-
|
|
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 = {
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
69
|
-
|
|
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("",
|
|
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
|
-
? [
|
|
134
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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"
|
|
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 &&
|
|
236
|
-
|
|
237
|
-
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
472
|
-
|
|
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
|
|
475
|
-
modelListCache.set(
|
|
559
|
+
const available = await load();
|
|
560
|
+
modelListCache.set(key, { at: Date.now(), available });
|
|
476
561
|
return available;
|
|
477
562
|
}
|
|
478
|
-
|
|
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
|
-
|
|
590
|
+
// Not fatal on purpose. Conduit's /v1 rejects a vendor model name — proven 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
|
|
602
|
+
const { model, skipped } = pickModelCandidate(candidates, await list());
|
|
488
603
|
const skippedNote = skipped.length ? ` (skipped unavailable/unsafe: ${skipped.join(", ")})` : "";
|
|
489
604
|
if (!model) {
|
|
490
|
-
|
|
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 =
|
|
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.
|
|
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": {
|