@miraland-labs/conduit-bridge 0.8.0 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -1
- package/dist/brief.js +35 -0
- package/dist/cli.js +77 -21
- package/dist/client.js +34 -10
- package/dist/config.js +189 -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 +215 -51
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Local Bridge CLI for [Conduit](https://github.com/miralandlabs/conduit). Connects a computer to one organization, claims work, and drives a local agent.
|
|
4
4
|
|
|
5
|
-
**Current npm:** `0.
|
|
5
|
+
**Current npm:** `0.9.1` — **disconnect/disengage**, join org prompt when `--organization` is omitted, multi-driver lanes (`drivers online|offline`), shared concurrent slots **1–4**, worktrees, optional `--ensure-checkout`.
|
|
6
6
|
|
|
7
7
|
## Prerequisites
|
|
8
8
|
|
|
@@ -20,6 +20,14 @@ Local Bridge CLI for [Conduit](https://github.com/miralandlabs/conduit). Connect
|
|
|
20
20
|
|
|
21
21
|
```bash
|
|
22
22
|
npx @miraland-labs/conduit-bridge join --url <https://your-conduit> --organization <slug>
|
|
23
|
+
# If --organization is omitted, the CLI asks: Join which org?
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Disengage (leave the organization; then you can join the same or another org):
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
npx @miraland-labs/conduit-bridge disconnect
|
|
30
|
+
# Or non-interactive: disconnect --yes
|
|
23
31
|
```
|
|
24
32
|
|
|
25
33
|
After approval, seed detects installed CLIs as **offline lanes**. Bring one or more online (subscription failover / concurrent lanes):
|
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
|
@@ -3,10 +3,12 @@ import { parseArgs } from "node:util";
|
|
|
3
3
|
import { resolve, dirname, join as pathJoin } from "node:path";
|
|
4
4
|
import { hostname, userInfo } from "node:os";
|
|
5
5
|
import { spawn } from "node:child_process";
|
|
6
|
+
import { createInterface } from "node:readline/promises";
|
|
6
7
|
import { readFileSync } from "node:fs";
|
|
8
|
+
import { stdin as input, stdout as output } from "node:process";
|
|
7
9
|
import { fileURLToPath } from "node:url";
|
|
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";
|
|
10
|
+
import { ConduitClient, ConduitRequestError } from "./client.js";
|
|
11
|
+
import { BRIDGE_LEASE_CAPACITY, BRIDGE_MAX_LEASE_CAPACITY, clampLeaseCapacity, clearLocalConnection, clearPendingConnection, loadConfig, loadConfigIfPresent, loadOrCreateInstallationId, loadPendingConnection, redactSecrets, saveConfig, saveConfigPrefs, savePendingConnection, suggestMachineName, } from "./config.js";
|
|
10
12
|
import { runMcp } from "./mcp.js";
|
|
11
13
|
import { detectInstalledClients, localFuelOnlyClients, suggestFuelSource } from "./detect.js";
|
|
12
14
|
import { DRIVERS } from "./driver.js";
|
|
@@ -54,6 +56,27 @@ async function connect() {
|
|
|
54
56
|
throw new Error(data.error?.message ?? `Connect failed (${response.status})`);
|
|
55
57
|
await finishConnection(baseUrl, data, parseFuelSource(values.fuel));
|
|
56
58
|
}
|
|
59
|
+
async function promptLine(question) {
|
|
60
|
+
if (!input.isTTY || !output.isTTY) {
|
|
61
|
+
throw new Error("Interactive input is unavailable; pass the value on the command line");
|
|
62
|
+
}
|
|
63
|
+
const rl = createInterface({ input, output });
|
|
64
|
+
try {
|
|
65
|
+
return (await rl.question(question)).trim();
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
rl.close();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
async function resolveJoinOrganization(explicit) {
|
|
72
|
+
const raw = explicit?.trim() || await promptLine("Join which org? ");
|
|
73
|
+
const organization = raw.toLowerCase();
|
|
74
|
+
if (!organization)
|
|
75
|
+
throw new Error("Organization slug is required");
|
|
76
|
+
if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(organization))
|
|
77
|
+
throw new Error("Organization must be its lowercase workspace slug");
|
|
78
|
+
return organization;
|
|
79
|
+
}
|
|
57
80
|
async function join() {
|
|
58
81
|
const { values } = parseArgs({ args: process.argv.slice(3), options: {
|
|
59
82
|
url: { type: "string" }, resume: { type: "boolean" }, machine: { type: "string" }, operator: { type: "string" },
|
|
@@ -75,13 +98,11 @@ async function join() {
|
|
|
75
98
|
if (pending && !values.resume)
|
|
76
99
|
console.log(`Resuming the pending connection request for ${pending.baseUrl}.`);
|
|
77
100
|
if (!pending) {
|
|
78
|
-
if (!values.url
|
|
79
|
-
throw new Error(`Usage: ${bridgeUsage("join", "--url", "<worker-url>", "--organization
|
|
101
|
+
if (!values.url) {
|
|
102
|
+
throw new Error(`Usage: ${bridgeUsage("join", "--url", "<worker-url>", "[--organization <slug>]", "[--machine <name>]", "[--capability <role>]", "[--fuel conduit|local]")}`);
|
|
80
103
|
}
|
|
81
104
|
const baseUrl = normalizeBaseUrl(values.url);
|
|
82
|
-
const organization = values.organization
|
|
83
|
-
if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(organization))
|
|
84
|
-
throw new Error("Organization must be its lowercase workspace slug");
|
|
105
|
+
const organization = await resolveJoinOrganization(values.organization);
|
|
85
106
|
// A config that fails validation (for example pre-organization) must not
|
|
86
107
|
// block rejoining — join replaces it with a complete identity.
|
|
87
108
|
const connected = await loadConfigIfPresent().catch(() => null);
|
|
@@ -219,7 +240,7 @@ async function finishConnection(baseUrl, data, fuelSource) {
|
|
|
219
240
|
}
|
|
220
241
|
console.log(`Confirmed capabilities (matching): ${config.capabilities.join(", ") || "none"}`);
|
|
221
242
|
console.log(`Allowed actions: ${config.grants.join(", ") || "none"}`);
|
|
222
|
-
console.log(`Fuel source: ${config.fuelSource === "local" ? "local subscription" : "Conduit
|
|
243
|
+
console.log(`Fuel source: ${config.fuelSource === "local" ? "local subscription" : "Conduit /v1 (project owner BYOK on open orgs; org credentials on invite-only)"}`
|
|
223
244
|
+ (fuelAutoLocal ? " (auto: only Cursor/Kiro/Antigravity detected)" : ""));
|
|
224
245
|
const localOnly = localFuelOnlyClients(detected);
|
|
225
246
|
if (config.fuelSource === "conduit" && localOnly.length) {
|
|
@@ -268,7 +289,7 @@ async function installService() {
|
|
|
268
289
|
}
|
|
269
290
|
config = seedDriverLanes(config, [values.agent]).config;
|
|
270
291
|
config = setDriversOnline(config, [values.agent], true);
|
|
271
|
-
await
|
|
292
|
+
await saveConfigPrefs(config);
|
|
272
293
|
console.log(`Brought ${values.agent} online for this computer (shared capacity ${config.leaseCapacity}).`);
|
|
273
294
|
}
|
|
274
295
|
else if (!onlineDriverIds(config).length) {
|
|
@@ -303,7 +324,7 @@ async function fuelCommand() {
|
|
|
303
324
|
}
|
|
304
325
|
const config = await loadConfig();
|
|
305
326
|
config.fuelSource = mode;
|
|
306
|
-
await
|
|
327
|
+
await saveConfigPrefs(config);
|
|
307
328
|
const client = new ConduitClient(config);
|
|
308
329
|
await heartbeat(client, config, null);
|
|
309
330
|
console.log(`Machine fuel source set to ${mode === "local" ? "local subscription" : "Conduit pump"} and reported on heartbeat.`);
|
|
@@ -313,7 +334,7 @@ async function driversCommand() {
|
|
|
313
334
|
let config = await loadConfig();
|
|
314
335
|
config = (await seedDriversFromDetection(config)).config;
|
|
315
336
|
if (!sub || sub === "list") {
|
|
316
|
-
await
|
|
337
|
+
await saveConfigPrefs(config);
|
|
317
338
|
const lanes = listDriverLanes(config);
|
|
318
339
|
if (!lanes.length) {
|
|
319
340
|
console.log("No driver lanes registered. Install a supported agent CLI, then re-run this command.");
|
|
@@ -339,7 +360,7 @@ async function driversCommand() {
|
|
|
339
360
|
}
|
|
340
361
|
config = seedDriverLanes(config, ids).config;
|
|
341
362
|
config = setDriversOnline(config, ids, sub === "online");
|
|
342
|
-
await
|
|
363
|
+
await saveConfigPrefs(config);
|
|
343
364
|
const client = new ConduitClient(config);
|
|
344
365
|
await heartbeat(client, config, null).catch(() => undefined);
|
|
345
366
|
console.log(`${sub === "online" ? "Online" : "Offline"}: ${ids.join(", ")}`);
|
|
@@ -354,7 +375,7 @@ async function driversCommand() {
|
|
|
354
375
|
}
|
|
355
376
|
config = seedDriverLanes(config, [id]).config;
|
|
356
377
|
config = setDriverFuel(config, id, mode);
|
|
357
|
-
await
|
|
378
|
+
await saveConfigPrefs(config);
|
|
358
379
|
console.log(`${driverLabel(id)} lane fuel set to ${mode}`);
|
|
359
380
|
return;
|
|
360
381
|
}
|
|
@@ -369,10 +390,10 @@ async function runner() {
|
|
|
369
390
|
const fuelOverride = parseFuelSource(values.fuel);
|
|
370
391
|
if (fuelOverride) {
|
|
371
392
|
config.fuelSource = fuelOverride;
|
|
372
|
-
await
|
|
393
|
+
await saveConfigPrefs(config);
|
|
373
394
|
}
|
|
374
395
|
config = (await seedDriversFromDetection(config)).config;
|
|
375
|
-
await
|
|
396
|
+
await saveConfigPrefs(config);
|
|
376
397
|
const client = new ConduitClient(config);
|
|
377
398
|
let processDriver = null;
|
|
378
399
|
let processOnlineIds = null;
|
|
@@ -415,11 +436,11 @@ async function runner() {
|
|
|
415
436
|
config.drivers = latest.drivers;
|
|
416
437
|
config.fuelSource = latest.fuelSource;
|
|
417
438
|
config.leaseCapacity = latest.leaseCapacity;
|
|
418
|
-
await heartbeat(client, config, workspace ? await buildWorkspaceBrief(workspace).catch(() => brief) : null);
|
|
439
|
+
await heartbeat(client, config, workspace ? await buildWorkspaceBrief(workspace).catch(() => brief) : null, processOnlineIds);
|
|
419
440
|
await renewLeases(client, config);
|
|
420
441
|
if (workspace && (processDriver || onlineDriverIds(config).length)) {
|
|
421
442
|
progressed = await pumpExecutionSlots(client, config, workspace, brief, timeoutMs, {
|
|
422
|
-
heartbeat: async () => heartbeat(client, config, await buildWorkspaceBrief(workspace).catch(() => brief)),
|
|
443
|
+
heartbeat: async () => heartbeat(client, config, await buildWorkspaceBrief(workspace).catch(() => brief), processOnlineIds),
|
|
423
444
|
heartbeatIntervalMs: intervalMs,
|
|
424
445
|
}, running, { driver: processDriver, processOnlineIds });
|
|
425
446
|
}
|
|
@@ -435,23 +456,58 @@ async function runner() {
|
|
|
435
456
|
await new Promise((resolveSleep) => setTimeout(resolveSleep, intervalMs));
|
|
436
457
|
}
|
|
437
458
|
}
|
|
438
|
-
async function heartbeat(client, config, brief) {
|
|
439
|
-
|
|
459
|
+
async function heartbeat(client, config, brief, processOnlineIds) {
|
|
460
|
+
// Process-level `--agent` override must count as online even when saved lanes are offline.
|
|
461
|
+
const online = processOnlineIds?.length ? processOnlineIds : onlineDriverIds(config);
|
|
440
462
|
const status = heartbeatStatusForDrivers(online);
|
|
441
463
|
await client.request("/runner/v1/heartbeat", { method: "POST", body: JSON.stringify({
|
|
442
464
|
status,
|
|
443
465
|
capabilities: config.capabilities,
|
|
444
466
|
lease_capacity: config.leaseCapacity,
|
|
445
467
|
fuel_source: config.fuelSource === "local" ? "local" : "conduit",
|
|
446
|
-
drivers: driversHeartbeatReport(config, config.activeAttempts),
|
|
468
|
+
drivers: driversHeartbeatReport(config, config.activeAttempts, processOnlineIds),
|
|
447
469
|
...(brief ? { workspace_brief: brief } : {}),
|
|
448
470
|
}) });
|
|
449
471
|
}
|
|
472
|
+
async function disconnect() {
|
|
473
|
+
const { values } = parseArgs({ args: process.argv.slice(3), options: { yes: { type: "boolean", short: "y" } } });
|
|
474
|
+
const config = await loadConfigIfPresent().catch(() => null);
|
|
475
|
+
if (!config) {
|
|
476
|
+
await clearLocalConnection();
|
|
477
|
+
console.log("This Bridge is not connected to an organization.");
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
if (!values.yes) {
|
|
481
|
+
const answer = (await promptLine(`Disengage this computer from the organization and clear local credentials? [y/N] `)).toLowerCase();
|
|
482
|
+
if (answer !== "y" && answer !== "yes") {
|
|
483
|
+
console.log("Cancelled.");
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
try {
|
|
488
|
+
await new ConduitClient(config).request("/runner/v1/disengage", { method: "POST", body: "{}" });
|
|
489
|
+
console.log("Conduit disengaged this computer from the organization.");
|
|
490
|
+
}
|
|
491
|
+
catch (error) {
|
|
492
|
+
// Local clear still proceeds when the server already revoked the key (admin disengage).
|
|
493
|
+
if (error instanceof ConduitRequestError && (error.status === 401 || error.status === 404)) {
|
|
494
|
+
console.warn(`Server disengage skipped (${error.status}): ${redactSecrets(error.message)}`);
|
|
495
|
+
}
|
|
496
|
+
else {
|
|
497
|
+
throw error;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
await clearLocalConnection();
|
|
501
|
+
console.log("Local Bridge credentials cleared. Run join to connect again (same or another org).");
|
|
502
|
+
console.log(`Tip: stop a background runner with \`${bridgeUsage("uninstall-service")}\` if one is installed.`);
|
|
503
|
+
}
|
|
450
504
|
try {
|
|
451
505
|
if (command === "connect")
|
|
452
506
|
await connect();
|
|
453
507
|
else if (command === "join")
|
|
454
508
|
await join();
|
|
509
|
+
else if (command === "disconnect")
|
|
510
|
+
await disconnect();
|
|
455
511
|
else if (command === "fuel")
|
|
456
512
|
await fuelCommand();
|
|
457
513
|
else if (command === "drivers")
|
|
@@ -465,7 +521,7 @@ try {
|
|
|
465
521
|
else if (command === "uninstall-service")
|
|
466
522
|
await uninstallService();
|
|
467
523
|
else
|
|
468
|
-
throw new Error(`Usage: ${BRIDGE_NPX} <join|connect|fuel|drivers|mcp|runner|install-service|uninstall-service>`);
|
|
524
|
+
throw new Error(`Usage: ${BRIDGE_NPX} <join|disconnect|connect|fuel|drivers|mcp|runner|install-service|uninstall-service>`);
|
|
469
525
|
}
|
|
470
526
|
catch (error) {
|
|
471
527
|
console.error(error instanceof Error ? redactSecrets(error.message) : "Conduit command failed");
|
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 {
|
|
@@ -87,6 +260,17 @@ export async function clearPendingConnection() {
|
|
|
87
260
|
throw error;
|
|
88
261
|
});
|
|
89
262
|
}
|
|
263
|
+
/** Drop local org credentials after a server-side disengage. Keeps installation.json. */
|
|
264
|
+
export async function clearLocalConnection() {
|
|
265
|
+
await withConfigLock(async () => {
|
|
266
|
+
for (const target of [path, runtimePath, pendingPath]) {
|
|
267
|
+
await unlink(target).catch((error) => {
|
|
268
|
+
if (error.code !== "ENOENT")
|
|
269
|
+
throw error;
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
}
|
|
90
274
|
export async function loadOrCreateInstallationId() {
|
|
91
275
|
try {
|
|
92
276
|
const identity = JSON.parse(await readFile(installationPath, "utf8"));
|
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(),
|
|
@@ -29,6 +44,7 @@ const taskSpecSchema = z.object({
|
|
|
29
44
|
change_scope: z.array(z.string()).optional(), work_role: z.string().optional(),
|
|
30
45
|
repository: z.object({ url: z.string().optional(), base_commit: z.string().optional() }).nullable().optional(),
|
|
31
46
|
risk_level: z.string().optional(),
|
|
47
|
+
deliverable: z.enum(["repository", "artifact"]).optional().default("repository"),
|
|
32
48
|
});
|
|
33
49
|
const executionContractSchema = z.object({
|
|
34
50
|
repository_fingerprint: z.string().nullable().optional().default(null),
|
|
@@ -43,6 +59,7 @@ const workPackageSchema = z.object({
|
|
|
43
59
|
acceptance: z.array(z.string()).optional(),
|
|
44
60
|
change_scope: z.array(z.string()).optional(),
|
|
45
61
|
required_evidence: z.array(z.string()).optional(),
|
|
62
|
+
deliverable: z.enum(["repository", "artifact"]).optional().default("repository"),
|
|
46
63
|
initiative: z.object({
|
|
47
64
|
title: z.string().nullable().optional(),
|
|
48
65
|
desired_outcome: z.string().nullable().optional(),
|
|
@@ -70,7 +87,11 @@ export async function renewLeases(client, config) {
|
|
|
70
87
|
function resolveAttemptDriver(config, active, fallback) {
|
|
71
88
|
if (active.driverId && DRIVERS[active.driverId])
|
|
72
89
|
return DRIVERS[active.driverId];
|
|
73
|
-
|
|
90
|
+
if (fallback)
|
|
91
|
+
return fallback;
|
|
92
|
+
// Crash between claim and driverId persist: pick any free online lane so recovery is not stranded.
|
|
93
|
+
const picked = pickDriverForClaim(config);
|
|
94
|
+
return picked ? DRIVERS[picked] ?? null : null;
|
|
74
95
|
}
|
|
75
96
|
export async function recoverActiveAttempt(client, config, driver, workspace, brief, timeoutMs, supervision, taskId) {
|
|
76
97
|
const active = taskId ? config.activeAttempts[taskId] : Object.values(config.activeAttempts)[0];
|
|
@@ -81,6 +102,11 @@ export async function recoverActiveAttempt(client, config, driver, workspace, br
|
|
|
81
102
|
console.error(`No driver for attempt ${active.attemptId} (driverId=${active.driverId ?? "unset"})`);
|
|
82
103
|
return false;
|
|
83
104
|
}
|
|
105
|
+
if (!active.driverId) {
|
|
106
|
+
const assigned = Object.entries(DRIVERS).find(([, d]) => d === laneDriver)?.[0];
|
|
107
|
+
if (assigned)
|
|
108
|
+
await client.updateAttempt(active.taskId, { driverId: assigned });
|
|
109
|
+
}
|
|
84
110
|
if (active.phase === "agent_running") {
|
|
85
111
|
const worktree = active.worktreePath ?? attemptWorktreePath(workspace, active.attemptId);
|
|
86
112
|
const sessionId = config.sessions?.[active.taskId]?.trim() || "";
|
|
@@ -158,10 +184,9 @@ export async function pumpExecutionSlots(client, config, workspace, brief, timeo
|
|
|
158
184
|
}
|
|
159
185
|
if (!laneDriver || !driverId)
|
|
160
186
|
break;
|
|
161
|
-
const taskId = await claimNextAssignment(client, config, workspace, brief);
|
|
187
|
+
const taskId = await claimNextAssignment(client, config, workspace, brief, driverId);
|
|
162
188
|
if (!taskId)
|
|
163
189
|
break;
|
|
164
|
-
await client.updateAttempt(taskId, { driverId });
|
|
165
190
|
progressed = true;
|
|
166
191
|
console.log(`Executing ${taskId} via ${laneDriver.name}`);
|
|
167
192
|
const slot = runClaimedAssignment(client, config, laneDriver, workspace, brief, taskId, timeoutMs, supervision)
|
|
@@ -175,7 +200,7 @@ export async function pumpExecutionSlots(client, config, workspace, brief, timeo
|
|
|
175
200
|
return progressed;
|
|
176
201
|
}
|
|
177
202
|
/** Claim one assignment when under capacity; does not start the agent (multi-slot pump does). */
|
|
178
|
-
export async function claimNextAssignment(client, config, workspace, brief) {
|
|
203
|
+
export async function claimNextAssignment(client, config, workspace, brief, driverId) {
|
|
179
204
|
if (Object.keys(config.activeAttempts).length >= config.leaseCapacity)
|
|
180
205
|
return null;
|
|
181
206
|
const data = await client.request("/runner/v1/assignments");
|
|
@@ -192,11 +217,15 @@ export async function claimNextAssignment(client, config, workspace, brief) {
|
|
|
192
217
|
let rejection = null;
|
|
193
218
|
if (assignment.repository_fingerprint && liveRepository !== assignment.repository_fingerprint)
|
|
194
219
|
rejection = "workspace_repository_mismatch";
|
|
195
|
-
else if (assignment.repository_fingerprint && liveBrief?.base_commit !== assignment.claimed_head)
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
220
|
+
else if (assignment.repository_fingerprint && assignment.claimed_head && liveBrief?.base_commit !== assignment.claimed_head) {
|
|
221
|
+
// Rework / advanced base may claim a head that differs from the source checkout HEAD.
|
|
222
|
+
const reachable = await ensureCommitAvailable(workspace, assignment.claimed_head).catch(() => false);
|
|
223
|
+
if (!reachable)
|
|
224
|
+
rejection = "workspace_head_changed";
|
|
225
|
+
}
|
|
226
|
+
if (!rejection && assignment.requested_base_commit && assignment.claimed_head) {
|
|
227
|
+
const startHead = await resolveStartHead(workspace, assignment.requested_base_commit, assignment.claimed_head);
|
|
228
|
+
if (!startHead)
|
|
200
229
|
rejection = "base_not_ancestor";
|
|
201
230
|
}
|
|
202
231
|
if (rejection) {
|
|
@@ -208,9 +237,28 @@ export async function claimNextAssignment(client, config, workspace, brief) {
|
|
|
208
237
|
return null;
|
|
209
238
|
}
|
|
210
239
|
console.log(`Claiming assignment ${assignment.id} (attempt ${assignment.attempt_id})`);
|
|
211
|
-
await client.claim(assignment.id, assignment.attempt_id);
|
|
240
|
+
await client.claim(assignment.id, assignment.attempt_id, driverId ? { driverId } : undefined);
|
|
212
241
|
return assignment.id;
|
|
213
242
|
}
|
|
243
|
+
/**
|
|
244
|
+
* When a sibling merge advances task.base_commit past the machine checkout, the required base is a
|
|
245
|
+
* descendant of claimed_head — not an ancestor. Fetch it and start from the advanced base instead of looping.
|
|
246
|
+
*/
|
|
247
|
+
async function resolveStartHead(workspace, requestedBase, claimedHead) {
|
|
248
|
+
if (await isBaseCommitAncestor(workspace, requestedBase, claimedHead).catch(() => false))
|
|
249
|
+
return claimedHead;
|
|
250
|
+
await ensureCommitAvailable(workspace, requestedBase).catch(() => false);
|
|
251
|
+
if (await isBaseCommitAncestor(workspace, requestedBase, claimedHead).catch(() => false))
|
|
252
|
+
return claimedHead;
|
|
253
|
+
// Workspace is behind the required base (dependent package after merge).
|
|
254
|
+
if (await isBaseCommitAncestor(workspace, claimedHead, requestedBase).catch(() => false))
|
|
255
|
+
return requestedBase;
|
|
256
|
+
if (await ensureCommitAvailable(workspace, requestedBase).catch(() => false)
|
|
257
|
+
&& await isBaseCommitAncestor(workspace, claimedHead, requestedBase).catch(() => false)) {
|
|
258
|
+
return requestedBase;
|
|
259
|
+
}
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
214
262
|
export async function executeNextAssignment(client, config, driver, workspace, brief, timeoutMs, supervision) {
|
|
215
263
|
if (Object.keys(config.activeAttempts).length)
|
|
216
264
|
return recoverActiveAttempt(client, config, driver, workspace, brief, timeoutMs, supervision);
|
|
@@ -226,28 +274,47 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
|
|
|
226
274
|
const task = taskDetailSchema.parse(detail.task);
|
|
227
275
|
const executionContract = executionContractSchema.parse(detail.execution_contract ?? {});
|
|
228
276
|
const workPackage = workPackageSchema.parse(detail.work_package) ?? null;
|
|
229
|
-
const
|
|
277
|
+
const parsedSpec = parseTaskSpec(task.spec_json);
|
|
278
|
+
const deliverable = workPackage?.deliverable ?? parsedSpec.deliverable;
|
|
279
|
+
const spec = { ...parsedSpec, deliverable };
|
|
280
|
+
const artifactDelivery = deliverable === "artifact";
|
|
230
281
|
const grants = z.array(z.string()).parse(task.grants_json ? JSON.parse(task.grants_json) : []);
|
|
231
|
-
const reworkFeedback = task.delivery_state === "changes_requested"
|
|
282
|
+
const reworkFeedback = task.delivery_state === "changes_requested" ? changesRequestedFeedback(task.delivery_summary) : null;
|
|
232
283
|
// Recompile current state at claim time — earlier packages may have moved the repo.
|
|
233
284
|
const liveBrief = await buildWorkspaceBrief(workspace).catch(() => brief);
|
|
234
285
|
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}`);
|
|
286
|
+
if (executionContract.repository_fingerprint && liveRepository !== executionContract.repository_fingerprint) {
|
|
287
|
+
await queueTerminal(client, taskId, { action: "fail", body: { error: "Workspace repository changed after dispatch", retryable: true, idempotency_key: `bridge:workspace-mismatch:${active.attemptId}` } });
|
|
288
|
+
console.error(`Assignment ${taskId} preflight failed: Workspace repository changed after dispatch`);
|
|
243
289
|
return;
|
|
244
290
|
}
|
|
245
|
-
const startCommit =
|
|
291
|
+
const startCommit = (() => {
|
|
292
|
+
const claimed = executionContract.claimed_head ?? liveBrief?.base_commit ?? null;
|
|
293
|
+
return claimed;
|
|
294
|
+
})();
|
|
246
295
|
if (!startCommit) {
|
|
247
296
|
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
297
|
return;
|
|
249
298
|
}
|
|
299
|
+
let worktreeStart = startCommit;
|
|
300
|
+
if (executionContract.requested_base_commit) {
|
|
301
|
+
const resolved = await resolveStartHead(workspace, executionContract.requested_base_commit, startCommit);
|
|
302
|
+
if (!resolved) {
|
|
303
|
+
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}` } });
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
worktreeStart = resolved;
|
|
307
|
+
}
|
|
308
|
+
else if (liveBrief?.base_commit && liveBrief.base_commit !== startCommit) {
|
|
309
|
+
const reachable = await ensureCommitAvailable(workspace, startCommit).catch(() => false);
|
|
310
|
+
if (!reachable) {
|
|
311
|
+
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}` } });
|
|
312
|
+
console.error(`Assignment ${taskId} preflight failed: Delivered head is not available in this workspace`);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
250
316
|
let attemptWorkspace;
|
|
317
|
+
let deliverySubmitted = false;
|
|
251
318
|
if (options.existingWorktree) {
|
|
252
319
|
attemptWorkspace = options.existingWorktree;
|
|
253
320
|
}
|
|
@@ -256,7 +323,7 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
|
|
|
256
323
|
attemptWorkspace = await createAttemptWorktree({
|
|
257
324
|
sourceWorkspace: workspace,
|
|
258
325
|
attemptId: active.attemptId,
|
|
259
|
-
startCommit,
|
|
326
|
+
startCommit: worktreeStart,
|
|
260
327
|
});
|
|
261
328
|
}
|
|
262
329
|
catch (error) {
|
|
@@ -272,7 +339,7 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
|
|
|
272
339
|
}
|
|
273
340
|
// Same list the driver hands to the permission contract, so the prompt states exactly what is
|
|
274
341
|
// executable rather than leaving the agent to guess and hit rejections.
|
|
275
|
-
const prompt = buildAssignmentPrompt({ taskId, objective: task.objective, spec, grants, workspace: attemptWorkspace, currentHead:
|
|
342
|
+
const prompt = buildAssignmentPrompt({ taskId, objective: task.objective, spec, grants, workspace: attemptWorkspace, currentHead: worktreeStart, reworkFeedback, workPackage, verificationCommands: liveBrief?.verification ?? [] });
|
|
276
343
|
const resuming = Boolean(options.forceResumeSessionId);
|
|
277
344
|
await client.attemptRequest(taskId, "progress", {
|
|
278
345
|
phase: "changing",
|
|
@@ -292,7 +359,7 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
|
|
|
292
359
|
const gatewayKey = await client.ensureFuel(task.project_id);
|
|
293
360
|
fuel = { baseUrl: config.baseUrl, gatewayKey };
|
|
294
361
|
}
|
|
295
|
-
const selection = await resolveAssignmentModel(driver, config, spec, taskId, attemptWorkspace);
|
|
362
|
+
const selection = await resolveAssignmentModel(driver, config, spec, taskId, attemptWorkspace, fuel);
|
|
296
363
|
await client.attemptRequest(taskId, "model-selection", {
|
|
297
364
|
risk: selection.risk, tier: selection.tier, model: selection.model ?? null, driver: driver.name,
|
|
298
365
|
...(selection.model ? {} : { note: "cli-default" }), idempotency_key: `model:${active.attemptId}`,
|
|
@@ -399,11 +466,25 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
|
|
|
399
466
|
}
|
|
400
467
|
}
|
|
401
468
|
try {
|
|
469
|
+
// Mechanical land path: agent may forget gh; Bridge pushes and the control plane opens the PR.
|
|
470
|
+
report = await ensureDeliveryPullRequest({
|
|
471
|
+
client,
|
|
472
|
+
taskId,
|
|
473
|
+
attemptId: active.attemptId,
|
|
474
|
+
workspace: attemptWorkspace,
|
|
475
|
+
report,
|
|
476
|
+
spec,
|
|
477
|
+
grants,
|
|
478
|
+
title: task.objective,
|
|
479
|
+
});
|
|
402
480
|
validateDeliveryReport(report, spec, grants);
|
|
403
481
|
}
|
|
404
482
|
catch (error) {
|
|
405
483
|
const message = error instanceof Error ? error.message : "Agent delivery report was invalid";
|
|
406
|
-
|
|
484
|
+
// Missing PR tooling / credential is retryable once the environment is fixed; other contract
|
|
485
|
+
// failures (scope, evidence mapping) stay non-retryable.
|
|
486
|
+
const retryable = /pull_request|merge_request|forge|GitHub|GitLab|gh |push|credential/i.test(message);
|
|
487
|
+
await queueTerminal(client, taskId, { action: "fail", body: { error: message, retryable, idempotency_key: `bridge:invalid-delivery:${active.attemptId}` } });
|
|
407
488
|
console.error(`Assignment ${taskId} could not produce a valid Delivery: ${redactSecrets(message)}`);
|
|
408
489
|
const replyTail = reportText.slice(-8_000);
|
|
409
490
|
console.error(`Assignment ${taskId} agent reply tail (${reportText.length} chars total, redacted): ${redactSecrets(replyTail) || "<empty>"}`);
|
|
@@ -411,20 +492,26 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
|
|
|
411
492
|
}
|
|
412
493
|
await client.updateAttempt(taskId, { phase: "agent_finished", delivery: { spec, report } });
|
|
413
494
|
await submitFinishedDelivery(client, taskId);
|
|
495
|
+
deliverySubmitted = true;
|
|
414
496
|
console.log(`Assignment ${taskId} delivered for review and acceptance.`);
|
|
415
497
|
}
|
|
416
498
|
finally {
|
|
417
499
|
clearInterval(renewTimer);
|
|
418
500
|
if (heartbeatTimer)
|
|
419
501
|
clearInterval(heartbeatTimer);
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
await
|
|
502
|
+
if (artifactDelivery && !deliverySubmitted) {
|
|
503
|
+
console.error(`Artifact delivery was not submitted; retained generated files at ${attemptWorkspace}`);
|
|
504
|
+
}
|
|
505
|
+
else {
|
|
506
|
+
// F-07: dispose the attempt worktree so the next claim cannot inherit edits.
|
|
507
|
+
// Terminal submit may already have cleared activeAttempts — never throw from cleanup.
|
|
508
|
+
const path = config.activeAttempts[taskId]?.worktreePath ?? attemptWorkspace;
|
|
509
|
+
await removeAttemptWorktree(workspace, path).catch((error) => {
|
|
510
|
+
console.error(`Attempt worktree cleanup failed: ${redactSecrets(error instanceof Error ? error.message : "unknown")}`);
|
|
511
|
+
});
|
|
512
|
+
if (config.activeAttempts[taskId]) {
|
|
513
|
+
await client.updateAttempt(taskId, { worktreePath: undefined }).catch(() => undefined);
|
|
514
|
+
}
|
|
428
515
|
}
|
|
429
516
|
}
|
|
430
517
|
}
|
|
@@ -458,36 +545,77 @@ async function submitFinishedDelivery(client, taskId) {
|
|
|
458
545
|
await client.attemptRequest(taskId, "progress", { phase: "preparing_delivery", message: "Agent finished; submitting the Delivery.", idempotency_key: `bridge:progress:${active.attemptId}:delivery` });
|
|
459
546
|
await queueTerminal(client, taskId, terminal);
|
|
460
547
|
}
|
|
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();
|
|
548
|
+
export const modelListCache = new Map();
|
|
467
549
|
const MODEL_LIST_TTL_MS = 6 * 60 * 60 * 1000;
|
|
468
|
-
|
|
550
|
+
/** A failure is remembered only briefly: long enough not to re-pay it per claim, short enough that
|
|
551
|
+
* validation resumes soon after the cause clears. */
|
|
552
|
+
const MODEL_LIST_FAILURE_TTL_MS = 60 * 1000;
|
|
553
|
+
export async function availableModels(driver, workspace, fuel) {
|
|
554
|
+
// Under Conduit fuel the agent's requests go to Conduit's /v1, which resolves an organization alias
|
|
555
|
+
// and rejects a vendor model name outright — so the list that matters is Conduit's, not the CLI's.
|
|
556
|
+
// If it cannot be read, fall back to the CLI's list rather than to no validation at all.
|
|
557
|
+
if (fuel) {
|
|
558
|
+
const aliases = await cachedList(`conduit:${fuel.baseUrl}`, () => conduitAliases(fuel));
|
|
559
|
+
if (aliases)
|
|
560
|
+
return aliases;
|
|
561
|
+
}
|
|
469
562
|
if (!driver.listModels)
|
|
470
563
|
return null;
|
|
471
|
-
|
|
472
|
-
|
|
564
|
+
return cachedList(driver.name, () => driver.listModels(undefined, workspace).catch(() => null));
|
|
565
|
+
}
|
|
566
|
+
export async function cachedList(key, load) {
|
|
567
|
+
const cached = modelListCache.get(key);
|
|
568
|
+
const ttl = cached?.available ? MODEL_LIST_TTL_MS : MODEL_LIST_FAILURE_TTL_MS;
|
|
569
|
+
if (cached && Date.now() - cached.at < ttl)
|
|
473
570
|
return cached.available;
|
|
474
|
-
const available = await
|
|
475
|
-
modelListCache.set(
|
|
571
|
+
const available = await load();
|
|
572
|
+
modelListCache.set(key, { at: Date.now(), available });
|
|
476
573
|
return available;
|
|
477
574
|
}
|
|
478
|
-
|
|
575
|
+
/** The organization's enabled aliases, which is exactly what /v1 will accept as a model. */
|
|
576
|
+
export async function conduitAliases(fuel) {
|
|
577
|
+
try {
|
|
578
|
+
const response = await fetch(`${fuelEndpoint(fuel.baseUrl)}/models`, {
|
|
579
|
+
headers: { authorization: `Bearer ${fuel.gatewayKey}` },
|
|
580
|
+
});
|
|
581
|
+
if (!response.ok)
|
|
582
|
+
return null;
|
|
583
|
+
const body = await response.json();
|
|
584
|
+
const ids = (body.data ?? []).map((row) => row.id).filter((id) => typeof id === "string");
|
|
585
|
+
return ids.length ? new Set(ids) : null;
|
|
586
|
+
}
|
|
587
|
+
catch {
|
|
588
|
+
return null;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
async function resolveAssignmentModel(driver, config, spec, taskId, workspace, fuel) {
|
|
479
592
|
const tier = tierForRisk(spec.risk_level);
|
|
480
593
|
const configured = config.models?.[tier];
|
|
481
594
|
const risk = spec.risk_level === "low" || spec.risk_level === "medium" || spec.risk_level === "high" ? spec.risk_level : "unset";
|
|
595
|
+
let listed;
|
|
596
|
+
const list = async () => (listed === undefined ? (listed = await availableModels(driver, workspace, fuel)) : listed);
|
|
597
|
+
const offered = async () => {
|
|
598
|
+
const available = fuel ? await list() : null;
|
|
599
|
+
return available ? ` Conduit accepts: ${[...available].join(", ")}.` : "";
|
|
600
|
+
};
|
|
482
601
|
if (!configured || (Array.isArray(configured) && configured.length === 0)) {
|
|
483
|
-
|
|
602
|
+
// Not fatal on purpose. Conduit's /v1 rejects a vendor model name — proven by `npm run
|
|
603
|
+
// probe:fuel` — so under Conduit fuel the CLI's own default cannot resolve. Whether every driver
|
|
604
|
+
// honours the injected base URL rather than its stored login is not proven per driver, so this
|
|
605
|
+
// says exactly what is wrong and lets the run show it rather than refusing on a half-proven chain.
|
|
606
|
+
const unmapped = `Assignment ${taskId} intelligence tier ${tier} (risk ${risk}); no model mapping — CLI default`;
|
|
607
|
+
if (fuel)
|
|
608
|
+
console.error(`${unmapped}, which Conduit resolves as an organization alias and rejects otherwise.${await offered()}`);
|
|
609
|
+
else
|
|
610
|
+
console.log(unmapped);
|
|
484
611
|
return { risk, tier };
|
|
485
612
|
}
|
|
486
613
|
const candidates = Array.isArray(configured) ? configured : [configured];
|
|
487
|
-
const { model, skipped } = pickModelCandidate(candidates, await
|
|
614
|
+
const { model, skipped } = pickModelCandidate(candidates, await list());
|
|
488
615
|
const skippedNote = skipped.length ? ` (skipped unavailable/unsafe: ${skipped.join(", ")})` : "";
|
|
489
616
|
if (!model) {
|
|
490
|
-
|
|
617
|
+
const none = `Assignment ${taskId} intelligence tier ${tier} (risk ${risk}): no configured candidate available — CLI default${skippedNote}`;
|
|
618
|
+
console.error(fuel ? `${none}. No candidate is a Conduit model alias.${await offered()}` : none);
|
|
491
619
|
return { risk, tier };
|
|
492
620
|
}
|
|
493
621
|
console.log(`Assignment ${taskId} intelligence tier ${tier} (risk ${risk}) → model ${model}${skippedNote}`);
|
|
@@ -542,14 +670,40 @@ async function prepareDelivery(client, attemptId, taskId, report) {
|
|
|
542
670
|
}
|
|
543
671
|
export function validateDeliveryReport(report, spec, grants = []) {
|
|
544
672
|
validateChangeScope(report, spec.change_scope ?? []);
|
|
673
|
+
if (spec.deliverable === "artifact") {
|
|
674
|
+
const published = report.evidence.some((item) => {
|
|
675
|
+
if (!["preview", "research", "documentation"].includes(item.kind))
|
|
676
|
+
return false;
|
|
677
|
+
if (!item.uri || !item.digest || !/^sha256:[0-9a-f]{64}$/i.test(item.digest))
|
|
678
|
+
return false;
|
|
679
|
+
try {
|
|
680
|
+
const protocol = new URL(item.uri).protocol;
|
|
681
|
+
return protocol === "https:" || protocol === "http:";
|
|
682
|
+
}
|
|
683
|
+
catch {
|
|
684
|
+
return false;
|
|
685
|
+
}
|
|
686
|
+
});
|
|
687
|
+
if (!published) {
|
|
688
|
+
throw new Error("Artifact delivery requires a published HTTP(S) URL and sha256 digest");
|
|
689
|
+
}
|
|
690
|
+
}
|
|
545
691
|
// Invariant 9: grants bound what an attempt may do. Cursor/plan-mode are soft hints the headless
|
|
546
692
|
// agent can leave, so repository-write authority is enforced here on the reported delivery: a task
|
|
547
693
|
// without repo_write must not report repository changes.
|
|
548
694
|
if (!grants.includes("repo_write") && (report.changes.length > 0 || report.evidence.some((item) => item.kind === "change"))) {
|
|
549
695
|
throw new Error("Agent reported repository changes without the repo_write grant");
|
|
550
696
|
}
|
|
697
|
+
// Merge-on-accept lands GitHub PRs. Local attempt commits without a PR never reach main.
|
|
698
|
+
if (grants.includes("pr_create")
|
|
699
|
+
&& (spec.change_scope?.length ?? 0) > 0
|
|
700
|
+
&& report.head_commit
|
|
701
|
+
&& !report.pull_request_url) {
|
|
702
|
+
throw new Error("Repository changes require a pull_request_url when pr_create is granted");
|
|
703
|
+
}
|
|
704
|
+
const requiredEvidence = spec.required_evidence ?? [];
|
|
551
705
|
const supplied = new Set(report.evidence.map((item) => item.kind));
|
|
552
|
-
const missing =
|
|
706
|
+
const missing = requiredEvidence.filter((kind) => !supplied.has(kind));
|
|
553
707
|
if (missing.length)
|
|
554
708
|
throw new Error(`Agent report is missing required evidence: ${missing.join(", ")}`);
|
|
555
709
|
const unsupported = report.acceptance_results
|
|
@@ -557,7 +711,17 @@ export function validateDeliveryReport(report, spec, grants = []) {
|
|
|
557
711
|
.map((result) => result.criterion);
|
|
558
712
|
if (unsupported.length)
|
|
559
713
|
throw new Error(`Met acceptance criteria require mapped evidence: ${unsupported.join("; ")}`);
|
|
714
|
+
// Plan-time normalizeRequiredEvidence puts "test" on verify packages; reject summary-only details.
|
|
715
|
+
if (requiredEvidence.includes("test")) {
|
|
716
|
+
const thin = report.evidence
|
|
717
|
+
.filter((item) => item.kind === "test")
|
|
718
|
+
.filter((item) => item.details.join("\n").trim().length < TEST_EVIDENCE_DETAILS_MIN);
|
|
719
|
+
if (thin.length) {
|
|
720
|
+
throw new Error("test evidence must include verbatim command output in details (not a summary)");
|
|
721
|
+
}
|
|
722
|
+
}
|
|
560
723
|
}
|
|
724
|
+
const TEST_EVIDENCE_DETAILS_MIN = 32;
|
|
561
725
|
function referenceKind(kind) {
|
|
562
726
|
if (kind === "change")
|
|
563
727
|
return "commit";
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@miraland-labs/conduit-bridge",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Conduit Bridge CLI — join, connect, multi-driver lanes, and run Claude Code / Codex / Cursor / OpenCode / Kiro / Antigravity agents for a Conduit organization",
|
|
3
|
+
"version": "0.9.1",
|
|
4
|
+
"description": "Conduit Bridge CLI — join, connect, disconnect, multi-driver lanes, and run Claude Code / Codex / Cursor / OpenCode / Kiro / Antigravity agents for a Conduit organization",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"conduit": "dist/cli.js"
|