@miraland-labs/conduit-bridge 0.16.142 → 0.16.147
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-worktree.js +1 -26
- package/dist/cli.js +80 -11
- package/dist/config.js +0 -2
- package/dist/driver.js +11 -26
- package/dist/drivers.js +0 -9
- package/dist/ensure-demonstration-evidence.js +1 -1
- package/dist/ensure-land-commit.js +42 -24
- package/dist/ensure-test-evidence.js +13 -2
- package/dist/execution.js +102 -73
- package/dist/failure-signal.js +48 -10
- package/dist/git-witness.js +6 -23
- package/dist/git.js +25 -0
- package/dist/land-git-reality.js +1 -23
- package/dist/ops.js +0 -8
- package/dist/preflight.js +84 -3
- package/dist/service.js +0 -1
- 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.125` — the workspace brief reports `declared_demonstration` so the planner knows whether the repository can show itself (
|
|
5
|
+
**Package version:** `0.16.125` — the workspace brief reports `declared_demonstration` so the planner knows whether the repository can show itself (docs/historical/KERNEL_HISTORY_2026-09.md §51). Earlier (0.16.124) — when a project declares `.conduit/demonstrate`, Bridge runs that bounded command at the delivered head and attaches a stamped `preview` row (docs/historical/KERNEL_HISTORY_2026-09.md §47). Earlier (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
|
|
package/dist/attempt-worktree.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* F-07 slice 1: one Git worktree per attempt. The install-service source checkout is never the
|
|
3
3
|
* agent cwd; retries must not inherit dirty files from a failed run.
|
|
4
4
|
*/
|
|
5
|
-
import { access, appendFile, mkdir, readFile,
|
|
5
|
+
import { access, appendFile, mkdir, readFile, rm } from "node:fs/promises";
|
|
6
6
|
import { constants } from "node:fs";
|
|
7
7
|
import { basename, join, resolve } from "node:path";
|
|
8
8
|
import { execFile } from "node:child_process";
|
|
@@ -136,28 +136,3 @@ export async function proveResumeWorktree(sourceWorkspace, attemptId, worktreePa
|
|
|
136
136
|
return false;
|
|
137
137
|
}
|
|
138
138
|
}
|
|
139
|
-
/** Hold a crashed attempt tree for local diagnosis; never reused as agent cwd. */
|
|
140
|
-
export async function quarantineAttemptWorktree(sourceWorkspace, worktreePath, attemptId) {
|
|
141
|
-
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
142
|
-
const dest = join(sourceWorkspace, ".conduit", "quarantine", `${attemptId}-${stamp}`);
|
|
143
|
-
await mkdir(join(sourceWorkspace, ".conduit", "quarantine"), { recursive: true });
|
|
144
|
-
try {
|
|
145
|
-
await execFileAsync("git", ["-C", sourceWorkspace, "worktree", "remove", "--force", worktreePath], { timeout: 60_000, maxBuffer: 2_000_000 });
|
|
146
|
-
return null;
|
|
147
|
-
}
|
|
148
|
-
catch {
|
|
149
|
-
try {
|
|
150
|
-
await rename(worktreePath, dest);
|
|
151
|
-
await execFileAsync("git", ["-C", sourceWorkspace, "worktree", "prune"], {
|
|
152
|
-
timeout: 30_000,
|
|
153
|
-
maxBuffer: 1_000_000,
|
|
154
|
-
}).catch(() => undefined);
|
|
155
|
-
await writeFile(join(dest, ".conduit-quarantine.json"), `${JSON.stringify({ attemptId, at: stamp })}\n`, "utf8");
|
|
156
|
-
return dest;
|
|
157
|
-
}
|
|
158
|
-
catch {
|
|
159
|
-
await removeAttemptWorktree(sourceWorkspace, worktreePath);
|
|
160
|
-
return null;
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
}
|
package/dist/cli.js
CHANGED
|
@@ -14,15 +14,15 @@ import { runMcp } from "./mcp.js";
|
|
|
14
14
|
import { detectInstalledClients, localFuelOnlyClients, suggestFuelSource } from "./detect.js";
|
|
15
15
|
import { DRIVERS, MODEL_TIERS } from "./driver.js";
|
|
16
16
|
import { driversHeartbeatReport, driverLabel, heartbeatStatusForDrivers, isSupportedDriverId, listDriverLanes, onlineDriverIds, seedDriversFromDetection, seedDriverLanes, setDriverFuel, setDriverModels, setDriversOnline, formatLaneTiers, laneDispatchBlock, laneStatuses, } from "./drivers.js";
|
|
17
|
-
import { buildWorkspaceBrief } from "./brief.js";
|
|
17
|
+
import { buildWorkspaceBrief, normalizeRepositoryUrl } from "./brief.js";
|
|
18
18
|
import { parseAgentTimeoutMinutes, parseMaxTurns, resolveMaxTurns } from "./execution-budget.js";
|
|
19
|
-
import { BOOTSTRAP_RESULTS, buildSovereignExecutionFacts } from "./execution-facts.js";
|
|
19
|
+
import { BOOTSTRAP_RESULTS, buildSovereignExecutionFacts, workspaceIsClean } from "./execution-facts.js";
|
|
20
20
|
import { ensureCheckout } from "./checkout.js";
|
|
21
|
-
import { buildOnShiftIntentProof, maybeApplyOnShiftIntent } from "./on-shift-apply.js";
|
|
22
|
-
import { loadOpsEnv, OPS_VERBS, runOps } from "./ops.js";
|
|
23
|
-
import { pumpExecutionSlots, renewLeases } from "./execution.js";
|
|
21
|
+
import { buildOnShiftIntentProof, managedWorkspacePath, maybeApplyOnShiftIntent } from "./on-shift-apply.js";
|
|
22
|
+
import { expandOpsValue, loadOpsEnv, OPS_VERBS, runOps } from "./ops.js";
|
|
23
|
+
import { discardWorktreeWrites, pumpExecutionSlots, renewLeases } from "./execution.js";
|
|
24
24
|
import { applyRunnerToolPath, installRunnerService, uninstallRunnerService } from "./service.js";
|
|
25
|
-
import { BRIDGE_PROTOCOL_VERSION, cachedBridgePreflight, PREFLIGHT_CACHE_TTL_MS, unavailableWorkspacePreflight } from "./preflight.js";
|
|
25
|
+
import { BRIDGE_PROTOCOL_VERSION, cachedBridgePreflight, planGateRehearsals, PREFLIGHT_CACHE_TTL_MS, runGateRehearsal, unavailableWorkspacePreflight } from "./preflight.js";
|
|
26
26
|
import { refreshSourceWorkspace } from "./workspace-refresh.js";
|
|
27
27
|
import { executeNextInvestigation } from "./investigation.js";
|
|
28
28
|
import { bridgeVersion } from "./version.js";
|
|
@@ -613,12 +613,20 @@ async function runner() {
|
|
|
613
613
|
config.maxTurns = latest.maxTurns;
|
|
614
614
|
// Refresh before discovery: the brief and the readiness report must describe origin's code,
|
|
615
615
|
// not whatever this checkout was left on. Cadence is the readiness report's own, not the beat.
|
|
616
|
-
|
|
616
|
+
// Not while the gate rehearses: a fast-forward would move the tree under the running gate, and
|
|
617
|
+
// the managed checkouts are not followed then either (rehearsalCheckouts is not called).
|
|
618
|
+
const refresh = workspace ? (gateRehearsalRunning ? lastRefresh?.result ?? null : await reportedRefresh(workspace)) : null;
|
|
617
619
|
const currentBrief = workspace ? await buildWorkspaceBrief(workspace).catch(() => brief) : null;
|
|
620
|
+
const rehearsalPlan = workspace && currentBrief && !gateRehearsalRunning
|
|
621
|
+
? await planGateRehearsals(await rehearsalCheckouts(workspace, currentBrief, refresh)).catch(() => null)
|
|
622
|
+
: null;
|
|
623
|
+
if (rehearsalPlan)
|
|
624
|
+
reportedGateRehearsals = rehearsalPlan.reports;
|
|
618
625
|
const preflight = workspace
|
|
619
|
-
? await cachedBridgePreflight({ config, workspace, managedRoot, brief: currentBrief ?? undefined, refresh })
|
|
620
|
-
: unavailableWorkspacePreflight({ config, managedRoot });
|
|
626
|
+
? await cachedBridgePreflight({ config, workspace, managedRoot, brief: currentBrief ?? undefined, refresh, agentTimeoutMs: timeoutMs })
|
|
627
|
+
: unavailableWorkspacePreflight({ config, managedRoot, agentTimeoutMs: timeoutMs });
|
|
621
628
|
const hb = await heartbeat(client, config, workspace, currentBrief, preflight, undefined, bootstrapResult);
|
|
629
|
+
lastOnShift = hb.on_shift ?? null;
|
|
622
630
|
if (hb.staleBridge) {
|
|
623
631
|
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.`);
|
|
624
632
|
await new Promise((resolveSleep) => setTimeout(resolveSleep, Math.max(intervalMs, 60_000)));
|
|
@@ -658,12 +666,17 @@ async function runner() {
|
|
|
658
666
|
await heartbeat(client, config, workspace, currentBrief, preflight, intentProof, bootstrapResult);
|
|
659
667
|
}
|
|
660
668
|
await renewLeases(client, config);
|
|
661
|
-
if (workspace && onlineDriverIds(config).length
|
|
669
|
+
if (workspace && onlineDriverIds(config).length && !gateRehearsalRunning && rehearsalPlan?.due
|
|
670
|
+
&& running.size === 0 && Object.keys(config.activeAttempts).length === 0) {
|
|
671
|
+
startGateRehearsal(rehearsalPlan.due.workspace, rehearsalPlan.due.plan);
|
|
672
|
+
}
|
|
673
|
+
// No claim while the gate rehearses: the rehearsal measures the gate alone, never beside an attempt.
|
|
674
|
+
if (workspace && onlineDriverIds(config).length && !gateRehearsalRunning) {
|
|
662
675
|
// Read the workspace again, then publish it. The last brief stands if the read fails.
|
|
663
676
|
const publishWorkspaceState = async (options = {}) => {
|
|
664
677
|
const refresh = await reportedRefresh(workspace, options);
|
|
665
678
|
const currentBrief = await buildWorkspaceBrief(workspace).catch(() => brief);
|
|
666
|
-
const preflight = await cachedBridgePreflight({ config, workspace, managedRoot, brief: currentBrief ?? undefined, refresh }, options);
|
|
679
|
+
const preflight = await cachedBridgePreflight({ config, workspace, managedRoot, brief: currentBrief ?? undefined, refresh, agentTimeoutMs: timeoutMs }, options);
|
|
667
680
|
const hb = await heartbeat(client, config, workspace, currentBrief, preflight, undefined, bootstrapResult);
|
|
668
681
|
return { preflight, hb };
|
|
669
682
|
};
|
|
@@ -706,6 +719,61 @@ async function runner() {
|
|
|
706
719
|
await new Promise((resolveSleep) => setTimeout(resolveSleep, intervalMs));
|
|
707
720
|
}
|
|
708
721
|
}
|
|
722
|
+
/** True while the gate rehearsal runs in the background; the loop keeps beating but claims nothing. */
|
|
723
|
+
let gateRehearsalRunning = false;
|
|
724
|
+
/** The rehearsals the heartbeat reports, one per on-shift repository; empty reports no field. */
|
|
725
|
+
let reportedGateRehearsals = [];
|
|
726
|
+
/** The last on-shift snapshot, so the next cycle knows every repository this computer serves. */
|
|
727
|
+
let lastOnShift = null;
|
|
728
|
+
/** Managed checkouts follow origin on the readiness report's cadence, as the runner's own does. */
|
|
729
|
+
const managedRefreshes = new Map();
|
|
730
|
+
/**
|
|
731
|
+
* Every checkout this computer is on shift for: the runner's workspace, then the managed checkout of
|
|
732
|
+
* each repository the control plane lists. A folder under the managed root counts only when it sits
|
|
733
|
+
* at that repository's managed path and its origin is that repository; anything else is a leftover.
|
|
734
|
+
* A checkout may rehearse now only when it is clean and can follow origin.
|
|
735
|
+
*/
|
|
736
|
+
async function rehearsalCheckouts(workspace, brief, refresh) {
|
|
737
|
+
const checkouts = [{ workspace, brief, eligible: refresh?.state !== "diverged" && await workspaceIsClean(workspace) }];
|
|
738
|
+
const root = lastOnShift?.managed_workspace_root?.trim();
|
|
739
|
+
for (const repository of root ? lastOnShift?.repositories ?? [] : []) {
|
|
740
|
+
let path;
|
|
741
|
+
try {
|
|
742
|
+
path = resolve(expandOpsValue(managedWorkspacePath(root, repository)));
|
|
743
|
+
}
|
|
744
|
+
catch {
|
|
745
|
+
continue;
|
|
746
|
+
}
|
|
747
|
+
if (path === workspace || !existsSync(path))
|
|
748
|
+
continue;
|
|
749
|
+
let managedRefresh = managedRefreshes.get(path);
|
|
750
|
+
if (!managedRefresh || Date.now() - managedRefresh.at >= PREFLIGHT_CACHE_TTL_MS) {
|
|
751
|
+
managedRefresh = { at: Date.now(), result: await refreshSourceWorkspace(path).catch(() => null) };
|
|
752
|
+
managedRefreshes.set(path, managedRefresh);
|
|
753
|
+
}
|
|
754
|
+
const managedBrief = await buildWorkspaceBrief(path).catch(() => null);
|
|
755
|
+
if (!managedBrief?.repository || normalizeRepositoryUrl(managedBrief.repository) !== normalizeRepositoryUrl(repository))
|
|
756
|
+
continue;
|
|
757
|
+
checkouts.push({ workspace: path, brief: managedBrief, eligible: managedRefresh.result?.state !== "diverged" && await workspaceIsClean(path) });
|
|
758
|
+
}
|
|
759
|
+
return checkouts;
|
|
760
|
+
}
|
|
761
|
+
/**
|
|
762
|
+
* Run one on-shift repository's gate once in its checkout, off the loop: a ten-minute gate
|
|
763
|
+
* must not stop the heartbeat, or the computer reads offline while it measures. The workspace was
|
|
764
|
+
* clean when it started, so whatever is written there afterwards is the gate's and is discarded.
|
|
765
|
+
*/
|
|
766
|
+
function startGateRehearsal(workspace, plan) {
|
|
767
|
+
gateRehearsalRunning = true;
|
|
768
|
+
console.log(`Rehearsing the gate in ${workspace}: ${plan.commands.join(", ")}`);
|
|
769
|
+
void runGateRehearsal(workspace, plan)
|
|
770
|
+
.catch((error) => console.error(`Gate rehearsal failed: ${redactSecrets(error instanceof Error ? error.message : "unknown error")}`))
|
|
771
|
+
.finally(async () => {
|
|
772
|
+
if (!await workspaceIsClean(workspace))
|
|
773
|
+
await discardWorktreeWrites(workspace);
|
|
774
|
+
gateRehearsalRunning = false;
|
|
775
|
+
});
|
|
776
|
+
}
|
|
709
777
|
/** Last state announced, so a workspace that stays diverged does not print on every cycle. */
|
|
710
778
|
let lastRefreshState = null;
|
|
711
779
|
/** Last refresh and when it ran, so the fetch keeps the readiness report's cadence. */
|
|
@@ -755,6 +823,7 @@ async function heartbeat(client, config, workspacePath, brief, preflight, intent
|
|
|
755
823
|
...preflight,
|
|
756
824
|
...(assignmentPollBehindServer() ? { runner_behind_server: true } : {}),
|
|
757
825
|
...(process.env.CONDUIT_RUNNER_SERVICE === "container" ? { runner_service: "container" } : {}),
|
|
826
|
+
...(workspacePath && reportedGateRehearsals.length ? { gate_rehearsals: reportedGateRehearsals } : {}),
|
|
758
827
|
};
|
|
759
828
|
const response = await client.request("/runner/v1/heartbeat", { method: "POST", body: JSON.stringify({
|
|
760
829
|
status,
|
package/dist/config.js
CHANGED
|
@@ -58,8 +58,6 @@ export async function loadConfigIfPresent() {
|
|
|
58
58
|
// Claims live only in runtime.json; a fresh install has none.
|
|
59
59
|
config.activeAttempts = runtime?.activeAttempts ?? {};
|
|
60
60
|
config.sessions = runtime?.sessions;
|
|
61
|
-
for (const active of Object.values(config.activeAttempts))
|
|
62
|
-
active.phase ??= "agent_running";
|
|
63
61
|
if (config.fuelSource !== "local" && config.fuelSource !== "conduit")
|
|
64
62
|
config.fuelSource = "conduit";
|
|
65
63
|
// Clamp to what concurrent slot workers can run (1..BRIDGE_MAX_LEASE_CAPACITY).
|
package/dist/driver.js
CHANGED
|
@@ -152,16 +152,13 @@ export function tierForRisk(risk) {
|
|
|
152
152
|
export function isSafeModelName(value) {
|
|
153
153
|
return /^[A-Za-z0-9][A-Za-z0-9._/-]{0,99}$/.test(value);
|
|
154
154
|
}
|
|
155
|
-
/** Grants → Claude Code tool allow-list (mutate_repo mapping). Privileged grants are never mapped. */
|
|
156
|
-
export function claudeToolsForGrants(grants, verificationCommands = [], capabilities = []) {
|
|
157
|
-
return projectClaude("mutate_repo", { grants, verificationCommands, capabilities }).allowedTools;
|
|
158
|
-
}
|
|
159
|
-
export const claudeDeniedTools = deniedCommands.map((command) => `Bash(${command}:*)`);
|
|
160
155
|
function resolveRunClass(input) {
|
|
161
156
|
if (input.executionClass)
|
|
162
157
|
return input.executionClass;
|
|
163
158
|
throw new Error("Driver run is missing stamped execution_class. Pass executionClass from the assignment stamp.");
|
|
164
159
|
}
|
|
160
|
+
/** Heads the prompt section that tells the agent to continue a timed-out run's work. */
|
|
161
|
+
export const RESUME_PROMPT_HEADING = "CONTINUE THE EARLIER RUN";
|
|
165
162
|
export function buildAssignmentPrompt(context) {
|
|
166
163
|
const packageContext = context.workPackage;
|
|
167
164
|
const spec = context.spec;
|
|
@@ -251,6 +248,9 @@ export function buildAssignmentPrompt(context) {
|
|
|
251
248
|
: "REWORK FEEDBACK — an independent review returned this delivery; address every point";
|
|
252
249
|
lines.push("", `${heading}\n${rework}`);
|
|
253
250
|
}
|
|
251
|
+
if (context.resumeHead) {
|
|
252
|
+
lines.push("", `${RESUME_PROMPT_HEADING} — an earlier run of this same package ran out of time before it finished.`, `Its work is already on this branch at commit ${context.resumeHead}. Continue from that work: read what is there, keep what is correct, and finish the package. Do not start over.`);
|
|
253
|
+
}
|
|
254
254
|
const mustCommit = context.grants.includes("repo_write") && context.grants.includes("branch_create");
|
|
255
255
|
const mustOpenPr = mustCommit && context.grants.includes("pr_create");
|
|
256
256
|
const executionClass = context.executionClass
|
|
@@ -288,7 +288,10 @@ export function buildAssignmentPrompt(context) {
|
|
|
288
288
|
"- Every criterion you mark \"unknown\" must carry unverified_reason: not_attempted when you simply did not check it and another run could; needs_authority when this package has no grant or capability for it; needs_environment when it needs a running or deployed system you cannot start here; needs_third_party when it needs an account or platform outside this workspace; needs_human_judgment when only a person can assess it. Say which one — it decides whether the owner returns the work or accepts it.",
|
|
289
289
|
// A code can only say the class. The owner reads a sentence, and the sentence needs the
|
|
290
290
|
// particular: which app, which authority, which account. The run already knows it.
|
|
291
|
-
"- With it, write unverified_detail: one sentence naming exactly what was missing, in the owner's terms. \"Checking this needs the app deployed and running, and this task cannot deploy\" — not \"environment unavailable\".",
|
|
291
|
+
"- With it, write unverified_detail: one sentence naming exactly what was missing, in the owner's terms. \"Checking this needs the app deployed and running, and this task cannot deploy\" — not \"environment unavailable\".",
|
|
292
|
+
// The owner reads outcome, limitations and unverified_detail on a phone. Dense prose was the
|
|
293
|
+
// complaint on every delivery this trial; ASD-STE100's rules are the shortest fix.
|
|
294
|
+
"- Write outcome, risks, limitations and every unverified_detail in Simplified Technical English (ASD-STE100): short sentences of at most 20 words, one fact or instruction per sentence, active voice, present tense, and the product's own terms (work package, delivery, gate, Hold) with no synonyms.", "", "When the work is finished, end your reply with exactly one fenced ```json block:", agentReportTemplate);
|
|
292
295
|
return lines.join("\n");
|
|
293
296
|
}
|
|
294
297
|
/**
|
|
@@ -916,21 +919,6 @@ export const codexDriver = {
|
|
|
916
919
|
return { status: "completed", resultText, sessionId: parsed.sessionId, usage: parsed.usage };
|
|
917
920
|
},
|
|
918
921
|
};
|
|
919
|
-
/**
|
|
920
|
-
* Grants + capabilities → Cursor CLI permissions via ExecutionClass.
|
|
921
|
-
* Current Cursor Agent schema accepts only `permissions.{allow,deny}` (no
|
|
922
|
-
* `version` / `approvalMode`). Outside the allow list is denied without --force.
|
|
923
|
-
*
|
|
924
|
-
* `observe_network` / networked mutate / publish_artifact use `--force` (see
|
|
925
|
-
* cursorRunArgs). Field evidence: allow-listing WebFetch alone still yields
|
|
926
|
-
* "User Rejected" under headless `-p`; `--force` is required for fetch and for
|
|
927
|
-
* publish shell under open class policy. Research-only networked assignments
|
|
928
|
-
* deny Shell(*) and Write(**).
|
|
929
|
-
*/
|
|
930
|
-
export function cursorPermissionsForGrants(grants, verificationCommands, capabilities, executionClass) {
|
|
931
|
-
const { allow, deny } = projectCursor(executionClass, { grants, verificationCommands, capabilities });
|
|
932
|
-
return { allow, deny };
|
|
933
|
-
}
|
|
934
922
|
/**
|
|
935
923
|
* Cursor CLI arguments. Deliberately no `--mode plan` for read-only assignments: plan mode is not an
|
|
936
924
|
* enforcement boundary (in headless `-p` mode the agent can self-escalate out of it and edit anyway)
|
|
@@ -1996,7 +1984,8 @@ const LOCAL_VENDOR_ENV = [
|
|
|
1996
1984
|
"CURSOR_API_KEY", "KIRO_API_KEY", "GEMINI_API_KEY", "GOOGLE_API_KEY",
|
|
1997
1985
|
"XAI_API_KEY",
|
|
1998
1986
|
];
|
|
1999
|
-
|
|
1987
|
+
/** The stripped process environment an agent CLI runs with, carrying Conduit fuel when given. */
|
|
1988
|
+
export function boundedEnvironment(fuel, fuelSource = "conduit", extraEnv) {
|
|
2000
1989
|
// Proxy and TLS variables stay: fueled agents must reach Conduit's /v1 on
|
|
2001
1990
|
// machines that only have network access through a proxy.
|
|
2002
1991
|
const allowed = ["PATH", "HOME", "USER", "LOGNAME", "SHELL", "TMPDIR", "TMP", "TEMP", "LANG", "LC_ALL", "TERM", "NO_COLOR", "FORCE_COLOR",
|
|
@@ -2071,7 +2060,3 @@ export function hasGrokLogin(env = process.env) {
|
|
|
2071
2060
|
// `~/.grok` exists after install; auth.json is the `grok login` / cached_token file.
|
|
2072
2061
|
return Boolean(home && existsSync(join(home, ".grok", "auth.json")));
|
|
2073
2062
|
}
|
|
2074
|
-
/** Exported for tests — builds the stripped process env with optional Conduit fuel. */
|
|
2075
|
-
export function agentProcessEnv(fuel, fuelSource = "conduit", extraEnv) {
|
|
2076
|
-
return boundedEnvironment(fuel, fuelSource, extraEnv);
|
|
2077
|
-
}
|
package/dist/drivers.js
CHANGED
|
@@ -400,15 +400,6 @@ export function setDriverFuel(config, driverId, fuel) {
|
|
|
400
400
|
export function heartbeatStatusForDrivers(onlineIds) {
|
|
401
401
|
return onlineIds.length > 0 ? "online" : "standby";
|
|
402
402
|
}
|
|
403
|
-
/**
|
|
404
|
-
* Effective capacity advertised on heartbeat: full approved pool when any lane is online;
|
|
405
|
-
* 0 (via standby + no claims) when none are. Caller still sends lease_capacity = approved when online.
|
|
406
|
-
*/
|
|
407
|
-
export function advertiseLeaseCapacity(approved, onlineIds) {
|
|
408
|
-
if (onlineIds.length === 0)
|
|
409
|
-
return 1; // schema/heartbeat min; status standby prevents matching
|
|
410
|
-
return approved;
|
|
411
|
-
}
|
|
412
403
|
export function driversHeartbeatReport(config, activeAttempts, processOnlineIds, env = process.env) {
|
|
413
404
|
const processOnline = new Set(processOnlineIds ?? []);
|
|
414
405
|
const busyByDriver = new Map();
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* When the project declares how it shows itself, Bridge runs that command at the delivered head and
|
|
3
|
-
* attaches what it printed. The agent never authors this entry (
|
|
3
|
+
* attaches what it printed. The agent never authors this entry (docs/historical/KERNEL_HISTORY_2026-09.md §47, "See it").
|
|
4
4
|
*
|
|
5
5
|
* `.conduit/demonstrate` is one bounded command, `#` comments allowed, absent by default. It runs
|
|
6
6
|
* with the same shape check, runner and bound as a verification command, and it is never a gate:
|
|
@@ -3,33 +3,14 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { execFile } from "node:child_process";
|
|
5
5
|
import { promisify } from "node:util";
|
|
6
|
-
import { commitsAheadOfBase } from "./ensure-pull-request.js";
|
|
7
|
-
import { readHeadCommit } from "./git.js";
|
|
6
|
+
import { commitsAheadOfBase, pushOriginHead } from "./ensure-pull-request.js";
|
|
7
|
+
import { readHeadCommit, uncommittedPaths } from "./git.js";
|
|
8
|
+
import { redactSecrets } from "./secret-redaction.js";
|
|
8
9
|
import { AgentNoLandCommitError, filterPathsInScope, requiresLandCommit, } from "./land-contract.js";
|
|
9
10
|
const execFileAsync = promisify(execFile);
|
|
10
|
-
async function uncommittedPaths(workspace) {
|
|
11
|
-
const { stdout } = await execFileAsync("git", ["-C", workspace, "status", "--porcelain"], {
|
|
12
|
-
timeout: 30_000,
|
|
13
|
-
maxBuffer: 8_000_000,
|
|
14
|
-
});
|
|
15
|
-
const paths = [];
|
|
16
|
-
for (const line of stdout.split("\n")) {
|
|
17
|
-
if (!line.trim())
|
|
18
|
-
continue;
|
|
19
|
-
const entry = line.slice(3).trim();
|
|
20
|
-
if (!entry)
|
|
21
|
-
continue;
|
|
22
|
-
const path = entry.includes(" -> ")
|
|
23
|
-
? entry.slice(entry.lastIndexOf(" -> ") + 4).trim()
|
|
24
|
-
: entry;
|
|
25
|
-
if (path)
|
|
26
|
-
paths.push(path.replace(/^"|"$/g, ""));
|
|
27
|
-
}
|
|
28
|
-
return paths;
|
|
29
|
-
}
|
|
30
11
|
const LAND_COMMIT_EMAIL = "conduit-bridge@miraland.io";
|
|
31
12
|
const LAND_COMMIT_NAME = "Conduit Bridge";
|
|
32
|
-
async function commitScopedPaths(workspace, paths) {
|
|
13
|
+
async function commitScopedPaths(workspace, paths, message = "Conduit: land scoped agent changes") {
|
|
33
14
|
if (!paths.length)
|
|
34
15
|
throw new Error("Cannot land an empty path set");
|
|
35
16
|
await execFileAsync("git", ["-C", workspace, "add", "--", ...paths], {
|
|
@@ -40,7 +21,7 @@ async function commitScopedPaths(workspace, paths) {
|
|
|
40
21
|
"-C", workspace,
|
|
41
22
|
"-c", `user.email=${LAND_COMMIT_EMAIL}`,
|
|
42
23
|
"-c", `user.name=${LAND_COMMIT_NAME}`,
|
|
43
|
-
"commit", "-m",
|
|
24
|
+
"commit", "-m", message,
|
|
44
25
|
], { timeout: 120_000, maxBuffer: 8_000_000 });
|
|
45
26
|
}
|
|
46
27
|
/**
|
|
@@ -73,3 +54,40 @@ export async function ensureLandCommit(input) {
|
|
|
73
54
|
}
|
|
74
55
|
return { kind: "committed", headCommit: await readHead(input.workspace), paths: pending };
|
|
75
56
|
}
|
|
57
|
+
async function currentBranch(workspace) {
|
|
58
|
+
const { stdout } = await execFileAsync("git", ["-C", workspace, "branch", "--show-current"], {
|
|
59
|
+
timeout: 30_000,
|
|
60
|
+
maxBuffer: 1_000_000,
|
|
61
|
+
});
|
|
62
|
+
return stdout.trim();
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* A run that spent Conduit's package budget keeps its work for the retry: commit the scoped files the
|
|
66
|
+
* agent left uncommitted, push the attempt branch where the grants allow a push, and name the head.
|
|
67
|
+
* Null when the attempt has nothing after base — the retry then starts fresh, as before.
|
|
68
|
+
*/
|
|
69
|
+
export async function checkpointWorkInProgress(input) {
|
|
70
|
+
if (!requiresLandCommit({ grants: input.grants, spec: input.spec }))
|
|
71
|
+
return null;
|
|
72
|
+
const pending = filterPathsInScope(await uncommittedPaths(input.workspace), input.spec.change_scope ?? []);
|
|
73
|
+
if (pending.length) {
|
|
74
|
+
await commitScopedPaths(input.workspace, pending, "Conduit: work in progress at the package budget");
|
|
75
|
+
}
|
|
76
|
+
const ahead = await commitsAheadOfBase(input.workspace, input.baseCommit);
|
|
77
|
+
if (!ahead)
|
|
78
|
+
return null;
|
|
79
|
+
// The same grant the delivery push needs (needsPullRequest). Without it the head stays on this
|
|
80
|
+
// computer, where the retained worktree still holds it.
|
|
81
|
+
if (input.grants.includes("pr_create")) {
|
|
82
|
+
try {
|
|
83
|
+
await pushOriginHead(input.workspace);
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
console.error(`Work in progress was not pushed: ${redactSecrets(error instanceof Error ? error.message : String(error))}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
head: await readHeadCommit(input.workspace),
|
|
91
|
+
branch: await currentBranch(input.workspace),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
@@ -319,8 +319,11 @@ export function boundedFailureDetail(command, stdoutLines, stderrLines, code, ma
|
|
|
319
319
|
/**
|
|
320
320
|
* The bounded runner every Bridge-run verification uses: the declared command, in the given
|
|
321
321
|
* workspace, with the verification timeout and buffer. A non-zero exit is a result, not a throw.
|
|
322
|
+
*
|
|
323
|
+
* A gate the timeout stopped did not exit: `timedOut` says so, and stderr ends with the limit it
|
|
324
|
+
* reached. Read as exit 1, a gate that hung on the untouched base was a red base on the author.
|
|
322
325
|
*/
|
|
323
|
-
export async function runBoundedVerificationCommand(command, workspace) {
|
|
326
|
+
export async function runBoundedVerificationCommand(command, workspace, timeoutMs = VERIFICATION_TIMEOUT_MS) {
|
|
324
327
|
const argv = argvForBoundedCommand(command);
|
|
325
328
|
const bin = argv[0];
|
|
326
329
|
if (!bin)
|
|
@@ -328,7 +331,7 @@ export async function runBoundedVerificationCommand(command, workspace) {
|
|
|
328
331
|
try {
|
|
329
332
|
const { stdout, stderr } = await execFileAsync(bin, argv.slice(1), {
|
|
330
333
|
cwd: workspace,
|
|
331
|
-
timeout:
|
|
334
|
+
timeout: timeoutMs,
|
|
332
335
|
maxBuffer: VERIFICATION_MAX_BUFFER_BYTES,
|
|
333
336
|
env: process.env,
|
|
334
337
|
});
|
|
@@ -343,6 +346,14 @@ export async function runBoundedVerificationCommand(command, workspace) {
|
|
|
343
346
|
// message reaches the `enoent` rule that Holds it on the computer operator.
|
|
344
347
|
if (typeof err.code === "string")
|
|
345
348
|
throw error;
|
|
349
|
+
if (err.killed === true) {
|
|
350
|
+
return {
|
|
351
|
+
stdout: String(err.stdout ?? ""),
|
|
352
|
+
stderr: `${String(err.stderr ?? "")}\nBridge stopped the gate: it did not exit within ${Math.round(timeoutMs / 1_000)} seconds.`.trimStart(),
|
|
353
|
+
code: 1,
|
|
354
|
+
timedOut: true,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
346
357
|
const stderr = String(err.stderr || err.message || "verification failed");
|
|
347
358
|
return {
|
|
348
359
|
stdout: String(err.stdout ?? ""),
|
package/dist/execution.js
CHANGED
|
@@ -11,24 +11,24 @@ import { saveDriverOutcome, saveDriverQuota, tierCandidates } from "./config.js"
|
|
|
11
11
|
import { DRIVERS, acceptanceCriteriaLines, acceptanceReportSkeleton, agentReportTemplate, buildAssignmentPrompt, evidenceKinds, extractAgentReportJsonText, fuelEndpoint, normalizeEvidenceDigest, parseAgentReport, parseJsonObjectCandidate, pickModelCandidate, isSafeModelName, driverExecutable, requireStampedExecutionClass, tierForRisk } from "./driver.js";
|
|
12
12
|
import { assertClassFloor } from "./execution-class.js";
|
|
13
13
|
import { pickDriverForClaim, recordDriverQuota, resolveDriverFuel, resolveDriverFuelProvenance, supportsReadOnlyDiagnosis, clearDriverOutcome, recordDriverTimeout } from "./drivers.js";
|
|
14
|
-
import { attemptWorktreePath, createAttemptWorktree, proveResumeWorktree,
|
|
14
|
+
import { attemptWorktreePath, createAttemptWorktree, proveResumeWorktree, removeAttemptWorktree, } from "./attempt-worktree.js";
|
|
15
15
|
import { execFile } from "node:child_process";
|
|
16
16
|
import { promisify } from "node:util";
|
|
17
17
|
import { buildWorkspaceBrief, ensureCommitAvailable, fetchOrigin, normalizeRepositoryUrl, resolveAttemptStartCommit } from "./brief.js";
|
|
18
18
|
import { buildSovereignExecutionFacts, bootstrapResultForWorkspace, changedExecutionFact, resumeExecutionFacts, workspaceIsClean } from "./execution-facts.js";
|
|
19
|
-
import { agentOutputCause, boundedDetail, bridgeCause, contractScopeConflictFailure, executionFactChangedSignal, verificationRedBaseSignal, verificationScopeConflictSignal } from "./failure-signal.js";
|
|
19
|
+
import { agentOutputCause, boundedDetail, bridgeCause, contractScopeConflictFailure, executionFactChangedSignal, verificationGateTimeoutSignal, verificationRedBaseSignal, verificationScopeConflictSignal } from "./failure-signal.js";
|
|
20
20
|
import { forgeTransportFailure } from "./transport-fault.js";
|
|
21
21
|
import { managedWorkspacePath, switchManagedWorkspace } from "./on-shift-apply.js";
|
|
22
22
|
import { formatResetMinute, parseQuotaResetAt } from "./quota-reset.js";
|
|
23
23
|
import { headCommitOrNull } from "./git.js";
|
|
24
24
|
import { BRIDGE_PROTOCOL_VERSION } from "./preflight.js";
|
|
25
25
|
import { bridgeVersion } from "./version.js";
|
|
26
|
-
import { PULL_REQUEST_OPEN_FAILURE_PREFIX, ensureDeliveryPullRequest } from "./ensure-pull-request.js";
|
|
27
|
-
import { ensureLandCommit } from "./ensure-land-commit.js";
|
|
26
|
+
import { PULL_REQUEST_OPEN_FAILURE_PREFIX, commitsMatch, ensureDeliveryPullRequest } from "./ensure-pull-request.js";
|
|
27
|
+
import { checkpointWorkInProgress, ensureLandCommit } from "./ensure-land-commit.js";
|
|
28
28
|
import { AGENT_NO_LAND_COMMIT_PREFIX, AgentNoLandCommitError, agentNoLandCommitMessage, requiresLandCommit } from "./land-contract.js";
|
|
29
29
|
import { agentClaimsRepositoryWork, claimsVsGitMismatch, isLandTreeEmpty, reportClaimsRepositoryWork, } from "./land-git-reality.js";
|
|
30
30
|
const execFileAsync = promisify(execFile);
|
|
31
|
-
import { boundedTail, captureVerificationFailure, DeliveryReportDefectError, detectGateRanNothing, ensureTestEvidence, needsTestEvidence, runBoundedVerificationCommand, verificationEvidenceCommands, VerificationFailedError, VerificationGateRanNothingError } from "./ensure-test-evidence.js";
|
|
31
|
+
import { boundedTail, captureVerificationFailure, DeliveryReportDefectError, detectGateRanNothing, ensureTestEvidence, needsTestEvidence, runBoundedVerificationCommand, verificationEvidenceCommands, VerificationFailedError, VerificationGateRanNothingError, VERIFICATION_TIMEOUT_MS } from "./ensure-test-evidence.js";
|
|
32
32
|
import { ensureChangeEvidence } from "./ensure-change-evidence.js";
|
|
33
33
|
import { ensureDemonstrationEvidence } from "./ensure-demonstration-evidence.js";
|
|
34
34
|
import { ensureNormativeEvidence } from "./ensure-normative-evidence.js";
|
|
@@ -156,10 +156,14 @@ export function agentModelAborted(message) {
|
|
|
156
156
|
function agentModelAbortPrefix(model) {
|
|
157
157
|
return model ? `agent_model_abort: model=${model}; ` : "agent_model_abort: ";
|
|
158
158
|
}
|
|
159
|
-
|
|
160
|
-
|
|
159
|
+
/** The computer's own ceiling ended the turn, not Conduit's package budget. */
|
|
160
|
+
function machineBoundTimeout(resolution) {
|
|
161
|
+
return resolution.source === "default"
|
|
161
162
|
|| resolution.source === "machine"
|
|
162
163
|
|| resolution.source === "clamped_to_machine";
|
|
164
|
+
}
|
|
165
|
+
function timeoutFailureBody(resolution, attemptId, resume) {
|
|
166
|
+
const machineBound = machineBoundTimeout(resolution);
|
|
163
167
|
const minutes = Math.round(resolution.timeoutMs / 60_000);
|
|
164
168
|
return {
|
|
165
169
|
failure: machineBound ? {
|
|
@@ -181,6 +185,7 @@ function timeoutFailureBody(resolution, attemptId) {
|
|
|
181
185
|
},
|
|
182
186
|
error: `agent_turn_timeout: agent turn exceeded ${resolution.timeoutMs}ms`,
|
|
183
187
|
retryable: false,
|
|
188
|
+
...(resume ? { resume } : {}),
|
|
184
189
|
idempotency_key: `bridge:agent-timeout:${attemptId}`,
|
|
185
190
|
};
|
|
186
191
|
}
|
|
@@ -429,6 +434,9 @@ const executionContractSchema = z.object({
|
|
|
429
434
|
// head reuses that change request instead of opening a second one at the same commit.
|
|
430
435
|
prior_pull_request_url: z.string().nullable().optional().default(null),
|
|
431
436
|
prior_head_commit: z.string().nullable().optional().default(null),
|
|
437
|
+
// A package-budget retry: the timed-out run's work is committed at prior_head_commit, which is also
|
|
438
|
+
// the claimed head this attempt starts from.
|
|
439
|
+
resume_from_attempt_id: z.string().uuid().nullable().optional().default(null),
|
|
432
440
|
});
|
|
433
441
|
const diagnosticRepairBriefSchema = z.object({
|
|
434
442
|
root_cause: z.string().trim().min(1).max(2_000),
|
|
@@ -534,7 +542,17 @@ export function buildDiagnosticPrompt(input) {
|
|
|
534
542
|
* control plane has ended.
|
|
535
543
|
*/
|
|
536
544
|
const slotAborts = new Map();
|
|
537
|
-
|
|
545
|
+
/**
|
|
546
|
+
* The renewal pass in flight. Each running slot and the runner loop all ask for a pass; one pass
|
|
547
|
+
* covers every attempt, so a caller that arrives while one runs waits for it instead of renewing
|
|
548
|
+
* the same leases and dropping the same attempts a second time.
|
|
549
|
+
*/
|
|
550
|
+
let renewalPass = null;
|
|
551
|
+
export function renewLeases(client, config) {
|
|
552
|
+
renewalPass ??= renewEachLease(client, config).finally(() => { renewalPass = null; });
|
|
553
|
+
return renewalPass;
|
|
554
|
+
}
|
|
555
|
+
async function renewEachLease(client, config) {
|
|
538
556
|
for (const active of Object.values(config.activeAttempts)) {
|
|
539
557
|
if (Date.parse(active.leaseExpiresAt) - Date.now() < 120_000) {
|
|
540
558
|
try {
|
|
@@ -633,14 +651,8 @@ function checkpointInstructionPrompt(instructions) {
|
|
|
633
651
|
agentReportTemplate,
|
|
634
652
|
].join("\n");
|
|
635
653
|
}
|
|
636
|
-
function resolveAttemptDriver(
|
|
637
|
-
|
|
638
|
-
return DRIVERS[active.driverId];
|
|
639
|
-
if (fallback)
|
|
640
|
-
return fallback;
|
|
641
|
-
// Crash between claim and driverId persist: pick any free online lane so recovery is not stranded.
|
|
642
|
-
const picked = pickDriverForClaim(config);
|
|
643
|
-
return picked ? DRIVERS[picked] ?? null : null;
|
|
654
|
+
function resolveAttemptDriver(active, fallback) {
|
|
655
|
+
return (active.driverId ? DRIVERS[active.driverId] : undefined) ?? fallback ?? null;
|
|
644
656
|
}
|
|
645
657
|
export async function recoverActiveAttempt(client, config, driver, sourceWorkspace, brief, timeoutMs, supervision, taskId) {
|
|
646
658
|
const active = taskId ? config.activeAttempts[taskId] : Object.values(config.activeAttempts)[0];
|
|
@@ -660,25 +672,15 @@ export async function recoverActiveAttempt(client, config, driver, sourceWorkspa
|
|
|
660
672
|
await removeAttemptWorktree(workspace, worktree).catch(() => undefined);
|
|
661
673
|
}
|
|
662
674
|
else if (!diagnosis && response.retain_worktree !== true && worktree) {
|
|
663
|
-
|
|
664
|
-
await quarantineAttemptWorktree(workspace, worktree, active.attemptId).catch(() => removeAttemptWorktree(workspace, worktree).catch(() => undefined));
|
|
665
|
-
}
|
|
666
|
-
else {
|
|
667
|
-
await removeAttemptWorktree(workspace, worktree).catch(() => undefined);
|
|
668
|
-
}
|
|
675
|
+
await removeAttemptWorktree(workspace, worktree).catch(() => undefined);
|
|
669
676
|
}
|
|
670
677
|
return true;
|
|
671
678
|
}
|
|
672
|
-
const laneDriver = resolveAttemptDriver(
|
|
679
|
+
const laneDriver = resolveAttemptDriver(active, driver);
|
|
673
680
|
if (!laneDriver) {
|
|
674
681
|
console.error(`No driver for attempt ${active.attemptId} (driverId=${active.driverId ?? "unset"})`);
|
|
675
682
|
return false;
|
|
676
683
|
}
|
|
677
|
-
if (!active.driverId) {
|
|
678
|
-
const assigned = Object.entries(DRIVERS).find(([, d]) => d === laneDriver)?.[0];
|
|
679
|
-
if (assigned)
|
|
680
|
-
await client.updateAttempt(active.taskId, { driverId: assigned });
|
|
681
|
-
}
|
|
682
684
|
if (active.phase === "agent_running") {
|
|
683
685
|
const diagnosis = active.executionKind === "diagnosis";
|
|
684
686
|
const worktreeOwner = diagnosis ? active.sourceAttemptId : active.attemptId;
|
|
@@ -706,9 +708,9 @@ export async function recoverActiveAttempt(client, config, driver, sourceWorkspa
|
|
|
706
708
|
await removeAttemptWorktree(workspace, worktree).catch(() => undefined);
|
|
707
709
|
}
|
|
708
710
|
else if (!diagnosis && response.retain_worktree !== true && worktree) {
|
|
709
|
-
// Do not
|
|
711
|
+
// Do not remove before the terminal response: Conduit may need this exact tree for a
|
|
710
712
|
// pinned diagnosis even when Bridge restarted before it could preserve the agent session.
|
|
711
|
-
await
|
|
713
|
+
await removeAttemptWorktree(workspace, worktree).catch(() => undefined);
|
|
712
714
|
}
|
|
713
715
|
return true;
|
|
714
716
|
}
|
|
@@ -752,7 +754,7 @@ export async function pumpExecutionSlots(client, config, workspace, brief, timeo
|
|
|
752
754
|
const active = config.activeAttempts[id];
|
|
753
755
|
const laneDriver = active.phase === "terminal_pending"
|
|
754
756
|
? fallbackDriver
|
|
755
|
-
: resolveAttemptDriver(
|
|
757
|
+
: resolveAttemptDriver(active, fallbackDriver);
|
|
756
758
|
if (!laneDriver && active.phase !== "terminal_pending") {
|
|
757
759
|
console.error(`Slot recovery skipped for ${id}: no driver lane`);
|
|
758
760
|
continue;
|
|
@@ -799,7 +801,7 @@ export async function pumpExecutionSlots(client, config, workspace, brief, timeo
|
|
|
799
801
|
break;
|
|
800
802
|
const taskId = claimed.taskId;
|
|
801
803
|
driverId = claimed.driverId ?? driverId;
|
|
802
|
-
laneDriver =
|
|
804
|
+
laneDriver = fallbackDriver ?? DRIVERS[driverId] ?? laneDriver;
|
|
803
805
|
progressed = true;
|
|
804
806
|
console.log(`Executing ${taskId} via ${laneDriver.name}`);
|
|
805
807
|
// T86: the claim answers with the checkout it proved, which is this assignment's repository.
|
|
@@ -945,27 +947,6 @@ export async function claimNextAssignment(client, config, workspace, brief, driv
|
|
|
945
947
|
});
|
|
946
948
|
return { taskId: assignment.id, driverId: selectedDriverId, workspace: claimWorkspace };
|
|
947
949
|
}
|
|
948
|
-
export function claimedAssignmentDriver(fallback, driverId) {
|
|
949
|
-
if (!driverId)
|
|
950
|
-
return fallback;
|
|
951
|
-
const selected = DRIVERS[driverId];
|
|
952
|
-
if (!selected)
|
|
953
|
-
throw new Error(`Claimed assignment selected unavailable driver ${driverId}`);
|
|
954
|
-
return selected;
|
|
955
|
-
}
|
|
956
|
-
export async function executeNextAssignment(client, config, driver, workspace, brief, timeoutMs, supervision) {
|
|
957
|
-
if (Object.keys(config.activeAttempts).length)
|
|
958
|
-
return recoverActiveAttempt(client, config, driver, workspace, brief, timeoutMs, supervision);
|
|
959
|
-
const preferredDriverId = Object.entries(DRIVERS).find(([, candidate]) => candidate === driver)?.[0] ?? null;
|
|
960
|
-
const claimed = await claimNextAssignment(client, config, workspace, brief, preferredDriverId, null, {
|
|
961
|
-
beforeClaim: supervision?.beforeClaim,
|
|
962
|
-
});
|
|
963
|
-
if (!claimed)
|
|
964
|
-
return false;
|
|
965
|
-
const selectedDriver = claimedAssignmentDriver(driver, claimed.driverId);
|
|
966
|
-
await runClaimedAssignment(client, config, selectedDriver, claimed.workspace, brief, claimed.taskId, timeoutMs, supervision);
|
|
967
|
-
return true;
|
|
968
|
-
}
|
|
969
950
|
/**
|
|
970
951
|
* T90: attempts whose stall report already ended them, by attempt id.
|
|
971
952
|
*
|
|
@@ -1197,6 +1178,13 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
|
|
|
1197
1178
|
?? (rework
|
|
1198
1179
|
? (executionContract.requested_base_commit ?? spec.repository?.base_commit ?? worktreeStart)
|
|
1199
1180
|
: worktreeStart);
|
|
1181
|
+
// Only when the worktree really stands at the timed-out run's head: a computer that cannot reach
|
|
1182
|
+
// that commit starts at the base (resolveAttemptStartCommit), and the agent must not be told the
|
|
1183
|
+
// work is there.
|
|
1184
|
+
const resumeHead = !diagnosis && executionContract.resume_from_attempt_id && executionContract.prior_head_commit
|
|
1185
|
+
&& commitsMatch(executionContract.prior_head_commit, worktreeStart)
|
|
1186
|
+
? executionContract.prior_head_commit
|
|
1187
|
+
: null;
|
|
1200
1188
|
let normativeMaterialized = [];
|
|
1201
1189
|
if (!diagnosis) {
|
|
1202
1190
|
const normativeRefs = parseNormativeRefs(spec.normative_refs);
|
|
@@ -1342,13 +1330,15 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
|
|
|
1342
1330
|
return;
|
|
1343
1331
|
}
|
|
1344
1332
|
// T22/T24/T28: the gates run once here, before the first model call. A gate that started but
|
|
1345
|
-
// tested nothing is the earliest fact and refuses first; then a gate
|
|
1346
|
-
// base; only a green base still runs the scope check, which refuses
|
|
1347
|
-
// approved change scope.
|
|
1333
|
+
// tested nothing is the earliest fact and refuses first; then a gate that never exited; then a gate
|
|
1334
|
+
// already red on the untouched base; only a green base still runs the scope check, which refuses
|
|
1335
|
+
// when a gate wrote outside the approved change scope.
|
|
1336
|
+
// A resumed tree stands at the timed-out run's unfinished work, where red gates are expected; the
|
|
1337
|
+
// timed-out run already passed these checks on the base.
|
|
1348
1338
|
if (dryRunsVerification({
|
|
1349
1339
|
executionKind,
|
|
1350
1340
|
executionClass,
|
|
1351
|
-
freshWorktree: options.freshWorktree ?? !options.existingWorktree,
|
|
1341
|
+
freshWorktree: (options.freshWorktree ?? !options.existingWorktree) && !resumeHead,
|
|
1352
1342
|
})) {
|
|
1353
1343
|
watchdog.phase("verification");
|
|
1354
1344
|
const dryRun = await verificationScopeDryRun({
|
|
@@ -1388,6 +1378,25 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
|
|
|
1388
1378
|
console.error(`Assignment ${taskId} refused before the agent started: ${redactSecrets(detail)}`);
|
|
1389
1379
|
return;
|
|
1390
1380
|
}
|
|
1381
|
+
if (dryRun.timedOut) {
|
|
1382
|
+
const { command, stderrTail, stdoutTail } = dryRun.timedOut;
|
|
1383
|
+
const detail = `Verification gate did not finish on the base commit ${worktreeStart}: ${command} (stopped after ${Math.round(VERIFICATION_TIMEOUT_MS / 60_000)} minutes)`;
|
|
1384
|
+
const response = await queueTerminal(client, taskId, {
|
|
1385
|
+
action: "fail",
|
|
1386
|
+
body: {
|
|
1387
|
+
error: detail,
|
|
1388
|
+
retryable: false,
|
|
1389
|
+
signal: verificationGateTimeoutSignal({ command, baseCommit: worktreeStart, stderr: stderrTail, stdout: stdoutTail }),
|
|
1390
|
+
idempotency_key: `bridge:verification-gate-timeout:${active.attemptId}`,
|
|
1391
|
+
},
|
|
1392
|
+
});
|
|
1393
|
+
// No agent ran and the tree stands at the base commit, so it holds no diagnostic value.
|
|
1394
|
+
if (response.retain_worktree !== true) {
|
|
1395
|
+
await removeAttemptWorktree(workspace, attemptWorkspace).catch(() => undefined);
|
|
1396
|
+
}
|
|
1397
|
+
console.error(`Assignment ${taskId} refused before the agent started: ${redactSecrets(detail)}`);
|
|
1398
|
+
return;
|
|
1399
|
+
}
|
|
1391
1400
|
if (dryRun.redBase) {
|
|
1392
1401
|
const { command, exitCode, stderrTail, stdoutTail } = dryRun.redBase;
|
|
1393
1402
|
const detail = `Verification fails on the base commit ${worktreeStart}: ${command} (exit ${exitCode ?? "unknown"})`;
|
|
@@ -1445,6 +1454,7 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
|
|
|
1445
1454
|
workspace: attemptWorkspace,
|
|
1446
1455
|
currentHead: worktreeStart,
|
|
1447
1456
|
reworkFeedback,
|
|
1457
|
+
resumeHead,
|
|
1448
1458
|
repairMode: task.repair_mode,
|
|
1449
1459
|
workPackage,
|
|
1450
1460
|
verificationCommands: attemptBrief?.verification ?? [],
|
|
@@ -1580,6 +1590,24 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
|
|
|
1580
1590
|
clearInterval(heartbeatTimer);
|
|
1581
1591
|
};
|
|
1582
1592
|
attemptDisposers.set(taskId, disposeTimers);
|
|
1593
|
+
// A run that spent Conduit's package budget keeps its work: the control plane retries it once, and
|
|
1594
|
+
// the retry resumes from the head reported here instead of starting over at the base. The tree
|
|
1595
|
+
// stays on this computer, where that head is always reachable.
|
|
1596
|
+
const reportTurnTimeout = async (message) => {
|
|
1597
|
+
retainAttemptWorktree = true;
|
|
1598
|
+
const resume = diagnosis || machineBoundTimeout(agentTimeout) ? null : await checkpointWorkInProgress({
|
|
1599
|
+
workspace: attemptWorkspace, spec, grants, baseCommit: deliveryBaseCommit,
|
|
1600
|
+
}).catch((error) => {
|
|
1601
|
+
console.error(`Work in progress was not committed: ${redactSecrets(error instanceof Error ? error.message : "unknown")}`);
|
|
1602
|
+
return null;
|
|
1603
|
+
});
|
|
1604
|
+
const response = await queueTerminal(client, taskId, {
|
|
1605
|
+
action: "fail",
|
|
1606
|
+
body: timeoutFailureBody(agentTimeout, active.attemptId, resume),
|
|
1607
|
+
});
|
|
1608
|
+
retainAttemptWorktree = Boolean(resume) || response.retain_worktree === true;
|
|
1609
|
+
console.error(`Assignment ${taskId} timed out: ${redactSecrets(message)}`);
|
|
1610
|
+
};
|
|
1583
1611
|
try {
|
|
1584
1612
|
// Resume for (1) Bridge restart with proven session+worktree, or (2) review rework.
|
|
1585
1613
|
// A fresh attempt after a failed delivery must start clean: resuming a session whose last
|
|
@@ -1728,13 +1756,7 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
|
|
|
1728
1756
|
if (result.status === "failed") {
|
|
1729
1757
|
const agentMessage = result.error ?? "Agent execution failed";
|
|
1730
1758
|
if (agentTurnTimedOut(agentMessage)) {
|
|
1731
|
-
|
|
1732
|
-
const response = await queueTerminal(client, taskId, {
|
|
1733
|
-
action: "fail",
|
|
1734
|
-
body: timeoutFailureBody(agentTimeout, active.attemptId),
|
|
1735
|
-
});
|
|
1736
|
-
retainAttemptWorktree = response.retain_worktree === true;
|
|
1737
|
-
console.error(`Assignment ${taskId} timed out: ${redactSecrets(agentMessage)}`);
|
|
1759
|
+
await reportTurnTimeout(agentMessage);
|
|
1738
1760
|
return;
|
|
1739
1761
|
}
|
|
1740
1762
|
// The agent verifies inside its own tool loop, so its compiler/test output never reaches
|
|
@@ -2315,13 +2337,7 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
|
|
|
2315
2337
|
catch (error) {
|
|
2316
2338
|
if (!(error instanceof AgentTurnTimeoutError))
|
|
2317
2339
|
throw error;
|
|
2318
|
-
|
|
2319
|
-
const response = await queueTerminal(client, taskId, {
|
|
2320
|
-
action: "fail",
|
|
2321
|
-
body: timeoutFailureBody(agentTimeout, active.attemptId),
|
|
2322
|
-
});
|
|
2323
|
-
retainAttemptWorktree = response.retain_worktree === true;
|
|
2324
|
-
console.error(`Assignment ${taskId} timed out: ${redactSecrets(error.message)}`);
|
|
2340
|
+
await reportTurnTimeout(error.message);
|
|
2325
2341
|
}
|
|
2326
2342
|
finally {
|
|
2327
2343
|
// The stall report already ended this attempt and its tree is the only evidence of the step
|
|
@@ -2888,15 +2904,28 @@ export function verificationScopeConflictDetail(paths, changeScope) {
|
|
|
2888
2904
|
export async function verificationScopeDryRun(input) {
|
|
2889
2905
|
const commands = verificationEvidenceCommands(input.verificationCommands, input.declaredVerificationCommands ?? [], input.acceptance ?? null);
|
|
2890
2906
|
if (!commands.length)
|
|
2891
|
-
return { ranNothing: null, redBase: null, outsideScope: [] };
|
|
2907
|
+
return { ranNothing: null, timedOut: null, redBase: null, outsideScope: [] };
|
|
2892
2908
|
const run = input.runCommand ?? runBoundedVerificationCommand;
|
|
2893
2909
|
let ranNothing = null;
|
|
2910
|
+
let timedOut = null;
|
|
2894
2911
|
let redBase = null;
|
|
2895
2912
|
for (const command of commands) {
|
|
2896
2913
|
// A gate that cannot start says nothing about the scope; the next one still can.
|
|
2897
2914
|
const result = await run(command, input.workspace).catch(() => null);
|
|
2898
2915
|
if (!result)
|
|
2899
2916
|
continue;
|
|
2917
|
+
// A gate that never exited says nothing about the base commit: it is not a red base, and it
|
|
2918
|
+
// does not go to the initiative author.
|
|
2919
|
+
if (result.timedOut) {
|
|
2920
|
+
if (!timedOut) {
|
|
2921
|
+
timedOut = {
|
|
2922
|
+
command,
|
|
2923
|
+
...(result.stderr.trim() ? { stderrTail: result.stderr } : {}),
|
|
2924
|
+
...(result.stdout.trim() ? { stdoutTail: result.stdout } : {}),
|
|
2925
|
+
};
|
|
2926
|
+
}
|
|
2927
|
+
continue;
|
|
2928
|
+
}
|
|
2900
2929
|
// Ahead of the exit code: a gate that discovered no test exits 5, and a no-op recipe exits 0
|
|
2901
2930
|
// with no output. Neither is a red base, so neither goes to the initiative author.
|
|
2902
2931
|
const reason = detectGateRanNothing(command, result.stdout, result.stderr, result.code)
|
|
@@ -2921,7 +2950,7 @@ export async function verificationScopeDryRun(input) {
|
|
|
2921
2950
|
const outsideScope = written === null
|
|
2922
2951
|
? []
|
|
2923
2952
|
: written.filter((path) => !input.changeScope.some((scope) => pathMatchesScope(path, scope)));
|
|
2924
|
-
return { ranNothing, redBase, outsideScope };
|
|
2953
|
+
return { ranNothing, timedOut, redBase, outsideScope };
|
|
2925
2954
|
}
|
|
2926
2955
|
/** Prompt and failure lines keep a short tail; the investigation brief caps each failure at 1,000. */
|
|
2927
2956
|
const DELIVERY_VERIFICATION_GATE_TAIL_CHARS = 800;
|
package/dist/failure-signal.js
CHANGED
|
@@ -399,9 +399,30 @@ export function verificationRedBaseSignal(input) {
|
|
|
399
399
|
...(input.stdout?.trim() ? { stdout_tail: bounded2k(input.stdout) } : {}),
|
|
400
400
|
};
|
|
401
401
|
}
|
|
402
|
+
/**
|
|
403
|
+
* The signal Bridge emits when the verification timeout stops a gate on the base commit, before the
|
|
404
|
+
* agent starts. The gate never exited, so it says nothing about the base: read as exit 1, a gate
|
|
405
|
+
* that hung (waiting for input or the network, or on a slow computer) was a red base on the author.
|
|
406
|
+
*/
|
|
407
|
+
export function verificationGateTimeoutSignal(input) {
|
|
408
|
+
return {
|
|
409
|
+
phase: "verification",
|
|
410
|
+
witness: "bridge",
|
|
411
|
+
kind: "timeout",
|
|
412
|
+
command: input.command,
|
|
413
|
+
base_commit: input.baseCommit,
|
|
414
|
+
exit_code: null,
|
|
415
|
+
...(input.stderr?.trim() ? { stderr_tail: bounded2k(input.stderr) } : {}),
|
|
416
|
+
...(input.stdout?.trim() ? { stdout_tail: bounded2k(input.stdout) } : {}),
|
|
417
|
+
};
|
|
418
|
+
}
|
|
402
419
|
/** A fact value as a person reads it on the card; an absent value says so in words. */
|
|
403
420
|
function factValue(value) {
|
|
404
|
-
return value === null ? "none" : value;
|
|
421
|
+
return value === null ? "none" : shortCommit(value);
|
|
422
|
+
}
|
|
423
|
+
/** A commit hash on the card is its first 7 characters; diagnostic_detail keeps the full value. */
|
|
424
|
+
function shortCommit(value) {
|
|
425
|
+
return /^[0-9a-f]{40}$/i.test(value) ? value.slice(0, 7) : value;
|
|
405
426
|
}
|
|
406
427
|
/** The vendor's own reason, kept inside the 2,000-char message bound; a stderr tail keeps its end. */
|
|
407
428
|
function quotedVendorText(signal) {
|
|
@@ -421,17 +442,19 @@ function quotedVendorText(signal) {
|
|
|
421
442
|
* 2. model_unbound → the tier has no other model to retry into: Hold, naming the tier and driver.
|
|
422
443
|
* 3. denied_tool / usage_error → the driver and vendor cannot run this assignment: Hold.
|
|
423
444
|
* 4. a changed pinned execution fact → the computer moved under the run: Hold, then Retry re-pins.
|
|
424
|
-
* 5. a gate
|
|
445
|
+
* 5. a gate the verification timeout stopped on the base commit → it never exited, so it says
|
|
446
|
+
* nothing about the base: Hold, on the computer operator, naming the gate.
|
|
447
|
+
* 6. a gate that already fails on the untouched base commit → no agent ran and none can repair a
|
|
425
448
|
* gate broken before it started: Hold, on the initiative author, naming the gate and the base.
|
|
426
|
-
*
|
|
449
|
+
* 7. verification whose gates wrote outside the approved change scope → the work package is the
|
|
427
450
|
* defect: Hold, on the initiative author, who opens a revision.
|
|
428
|
-
*
|
|
451
|
+
* 8. verification run by Bridge whose gate ran nothing → the gate is misconfigured, not failed:
|
|
429
452
|
* Hold, on the computer operator, naming the command and the file that declares it. Never a
|
|
430
453
|
* repair attempt: the delivery pack may be entirely correct.
|
|
431
|
-
*
|
|
432
|
-
*
|
|
433
|
-
*
|
|
434
|
-
*
|
|
454
|
+
* 9. verification run by Bridge, any other cause → the verify class: diagnose and brief a repair.
|
|
455
|
+
* 10. contract, or a delivery_report phase → the delivery contract codes: rework.
|
|
456
|
+
* 11. timeout → the turn reached its execution limit: stop.
|
|
457
|
+
* 12. turn_cap → the run reached the computer's per-attempt turn cap: Hold, on the operator, who
|
|
435
458
|
* decides whether the work was looping or the cap is too tight. Never a silent retry.
|
|
436
459
|
*/
|
|
437
460
|
export function classifyFailureSignal(signal) {
|
|
@@ -532,7 +555,22 @@ export function classifyFailureSignal(signal) {
|
|
|
532
555
|
responsible_party: "computer_operator",
|
|
533
556
|
message: `The computer changed ${fact.name} while the work package ran, so the run no longer matched the work it claimed. It started with ${factValue(fact.pinned)} and found ${factValue(fact.observed)}.`,
|
|
534
557
|
next_action: "Let the workspace settle on this computer, then Retry: the next attempt pins the facts again from the workspace as it is now.",
|
|
535
|
-
diagnostic_detail: `${fact.name}: pinned ${
|
|
558
|
+
diagnostic_detail: `${fact.name}: pinned ${fact.pinned ?? "none"}, found ${fact.observed ?? "none"}`,
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
// Ahead of the red-base and generic verification rules: the gate never exited, so neither the
|
|
562
|
+
// base commit nor a change is known to be at fault. The generic rule read it as a failed test.
|
|
563
|
+
if (signal.phase === "verification" && signal.kind === "timeout") {
|
|
564
|
+
const command = signal.command ?? "the declared gate";
|
|
565
|
+
const base = signal.base_commit ? shortCommit(signal.base_commit) : "the base commit";
|
|
566
|
+
return {
|
|
567
|
+
code: "verification_gate_timeout",
|
|
568
|
+
class: "environment",
|
|
569
|
+
disposition: "hold",
|
|
570
|
+
responsible_party: "computer_operator",
|
|
571
|
+
message: `The verification gate \`${command}\` did not finish on the base commit ${base} within Bridge's 10-minute limit, so Bridge stopped it before any change was made.`,
|
|
572
|
+
next_action: `Run \`${command}\` on this computer and find where it hangs — a test that waits for input or the network, or a computer too slow for the gate — then fix it and Recheck. Conduit does not retry or repair a gate that does not finish.`,
|
|
573
|
+
diagnostic_detail: [`$ ${command}`, "stopped after 10 minutes", signal.stderr_tail, signal.stdout_tail].filter(Boolean).join("\n"),
|
|
536
574
|
};
|
|
537
575
|
}
|
|
538
576
|
// Ahead of the scope and generic verification rules: the base commit itself does not pass the
|
|
@@ -542,7 +580,7 @@ export function classifyFailureSignal(signal) {
|
|
|
542
580
|
if (signal.phase === "verification" && signal.kind === "contract"
|
|
543
581
|
&& signal.vendor_message === VERIFICATION_FAILS_ON_BASE) {
|
|
544
582
|
const command = signal.command ?? "the declared gate";
|
|
545
|
-
const base = signal.base_commit
|
|
583
|
+
const base = signal.base_commit ? shortCommit(signal.base_commit) : "the base commit";
|
|
546
584
|
const output = [signal.stderr_tail, signal.stdout_tail].filter(Boolean).join("\n");
|
|
547
585
|
// T198: a gate that could not start (`sh: tsc: not found`, exit 127) says nothing about the
|
|
548
586
|
// base commit. It is the computer's checkout that lacks a tool — an environment Hold that
|
package/dist/git-witness.js
CHANGED
|
@@ -6,14 +6,16 @@ import { createHash } from "node:crypto";
|
|
|
6
6
|
import { lstat, readFile, readlink } from "node:fs/promises";
|
|
7
7
|
import { join } from "node:path";
|
|
8
8
|
import { promisify } from "node:util";
|
|
9
|
+
import { uncommittedPaths } from "./git.js";
|
|
9
10
|
const execFileAsync = promisify(execFile);
|
|
10
|
-
|
|
11
|
+
/** NUL-delimited git output as a list; git quotes and escapes unusual names in line output. */
|
|
12
|
+
async function gitFields(worktree, args) {
|
|
11
13
|
try {
|
|
12
14
|
const { stdout } = await execFileAsync("git", ["-C", worktree, ...args], {
|
|
13
15
|
timeout: 30_000,
|
|
14
16
|
maxBuffer: 8_000_000,
|
|
15
17
|
});
|
|
16
|
-
return stdout.split("\
|
|
18
|
+
return stdout.split("\0").filter(Boolean);
|
|
17
19
|
}
|
|
18
20
|
catch {
|
|
19
21
|
return null;
|
|
@@ -24,13 +26,10 @@ async function gitLines(worktree, args) {
|
|
|
24
26
|
* Null when git cannot answer — callers must not treat null as failure.
|
|
25
27
|
*/
|
|
26
28
|
export async function changedPathsSince(worktree, startCommit) {
|
|
27
|
-
const committed = await
|
|
29
|
+
const committed = await gitFields(worktree, ["diff", "--name-only", "-z", `${startCommit}..HEAD`]);
|
|
28
30
|
if (committed === null)
|
|
29
31
|
return null;
|
|
30
32
|
const paths = new Set(committed);
|
|
31
|
-
// T133: porcelain lines must not be trimmed before the status columns are cut off. `gitLines`
|
|
32
|
-
// trims, so an unstaged modification (" M bureau/x.py") lost its first path character and the
|
|
33
|
-
// first production verification was discarded naming "ureau/claim_rules.py".
|
|
34
33
|
const pending = await worktreeWrittenPaths(worktree);
|
|
35
34
|
for (const path of pending ?? [])
|
|
36
35
|
paths.add(path);
|
|
@@ -46,28 +45,12 @@ export async function changedPathsSince(worktree, startCommit) {
|
|
|
46
45
|
* Null when git cannot answer — callers must not treat null as a violation.
|
|
47
46
|
*/
|
|
48
47
|
export async function worktreeWrittenPaths(worktree) {
|
|
49
|
-
let stdout;
|
|
50
48
|
try {
|
|
51
|
-
|
|
52
|
-
timeout: 30_000,
|
|
53
|
-
maxBuffer: 8_000_000,
|
|
54
|
-
}));
|
|
49
|
+
return [...new Set(await uncommittedPaths(worktree, { untracked: "all" }))];
|
|
55
50
|
}
|
|
56
51
|
catch {
|
|
57
52
|
return null;
|
|
58
53
|
}
|
|
59
|
-
const paths = new Set();
|
|
60
|
-
for (const line of stdout.split("\n")) {
|
|
61
|
-
// Porcelain v1 gives two status letters, a space, then the path. A rename names the source
|
|
62
|
-
// after " -> "; the destination is the file the command wrote.
|
|
63
|
-
if (line.length <= 3)
|
|
64
|
-
continue;
|
|
65
|
-
const entry = line.slice(3).trim();
|
|
66
|
-
const path = entry.includes(" -> ") ? entry.slice(entry.lastIndexOf(" -> ") + 4).trim() : entry;
|
|
67
|
-
if (path)
|
|
68
|
-
paths.add(path.replace(/^"|"$/g, ""));
|
|
69
|
-
}
|
|
70
|
-
return [...paths];
|
|
71
54
|
}
|
|
72
55
|
/**
|
|
73
56
|
* Exact Git-visible state of a retained worktree, including the contents of untracked files.
|
package/dist/git.js
CHANGED
|
@@ -6,6 +6,31 @@
|
|
|
6
6
|
import { execFile } from "node:child_process";
|
|
7
7
|
import { promisify } from "node:util";
|
|
8
8
|
const execFileAsync = promisify(execFile);
|
|
9
|
+
/**
|
|
10
|
+
* Every path `git status` reports as changed in the workspace: modified, staged, deleted, renamed
|
|
11
|
+
* (the destination) or untracked. The output is NUL-delimited, so a name git would otherwise quote
|
|
12
|
+
* and escape (`中文.txt` as `"\344\270\255\346\226\207.txt"`) comes back verbatim and can be
|
|
13
|
+
* handed back to git (review 2026-10-03, finding 3).
|
|
14
|
+
*/
|
|
15
|
+
export async function uncommittedPaths(workspace, options = {}) {
|
|
16
|
+
const { stdout } = await execFileAsync("git", ["-C", workspace, "status", "--porcelain=v1", "-z", `--untracked-files=${options.untracked ?? "normal"}`], { timeout: 30_000, maxBuffer: 8_000_000 });
|
|
17
|
+
return porcelainPaths(stdout);
|
|
18
|
+
}
|
|
19
|
+
/** Porcelain v1 with `-z`: `XY path`, and a rename or copy adds its source as the next field. */
|
|
20
|
+
export function porcelainPaths(stdout) {
|
|
21
|
+
const fields = stdout.split("\0");
|
|
22
|
+
const paths = [];
|
|
23
|
+
for (let index = 0; index < fields.length; index++) {
|
|
24
|
+
const entry = fields[index];
|
|
25
|
+
if (entry.length < 4)
|
|
26
|
+
continue;
|
|
27
|
+
const status = entry.slice(0, 2);
|
|
28
|
+
paths.push(entry.slice(3));
|
|
29
|
+
if (status.includes("R") || status.includes("C"))
|
|
30
|
+
index++;
|
|
31
|
+
}
|
|
32
|
+
return paths;
|
|
33
|
+
}
|
|
9
34
|
/** A full object name, lowercased, or null when the text is not one. */
|
|
10
35
|
export function commitOrNull(value) {
|
|
11
36
|
const text = value?.trim() ?? "";
|
package/dist/land-git-reality.js
CHANGED
|
@@ -2,33 +2,11 @@
|
|
|
2
2
|
* Claims vs git — detect when an agent's Delivery narrative claims repository work
|
|
3
3
|
* that git cannot see on the attempt branch.
|
|
4
4
|
*/
|
|
5
|
-
import { execFile } from "node:child_process";
|
|
6
|
-
import { promisify } from "node:util";
|
|
7
5
|
import { commitsAheadOfBase } from "./ensure-pull-request.js";
|
|
8
6
|
import { extractAgentReportJsonText, parseJsonObjectCandidate } from "./driver.js";
|
|
7
|
+
import { uncommittedPaths as listUncommittedPaths } from "./git.js";
|
|
9
8
|
import { filterPathsInScope, requiresLandCommit, } from "./land-contract.js";
|
|
10
|
-
const execFileAsync = promisify(execFile);
|
|
11
9
|
const NO_CHANGE_OUTCOME = /no code changes|no changes were made|working tree clean|did not (?:make|land)|nothing to (?:change|deliver)|no commit was made/i;
|
|
12
|
-
async function listUncommittedPaths(workspace) {
|
|
13
|
-
const { stdout } = await execFileAsync("git", ["-C", workspace, "status", "--porcelain"], {
|
|
14
|
-
timeout: 30_000,
|
|
15
|
-
maxBuffer: 8_000_000,
|
|
16
|
-
});
|
|
17
|
-
const paths = [];
|
|
18
|
-
for (const line of stdout.split("\n")) {
|
|
19
|
-
if (!line.trim())
|
|
20
|
-
continue;
|
|
21
|
-
const entry = line.slice(3).trim();
|
|
22
|
-
if (!entry)
|
|
23
|
-
continue;
|
|
24
|
-
const path = entry.includes(" -> ")
|
|
25
|
-
? entry.slice(entry.lastIndexOf(" -> ") + 4).trim()
|
|
26
|
-
: entry;
|
|
27
|
-
if (path)
|
|
28
|
-
paths.push(path.replace(/^"|"$/g, ""));
|
|
29
|
-
}
|
|
30
|
-
return paths;
|
|
31
|
-
}
|
|
32
10
|
/** True when the land contract applies and git shows no commit and no scoped dirty files. */
|
|
33
11
|
export async function isLandTreeEmpty(input) {
|
|
34
12
|
if (!requiresLandCommit({ grants: input.grants, spec: input.spec }))
|
package/dist/ops.js
CHANGED
|
@@ -191,14 +191,6 @@ export function resolveInstallDrivers(env, argv, detect = detectInstalledClients
|
|
|
191
191
|
return [preferred];
|
|
192
192
|
});
|
|
193
193
|
}
|
|
194
|
-
/** Quote args for a copy-pasteable shell/cmd line (paths with spaces). */
|
|
195
|
-
export function shellQuoteArgs(args) {
|
|
196
|
-
return args.map((arg) => {
|
|
197
|
-
if (!/[^\w@%+=:,./\\-]/i.test(arg))
|
|
198
|
-
return arg;
|
|
199
|
-
return `"${arg.replaceAll('"', '\\"')}"`;
|
|
200
|
-
}).join(" ");
|
|
201
|
-
}
|
|
202
194
|
/** Ask the operator to approve a step that cannot be undone. Non-interactive shells must pass --yes. */
|
|
203
195
|
async function confirmOps(question) {
|
|
204
196
|
if (!input.isTTY || !output.isTTY) {
|
package/dist/preflight.js
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
3
5
|
import { promisify } from "node:util";
|
|
4
6
|
import { buildWorkspaceBrief, normalizeRepositoryUrl } from "./brief.js";
|
|
5
|
-
import {
|
|
7
|
+
import { bridgeConfigDirectory } from "./config.js";
|
|
8
|
+
import { runBoundedVerificationCommand, verificationEvidenceCommands } from "./ensure-test-evidence.js";
|
|
9
|
+
import { resolveAgentTimeout, resolveMaxTurns } from "./execution-budget.js";
|
|
6
10
|
import { hasAntigravityLogin, hasClaudeLogin, hasGrokLogin, hasOpenAiLogin, hasOpenCodeLogin, driverExecutable, readSignInStatus, } from "./driver.js";
|
|
7
11
|
import { laneQuota, laneTierBindings, localFuelOnlyDriver, onlineDriverIds, resolveDriverFuel, supportsReadOnlyDiagnosis } from "./drivers.js";
|
|
8
12
|
/** Protocol 3: read-only diagnosis can reuse a retained failed-attempt worktree. */
|
|
@@ -256,7 +260,8 @@ export async function runBridgePreflight(input, deps = {}) {
|
|
|
256
260
|
}
|
|
257
261
|
return { ready: issues.length === 0, checked_at: new Date().toISOString(), workspace_clean: workspaceClean,
|
|
258
262
|
drivers: driverChecks.map((check) => check.snapshot), models_fingerprint: modelsFingerprint(input.config),
|
|
259
|
-
max_turns: resolveMaxTurns(input.config.maxTurns),
|
|
263
|
+
max_turns: resolveMaxTurns(input.config.maxTurns),
|
|
264
|
+
agent_timeout_ms: resolveAgentTimeout({ machineCeilingMs: input.agentTimeoutMs }).timeoutMs, issues,
|
|
260
265
|
...(input.managedRoot?.trim() ? { managed_root: input.managedRoot.trim() } : {}) };
|
|
261
266
|
}
|
|
262
267
|
let cached = null;
|
|
@@ -269,7 +274,7 @@ export const PREFLIGHT_CACHE_TTL_MS = 5 * 60_000;
|
|
|
269
274
|
export async function cachedBridgePreflight(input, options = {}) {
|
|
270
275
|
const online = input.processOnlineIds?.length ? input.processOnlineIds : onlineDriverIds(input.config);
|
|
271
276
|
const key = JSON.stringify([input.workspace, input.expectedRepository ?? "", online, input.config.fuelSource, input.config.drivers, input.config.maxTurns ?? 0,
|
|
272
|
-
input.refresh?.state ?? "", input.refresh?.local_commits ?? 0, input.managedRoot ?? ""]);
|
|
277
|
+
input.agentTimeoutMs ?? 0, input.refresh?.state ?? "", input.refresh?.local_commits ?? 0, input.managedRoot ?? ""]);
|
|
273
278
|
if (!options.force && cached?.key === key && Date.now() - cached.at < PREFLIGHT_CACHE_TTL_MS)
|
|
274
279
|
return cached.report;
|
|
275
280
|
const report = await runBridgePreflight(input);
|
|
@@ -291,10 +296,86 @@ export function unavailableWorkspacePreflight(input) {
|
|
|
291
296
|
tiers: laneTierBindings(input.config, id) })),
|
|
292
297
|
models_fingerprint: modelsFingerprint(input.config),
|
|
293
298
|
max_turns: resolveMaxTurns(input.config.maxTurns),
|
|
299
|
+
agent_timeout_ms: resolveAgentTimeout({ machineCeilingMs: input.agentTimeoutMs }).timeoutMs,
|
|
294
300
|
issues,
|
|
295
301
|
...(input.managedRoot?.trim() ? { managed_root: input.managedRoot.trim() } : {}),
|
|
296
302
|
};
|
|
297
303
|
}
|
|
304
|
+
export function gateRehearsalStatePath() {
|
|
305
|
+
return join(bridgeConfigDirectory(), "gate-rehearsal.json");
|
|
306
|
+
}
|
|
307
|
+
async function readGateRehearsals(statePath) {
|
|
308
|
+
try {
|
|
309
|
+
const parsed = JSON.parse(await readFile(statePath, "utf8"));
|
|
310
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
311
|
+
}
|
|
312
|
+
catch {
|
|
313
|
+
return {};
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
/** UTC calendar day: a push to main costs no run, and a new day measures the cache again. */
|
|
317
|
+
function rehearsalDay(at) {
|
|
318
|
+
return new Date(at).toISOString().slice(0, 10);
|
|
319
|
+
}
|
|
320
|
+
/** The last rehearsal of this workspace's repository, and the gate to run when one is due today. */
|
|
321
|
+
export async function planGateRehearsal(brief, options = {}) {
|
|
322
|
+
const commands = verificationEvidenceCommands(brief.verification, brief.declared_verification, null);
|
|
323
|
+
if (!brief.repository || !brief.base_commit || !commands.length)
|
|
324
|
+
return { report: null, due: null };
|
|
325
|
+
const repository = normalizeRepositoryUrl(brief.repository);
|
|
326
|
+
const stored = (await readGateRehearsals(options.statePath ?? gateRehearsalStatePath()))[repository] ?? null;
|
|
327
|
+
const digest = createHash("sha256").update(commands.join("\n")).digest("hex").slice(0, 16);
|
|
328
|
+
const day = rehearsalDay((options.now ?? Date.now)());
|
|
329
|
+
if (stored && stored.day === day && stored.digest === digest)
|
|
330
|
+
return { report: stored.rehearsal, due: null };
|
|
331
|
+
return { report: stored?.rehearsal ?? null, due: { repository, commit: brief.base_commit, commands, digest } };
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* One runner cycle's view of every on-shift checkout: the rehearsals to report, and the first
|
|
335
|
+
* checkout whose gate is due. One repository per cycle, so a waiting assignment waits for one gate.
|
|
336
|
+
* The caller passes only checkouts that may run now (clean, not diverged) as `eligible`.
|
|
337
|
+
*/
|
|
338
|
+
export async function planGateRehearsals(checkouts, options = {}) {
|
|
339
|
+
const reports = [];
|
|
340
|
+
const seen = new Set();
|
|
341
|
+
let due = null;
|
|
342
|
+
for (const checkout of checkouts) {
|
|
343
|
+
const planned = await planGateRehearsal(checkout.brief, options);
|
|
344
|
+
const repository = planned.report?.repository ?? planned.due?.repository;
|
|
345
|
+
if (!repository || seen.has(repository))
|
|
346
|
+
continue;
|
|
347
|
+
seen.add(repository);
|
|
348
|
+
if (planned.report)
|
|
349
|
+
reports.push(planned.report);
|
|
350
|
+
if (!due && planned.due && checkout.eligible)
|
|
351
|
+
due = { workspace: checkout.workspace, plan: planned.due };
|
|
352
|
+
}
|
|
353
|
+
return { reports, due };
|
|
354
|
+
}
|
|
355
|
+
/** Run the planned gate once and remember it for the day. Null when no command could start. */
|
|
356
|
+
export async function runGateRehearsal(workspace, plan, options = {}) {
|
|
357
|
+
const now = options.now ?? Date.now;
|
|
358
|
+
const run = options.runCommand ?? runBoundedVerificationCommand;
|
|
359
|
+
const commands = [];
|
|
360
|
+
for (const command of plan.commands) {
|
|
361
|
+
const started = now();
|
|
362
|
+
// A command that cannot start measured nothing; the dry run skips it the same way.
|
|
363
|
+
const result = await run(command, workspace).catch(() => null);
|
|
364
|
+
if (!result)
|
|
365
|
+
continue;
|
|
366
|
+
commands.push({ command, seconds: Math.round((now() - started) / 1_000), exit_code: result.code, timed_out: result.timedOut === true });
|
|
367
|
+
}
|
|
368
|
+
const at = now();
|
|
369
|
+
const rehearsal = commands.length
|
|
370
|
+
? { repository: plan.repository, commit: plan.commit, rehearsed_at: new Date(at).toISOString(), commands }
|
|
371
|
+
: null;
|
|
372
|
+
const statePath = options.statePath ?? gateRehearsalStatePath();
|
|
373
|
+
const state = await readGateRehearsals(statePath);
|
|
374
|
+
state[plan.repository] = { day: rehearsalDay(at), digest: plan.digest, rehearsal };
|
|
375
|
+
await mkdir(dirname(statePath), { recursive: true });
|
|
376
|
+
await writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
|
|
377
|
+
return rehearsal;
|
|
378
|
+
}
|
|
298
379
|
export function describePreflightIssue(issue) {
|
|
299
380
|
const lane = issue.driver ? ` (${issue.driver})` : "";
|
|
300
381
|
if (issue.code === "no_online_driver")
|
package/dist/service.js
CHANGED
|
@@ -262,7 +262,6 @@ export function runnerServiceWorkspaceWarnings(declaredWorkspace, home = homedir
|
|
|
262
262
|
}
|
|
263
263
|
return warnings;
|
|
264
264
|
}
|
|
265
|
-
export const WINDOWS_TASK_NAME = "ConduitBridgeRunner";
|
|
266
265
|
/** Quote one argv token for a Windows Task Scheduler /TR command line. */
|
|
267
266
|
export function quoteWindowsTaskArg(value) {
|
|
268
267
|
if (!/[\s&<>|^()"]/.test(value))
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@miraland-labs/conduit-bridge",
|
|
3
|
-
"version": "0.16.
|
|
3
|
+
"version": "0.16.147",
|
|
4
4
|
"description": "Conduit Bridge CLI \u2014 join, connect, disconnect, multi-driver lanes, and run Claude Code / Codex / Cursor / OpenCode / Pi / Kiro / Antigravity / Grok Build agents for a Conduit organization",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|