@miraland-labs/conduit-bridge 0.16.143 → 0.16.148
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +80 -11
- package/dist/driver.js +9 -1
- package/dist/ensure-land-commit.js +42 -24
- package/dist/ensure-test-evidence.js +38 -8
- package/dist/execution.js +46 -20
- package/dist/failure-signal.js +8 -4
- package/dist/git-witness.js +6 -23
- package/dist/git.js +25 -0
- package/dist/land-git-reality.js +1 -23
- package/dist/preflight.js +84 -3
- package/package.json +1 -1
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/driver.js
CHANGED
|
@@ -157,6 +157,8 @@ function resolveRunClass(input) {
|
|
|
157
157
|
return input.executionClass;
|
|
158
158
|
throw new Error("Driver run is missing stamped execution_class. Pass executionClass from the assignment stamp.");
|
|
159
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";
|
|
160
162
|
export function buildAssignmentPrompt(context) {
|
|
161
163
|
const packageContext = context.workPackage;
|
|
162
164
|
const spec = context.spec;
|
|
@@ -246,6 +248,9 @@ export function buildAssignmentPrompt(context) {
|
|
|
246
248
|
: "REWORK FEEDBACK — an independent review returned this delivery; address every point";
|
|
247
249
|
lines.push("", `${heading}\n${rework}`);
|
|
248
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
|
+
}
|
|
249
254
|
const mustCommit = context.grants.includes("repo_write") && context.grants.includes("branch_create");
|
|
250
255
|
const mustOpenPr = mustCommit && context.grants.includes("pr_create");
|
|
251
256
|
const executionClass = context.executionClass
|
|
@@ -283,7 +288,10 @@ export function buildAssignmentPrompt(context) {
|
|
|
283
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.",
|
|
284
289
|
// A code can only say the class. The owner reads a sentence, and the sentence needs the
|
|
285
290
|
// particular: which app, which authority, which account. The run already knows it.
|
|
286
|
-
"- 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);
|
|
287
295
|
return lines.join("\n");
|
|
288
296
|
}
|
|
289
297
|
/**
|
|
@@ -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
|
+
}
|
|
@@ -208,7 +208,7 @@ export async function captureVerificationFailure(input) {
|
|
|
208
208
|
// Same rule as the pre-delivery gate: this is the diagnosis a repair brief is written from, so
|
|
209
209
|
// it must keep the end. It carried its own head-truncating copy of that logic until the two were
|
|
210
210
|
// shown to be the same bug.
|
|
211
|
-
const text = boundedFailureDetail(command,
|
|
211
|
+
const text = boundedFailureDetail(command, outputLines(result), result.code, DIAGNOSIS_DETAIL_MAX_CHARS);
|
|
212
212
|
if (text.trim())
|
|
213
213
|
return { command, detail: text };
|
|
214
214
|
}
|
|
@@ -263,6 +263,14 @@ export function boundedTail(text, maxChars) {
|
|
|
263
263
|
return text.slice(text.length - maxChars);
|
|
264
264
|
return `${text.slice(0, head)}${marker}${text.slice(text.length - tail)}`;
|
|
265
265
|
}
|
|
266
|
+
/**
|
|
267
|
+
* The lines of a run in the order the command wrote them. A run without the combined output (a
|
|
268
|
+
* test's fake runner) reads as stdout followed by stderr.
|
|
269
|
+
*/
|
|
270
|
+
function outputLines(result) {
|
|
271
|
+
const split = (text) => (text.trim() ? text.trim().split("\n") : []);
|
|
272
|
+
return result.output === undefined ? [...split(result.stdout), ...split(result.stderr)] : split(result.output);
|
|
273
|
+
}
|
|
266
274
|
/**
|
|
267
275
|
* A failure detail that keeps the part which explains the failure.
|
|
268
276
|
*
|
|
@@ -275,10 +283,13 @@ export function boundedTail(text, maxChars) {
|
|
|
275
283
|
* So: the command line for orientation, a few opening lines, then the **tail**, and the exit code
|
|
276
284
|
* always. What was dropped is stated rather than silently vanishing, because a reader must be able
|
|
277
285
|
* to tell a short failure from a truncated one.
|
|
286
|
+
*
|
|
287
|
+
* The lines are in the order the command wrote them. Joining all stdout before all stderr made the
|
|
288
|
+
* tail of a failed `cargo test` its stderr `Running …` lines and elided the panic on stdout.
|
|
278
289
|
*/
|
|
279
|
-
export function boundedFailureDetail(command,
|
|
290
|
+
export function boundedFailureDetail(command, lines, code, maxChars = FAILURE_DETAIL_MAX_CHARS) {
|
|
280
291
|
const cap = (line) => (line.length > 500 ? `${line.slice(0, 500)}…` : line);
|
|
281
|
-
const body =
|
|
292
|
+
const body = lines.map(cap);
|
|
282
293
|
const first = `$ ${command}`;
|
|
283
294
|
const last = `exit ${code}`;
|
|
284
295
|
const marker = (dropped) => `… ${dropped} line${dropped === 1 ? "" : "s"} omitted …`;
|
|
@@ -322,20 +333,36 @@ export function boundedFailureDetail(command, stdoutLines, stderrLines, code, ma
|
|
|
322
333
|
*
|
|
323
334
|
* A gate the timeout stopped did not exit: `timedOut` says so, and stderr ends with the limit it
|
|
324
335
|
* reached. Read as exit 1, a gate that hung on the untouched base was a red base on the author.
|
|
336
|
+
*
|
|
337
|
+
* `output` is stdout and stderr together, in the order the lines arrived: a failure detail reads its
|
|
338
|
+
* tail from it.
|
|
325
339
|
*/
|
|
326
340
|
export async function runBoundedVerificationCommand(command, workspace, timeoutMs = VERIFICATION_TIMEOUT_MS) {
|
|
327
341
|
const argv = argvForBoundedCommand(command);
|
|
328
342
|
const bin = argv[0];
|
|
329
343
|
if (!bin)
|
|
330
344
|
throw new Error("Verification command is empty");
|
|
345
|
+
// Chunks are not whole lines. Each stream keeps its partial last line until the line ends, so a
|
|
346
|
+
// stderr write between two stdout chunks cannot split a stdout line.
|
|
347
|
+
const lines = [];
|
|
348
|
+
const partial = { stdout: "", stderr: "" };
|
|
349
|
+
const collect = (stream) => (chunk) => {
|
|
350
|
+
const parts = `${partial[stream]}${String(chunk)}`.split("\n");
|
|
351
|
+
partial[stream] = parts.pop() ?? "";
|
|
352
|
+
lines.push(...parts);
|
|
353
|
+
};
|
|
354
|
+
const output = () => [...lines, ...[partial.stdout, partial.stderr].filter(Boolean)].join("\n");
|
|
331
355
|
try {
|
|
332
|
-
const
|
|
356
|
+
const pending = execFileAsync(bin, argv.slice(1), {
|
|
333
357
|
cwd: workspace,
|
|
334
358
|
timeout: timeoutMs,
|
|
335
359
|
maxBuffer: VERIFICATION_MAX_BUFFER_BYTES,
|
|
336
360
|
env: process.env,
|
|
337
361
|
});
|
|
338
|
-
|
|
362
|
+
pending.child.stdout?.on("data", collect("stdout"));
|
|
363
|
+
pending.child.stderr?.on("data", collect("stderr"));
|
|
364
|
+
const { stdout, stderr } = await pending;
|
|
365
|
+
return { stdout: String(stdout), stderr: String(stderr), output: output(), code: 0 };
|
|
339
366
|
}
|
|
340
367
|
catch (error) {
|
|
341
368
|
const err = error;
|
|
@@ -347,9 +374,11 @@ export async function runBoundedVerificationCommand(command, workspace, timeoutM
|
|
|
347
374
|
if (typeof err.code === "string")
|
|
348
375
|
throw error;
|
|
349
376
|
if (err.killed === true) {
|
|
377
|
+
const stopped = `Bridge stopped the gate: it did not exit within ${Math.round(timeoutMs / 1_000)} seconds.`;
|
|
350
378
|
return {
|
|
351
379
|
stdout: String(err.stdout ?? ""),
|
|
352
|
-
stderr: `${String(err.stderr ?? "")}\
|
|
380
|
+
stderr: `${String(err.stderr ?? "")}\n${stopped}`.trimStart(),
|
|
381
|
+
output: `${output()}\n${stopped}`.trimStart(),
|
|
353
382
|
code: 1,
|
|
354
383
|
timedOut: true,
|
|
355
384
|
};
|
|
@@ -358,6 +387,7 @@ export async function runBoundedVerificationCommand(command, workspace, timeoutM
|
|
|
358
387
|
return {
|
|
359
388
|
stdout: String(err.stdout ?? ""),
|
|
360
389
|
stderr,
|
|
390
|
+
output: output() || stderr,
|
|
361
391
|
code: typeof err.code === "number" ? err.code : 1,
|
|
362
392
|
};
|
|
363
393
|
}
|
|
@@ -536,12 +566,12 @@ export async function ensureTestEvidence(input) {
|
|
|
536
566
|
// and would otherwise be witnessed as a passing test run that tested nothing.
|
|
537
567
|
const ranNothing = detectGateRanNothing(command, result.stdout, result.stderr, result.code);
|
|
538
568
|
if (ranNothing) {
|
|
539
|
-
const detail = boundedFailureDetail(command,
|
|
569
|
+
const detail = boundedFailureDetail(command, outputLines(result), result.code);
|
|
540
570
|
throw new VerificationGateRanNothingError(command, result.code, ranNothing, detail);
|
|
541
571
|
}
|
|
542
572
|
if (result.code !== 0) {
|
|
543
573
|
// Prefix must match FINALIZE_CONTRACT_PATTERN ("Agent report") — not Bridge-fault laundering.
|
|
544
|
-
const detail = boundedFailureDetail(command,
|
|
574
|
+
const detail = boundedFailureDetail(command, outputLines(result), result.code);
|
|
545
575
|
// One line an operator can grep when a stored failure looks head-truncated. Reading the shape
|
|
546
576
|
// back out of the database could not distinguish "the fix did not run" from "something
|
|
547
577
|
// downstream re-truncated it"; this says which, at the moment it is produced.
|
package/dist/execution.js
CHANGED
|
@@ -23,8 +23,8 @@ 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);
|
|
@@ -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),
|
|
@@ -518,7 +526,9 @@ export function buildDiagnosticPrompt(input) {
|
|
|
518
526
|
...(decisions.length ? ["", `OWNER DECISIONS\n${decisions.map((item) => `- ${item.question}: ${item.decision}`).join("\n")}`] : []),
|
|
519
527
|
...(input.verificationCommands.length ? ["", `BOUNDED VERIFICATION COMMANDS\n${input.verificationCommands.map((item) => `- ${item}`).join("\n")}`] : []),
|
|
520
528
|
...(gate ? ["", `GATE — Bridge re-runs \`${gate}\` after the next run; the agent cannot change or replace it.`
|
|
521
|
-
+ "
|
|
529
|
+
+ " A fix that needs a file outside the APPROVED CHANGE SCOPE is still a grounded diagnosis."
|
|
530
|
+
+ " Answer confident:true and write the full repository path of each such file in fix_direction. Conductor makes it a scope decision."
|
|
531
|
+
+ " Answer confident:false only when no file change can fix the cause (the gate itself, the environment), and name the gate in root_cause."] : []),
|
|
522
532
|
"",
|
|
523
533
|
"WITNESSED FAILURE OUTPUT",
|
|
524
534
|
redactSecrets(input.failureOutput),
|
|
@@ -1170,6 +1180,13 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
|
|
|
1170
1180
|
?? (rework
|
|
1171
1181
|
? (executionContract.requested_base_commit ?? spec.repository?.base_commit ?? worktreeStart)
|
|
1172
1182
|
: worktreeStart);
|
|
1183
|
+
// Only when the worktree really stands at the timed-out run's head: a computer that cannot reach
|
|
1184
|
+
// that commit starts at the base (resolveAttemptStartCommit), and the agent must not be told the
|
|
1185
|
+
// work is there.
|
|
1186
|
+
const resumeHead = !diagnosis && executionContract.resume_from_attempt_id && executionContract.prior_head_commit
|
|
1187
|
+
&& commitsMatch(executionContract.prior_head_commit, worktreeStart)
|
|
1188
|
+
? executionContract.prior_head_commit
|
|
1189
|
+
: null;
|
|
1173
1190
|
let normativeMaterialized = [];
|
|
1174
1191
|
if (!diagnosis) {
|
|
1175
1192
|
const normativeRefs = parseNormativeRefs(spec.normative_refs);
|
|
@@ -1318,10 +1335,12 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
|
|
|
1318
1335
|
// tested nothing is the earliest fact and refuses first; then a gate that never exited; then a gate
|
|
1319
1336
|
// already red on the untouched base; only a green base still runs the scope check, which refuses
|
|
1320
1337
|
// when a gate wrote outside the approved change scope.
|
|
1338
|
+
// A resumed tree stands at the timed-out run's unfinished work, where red gates are expected; the
|
|
1339
|
+
// timed-out run already passed these checks on the base.
|
|
1321
1340
|
if (dryRunsVerification({
|
|
1322
1341
|
executionKind,
|
|
1323
1342
|
executionClass,
|
|
1324
|
-
freshWorktree: options.freshWorktree ?? !options.existingWorktree,
|
|
1343
|
+
freshWorktree: (options.freshWorktree ?? !options.existingWorktree) && !resumeHead,
|
|
1325
1344
|
})) {
|
|
1326
1345
|
watchdog.phase("verification");
|
|
1327
1346
|
const dryRun = await verificationScopeDryRun({
|
|
@@ -1437,6 +1456,7 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
|
|
|
1437
1456
|
workspace: attemptWorkspace,
|
|
1438
1457
|
currentHead: worktreeStart,
|
|
1439
1458
|
reworkFeedback,
|
|
1459
|
+
resumeHead,
|
|
1440
1460
|
repairMode: task.repair_mode,
|
|
1441
1461
|
workPackage,
|
|
1442
1462
|
verificationCommands: attemptBrief?.verification ?? [],
|
|
@@ -1572,6 +1592,24 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
|
|
|
1572
1592
|
clearInterval(heartbeatTimer);
|
|
1573
1593
|
};
|
|
1574
1594
|
attemptDisposers.set(taskId, disposeTimers);
|
|
1595
|
+
// A run that spent Conduit's package budget keeps its work: the control plane retries it once, and
|
|
1596
|
+
// the retry resumes from the head reported here instead of starting over at the base. The tree
|
|
1597
|
+
// stays on this computer, where that head is always reachable.
|
|
1598
|
+
const reportTurnTimeout = async (message) => {
|
|
1599
|
+
retainAttemptWorktree = true;
|
|
1600
|
+
const resume = diagnosis || machineBoundTimeout(agentTimeout) ? null : await checkpointWorkInProgress({
|
|
1601
|
+
workspace: attemptWorkspace, spec, grants, baseCommit: deliveryBaseCommit,
|
|
1602
|
+
}).catch((error) => {
|
|
1603
|
+
console.error(`Work in progress was not committed: ${redactSecrets(error instanceof Error ? error.message : "unknown")}`);
|
|
1604
|
+
return null;
|
|
1605
|
+
});
|
|
1606
|
+
const response = await queueTerminal(client, taskId, {
|
|
1607
|
+
action: "fail",
|
|
1608
|
+
body: timeoutFailureBody(agentTimeout, active.attemptId, resume),
|
|
1609
|
+
});
|
|
1610
|
+
retainAttemptWorktree = Boolean(resume) || response.retain_worktree === true;
|
|
1611
|
+
console.error(`Assignment ${taskId} timed out: ${redactSecrets(message)}`);
|
|
1612
|
+
};
|
|
1575
1613
|
try {
|
|
1576
1614
|
// Resume for (1) Bridge restart with proven session+worktree, or (2) review rework.
|
|
1577
1615
|
// A fresh attempt after a failed delivery must start clean: resuming a session whose last
|
|
@@ -1720,13 +1758,7 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
|
|
|
1720
1758
|
if (result.status === "failed") {
|
|
1721
1759
|
const agentMessage = result.error ?? "Agent execution failed";
|
|
1722
1760
|
if (agentTurnTimedOut(agentMessage)) {
|
|
1723
|
-
|
|
1724
|
-
const response = await queueTerminal(client, taskId, {
|
|
1725
|
-
action: "fail",
|
|
1726
|
-
body: timeoutFailureBody(agentTimeout, active.attemptId),
|
|
1727
|
-
});
|
|
1728
|
-
retainAttemptWorktree = response.retain_worktree === true;
|
|
1729
|
-
console.error(`Assignment ${taskId} timed out: ${redactSecrets(agentMessage)}`);
|
|
1761
|
+
await reportTurnTimeout(agentMessage);
|
|
1730
1762
|
return;
|
|
1731
1763
|
}
|
|
1732
1764
|
// The agent verifies inside its own tool loop, so its compiler/test output never reaches
|
|
@@ -2307,13 +2339,7 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
|
|
|
2307
2339
|
catch (error) {
|
|
2308
2340
|
if (!(error instanceof AgentTurnTimeoutError))
|
|
2309
2341
|
throw error;
|
|
2310
|
-
|
|
2311
|
-
const response = await queueTerminal(client, taskId, {
|
|
2312
|
-
action: "fail",
|
|
2313
|
-
body: timeoutFailureBody(agentTimeout, active.attemptId),
|
|
2314
|
-
});
|
|
2315
|
-
retainAttemptWorktree = response.retain_worktree === true;
|
|
2316
|
-
console.error(`Assignment ${taskId} timed out: ${redactSecrets(error.message)}`);
|
|
2342
|
+
await reportTurnTimeout(error.message);
|
|
2317
2343
|
}
|
|
2318
2344
|
finally {
|
|
2319
2345
|
// The stall report already ended this attempt and its tree is the only evidence of the step
|
package/dist/failure-signal.js
CHANGED
|
@@ -418,7 +418,11 @@ export function verificationGateTimeoutSignal(input) {
|
|
|
418
418
|
}
|
|
419
419
|
/** A fact value as a person reads it on the card; an absent value says so in words. */
|
|
420
420
|
function factValue(value) {
|
|
421
|
-
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;
|
|
422
426
|
}
|
|
423
427
|
/** The vendor's own reason, kept inside the 2,000-char message bound; a stderr tail keeps its end. */
|
|
424
428
|
function quotedVendorText(signal) {
|
|
@@ -551,14 +555,14 @@ export function classifyFailureSignal(signal) {
|
|
|
551
555
|
responsible_party: "computer_operator",
|
|
552
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)}.`,
|
|
553
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.",
|
|
554
|
-
diagnostic_detail: `${fact.name}: pinned ${
|
|
558
|
+
diagnostic_detail: `${fact.name}: pinned ${fact.pinned ?? "none"}, found ${fact.observed ?? "none"}`,
|
|
555
559
|
};
|
|
556
560
|
}
|
|
557
561
|
// Ahead of the red-base and generic verification rules: the gate never exited, so neither the
|
|
558
562
|
// base commit nor a change is known to be at fault. The generic rule read it as a failed test.
|
|
559
563
|
if (signal.phase === "verification" && signal.kind === "timeout") {
|
|
560
564
|
const command = signal.command ?? "the declared gate";
|
|
561
|
-
const base = signal.base_commit
|
|
565
|
+
const base = signal.base_commit ? shortCommit(signal.base_commit) : "the base commit";
|
|
562
566
|
return {
|
|
563
567
|
code: "verification_gate_timeout",
|
|
564
568
|
class: "environment",
|
|
@@ -576,7 +580,7 @@ export function classifyFailureSignal(signal) {
|
|
|
576
580
|
if (signal.phase === "verification" && signal.kind === "contract"
|
|
577
581
|
&& signal.vendor_message === VERIFICATION_FAILS_ON_BASE) {
|
|
578
582
|
const command = signal.command ?? "the declared gate";
|
|
579
|
-
const base = signal.base_commit
|
|
583
|
+
const base = signal.base_commit ? shortCommit(signal.base_commit) : "the base commit";
|
|
580
584
|
const output = [signal.stderr_tail, signal.stdout_tail].filter(Boolean).join("\n");
|
|
581
585
|
// T198: a gate that could not start (`sh: tsc: not found`, exit 127) says nothing about the
|
|
582
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/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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@miraland-labs/conduit-bridge",
|
|
3
|
-
"version": "0.16.
|
|
3
|
+
"version": "0.16.148",
|
|
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": {
|