@miraland-labs/conduit-bridge 0.16.29 → 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.
@@ -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
- // Settled repair releases are returned on every assignment poll until the local tree is gone.
87
- // Make that at-least-once signal cheap and idempotent instead of invoking Git on a missing path.
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
- const { stdout } = await execFileAsync("git", ["-C", worktreePath, "rev-parse", "HEAD"], {
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, stat } from "node:fs/promises";
2
- import { join, resolve } from "node:path";
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 gitRemoteUrl(workspace),
25
- base_commit: await gitHeadCommit(workspace),
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 discoverVerificationCommands(workspace, files) {
231
- const commands = [];
184
+ export async function readDeclaredVerification(workspace) {
232
185
  try {
233
- commands.push(...parseDeclaredVerification(await readFile(join(workspace, ".conduit", "verification"), "utf8")));
186
+ return parseDeclaredVerification(await readFile(join(workspace, ".conduit", "verification"), "utf8"));
234
187
  }
235
- catch { /* no declaration, or unreadable: discovery below still applies */ }
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
@@ -440,7 +440,10 @@ async function driversCommand() {
440
440
  for (const lane of laneStatuses(config)) {
441
441
  const when = lane.observed_at ? ` observed=${lane.observed_at}` : "";
442
442
  const reset = lane.resets_at ? ` resets=${lane.resets_at}` : lane.allowance === "unavailable" ? " resets=unknown" : "";
443
- console.log(` ${lane.id.padEnd(14)} ${lane.state.padEnd(8)} fuel=${lane.fuel} allowance=${lane.allowance}${when}${reset} (${lane.label})`);
443
+ // A degraded provider never refuses, so allowance stays "unknown" while every turn times out.
444
+ // Printing the count is the only way an operator sees why dispatch keeps avoiding this lane.
445
+ const slow = lane.timeouts ? ` timeouts=${lane.timeouts} (demoted)` : "";
446
+ console.log(` ${lane.id.padEnd(14)} ${lane.state.padEnd(8)} fuel=${lane.fuel} allowance=${lane.allowance}${when}${reset}${slow} (${lane.label})`);
444
447
  }
445
448
  const blocked = laneDispatchBlock(laneStatuses(config));
446
449
  console.log(blocked
@@ -609,19 +612,22 @@ async function runner() {
609
612
  progressed = await executeNextInvestigation(client, config, workspace, brief, timeoutMs) || progressed;
610
613
  }
611
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
+ };
612
622
  progressed = await pumpExecutionSlots(client, config, workspace, brief, timeoutMs, {
613
623
  heartbeat: async () => {
614
- const currentBrief = await buildWorkspaceBrief(workspace).catch(() => brief);
615
- const preflight = await cachedBridgePreflight({ config, workspace, brief: currentBrief ?? undefined });
616
- await heartbeat(client, config, workspace, currentBrief, preflight, undefined, bootstrapResult);
624
+ await publishWorkspaceState();
617
625
  },
618
626
  beforeClaim: async (driverId) => {
619
627
  // Never let the five-minute heartbeat cache span a CLI upgrade into a certified claim.
620
628
  // Publish the fresh probe first; the Control Plane then remains the authority for the
621
629
  // exact version frozen onto the claim response and event.
622
- const currentBrief = await buildWorkspaceBrief(workspace).catch(() => brief);
623
- const preflight = await cachedBridgePreflight({ config, workspace, brief: currentBrief ?? undefined }, { force: true });
624
- const hb = await heartbeat(client, config, workspace, currentBrief, preflight, undefined, bootstrapResult);
630
+ const { preflight, hb } = await publishWorkspaceState({ force: true });
625
631
  if (hb.staleBridge)
626
632
  return false;
627
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 a quota to.
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
- if (quota)
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
@@ -55,10 +55,23 @@ export function normalizeDrivers(raw) {
55
55
  const state = lane.state === "online" ? "online" : "offline";
56
56
  const fuel = lane.fuel === "local" || lane.fuel === "conduit" ? lane.fuel : undefined;
57
57
  const quota = normalizeQuota(lane.quota);
58
- out[id] = { state, ...(fuel ? { fuel } : {}), ...(quota ? { quota } : {}) };
58
+ const outcome = normalizeOutcome(lane.outcome);
59
+ out[id] = { state, ...(fuel ? { fuel } : {}), ...(quota ? { quota } : {}), ...(outcome ? { outcome } : {}) };
59
60
  }
60
61
  return out;
61
62
  }
63
+ /** Same rule as quota: a malformed outcome is dropped, never allowed to hold a lane back. */
64
+ function normalizeOutcome(raw) {
65
+ if (!raw || typeof raw !== "object")
66
+ return undefined;
67
+ const value = raw;
68
+ if (!Number.isFinite(value.consecutive_timeouts) || typeof value.last_timeout_at !== "string")
69
+ return undefined;
70
+ if (!Number.isFinite(Date.parse(value.last_timeout_at)))
71
+ return undefined;
72
+ const count = Math.max(0, Math.floor(value.consecutive_timeouts));
73
+ return { consecutive_timeouts: count, last_timeout_at: value.last_timeout_at };
74
+ }
62
75
  /** Drop a malformed quota rather than fail the whole config: a bad record must not dark a lane. */
63
76
  function normalizeQuota(raw) {
64
77
  if (!raw || typeof raw !== "object")
@@ -159,11 +172,14 @@ export function laneStatuses(config, now = Date.now()) {
159
172
  const allowance = !local || !quota
160
173
  ? "unknown"
161
174
  : quota.exhausted ? "unavailable" : "available";
175
+ const outcome = normalizeDrivers(config.drivers)[lane.id]?.outcome;
162
176
  return {
163
177
  ...lane,
164
178
  allowance,
165
179
  observed_at: quota?.observed_at,
166
180
  resets_at: quota?.resets_at ?? undefined,
181
+ ...(laneTimedOutRecently(config, lane.id, now) ? { timeouts: outcome?.consecutive_timeouts } : {}),
182
+ // Timeouts demote but never make a lane ineligible: see pickDriverForClaim.
167
183
  eligible: lane.state === "online" && allowance !== "unavailable",
168
184
  };
169
185
  });
@@ -178,6 +194,53 @@ export function laneDispatchBlock(statuses) {
178
194
  return "every lane is offline";
179
195
  return "every online lane has an allowance the provider refused";
180
196
  }
197
+ /** Timeouts in a row before a lane stops being preferred. One is noise; two is a pattern. */
198
+ export const TIMEOUT_SUPPRESSION_THRESHOLD = 2;
199
+ /** How long a timeout pattern is believed. Short, because the cause is often the budget, not the lane. */
200
+ export const TIMEOUT_SUPPRESSION_MS = 60 * 60_000;
201
+ /** Record a turn that ended at the execution ceiling. Never touches the quota record. */
202
+ export function recordDriverTimeout(config, driverId, now = Date.now()) {
203
+ if (!isSupportedDriverId(driverId))
204
+ return config;
205
+ const drivers = normalizeDrivers(config.drivers);
206
+ const lane = drivers[driverId] ?? { state: "offline" };
207
+ const previous = lane.outcome?.consecutive_timeouts ?? 0;
208
+ drivers[driverId] = {
209
+ ...lane,
210
+ outcome: { consecutive_timeouts: previous + 1, last_timeout_at: new Date(now).toISOString() },
211
+ };
212
+ return { ...config, drivers };
213
+ }
214
+ /**
215
+ * Clear what the last runs said about this lane.
216
+ *
217
+ * A run that finished is the strongest evidence there is, and it is worth more than any older claim
218
+ * — a lane that just worked is not spent and is not slow.
219
+ */
220
+ export function clearDriverOutcome(config, driverId) {
221
+ if (!isSupportedDriverId(driverId))
222
+ return config;
223
+ const drivers = normalizeDrivers(config.drivers);
224
+ const lane = drivers[driverId];
225
+ if (!lane?.outcome)
226
+ return config;
227
+ const { outcome: _cleared, ...rest } = lane;
228
+ drivers[driverId] = rest;
229
+ return { ...config, drivers };
230
+ }
231
+ /**
232
+ * Whether this lane's recent timeouts should push it behind others.
233
+ *
234
+ * Expires on its own, because a timeout usually means the budget was too small for the work rather
235
+ * than that the lane is bad, and the next package may be smaller.
236
+ */
237
+ export function laneTimedOutRecently(config, driverId, now = Date.now()) {
238
+ const outcome = normalizeDrivers(config.drivers)[driverId]?.outcome;
239
+ if (!outcome || outcome.consecutive_timeouts < TIMEOUT_SUPPRESSION_THRESHOLD)
240
+ return false;
241
+ const seen = Date.parse(outcome.last_timeout_at);
242
+ return now >= seen && now - seen <= TIMEOUT_SUPPRESSION_MS;
243
+ }
181
244
  export function onlineDriverIds(config) {
182
245
  const drivers = normalizeDrivers(config.drivers);
183
246
  return SUPPORTED_AGENTS.map((agent) => agent.id).filter((id) => drivers[id]?.state === "online");
@@ -290,14 +353,17 @@ export function pickDriverForClaim(config, processOnlineIds, eligible = () => tr
290
353
  load.set(active.driverId, (load.get(active.driverId) ?? 0) + 1);
291
354
  }
292
355
  }
356
+ // Demote, never exclude. A lane held out of selection can never run the work that would prove it
357
+ // well again, and suppressing the last candidate would idle the machine over a claim about the
358
+ // past. Ordering is enough: a healthy lane wins while one exists, and a suppressed lane is still
359
+ // picked when it is all there is.
360
+ const rank = (id) => (laneTimedOutRecently(config, id) ? 1 : 0);
293
361
  let best = online[0];
294
- let bestLoad = load.get(best) ?? 0;
295
362
  for (const id of online.slice(1)) {
296
- const n = load.get(id) ?? 0;
297
- if (n < bestLoad) {
363
+ const better = rank(id) < rank(best)
364
+ || (rank(id) === rank(best) && (load.get(id) ?? 0) < (load.get(best) ?? 0));
365
+ if (better)
298
366
  best = id;
299
- bestLoad = n;
300
- }
301
367
  }
302
368
  return best;
303
369
  }
@@ -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 ?? workspaceHeadCommit;
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 && FORGE_TRANSPORT_PATTERN.test(lastMessage)) {
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 ?? workspaceHeadCommit;
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|python -m pytest|pytest(?: [\w./=-]+)?|\.\/gradlew test)$/.test(command.trim());
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 && isBoundedVerificationCommand(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 && isBoundedVerificationCommand(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 && isBoundedVerificationCommand(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
- const commands = diagnosisVerificationCommands(input.verificationCommands);
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 commands = verificationEvidenceCommands(input.verificationCommands);
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
  }
@@ -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._-]*|python -m pytest|pytest(?: [\w./=-]+)?|\.\/gradlew test)$/.test(command);
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((command) => isBoundedVerificationCommand(command) && !deniedCommands.includes(command))
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(isBoundedVerificationCommand).map((command) => `Shell(${command})`));
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(isBoundedVerificationCommand).map((command) => `Bash(${command}:*)`),
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(isBoundedVerificationCommand).map((command) => `Bash(${command}:*)`),
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(isBoundedVerificationCommand).map((command) => `Bash(${command}:*)`));
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(isBoundedVerificationCommand).map(grokBash));
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(isBoundedVerificationCommand).map(grokBash));
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(isBoundedVerificationCommand).map(grokBash));
318
+ allow.push(...verificationCommands.filter(isRunnableVerificationCommand).map(grokBash));
294
319
  }
