@miraland-labs/conduit-bridge 0.16.2 → 0.16.4
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/ensure-land-commit.js +77 -0
- package/dist/ensure-pull-request.js +2 -1
- package/dist/execution.js +380 -79
- package/dist/land-contract.js +47 -0
- package/dist/land-git-reality.js +108 -0
- package/dist/ops.js +39 -2
- package/dist/service.js +90 -5
- package/package.json +1 -1
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mechanical land completion — commit scoped dirty work the agent left uncommitted before finalize.
|
|
3
|
+
*/
|
|
4
|
+
import { execFile } from "node:child_process";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import { commitsAheadOfBase } from "./ensure-pull-request.js";
|
|
7
|
+
import { AgentNoLandCommitError, filterPathsInScope, requiresLandCommit, } from "./land-contract.js";
|
|
8
|
+
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
|
+
async function uncommittedPaths(workspace) {
|
|
20
|
+
const { stdout } = await execFileAsync("git", ["-C", workspace, "status", "--porcelain"], {
|
|
21
|
+
timeout: 30_000,
|
|
22
|
+
maxBuffer: 8_000_000,
|
|
23
|
+
});
|
|
24
|
+
const paths = [];
|
|
25
|
+
for (const line of stdout.split("\n")) {
|
|
26
|
+
if (!line.trim())
|
|
27
|
+
continue;
|
|
28
|
+
const entry = line.slice(3).trim();
|
|
29
|
+
if (!entry)
|
|
30
|
+
continue;
|
|
31
|
+
const path = entry.includes(" -> ")
|
|
32
|
+
? entry.slice(entry.lastIndexOf(" -> ") + 4).trim()
|
|
33
|
+
: entry;
|
|
34
|
+
if (path)
|
|
35
|
+
paths.push(path.replace(/^"|"$/g, ""));
|
|
36
|
+
}
|
|
37
|
+
return paths;
|
|
38
|
+
}
|
|
39
|
+
async function commitScopedPaths(workspace, paths) {
|
|
40
|
+
if (!paths.length)
|
|
41
|
+
throw new Error("Cannot land an empty path set");
|
|
42
|
+
await execFileAsync("git", ["-C", workspace, "add", "--", ...paths], {
|
|
43
|
+
timeout: 120_000,
|
|
44
|
+
maxBuffer: 8_000_000,
|
|
45
|
+
});
|
|
46
|
+
await execFileAsync("git", ["-C", workspace, "commit", "-m", "Conduit: land scoped agent changes"], { timeout: 120_000, maxBuffer: 8_000_000 });
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* When the land contract applies, ensure the attempt branch has at least one commit after base.
|
|
50
|
+
* Commits scoped dirty files Bridge can see; throws AgentNoLandCommitError when nothing is landable.
|
|
51
|
+
*/
|
|
52
|
+
export async function ensureLandCommit(input) {
|
|
53
|
+
if (!requiresLandCommit({ grants: input.grants, spec: input.spec })) {
|
|
54
|
+
return { kind: "skipped" };
|
|
55
|
+
}
|
|
56
|
+
const base = input.spec.repository?.base_commit;
|
|
57
|
+
if (!base)
|
|
58
|
+
return { kind: "skipped" };
|
|
59
|
+
const countAhead = input.countCommitsAhead ?? commitsAheadOfBase;
|
|
60
|
+
const readHead = input.readHeadCommit ?? workspaceHeadCommit;
|
|
61
|
+
const listPending = input.listUncommittedPaths ?? uncommittedPaths;
|
|
62
|
+
const commitPaths = input.commitPaths ?? commitScopedPaths;
|
|
63
|
+
const ahead = await countAhead(input.workspace, base);
|
|
64
|
+
if (ahead !== null && ahead > 0) {
|
|
65
|
+
return { kind: "already_landed", headCommit: await readHead(input.workspace) };
|
|
66
|
+
}
|
|
67
|
+
const pending = filterPathsInScope(await listPending(input.workspace), input.spec.change_scope ?? []);
|
|
68
|
+
if (!pending.length) {
|
|
69
|
+
throw new AgentNoLandCommitError(base);
|
|
70
|
+
}
|
|
71
|
+
await commitPaths(input.workspace, pending);
|
|
72
|
+
const afterAhead = await countAhead(input.workspace, base);
|
|
73
|
+
if (afterAhead === 0) {
|
|
74
|
+
throw new AgentNoLandCommitError(base);
|
|
75
|
+
}
|
|
76
|
+
return { kind: "committed", headCommit: await readHead(input.workspace), paths: pending };
|
|
77
|
+
}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { execFile } from "node:child_process";
|
|
7
7
|
import { promisify } from "node:util";
|
|
8
8
|
import { normalizeRepositoryUrl } from "./brief.js";
|
|
9
|
+
import { agentNoLandCommitMessage } from "./land-contract.js";
|
|
9
10
|
const execFileAsync = promisify(execFile);
|
|
10
11
|
/** Mirror execution.ts FORGE_TRANSPORT_PATTERN — keep local to avoid import cycles. */
|
|
11
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;
|
|
@@ -197,7 +198,7 @@ export async function ensureDeliveryPullRequest(input) {
|
|
|
197
198
|
if (base) {
|
|
198
199
|
const ahead = await countAhead(input.workspace, base);
|
|
199
200
|
if (ahead === 0) {
|
|
200
|
-
throw new Error(
|
|
201
|
+
throw new Error(agentNoLandCommitMessage(base));
|
|
201
202
|
}
|
|
202
203
|
}
|
|
203
204
|
await push(input.workspace);
|
package/dist/execution.js
CHANGED
|
@@ -11,6 +11,9 @@ import { execFile } from "node:child_process";
|
|
|
11
11
|
import { promisify } from "node:util";
|
|
12
12
|
import { buildWorkspaceBrief, ensureCommitAvailable, normalizeRepositoryUrl, resolveAttemptStartCommit } from "./brief.js";
|
|
13
13
|
import { ensureDeliveryPullRequest } from "./ensure-pull-request.js";
|
|
14
|
+
import { ensureLandCommit } from "./ensure-land-commit.js";
|
|
15
|
+
import { AgentNoLandCommitError, agentNoLandCommitMessage, requiresLandCommit } from "./land-contract.js";
|
|
16
|
+
import { agentClaimsRepositoryWork, claimsVsGitMismatch, isLandTreeEmpty, reportClaimsRepositoryWork, } from "./land-git-reality.js";
|
|
14
17
|
const execFileAsync = promisify(execFile);
|
|
15
18
|
import { captureVerificationFailure, ensureTestEvidence } from "./ensure-test-evidence.js";
|
|
16
19
|
/** Keep a Mac awake only while an assignment is active; display sleep remains allowed. */
|
|
@@ -63,7 +66,7 @@ export function forgeTransportFailure(message) {
|
|
|
63
66
|
/** Environment faults that must Hold — mirror CP ENVIRONMENT_FAILURES message patterns. */
|
|
64
67
|
const FINALIZE_ENVIRONMENT_PATTERN = /base[_ ]not[_ ]ancestor|required base commit is not available|source[_ ]workspace[_ ]dirty|uncommitted changes|dirty workspace|workspace[_ ]head[_ ]changed|workspace[_ ]repository|workspace[_ ]unavailable|driver[_ ]not[_ ]authenticated|not logged in|no login|not authenticated|login required|no[_ ]online[_ ]driver|bridge[_ ]preflight|stale bridge|bridge version/i;
|
|
65
68
|
/** Delivery-report / grant / evidence contract defects (non-retryable rework). */
|
|
66
|
-
const FINALIZE_CONTRACT_PATTERN = /missing required evidence|without the repo_write grant|pull_request_url|Artifact delivery requires|outside the approved scope|head commit|test evidence must include|Met acceptance criteria|Agent report|Agent reported|Delivery report/i;
|
|
69
|
+
const FINALIZE_CONTRACT_PATTERN = /missing required evidence|without the repo_write grant|pull_request_url|Artifact delivery requires|outside the approved scope|head commit|test evidence must include|Met acceptance criteria|Agent report|Agent reported|Agent did not land repository changes|Delivery report/i;
|
|
67
70
|
/**
|
|
68
71
|
* Classify throws from ensureDeliveryPullRequest + validateDeliveryReport.
|
|
69
72
|
* Unknown non-contract faults must not be laundered as execution_contract_failed.
|
|
@@ -852,106 +855,290 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
|
|
|
852
855
|
console.error(`Assignment ${taskId} failed: ${redactSecrets(message)}`);
|
|
853
856
|
return;
|
|
854
857
|
}
|
|
855
|
-
|
|
858
|
+
if (requiresLandCommit({ grants, spec })) {
|
|
859
|
+
try {
|
|
860
|
+
const land = await ensureLandCommit({ workspace: attemptWorkspace, spec, grants });
|
|
861
|
+
if (land.kind === "committed") {
|
|
862
|
+
await client.attemptRequest(taskId, "progress", {
|
|
863
|
+
phase: "preparing_delivery",
|
|
864
|
+
message: `Bridge committed scoped agent changes that were left uncommitted (${land.paths.join(", ")}).`,
|
|
865
|
+
idempotency_key: `bridge:progress:${active.attemptId}:land-commit`,
|
|
866
|
+
});
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
catch (error) {
|
|
870
|
+
if (!(error instanceof AgentNoLandCommitError))
|
|
871
|
+
throw error;
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
let agentSessionId = result.sessionId ?? undefined;
|
|
856
875
|
let reportText = result.resultText ?? "";
|
|
876
|
+
let landContinuationUsed = false;
|
|
877
|
+
if (await claimsVsGitMismatch({
|
|
878
|
+
workspace: attemptWorkspace,
|
|
879
|
+
spec,
|
|
880
|
+
grants,
|
|
881
|
+
agentReply: reportText,
|
|
882
|
+
})) {
|
|
883
|
+
const continuation = await runLandContinuationTurn({
|
|
884
|
+
client,
|
|
885
|
+
taskId,
|
|
886
|
+
attemptId: active.attemptId,
|
|
887
|
+
workspace: attemptWorkspace,
|
|
888
|
+
spec,
|
|
889
|
+
grants,
|
|
890
|
+
driver,
|
|
891
|
+
capabilities: spec.required_capabilities ?? [],
|
|
892
|
+
verificationCommands: attemptBrief?.verification ?? [],
|
|
893
|
+
objective: task.objective,
|
|
894
|
+
selection,
|
|
895
|
+
fuel,
|
|
896
|
+
fuelSource,
|
|
897
|
+
timeoutMs,
|
|
898
|
+
resumeSessionId: agentSessionId,
|
|
899
|
+
reason: "Agent claimed repository work but git shows none on the attempt branch; starting one land-only continuation turn instead of report-only repair.",
|
|
900
|
+
idempotencyKey: `bridge:progress:${active.attemptId}:claims-git-gate`,
|
|
901
|
+
});
|
|
902
|
+
landContinuationUsed = true;
|
|
903
|
+
if (continuation.sessionId) {
|
|
904
|
+
config.sessions = { ...config.sessions, [taskId]: continuation.sessionId };
|
|
905
|
+
agentSessionId = continuation.sessionId;
|
|
906
|
+
}
|
|
907
|
+
reportText = continuation.resultText;
|
|
908
|
+
}
|
|
909
|
+
let report;
|
|
857
910
|
try {
|
|
858
911
|
report = parseAgentReport(reportText, spec.acceptance ?? []);
|
|
859
912
|
}
|
|
860
913
|
catch (error) {
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
for (let repairTurn = 1; repairTurn <= maxRepairs; repairTurn++) {
|
|
870
|
-
const replyDigest = createHash("sha256").update(previousReply).digest("hex");
|
|
871
|
-
await client.attemptRequest(taskId, "progress", {
|
|
872
|
-
phase: "preparing_delivery",
|
|
873
|
-
message: `Delivery envelope invalid (${redactSecrets(parseError)}); starting report-only repair turn ${repairTurn}/${maxRepairs} without rerunning implementation. Original reply: ${previousReply.length} characters, sha256:${replyDigest}.`,
|
|
874
|
-
idempotency_key: `bridge:progress:${active.attemptId}:delivery-repair:${repairTurn}`,
|
|
875
|
-
});
|
|
876
|
-
try {
|
|
877
|
-
repaired = await driver.run({
|
|
878
|
-
prompt: buildDeliveryRepairPrompt(parseError, previousReply, spec.acceptance ?? []),
|
|
914
|
+
const gitEmpty = await isLandTreeEmpty({ workspace: attemptWorkspace, spec, grants });
|
|
915
|
+
const claimsWork = agentClaimsRepositoryWork(reportText);
|
|
916
|
+
if (requiresLandCommit({ grants, spec }) && gitEmpty && claimsWork) {
|
|
917
|
+
if (!landContinuationUsed) {
|
|
918
|
+
const continuation = await runLandContinuationTurn({
|
|
919
|
+
client,
|
|
920
|
+
taskId,
|
|
921
|
+
attemptId: active.attemptId,
|
|
879
922
|
workspace: attemptWorkspace,
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
capabilities: [],
|
|
884
|
-
verificationCommands: [],
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
resumeSessionId,
|
|
888
|
-
timeoutMs,
|
|
889
|
-
model: selection.model,
|
|
923
|
+
spec,
|
|
924
|
+
grants,
|
|
925
|
+
driver,
|
|
926
|
+
capabilities: spec.required_capabilities ?? [],
|
|
927
|
+
verificationCommands: attemptBrief?.verification ?? [],
|
|
928
|
+
objective: task.objective,
|
|
929
|
+
selection,
|
|
890
930
|
fuel,
|
|
891
931
|
fuelSource,
|
|
932
|
+
timeoutMs,
|
|
933
|
+
resumeSessionId: agentSessionId,
|
|
934
|
+
reason: "Agent claimed repository work but git shows none; starting land-only continuation instead of read-only report repair.",
|
|
935
|
+
idempotencyKey: `bridge:progress:${active.attemptId}:claims-git-gate-parse`,
|
|
892
936
|
});
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
const message = repaired.error ?? "Delivery report repair failed";
|
|
906
|
-
await queueTerminal(client, taskId, { action: "fail", body: { error: message, retryable: retryableAgentFailure(message), idempotency_key: `bridge:delivery-repair-failed:${active.attemptId}` } });
|
|
907
|
-
console.error(`Assignment ${taskId} report-only repair failed: ${redactSecrets(message)}`);
|
|
908
|
-
return;
|
|
909
|
-
}
|
|
910
|
-
previousReply = repaired.resultText ?? "";
|
|
911
|
-
try {
|
|
912
|
-
parsed = parseAgentReport(previousReply, spec.acceptance ?? []);
|
|
913
|
-
break;
|
|
914
|
-
}
|
|
915
|
-
catch (repairError) {
|
|
916
|
-
parseError = repairError instanceof Error ? repairError.message : "Repaired delivery report was invalid";
|
|
917
|
-
if (repairTurn >= maxRepairs) {
|
|
918
|
-
const detail = `Delivery report repair exhausted: ${parseError}`;
|
|
919
|
-
// Implementation finished; only its response envelope is invalid. Preserve the exact
|
|
920
|
-
// tree and identify this narrow condition so the control plane can prepare repair
|
|
921
|
-
// guidance instead of discarding the work and asking the owner to debug JSON.
|
|
922
|
-
retainAttemptWorktree = true;
|
|
937
|
+
landContinuationUsed = true;
|
|
938
|
+
if (continuation.sessionId) {
|
|
939
|
+
config.sessions = { ...config.sessions, [taskId]: continuation.sessionId };
|
|
940
|
+
agentSessionId = continuation.sessionId;
|
|
941
|
+
}
|
|
942
|
+
reportText = continuation.resultText;
|
|
943
|
+
try {
|
|
944
|
+
report = parseAgentReport(reportText, spec.acceptance ?? []);
|
|
945
|
+
}
|
|
946
|
+
catch {
|
|
947
|
+
const base = spec.repository?.base_commit ?? "unknown";
|
|
948
|
+
const classified = classifyFinalizeFailure(agentNoLandCommitMessage(base));
|
|
923
949
|
const response = await queueTerminal(client, taskId, {
|
|
924
950
|
action: "fail",
|
|
925
951
|
body: {
|
|
926
|
-
error:
|
|
927
|
-
retryable:
|
|
928
|
-
|
|
929
|
-
code: "delivery_report_invalid",
|
|
930
|
-
class: "contract",
|
|
931
|
-
disposition: "rework",
|
|
932
|
-
responsible_party: "conduit",
|
|
933
|
-
message: "Conduit could not prepare a valid delivery report from the completed agent run.",
|
|
934
|
-
next_action: "Conductor will prepare bounded recovery guidance. You do not need to edit technical constraints.",
|
|
935
|
-
diagnostic_detail: detail,
|
|
936
|
-
},
|
|
937
|
-
idempotency_key: `bridge:delivery-repair-invalid:${active.attemptId}`,
|
|
952
|
+
error: classified.error,
|
|
953
|
+
retryable: classified.retryable,
|
|
954
|
+
idempotency_key: `bridge:claims-git-gate:${active.attemptId}`,
|
|
938
955
|
},
|
|
939
956
|
});
|
|
940
957
|
retainAttemptWorktree = retainDiagnosticWorktree(response);
|
|
941
|
-
console.error(`Assignment ${taskId}
|
|
942
|
-
|
|
943
|
-
|
|
958
|
+
console.error(`Assignment ${taskId} claims-vs-git gate failed after land continuation: ${redactSecrets(classified.error)}`);
|
|
959
|
+
return;
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
else {
|
|
963
|
+
const base = spec.repository?.base_commit ?? "unknown";
|
|
964
|
+
const classified = classifyFinalizeFailure(agentNoLandCommitMessage(base));
|
|
965
|
+
const response = await queueTerminal(client, taskId, {
|
|
966
|
+
action: "fail",
|
|
967
|
+
body: {
|
|
968
|
+
error: classified.error,
|
|
969
|
+
retryable: classified.retryable,
|
|
970
|
+
idempotency_key: `bridge:claims-git-gate:${active.attemptId}`,
|
|
971
|
+
},
|
|
972
|
+
});
|
|
973
|
+
retainAttemptWorktree = retainDiagnosticWorktree(response);
|
|
974
|
+
console.error(`Assignment ${taskId} claims-vs-git gate blocked read-only report repair: ${redactSecrets(classified.error)}`);
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
else {
|
|
979
|
+
// Pump fuel keeps one repair turn. Local-fuel Experimental lanes (Pi et al.) get a second
|
|
980
|
+
// read-only repair — truncated JSON fences are common there and re-implementing is wasteful.
|
|
981
|
+
const maxRepairs = fuelSource === "local" ? 2 : 1;
|
|
982
|
+
let parseError = error instanceof Error ? error.message : "Agent delivery report was invalid";
|
|
983
|
+
let previousReply = reportText;
|
|
984
|
+
let resumeSessionId = result.sessionId ?? undefined;
|
|
985
|
+
let repaired = null;
|
|
986
|
+
let parsed = null;
|
|
987
|
+
for (let repairTurn = 1; repairTurn <= maxRepairs; repairTurn++) {
|
|
988
|
+
const replyDigest = createHash("sha256").update(previousReply).digest("hex");
|
|
989
|
+
await client.attemptRequest(taskId, "progress", {
|
|
990
|
+
phase: "preparing_delivery",
|
|
991
|
+
message: `Delivery envelope invalid (${redactSecrets(parseError)}); starting report-only repair turn ${repairTurn}/${maxRepairs} without rerunning implementation. Original reply: ${previousReply.length} characters, sha256:${replyDigest}.`,
|
|
992
|
+
idempotency_key: `bridge:progress:${active.attemptId}:delivery-repair:${repairTurn}`,
|
|
993
|
+
});
|
|
994
|
+
try {
|
|
995
|
+
repaired = await driver.run({
|
|
996
|
+
prompt: buildDeliveryRepairPrompt(parseError, previousReply, spec.acceptance ?? []),
|
|
997
|
+
workspace: attemptWorkspace,
|
|
998
|
+
// Preserve only existing read authority. No write, test, branch, or push
|
|
999
|
+
// capability is available while the agent repairs the response envelope.
|
|
1000
|
+
grants: grants.filter((grant) => grant === "repo_read"),
|
|
1001
|
+
capabilities: [],
|
|
1002
|
+
verificationCommands: [],
|
|
1003
|
+
// Envelope repair is read-only observe authority regardless of the original class.
|
|
1004
|
+
executionClass: "observe",
|
|
1005
|
+
resumeSessionId,
|
|
1006
|
+
timeoutMs,
|
|
1007
|
+
model: selection.model,
|
|
1008
|
+
fuel,
|
|
1009
|
+
fuelSource,
|
|
1010
|
+
});
|
|
1011
|
+
}
|
|
1012
|
+
catch (repairError) {
|
|
1013
|
+
const message = repairError instanceof Error ? repairError.message : "Delivery report repair failed";
|
|
1014
|
+
await queueTerminal(client, taskId, { action: "fail", body: { error: `Delivery report repair failed: ${message}`, retryable: retryableAgentFailure(message), idempotency_key: `bridge:delivery-repair-failed:${active.attemptId}` } });
|
|
1015
|
+
console.error(`Assignment ${taskId} report-only repair failed: ${redactSecrets(message)}`);
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
1018
|
+
if (repaired.sessionId) {
|
|
1019
|
+
config.sessions = { ...config.sessions, [taskId]: repaired.sessionId };
|
|
1020
|
+
resumeSessionId = repaired.sessionId;
|
|
1021
|
+
agentSessionId = repaired.sessionId;
|
|
1022
|
+
}
|
|
1023
|
+
if (repaired.status === "failed") {
|
|
1024
|
+
const message = repaired.error ?? "Delivery report repair failed";
|
|
1025
|
+
await queueTerminal(client, taskId, { action: "fail", body: { error: message, retryable: retryableAgentFailure(message), idempotency_key: `bridge:delivery-repair-failed:${active.attemptId}` } });
|
|
1026
|
+
console.error(`Assignment ${taskId} report-only repair failed: ${redactSecrets(message)}`);
|
|
944
1027
|
return;
|
|
945
1028
|
}
|
|
946
|
-
|
|
1029
|
+
previousReply = repaired.resultText ?? "";
|
|
1030
|
+
try {
|
|
1031
|
+
parsed = parseAgentReport(previousReply, spec.acceptance ?? []);
|
|
1032
|
+
break;
|
|
1033
|
+
}
|
|
1034
|
+
catch (repairError) {
|
|
1035
|
+
parseError = repairError instanceof Error ? repairError.message : "Repaired delivery report was invalid";
|
|
1036
|
+
if (repairTurn >= maxRepairs) {
|
|
1037
|
+
const detail = `Delivery report repair exhausted: ${parseError}`;
|
|
1038
|
+
// Implementation finished; only its response envelope is invalid. Preserve the exact
|
|
1039
|
+
// tree and identify this narrow condition so the control plane can prepare repair
|
|
1040
|
+
// guidance instead of discarding the work and asking the owner to debug JSON.
|
|
1041
|
+
retainAttemptWorktree = true;
|
|
1042
|
+
const response = await queueTerminal(client, taskId, {
|
|
1043
|
+
action: "fail",
|
|
1044
|
+
body: {
|
|
1045
|
+
error: detail,
|
|
1046
|
+
retryable: false,
|
|
1047
|
+
failure: {
|
|
1048
|
+
code: "delivery_report_invalid",
|
|
1049
|
+
class: "contract",
|
|
1050
|
+
disposition: "rework",
|
|
1051
|
+
responsible_party: "conduit",
|
|
1052
|
+
message: "Conduit could not prepare a valid delivery report from the completed agent run.",
|
|
1053
|
+
next_action: "Conductor will prepare bounded recovery guidance. You do not need to edit technical constraints.",
|
|
1054
|
+
diagnostic_detail: detail,
|
|
1055
|
+
},
|
|
1056
|
+
idempotency_key: `bridge:delivery-repair-invalid:${active.attemptId}`,
|
|
1057
|
+
},
|
|
1058
|
+
});
|
|
1059
|
+
retainAttemptWorktree = retainDiagnosticWorktree(response);
|
|
1060
|
+
console.error(`Assignment ${taskId} exhausted its report-only repair: ${redactSecrets(parseError)}`);
|
|
1061
|
+
const replyTail = previousReply.slice(-8_000);
|
|
1062
|
+
console.error(`Assignment ${taskId} repaired reply tail (${previousReply.length} chars total, redacted): ${redactSecrets(replyTail) || "<empty>"}`);
|
|
1063
|
+
return;
|
|
1064
|
+
}
|
|
1065
|
+
console.error(`Assignment ${taskId} report-only repair turn ${repairTurn} still invalid: ${redactSecrets(parseError)}; retrying`);
|
|
1066
|
+
}
|
|
947
1067
|
}
|
|
1068
|
+
if (!parsed)
|
|
1069
|
+
return;
|
|
1070
|
+
reportText = previousReply;
|
|
1071
|
+
report = parsed;
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
if (!landContinuationUsed
|
|
1075
|
+
&& requiresLandCommit({ grants, spec })
|
|
1076
|
+
&& reportClaimsRepositoryWork(report)
|
|
1077
|
+
&& await isLandTreeEmpty({ workspace: attemptWorkspace, spec, grants })) {
|
|
1078
|
+
const continuation = await runLandContinuationTurn({
|
|
1079
|
+
client,
|
|
1080
|
+
taskId,
|
|
1081
|
+
attemptId: active.attemptId,
|
|
1082
|
+
workspace: attemptWorkspace,
|
|
1083
|
+
spec,
|
|
1084
|
+
grants,
|
|
1085
|
+
driver,
|
|
1086
|
+
capabilities: spec.required_capabilities ?? [],
|
|
1087
|
+
verificationCommands: attemptBrief?.verification ?? [],
|
|
1088
|
+
objective: task.objective,
|
|
1089
|
+
selection,
|
|
1090
|
+
fuel,
|
|
1091
|
+
fuelSource,
|
|
1092
|
+
timeoutMs,
|
|
1093
|
+
resumeSessionId: agentSessionId,
|
|
1094
|
+
reason: "Parsed Delivery claims repository work but git shows none; starting land-only continuation.",
|
|
1095
|
+
idempotencyKey: `bridge:progress:${active.attemptId}:claims-git-gate-report`,
|
|
1096
|
+
});
|
|
1097
|
+
landContinuationUsed = true;
|
|
1098
|
+
if (continuation.sessionId) {
|
|
1099
|
+
config.sessions = { ...config.sessions, [taskId]: continuation.sessionId };
|
|
1100
|
+
agentSessionId = continuation.sessionId;
|
|
948
1101
|
}
|
|
949
|
-
|
|
1102
|
+
reportText = continuation.resultText;
|
|
1103
|
+
try {
|
|
1104
|
+
report = parseAgentReport(reportText, spec.acceptance ?? []);
|
|
1105
|
+
}
|
|
1106
|
+
catch {
|
|
1107
|
+
const base = spec.repository?.base_commit ?? "unknown";
|
|
1108
|
+
const classified = classifyFinalizeFailure(agentNoLandCommitMessage(base));
|
|
1109
|
+
await queueTerminal(client, taskId, {
|
|
1110
|
+
action: "fail",
|
|
1111
|
+
body: {
|
|
1112
|
+
error: classified.error,
|
|
1113
|
+
retryable: classified.retryable,
|
|
1114
|
+
idempotency_key: `bridge:claims-git-gate-report:${active.attemptId}`,
|
|
1115
|
+
},
|
|
1116
|
+
});
|
|
1117
|
+
console.error(`Assignment ${taskId} claims-vs-git gate failed after parsed-report continuation: ${redactSecrets(classified.error)}`);
|
|
950
1118
|
return;
|
|
951
|
-
|
|
952
|
-
report = parsed;
|
|
1119
|
+
}
|
|
953
1120
|
}
|
|
954
1121
|
try {
|
|
1122
|
+
report = await finalizeRepositoryLand({
|
|
1123
|
+
client,
|
|
1124
|
+
taskId,
|
|
1125
|
+
attemptId: active.attemptId,
|
|
1126
|
+
workspace: attemptWorkspace,
|
|
1127
|
+
spec,
|
|
1128
|
+
grants,
|
|
1129
|
+
report,
|
|
1130
|
+
driver,
|
|
1131
|
+
executionClass,
|
|
1132
|
+
capabilities: spec.required_capabilities ?? [],
|
|
1133
|
+
verificationCommands: attemptBrief?.verification ?? [],
|
|
1134
|
+
objective: task.objective,
|
|
1135
|
+
selection,
|
|
1136
|
+
fuel,
|
|
1137
|
+
fuelSource,
|
|
1138
|
+
timeoutMs,
|
|
1139
|
+
resumeSessionId: agentSessionId,
|
|
1140
|
+
landContinuationUsed,
|
|
1141
|
+
});
|
|
955
1142
|
// Mechanical land path: agent may forget gh; Bridge pushes and the control plane opens the PR.
|
|
956
1143
|
report = await ensureDeliveryPullRequest({
|
|
957
1144
|
client,
|
|
@@ -1037,6 +1224,120 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
|
|
|
1037
1224
|
}
|
|
1038
1225
|
}
|
|
1039
1226
|
}
|
|
1227
|
+
function buildLandContinuationPrompt(input) {
|
|
1228
|
+
const scope = input.spec.change_scope ?? [];
|
|
1229
|
+
return [
|
|
1230
|
+
"The implementation turn finished without landing repository changes on the attempt branch.",
|
|
1231
|
+
"Perform exactly one land-only continuation: make the minimal scoped edits required by the approved plan, commit them on the current branch, run any bounded verification commands you are allowed to run, then return one fenced ```json Delivery object.",
|
|
1232
|
+
"Do not open a pull request — Bridge opens it after finalize when pr_create is granted.",
|
|
1233
|
+
"Your working branch is already checked out. Use git add and git commit; report the resulting sha as head_commit.",
|
|
1234
|
+
"",
|
|
1235
|
+
"OBJECTIVE",
|
|
1236
|
+
input.objective,
|
|
1237
|
+
"",
|
|
1238
|
+
"APPROVED CHANGE SCOPE",
|
|
1239
|
+
...(scope.length ? scope.map((path) => `- ${path}`) : ["- (none — stay within the plan)"]),
|
|
1240
|
+
"",
|
|
1241
|
+
"APPROVED ACCEPTANCE CRITERIA",
|
|
1242
|
+
...(input.acceptance.length ? input.acceptance.map((criterion) => `- ${criterion}`) : ["- None"]),
|
|
1243
|
+
"",
|
|
1244
|
+
"REQUIRED DELIVERY SHAPE",
|
|
1245
|
+
agentReportTemplate,
|
|
1246
|
+
].join("\n");
|
|
1247
|
+
}
|
|
1248
|
+
function bindLandHeadCommit(report, headCommit) {
|
|
1249
|
+
return { ...report, head_commit: headCommit };
|
|
1250
|
+
}
|
|
1251
|
+
async function runLandContinuationTurn(input) {
|
|
1252
|
+
await input.client.attemptRequest(input.taskId, "progress", {
|
|
1253
|
+
phase: "changing",
|
|
1254
|
+
message: input.reason,
|
|
1255
|
+
idempotency_key: input.idempotencyKey,
|
|
1256
|
+
});
|
|
1257
|
+
const continuation = await input.driver.run({
|
|
1258
|
+
prompt: buildLandContinuationPrompt({
|
|
1259
|
+
objective: input.objective,
|
|
1260
|
+
spec: input.spec,
|
|
1261
|
+
acceptance: input.spec.acceptance ?? [],
|
|
1262
|
+
}),
|
|
1263
|
+
workspace: input.workspace,
|
|
1264
|
+
grants: input.grants,
|
|
1265
|
+
capabilities: input.capabilities,
|
|
1266
|
+
verificationCommands: input.verificationCommands,
|
|
1267
|
+
executionClass: "mutate_repo",
|
|
1268
|
+
resumeSessionId: input.resumeSessionId ?? undefined,
|
|
1269
|
+
timeoutMs: input.timeoutMs,
|
|
1270
|
+
model: input.selection.model ?? undefined,
|
|
1271
|
+
fuel: input.fuel,
|
|
1272
|
+
fuelSource: input.fuelSource,
|
|
1273
|
+
});
|
|
1274
|
+
if (continuation.status === "failed") {
|
|
1275
|
+
throw new AgentNoLandCommitError(input.spec.repository?.base_commit ?? "unknown");
|
|
1276
|
+
}
|
|
1277
|
+
return {
|
|
1278
|
+
resultText: continuation.resultText ?? "",
|
|
1279
|
+
sessionId: continuation.sessionId ?? undefined,
|
|
1280
|
+
};
|
|
1281
|
+
}
|
|
1282
|
+
async function finalizeRepositoryLand(input) {
|
|
1283
|
+
if (!requiresLandCommit({ grants: input.grants, spec: input.spec }))
|
|
1284
|
+
return input.report;
|
|
1285
|
+
const applyLand = async (report) => {
|
|
1286
|
+
const land = await ensureLandCommit({
|
|
1287
|
+
workspace: input.workspace,
|
|
1288
|
+
spec: input.spec,
|
|
1289
|
+
grants: input.grants,
|
|
1290
|
+
});
|
|
1291
|
+
if (land.kind === "committed") {
|
|
1292
|
+
await input.client.attemptRequest(input.taskId, "progress", {
|
|
1293
|
+
phase: "preparing_delivery",
|
|
1294
|
+
message: `Bridge committed scoped agent changes that were left uncommitted (${land.paths.join(", ")}).`,
|
|
1295
|
+
idempotency_key: `bridge:progress:${input.attemptId}:land-commit`,
|
|
1296
|
+
});
|
|
1297
|
+
}
|
|
1298
|
+
if (land.kind === "already_landed" || land.kind === "committed") {
|
|
1299
|
+
return bindLandHeadCommit(report, land.headCommit);
|
|
1300
|
+
}
|
|
1301
|
+
return report;
|
|
1302
|
+
};
|
|
1303
|
+
try {
|
|
1304
|
+
return await applyLand(input.report);
|
|
1305
|
+
}
|
|
1306
|
+
catch (error) {
|
|
1307
|
+
if (!(error instanceof AgentNoLandCommitError))
|
|
1308
|
+
throw error;
|
|
1309
|
+
}
|
|
1310
|
+
if (input.landContinuationUsed) {
|
|
1311
|
+
throw new AgentNoLandCommitError(input.spec.repository?.base_commit ?? "unknown");
|
|
1312
|
+
}
|
|
1313
|
+
const continuation = await runLandContinuationTurn({
|
|
1314
|
+
client: input.client,
|
|
1315
|
+
taskId: input.taskId,
|
|
1316
|
+
attemptId: input.attemptId,
|
|
1317
|
+
workspace: input.workspace,
|
|
1318
|
+
spec: input.spec,
|
|
1319
|
+
grants: input.grants,
|
|
1320
|
+
driver: input.driver,
|
|
1321
|
+
capabilities: input.capabilities,
|
|
1322
|
+
verificationCommands: input.verificationCommands,
|
|
1323
|
+
objective: input.objective,
|
|
1324
|
+
selection: input.selection,
|
|
1325
|
+
fuel: input.fuel,
|
|
1326
|
+
fuelSource: input.fuelSource,
|
|
1327
|
+
timeoutMs: input.timeoutMs,
|
|
1328
|
+
resumeSessionId: input.resumeSessionId,
|
|
1329
|
+
reason: "Agent finished without landing repository changes; starting one land-only continuation turn.",
|
|
1330
|
+
idempotencyKey: `bridge:progress:${input.attemptId}:land-continuation`,
|
|
1331
|
+
});
|
|
1332
|
+
let report = input.report;
|
|
1333
|
+
try {
|
|
1334
|
+
report = parseAgentReport(continuation.resultText, input.spec.acceptance ?? []);
|
|
1335
|
+
}
|
|
1336
|
+
catch {
|
|
1337
|
+
// Land may have succeeded even when the continuation envelope is malformed — git is authoritative.
|
|
1338
|
+
}
|
|
1339
|
+
return applyLand(report);
|
|
1340
|
+
}
|
|
1040
1341
|
function buildDeliveryRepairPrompt(parseError, previousReply, acceptance) {
|
|
1041
1342
|
const replyTail = redactSecrets(previousReply.slice(-8_000));
|
|
1042
1343
|
return [
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Land contract — when a repository delivery must carry a commit on the attempt branch.
|
|
3
|
+
* Mirrors driver mustCommit / executionClassPromptRules; observe/verify/artifact paths stay out.
|
|
4
|
+
*/
|
|
5
|
+
/** True when finalize must prove a commit after the plan base on the attempt branch. */
|
|
6
|
+
export function requiresLandCommit(input) {
|
|
7
|
+
if (input.spec.deliverable === "artifact")
|
|
8
|
+
return false;
|
|
9
|
+
const executionClass = input.spec.execution_class;
|
|
10
|
+
if (executionClass === "observe" || executionClass === "observe_network" || executionClass === "verify") {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
if (executionClass === "publish_artifact")
|
|
14
|
+
return false;
|
|
15
|
+
return input.grants.includes("repo_write") && input.grants.includes("branch_create");
|
|
16
|
+
}
|
|
17
|
+
/** Stable prefix for classifyFinalizeFailure and the control-plane failure envelope. */
|
|
18
|
+
export const AGENT_NO_LAND_COMMIT_PREFIX = "Agent did not land repository changes";
|
|
19
|
+
export function agentNoLandCommitMessage(baseCommit) {
|
|
20
|
+
return `${AGENT_NO_LAND_COMMIT_PREFIX}: the attempt branch has no commit after base ${baseCommit.slice(0, 12)}.`;
|
|
21
|
+
}
|
|
22
|
+
/** Legacy ensureDeliveryPullRequest text — kept recognizable for stored failures. */
|
|
23
|
+
export const LEGACY_NO_LAND_COMMIT_PATTERN = /no commit after base .*; this run produced no change to deliver/i;
|
|
24
|
+
export function isAgentNoLandCommitMessage(message) {
|
|
25
|
+
return message.startsWith(AGENT_NO_LAND_COMMIT_PREFIX) || LEGACY_NO_LAND_COMMIT_PATTERN.test(message);
|
|
26
|
+
}
|
|
27
|
+
export class AgentNoLandCommitError extends Error {
|
|
28
|
+
baseCommit;
|
|
29
|
+
constructor(baseCommit) {
|
|
30
|
+
super(agentNoLandCommitMessage(baseCommit));
|
|
31
|
+
this.name = "AgentNoLandCommitError";
|
|
32
|
+
this.baseCommit = baseCommit;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export function pathMatchesScope(path, scope) {
|
|
36
|
+
const normalized = scope.replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/$/, "");
|
|
37
|
+
if (normalized.endsWith("/**")) {
|
|
38
|
+
const prefix = normalized.slice(0, -3);
|
|
39
|
+
return path === prefix || path.startsWith(`${prefix}/`);
|
|
40
|
+
}
|
|
41
|
+
return path === normalized;
|
|
42
|
+
}
|
|
43
|
+
export function filterPathsInScope(paths, changeScope) {
|
|
44
|
+
if (!changeScope.length)
|
|
45
|
+
return [...paths];
|
|
46
|
+
return paths.filter((path) => changeScope.some((scope) => pathMatchesScope(path, scope)));
|
|
47
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claims vs git — detect when an agent's Delivery narrative claims repository work
|
|
3
|
+
* that git cannot see on the attempt branch.
|
|
4
|
+
*/
|
|
5
|
+
import { execFile } from "node:child_process";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import { commitsAheadOfBase } from "./ensure-pull-request.js";
|
|
8
|
+
import { extractAgentReportJsonText, parseJsonObjectCandidate } from "./driver.js";
|
|
9
|
+
import { filterPathsInScope, requiresLandCommit, } from "./land-contract.js";
|
|
10
|
+
const execFileAsync = promisify(execFile);
|
|
11
|
+
const NO_CHANGE_OUTCOME = /no code changes|no changes were made|working tree clean|did not (?:make|land)|nothing to (?:change|deliver)|no commit was made/i;
|
|
12
|
+
async function listUncommittedPaths(workspace) {
|
|
13
|
+
const { stdout } = await execFileAsync("git", ["-C", workspace, "status", "--porcelain"], {
|
|
14
|
+
timeout: 30_000,
|
|
15
|
+
maxBuffer: 8_000_000,
|
|
16
|
+
});
|
|
17
|
+
const paths = [];
|
|
18
|
+
for (const line of stdout.split("\n")) {
|
|
19
|
+
if (!line.trim())
|
|
20
|
+
continue;
|
|
21
|
+
const entry = line.slice(3).trim();
|
|
22
|
+
if (!entry)
|
|
23
|
+
continue;
|
|
24
|
+
const path = entry.includes(" -> ")
|
|
25
|
+
? entry.slice(entry.lastIndexOf(" -> ") + 4).trim()
|
|
26
|
+
: entry;
|
|
27
|
+
if (path)
|
|
28
|
+
paths.push(path.replace(/^"|"$/g, ""));
|
|
29
|
+
}
|
|
30
|
+
return paths;
|
|
31
|
+
}
|
|
32
|
+
/** True when the land contract applies and git shows no commit and no scoped dirty files. */
|
|
33
|
+
export async function isLandTreeEmpty(input) {
|
|
34
|
+
if (!requiresLandCommit({ grants: input.grants, spec: input.spec }))
|
|
35
|
+
return false;
|
|
36
|
+
const base = input.spec.repository?.base_commit;
|
|
37
|
+
if (!base)
|
|
38
|
+
return false;
|
|
39
|
+
const countAhead = input.countCommitsAhead ?? commitsAheadOfBase;
|
|
40
|
+
const ahead = await countAhead(input.workspace, base);
|
|
41
|
+
if (ahead !== null && ahead > 0)
|
|
42
|
+
return false;
|
|
43
|
+
const pending = filterPathsInScope(await (input.listUncommittedPaths ?? listUncommittedPaths)(input.workspace), input.spec.change_scope ?? []);
|
|
44
|
+
return pending.length === 0;
|
|
45
|
+
}
|
|
46
|
+
function deliveryObjectClaimsWork(raw) {
|
|
47
|
+
if (Array.isArray(raw.changes) && raw.changes.some((entry) => typeof entry === "string" && entry.trim())) {
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
if (typeof raw.head_commit === "string" && raw.head_commit.trim())
|
|
51
|
+
return true;
|
|
52
|
+
if (Array.isArray(raw.evidence) && raw.evidence.some((entry) => {
|
|
53
|
+
return entry && typeof entry === "object" && entry.kind === "change";
|
|
54
|
+
})) {
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
if (Array.isArray(raw.acceptance_results) && raw.acceptance_results.some((entry) => {
|
|
58
|
+
return entry && typeof entry === "object" && entry.status === "met";
|
|
59
|
+
})) {
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
const outcome = typeof raw.outcome === "string" ? raw.outcome : "";
|
|
63
|
+
if (outcome && NO_CHANGE_OUTCOME.test(outcome))
|
|
64
|
+
return false;
|
|
65
|
+
if (outcome.trim()) {
|
|
66
|
+
if (/\b(rewrote|replaced|implemented|added|modified|removed|commit(?:ted)?|fetcher|\.rs\b|\.ts\b)\b/i.test(outcome)) {
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
/** Best-effort read of the agent reply — used before strict Delivery validation. */
|
|
73
|
+
export function agentClaimsRepositoryWork(text) {
|
|
74
|
+
if (!text.trim())
|
|
75
|
+
return false;
|
|
76
|
+
try {
|
|
77
|
+
const raw = parseJsonObjectCandidate(extractAgentReportJsonText(text));
|
|
78
|
+
if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
|
79
|
+
return deliveryObjectClaimsWork(raw);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// Fall through — malformed JSON may still prose-claim work in the tail.
|
|
84
|
+
}
|
|
85
|
+
if (NO_CHANGE_OUTCOME.test(text))
|
|
86
|
+
return false;
|
|
87
|
+
return /\b(rewrote|replaced|implemented|fetcher\.rs|head_commit|"changes"\s*:\s*\[)/i.test(text);
|
|
88
|
+
}
|
|
89
|
+
/** Parsed Delivery claims repository work (stricter than free text). */
|
|
90
|
+
export function reportClaimsRepositoryWork(report) {
|
|
91
|
+
if (report.changes.some((entry) => entry.trim()))
|
|
92
|
+
return true;
|
|
93
|
+
if (report.head_commit?.trim())
|
|
94
|
+
return true;
|
|
95
|
+
if (report.evidence.some((entry) => entry.kind === "change"))
|
|
96
|
+
return true;
|
|
97
|
+
if (report.acceptance_results.some((entry) => entry.status === "met"))
|
|
98
|
+
return true;
|
|
99
|
+
if (report.outcome && NO_CHANGE_OUTCOME.test(report.outcome))
|
|
100
|
+
return false;
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
/** Hard gate: land is required, git is empty, and the agent narrative claims work landed. */
|
|
104
|
+
export async function claimsVsGitMismatch(input) {
|
|
105
|
+
if (!agentClaimsRepositoryWork(input.agentReply))
|
|
106
|
+
return false;
|
|
107
|
+
return isLandTreeEmpty(input);
|
|
108
|
+
}
|
package/dist/ops.js
CHANGED
|
@@ -9,9 +9,11 @@ import { dirname, join, resolve } from "node:path";
|
|
|
9
9
|
import { parseArgs } from "node:util";
|
|
10
10
|
import { ConduitClient } from "./client.js";
|
|
11
11
|
import { loadConfig } from "./config.js";
|
|
12
|
+
import { ensureCheckout } from "./checkout.js";
|
|
12
13
|
import { detectInstalledClients, probeAgentHealth } from "./detect.js";
|
|
13
14
|
import { driverIdsFromDetectedLabels } from "./drivers.js";
|
|
14
15
|
import { BRIDGE_PROTOCOL_VERSION, describePreflightIssue, runBridgePreflight } from "./preflight.js";
|
|
16
|
+
import { applyRunnerToolPath, runnerServiceWorkspaceWarnings } from "./service.js";
|
|
15
17
|
import { bridgeVersion } from "./version.js";
|
|
16
18
|
export const OPS_VERBS = [
|
|
17
19
|
"connect", "enroll", "install", "switch", "online", "offline", "status", "doctor", "disconnect", "uninstall",
|
|
@@ -154,6 +156,17 @@ export async function resolveDrivers(env, argv, detect = detectInstalledClients)
|
|
|
154
156
|
throw new Error("No coding agent found on this computer. Install one (Claude Code, Codex, Cursor, OpenCode, Pi, Kiro, Antigravity, Grok Build), " +
|
|
155
157
|
"then retry — or set CONDUIT_DRIVERS / pass driver ids explicitly.");
|
|
156
158
|
}
|
|
159
|
+
/** When CONDUIT_DRIVERS is unset, shared-capacity machines should not auto-online every detected IDE. */
|
|
160
|
+
export function resolveInstallDrivers(env, argv, detect = detectInstalledClients) {
|
|
161
|
+
return resolveDrivers(env, argv, detect).then((drivers) => {
|
|
162
|
+
if (argv.length || splitOpsList(env.CONDUIT_DRIVERS).length || drivers.length <= 1)
|
|
163
|
+
return drivers;
|
|
164
|
+
const preferred = drivers.includes("claude-code") ? "claude-code" : drivers[0];
|
|
165
|
+
console.log(`Multiple agents detected — bringing only ${preferred} online. ` +
|
|
166
|
+
"Set CONDUIT_DRIVERS in ops.env to choose lanes explicitly.");
|
|
167
|
+
return [preferred];
|
|
168
|
+
});
|
|
169
|
+
}
|
|
157
170
|
/** Quote args for a copy-pasteable shell/cmd line (paths with spaces). */
|
|
158
171
|
export function shellQuoteArgs(args) {
|
|
159
172
|
return args.map((arg) => {
|
|
@@ -220,6 +233,11 @@ export async function runOps(verb, argv = [], deps = {}) {
|
|
|
220
233
|
console.log(`Repo: ${env.CONDUIT_REPO || "(none)"}`);
|
|
221
234
|
console.log(`Drivers: ${env.CONDUIT_DRIVERS || "(auto-detect installed agents)"}`);
|
|
222
235
|
console.log(`Roles: ${env.CONDUIT_ROLES}`);
|
|
236
|
+
if (env.CONDUIT_WORKSPACE) {
|
|
237
|
+
for (const warning of runnerServiceWorkspaceWarnings(resolve(expandOpsValue(env.CONDUIT_WORKSPACE)))) {
|
|
238
|
+
console.warn(`WARNING: ${warning}`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
223
241
|
console.log("");
|
|
224
242
|
}
|
|
225
243
|
else {
|
|
@@ -232,10 +250,15 @@ export async function runOps(verb, argv = [], deps = {}) {
|
|
|
232
250
|
requireOpsEnv(env);
|
|
233
251
|
if (!env.CONDUIT_WORKSPACE)
|
|
234
252
|
throw new Error(`Set CONDUIT_WORKSPACE in ${defaultOpsEnvPath()}`);
|
|
253
|
+
applyRunnerToolPath();
|
|
235
254
|
const config = await (deps.loadBridgeConfig ?? loadConfig)();
|
|
255
|
+
const workspace = resolve(expandOpsValue(env.CONDUIT_WORKSPACE));
|
|
256
|
+
for (const warning of runnerServiceWorkspaceWarnings(workspace)) {
|
|
257
|
+
console.warn(`WARNING: ${warning}`);
|
|
258
|
+
}
|
|
236
259
|
const report = await (deps.preflight ?? runBridgePreflight)({
|
|
237
260
|
config,
|
|
238
|
-
workspace
|
|
261
|
+
workspace,
|
|
239
262
|
expectedRepository: env.CONDUIT_REPO || undefined,
|
|
240
263
|
});
|
|
241
264
|
const installed = await detectInstalledClients();
|
|
@@ -257,6 +280,12 @@ export async function runOps(verb, argv = [], deps = {}) {
|
|
|
257
280
|
console.log("");
|
|
258
281
|
}
|
|
259
282
|
if (report.ready) {
|
|
283
|
+
const serviceWarnings = runnerServiceWorkspaceWarnings(workspace);
|
|
284
|
+
if (serviceWarnings.length) {
|
|
285
|
+
for (const warning of serviceWarnings)
|
|
286
|
+
console.warn(`WARNING: ${warning}`);
|
|
287
|
+
throw new Error("Background runner service workspace does not match ops.env");
|
|
288
|
+
}
|
|
260
289
|
console.log("Workspace, online lanes, local fuel, CLI availability, and authentication are ready.");
|
|
261
290
|
return;
|
|
262
291
|
}
|
|
@@ -446,12 +475,20 @@ export async function runOps(verb, argv = [], deps = {}) {
|
|
|
446
475
|
throw new Error(`No workspace declared. Rerun with --workspace /path/to/repo, or set CONDUIT_WORKSPACE in ${defaultOpsEnvPath()}`);
|
|
447
476
|
}
|
|
448
477
|
const workspace = resolve(expandOpsValue(installEnv.CONDUIT_WORKSPACE));
|
|
449
|
-
const drivers = await
|
|
478
|
+
const drivers = await (deps.resolveInstallDrivers ?? resolveInstallDrivers)(installEnv, installArgv);
|
|
450
479
|
for (const id of drivers) {
|
|
451
480
|
if (LOCAL_FUEL_DRIVERS.has(id))
|
|
452
481
|
runBridge(["drivers", "fuel", id, "local"]);
|
|
453
482
|
}
|
|
454
483
|
runBridge(["drivers", "online", ...drivers]);
|
|
484
|
+
applyRunnerToolPath();
|
|
485
|
+
if (installEnv.CONDUIT_REPO) {
|
|
486
|
+
const checkout = deps.ensureCheckout ?? ensureCheckout;
|
|
487
|
+
const result = await checkout(workspace, installEnv.CONDUIT_REPO);
|
|
488
|
+
console.log(result === "cloned"
|
|
489
|
+
? `Cloned ${installEnv.CONDUIT_REPO} into ${workspace}`
|
|
490
|
+
: `Workspace already matches ${installEnv.CONDUIT_REPO}`);
|
|
491
|
+
}
|
|
455
492
|
// Prove the exact local environment before installing a service that advertises availability.
|
|
456
493
|
runBridge(["ops", "doctor"]);
|
|
457
494
|
const installArgs = ["install-service", "--workspace", workspace];
|
package/dist/service.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
|
|
1
|
+
import { mkdir, readFile, readdir, unlink, writeFile } from "node:fs/promises";
|
|
2
2
|
import { homedir, platform } from "node:os";
|
|
3
|
-
import { dirname, join } from "node:path";
|
|
4
|
-
import { realpathSync } from "node:fs";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
5
5
|
import { spawn, spawnSync } from "node:child_process";
|
|
6
6
|
export const SERVICE_LABEL = "io.miraland.conduit-runner";
|
|
7
7
|
function configDir() {
|
|
@@ -124,8 +124,88 @@ function escapeSystemd(value) {
|
|
|
124
124
|
function launchAgentsPath() {
|
|
125
125
|
return join(homedir(), "Library", "LaunchAgents", `${SERVICE_LABEL}.plist`);
|
|
126
126
|
}
|
|
127
|
-
function systemdUnitPath() {
|
|
128
|
-
return join(
|
|
127
|
+
export function systemdUnitPath(home = homedir()) {
|
|
128
|
+
return join(home, ".config", "systemd", "user", "conduit-runner.service");
|
|
129
|
+
}
|
|
130
|
+
export function systemdDropInDir(home = homedir()) {
|
|
131
|
+
return join(home, ".config", "systemd", "user", "conduit-runner.service.d");
|
|
132
|
+
}
|
|
133
|
+
/** Parse `--workspace` from a runner argv list or systemd ExecStart line. */
|
|
134
|
+
export function parseRunnerWorkspaceFromProgramArguments(args) {
|
|
135
|
+
const index = args.indexOf("--workspace");
|
|
136
|
+
return index >= 0 && args[index + 1] ? args[index + 1] : null;
|
|
137
|
+
}
|
|
138
|
+
export function parseRunnerWorkspaceFromExecStart(execStart) {
|
|
139
|
+
const match = /--workspace(?:=|\s+)(?:"([^"]+)"|'([^']+)'|([^\s]+))/.exec(execStart);
|
|
140
|
+
return match?.[1] ?? match?.[2] ?? match?.[3] ?? null;
|
|
141
|
+
}
|
|
142
|
+
function dropInOverridesExecStart(content) {
|
|
143
|
+
return /^\s*ExecStart\s*=/m.test(content);
|
|
144
|
+
}
|
|
145
|
+
/** Drop-in fragments that override ExecStart can silently hijack ops install — strip them. */
|
|
146
|
+
export async function removeExecStartOverridesFromSystemdDropIns(home = homedir()) {
|
|
147
|
+
const dir = systemdDropInDir(home);
|
|
148
|
+
if (!existsSync(dir))
|
|
149
|
+
return [];
|
|
150
|
+
const removed = [];
|
|
151
|
+
for (const name of await readdir(dir)) {
|
|
152
|
+
if (!name.endsWith(".conf"))
|
|
153
|
+
continue;
|
|
154
|
+
const path = join(dir, name);
|
|
155
|
+
const content = await readFile(path, "utf8");
|
|
156
|
+
if (!dropInOverridesExecStart(content))
|
|
157
|
+
continue;
|
|
158
|
+
const kept = content.split(/\r?\n/).filter((line) => {
|
|
159
|
+
const trimmed = line.trim();
|
|
160
|
+
return trimmed && !trimmed.startsWith("#") && !/^ExecStart\s*=/.test(trimmed);
|
|
161
|
+
});
|
|
162
|
+
if (kept.length === 0) {
|
|
163
|
+
await unlink(path);
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
const next = content.split(/\r?\n/).filter((line) => !/^\s*ExecStart\s*=/.test(line)).join("\n").trimEnd();
|
|
167
|
+
await writeFile(path, next ? `${next}\n` : "", { mode: 0o644 });
|
|
168
|
+
if (!next)
|
|
169
|
+
await unlink(path).catch(() => undefined);
|
|
170
|
+
}
|
|
171
|
+
removed.push(name);
|
|
172
|
+
}
|
|
173
|
+
return removed;
|
|
174
|
+
}
|
|
175
|
+
export async function readStoredRunnerServiceWorkspace(home = homedir()) {
|
|
176
|
+
try {
|
|
177
|
+
const stored = JSON.parse(await readFile(serviceStatePath(), "utf8"));
|
|
178
|
+
return stored.options.workspace
|
|
179
|
+
?? parseRunnerWorkspaceFromProgramArguments(stored.programArguments)
|
|
180
|
+
?? null;
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
/** Effective workspace from the installed supervisor (systemd user unit on Linux). */
|
|
187
|
+
export function readEffectiveLinuxRunnerWorkspace(home = homedir()) {
|
|
188
|
+
if (platform() !== "linux")
|
|
189
|
+
return null;
|
|
190
|
+
const result = spawnSync("systemctl", ["--user", "cat", "conduit-runner.service"], { encoding: "utf8" });
|
|
191
|
+
if (result.status !== 0)
|
|
192
|
+
return null;
|
|
193
|
+
let execStart = null;
|
|
194
|
+
for (const line of result.stdout.split(/\r?\n/)) {
|
|
195
|
+
if (line.startsWith("ExecStart="))
|
|
196
|
+
execStart = line.slice("ExecStart=".length);
|
|
197
|
+
}
|
|
198
|
+
return execStart ? parseRunnerWorkspaceFromExecStart(execStart) : null;
|
|
199
|
+
}
|
|
200
|
+
export function runnerServiceWorkspaceWarnings(declaredWorkspace, home = homedir()) {
|
|
201
|
+
const declared = resolve(declaredWorkspace);
|
|
202
|
+
const warnings = [];
|
|
203
|
+
const stored = platform() === "linux" ? readEffectiveLinuxRunnerWorkspace(home) : null;
|
|
204
|
+
if (stored && resolve(stored) !== declared) {
|
|
205
|
+
warnings.push(`Background runner service uses workspace ${stored}, but ops.env declares ${declaredWorkspace}. ` +
|
|
206
|
+
"Run ops install to realign the service, or remove stale systemd drop-ins under conduit-runner.service.d/.");
|
|
207
|
+
}
|
|
208
|
+
return warnings;
|
|
129
209
|
}
|
|
130
210
|
export const WINDOWS_TASK_NAME = "ConduitBridgeRunner";
|
|
131
211
|
/** Quote one argv token for a Windows Task Scheduler /TR command line. */
|
|
@@ -256,6 +336,11 @@ export async function installRunnerService(options = {}) {
|
|
|
256
336
|
return { path: plistPath, platform: "darwin" };
|
|
257
337
|
}
|
|
258
338
|
const unitPath = systemdUnitPath();
|
|
339
|
+
const removedDropIns = await removeExecStartOverridesFromSystemdDropIns();
|
|
340
|
+
if (removedDropIns.length) {
|
|
341
|
+
console.warn(`Removed stale systemd drop-in(s) that overrode ExecStart: ${removedDropIns.join(", ")}. ` +
|
|
342
|
+
"Those files can silently point the runner at the wrong checkout after ops switch.");
|
|
343
|
+
}
|
|
259
344
|
await mkdir(dirname(unitPath), { recursive: true });
|
|
260
345
|
await mkdir(linuxLogDir(), { recursive: true });
|
|
261
346
|
await writeFile(unitPath, systemdUserUnit(programArguments, configDir()), { mode: 0o644 });
|
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.4",
|
|
4
4
|
"description": "Conduit Bridge CLI — 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": {
|