@miraland-labs/conduit-bridge 0.16.3 → 0.16.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/attempt-worktree.js +27 -0
- package/dist/ensure-land-commit.js +8 -1
- package/dist/execution.js +280 -100
- package/dist/land-contract.js +6 -1
- package/dist/land-git-reality.js +108 -0
- package/package.json +1 -1
package/dist/attempt-worktree.js
CHANGED
|
@@ -53,8 +53,35 @@ export async function createAttemptWorktree(input) {
|
|
|
53
53
|
// "commit on the current branch" accurate. -b fails if the branch exists, which is the desired
|
|
54
54
|
// guard — createAttemptWorktree only ever runs for a fresh attempt id (resume reuses its worktree).
|
|
55
55
|
["-C", input.sourceWorkspace, "worktree", "add", "-b", attemptBranchName(input.attemptId), path, input.startCommit], { timeout: 60_000, maxBuffer: 2_000_000 });
|
|
56
|
+
await ensureWorktreeGitIdentity(path, input.sourceWorkspace);
|
|
56
57
|
return path;
|
|
57
58
|
}
|
|
59
|
+
/** Attempt worktrees are separate checkouts; agents cannot always run git config. Inherit identity from source or set Bridge defaults. */
|
|
60
|
+
export async function ensureWorktreeGitIdentity(worktree, sourceWorkspace) {
|
|
61
|
+
const git = async (...args) => execFileAsync("git", ["-C", worktree, ...args], {
|
|
62
|
+
timeout: 30_000,
|
|
63
|
+
maxBuffer: 1_000_000,
|
|
64
|
+
});
|
|
65
|
+
const readConfig = async (repo, key) => {
|
|
66
|
+
try {
|
|
67
|
+
const { stdout } = await execFileAsync("git", ["-C", repo, "config", "--get", key], {
|
|
68
|
+
timeout: 30_000,
|
|
69
|
+
maxBuffer: 1_000_000,
|
|
70
|
+
});
|
|
71
|
+
return stdout.trim();
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return "";
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
for (const key of ["user.email", "user.name"]) {
|
|
78
|
+
if (await readConfig(worktree, key))
|
|
79
|
+
continue;
|
|
80
|
+
const inherited = await readConfig(sourceWorkspace, key);
|
|
81
|
+
const fallback = key === "user.email" ? "conduit-bridge@miraland.io" : "Conduit Bridge";
|
|
82
|
+
await git("config", key, inherited || fallback);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
58
85
|
export async function removeAttemptWorktree(sourceWorkspace, worktreePath) {
|
|
59
86
|
// Settled repair releases are returned on every assignment poll until the local tree is gone.
|
|
60
87
|
// Make that at-least-once signal cheap and idempotent instead of invoking Git on a missing path.
|
|
@@ -36,6 +36,8 @@ async function uncommittedPaths(workspace) {
|
|
|
36
36
|
}
|
|
37
37
|
return paths;
|
|
38
38
|
}
|
|
39
|
+
const LAND_COMMIT_EMAIL = "conduit-bridge@miraland.io";
|
|
40
|
+
const LAND_COMMIT_NAME = "Conduit Bridge";
|
|
39
41
|
async function commitScopedPaths(workspace, paths) {
|
|
40
42
|
if (!paths.length)
|
|
41
43
|
throw new Error("Cannot land an empty path set");
|
|
@@ -43,7 +45,12 @@ async function commitScopedPaths(workspace, paths) {
|
|
|
43
45
|
timeout: 120_000,
|
|
44
46
|
maxBuffer: 8_000_000,
|
|
45
47
|
});
|
|
46
|
-
await execFileAsync("git", [
|
|
48
|
+
await execFileAsync("git", [
|
|
49
|
+
"-C", workspace,
|
|
50
|
+
"-c", `user.email=${LAND_COMMIT_EMAIL}`,
|
|
51
|
+
"-c", `user.name=${LAND_COMMIT_NAME}`,
|
|
52
|
+
"commit", "-m", "Conduit: land scoped agent changes",
|
|
53
|
+
], { timeout: 120_000, maxBuffer: 8_000_000 });
|
|
47
54
|
}
|
|
48
55
|
/**
|
|
49
56
|
* When the land contract applies, ensure the attempt branch has at least one commit after base.
|
package/dist/execution.js
CHANGED
|
@@ -12,7 +12,8 @@ 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
14
|
import { ensureLandCommit } from "./ensure-land-commit.js";
|
|
15
|
-
import { AgentNoLandCommitError, requiresLandCommit } from "./land-contract.js";
|
|
15
|
+
import { AgentNoLandCommitError, agentNoLandCommitMessage, requiresLandCommit } from "./land-contract.js";
|
|
16
|
+
import { agentClaimsRepositoryWork, claimsVsGitMismatch, isLandTreeEmpty, reportClaimsRepositoryWork, } from "./land-git-reality.js";
|
|
16
17
|
const execFileAsync = promisify(execFile);
|
|
17
18
|
import { captureVerificationFailure, ensureTestEvidence } from "./ensure-test-evidence.js";
|
|
18
19
|
/** Keep a Mac awake only while an assignment is active; display sleep remains allowed. */
|
|
@@ -871,105 +872,251 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
|
|
|
871
872
|
}
|
|
872
873
|
}
|
|
873
874
|
let agentSessionId = result.sessionId ?? undefined;
|
|
874
|
-
let report;
|
|
875
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;
|
|
876
910
|
try {
|
|
877
911
|
report = parseAgentReport(reportText, spec.acceptance ?? []);
|
|
878
912
|
}
|
|
879
913
|
catch (error) {
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
for (let repairTurn = 1; repairTurn <= maxRepairs; repairTurn++) {
|
|
889
|
-
const replyDigest = createHash("sha256").update(previousReply).digest("hex");
|
|
890
|
-
await client.attemptRequest(taskId, "progress", {
|
|
891
|
-
phase: "preparing_delivery",
|
|
892
|
-
message: `Delivery envelope invalid (${redactSecrets(parseError)}); starting report-only repair turn ${repairTurn}/${maxRepairs} without rerunning implementation. Original reply: ${previousReply.length} characters, sha256:${replyDigest}.`,
|
|
893
|
-
idempotency_key: `bridge:progress:${active.attemptId}:delivery-repair:${repairTurn}`,
|
|
894
|
-
});
|
|
895
|
-
try {
|
|
896
|
-
repaired = await driver.run({
|
|
897
|
-
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,
|
|
898
922
|
workspace: attemptWorkspace,
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
capabilities: [],
|
|
903
|
-
verificationCommands: [],
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
resumeSessionId,
|
|
907
|
-
timeoutMs,
|
|
908
|
-
model: selection.model,
|
|
923
|
+
spec,
|
|
924
|
+
grants,
|
|
925
|
+
driver,
|
|
926
|
+
capabilities: spec.required_capabilities ?? [],
|
|
927
|
+
verificationCommands: attemptBrief?.verification ?? [],
|
|
928
|
+
objective: task.objective,
|
|
929
|
+
selection,
|
|
909
930
|
fuel,
|
|
910
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`,
|
|
911
936
|
});
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
if (repaired.status === "failed") {
|
|
925
|
-
const message = repaired.error ?? "Delivery report repair failed";
|
|
926
|
-
await queueTerminal(client, taskId, { action: "fail", body: { error: message, retryable: retryableAgentFailure(message), idempotency_key: `bridge:delivery-repair-failed:${active.attemptId}` } });
|
|
927
|
-
console.error(`Assignment ${taskId} report-only repair failed: ${redactSecrets(message)}`);
|
|
928
|
-
return;
|
|
929
|
-
}
|
|
930
|
-
previousReply = repaired.resultText ?? "";
|
|
931
|
-
try {
|
|
932
|
-
parsed = parseAgentReport(previousReply, spec.acceptance ?? []);
|
|
933
|
-
break;
|
|
934
|
-
}
|
|
935
|
-
catch (repairError) {
|
|
936
|
-
parseError = repairError instanceof Error ? repairError.message : "Repaired delivery report was invalid";
|
|
937
|
-
if (repairTurn >= maxRepairs) {
|
|
938
|
-
const detail = `Delivery report repair exhausted: ${parseError}`;
|
|
939
|
-
// Implementation finished; only its response envelope is invalid. Preserve the exact
|
|
940
|
-
// tree and identify this narrow condition so the control plane can prepare repair
|
|
941
|
-
// guidance instead of discarding the work and asking the owner to debug JSON.
|
|
942
|
-
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));
|
|
943
949
|
const response = await queueTerminal(client, taskId, {
|
|
944
950
|
action: "fail",
|
|
945
951
|
body: {
|
|
946
|
-
error:
|
|
947
|
-
retryable:
|
|
948
|
-
|
|
949
|
-
code: "delivery_report_invalid",
|
|
950
|
-
class: "contract",
|
|
951
|
-
disposition: "rework",
|
|
952
|
-
responsible_party: "conduit",
|
|
953
|
-
message: "Conduit could not prepare a valid delivery report from the completed agent run.",
|
|
954
|
-
next_action: "Conductor will prepare bounded recovery guidance. You do not need to edit technical constraints.",
|
|
955
|
-
diagnostic_detail: detail,
|
|
956
|
-
},
|
|
957
|
-
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}`,
|
|
958
955
|
},
|
|
959
956
|
});
|
|
960
957
|
retainAttemptWorktree = retainDiagnosticWorktree(response);
|
|
961
|
-
console.error(`Assignment ${taskId}
|
|
962
|
-
|
|
963
|
-
|
|
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)}`);
|
|
964
1016
|
return;
|
|
965
1017
|
}
|
|
966
|
-
|
|
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)}`);
|
|
1027
|
+
return;
|
|
1028
|
+
}
|
|
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
|
+
}
|
|
967
1067
|
}
|
|
1068
|
+
if (!parsed)
|
|
1069
|
+
return;
|
|
1070
|
+
reportText = previousReply;
|
|
1071
|
+
report = parsed;
|
|
968
1072
|
}
|
|
969
|
-
|
|
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;
|
|
1101
|
+
}
|
|
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)}`);
|
|
970
1118
|
return;
|
|
971
|
-
|
|
972
|
-
report = parsed;
|
|
1119
|
+
}
|
|
973
1120
|
}
|
|
974
1121
|
try {
|
|
975
1122
|
report = await finalizeRepositoryLand({
|
|
@@ -990,6 +1137,7 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
|
|
|
990
1137
|
fuelSource,
|
|
991
1138
|
timeoutMs,
|
|
992
1139
|
resumeSessionId: agentSessionId,
|
|
1140
|
+
landContinuationUsed,
|
|
993
1141
|
});
|
|
994
1142
|
// Mechanical land path: agent may forget gh; Bridge pushes and the control plane opens the PR.
|
|
995
1143
|
report = await ensureDeliveryPullRequest({
|
|
@@ -1100,6 +1248,37 @@ function buildLandContinuationPrompt(input) {
|
|
|
1100
1248
|
function bindLandHeadCommit(report, headCommit) {
|
|
1101
1249
|
return { ...report, head_commit: headCommit };
|
|
1102
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
|
+
}
|
|
1103
1282
|
async function finalizeRepositoryLand(input) {
|
|
1104
1283
|
if (!requiresLandCommit({ grants: input.grants, spec: input.spec }))
|
|
1105
1284
|
return input.report;
|
|
@@ -1128,34 +1307,31 @@ async function finalizeRepositoryLand(input) {
|
|
|
1128
1307
|
if (!(error instanceof AgentNoLandCommitError))
|
|
1129
1308
|
throw error;
|
|
1130
1309
|
}
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
objective: input.objective,
|
|
1139
|
-
spec: input.spec,
|
|
1140
|
-
acceptance: input.spec.acceptance ?? [],
|
|
1141
|
-
}),
|
|
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,
|
|
1142
1317
|
workspace: input.workspace,
|
|
1318
|
+
spec: input.spec,
|
|
1143
1319
|
grants: input.grants,
|
|
1320
|
+
driver: input.driver,
|
|
1144
1321
|
capabilities: input.capabilities,
|
|
1145
1322
|
verificationCommands: input.verificationCommands,
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
timeoutMs: input.timeoutMs,
|
|
1149
|
-
model: input.selection.model ?? undefined,
|
|
1323
|
+
objective: input.objective,
|
|
1324
|
+
selection: input.selection,
|
|
1150
1325
|
fuel: input.fuel,
|
|
1151
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`,
|
|
1152
1331
|
});
|
|
1153
|
-
if (continuation.status === "failed") {
|
|
1154
|
-
throw new AgentNoLandCommitError(input.spec.repository?.base_commit ?? "unknown");
|
|
1155
|
-
}
|
|
1156
1332
|
let report = input.report;
|
|
1157
1333
|
try {
|
|
1158
|
-
report = parseAgentReport(continuation.resultText
|
|
1334
|
+
report = parseAgentReport(continuation.resultText, input.spec.acceptance ?? []);
|
|
1159
1335
|
}
|
|
1160
1336
|
catch {
|
|
1161
1337
|
// Land may have succeeded even when the continuation envelope is malformed — git is authoritative.
|
|
@@ -1486,7 +1662,11 @@ function pathMatchesScope(path, scope) {
|
|
|
1486
1662
|
const prefix = normalized.slice(0, -3);
|
|
1487
1663
|
return path === prefix || path.startsWith(`${prefix}/`);
|
|
1488
1664
|
}
|
|
1489
|
-
|
|
1665
|
+
if (path === normalized)
|
|
1666
|
+
return true;
|
|
1667
|
+
if (!normalized.includes("*") && path.startsWith(`${normalized}/`))
|
|
1668
|
+
return true;
|
|
1669
|
+
return false;
|
|
1490
1670
|
}
|
|
1491
1671
|
function retainDiagnosticWorktree(response) {
|
|
1492
1672
|
// Lease expiry is reconciled by the CP into another diagnostic attempt. The old terminal cannot
|
package/dist/land-contract.js
CHANGED
|
@@ -38,7 +38,12 @@ export function pathMatchesScope(path, scope) {
|
|
|
38
38
|
const prefix = normalized.slice(0, -3);
|
|
39
39
|
return path === prefix || path.startsWith(`${prefix}/`);
|
|
40
40
|
}
|
|
41
|
-
|
|
41
|
+
if (path === normalized)
|
|
42
|
+
return true;
|
|
43
|
+
// Directory scopes without /** still cover nested files (oracle-file-delivery/src/fetcher.rs).
|
|
44
|
+
if (!normalized.includes("*") && path.startsWith(`${normalized}/`))
|
|
45
|
+
return true;
|
|
46
|
+
return false;
|
|
42
47
|
}
|
|
43
48
|
export function filterPathsInScope(paths, changeScope) {
|
|
44
49
|
if (!changeScope.length)
|
|
@@ -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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@miraland-labs/conduit-bridge",
|
|
3
|
-
"version": "0.16.
|
|
3
|
+
"version": "0.16.5",
|
|
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": {
|