295
320
  if (grants.includes("branch_create"))
296
321
  allow.push(...branchCreateCommands.map(grokBash));
@@ -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,15 +3,17 @@ 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
- import { pickDriverForClaim, recordDriverQuota, resolveDriverFuel, resolveDriverFuelProvenance, supportsReadOnlyDiagnosis } from "./drivers.js";
9
+ import { pickDriverForClaim, recordDriverQuota, resolveDriverFuel, resolveDriverFuelProvenance, supportsReadOnlyDiagnosis, clearDriverOutcome, recordDriverTimeout } from "./drivers.js";
10
10
  import { attemptWorktreePath, createAttemptWorktree, proveResumeWorktree, quarantineAttemptWorktree, removeAttemptWorktree, } from "./attempt-worktree.js";
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, finalCommitForWorkspace, workspaceIsClean } from "./execution-facts.js";
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
  *
@@ -231,14 +224,36 @@ export function classifyFinalizeFailure(message) {
231
224
  * leave the lane eligible.
232
225
  */
233
226
  export async function learnDriverFuel(config, driverId, fuelSource, result) {
234
- const drivers = observeDriverFuel(config, driverId, fuelSource, result).drivers;
227
+ // Fuel is what the vendor said; outcome is what the run did. Both are learned here so every path
228
+ // that already reports a result learns both, and no new call site can forget one of them.
229
+ let next = observeDriverFuel(config, driverId, fuelSource, result);
230
+ next = observeDriverOutcome(next, driverId, result);
231
+ const drivers = next.drivers;
235
232
  if (JSON.stringify(drivers ?? {}) === JSON.stringify(config.drivers ?? {}))
236
233
  return;
237
234
  config.drivers = drivers;
238
235
  // Only this lane's quota reaches disk. Writing the whole snapshot would carry the rest of a config
239
236
  // that may be minutes stale back over the operator's newer choices — see saveDriverQuota.
240
237
  // A fuel record is worth less than the run it came from: never let a disk fault fail the attempt.
241
- await saveDriverQuota(driverId, drivers?.[driverId]?.quota).catch(() => undefined);
238
+ await Promise.all([
239
+ saveDriverQuota(driverId, drivers?.[driverId]?.quota),
240
+ saveDriverOutcome(driverId, drivers?.[driverId]?.outcome),
241
+ ]).catch(() => undefined);
242
+ }
243
+ /**
244
+ * Learn from what the run did, not from what a vendor said about it.
245
+ *
246
+ * A provider that degrades instead of refusing — a spent Cursor subscription still answering from a
247
+ * slow free tier — produces no refusal to learn from, so the quota record stays empty and the lane
248
+ * looks healthy while every turn runs to the ceiling. The timeout itself is the evidence.
249
+ *
250
+ * Recorded separately from quota on purpose. A timeout means the budget was too small or the lane is
251
+ * degraded; a refusal means the allowance is spent. They call for different operator actions.
252
+ */
253
+ export function observeDriverOutcome(config, driverId, result) {
254
+ if (result.status !== "failed")
255
+ return clearDriverOutcome(config, driverId);
256
+ return agentTurnTimedOut(result.error ?? "") ? recordDriverTimeout(config, driverId) : config;
242
257
  }
243
258
  export function observeDriverFuel(config, driverId, fuelSource, result, now = Date.now()) {
244
259
  if (fuelSource !== "local")
@@ -680,7 +695,8 @@ export async function claimNextAssignment(client, config, workspace, brief, driv
680
695
  for (const attemptId of releasedWorktrees) {
681
696
  const worktree = attemptWorktreePath(workspace, attemptId);
682
697
  await removeAttemptWorktree(workspace, worktree)
683
- .then(() => console.log(`Released settled diagnostic worktree ${worktree}`))
698
+ .then((released) => { if (released)
699
+ console.log(`Released settled diagnostic worktree ${worktree}`); })
684
700
  .catch((error) => console.error(`Diagnostic worktree release failed: ${redactSecrets(error instanceof Error ? error.message : "unknown")}`));
685
701
  }
686
702
  const assignments = z.array(assignmentSchema).parse(data.assignments ?? []);
@@ -1219,6 +1235,7 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
1219
1235
  ? await captureVerificationFailure({
1220
1236
  workspace: attemptWorkspace,
1221
1237
  verificationCommands: attemptBrief?.verification ?? [],
1238
+ acceptance: spec.acceptance ?? [],
1222
1239
  })
1223
1240
  : null;
1224
1241
  const message = verificationDetail
@@ -1395,7 +1412,9 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
1395
1412
  }
1396
1413
  catch (repairError) {
1397
1414
  const message = repairError instanceof Error ? repairError.message : "Delivery report repair failed";
1398
- await queueTerminal(client, taskId, { action: "fail", body: { error: `Delivery report repair failed: ${message}`, retryable: retryableAgentFailure(message), idempotency_key: `bridge:delivery-repair-failed:${active.attemptId}` } });
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}` } });
1399
1418
  console.error(`Assignment ${taskId} report-only repair failed: ${redactSecrets(message)}`);
1400
1419
  return;
1401
1420
  }
@@ -1407,7 +1426,8 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
1407
1426
  }
1408
1427
  if (repaired.status === "failed") {
1409
1428
  const message = repaired.error ?? "Delivery report repair failed";
1410
- await queueTerminal(client, taskId, { action: "fail", body: { error: message, retryable: retryableAgentFailure(message), idempotency_key: `bridge:delivery-repair-failed:${active.attemptId}` } });
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}` } });
1411
1431
  console.error(`Assignment ${taskId} report-only repair failed: ${redactSecrets(message)}`);
