@miraland-labs/conduit-bridge 0.16.30 → 0.16.32
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/attempt-worktree.js +10 -8
- package/dist/brief.js +22 -63
- package/dist/checkout.js +1 -13
- package/dist/cli.js +9 -6
- package/dist/config.js +19 -5
- package/dist/drivers.js +3 -1
- package/dist/ensure-land-commit.js +2 -11
- package/dist/ensure-pull-request.js +4 -14
- package/dist/ensure-test-evidence.js +68 -9
- package/dist/execution-class.js +34 -9
- package/dist/execution-facts.js +1 -16
- package/dist/execution.js +36 -25
- package/dist/git.js +48 -0
- package/dist/investigation.js +5 -2
- package/dist/normative-refs.js +2 -13
- package/dist/transport-fault.js +12 -0
- package/package.json +1 -1
package/dist/attempt-worktree.js
CHANGED
|
@@ -7,6 +7,7 @@ import { constants } from "node:fs";
|
|
|
7
7
|
import { join, resolve } from "node:path";
|
|
8
8
|
import { execFile } from "node:child_process";
|
|
9
9
|
import { promisify } from "node:util";
|
|
10
|
+
import { headCommitOrNull } from "./git.js";
|
|
10
11
|
const execFileAsync = promisify(execFile);
|
|
11
12
|
export function attemptWorktreePath(sourceWorkspace, attemptId) {
|
|
12
13
|
return join(sourceWorkspace, ".conduit", "attempts", attemptId);
|
|
@@ -82,14 +83,18 @@ export async function ensureWorktreeGitIdentity(worktree, sourceWorkspace) {
|
|
|
82
83
|
await git("config", key, inherited || fallback);
|
|
83
84
|
}
|
|
84
85
|
}
|
|
86
|
+
/** Removes the worktree; answers whether one was there to remove. */
|
|
85
87
|
export async function removeAttemptWorktree(sourceWorkspace, worktreePath) {
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
+
// The control plane names every settled repair on every assignment poll, because it holds no
|
|
89
|
+
// record of what this computer still has on disk. That is a statement of fact to reconcile
|
|
90
|
+
// against, not a queue to drain, so the caller asks whether anything actually went -- a release
|
|
91
|
+
// nobody performed is not an event, and logging one made the same eleven paths scroll past every
|
|
92
|
+
// fifteen seconds. It also keeps Git off a path that is already gone.
|
|
88
93
|
try {
|
|
89
94
|
await access(worktreePath, constants.F_OK);
|
|
90
95
|
}
|
|
91
96
|
catch {
|
|
92
|
-
return;
|
|
97
|
+
return false;
|
|
93
98
|
}
|
|
94
99
|
try {
|
|
95
100
|
await execFileAsync("git", ["-C", sourceWorkspace, "worktree", "remove", "--force", worktreePath], { timeout: 60_000, maxBuffer: 2_000_000 });
|
|
@@ -101,6 +106,7 @@ export async function removeAttemptWorktree(sourceWorkspace, worktreePath) {
|
|
|
101
106
|
maxBuffer: 1_000_000,
|
|
102
107
|
}).catch(() => undefined);
|
|
103
108
|
}
|
|
109
|
+
return true;
|
|
104
110
|
}
|
|
105
111
|
/**
|
|
106
112
|
* F-07 safe resume: prove the interrupted attempt still owns this worktree path.
|
|
@@ -114,11 +120,7 @@ export async function proveResumeWorktree(sourceWorkspace, attemptId, worktreePa
|
|
|
114
120
|
return false;
|
|
115
121
|
try {
|
|
116
122
|
await access(worktreePath, constants.F_OK);
|
|
117
|
-
|
|
118
|
-
timeout: 15_000,
|
|
119
|
-
maxBuffer: 1_000_000,
|
|
120
|
-
});
|
|
121
|
-
return /^[0-9a-f]{40,64}$/i.test(stdout.trim());
|
|
123
|
+
return (await headCommitOrNull(worktreePath)) !== null;
|
|
122
124
|
}
|
|
123
125
|
catch {
|
|
124
126
|
return false;
|
package/dist/brief.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { readdir, readFile
|
|
2
|
-
import { join
|
|
1
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
3
|
import { execFile } from "node:child_process";
|
|
4
4
|
import { promisify } from "node:util";
|
|
5
5
|
import { isBoundedVerificationCommand } from "./execution-class.js";
|
|
6
|
+
import { gitOriginUrl, headCommitOrNull } from "./git.js";
|
|
6
7
|
const execFileAsync = promisify(execFile);
|
|
7
8
|
const MANIFESTS = [
|
|
8
9
|
"package.json", "wrangler.jsonc", "wrangler.toml", "tsconfig.json",
|
|
@@ -20,14 +21,22 @@ export async function buildWorkspaceBrief(workspace) {
|
|
|
20
21
|
.map((entry) => entry.name).sort().slice(0, 30);
|
|
21
22
|
const files = new Set(entries.filter((entry) => entry.isFile()).map((entry) => entry.name));
|
|
22
23
|
const manifests = MANIFESTS.filter((name) => files.has(name));
|
|
24
|
+
const declared = await readDeclaredVerification(workspace);
|
|
23
25
|
return {
|
|
24
|
-
repository: await
|
|
25
|
-
base_commit: await
|
|
26
|
+
repository: await gitOriginUrl(workspace),
|
|
27
|
+
base_commit: await headCommitOrNull(workspace),
|
|
26
28
|
modules,
|
|
27
29
|
manifests,
|
|
28
30
|
verification: await discoverVerificationCommands(workspace, files),
|
|
31
|
+
declared_verification: declared,
|
|
29
32
|
};
|
|
30
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* Mirror of `src/conductor/repository.ts`. The server fingerprints the initiative URL and the Bridge
|
|
36
|
+
* fingerprints the workspace remote; dispatch requires the two to be equal, so a divergence stops
|
|
37
|
+
* matching silently. The Bridge is a published package and cannot import server code, so
|
|
38
|
+
* `test/matcher.test.ts` ("repository fingerprint agreement") holds the two spellings together.
|
|
39
|
+
*/
|
|
31
40
|
export function normalizeRepositoryUrl(url) {
|
|
32
41
|
let value = url.trim().replace(/\.git$/, "").replace(/\/+$/, "").toLowerCase();
|
|
33
42
|
const ssh = value.match(/^git@([^:]+):(.+)$/);
|
|
@@ -143,61 +152,6 @@ export async function resolveAttemptStartCommit(workspace, requestedBase, claime
|
|
|
143
152
|
return base;
|
|
144
153
|
return null;
|
|
145
154
|
}
|
|
146
|
-
async function gitRemoteUrl(workspace) {
|
|
147
|
-
try {
|
|
148
|
-
const { common } = await gitDirectories(workspace);
|
|
149
|
-
const config = await readFile(join(common, "config"), "utf8");
|
|
150
|
-
const remote = config.match(/\[remote "origin"\][^[]*?url\s*=\s*(\S+)/);
|
|
151
|
-
return remote?.[1] ?? null;
|
|
152
|
-
}
|
|
153
|
-
catch {
|
|
154
|
-
return null;
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
async function gitHeadCommit(workspace) {
|
|
158
|
-
try {
|
|
159
|
-
const { worktree, common } = await gitDirectories(workspace);
|
|
160
|
-
let head = String(await readFile(join(worktree, "HEAD"), "utf8")).trim();
|
|
161
|
-
// Follow one level of symbolic refs (HEAD → refs/heads/main, or a nested ref file).
|
|
162
|
-
for (let hop = 0; hop < 3; hop++) {
|
|
163
|
-
if (/^[0-9a-f]{40,64}$/i.test(head))
|
|
164
|
-
return head.toLowerCase();
|
|
165
|
-
const ref = head.match(/^ref:\s*(\S+)$/)?.[1];
|
|
166
|
-
if (!ref)
|
|
167
|
-
return null;
|
|
168
|
-
try {
|
|
169
|
-
head = String(await readFile(join(common, ref), "utf8")).trim();
|
|
170
|
-
}
|
|
171
|
-
catch {
|
|
172
|
-
const packed = String(await readFile(join(common, "packed-refs"), "utf8"));
|
|
173
|
-
const line = packed.split("\n").find((entry) => {
|
|
174
|
-
const trimmed = entry.trim();
|
|
175
|
-
return trimmed.length > 0 && !trimmed.startsWith("#") && !trimmed.startsWith("^") && trimmed.endsWith(` ${ref}`);
|
|
176
|
-
});
|
|
177
|
-
const sha = line?.split(/\s+/)[0]?.trim() ?? null;
|
|
178
|
-
return sha && /^[0-9a-f]{40,64}$/i.test(sha) ? sha.toLowerCase() : null;
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
return null;
|
|
182
|
-
}
|
|
183
|
-
catch {
|
|
184
|
-
return null;
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
async function gitDirectories(workspace) {
|
|
188
|
-
const dotGit = join(workspace, ".git");
|
|
189
|
-
const info = await stat(dotGit);
|
|
190
|
-
const pointer = info.isDirectory() ? null : (await readFile(dotGit, "utf8")).trim().match(/^gitdir:\s*(.+)$/)?.[1];
|
|
191
|
-
if (!info.isDirectory() && !pointer)
|
|
192
|
-
throw new Error("Invalid Git worktree metadata");
|
|
193
|
-
const worktree = info.isDirectory() ? dotGit : resolve(workspace, pointer);
|
|
194
|
-
try {
|
|
195
|
-
return { worktree, common: resolve(worktree, (await readFile(join(worktree, "commondir"), "utf8")).trim()) };
|
|
196
|
-
}
|
|
197
|
-
catch {
|
|
198
|
-
return { worktree, common: worktree };
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
155
|
/** Exported for tests — discovers bounded verification commands from workspace manifests. */
|
|
202
156
|
/**
|
|
203
157
|
* Commands a project declares for itself, in `.conduit/verification` — one per line, `#` comments
|
|
@@ -227,12 +181,17 @@ export function parseDeclaredVerification(text) {
|
|
|
227
181
|
}
|
|
228
182
|
return declared;
|
|
229
183
|
}
|
|
230
|
-
export async function
|
|
231
|
-
const commands = [];
|
|
184
|
+
export async function readDeclaredVerification(workspace) {
|
|
232
185
|
try {
|
|
233
|
-
|
|
186
|
+
return parseDeclaredVerification(await readFile(join(workspace, ".conduit", "verification"), "utf8"));
|
|
234
187
|
}
|
|
235
|
-
catch {
|
|
188
|
+
catch {
|
|
189
|
+
return []; // no declaration, or unreadable: discovery still applies
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
export async function discoverVerificationCommands(workspace, files) {
|
|
193
|
+
// Declared first: the order is a preference order for every reader that takes one command.
|
|
194
|
+
const commands = await readDeclaredVerification(workspace);
|
|
236
195
|
if (files.has("package.json")) {
|
|
237
196
|
try {
|
|
238
197
|
const manifest = JSON.parse(await readFile(join(workspace, "package.json"), "utf8"));
|
package/dist/checkout.js
CHANGED
|
@@ -11,6 +11,7 @@ import { dirname } from "node:path";
|
|
|
11
11
|
import { execFile } from "node:child_process";
|
|
12
12
|
import { promisify } from "node:util";
|
|
13
13
|
import { normalizeRepositoryUrl } from "./brief.js";
|
|
14
|
+
import { gitOriginUrl } from "./git.js";
|
|
14
15
|
const execFileAsync = promisify(execFile);
|
|
15
16
|
async function pathExists(path, accessFn) {
|
|
16
17
|
try {
|
|
@@ -21,19 +22,6 @@ async function pathExists(path, accessFn) {
|
|
|
21
22
|
return false;
|
|
22
23
|
}
|
|
23
24
|
}
|
|
24
|
-
async function gitOriginUrl(workspace, exec) {
|
|
25
|
-
try {
|
|
26
|
-
const { stdout } = await exec("git", ["-C", workspace, "config", "--get", "remote.origin.url"], {
|
|
27
|
-
timeout: 15_000,
|
|
28
|
-
maxBuffer: 1_000_000,
|
|
29
|
-
});
|
|
30
|
-
const value = stdout.trim();
|
|
31
|
-
return value.length > 0 ? value : null;
|
|
32
|
-
}
|
|
33
|
-
catch {
|
|
34
|
-
return null;
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
25
|
/** A control-plane repository fingerprint needs a transport before Git can clone it. */
|
|
38
26
|
function cloneRepositoryUrls(repositoryUrl) {
|
|
39
27
|
const value = repositoryUrl.trim();
|
package/dist/cli.js
CHANGED
|
@@ -612,19 +612,22 @@ async function runner() {
|
|
|
612
612
|
progressed = await executeNextInvestigation(client, config, workspace, brief, timeoutMs) || progressed;
|
|
613
613
|
}
|
|
614
614
|
if (workspace && onlineDriverIds(config).length) {
|
|
615
|
+
// Read the workspace again, then publish it. The last brief stands if the read fails.
|
|
616
|
+
const publishWorkspaceState = async (options = {}) => {
|
|
617
|
+
const currentBrief = await buildWorkspaceBrief(workspace).catch(() => brief);
|
|
618
|
+
const preflight = await cachedBridgePreflight({ config, workspace, brief: currentBrief ?? undefined }, options);
|
|
619
|
+
const hb = await heartbeat(client, config, workspace, currentBrief, preflight, undefined, bootstrapResult);
|
|
620
|
+
return { preflight, hb };
|
|
621
|
+
};
|
|
615
622
|
progressed = await pumpExecutionSlots(client, config, workspace, brief, timeoutMs, {
|
|
616
623
|
heartbeat: async () => {
|
|
617
|
-
|
|
618
|
-
const preflight = await cachedBridgePreflight({ config, workspace, brief: currentBrief ?? undefined });
|
|
619
|
-
await heartbeat(client, config, workspace, currentBrief, preflight, undefined, bootstrapResult);
|
|
624
|
+
await publishWorkspaceState();
|
|
620
625
|
},
|
|
621
626
|
beforeClaim: async (driverId) => {
|
|
622
627
|
// Never let the five-minute heartbeat cache span a CLI upgrade into a certified claim.
|
|
623
628
|
// Publish the fresh probe first; the Control Plane then remains the authority for the
|
|
624
629
|
// exact version frozen onto the claim response and event.
|
|
625
|
-
const
|
|
626
|
-
const preflight = await cachedBridgePreflight({ config, workspace, brief: currentBrief ?? undefined }, { force: true });
|
|
627
|
-
const hb = await heartbeat(client, config, workspace, currentBrief, preflight, undefined, bootstrapResult);
|
|
630
|
+
const { preflight, hb } = await publishWorkspaceState({ force: true });
|
|
628
631
|
if (hb.staleBridge)
|
|
629
632
|
return false;
|
|
630
633
|
const lane = preflight.drivers.find((candidate) => candidate.id === driverId);
|
package/dist/config.js
CHANGED
|
@@ -262,6 +262,23 @@ export async function saveConfigPrefs(config) {
|
|
|
262
262
|
* nothing without one.
|
|
263
263
|
*/
|
|
264
264
|
export async function saveDriverQuota(driverId, quota) {
|
|
265
|
+
await updateDriverLane(driverId, (lane) => {
|
|
266
|
+
if (quota)
|
|
267
|
+
lane.quota = quota;
|
|
268
|
+
else
|
|
269
|
+
delete lane.quota;
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
/** Persist one lane's execution outcome without overwriting operator preferences or quota state. */
|
|
273
|
+
export async function saveDriverOutcome(driverId, outcome) {
|
|
274
|
+
await updateDriverLane(driverId, (lane) => {
|
|
275
|
+
if (outcome)
|
|
276
|
+
lane.outcome = outcome;
|
|
277
|
+
else
|
|
278
|
+
delete lane.outcome;
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
async function updateDriverLane(driverId, update) {
|
|
265
282
|
const run = async () => {
|
|
266
283
|
await withConfigLock(async () => {
|
|
267
284
|
let prefs;
|
|
@@ -269,15 +286,12 @@ export async function saveDriverQuota(driverId, quota) {
|
|
|
269
286
|
prefs = JSON.parse(await readFile(path, "utf8"));
|
|
270
287
|
}
|
|
271
288
|
catch {
|
|
272
|
-
return; // No prefs on disk yet: nothing to attach
|
|
289
|
+
return; // No prefs on disk yet: nothing to attach lane state to.
|
|
273
290
|
}
|
|
274
291
|
const lane = prefs.drivers?.[driverId];
|
|
275
292
|
if (!lane || typeof lane !== "object")
|
|
276
293
|
return;
|
|
277
|
-
|
|
278
|
-
lane.quota = quota;
|
|
279
|
-
else
|
|
280
|
-
delete lane.quota;
|
|
294
|
+
update(lane);
|
|
281
295
|
await writeJsonAtomic(path, { ...prefs, activeAttempts: {}, sessions: undefined });
|
|
282
296
|
});
|
|
283
297
|
};
|
package/dist/drivers.js
CHANGED
|
@@ -67,6 +67,8 @@ function normalizeOutcome(raw) {
|
|
|
67
67
|
const value = raw;
|
|
68
68
|
if (!Number.isFinite(value.consecutive_timeouts) || typeof value.last_timeout_at !== "string")
|
|
69
69
|
return undefined;
|
|
70
|
+
if (!Number.isFinite(Date.parse(value.last_timeout_at)))
|
|
71
|
+
return undefined;
|
|
70
72
|
const count = Math.max(0, Math.floor(value.consecutive_timeouts));
|
|
71
73
|
return { consecutive_timeouts: count, last_timeout_at: value.last_timeout_at };
|
|
72
74
|
}
|
|
@@ -237,7 +239,7 @@ export function laneTimedOutRecently(config, driverId, now = Date.now()) {
|
|
|
237
239
|
if (!outcome || outcome.consecutive_timeouts < TIMEOUT_SUPPRESSION_THRESHOLD)
|
|
238
240
|
return false;
|
|
239
241
|
const seen = Date.parse(outcome.last_timeout_at);
|
|
240
|
-
return
|
|
242
|
+
return now >= seen && now - seen <= TIMEOUT_SUPPRESSION_MS;
|
|
241
243
|
}
|
|
242
244
|
export function onlineDriverIds(config) {
|
|
243
245
|
const drivers = normalizeDrivers(config.drivers);
|
|
@@ -4,18 +4,9 @@
|
|
|
4
4
|
import { execFile } from "node:child_process";
|
|
5
5
|
import { promisify } from "node:util";
|
|
6
6
|
import { commitsAheadOfBase } from "./ensure-pull-request.js";
|
|
7
|
+
import { readHeadCommit } from "./git.js";
|
|
7
8
|
import { AgentNoLandCommitError, filterPathsInScope, requiresLandCommit, } from "./land-contract.js";
|
|
8
9
|
const execFileAsync = promisify(execFile);
|
|
9
|
-
async function workspaceHeadCommit(workspace) {
|
|
10
|
-
const { stdout } = await execFileAsync("git", ["-C", workspace, "rev-parse", "HEAD"], {
|
|
11
|
-
timeout: 30_000,
|
|
12
|
-
maxBuffer: 1_000_000,
|
|
13
|
-
});
|
|
14
|
-
const head = stdout.trim();
|
|
15
|
-
if (!head)
|
|
16
|
-
throw new Error("Could not read workspace HEAD");
|
|
17
|
-
return head;
|
|
18
|
-
}
|
|
19
10
|
async function uncommittedPaths(workspace) {
|
|
20
11
|
const { stdout } = await execFileAsync("git", ["-C", workspace, "status", "--porcelain"], {
|
|
21
12
|
timeout: 30_000,
|
|
@@ -64,7 +55,7 @@ export async function ensureLandCommit(input) {
|
|
|
64
55
|
if (!base)
|
|
65
56
|
return { kind: "skipped" };
|
|
66
57
|
const countAhead = input.countCommitsAhead ?? commitsAheadOfBase;
|
|
67
|
-
const readHead = input.readHeadCommit ??
|
|
58
|
+
const readHead = input.readHeadCommit ?? readHeadCommit;
|
|
68
59
|
const listPending = input.listUncommittedPaths ?? uncommittedPaths;
|
|
69
60
|
const commitPaths = input.commitPaths ?? commitScopedPaths;
|
|
70
61
|
const ahead = await countAhead(input.workspace, base);
|
|
@@ -6,10 +6,10 @@
|
|
|
6
6
|
import { execFile } from "node:child_process";
|
|
7
7
|
import { promisify } from "node:util";
|
|
8
8
|
import { normalizeRepositoryUrl } from "./brief.js";
|
|
9
|
+
import { readHeadCommit } from "./git.js";
|
|
10
|
+
import { forgeTransportFailure } from "./transport-fault.js";
|
|
9
11
|
import { agentNoLandCommitMessage } from "./land-contract.js";
|
|
10
12
|
const execFileAsync = promisify(execFile);
|
|
11
|
-
/** Mirror execution.ts FORGE_TRANSPORT_PATTERN — keep local to avoid import cycles. */
|
|
12
|
-
const FORGE_TRANSPORT_PATTERN = /unable to access '?https?:\/\/|error in the http2 framing layer|could not resolve host|connection (?:reset|timed out|refused)|\bcurl\b.*\b(?:52|55|56|92)\b|remote end hung up unexpectedly|\brpc failed\b|tls handshake|network is unreachable|operation timed out/i;
|
|
13
13
|
/**
|
|
14
14
|
* Commits the attempt branch has and the base does not.
|
|
15
15
|
* Returns null when this checkout does not know the base. An unverifiable precondition must not
|
|
@@ -103,16 +103,6 @@ export function adoptPullRequestUrlFromEvidence(report, repositoryFingerprint) {
|
|
|
103
103
|
}
|
|
104
104
|
return report;
|
|
105
105
|
}
|
|
106
|
-
async function workspaceHeadCommit(workspace) {
|
|
107
|
-
const { stdout } = await execFileAsync("git", ["-C", workspace, "rev-parse", "HEAD"], {
|
|
108
|
-
timeout: 30_000,
|
|
109
|
-
maxBuffer: 1_000_000,
|
|
110
|
-
});
|
|
111
|
-
const head = stdout.trim().toLowerCase();
|
|
112
|
-
if (!/^[0-9a-f]{7,64}$/.test(head))
|
|
113
|
-
throw new Error("Could not determine workspace HEAD commit");
|
|
114
|
-
return head;
|
|
115
|
-
}
|
|
116
106
|
function execErrorMessage(error) {
|
|
117
107
|
if (!(error instanceof Error))
|
|
118
108
|
return String(error);
|
|
@@ -157,7 +147,7 @@ export async function pushOriginHead(workspace, options = {}) {
|
|
|
157
147
|
catch {
|
|
158
148
|
// fall through to retry / throw
|
|
159
149
|
}
|
|
160
|
-
if (attempt < attempts &&
|
|
150
|
+
if (attempt < attempts && forgeTransportFailure(lastMessage)) {
|
|
161
151
|
await sleep(750 * attempt);
|
|
162
152
|
continue;
|
|
163
153
|
}
|
|
@@ -179,7 +169,7 @@ export async function pushOriginHead(workspace, options = {}) {
|
|
|
179
169
|
* Agent-supplied PR URLs skip creation but still require HEAD identity.
|
|
180
170
|
*/
|
|
181
171
|
export async function ensureDeliveryPullRequest(input) {
|
|
182
|
-
const readHead = input.readHeadCommit ??
|
|
172
|
+
const readHead = input.readHeadCommit ?? readHeadCommit;
|
|
183
173
|
const push = input.pushOriginHead ?? pushOriginHead;
|
|
184
174
|
const countAhead = input.countCommitsAhead ?? commitsAheadOfBase;
|
|
185
175
|
let report = adoptPullRequestUrlFromEvidence(input.report, input.repositoryFingerprint);
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { execFile } from "node:child_process";
|
|
7
7
|
import { promisify } from "node:util";
|
|
8
|
-
import { isBoundedVerificationCommand } from "./execution-class.js";
|
|
8
|
+
import { isBoundedVerificationCommand, isRunnableVerificationCommand } from "./execution-class.js";
|
|
9
9
|
const execFileAsync = promisify(execFile);
|
|
10
10
|
const TEST_EVIDENCE_DETAILS_MIN = 32;
|
|
11
11
|
/** Cold `cargo test` in an attempt worktree routinely exceeds two minutes of compile. */
|
|
@@ -23,22 +23,53 @@ export function needsTestEvidence(_report, spec, grants) {
|
|
|
23
23
|
* is not satisfied by a silent `npm run typecheck`.
|
|
24
24
|
*/
|
|
25
25
|
export function isPreferentialTestCommand(command) {
|
|
26
|
-
return /^(npm run test|pnpm test|yarn test|cargo test|go test(?: \.\/\.\.\.)?|make test|
|
|
26
|
+
return /^(npm run test|pnpm test|yarn test|cargo test|go test(?: \.\/\.\.\.)?|make test|python3? -m (?:pytest|unittest)(?: [\w./=:-]+)*|pytest(?: [\w./=-]+)?|\.\/gradlew test)$/.test(command.trim());
|
|
27
27
|
}
|
|
28
28
|
export function pickVerificationCommand(commands) {
|
|
29
29
|
const bounded = commands
|
|
30
30
|
.map((command) => command.trim())
|
|
31
|
-
.filter((command) => command &&
|
|
31
|
+
.filter((command) => command && isRunnableVerificationCommand(command));
|
|
32
32
|
return bounded.find(isPreferentialTestCommand) ?? bounded[0] ?? null;
|
|
33
33
|
}
|
|
34
34
|
/** Prefer one project-defined aggregate; otherwise witness every distinct bounded command. */
|
|
35
35
|
export function verificationEvidenceCommands(commands) {
|
|
36
36
|
const bounded = [...new Set(commands
|
|
37
37
|
.map((command) => command.trim())
|
|
38
|
-
.filter((command) => command &&
|
|
38
|
+
.filter((command) => command && isRunnableVerificationCommand(command)))];
|
|
39
39
|
const aggregate = bounded.find((command) => /^(?:npm run|pnpm|yarn) verify$|^make check$/.test(command));
|
|
40
40
|
return aggregate ? [aggregate] : bounded;
|
|
41
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* Bounded verification commands named inside free text. Mirror of the control plane's
|
|
44
|
+
* `verificationCommandsMentioned` in src/conductor/verification-capability.ts — Bridge cannot import
|
|
45
|
+
* server code, and test/planner-capability.test.ts pins the two together.
|
|
46
|
+
*/
|
|
47
|
+
export function verificationCommandsMentioned(text) {
|
|
48
|
+
const pattern = /(?:^|[\s("'`])((?:npm run [A-Za-z0-9][A-Za-z0-9._:-]*|pnpm [A-Za-z0-9][A-Za-z0-9._:-]*|yarn [A-Za-z0-9][A-Za-z0-9._:-]*|cargo (?:test|check)|go test(?: \.\/\.\.\.)?|make [A-Za-z0-9][A-Za-z0-9._-]*|python3? -m [A-Za-z0-9_][\w.]*(?: [\w./=:-]*[./=-][\w./=:-]*)*|pytest(?: [\w./=-]+)?|python3? [\w./-]+\.py(?: [\w./=:-]*[./=-][\w./=:-]*)*|\.\/gradlew test))(?=$|[\s"'`),.;:])/g;
|
|
49
|
+
return [...new Set([...text.slice(0, 20_000).matchAll(pattern)].map((match) => match[1]).filter(isBoundedVerificationCommand))];
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Does a discovered or declared command authorize this one? The agent receives `Bash(<command>:*)`,
|
|
53
|
+
* so a command authorizes itself with arguments. Mirror of the control plane's
|
|
54
|
+
* `verificationCommandIsAuthorized`.
|
|
55
|
+
*/
|
|
56
|
+
export function verificationCommandIsAuthorized(command, discovered) {
|
|
57
|
+
return discovered.some((offered) => command === offered || command.startsWith(`${offered} `));
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The commands the acceptance criteria name, when the workspace authorizes them.
|
|
61
|
+
*
|
|
62
|
+
* A criterion that says `python3 tools/mutate.py --mutants …/mutants.json reports every mutant killed`
|
|
63
|
+
* names its own witness. Witnessing only the aggregate gate left that command agent-attested, the
|
|
64
|
+
* reviewer could not treat the criterion as supported, and a delivery whose code was correct still
|
|
65
|
+
* landed in Needs you instead of Ready. A named command runs only when a discovered or declared
|
|
66
|
+
* command is its prefix: the criterion cannot widen the shell the grant already bounds.
|
|
67
|
+
*/
|
|
68
|
+
export function acceptanceVerificationCommands(acceptance, discovered) {
|
|
69
|
+
const authorized = discovered.map((command) => command.trim()).filter(Boolean);
|
|
70
|
+
return verificationCommandsMentioned(acceptance.join("\n"))
|
|
71
|
+
.filter((command) => isRunnableVerificationCommand(command) && verificationCommandIsAuthorized(command, authorized));
|
|
72
|
+
}
|
|
42
73
|
function argvForBoundedCommand(command) {
|
|
43
74
|
return command.trim().split(/\s+/);
|
|
44
75
|
}
|
|
@@ -62,13 +93,21 @@ function argvForBoundedCommand(command) {
|
|
|
62
93
|
export function diagnosisVerificationCommands(commands) {
|
|
63
94
|
const bounded = commands
|
|
64
95
|
.map((command) => command.trim())
|
|
65
|
-
.filter((command) => command &&
|
|
96
|
+
.filter((command) => command && isRunnableVerificationCommand(command));
|
|
66
97
|
const nonTest = bounded.filter((command) => !isPreferentialTestCommand(command));
|
|
67
98
|
const tests = bounded.filter((command) => isPreferentialTestCommand(command));
|
|
68
99
|
return [...nonTest, ...tests];
|
|
69
100
|
}
|
|
70
101
|
export async function captureVerificationFailure(input) {
|
|
71
|
-
|
|
102
|
+
// A declared prefix such as `python3 tools/mutate.py` is authority, not a gate: run bare it only
|
|
103
|
+
// prints usage and exits non-zero, which would be reported as the failure. When a criterion names
|
|
104
|
+
// the full command, that command is the gate and the bare prefix is not run.
|
|
105
|
+
const named = acceptanceVerificationCommands(input.acceptance ?? [], input.verificationCommands);
|
|
106
|
+
const commands = [...new Set([
|
|
107
|
+
...diagnosisVerificationCommands(input.verificationCommands)
|
|
108
|
+
.filter((command) => !named.some((full) => full !== command && full.startsWith(`${command} `))),
|
|
109
|
+
...named,
|
|
110
|
+
])];
|
|
72
111
|
if (commands.length === 0)
|
|
73
112
|
return null;
|
|
74
113
|
const run = input.runCommand ?? defaultRunCommand;
|
|
@@ -236,7 +275,7 @@ function verificationCommandNames(command) {
|
|
|
236
275
|
names.add(script);
|
|
237
276
|
return names;
|
|
238
277
|
}
|
|
239
|
-
export function criteriaForTestEvidence(report, command) {
|
|
278
|
+
export function criteriaForTestEvidence(report, command, acceptance = []) {
|
|
240
279
|
const testEvidence = report.evidence.filter((item) => item.kind === "test");
|
|
241
280
|
const commandNames = command ? verificationCommandNames(command) : null;
|
|
242
281
|
const sources = commandNames
|
|
@@ -252,6 +291,20 @@ export function criteriaForTestEvidence(report, command) {
|
|
|
252
291
|
mapped.add(criterion);
|
|
253
292
|
}
|
|
254
293
|
}
|
|
294
|
+
// A criterion that names the command verbatim is proved by that command's witness, whether or
|
|
295
|
+
// not the agent remembered to map it. Exact command text, followed by nothing that could extend
|
|
296
|
+
// it: `make check` must not claim a criterion about `make checkout`.
|
|
297
|
+
if (command) {
|
|
298
|
+
const exact = command.trim();
|
|
299
|
+
for (const criterion of acceptance) {
|
|
300
|
+
const at = criterion.indexOf(exact);
|
|
301
|
+
if (at < 0)
|
|
302
|
+
continue;
|
|
303
|
+
const next = criterion.charAt(at + exact.length);
|
|
304
|
+
if (next === "" || !/[\w./=:-]/.test(next))
|
|
305
|
+
mapped.add(criterion);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
255
308
|
return [...mapped];
|
|
256
309
|
}
|
|
257
310
|
/**
|
|
@@ -261,7 +314,13 @@ export function criteriaForTestEvidence(report, command) {
|
|
|
261
314
|
export async function ensureTestEvidence(input) {
|
|
262
315
|
if (!needsTestEvidence(input.report, input.spec, input.grants))
|
|
263
316
|
return input.report;
|
|
264
|
-
const
|
|
317
|
+
const acceptance = input.spec.acceptance ?? [];
|
|
318
|
+
// The project's gate first, then every bounded command the criteria name that the workspace
|
|
319
|
+
// authorizes. Each is witnessed on its own, so the reviewer sees the criterion's own proof.
|
|
320
|
+
const commands = [...new Set([
|
|
321
|
+
...verificationEvidenceCommands(input.verificationCommands),
|
|
322
|
+
...acceptanceVerificationCommands(acceptance, input.verificationCommands),
|
|
323
|
+
])];
|
|
265
324
|
if (commands.length === 0) {
|
|
266
325
|
throw new Error("Agent report is missing required evidence: test (no bounded verification command available for Bridge to run)");
|
|
267
326
|
}
|
|
@@ -307,7 +366,7 @@ export async function ensureTestEvidence(input) {
|
|
|
307
366
|
kind: "test",
|
|
308
367
|
name: command,
|
|
309
368
|
details,
|
|
310
|
-
acceptance_criteria: criteriaForTestEvidence(input.report, command),
|
|
369
|
+
acceptance_criteria: criteriaForTestEvidence(input.report, command, acceptance),
|
|
311
370
|
witness: "bridge",
|
|
312
371
|
});
|
|
313
372
|
}
|
package/dist/execution-class.js
CHANGED
|
@@ -10,7 +10,7 @@ export function isBoundedVerificationCommand(command) {
|
|
|
10
10
|
// project from naming its own gate, which is what forced four attempts to die on a command they
|
|
11
11
|
// could not run. The boundary that matters is the SHAPE: one tool, one identifier, no shell
|
|
12
12
|
// metacharacters, so nothing can be smuggled in through the name.
|
|
13
|
-
return /^(npm run [A-Za-z0-9][A-Za-z0-9._:-]*|pnpm [A-Za-z0-9][A-Za-z0-9._:-]*|yarn [A-Za-z0-9][A-Za-z0-9._:-]*|cargo (test|check)|go test(?: \.\/\.\.\.)?|make [A-Za-z0-9][A-Za-z0-9._-]*|
|
|
13
|
+
return /^(npm run [A-Za-z0-9][A-Za-z0-9._:-]*|pnpm [A-Za-z0-9][A-Za-z0-9._:-]*|yarn [A-Za-z0-9][A-Za-z0-9._:-]*|cargo (test|check)|go test(?: \.\/\.\.\.)?|make [A-Za-z0-9][A-Za-z0-9._-]*|python3? -m [A-Za-z0-9_][\w.]*(?: [\w./=:-]+)*|pytest(?: [\w./=-]+)?|python3? [\w./-]+\.py(?: [\w./=:-]+)*|\.\/gradlew test)$/.test(command);
|
|
14
14
|
}
|
|
15
15
|
/** Shell commands each grant authorizes — mapped per driver so they cannot drift. */
|
|
16
16
|
export const branchCreateCommands = [
|
|
@@ -25,6 +25,31 @@ export const deniedCommands = [
|
|
|
25
25
|
];
|
|
26
26
|
/** Land commands denied for publish_artifact / observe* (delivery gate still enforces). */
|
|
27
27
|
export const landCommands = ["git commit", "git push", "gh pr create"];
|
|
28
|
+
/** Script and target names that publish, whatever tool runs them. */
|
|
29
|
+
const DENIED_ACTION_PATTERN = /^(?:npm run|pnpm|yarn|make)\s+[\w.:-]*\b(?:deploy|publish|release|promote)\b/i;
|
|
30
|
+
/**
|
|
31
|
+
* A denied action, read the way permission is granted.
|
|
32
|
+
*
|
|
33
|
+
* `Bash(git push:*)` authorizes `git push` and its arguments, so a denial must cover the arguments
|
|
34
|
+
* too. Equality covered only the exact spelling: `pnpm deploy:prod` and `make deploy` were never on
|
|
35
|
+
* the list, and a project names its own gates now, so the spelling reaching a driver is the
|
|
36
|
+
* project's. The check therefore reads the action.
|
|
37
|
+
*/
|
|
38
|
+
export function isDeniedCommand(command) {
|
|
39
|
+
const text = command.trim();
|
|
40
|
+
return deniedCommands.some((denied) => text === denied || text.startsWith(`${denied} `))
|
|
41
|
+
|| DENIED_ACTION_PATTERN.test(text);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* A command a `test_run` grant may run: bounded in shape, and not a denied action.
|
|
45
|
+
*
|
|
46
|
+
* Every driver projection asks this one question. Only the prompt used to remove denied commands, so
|
|
47
|
+
* `Bash(npm run deploy:*)` reached the agent while the prompt beside it said that command was
|
|
48
|
+
* forbidden.
|
|
49
|
+
*/
|
|
50
|
+
export function isRunnableVerificationCommand(command) {
|
|
51
|
+
return isBoundedVerificationCommand(command) && !isDeniedCommand(command);
|
|
52
|
+
}
|
|
28
53
|
export const EXECUTION_CLASSES = [
|
|
29
54
|
"observe",
|
|
30
55
|
"observe_network",
|
|
@@ -82,7 +107,7 @@ export function executionClassPromptRules(input) {
|
|
|
82
107
|
const rules = [];
|
|
83
108
|
const grantShell = [
|
|
84
109
|
...(grants.includes("test_run")
|
|
85
|
-
? verificationCommands.filter(
|
|
110
|
+
? verificationCommands.filter(isRunnableVerificationCommand)
|
|
86
111
|
: []),
|
|
87
112
|
...(grants.includes("branch_create") ? branchCreateCommands : []),
|
|
88
113
|
...(grants.includes("pr_create") ? prCreateCommands : []),
|
|
@@ -146,7 +171,7 @@ export function projectCursor(executionClass, input) {
|
|
|
146
171
|
allow.push("WebFetch(*)");
|
|
147
172
|
if (executionClass === "mutate_repo" || executionClass === "verify") {
|
|
148
173
|
if (grants.includes("test_run")) {
|
|
149
|
-
allow.push(...verificationCommands.filter(
|
|
174
|
+
allow.push(...verificationCommands.filter(isRunnableVerificationCommand).map((command) => `Shell(${command})`));
|
|
150
175
|
}
|
|
151
176
|
if (executionClass === "mutate_repo" && !input.diagnosis) {
|
|
152
177
|
if (grants.includes("branch_create"))
|
|
@@ -182,7 +207,7 @@ export function projectClaude(executionClass, input) {
|
|
|
182
207
|
return {
|
|
183
208
|
allowedTools: [
|
|
184
209
|
"Read", "Glob", "Grep",
|
|
185
|
-
...verificationCommands.filter(
|
|
210
|
+
...verificationCommands.filter(isRunnableVerificationCommand).map((command) => `Bash(${command}:*)`),
|
|
186
211
|
...fetchTools,
|
|
187
212
|
],
|
|
188
213
|
disallowedTools,
|
|
@@ -200,7 +225,7 @@ export function projectClaude(executionClass, input) {
|
|
|
200
225
|
return {
|
|
201
226
|
allowedTools: [
|
|
202
227
|
"Read",
|
|
203
|
-
...verificationCommands.filter(
|
|
228
|
+
...verificationCommands.filter(isRunnableVerificationCommand).map((command) => `Bash(${command}:*)`),
|
|
204
229
|
...fetchTools,
|
|
205
230
|
],
|
|
206
231
|
disallowedTools,
|
|
@@ -214,7 +239,7 @@ export function projectClaude(executionClass, input) {
|
|
|
214
239
|
if (grants.includes("repo_write"))
|
|
215
240
|
tools.push("Edit", "Write");
|
|
216
241
|
if (grants.includes("test_run")) {
|
|
217
|
-
tools.push(...verificationCommands.filter(
|
|
242
|
+
tools.push(...verificationCommands.filter(isRunnableVerificationCommand).map((command) => `Bash(${command}:*)`));
|
|
218
243
|
}
|
|
219
244
|
if (grants.includes("branch_create"))
|
|
220
245
|
tools.push(...branchCreateCommands.map((command) => `Bash(${command}:*)`));
|
|
@@ -272,12 +297,12 @@ export function projectGrok(executionClass, input) {
|
|
|
272
297
|
return { sandbox: "read-only", allow, deny, disableWebSearch: !fetch, disallowedTools };
|
|
273
298
|
}
|
|
274
299
|
if (input.diagnosis) {
|
|
275
|
-
allow.push("Read(*)", "Grep(*)", ...verificationCommands.filter(
|
|
300
|
+
allow.push("Read(*)", "Grep(*)", ...verificationCommands.filter(isRunnableVerificationCommand).map(grokBash));
|
|
276
301
|
deny.push("Edit(*)", "Write(*)");
|
|
277
302
|
return { sandbox: "read-only", allow, deny, disableWebSearch: !fetch, disallowedTools };
|
|
278
303
|
}
|
|
279
304
|
if (executionClass === "verify") {
|
|
280
|
-
allow.push("Read(*)", "Grep(*)", ...verificationCommands.filter(
|
|
305
|
+
allow.push("Read(*)", "Grep(*)", ...verificationCommands.filter(isRunnableVerificationCommand).map(grokBash));
|
|
281
306
|
deny.push("Edit(*)", "Write(*)");
|
|
282
307
|
return { sandbox: "workspace", allow, deny, disableWebSearch: !fetch, disallowedTools };
|
|
283
308
|
}
|
|
@@ -290,7 +315,7 @@ export function projectGrok(executionClass, input) {
|
|
|
290
315
|
if (grants.includes("repo_write"))
|
|
291
316
|
allow.push("Edit(*)", "Write(*)");
|
|
292
317
|
if (grants.includes("test_run")) {
|
|
293
|
-
allow.push(...verificationCommands.filter(
|
|
318
|
+
allow.push(...verificationCommands.filter(isRunnableVerificationCommand).map(grokBash));
|
|
294
319
|
}
|
|
295
320
|
if (grants.includes("branch_create"))
|
|
296
321
|
allow.push(...branchCreateCommands.map(grokBash));
|
package/dist/execution-facts.js
CHANGED
|
@@ -4,6 +4,7 @@ import { join } from "node:path";
|
|
|
4
4
|
import { execFile } from "node:child_process";
|
|
5
5
|
import { promisify } from "node:util";
|
|
6
6
|
import { normalizeRepositoryUrl } from "./brief.js";
|
|
7
|
+
import { commitOrNull } from "./git.js";
|
|
7
8
|
const execFileAsync = promisify(execFile);
|
|
8
9
|
export const EXECUTION_CLASSES = ["observe", "observe_network", "publish_artifact", "verify", "mutate_repo"];
|
|
9
10
|
export const BOOTSTRAP_RESULTS = ["not_applicable", "not_run", "installed", "failed"];
|
|
@@ -91,22 +92,6 @@ export async function workspaceIsClean(workspace) {
|
|
|
91
92
|
return false;
|
|
92
93
|
}
|
|
93
94
|
}
|
|
94
|
-
/** Read only the bounded HEAD identity retained when an attempt terminates. */
|
|
95
|
-
export async function finalCommitForWorkspace(workspace) {
|
|
96
|
-
try {
|
|
97
|
-
const result = await execFileAsync("git", ["-C", workspace, "rev-parse", "HEAD"], {
|
|
98
|
-
timeout: 15_000,
|
|
99
|
-
windowsHide: true,
|
|
100
|
-
});
|
|
101
|
-
return commitOrNull(result.stdout.trim());
|
|
102
|
-
}
|
|
103
|
-
catch {
|
|
104
|
-
return null;
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
function commitOrNull(value) {
|
|
108
|
-
return value && /^[0-9a-f]{40,64}$/i.test(value) ? value.toLowerCase() : null;
|
|
109
|
-
}
|
|
110
95
|
/** Build one self-contained heartbeat/attempt snapshot from existing Bridge observations. */
|
|
111
96
|
export async function buildSovereignExecutionFacts(input) {
|
|
112
97
|
const repository = input.brief?.repository?.trim();
|
package/dist/execution.js
CHANGED
|
@@ -3,7 +3,7 @@ import { createHash } from "node:crypto";
|
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { resolveAgentTimeout } from "./execution-budget.js";
|
|
5
5
|
import { ConduitRequestError } from "./client.js";
|
|
6
|
-
import { redactSecrets, saveDriverQuota } from "./config.js";
|
|
6
|
+
import { redactSecrets, saveDriverOutcome, saveDriverQuota } from "./config.js";
|
|
7
7
|
import { DRIVERS, agentReportTemplate, buildAssignmentPrompt, evidenceKinds, extractAgentReportJsonText, fuelEndpoint, normalizeEvidenceDigest, parseAgentReport, parseJsonObjectCandidate, pickModelCandidate, requireStampedExecutionClass, tierForRisk } from "./driver.js";
|
|
8
8
|
import { assertClassFloor } from "./execution-class.js";
|
|
9
9
|
import { pickDriverForClaim, recordDriverQuota, resolveDriverFuel, resolveDriverFuelProvenance, supportsReadOnlyDiagnosis, clearDriverOutcome, recordDriverTimeout } from "./drivers.js";
|
|
@@ -11,7 +11,9 @@ import { attemptWorktreePath, createAttemptWorktree, proveResumeWorktree, quaran
|
|
|
11
11
|
import { execFile } from "node:child_process";
|
|
12
12
|
import { promisify } from "node:util";
|
|
13
13
|
import { buildWorkspaceBrief, ensureCommitAvailable, normalizeRepositoryUrl, resolveAttemptStartCommit } from "./brief.js";
|
|
14
|
-
import { buildSovereignExecutionFacts, bootstrapResultForWorkspace,
|
|
14
|
+
import { buildSovereignExecutionFacts, bootstrapResultForWorkspace, workspaceIsClean } from "./execution-facts.js";
|
|
15
|
+
import { forgeTransportFailure } from "./transport-fault.js";
|
|
16
|
+
import { headCommitOrNull } from "./git.js";
|
|
15
17
|
import { BRIDGE_PROTOCOL_VERSION } from "./preflight.js";
|
|
16
18
|
import { bridgeVersion } from "./version.js";
|
|
17
19
|
import { ensureDeliveryPullRequest } from "./ensure-pull-request.js";
|
|
@@ -63,15 +65,6 @@ function changesRequestedFeedback(summary) {
|
|
|
63
65
|
return null;
|
|
64
66
|
}
|
|
65
67
|
}
|
|
66
|
-
/**
|
|
67
|
-
* A network fault reaching the code host. Mirrors FORGE_TRANSPORT_PATTERN in the control plane's
|
|
68
|
-
* failures.ts — Bridge cannot import from there, so test/failure-classification.test.ts pins the two
|
|
69
|
-
* together rather than trusting them to stay in step.
|
|
70
|
-
*/
|
|
71
|
-
export const FORGE_TRANSPORT_PATTERN = /unable to access '?https?:\/\/|error in the http2 framing layer|could not resolve host|connection (?:reset|timed out|refused)|\bcurl\b.*\b(?:52|55|56|92)\b|remote end hung up unexpectedly|\brpc failed\b|tls handshake|network is unreachable|operation timed out/i;
|
|
72
|
-
export function forgeTransportFailure(message) {
|
|
73
|
-
return FORGE_TRANSPORT_PATTERN.test(message);
|
|
74
|
-
}
|
|
75
68
|
/**
|
|
76
69
|
* A vendor refusing a local-fuel run because the operator's subscription window is spent.
|
|
77
70
|
*
|
|
@@ -242,7 +235,10 @@ export async function learnDriverFuel(config, driverId, fuelSource, result) {
|
|
|
242
235
|
// Only this lane's quota reaches disk. Writing the whole snapshot would carry the rest of a config
|
|
243
236
|
// that may be minutes stale back over the operator's newer choices — see saveDriverQuota.
|
|
244
237
|
// A fuel record is worth less than the run it came from: never let a disk fault fail the attempt.
|
|
245
|
-
await
|
|
238
|
+
await Promise.all([
|
|
239
|
+
saveDriverQuota(driverId, drivers?.[driverId]?.quota),
|
|
240
|
+
saveDriverOutcome(driverId, drivers?.[driverId]?.outcome),
|
|
241
|
+
]).catch(() => undefined);
|
|
246
242
|
}
|
|
247
243
|
/**
|
|
248
244
|
* Learn from what the run did, not from what a vendor said about it.
|
|
@@ -699,7 +695,8 @@ export async function claimNextAssignment(client, config, workspace, brief, driv
|
|
|
699
695
|
for (const attemptId of releasedWorktrees) {
|
|
700
696
|
const worktree = attemptWorktreePath(workspace, attemptId);
|
|
701
697
|
await removeAttemptWorktree(workspace, worktree)
|
|
702
|
-
.then(() =>
|
|
698
|
+
.then((released) => { if (released)
|
|
699
|
+
console.log(`Released settled diagnostic worktree ${worktree}`); })
|
|
703
700
|
.catch((error) => console.error(`Diagnostic worktree release failed: ${redactSecrets(error instanceof Error ? error.message : "unknown")}`));
|
|
704
701
|
}
|
|
705
702
|
const assignments = z.array(assignmentSchema).parse(data.assignments ?? []);
|
|
@@ -1238,6 +1235,7 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
|
|
|
1238
1235
|
? await captureVerificationFailure({
|
|
1239
1236
|
workspace: attemptWorkspace,
|
|
1240
1237
|
verificationCommands: attemptBrief?.verification ?? [],
|
|
1238
|
+
acceptance: spec.acceptance ?? [],
|
|
1241
1239
|
})
|
|
1242
1240
|
: null;
|
|
1243
1241
|
const message = verificationDetail
|
|
@@ -1414,7 +1412,9 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
|
|
|
1414
1412
|
}
|
|
1415
1413
|
catch (repairError) {
|
|
1416
1414
|
const message = repairError instanceof Error ? repairError.message : "Delivery report repair failed";
|
|
1417
|
-
|
|
1415
|
+
const detail = `Delivery report repair failed: ${message}`;
|
|
1416
|
+
const retryable = retryableAgentFailure(message);
|
|
1417
|
+
await queueTerminal(client, taskId, { action: "fail", body: { error: detail, retryable, failure: retryable ? undefined : deliveryReportRepairFailure(detail), idempotency_key: `bridge:delivery-repair-failed:${active.attemptId}` } });
|
|
1418
1418
|
console.error(`Assignment ${taskId} report-only repair failed: ${redactSecrets(message)}`);
|
|
1419
1419
|
return;
|
|
1420
1420
|
}
|
|
@@ -1426,7 +1426,8 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
|
|
|
1426
1426
|
}
|
|
1427
1427
|
if (repaired.status === "failed") {
|
|
1428
1428
|
const message = repaired.error ?? "Delivery report repair failed";
|
|
1429
|
-
|
|
1429
|
+
const retryable = retryableAgentFailure(message);
|
|
1430
|
+
await queueTerminal(client, taskId, { action: "fail", body: { error: message, retryable, failure: retryable ? undefined : deliveryReportRepairFailure(message), idempotency_key: `bridge:delivery-repair-failed:${active.attemptId}` } });
|
|
1430
1431
|
console.error(`Assignment ${taskId} report-only repair failed: ${redactSecrets(message)}`);
|
|
1431
1432
|
return;
|
|
1432
1433
|
}
|
|
@@ -1448,15 +1449,7 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
|
|
|
1448
1449
|
body: {
|
|
1449
1450
|
error: detail,
|
|
1450
1451
|
retryable: false,
|
|
1451
|
-
failure:
|
|
1452
|
-
code: "delivery_report_invalid",
|
|
1453
|
-
class: "contract",
|
|
1454
|
-
disposition: "rework",
|
|
1455
|
-
responsible_party: "conduit",
|
|
1456
|
-
message: "Conduit could not prepare a valid delivery report from the completed agent run.",
|
|
1457
|
-
next_action: "Conductor will prepare bounded recovery guidance. You do not need to edit technical constraints.",
|
|
1458
|
-
diagnostic_detail: detail,
|
|
1459
|
-
},
|
|
1452
|
+
failure: deliveryReportRepairFailure(detail),
|
|
1460
1453
|
idempotency_key: `bridge:delivery-repair-invalid:${active.attemptId}`,
|
|
1461
1454
|
},
|
|
1462
1455
|
});
|
|
@@ -2090,11 +2083,29 @@ function retainDiagnosticWorktree(response) {
|
|
|
2090
2083
|
// receive that decision, so fail safe by retaining the only copy of the failed code.
|
|
2091
2084
|
return response.retain_worktree === true || response.status === "invalid_lease";
|
|
2092
2085
|
}
|
|
2086
|
+
/**
|
|
2087
|
+
* The envelope for a delivery report the Bridge could not repair.
|
|
2088
|
+
*
|
|
2089
|
+
* The implementation finished; only the response envelope is unusable. The control plane must be
|
|
2090
|
+
* able to tell that from the code alone -- it used to read the failure text, and prose is not an
|
|
2091
|
+
* authority. A retryable fault is not this: it gets another attempt rather than repair guidance.
|
|
2092
|
+
*/
|
|
2093
|
+
function deliveryReportRepairFailure(detail) {
|
|
2094
|
+
return {
|
|
2095
|
+
code: "delivery_report_invalid",
|
|
2096
|
+
class: "contract",
|
|
2097
|
+
disposition: "rework",
|
|
2098
|
+
responsible_party: "conduit",
|
|
2099
|
+
message: "Conduit could not prepare a valid delivery report from the completed agent run.",
|
|
2100
|
+
next_action: "Conductor will prepare bounded recovery guidance. You do not need to edit technical constraints.",
|
|
2101
|
+
diagnostic_detail: detail,
|
|
2102
|
+
};
|
|
2103
|
+
}
|
|
2093
2104
|
async function queueTerminal(client, taskId, terminal) {
|
|
2094
2105
|
const active = client.attempt(taskId);
|
|
2095
2106
|
let executionFacts = active.executionFacts;
|
|
2096
2107
|
if (executionFacts && executionFacts.final_commit === null && active.worktreePath) {
|
|
2097
|
-
const finalCommit = await
|
|
2108
|
+
const finalCommit = await headCommitOrNull(active.worktreePath);
|
|
2098
2109
|
if (finalCommit) {
|
|
2099
2110
|
executionFacts = { ...executionFacts, final_commit: finalCommit };
|
|
2100
2111
|
await client.updateAttempt(taskId, { executionFacts });
|
package/dist/git.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every read of Git identity in a workspace — HEAD and the origin URL.
|
|
3
|
+
* One shape of commit identity, so the control plane never receives two spellings
|
|
4
|
+
* of the same object name from two callers.
|
|
5
|
+
*/
|
|
6
|
+
import { execFile } from "node:child_process";
|
|
7
|
+
import { promisify } from "node:util";
|
|
8
|
+
const execFileAsync = promisify(execFile);
|
|
9
|
+
/** A full object name, lowercased, or null when the text is not one. */
|
|
10
|
+
export function commitOrNull(value) {
|
|
11
|
+
const text = value?.trim() ?? "";
|
|
12
|
+
return /^[0-9a-f]{40,64}$/i.test(text) ? text.toLowerCase() : null;
|
|
13
|
+
}
|
|
14
|
+
/** The workspace HEAD, or null when the directory is not a readable repository. */
|
|
15
|
+
export async function headCommitOrNull(workspace) {
|
|
16
|
+
try {
|
|
17
|
+
const { stdout } = await execFileAsync("git", ["-C", workspace, "rev-parse", "HEAD"], {
|
|
18
|
+
timeout: 30_000,
|
|
19
|
+
maxBuffer: 1_000_000,
|
|
20
|
+
windowsHide: true,
|
|
21
|
+
});
|
|
22
|
+
return commitOrNull(stdout);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** The workspace HEAD, where a missing HEAD must end the step instead of being reported as absent. */
|
|
29
|
+
export async function readHeadCommit(workspace) {
|
|
30
|
+
const head = await headCommitOrNull(workspace);
|
|
31
|
+
if (!head)
|
|
32
|
+
throw new Error("Could not determine workspace HEAD commit");
|
|
33
|
+
return head;
|
|
34
|
+
}
|
|
35
|
+
/** The configured origin, or null when the workspace has no origin remote. */
|
|
36
|
+
export async function gitOriginUrl(workspace, exec = execFileAsync) {
|
|
37
|
+
try {
|
|
38
|
+
const { stdout } = await exec("git", ["-C", workspace, "config", "--get", "remote.origin.url"], {
|
|
39
|
+
timeout: 15_000,
|
|
40
|
+
maxBuffer: 1_000_000,
|
|
41
|
+
});
|
|
42
|
+
const value = stdout.trim();
|
|
43
|
+
return value.length > 0 ? value : null;
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
package/dist/investigation.js
CHANGED
|
@@ -9,6 +9,7 @@ import { z } from "zod";
|
|
|
9
9
|
import { boundedTail } from "./ensure-test-evidence.js";
|
|
10
10
|
import { createAttemptWorktree, removeAttemptWorktree } from "./attempt-worktree.js";
|
|
11
11
|
import { buildWorkspaceBrief, ensureCommitAvailable, normalizeRepositoryUrl } from "./brief.js";
|
|
12
|
+
import { headCommitOrNull } from "./git.js";
|
|
12
13
|
import { redactSecrets } from "./config.js";
|
|
13
14
|
import { learnDriverFuel } from "./execution.js";
|
|
14
15
|
import { DRIVERS, extractAgentReportJsonText, parseJsonObjectCandidate } from "./driver.js";
|
|
@@ -93,9 +94,11 @@ export function parseInvestigationBrief(text) {
|
|
|
93
94
|
*/
|
|
94
95
|
async function workspaceFingerprint(workspace) {
|
|
95
96
|
try {
|
|
96
|
-
const head = await
|
|
97
|
+
const head = await headCommitOrNull(workspace);
|
|
98
|
+
if (!head)
|
|
99
|
+
return null;
|
|
97
100
|
const dirty = await execFileAsync("git", ["-C", workspace, "status", "--porcelain"], { timeout: 30_000, maxBuffer: 8_000_000 });
|
|
98
|
-
return `${head
|
|
101
|
+
return `${head}\n${dirty.stdout.trim()}`;
|
|
99
102
|
}
|
|
100
103
|
catch {
|
|
101
104
|
return null;
|
package/dist/normative-refs.js
CHANGED
|
@@ -11,6 +11,7 @@ import { execFile } from "node:child_process";
|
|
|
11
11
|
import { promisify } from "node:util";
|
|
12
12
|
import { z } from "zod";
|
|
13
13
|
import { normalizeRepositoryUrl } from "./brief.js";
|
|
14
|
+
import { headCommitOrNull } from "./git.js";
|
|
14
15
|
const execFileAsync = promisify(execFile);
|
|
15
16
|
export const normativeRefSchema = z.object({
|
|
16
17
|
label: z.string().trim().min(1).max(120),
|
|
@@ -68,18 +69,6 @@ async function pathExists(path) {
|
|
|
68
69
|
return false;
|
|
69
70
|
}
|
|
70
71
|
}
|
|
71
|
-
async function readPinnedRevision(repoDir) {
|
|
72
|
-
try {
|
|
73
|
-
const { stdout } = await execFileAsync("git", ["-C", repoDir, "rev-parse", "HEAD"], {
|
|
74
|
-
timeout: 30_000,
|
|
75
|
-
maxBuffer: 256_000,
|
|
76
|
-
});
|
|
77
|
-
return stdout.trim() || null;
|
|
78
|
-
}
|
|
79
|
-
catch {
|
|
80
|
-
return null;
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
72
|
async function gitNoPrompt(args, timeout, maxBuffer) {
|
|
84
73
|
await execFileAsync("git", args, { timeout, maxBuffer, env: GIT_NO_PROMPT });
|
|
85
74
|
}
|
|
@@ -127,7 +116,7 @@ async function ensureRepoCache(sourceWorkspace, ref) {
|
|
|
127
116
|
// Keep the last successful cache when origin is unreachable (offline tests, private host already rejected).
|
|
128
117
|
}
|
|
129
118
|
}
|
|
130
|
-
const revision = (await
|
|
119
|
+
const revision = (await headCommitOrNull(dir)) ?? wantedRevision;
|
|
131
120
|
await writeFile(marker, `${revision}\n`, "utf8");
|
|
132
121
|
return { dir, revision };
|
|
133
122
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A network fault reaching the code host. Mirrors FORGE_TRANSPORT_PATTERN in the control plane's
|
|
3
|
+
* failures.ts — Bridge cannot import from there, so test/failure-classification.test.ts pins the two
|
|
4
|
+
* together rather than trusting them to stay in step.
|
|
5
|
+
*
|
|
6
|
+
* It lives in its own module because both execution.ts and ensure-pull-request.ts read it, and
|
|
7
|
+
* execution.ts imports ensure-pull-request.ts.
|
|
8
|
+
*/
|
|
9
|
+
export const FORGE_TRANSPORT_PATTERN = /unable to access '?https?:\/\/|error in the http2 framing layer|could not resolve host|connection (?:reset|timed out|refused)|\bcurl\b.*\b(?:52|55|56|92)\b|remote end hung up unexpectedly|\brpc failed\b|tls handshake|network is unreachable|operation timed out/i;
|
|
10
|
+
export function forgeTransportFailure(message) {
|
|
11
|
+
return FORGE_TRANSPORT_PATTERN.test(message);
|
|
12
|
+
}
|
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.32",
|
|
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": {
|