@miraland-labs/conduit-bridge 0.16.70 → 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 +2 -1
- package/dist/cli.js +34 -9
- package/dist/client.js +33 -0
- package/dist/config.js +4 -1
- package/dist/driver.js +178 -35
- package/dist/drivers.js +57 -8
- package/dist/ensure-pull-request.js +82 -11
- package/dist/execution-class.js +16 -3
- package/dist/execution.js +392 -62
- package/dist/failure-signal.js +83 -9
- package/dist/git-witness.js +60 -9
- 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 +32 -4
- package/dist/preflight.js +10 -10
- package/dist/service.js +7 -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
|
@@ -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,
|
package/dist/cli.js
CHANGED
|
@@ -7,7 +7,7 @@ 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";
|
|
@@ -46,6 +46,16 @@ function parseBootstrapResult(value) {
|
|
|
46
46
|
throw new Error(`Bootstrap result must be one of: ${BOOTSTRAP_RESULTS.join(", ")}`);
|
|
47
47
|
return result;
|
|
48
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
|
+
}
|
|
49
59
|
async function connect() {
|
|
50
60
|
const { values } = parseArgs({ args: process.argv.slice(3), options: { url: { type: "string" }, code: { type: "string" }, fuel: { type: "string" } } });
|
|
51
61
|
if (!values.url || !values.code)
|
|
@@ -278,7 +288,7 @@ async function finishConnection(baseUrl, data, fuelSource) {
|
|
|
278
288
|
const client = new ConduitClient(config);
|
|
279
289
|
let heartbeatOk = true;
|
|
280
290
|
try {
|
|
281
|
-
await heartbeat(client, config, null, null, unavailableWorkspacePreflight({ config }));
|
|
291
|
+
await heartbeat(client, config, null, null, unavailableWorkspacePreflight({ config, managedRoot: configuredManagedRoot() }));
|
|
282
292
|
}
|
|
283
293
|
catch {
|
|
284
294
|
heartbeatOk = false;
|
|
@@ -408,7 +418,7 @@ async function initOps() {
|
|
|
408
418
|
async function opsCommand() {
|
|
409
419
|
const verb = process.argv[3];
|
|
410
420
|
if (!verb || !OPS_VERBS.includes(verb)) {
|
|
411
|
-
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>])`);
|
|
412
422
|
}
|
|
413
423
|
await runOps(verb, process.argv.slice(4));
|
|
414
424
|
}
|
|
@@ -543,6 +553,7 @@ async function runner() {
|
|
|
543
553
|
throw new Error("--ensure-checkout requires --workspace <repository-path>");
|
|
544
554
|
}
|
|
545
555
|
const workspace = values.workspace ? resolve(values.workspace) : null;
|
|
556
|
+
const managedRoot = configuredManagedRoot();
|
|
546
557
|
if (workspace && values["ensure-checkout"]) {
|
|
547
558
|
const result = await ensureCheckout(workspace, values["ensure-checkout"]);
|
|
548
559
|
console.log(result === "cloned"
|
|
@@ -585,8 +596,8 @@ async function runner() {
|
|
|
585
596
|
const refresh = workspace ? await reportedRefresh(workspace) : null;
|
|
586
597
|
const currentBrief = workspace ? await buildWorkspaceBrief(workspace).catch(() => brief) : null;
|
|
587
598
|
const preflight = workspace
|
|
588
|
-
? await cachedBridgePreflight({ config, workspace, brief: currentBrief ?? undefined, refresh })
|
|
589
|
-
: unavailableWorkspacePreflight({ config });
|
|
599
|
+
? await cachedBridgePreflight({ config, workspace, managedRoot, brief: currentBrief ?? undefined, refresh })
|
|
600
|
+
: unavailableWorkspacePreflight({ config, managedRoot });
|
|
590
601
|
const hb = await heartbeat(client, config, workspace, currentBrief, preflight, undefined, bootstrapResult);
|
|
591
602
|
if (hb.staleBridge) {
|
|
592
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.`);
|
|
@@ -630,14 +641,16 @@ async function runner() {
|
|
|
630
641
|
if (workspace && onlineDriverIds(config).length) {
|
|
631
642
|
// Observation runs before authoring: an owner waiting on an answer should not queue behind
|
|
632
643
|
// a long delivery, and the control plane already counted this slot as busy.
|
|
633
|
-
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;
|
|
634
647
|
}
|
|
635
648
|
if (workspace && onlineDriverIds(config).length) {
|
|
636
649
|
// Read the workspace again, then publish it. The last brief stands if the read fails.
|
|
637
650
|
const publishWorkspaceState = async (options = {}) => {
|
|
638
651
|
const refresh = await reportedRefresh(workspace, options);
|
|
639
652
|
const currentBrief = await buildWorkspaceBrief(workspace).catch(() => brief);
|
|
640
|
-
const preflight = await cachedBridgePreflight({ config, workspace, brief: currentBrief ?? undefined, refresh }, options);
|
|
653
|
+
const preflight = await cachedBridgePreflight({ config, workspace, managedRoot, brief: currentBrief ?? undefined, refresh }, options);
|
|
641
654
|
const hb = await heartbeat(client, config, workspace, currentBrief, preflight, undefined, bootstrapResult);
|
|
642
655
|
return { preflight, hb };
|
|
643
656
|
};
|
|
@@ -656,7 +669,9 @@ async function runner() {
|
|
|
656
669
|
return preflight.ready && lane?.ready === true && typeof lane.version === "string" && lane.version.length > 0;
|
|
657
670
|
},
|
|
658
671
|
heartbeatIntervalMs: intervalMs,
|
|
659
|
-
|
|
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 });
|
|
660
675
|
}
|
|
661
676
|
}
|
|
662
677
|
catch (error) {
|
|
@@ -710,6 +725,16 @@ async function heartbeat(client, config, workspacePath, brief, preflight, intent
|
|
|
710
725
|
bridgeProtocol: BRIDGE_PROTOCOL_VERSION,
|
|
711
726
|
bootstrapResult,
|
|
712
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
|
+
};
|
|
713
738
|
const response = await client.request("/runner/v1/heartbeat", { method: "POST", body: JSON.stringify({
|
|
714
739
|
status,
|
|
715
740
|
capabilities: config.capabilities,
|
|
@@ -719,7 +744,7 @@ async function heartbeat(client, config, workspacePath, brief, preflight, intent
|
|
|
719
744
|
bridge_version: version,
|
|
720
745
|
bridge_protocol: BRIDGE_PROTOCOL_VERSION,
|
|
721
746
|
drivers: driversHeartbeatReport(config, config.activeAttempts),
|
|
722
|
-
preflight,
|
|
747
|
+
preflight: preflightToSend,
|
|
723
748
|
execution_facts: executionFacts,
|
|
724
749
|
...(intentProof ? { on_shift_intent_proof: intentProof } : {}),
|
|
725
750
|
...(brief ? { workspace_brief: brief } : {}),
|
package/dist/client.js
CHANGED
|
@@ -12,6 +12,34 @@ export class ConduitRequestError extends Error {
|
|
|
12
12
|
this.details = details;
|
|
13
13
|
}
|
|
14
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;
|
|
15
43
|
export class ConduitClient {
|
|
16
44
|
config;
|
|
17
45
|
persistRuntime;
|
|
@@ -36,6 +64,9 @@ export class ConduitClient {
|
|
|
36
64
|
async request(path, init = {}) {
|
|
37
65
|
const response = await fetch(`${this.config.baseUrl}${path}`, {
|
|
38
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),
|
|
39
70
|
headers: { authorization: `Bearer ${this.config.runnerKey}`, "content-type": "application/json", ...init.headers },
|
|
40
71
|
});
|
|
41
72
|
const data = await response.json();
|
|
@@ -57,6 +88,7 @@ export class ConduitClient {
|
|
|
57
88
|
...(extras?.driverId ? { driver_id: extras.driverId } : {}),
|
|
58
89
|
...(extras?.fuelMode ? { fuel_mode: extras.fuelMode } : {}),
|
|
59
90
|
...(extras?.fuelProvenance ? { fuel_provenance: extras.fuelProvenance } : {}),
|
|
91
|
+
...(extras?.startCommit ? { start_commit: extras.startCommit } : {}),
|
|
60
92
|
}),
|
|
61
93
|
});
|
|
62
94
|
const active = {
|
|
@@ -69,6 +101,7 @@ export class ConduitClient {
|
|
|
69
101
|
...(extras?.fuelMode ? { fuelMode: extras.fuelMode } : {}),
|
|
70
102
|
...(extras?.executionKind ? { executionKind: extras.executionKind } : {}),
|
|
71
103
|
...(extras?.sourceAttemptId ? { sourceAttemptId: extras.sourceAttemptId } : {}),
|
|
104
|
+
...(extras?.sourceWorkspace ? { sourceWorkspace: extras.sourceWorkspace } : {}),
|
|
72
105
|
};
|
|
73
106
|
this.config.activeAttempts[taskId] = active;
|
|
74
107
|
await this.persistRuntime(this.config);
|
package/dist/config.js
CHANGED
|
@@ -146,7 +146,10 @@ function plainQuota(raw) {
|
|
|
146
146
|
return undefined;
|
|
147
147
|
if (raw.resets_at !== null && typeof raw.resets_at !== "string")
|
|
148
148
|
return undefined;
|
|
149
|
-
|
|
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 } : {}) };
|
|
150
153
|
}
|
|
151
154
|
async function loadRuntime() {
|
|
152
155
|
try {
|