1412
1432
  return;
1413
1433
  }
@@ -1429,15 +1449,7 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
1429
1449
  body: {
1430
1450
  error: detail,
1431
1451
  retryable: false,
1432
- failure: {
1433
- code: "delivery_report_invalid",
1434
- class: "contract",
1435
- disposition: "rework",
1436
- responsible_party: "conduit",
1437
- message: "Conduit could not prepare a valid delivery report from the completed agent run.",
1438
- next_action: "Conductor will prepare bounded recovery guidance. You do not need to edit technical constraints.",
1439
- diagnostic_detail: detail,
1440
- },
1452
+ failure: deliveryReportRepairFailure(detail),
1441
1453
  idempotency_key: `bridge:delivery-repair-invalid:${active.attemptId}`,
1442
1454
  },
1443
1455
  });
@@ -2071,11 +2083,29 @@ function retainDiagnosticWorktree(response) {
2071
2083
  // receive that decision, so fail safe by retaining the only copy of the failed code.
2072
2084
  return response.retain_worktree === true || response.status === "invalid_lease";
2073
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
+ }
2074
2104
  async function queueTerminal(client, taskId, terminal) {
2075
2105
  const active = client.attempt(taskId);
2076
2106
  let executionFacts = active.executionFacts;
2077
2107
  if (executionFacts && executionFacts.final_commit === null && active.worktreePath) {
2078
- const finalCommit = await finalCommitForWorkspace(active.worktreePath);
2108
+ const finalCommit = await headCommitOrNull(active.worktreePath);
2079
2109
  if (finalCommit) {
2080
2110
  executionFacts = { ...executionFacts, final_commit: finalCommit };
2081
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
+ }
@@ -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 execFileAsync("git", ["-C", workspace, "rev-parse", "HEAD"], { timeout: 30_000 });
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.stdout.trim()}\n${dirty.stdout.trim()}`;
101
+ return `${head}\n${dirty.stdout.trim()}`;
99
102
  }
100
103
  catch {
101
104
  return null;
@@ -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 readPinnedRevision(dir)) ?? wantedRevision;
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.29",
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": {