@miraland-labs/conduit-bridge 0.16.42 → 0.16.100
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 +1 -1
- package/dist/attempt-watchdog.js +88 -0
- package/dist/brief.js +28 -4
- package/dist/cli.js +88 -13
- package/dist/client.js +39 -2
- package/dist/config.js +57 -3
- package/dist/driver.js +416 -82
- package/dist/drivers.js +116 -15
- package/dist/ensure-pull-request.js +92 -11
- package/dist/ensure-test-evidence.js +199 -31
- package/dist/execution-class.js +20 -4
- package/dist/execution-facts.js +52 -0
- package/dist/execution.js +968 -101
- package/dist/failure-signal.js +295 -9
- package/dist/git-witness.js +88 -4
- package/dist/investigation.js +137 -14
- package/dist/managed-workspace-path.js +9 -0
- package/dist/on-shift-apply.js +22 -0
- package/dist/ops.js +36 -5
- package/dist/preflight.js +74 -39
- package/dist/quota-reset.js +69 -4
- package/dist/service.js +7 -0
- package/dist/version.js +13 -2
- package/dist/workspace-refresh.js +110 -0
- package/package.json +1 -1
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
|
-
**Package version:** `0.16.
|
|
5
|
+
**Package version:** `0.16.95` — Conduit-fuel lanes bind organization aliases (T144), the Anthropic base URL is the origin (T148), the investigation lane resolves its model (T154); a hosted container image lives under `hosted/` (docs/HOSTED_RUNNER.md). Earlier (0.16.27) — work-package execution budgets reach every agent turn, are bounded by the machine ceiling, and expose their effective source for operations. Local-fuel provider refusals make the affected lane unavailable instead of repeatedly spending attempts; an operator who changes the lane's account or plan can invalidate that observation with `ops quota-clear <driver>`. Bridge discovers bounded repository verification commands, including Make targets `test`, `check`, `verify`, `replay`, `typecheck`, `lint`, and `build`. `ops disconnect` asks before it removes the runner service and clears credentials; pass `--yes` to skip the question. `ops.env` is enrollment/bootstrap defaults only: an explicit workspace does not inherit an old repository, and project switches do not rewrite global project state. `ops enroll` exchanges a single-use token, registers the machine, binds project affinity, provisions fuel keys, brings detected drivers online, and starts the runner in one command. On Linux, `ops install` refuses any effective systemd drop-in. A switch intent remains queued until the target runner heartbeat proves Bound. After every Bridge publish, operators must re-run `ops install` (LaunchAgent pins an absolute `cli.js`).
|
|
6
6
|
|
|
7
7
|
## Prerequisites
|
|
8
8
|
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* T90: an attempt that stops answering must end, not hold its lease for ever.
|
|
3
|
+
*
|
|
4
|
+
* Field, 2026-09-07: a cursor attempt was claimed at 15:24 and was still `running` at 18:30 with
|
|
5
|
+
* the lease renewed on every heartbeat, while the runner had no child process and the journal had
|
|
6
|
+
* printed nothing since the model line. One awaited step never returned, and the Bridge had no
|
|
7
|
+
* clock of its own above the agent turn: the attempt could only end when that step answered.
|
|
8
|
+
*
|
|
9
|
+
* The watchdog is that clock. It carries the phase the attempt is in, prints each transition to
|
|
10
|
+
* the journal, and rejects `stalled` when the phase has not moved for its own budget. The phase
|
|
11
|
+
* name travels with the failure, so the next reader knows which step never returned.
|
|
12
|
+
*/
|
|
13
|
+
/** How long a phase may stay silent after the step that can answer has finished. */
|
|
14
|
+
export const ATTEMPT_STALL_GRACE_MS = 15 * 60_000;
|
|
15
|
+
/**
|
|
16
|
+
* Phases that may run an agent turn or the project's own gates, so their budget is the turn budget
|
|
17
|
+
* plus the grace. Every other phase is bookkeeping the machine does alone: the grace is enough, and
|
|
18
|
+
* a shorter clock reports a stall sooner.
|
|
19
|
+
*/
|
|
20
|
+
const AGENT_TURN_PHASES = new Set([
|
|
21
|
+
"claim",
|
|
22
|
+
"agent",
|
|
23
|
+
"verification",
|
|
24
|
+
"report_repair",
|
|
25
|
+
"land",
|
|
26
|
+
]);
|
|
27
|
+
/** The end of an attempt that stopped answering, naming the step that never returned. */
|
|
28
|
+
export class AttemptStalledError extends Error {
|
|
29
|
+
phase;
|
|
30
|
+
waitedMs;
|
|
31
|
+
constructor(phase, waitedMs) {
|
|
32
|
+
super(`bridge_attempt_stalled: phase=${phase}; the step gave no answer for ${Math.round(waitedMs / 60_000)} minutes`);
|
|
33
|
+
this.phase = phase;
|
|
34
|
+
this.waitedMs = waitedMs;
|
|
35
|
+
this.name = "AttemptStalledError";
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** Bound one awaited step. The rejection names the phase, as a stall report does. */
|
|
39
|
+
export function withTimeout(promise, ms, phase) {
|
|
40
|
+
return new Promise((resolve, reject) => {
|
|
41
|
+
const timer = setTimeout(() => reject(new AttemptStalledError(phase, ms)), ms);
|
|
42
|
+
timer.unref?.();
|
|
43
|
+
promise.then((value) => { clearTimeout(timer); resolve(value); }, (error) => { clearTimeout(timer); reject(error); });
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
export function createAttemptWatchdog(input) {
|
|
47
|
+
let fail = () => undefined;
|
|
48
|
+
const stalled = new Promise((_resolve, reject) => { fail = reject; });
|
|
49
|
+
// The attempt usually wins the race and nobody ever reads this rejection.
|
|
50
|
+
stalled.catch(() => undefined);
|
|
51
|
+
const journal = input.journal ?? ((line) => console.log(line));
|
|
52
|
+
let turnBudgetMs = input.turnBudgetMs;
|
|
53
|
+
let current = "claim";
|
|
54
|
+
let timer = null;
|
|
55
|
+
let stopped = false;
|
|
56
|
+
const arm = (ms) => {
|
|
57
|
+
if (timer)
|
|
58
|
+
clearTimeout(timer);
|
|
59
|
+
if (stopped)
|
|
60
|
+
return;
|
|
61
|
+
timer = setTimeout(() => fail(new AttemptStalledError(current, ms)), ms);
|
|
62
|
+
timer.unref?.();
|
|
63
|
+
};
|
|
64
|
+
const budgetFor = (name) => AGENT_TURN_PHASES.has(name) ? turnBudgetMs + ATTEMPT_STALL_GRACE_MS : ATTEMPT_STALL_GRACE_MS;
|
|
65
|
+
arm(budgetFor(current));
|
|
66
|
+
return {
|
|
67
|
+
stalled,
|
|
68
|
+
phase(name) {
|
|
69
|
+
if (stopped)
|
|
70
|
+
return;
|
|
71
|
+
current = name;
|
|
72
|
+
journal(`Assignment ${input.taskId} phase: ${name}`);
|
|
73
|
+
arm(budgetFor(name));
|
|
74
|
+
},
|
|
75
|
+
budget(next) {
|
|
76
|
+
if (stopped || !(next > 0))
|
|
77
|
+
return;
|
|
78
|
+
turnBudgetMs = next;
|
|
79
|
+
arm(budgetFor(current));
|
|
80
|
+
},
|
|
81
|
+
stop() {
|
|
82
|
+
stopped = true;
|
|
83
|
+
if (timer)
|
|
84
|
+
clearTimeout(timer);
|
|
85
|
+
timer = null;
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
package/dist/brief.js
CHANGED
|
@@ -12,7 +12,7 @@ const MANIFESTS = [
|
|
|
12
12
|
];
|
|
13
13
|
/** Prefer test before typecheck/lint so discovery order matches what pickVerificationCommand wants. */
|
|
14
14
|
const VERIFICATION_SCRIPTS = ["test", "verify", "typecheck", "lint", "build"];
|
|
15
|
-
const MAKE_VERIFICATION_TARGETS = ["test", "check", "verify", "replay", "typecheck", "lint", "build"];
|
|
15
|
+
export const MAKE_VERIFICATION_TARGETS = ["test", "check", "verify", "replay", "typecheck", "lint", "build"];
|
|
16
16
|
const EXCLUDED_DIRECTORIES = new Set(["node_modules", "dist", "build", "target", "coverage", ".venv", "venv"]);
|
|
17
17
|
export async function buildWorkspaceBrief(workspace) {
|
|
18
18
|
const entries = await readdir(workspace, { withFileTypes: true });
|
|
@@ -59,7 +59,8 @@ export async function isBaseCommitAncestor(workspace, baseCommit, headCommit) {
|
|
|
59
59
|
throw error;
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
|
-
|
|
62
|
+
/** Refresh every origin ref; deepens a shallow clone enough to see a commit a tip fetch missed. */
|
|
63
|
+
export async function fetchOrigin(workspace) {
|
|
63
64
|
await execFileAsync("git", ["-C", workspace, "fetch", "--no-tags", "origin"], {
|
|
64
65
|
timeout: 120_000,
|
|
65
66
|
windowsHide: true,
|
|
@@ -168,6 +169,8 @@ export async function resolveAttemptStartCommit(workspace, requestedBase, claime
|
|
|
168
169
|
* recipe body was always repository-authored.
|
|
169
170
|
*/
|
|
170
171
|
const MAX_DECLARED_VERIFICATION = 8;
|
|
172
|
+
/** Keep in sync with `workspaceBriefSchema.verification` in `src/runner/routes.ts`. */
|
|
173
|
+
const MAX_VERIFICATION_COMMANDS = 10;
|
|
171
174
|
export function parseDeclaredVerification(text) {
|
|
172
175
|
const declared = [];
|
|
173
176
|
for (const raw of text.split("\n")) {
|
|
@@ -182,12 +185,30 @@ export function parseDeclaredVerification(text) {
|
|
|
182
185
|
return declared;
|
|
183
186
|
}
|
|
184
187
|
export async function readDeclaredVerification(workspace) {
|
|
188
|
+
let text;
|
|
185
189
|
try {
|
|
186
|
-
|
|
190
|
+
text = await readFile(join(workspace, ".conduit", "verification"), "utf8");
|
|
187
191
|
}
|
|
188
192
|
catch {
|
|
189
193
|
return []; // no declaration, or unreadable: discovery still applies
|
|
190
194
|
}
|
|
195
|
+
const declared = parseDeclaredVerification(text);
|
|
196
|
+
// A line the shape check refuses, or a line after the eighth, was dropped with no reason given:
|
|
197
|
+
// the gate was then absent from every list and the operator had nothing to read. One line per
|
|
198
|
+
// dropped declaration says which line went and why it can go.
|
|
199
|
+
for (const raw of text.split("\n")) {
|
|
200
|
+
const line = raw.split("#")[0].trim();
|
|
201
|
+
if (!line || declared.includes(line))
|
|
202
|
+
continue;
|
|
203
|
+
console.warn(JSON.stringify({
|
|
204
|
+
event: "declared_verification_dropped",
|
|
205
|
+
line: line.slice(0, 200),
|
|
206
|
+
reason: isBoundedVerificationCommand(line)
|
|
207
|
+
? `more than ${MAX_DECLARED_VERIFICATION} declared commands`
|
|
208
|
+
: "not a bounded verification command",
|
|
209
|
+
}));
|
|
210
|
+
}
|
|
211
|
+
return declared;
|
|
191
212
|
}
|
|
192
213
|
export async function discoverVerificationCommands(workspace, files) {
|
|
193
214
|
// Declared first: the order is a preference order for every reader that takes one command.
|
|
@@ -240,5 +261,8 @@ export async function discoverVerificationCommands(workspace, files) {
|
|
|
240
261
|
if (files.has("gradlew"))
|
|
241
262
|
commands.push("./gradlew test");
|
|
242
263
|
}
|
|
243
|
-
|
|
264
|
+
// The heartbeat schema caps `verification` at 10. A gate-rich checkout discovers more than that,
|
|
265
|
+
// and the whole heartbeat was then refused: the computer went dark with no reason on any card.
|
|
266
|
+
// Declared commands come first, so the cap removes only the least preferred discovered names.
|
|
267
|
+
return [...new Set(commands)].slice(0, MAX_VERIFICATION_COMMANDS);
|
|
244
268
|
}
|
package/dist/cli.js
CHANGED
|
@@ -7,12 +7,12 @@ import { createInterface } from "node:readline/promises";
|
|
|
7
7
|
import { chmodSync, cpSync, existsSync, mkdirSync, readdirSync } from "node:fs";
|
|
8
8
|
import { stdin as input, stdout as output } from "node:process";
|
|
9
9
|
import { fileURLToPath } from "node:url";
|
|
10
|
-
import { ConduitClient, ConduitRequestError } from "./client.js";
|
|
10
|
+
import { assignmentPollBehindServer, ConduitClient, ConduitRequestError } from "./client.js";
|
|
11
11
|
import { BRIDGE_LEASE_CAPACITY, BRIDGE_MAX_LEASE_CAPACITY, clampLeaseCapacity, clearLocalConnection, clearPendingConnection, loadConfig, loadConfigIfPresent, loadOrCreateInstallationId, loadPendingConnection, redactSecrets, saveConfig, saveConfigPrefs, saveDriverQuota, savePendingConnection, suggestMachineName, } from "./config.js";
|
|
12
12
|
import { runMcp } from "./mcp.js";
|
|
13
13
|
import { detectInstalledClients, localFuelOnlyClients, suggestFuelSource } from "./detect.js";
|
|
14
|
-
import { DRIVERS } from "./driver.js";
|
|
15
|
-
import { driversHeartbeatReport, driverLabel, heartbeatStatusForDrivers, isSupportedDriverId, listDriverLanes, onlineDriverIds, seedDriversFromDetection, seedDriverLanes, setDriverFuel, setDriversOnline, laneDispatchBlock, laneStatuses, } from "./drivers.js";
|
|
14
|
+
import { DRIVERS, MODEL_TIERS } from "./driver.js";
|
|
15
|
+
import { driversHeartbeatReport, driverLabel, heartbeatStatusForDrivers, isSupportedDriverId, listDriverLanes, onlineDriverIds, seedDriversFromDetection, seedDriverLanes, setDriverFuel, setDriverModels, setDriversOnline, formatLaneTiers, laneDispatchBlock, laneStatuses, } from "./drivers.js";
|
|
16
16
|
import { buildWorkspaceBrief } from "./brief.js";
|
|
17
17
|
import { parseAgentTimeoutMinutes } from "./execution-budget.js";
|
|
18
18
|
import { BOOTSTRAP_RESULTS, buildSovereignExecutionFacts } from "./execution-facts.js";
|
|
@@ -21,7 +21,8 @@ import { buildOnShiftIntentProof, maybeApplyOnShiftIntent } from "./on-shift-app
|
|
|
21
21
|
import { loadOpsEnv, OPS_VERBS, runOps } from "./ops.js";
|
|
22
22
|
import { pumpExecutionSlots, renewLeases } from "./execution.js";
|
|
23
23
|
import { applyRunnerToolPath, installRunnerService, uninstallRunnerService } from "./service.js";
|
|
24
|
-
import { BRIDGE_PROTOCOL_VERSION, cachedBridgePreflight, unavailableWorkspacePreflight } from "./preflight.js";
|
|
24
|
+
import { BRIDGE_PROTOCOL_VERSION, cachedBridgePreflight, PREFLIGHT_CACHE_TTL_MS, unavailableWorkspacePreflight } from "./preflight.js";
|
|
25
|
+
import { refreshSourceWorkspace } from "./workspace-refresh.js";
|
|
25
26
|
import { executeNextInvestigation } from "./investigation.js";
|
|
26
27
|
import { bridgeVersion } from "./version.js";
|
|
27
28
|
const [command] = process.argv.slice(2);
|
|
@@ -45,6 +46,16 @@ function parseBootstrapResult(value) {
|
|
|
45
46
|
throw new Error(`Bootstrap result must be one of: ${BOOTSTRAP_RESULTS.join(", ")}`);
|
|
46
47
|
return result;
|
|
47
48
|
}
|
|
49
|
+
/** Managed authority comes from this Bridge installation's ops.env, never from the control plane. */
|
|
50
|
+
function configuredManagedRoot() {
|
|
51
|
+
try {
|
|
52
|
+
const configured = loadOpsEnv().CONDUIT_MANAGED_ROOT.trim();
|
|
53
|
+
return configured ? resolve(configured) : undefined;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
48
59
|
async function connect() {
|
|
49
60
|
const { values } = parseArgs({ args: process.argv.slice(3), options: { url: { type: "string" }, code: { type: "string" }, fuel: { type: "string" } } });
|
|
50
61
|
if (!values.url || !values.code)
|
|
@@ -277,7 +288,7 @@ async function finishConnection(baseUrl, data, fuelSource) {
|
|
|
277
288
|
const client = new ConduitClient(config);
|
|
278
289
|
let heartbeatOk = true;
|
|
279
290
|
try {
|
|
280
|
-
await heartbeat(client, config, null, null, unavailableWorkspacePreflight({ config }));
|
|
291
|
+
await heartbeat(client, config, null, null, unavailableWorkspacePreflight({ config, managedRoot: configuredManagedRoot() }));
|
|
281
292
|
}
|
|
282
293
|
catch {
|
|
283
294
|
heartbeatOk = false;
|
|
@@ -407,7 +418,7 @@ async function initOps() {
|
|
|
407
418
|
async function opsCommand() {
|
|
408
419
|
const verb = process.argv[3];
|
|
409
420
|
if (!verb || !OPS_VERBS.includes(verb)) {
|
|
410
|
-
throw new Error(`Usage: ${bridgeUsage("ops", "<connect|install|switch|online|offline|quota-clear|status|doctor|disconnect|uninstall>", "[driver…]")} (install also takes --workspace <path> [--repo <url>])`);
|
|
421
|
+
throw new Error(`Usage: ${bridgeUsage("ops", "<connect|install|switch|online|offline|quota-clear|status|workspace|doctor|disconnect|uninstall>", "[driver…]")} (install also takes --workspace <path> [--repo <url>])`);
|
|
411
422
|
}
|
|
412
423
|
await runOps(verb, process.argv.slice(4));
|
|
413
424
|
}
|
|
@@ -444,12 +455,16 @@ async function driversCommand() {
|
|
|
444
455
|
// Printing the count is the only way an operator sees why dispatch keeps avoiding this lane.
|
|
445
456
|
const slow = lane.timeouts ? ` timeouts=${lane.timeouts} (demoted)` : "";
|
|
446
457
|
console.log(` ${lane.id.padEnd(14)} ${lane.state.padEnd(8)} fuel=${lane.fuel} allowance=${lane.allowance}${when}${reset}${slow} (${lane.label})`);
|
|
458
|
+
// An unbound tier runs the CLI default and Holds on the first vendor abort, so the map is
|
|
459
|
+
// printed here — the operator learned a typo such as `fast` only from that Hold before.
|
|
460
|
+
console.log(` tiers ${formatLaneTiers(lane.tiers)}`);
|
|
447
461
|
}
|
|
448
462
|
const blocked = laneDispatchBlock(laneStatuses(config));
|
|
449
463
|
console.log(blocked
|
|
450
464
|
? `Dispatch: BLOCKED — ${blocked}.`
|
|
451
465
|
: `Dispatch: ready — eligible ${laneStatuses(config).filter((lane) => lane.eligible).map((lane) => lane.id).join(", ")}.`);
|
|
452
466
|
const online = onlineDriverIds(config);
|
|
467
|
+
console.log(`Bind a tier: ${bridgeUsage("drivers", "models", "<driver-id>", MODEL_TIERS.join("|"), "<model…>")}`);
|
|
453
468
|
console.log(online.length
|
|
454
469
|
? `Toggle a lane: ${bridgeUsage("drivers", "online|offline", "<id…>")}`
|
|
455
470
|
: `All offline — no new claims. Bring one online: ${bridgeUsage("drivers", "online", AGENT_PLACEHOLDER)}`);
|
|
@@ -500,7 +515,20 @@ async function driversCommand() {
|
|
|
500
515
|
console.log(`${driverLabel(id)} lane fuel set to ${mode}`);
|
|
501
516
|
return;
|
|
502
517
|
}
|
|
503
|
-
|
|
518
|
+
if (sub === "models") {
|
|
519
|
+
const id = process.argv[4]?.trim();
|
|
520
|
+
const tier = process.argv[5]?.trim();
|
|
521
|
+
const names = process.argv.slice(6).map((name) => name.trim()).filter(Boolean);
|
|
522
|
+
if (!id || !tier || !MODEL_TIERS.includes(tier) || !names.length) {
|
|
523
|
+
throw new Error(`Usage: ${bridgeUsage("drivers", "models", "<driver-id>", MODEL_TIERS.join("|"), "<model…>")}`);
|
|
524
|
+
}
|
|
525
|
+
config = seedDriverLanes(config, [id]).config;
|
|
526
|
+
config = setDriverModels(config, id, tier, names);
|
|
527
|
+
await saveConfigPrefs(config);
|
|
528
|
+
console.log(`${driverLabel(id)} lane ${tier} tier: ${names.join(", ")}`);
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
throw new Error(`Usage: ${bridgeUsage("drivers", "[list|online|offline|fuel|models|quota]", "…")}`);
|
|
504
532
|
}
|
|
505
533
|
async function runner() {
|
|
506
534
|
applyRunnerToolPath();
|
|
@@ -525,6 +553,7 @@ async function runner() {
|
|
|
525
553
|
throw new Error("--ensure-checkout requires --workspace <repository-path>");
|
|
526
554
|
}
|
|
527
555
|
const workspace = values.workspace ? resolve(values.workspace) : null;
|
|
556
|
+
const managedRoot = configuredManagedRoot();
|
|
528
557
|
if (workspace && values["ensure-checkout"]) {
|
|
529
558
|
const result = await ensureCheckout(workspace, values["ensure-checkout"]);
|
|
530
559
|
console.log(result === "cloned"
|
|
@@ -562,10 +591,13 @@ async function runner() {
|
|
|
562
591
|
config.drivers = latest.drivers;
|
|
563
592
|
config.fuelSource = latest.fuelSource;
|
|
564
593
|
config.leaseCapacity = latest.leaseCapacity;
|
|
594
|
+
// Refresh before discovery: the brief and the readiness report must describe origin's code,
|
|
595
|
+
// not whatever this checkout was left on. Cadence is the readiness report's own, not the beat.
|
|
596
|
+
const refresh = workspace ? await reportedRefresh(workspace) : null;
|
|
565
597
|
const currentBrief = workspace ? await buildWorkspaceBrief(workspace).catch(() => brief) : null;
|
|
566
598
|
const preflight = workspace
|
|
567
|
-
? await cachedBridgePreflight({ config, workspace, brief: currentBrief ?? undefined })
|
|
568
|
-
: unavailableWorkspacePreflight({ config });
|
|
599
|
+
? await cachedBridgePreflight({ config, workspace, managedRoot, brief: currentBrief ?? undefined, refresh })
|
|
600
|
+
: unavailableWorkspacePreflight({ config, managedRoot });
|
|
569
601
|
const hb = await heartbeat(client, config, workspace, currentBrief, preflight, undefined, bootstrapResult);
|
|
570
602
|
if (hb.staleBridge) {
|
|
571
603
|
console.error(`Bridge update required: running protocol ${BRIDGE_PROTOCOL_VERSION}; control plane requires protocol ${hb.requiredProtocol ?? "unknown"} (minimum Bridge ${hb.minimumVersion ?? "unknown"}). Reinstall the latest Bridge, then restart this runner.`);
|
|
@@ -609,13 +641,16 @@ async function runner() {
|
|
|
609
641
|
if (workspace && onlineDriverIds(config).length) {
|
|
610
642
|
// Observation runs before authoring: an owner waiting on an answer should not queue behind
|
|
611
643
|
// a long delivery, and the control plane already counted this slot as busy.
|
|
612
|
-
progressed = await executeNextInvestigation(client, config, workspace, brief, timeoutMs
|
|
644
|
+
progressed = await executeNextInvestigation(client, config, workspace, brief, timeoutMs, {
|
|
645
|
+
managedRoot: hb.on_shift?.managed_workspace_root ?? null,
|
|
646
|
+
}) || progressed;
|
|
613
647
|
}
|
|
614
648
|
if (workspace && onlineDriverIds(config).length) {
|
|
615
649
|
// Read the workspace again, then publish it. The last brief stands if the read fails.
|
|
616
650
|
const publishWorkspaceState = async (options = {}) => {
|
|
651
|
+
const refresh = await reportedRefresh(workspace, options);
|
|
617
652
|
const currentBrief = await buildWorkspaceBrief(workspace).catch(() => brief);
|
|
618
|
-
const preflight = await cachedBridgePreflight({ config, workspace, brief: currentBrief ?? undefined }, options);
|
|
653
|
+
const preflight = await cachedBridgePreflight({ config, workspace, managedRoot, brief: currentBrief ?? undefined, refresh }, options);
|
|
619
654
|
const hb = await heartbeat(client, config, workspace, currentBrief, preflight, undefined, bootstrapResult);
|
|
620
655
|
return { preflight, hb };
|
|
621
656
|
};
|
|
@@ -634,7 +669,9 @@ async function runner() {
|
|
|
634
669
|
return preflight.ready && lane?.ready === true && typeof lane.version === "string" && lane.version.length > 0;
|
|
635
670
|
},
|
|
636
671
|
heartbeatIntervalMs: intervalMs,
|
|
637
|
-
|
|
672
|
+
// T86: a computer on shift for several projects moves to the assignment's repository
|
|
673
|
+
// under this root when it claims.
|
|
674
|
+
}, running, { managedRoot: hb.on_shift?.managed_workspace_root ?? null });
|
|
638
675
|
}
|
|
639
676
|
}
|
|
640
677
|
catch (error) {
|
|
@@ -648,6 +685,34 @@ async function runner() {
|
|
|
648
685
|
await new Promise((resolveSleep) => setTimeout(resolveSleep, intervalMs));
|
|
649
686
|
}
|
|
650
687
|
}
|
|
688
|
+
/** Last state announced, so a workspace that stays diverged does not print on every cycle. */
|
|
689
|
+
let lastRefreshState = null;
|
|
690
|
+
/** Last refresh and when it ran, so the fetch keeps the readiness report's cadence. */
|
|
691
|
+
let lastRefresh = null;
|
|
692
|
+
/**
|
|
693
|
+
* Fast-forward the source workspace and announce only what changed.
|
|
694
|
+
*
|
|
695
|
+
* The fetch runs on the readiness report's cadence, not the heartbeat's: forced before every claim,
|
|
696
|
+
* and at most once every five minutes otherwise. A fetch on every fifteen-second beat is traffic
|
|
697
|
+
* nothing can use, because the readiness report it feeds only recomputes on that same cadence.
|
|
698
|
+
*/
|
|
699
|
+
async function reportedRefresh(workspace, options = {}) {
|
|
700
|
+
if (!options.force && lastRefresh && Date.now() - lastRefresh.at < PREFLIGHT_CACHE_TTL_MS)
|
|
701
|
+
return lastRefresh.result;
|
|
702
|
+
const refresh = await refreshSourceWorkspace(workspace).catch(() => null);
|
|
703
|
+
lastRefresh = { at: Date.now(), result: refresh };
|
|
704
|
+
const state = refresh ? `${refresh.state}:${refresh.local_commits}` : null;
|
|
705
|
+
if (state !== lastRefreshState) {
|
|
706
|
+
lastRefreshState = state;
|
|
707
|
+
if (refresh?.state === "fast_forwarded") {
|
|
708
|
+
console.log(`Workspace fast-forwarded to origin/${refresh.branch}.`);
|
|
709
|
+
}
|
|
710
|
+
else if (refresh?.state === "diverged") {
|
|
711
|
+
console.error(`Workspace cannot fast-forward: ${refresh.local_commits} local-only commit(s) on ${refresh.branch}. Park or push them; this computer takes no work until it can follow origin.`);
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
return refresh;
|
|
715
|
+
}
|
|
651
716
|
async function heartbeat(client, config, workspacePath, brief, preflight, intentProof, bootstrapResult) {
|
|
652
717
|
const online = onlineDriverIds(config);
|
|
653
718
|
const status = heartbeatStatusForDrivers(online);
|
|
@@ -660,6 +725,16 @@ async function heartbeat(client, config, workspacePath, brief, preflight, intent
|
|
|
660
725
|
bridgeProtocol: BRIDGE_PROTOCOL_VERSION,
|
|
661
726
|
bootstrapResult,
|
|
662
727
|
});
|
|
728
|
+
// T98: an assignments poll that has failed schema validation flags this runner as behind the
|
|
729
|
+
// server, so the control plane can tell the operator to restart it instead of leaving the runner
|
|
730
|
+
// to fail every poll silently. Merged in here rather than into runBridgePreflight itself: the
|
|
731
|
+
// preflight probe is cached for minutes at a time, and this fact must reach the very next
|
|
732
|
+
// heartbeat.
|
|
733
|
+
const preflightToSend = {
|
|
734
|
+
...preflight,
|
|
735
|
+
...(assignmentPollBehindServer() ? { runner_behind_server: true } : {}),
|
|
736
|
+
...(process.env.CONDUIT_RUNNER_SERVICE === "container" ? { runner_service: "container" } : {}),
|
|
737
|
+
};
|
|
663
738
|
const response = await client.request("/runner/v1/heartbeat", { method: "POST", body: JSON.stringify({
|
|
664
739
|
status,
|
|
665
740
|
capabilities: config.capabilities,
|
|
@@ -669,7 +744,7 @@ async function heartbeat(client, config, workspacePath, brief, preflight, intent
|
|
|
669
744
|
bridge_version: version,
|
|
670
745
|
bridge_protocol: BRIDGE_PROTOCOL_VERSION,
|
|
671
746
|
drivers: driversHeartbeatReport(config, config.activeAttempts),
|
|
672
|
-
preflight,
|
|
747
|
+
preflight: preflightToSend,
|
|
673
748
|
execution_facts: executionFacts,
|
|
674
749
|
...(intentProof ? { on_shift_intent_proof: intentProof } : {}),
|
|
675
750
|
...(brief ? { workspace_brief: brief } : {}),
|
package/dist/client.js
CHANGED
|
@@ -2,12 +2,44 @@ import { removeRuntimeAttempt, saveConfigPrefs, saveRuntime } from "./config.js"
|
|
|
2
2
|
export class ConduitRequestError extends Error {
|
|
3
3
|
status;
|
|
4
4
|
code;
|
|
5
|
-
|
|
5
|
+
details;
|
|
6
|
+
constructor(message, status, code,
|
|
7
|
+
/** The control plane's own structured reason, e.g. which execution fact changed. */
|
|
8
|
+
details) {
|
|
6
9
|
super(message);
|
|
7
10
|
this.status = status;
|
|
8
11
|
this.code = code;
|
|
12
|
+
this.details = details;
|
|
9
13
|
}
|
|
10
14
|
}
|
|
15
|
+
// T98: a server wire-shape change (e.g. T96's released_worktree_attempt_ids) can move ahead of an
|
|
16
|
+
// older runner's schema. The old failure mode printed the full zod issue dump on every poll — one
|
|
17
|
+
// stranded runner logged the same 25-issue dump every cycle and never said what to do about it.
|
|
18
|
+
// This runner logs one short line per minute instead, and flags itself on the next heartbeat so the
|
|
19
|
+
// control plane can tell the operator to restart it.
|
|
20
|
+
let assignmentPollShapeUnderstood = true;
|
|
21
|
+
let lastAssignmentPollShapeWarningAt = 0;
|
|
22
|
+
const ASSIGNMENT_POLL_SHAPE_WARNING_INTERVAL_MS = 60_000;
|
|
23
|
+
/** Call when the /runner/v1/assignments response fails schema validation. */
|
|
24
|
+
export function reportAssignmentPollShapeMismatch(bridgeVersion) {
|
|
25
|
+
assignmentPollShapeUnderstood = false;
|
|
26
|
+
const now = Date.now();
|
|
27
|
+
if (now - lastAssignmentPollShapeWarningAt < ASSIGNMENT_POLL_SHAPE_WARNING_INTERVAL_MS)
|
|
28
|
+
return;
|
|
29
|
+
lastAssignmentPollShapeWarningAt = now;
|
|
30
|
+
console.error(`assignment poll: response shape not understood — this runner (v${bridgeVersion}) is behind the server; restart it`);
|
|
31
|
+
}
|
|
32
|
+
/** True once an assignments poll has failed schema validation. Read by the next heartbeat. */
|
|
33
|
+
export function assignmentPollBehindServer() {
|
|
34
|
+
return !assignmentPollShapeUnderstood;
|
|
35
|
+
}
|
|
36
|
+
/** Test-only: put the module back to its just-started state between cases. */
|
|
37
|
+
export function resetAssignmentPollShapeState() {
|
|
38
|
+
assignmentPollShapeUnderstood = true;
|
|
39
|
+
lastAssignmentPollShapeWarningAt = 0;
|
|
40
|
+
}
|
|
41
|
+
/** The longest one control-plane call may take before the caller gets an error instead of a wait. */
|
|
42
|
+
const CONTROL_PLANE_TIMEOUT_MS = 120_000;
|
|
11
43
|
export class ConduitClient {
|
|
12
44
|
config;
|
|
13
45
|
persistRuntime;
|
|
@@ -32,11 +64,14 @@ export class ConduitClient {
|
|
|
32
64
|
async request(path, init = {}) {
|
|
33
65
|
const response = await fetch(`${this.config.baseUrl}${path}`, {
|
|
34
66
|
...init,
|
|
67
|
+
// T90: every control-plane call of an attempt is bounded. A request that never answers used
|
|
68
|
+
// to be an awaited step with no clock above it, and the attempt could not end.
|
|
69
|
+
signal: init.signal ?? AbortSignal.timeout(CONTROL_PLANE_TIMEOUT_MS),
|
|
35
70
|
headers: { authorization: `Bearer ${this.config.runnerKey}`, "content-type": "application/json", ...init.headers },
|
|
36
71
|
});
|
|
37
72
|
const data = await response.json();
|
|
38
73
|
if (!response.ok)
|
|
39
|
-
throw new ConduitRequestError(data.error?.message ?? `Conduit request failed (${response.status})`, response.status, data.error?.code);
|
|
74
|
+
throw new ConduitRequestError(data.error?.message ?? `Conduit request failed (${response.status})`, response.status, data.error?.code, data.error?.details);
|
|
40
75
|
return data;
|
|
41
76
|
}
|
|
42
77
|
async claim(taskId, attemptId, extras) {
|
|
@@ -53,6 +88,7 @@ export class ConduitClient {
|
|
|
53
88
|
...(extras?.driverId ? { driver_id: extras.driverId } : {}),
|
|
54
89
|
...(extras?.fuelMode ? { fuel_mode: extras.fuelMode } : {}),
|
|
55
90
|
...(extras?.fuelProvenance ? { fuel_provenance: extras.fuelProvenance } : {}),
|
|
91
|
+
...(extras?.startCommit ? { start_commit: extras.startCommit } : {}),
|
|
56
92
|
}),
|
|
57
93
|
});
|
|
58
94
|
const active = {
|
|
@@ -65,6 +101,7 @@ export class ConduitClient {
|
|
|
65
101
|
...(extras?.fuelMode ? { fuelMode: extras.fuelMode } : {}),
|
|
66
102
|
...(extras?.executionKind ? { executionKind: extras.executionKind } : {}),
|
|
67
103
|
...(extras?.sourceAttemptId ? { sourceAttemptId: extras.sourceAttemptId } : {}),
|
|
104
|
+
...(extras?.sourceWorkspace ? { sourceWorkspace: extras.sourceWorkspace } : {}),
|
|
68
105
|
};
|
|
69
106
|
this.config.activeAttempts[taskId] = active;
|
|
70
107
|
await this.persistRuntime(this.config);
|
package/dist/config.js
CHANGED
|
@@ -2,6 +2,7 @@ import { chmod, mkdir, open, readFile, rename, unlink, writeFile, stat } from "n
|
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { join, resolve } from "node:path";
|
|
5
|
+
import { MODEL_TIERS } from "./driver.js";
|
|
5
6
|
/** Serialize config writes in-process so concurrent claim/phase updates cannot clobber each other. */
|
|
6
7
|
let saveChain = Promise.resolve();
|
|
7
8
|
/** Default when join omits --capacity. */
|
|
@@ -84,18 +85,59 @@ export async function loadConfigIfPresent() {
|
|
|
84
85
|
if (!lane || typeof lane !== "object")
|
|
85
86
|
continue;
|
|
86
87
|
const state = lane.state === "online" ? "online" : "offline";
|
|
88
|
+
const modelsPolicy = lane.models_policy === "declared" ? "declared" : undefined;
|
|
87
89
|
const fuel = lane.fuel === "local" || lane.fuel === "conduit" ? lane.fuel : undefined;
|
|
88
90
|
// Every field a lane carries must be listed here. This sanitizer duplicates the shape check in
|
|
89
91
|
// `normalizeDrivers` (drivers.ts) — importing it would close the loop config → drivers → driver
|
|
90
92
|
// → config — so a new lane field has to be added in both, and forgetting here is silent: the
|
|
91
93
|
// value survives in memory, then vanishes on the next load. `quota` was lost exactly that way.
|
|
92
94
|
const quota = plainQuota(lane.quota);
|
|
93
|
-
|
|
95
|
+
const models = plainTierModels(lane.models, `drivers.${id}.models`);
|
|
96
|
+
cleaned[id] = { state, ...(modelsPolicy ? { models_policy: modelsPolicy } : {}), ...(fuel ? { fuel } : {}), ...(quota ? { quota } : {}),
|
|
97
|
+
...(models && Object.keys(models).length ? { models } : {}) };
|
|
94
98
|
}
|
|
95
99
|
config.drivers = cleaned;
|
|
96
100
|
}
|
|
101
|
+
if (config.models !== undefined)
|
|
102
|
+
config.models = plainTierModels(config.models, "models");
|
|
97
103
|
return config;
|
|
98
104
|
}
|
|
105
|
+
/** Tier keys already reported. The runner reloads the config every 15 seconds; one warning is enough. */
|
|
106
|
+
const warnedTierKeys = new Set();
|
|
107
|
+
/**
|
|
108
|
+
* Keep only the three intelligence tiers, and name each other key one time.
|
|
109
|
+
*
|
|
110
|
+
* A key such as `fast` is not a tier, so it binds nothing: every package of that risk runs the
|
|
111
|
+
* CLI default and the first vendor abort Holds. Dropping the key silently is what made that typo
|
|
112
|
+
* findable only from the Hold, hours later.
|
|
113
|
+
*/
|
|
114
|
+
function plainTierModels(raw, where) {
|
|
115
|
+
if (!raw || typeof raw !== "object")
|
|
116
|
+
return undefined;
|
|
117
|
+
const kept = {};
|
|
118
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
119
|
+
if (MODEL_TIERS.includes(key)) {
|
|
120
|
+
if (typeof value === "string" || Array.isArray(value))
|
|
121
|
+
kept[key] = value;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
const named = `${where}.${key}`;
|
|
125
|
+
if (warnedTierKeys.has(named))
|
|
126
|
+
continue;
|
|
127
|
+
warnedTierKeys.add(named);
|
|
128
|
+
console.warn(`Bridge configuration: \`${named}\` is not an intelligence tier and is ignored. The tiers are ${MODEL_TIERS.join(", ")}.`);
|
|
129
|
+
}
|
|
130
|
+
return kept;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Model candidates for one tier on one lane, best first: the lane's own map, then the machine-wide
|
|
134
|
+
* map. One reader for every surface — resolution, the preflight report and the operator printers.
|
|
135
|
+
*/
|
|
136
|
+
export function tierCandidates(config, driverId, tier) {
|
|
137
|
+
const configured = config.drivers?.[driverId]?.models?.[tier] ?? config.models?.[tier];
|
|
138
|
+
const listed = !configured ? [] : (Array.isArray(configured) ? configured : [configured]);
|
|
139
|
+
return listed.filter((candidate) => typeof candidate === "string" && candidate.trim().length > 0);
|
|
140
|
+
}
|
|
99
141
|
/** A stored quota record, or nothing. Malformed is dropped rather than thrown — see normalizeQuota. */
|
|
100
142
|
function plainQuota(raw) {
|
|
101
143
|
if (!raw || typeof raw !== "object")
|
|
@@ -104,7 +146,10 @@ function plainQuota(raw) {
|
|
|
104
146
|
return undefined;
|
|
105
147
|
if (raw.resets_at !== null && typeof raw.resets_at !== "string")
|
|
106
148
|
return undefined;
|
|
107
|
-
|
|
149
|
+
// The vendor is half the record's key, so it must survive the write. Losing it here would make
|
|
150
|
+
// every stored refusal lane-wide again on the next load.
|
|
151
|
+
const vendor = typeof raw.vendor === "string" && raw.vendor.trim() ? raw.vendor : undefined;
|
|
152
|
+
return { exhausted: raw.exhausted, resets_at: raw.resets_at, observed_at: raw.observed_at, ...(vendor ? { vendor } : {}) };
|
|
108
153
|
}
|
|
109
154
|
async function loadRuntime() {
|
|
110
155
|
try {
|
|
@@ -394,5 +439,14 @@ export function suggestMachineName(host, installationId) {
|
|
|
394
439
|
return `${base.slice(0, 199 - suffix.length)}-${suffix}`;
|
|
395
440
|
}
|
|
396
441
|
export function redactSecrets(value) {
|
|
397
|
-
|
|
442
|
+
// Conduit's own key classes, then the provider and forge shapes an agent's own environment
|
|
443
|
+
// carries (ANTHROPIC_API_KEY, XAI_API_KEY, GEMINI_API_KEY, a GitHub token, a bearer header) —
|
|
444
|
+
// an agent that prints its environment on failure must not leak them through this terminal.
|
|
445
|
+
return value
|
|
446
|
+
.replace(/(runner_sk_|gateway_sk_|lease_|connect_)[A-Za-z0-9-]+/g, "$1[redacted]")
|
|
447
|
+
.replace(/sk-[A-Za-z0-9_-]{8,}/g, "sk-[redacted]")
|
|
448
|
+
.replace(/xai-[A-Za-z0-9]+/g, "xai-[redacted]")
|
|
449
|
+
.replace(/AIza[0-9A-Za-z_-]{35}/g, "AIza[redacted]")
|
|
450
|
+
.replace(/(ghp_|gho_|github_pat_|glpat-)[A-Za-z0-9_-]+/g, "$1[redacted]")
|
|
451
|
+
.replace(/Bearer\s+\S+/g, "Bearer [redacted]");
|
|
398
452
|
}
|