@miraland-labs/conduit-bridge 0.16.157 → 0.16.160
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/client.js +10 -4
- package/dist/driver.js +19 -11
- package/dist/ensure-land-commit.js +1 -1
- package/dist/ensure-pull-request.js +9 -4
- package/dist/ensure-test-evidence.js +127 -56
- package/dist/execution.js +29 -17
- package/dist/git-witness.js +1 -1
- package/dist/git.js +2 -2
- package/dist/integration-obligations.js +1 -1
- package/dist/investigation.js +14 -8
- package/dist/land-git-reality.js +1 -1
- package/dist/normative-refs.js +1 -1
- package/package.json +1 -1
package/dist/client.js
CHANGED
|
@@ -69,10 +69,16 @@ export class ConduitClient {
|
|
|
69
69
|
signal: init.signal ?? AbortSignal.timeout(CONTROL_PLANE_TIMEOUT_MS),
|
|
70
70
|
headers: { authorization: `Bearer ${this.config.runnerKey}`, "content-type": "application/json", ...init.headers },
|
|
71
71
|
});
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
72
|
+
if (!response.ok) {
|
|
73
|
+
const text = await response.text();
|
|
74
|
+
let data = null;
|
|
75
|
+
try {
|
|
76
|
+
data = JSON.parse(text);
|
|
77
|
+
}
|
|
78
|
+
catch { /* A proxy may answer with a non-JSON error page. */ }
|
|
79
|
+
throw new ConduitRequestError(data?.error?.message ?? `Conduit request failed (${response.status})`, response.status, data?.error?.code, data?.error?.details);
|
|
80
|
+
}
|
|
81
|
+
return await response.json();
|
|
76
82
|
}
|
|
77
83
|
async claim(taskId, attemptId, extras) {
|
|
78
84
|
// The lease token is this machine's bearer credential for the attempt. The server stores only
|
package/dist/driver.js
CHANGED
|
@@ -1884,18 +1884,18 @@ const MAX_AGENT_OUTPUT = 8 * 1024 * 1024;
|
|
|
1884
1884
|
*/
|
|
1885
1885
|
export const STDIO_DRAIN_MS = 2_000;
|
|
1886
1886
|
/** How long a stopped child may take to die before the run reports what it already has. */
|
|
1887
|
-
const CHILD_STOP_GRACE_MS = 20_000;
|
|
1887
|
+
export const CHILD_STOP_GRACE_MS = 20_000;
|
|
1888
1888
|
/**
|
|
1889
1889
|
* An agent runs as the leader of its own process group, so what it starts in the background (a dev
|
|
1890
1890
|
* server, a test watcher, a database) is stopped with it. Before, only the agent itself was
|
|
1891
1891
|
* stopped: its background processes outlived the run and held files and ports on the computer
|
|
1892
1892
|
* (acme-sim SV1b, 2026-10-10). Windows has no process groups; there only the agent is stopped.
|
|
1893
1893
|
*/
|
|
1894
|
-
const OWN_PROCESS_GROUP = process.platform !== "win32";
|
|
1894
|
+
export const OWN_PROCESS_GROUP = process.platform !== "win32";
|
|
1895
1895
|
/** Process groups of the agents running now, stopped when the runner itself is stopped. */
|
|
1896
1896
|
const runningGroups = new Set();
|
|
1897
1897
|
let shutdownHookInstalled = false;
|
|
1898
|
-
function signalGroup(pid, signal) {
|
|
1898
|
+
export function signalGroup(pid, signal) {
|
|
1899
1899
|
try {
|
|
1900
1900
|
process.kill(-pid, signal);
|
|
1901
1901
|
}
|
|
@@ -1903,6 +1903,17 @@ function signalGroup(pid, signal) {
|
|
|
1903
1903
|
// ESRCH: every process of the group is already gone.
|
|
1904
1904
|
}
|
|
1905
1905
|
}
|
|
1906
|
+
export function trackProcessGroup(pid) {
|
|
1907
|
+
installShutdownHook();
|
|
1908
|
+
runningGroups.add(pid);
|
|
1909
|
+
}
|
|
1910
|
+
export function untrackProcessGroup(pid) {
|
|
1911
|
+
runningGroups.delete(pid);
|
|
1912
|
+
}
|
|
1913
|
+
export function stopTrackedProcessGroups() {
|
|
1914
|
+
for (const pid of runningGroups)
|
|
1915
|
+
signalGroup(pid, "SIGKILL");
|
|
1916
|
+
}
|
|
1906
1917
|
/**
|
|
1907
1918
|
* systemd stops a whole cgroup, but launchd stops only the runner's own process group, which an
|
|
1908
1919
|
* agent no longer shares. Stop the agents' groups first, then let the signal end the runner as it
|
|
@@ -1914,8 +1925,7 @@ function installShutdownHook() {
|
|
|
1914
1925
|
shutdownHookInstalled = true;
|
|
1915
1926
|
for (const signal of ["SIGTERM", "SIGINT"]) {
|
|
1916
1927
|
process.once(signal, () => {
|
|
1917
|
-
|
|
1918
|
-
signalGroup(pid, "SIGKILL");
|
|
1928
|
+
stopTrackedProcessGroups();
|
|
1919
1929
|
process.kill(process.pid, signal);
|
|
1920
1930
|
});
|
|
1921
1931
|
}
|
|
@@ -1931,10 +1941,8 @@ env) {
|
|
|
1931
1941
|
detached: OWN_PROCESS_GROUP,
|
|
1932
1942
|
});
|
|
1933
1943
|
const group = OWN_PROCESS_GROUP ? child.pid : undefined;
|
|
1934
|
-
if (group !== undefined)
|
|
1935
|
-
|
|
1936
|
-
runningGroups.add(group);
|
|
1937
|
-
}
|
|
1944
|
+
if (group !== undefined)
|
|
1945
|
+
trackProcessGroup(group);
|
|
1938
1946
|
const stop = (signal) => {
|
|
1939
1947
|
if (group !== undefined)
|
|
1940
1948
|
signalGroup(group, signal);
|
|
@@ -1966,7 +1974,7 @@ env) {
|
|
|
1966
1974
|
child.stderr?.destroy();
|
|
1967
1975
|
// The agent has answered; whatever it left running in its group stops now.
|
|
1968
1976
|
if (group !== undefined) {
|
|
1969
|
-
|
|
1977
|
+
untrackProcessGroup(group);
|
|
1970
1978
|
signalGroup(group, "SIGTERM");
|
|
1971
1979
|
setTimeout(() => signalGroup(group, "SIGKILL"), 5_000).unref?.();
|
|
1972
1980
|
}
|
|
@@ -2012,7 +2020,7 @@ env) {
|
|
|
2012
2020
|
return;
|
|
2013
2021
|
settled = true;
|
|
2014
2022
|
if (group !== undefined)
|
|
2015
|
-
|
|
2023
|
+
untrackProcessGroup(group);
|
|
2016
2024
|
clearTimeout(timer);
|
|
2017
2025
|
signal?.removeEventListener("abort", stopChild);
|
|
2018
2026
|
reject(error);
|
|
@@ -37,7 +37,7 @@ export async function ensureLandCommit(input) {
|
|
|
37
37
|
return { kind: "skipped" };
|
|
38
38
|
const countAhead = input.countCommitsAhead ?? commitsAheadOfBase;
|
|
39
39
|
const readHead = input.readHeadCommit ?? readHeadCommit;
|
|
40
|
-
const listPending = input.listUncommittedPaths ?? uncommittedPaths;
|
|
40
|
+
const listPending = input.listUncommittedPaths ?? ((workspace) => uncommittedPaths(workspace));
|
|
41
41
|
const commitPaths = input.commitPaths ?? commitScopedPaths;
|
|
42
42
|
const ahead = await countAhead(input.workspace, base);
|
|
43
43
|
if (ahead !== null && ahead > 0) {
|
|
@@ -8,10 +8,13 @@ import { promisify } from "node:util";
|
|
|
8
8
|
import { normalizeRepositoryUrl } from "./brief.js";
|
|
9
9
|
import { readHeadCommit } from "./git.js";
|
|
10
10
|
import { forgeTransportFailure } from "./transport-fault.js";
|
|
11
|
+
import { ConduitRequestError } from "./client.js";
|
|
12
|
+
import { redactSecrets } from "./secret-redaction.js";
|
|
11
13
|
import { agentNoLandCommitMessage } from "./land-contract.js";
|
|
12
14
|
const execFileAsync = promisify(execFile);
|
|
13
15
|
/** Keep the pull request body inside forge limits, even for a very large delivery packet. */
|
|
14
16
|
const PULL_REQUEST_BODY_MAX_BYTES = 60_000;
|
|
17
|
+
export const CONTROL_PLANE_TRANSIENT_PREFIX = "Control plane did not answer the change request open: ";
|
|
15
18
|
/**
|
|
16
19
|
* What the packet says when the control plane refused to open the change request.
|
|
17
20
|
*
|
|
@@ -289,14 +292,16 @@ export async function ensureDeliveryPullRequest(input) {
|
|
|
289
292
|
head_branch: headBranch,
|
|
290
293
|
head_commit: report.head_commit,
|
|
291
294
|
title: (input.title ?? "Conduit delivery").slice(0, 200),
|
|
292
|
-
body: composePullRequestBody(report),
|
|
295
|
+
body: redactSecrets(composePullRequestBody(report)),
|
|
293
296
|
idempotency_key: `bridge:open-pr:${input.attemptId}`,
|
|
294
297
|
});
|
|
295
298
|
}
|
|
296
299
|
catch (error) {
|
|
297
300
|
const message = error instanceof Error ? error.message : String(error);
|
|
298
|
-
|
|
299
|
-
|
|
301
|
+
if (error instanceof ConduitRequestError && error.status < 500) {
|
|
302
|
+
throw new Error(`${PULL_REQUEST_OPEN_FAILURE_PREFIX}: ${message}${error.code ? ` (${error.code})` : ""}`);
|
|
303
|
+
}
|
|
304
|
+
throw new Error(`${CONTROL_PLANE_TRANSIENT_PREFIX}${message}`);
|
|
300
305
|
}
|
|
301
306
|
const pullRequestUrl = typeof response.pull_request_url === "string" ? response.pull_request_url : "";
|
|
302
307
|
// Any https URL: the control plane resolved which forge this project uses and parsed the response
|
|
@@ -304,7 +309,7 @@ export async function ensureDeliveryPullRequest(input) {
|
|
|
304
309
|
// GitLab merge request — after the branch was already pushed. The head-commit cross-check below is
|
|
305
310
|
// what actually proves the returned change request is this delivery's.
|
|
306
311
|
if (!isAbsoluteHttpsUrl(pullRequestUrl)) {
|
|
307
|
-
throw new Error("Control plane
|
|
312
|
+
throw new Error("Control plane answered without a change request link");
|
|
308
313
|
}
|
|
309
314
|
const openedHead = typeof response.head_commit === "string" ? response.head_commit : "";
|
|
310
315
|
const deliveredHead = report.head_commit ?? "";
|
|
@@ -3,15 +3,13 @@
|
|
|
3
3
|
* commands and attaches their verbatim results. Agent prose may supplement those runs;
|
|
4
4
|
* it never substitutes for it.
|
|
5
5
|
*/
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import { boundedEnvironment, criterionKey, resolveCriterionReference } from "./driver.js";
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
import { boundedEnvironment, criterionKey, resolveCriterionReference, signalGroup, STDIO_DRAIN_MS, CHILD_STOP_GRACE_MS, OWN_PROCESS_GROUP, trackProcessGroup, untrackProcessGroup } from "./driver.js";
|
|
9
8
|
import { isBoundedVerificationCommand, isRunnableVerificationCommand } from "./execution-class.js";
|
|
10
|
-
const execFileAsync = promisify(execFile);
|
|
11
9
|
const TEST_EVIDENCE_DETAILS_MIN = 32;
|
|
12
10
|
/** Cold `cargo test` in an attempt worktree routinely exceeds two minutes of compile. */
|
|
13
11
|
export const VERIFICATION_TIMEOUT_MS = 600_000;
|
|
14
|
-
/** rustc + cargo logs routinely exceed 2 MiB;
|
|
12
|
+
/** rustc + cargo logs routinely exceed 2 MiB; keep a bounded tail per stream. */
|
|
15
13
|
export const VERIFICATION_MAX_BUFFER_BYTES = 32_000_000;
|
|
16
14
|
/**
|
|
17
15
|
* Contract requires test + test_run — Bridge always runs; agent paste is not a substitute.
|
|
@@ -195,13 +193,13 @@ export async function captureVerificationFailure(input) {
|
|
|
195
193
|
const run = input.runCommand ?? runBoundedVerificationCommand;
|
|
196
194
|
// Try each bounded command until one fails. First non-zero wins (typecheck before test when both exist).
|
|
197
195
|
for (const command of commands) {
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
}
|
|
202
|
-
catch {
|
|
203
|
-
// Diagnosis is best-effort: try the next command rather than failing the attempt again.
|
|
196
|
+
// Diagnosis is best-effort: try the next command rather than failing the attempt again.
|
|
197
|
+
const result = await run(command, input.workspace).catch(() => null);
|
|
198
|
+
if (!result)
|
|
204
199
|
continue;
|
|
200
|
+
if (result.timedOut) {
|
|
201
|
+
return { command, detail: boundedTail(outputLines(result).join("\n"), DIAGNOSIS_DETAIL_MAX_CHARS),
|
|
202
|
+
timedOut: true, stdout: result.stdout, stderr: result.stderr };
|
|
205
203
|
}
|
|
206
204
|
if (result.code === 0)
|
|
207
205
|
continue;
|
|
@@ -375,55 +373,128 @@ export async function runBoundedVerificationCommand(command, workspace, timeoutM
|
|
|
375
373
|
throw new Error("Verification command is empty");
|
|
376
374
|
// Chunks are not whole lines. Each stream keeps its partial last line until the line ends, so a
|
|
377
375
|
// stderr write between two stdout chunks cannot split a stdout line.
|
|
378
|
-
const
|
|
379
|
-
|
|
376
|
+
const boundedOutput = (limit) => {
|
|
377
|
+
const chunks = [];
|
|
378
|
+
let bytes = 0;
|
|
379
|
+
return {
|
|
380
|
+
append(text) {
|
|
381
|
+
const chunk = Buffer.from(text);
|
|
382
|
+
if (!chunk.length)
|
|
383
|
+
return;
|
|
384
|
+
const last = chunks.at(-1);
|
|
385
|
+
if (last && last.length + chunk.length <= 64 * 1024)
|
|
386
|
+
chunks[chunks.length - 1] = Buffer.concat([last, chunk]);
|
|
387
|
+
else
|
|
388
|
+
chunks.push(chunk);
|
|
389
|
+
bytes += chunk.length;
|
|
390
|
+
while (bytes > limit) {
|
|
391
|
+
const first = chunks[0];
|
|
392
|
+
const excess = bytes - limit;
|
|
393
|
+
if (first.length <= excess) {
|
|
394
|
+
bytes -= first.length;
|
|
395
|
+
chunks.shift();
|
|
396
|
+
}
|
|
397
|
+
else {
|
|
398
|
+
chunks[0] = first.subarray(excess);
|
|
399
|
+
bytes = limit;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
},
|
|
403
|
+
text() {
|
|
404
|
+
const buffer = Buffer.concat(chunks, bytes);
|
|
405
|
+
let start = 0;
|
|
406
|
+
while (start < buffer.length && (buffer[start] & 0xc0) === 0x80)
|
|
407
|
+
start += 1;
|
|
408
|
+
return buffer.subarray(start).toString("utf8");
|
|
409
|
+
},
|
|
410
|
+
clear() { chunks.length = 0; bytes = 0; },
|
|
411
|
+
};
|
|
412
|
+
};
|
|
413
|
+
const lines = boundedOutput(2 * VERIFICATION_MAX_BUFFER_BYTES);
|
|
414
|
+
const partial = { stdout: boundedOutput(VERIFICATION_MAX_BUFFER_BYTES), stderr: boundedOutput(VERIFICATION_MAX_BUFFER_BYTES) };
|
|
380
415
|
const collect = (stream) => (chunk) => {
|
|
381
|
-
const parts =
|
|
382
|
-
partial[stream]
|
|
383
|
-
|
|
416
|
+
const parts = String(chunk).split("\n");
|
|
417
|
+
partial[stream].append(parts[0]);
|
|
418
|
+
if (parts.length === 1)
|
|
419
|
+
return;
|
|
420
|
+
lines.append(`${partial[stream].text()}\n`);
|
|
421
|
+
partial[stream].clear();
|
|
422
|
+
if (parts.length > 2)
|
|
423
|
+
lines.append(`${parts.slice(1, -1).join("\n")}\n`);
|
|
424
|
+
partial[stream].append(parts.at(-1));
|
|
384
425
|
};
|
|
385
|
-
const output = () => [
|
|
386
|
-
|
|
387
|
-
const
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
426
|
+
const output = () => [lines.text().replace(/\n$/, ""), partial.stdout.text(), partial.stderr.text()].filter(Boolean).join("\n");
|
|
427
|
+
return new Promise((resolve, reject) => {
|
|
428
|
+
const child = spawn(bin, argv.slice(1), { cwd: workspace, env: boundedEnvironment(),
|
|
429
|
+
stdio: ["ignore", "pipe", "pipe"], detached: OWN_PROCESS_GROUP });
|
|
430
|
+
const group = OWN_PROCESS_GROUP ? child.pid : undefined;
|
|
431
|
+
if (group !== undefined)
|
|
432
|
+
trackProcessGroup(group);
|
|
433
|
+
const stop = (signal) => {
|
|
434
|
+
if (group !== undefined)
|
|
435
|
+
signalGroup(group, signal);
|
|
436
|
+
else
|
|
437
|
+
child.kill(signal);
|
|
438
|
+
};
|
|
439
|
+
const stdout = boundedOutput(VERIFICATION_MAX_BUFFER_BYTES);
|
|
440
|
+
const stderr = boundedOutput(VERIFICATION_MAX_BUFFER_BYTES);
|
|
441
|
+
let timedOut = false;
|
|
442
|
+
let settled = false;
|
|
443
|
+
let drainTimer = null;
|
|
444
|
+
let graceTimer = null;
|
|
445
|
+
const cleanup = () => {
|
|
446
|
+
if (group !== undefined)
|
|
447
|
+
untrackProcessGroup(group);
|
|
448
|
+
clearTimeout(timer);
|
|
449
|
+
if (drainTimer)
|
|
450
|
+
clearTimeout(drainTimer);
|
|
451
|
+
if (graceTimer)
|
|
452
|
+
clearTimeout(graceTimer);
|
|
453
|
+
child.stdout?.destroy();
|
|
454
|
+
child.stderr?.destroy();
|
|
455
|
+
stop("SIGTERM");
|
|
456
|
+
setTimeout(() => stop("SIGKILL"), 5_000).unref?.();
|
|
457
|
+
};
|
|
458
|
+
const settle = (code) => {
|
|
459
|
+
if (settled)
|
|
460
|
+
return;
|
|
461
|
+
settled = true;
|
|
462
|
+
cleanup();
|
|
410
463
|
const stopped = `Bridge stopped the gate: it did not exit within ${Math.round(timeoutMs / 1_000)} seconds.`;
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
output: `${output()}\n${stopped}`.trimStart(),
|
|
415
|
-
code: 1,
|
|
416
|
-
timedOut: true,
|
|
417
|
-
};
|
|
418
|
-
}
|
|
419
|
-
const stderr = String(err.stderr || err.message || "verification failed");
|
|
420
|
-
return {
|
|
421
|
-
stdout: String(err.stdout ?? ""),
|
|
422
|
-
stderr,
|
|
423
|
-
output: output() || stderr,
|
|
424
|
-
code: typeof err.code === "number" ? err.code : 1,
|
|
464
|
+
resolve(timedOut ? { stdout: stdout.text(), stderr: `${stderr.text()}\n${stopped}`.trimStart(),
|
|
465
|
+
output: `${output()}\n${stopped}`.trimStart(), code: 1, timedOut: true }
|
|
466
|
+
: { stdout: stdout.text(), stderr: stderr.text(), output: output(), code: code ?? 1 });
|
|
425
467
|
};
|
|
426
|
-
|
|
468
|
+
const timer = setTimeout(() => {
|
|
469
|
+
timedOut = true;
|
|
470
|
+
stop("SIGTERM");
|
|
471
|
+
setTimeout(() => stop("SIGKILL"), 10_000).unref?.();
|
|
472
|
+
graceTimer = setTimeout(() => settle(null), CHILD_STOP_GRACE_MS);
|
|
473
|
+
}, timeoutMs);
|
|
474
|
+
child.stdout?.setEncoding("utf8");
|
|
475
|
+
child.stderr?.setEncoding("utf8");
|
|
476
|
+
child.stdout?.on("data", (chunk) => { stdout.append(chunk); collect("stdout")(chunk); });
|
|
477
|
+
child.stderr?.on("data", (chunk) => { stderr.append(chunk); collect("stderr")(chunk); });
|
|
478
|
+
child.on("error", (error) => {
|
|
479
|
+
if (settled)
|
|
480
|
+
return;
|
|
481
|
+
if (typeof error.code === "string") {
|
|
482
|
+
settled = true;
|
|
483
|
+
cleanup();
|
|
484
|
+
reject(error);
|
|
485
|
+
}
|
|
486
|
+
else {
|
|
487
|
+
if (!stderr.text())
|
|
488
|
+
stderr.append(error.message);
|
|
489
|
+
settle(1);
|
|
490
|
+
}
|
|
491
|
+
});
|
|
492
|
+
child.on("close", (code) => settle(code));
|
|
493
|
+
child.on("exit", (code) => {
|
|
494
|
+
clearTimeout(timer);
|
|
495
|
+
drainTimer = setTimeout(() => settle(code), STDIO_DRAIN_MS);
|
|
496
|
+
});
|
|
497
|
+
});
|
|
427
498
|
}
|
|
428
499
|
/**
|
|
429
500
|
* Bridge may attach genuine command output; it must not invent which criteria that output proves.
|
package/dist/execution.js
CHANGED
|
@@ -23,7 +23,7 @@ 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, commitsMatch, ensureDeliveryPullRequest } from "./ensure-pull-request.js";
|
|
26
|
+
import { CONTROL_PLANE_TRANSIENT_PREFIX, PULL_REQUEST_OPEN_FAILURE_PREFIX, commitsMatch, ensureDeliveryPullRequest } from "./ensure-pull-request.js";
|
|
27
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";
|
|
@@ -219,6 +219,8 @@ const FINALIZE_CONTRACT_PATTERN = /missing required evidence|without the repo_wr
|
|
|
219
219
|
export function classifyFinalizeFailure(message) {
|
|
220
220
|
if (forgeTransportFailure(message))
|
|
221
221
|
return { retryable: true, error: message, failure: bridgeCause("forge_unreachable", message) };
|
|
222
|
+
if (message.startsWith(CONTROL_PLANE_TRANSIENT_PREFIX))
|
|
223
|
+
return { retryable: true, error: message };
|
|
222
224
|
// The control plane refused to open the change request — no forge connection resolves for this
|
|
223
225
|
// organization or project, or the task's repository is not one that connection can open a change
|
|
224
226
|
// request on. No retry and no rework can supply a connection. The cause travels as an envelope so
|
|
@@ -1088,6 +1090,17 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
|
|
|
1088
1090
|
// A diagnostic terminal defaults to retention. If its lease disappears, deleting the only copy
|
|
1089
1091
|
// of the failed code would make the durable retry mechanically impossible.
|
|
1090
1092
|
let retainAttemptWorktree = diagnosis;
|
|
1093
|
+
const reportVerificationGateTimeout = async (input) => {
|
|
1094
|
+
const response = await queueTerminal(client, taskId, { action: "fail", body: {
|
|
1095
|
+
error: input.detail,
|
|
1096
|
+
retryable: false,
|
|
1097
|
+
signal: verificationGateTimeoutSignal({ command: input.command, stderr: input.stderr, stdout: input.stdout }),
|
|
1098
|
+
...(input.pullRequestUrl ? { pull_request_url: input.pullRequestUrl } : {}),
|
|
1099
|
+
idempotency_key: `bridge:verification-gate-timeout:${active.attemptId}`,
|
|
1100
|
+
} });
|
|
1101
|
+
retainAttemptWorktree = response.retain_worktree === true;
|
|
1102
|
+
console.error(`Assignment ${taskId} verification gate did not finish: ${redactSecrets(input.detail)}`);
|
|
1103
|
+
};
|
|
1091
1104
|
if (diagnosis) {
|
|
1092
1105
|
if (!sourceAttemptId) {
|
|
1093
1106
|
await queueTerminal(client, taskId, {
|
|
@@ -1800,6 +1813,12 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
|
|
|
1800
1813
|
acceptance: spec.acceptance ?? [],
|
|
1801
1814
|
})
|
|
1802
1815
|
: null;
|
|
1816
|
+
if (capturedVerification?.timedOut) {
|
|
1817
|
+
retainAttemptWorktree = true;
|
|
1818
|
+
await reportVerificationGateTimeout({ command: capturedVerification.command, detail: capturedVerification.detail,
|
|
1819
|
+
stderr: capturedVerification.stderr ?? "", stdout: capturedVerification.stdout ?? "" });
|
|
1820
|
+
return;
|
|
1821
|
+
}
|
|
1803
1822
|
const verificationDetail = capturedVerification?.detail ?? null;
|
|
1804
1823
|
// The vendor named the model as the cause, so the model is what the retry must change. The
|
|
1805
1824
|
// model rides in the prefix: the control plane cannot read Bridge's selection any other way.
|
|
@@ -2259,18 +2278,8 @@ async function runAttempt(client, config, driver, workspace, brief, taskId, watc
|
|
|
2259
2278
|
// timeout signal the dry run emits, held on the computer operator — never a diagnosis or a
|
|
2260
2279
|
// repair brief. Task ce673ef1 (2026-10-10) ran two diagnoses on a CPU-starved `npm run test`.
|
|
2261
2280
|
if (error instanceof VerificationGateTimedOutError) {
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
body: {
|
|
2265
|
-
error: error.message,
|
|
2266
|
-
retryable: false,
|
|
2267
|
-
signal: verificationGateTimeoutSignal({ command: error.command, stderr: error.stderr, stdout: error.stdout }),
|
|
2268
|
-
...(report.pull_request_url ? { pull_request_url: report.pull_request_url } : {}),
|
|
2269
|
-
idempotency_key: `bridge:verification-gate-timeout:${active.attemptId}`,
|
|
2270
|
-
},
|
|
2271
|
-
});
|
|
2272
|
-
retainAttemptWorktree = response.retain_worktree === true;
|
|
2273
|
-
console.error(`Assignment ${taskId} verification gate did not finish: ${redactSecrets(error.message)}`);
|
|
2281
|
+
await reportVerificationGateTimeout({ command: error.command, detail: error.message,
|
|
2282
|
+
stderr: error.stderr, stdout: error.stdout, pullRequestUrl: report.pull_request_url });
|
|
2274
2283
|
return;
|
|
2275
2284
|
}
|
|
2276
2285
|
// A gate that ran no tests is a misconfigured gate, not a failed verification: the delivery
|
|
@@ -2750,7 +2759,8 @@ async function prepareDelivery(client, attemptId, taskId, report) {
|
|
|
2750
2759
|
const fuelMode = client.attempt(taskId).fuelMode;
|
|
2751
2760
|
const evidence = [];
|
|
2752
2761
|
const evidenceByCriterion = new Map();
|
|
2753
|
-
for (const [index,
|
|
2762
|
+
for (const [index, evidenceItem] of report.evidence.entries()) {
|
|
2763
|
+
const item = redactSecretsDeep(evidenceItem);
|
|
2754
2764
|
const uri = item.uri ?? `urn:conduit:attempt:${attemptId}:evidence:${index + 1}`;
|
|
2755
2765
|
// Publication receipts require a digest on every acceptance artifact. Textual agent
|
|
2756
2766
|
// evidence has no file to hash, so default to a canonical content digest of the
|
|
@@ -2908,10 +2918,10 @@ export function verificationScopeConflictDetail(paths, changeScope) {
|
|
|
2908
2918
|
return `Verification writes outside the approved scope: ${paths.join(", ")}\napproved change scope: ${changeScope.join(", ")}`;
|
|
2909
2919
|
}
|
|
2910
2920
|
/**
|
|
2911
|
-
* Run the project's verification commands once in the fresh attempt worktree, then answer
|
|
2921
|
+
* Run the project's verification commands once in the fresh attempt worktree, then answer four
|
|
2912
2922
|
* questions before the first model call: did a gate start but test nothing, did a gate already fail
|
|
2913
|
-
* on the untouched base commit,
|
|
2914
|
-
* does not contain?
|
|
2923
|
+
* on the untouched base commit, which paths did a gate write that the approved change scope
|
|
2924
|
+
* does not contain, and did a gate fail to finish?
|
|
2915
2925
|
*
|
|
2916
2926
|
* R5 (2026-09-03) and R10 (2026-09-05) failed the same way: the gates write files under the
|
|
2917
2927
|
* repository root, the change scope did not contain them, and `validateChangeScope` rejected the
|
|
@@ -3001,6 +3011,7 @@ export async function runDeliveryVerificationGates(input) {
|
|
|
3001
3011
|
results.push({
|
|
3002
3012
|
command,
|
|
3003
3013
|
exitCode: result.code,
|
|
3014
|
+
timedOut: result.timedOut === true,
|
|
3004
3015
|
stdoutTail: boundedTail(result.stdout, DELIVERY_VERIFICATION_GATE_TAIL_CHARS),
|
|
3005
3016
|
stderrTail: boundedTail(result.stderr, DELIVERY_VERIFICATION_GATE_TAIL_CHARS),
|
|
3006
3017
|
});
|
|
@@ -3011,6 +3022,7 @@ export async function runDeliveryVerificationGates(input) {
|
|
|
3011
3022
|
results.push({
|
|
3012
3023
|
command,
|
|
3013
3024
|
exitCode: -1,
|
|
3025
|
+
timedOut: false,
|
|
3014
3026
|
stdoutTail: "",
|
|
3015
3027
|
stderrTail: boundedTail(message, DELIVERY_VERIFICATION_GATE_TAIL_CHARS),
|
|
3016
3028
|
});
|
package/dist/git-witness.js
CHANGED
|
@@ -46,7 +46,7 @@ export async function changedPathsSince(worktree, startCommit) {
|
|
|
46
46
|
*/
|
|
47
47
|
export async function worktreeWrittenPaths(worktree) {
|
|
48
48
|
try {
|
|
49
|
-
return [...new Set(await uncommittedPaths(worktree
|
|
49
|
+
return [...new Set(await uncommittedPaths(worktree))];
|
|
50
50
|
}
|
|
51
51
|
catch {
|
|
52
52
|
return null;
|
package/dist/git.js
CHANGED
|
@@ -12,8 +12,8 @@ const execFileAsync = promisify(execFile);
|
|
|
12
12
|
* and escape (`中文.txt` as `"\344\270\255\346\226\207.txt"`) comes back verbatim and can be
|
|
13
13
|
* handed back to git (review 2026-10-03, finding 3).
|
|
14
14
|
*/
|
|
15
|
-
export async function uncommittedPaths(workspace
|
|
16
|
-
const { stdout } = await execFileAsync("git", ["-C", workspace, "status", "--porcelain=v1", "-z",
|
|
15
|
+
export async function uncommittedPaths(workspace) {
|
|
16
|
+
const { stdout } = await execFileAsync("git", ["-C", workspace, "status", "--porcelain=v1", "-z", "--untracked-files=all"], { timeout: 30_000, maxBuffer: 8_000_000 });
|
|
17
17
|
return porcelainPaths(stdout);
|
|
18
18
|
}
|
|
19
19
|
/** Porcelain v1 with `-z`: `XY path`, and a rename or copy adds its source as the next field. */
|
|
@@ -41,7 +41,7 @@ export function parseIntegrationObligations(value) {
|
|
|
41
41
|
async function symbolReferencedFrom(workspace, symbol, globs) {
|
|
42
42
|
const pathspecs = globs.map((glob) => `:(glob)${glob}`);
|
|
43
43
|
try {
|
|
44
|
-
const { stdout } = await execFileAsync("git", ["-C", workspace, "grep", "-l", "-F", "--untracked", symbol, "--", ...pathspecs], { timeout: 30_000, maxBuffer: 4_000_000 });
|
|
44
|
+
const { stdout } = await execFileAsync("git", ["-C", workspace, "grep", "-l", "-F", "--untracked", "-e", symbol, "--", ...pathspecs], { timeout: 30_000, maxBuffer: 4_000_000 });
|
|
45
45
|
return stdout.trim().length > 0;
|
|
46
46
|
}
|
|
47
47
|
catch (error) {
|
package/dist/investigation.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* held to repo_read + test_run by the same driver projection that enforces read-only diagnosis, and
|
|
6
6
|
* the worktree it reads is deleted whichever way the run ends.
|
|
7
7
|
*/
|
|
8
|
-
import { redactSecrets } from "./secret-redaction.js";
|
|
8
|
+
import { redactSecrets, redactSecretsDeep } from "./secret-redaction.js";
|
|
9
9
|
import { z } from "zod";
|
|
10
10
|
import { boundedTail, runBoundedVerificationCommand } from "./ensure-test-evidence.js";
|
|
11
11
|
import { createAttemptWorktree, removeAttemptWorktree } from "./attempt-worktree.js";
|
|
@@ -191,9 +191,10 @@ function formatGatesRunBlock(gates) {
|
|
|
191
191
|
return `${header}\n- none`;
|
|
192
192
|
return `${header}\n${gates.map((gate) => {
|
|
193
193
|
const tail = gate.stderrTail.trim() || gate.stdoutTail.trim();
|
|
194
|
+
const verdict = gate.timedOut ? "did not finish (stopped by Bridge)" : `exit ${gate.exitCode}`;
|
|
194
195
|
return tail
|
|
195
|
-
? `- ${gate.command} —
|
|
196
|
-
: `- ${gate.command} —
|
|
196
|
+
? `- ${gate.command} — ${verdict}\n ${tail.replace(/\n/g, "\n ")}`
|
|
197
|
+
: `- ${gate.command} — ${verdict}`;
|
|
197
198
|
}).join("\n")}`;
|
|
198
199
|
}
|
|
199
200
|
function gateFailureLine(tail) {
|
|
@@ -444,8 +445,8 @@ function sourceProbeVerdict(exitCode, stdout) {
|
|
|
444
445
|
return "fails";
|
|
445
446
|
return "broken";
|
|
446
447
|
}
|
|
447
|
-
function testProbeVerdict(ran, exitCode) {
|
|
448
|
-
if (!ran)
|
|
448
|
+
function testProbeVerdict(ran, exitCode, timedOut) {
|
|
449
|
+
if (!ran || timedOut)
|
|
449
450
|
return "broken";
|
|
450
451
|
return exitCode === 0 ? "holds" : "fails";
|
|
451
452
|
}
|
|
@@ -553,12 +554,14 @@ export async function runDeliveryVerificationProbes(input) {
|
|
|
553
554
|
let stdout = "";
|
|
554
555
|
let stderr = "";
|
|
555
556
|
let ran = false;
|
|
557
|
+
let timedOut = false;
|
|
556
558
|
try {
|
|
557
559
|
const result = await (input.runCommand ?? runBoundedVerificationCommand)(named, input.workspace);
|
|
558
560
|
exitCode = result.code;
|
|
559
561
|
stdout = result.stdout;
|
|
560
562
|
stderr = result.stderr;
|
|
561
563
|
ran = true;
|
|
564
|
+
timedOut = result.timedOut === true;
|
|
562
565
|
}
|
|
563
566
|
catch (error) {
|
|
564
567
|
stderr = error instanceof Error ? error.message : String(error);
|
|
@@ -566,7 +569,7 @@ export async function runDeliveryVerificationProbes(input) {
|
|
|
566
569
|
}
|
|
567
570
|
witnesses.push(witnessRecord({
|
|
568
571
|
key: probe.key, kind: "test", command: named, exitCode, stdout, stderr, checks,
|
|
569
|
-
verdict: testProbeVerdict(ran, exitCode),
|
|
572
|
+
verdict: testProbeVerdict(ran, exitCode, timedOut),
|
|
570
573
|
}));
|
|
571
574
|
continue;
|
|
572
575
|
}
|
|
@@ -688,7 +691,10 @@ export async function runDeliveryVerificationProbesForAttempt(input) {
|
|
|
688
691
|
async function settle(client, investigationId, body) {
|
|
689
692
|
await client.request(`/runner/v1/investigations/${investigationId}/settle`, {
|
|
690
693
|
method: "POST",
|
|
691
|
-
body: JSON.stringify(body
|
|
694
|
+
body: JSON.stringify({ ...body,
|
|
695
|
+
...(body.result !== undefined ? { result: redactSecretsDeep(body.result) } : {}),
|
|
696
|
+
...(body.error !== undefined ? { error: redactSecrets(body.error) } : {}),
|
|
697
|
+
}),
|
|
692
698
|
});
|
|
693
699
|
}
|
|
694
700
|
/**
|
|
@@ -901,7 +907,7 @@ export async function executeNextInvestigation(client, config, workspace, brief,
|
|
|
901
907
|
limitations: observed.limitations,
|
|
902
908
|
criteria: observed.criteria,
|
|
903
909
|
command_failures: deliveryVerification
|
|
904
|
-
? gatesRun.filter((gate) => gate.exitCode !== 0).map(gateFailureFact).slice(0, 10)
|
|
910
|
+
? gatesRun.filter((gate) => gate.exitCode !== 0 && !gate.timedOut).map(gateFailureFact).slice(0, 10)
|
|
905
911
|
: observed.command_failures,
|
|
906
912
|
step_witness: stepWitness,
|
|
907
913
|
no_behaviour: deliveryVerification ? observed.no_behaviour : [],
|
package/dist/land-git-reality.js
CHANGED
|
@@ -18,7 +18,7 @@ export async function isLandTreeEmpty(input) {
|
|
|
18
18
|
const ahead = await countAhead(input.workspace, base);
|
|
19
19
|
if (ahead !== null && ahead > 0)
|
|
20
20
|
return false;
|
|
21
|
-
const pending = filterPathsInScope(await (input.listUncommittedPaths ?? listUncommittedPaths)(input.workspace), input.spec.change_scope ?? []);
|
|
21
|
+
const pending = filterPathsInScope(await (input.listUncommittedPaths ?? ((workspace) => listUncommittedPaths(workspace)))(input.workspace), input.spec.change_scope ?? []);
|
|
22
22
|
return pending.length === 0;
|
|
23
23
|
}
|
|
24
24
|
function deliveryObjectClaimsWork(raw) {
|
package/dist/normative-refs.js
CHANGED
|
@@ -197,7 +197,7 @@ function isConduitPath(path) {
|
|
|
197
197
|
return normalized === ".conduit" || normalized.startsWith(".conduit/");
|
|
198
198
|
}
|
|
199
199
|
async function markerPresent(workspace, marker, paths) {
|
|
200
|
-
const args = ["-C", workspace, "grep", "-l", "-F", "--untracked", marker, "--", ...paths];
|
|
200
|
+
const args = ["-C", workspace, "grep", "-l", "-F", "--untracked", "-e", marker, "--", ...paths];
|
|
201
201
|
try {
|
|
202
202
|
const { stdout } = await execFileAsync("git", args, {
|
|
203
203
|
timeout: 30_000,
|
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.160",
|
|
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": {
|