@bridge_gpt/mcp-server 0.2.53 → 0.2.55
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/README.md +116 -56
- package/build/agent-guidance.generated.js +3 -0
- package/build/agent-launchers/claude-executor-adapter.js +3 -0
- package/build/agent-launchers/claude.js +3 -3
- package/build/agent-launchers/prompt.js +8 -11
- package/build/agent-notes.js +178 -0
- package/build/agent-registry.js +5 -2
- package/build/agent-utils.js +58 -0
- package/build/agents.generated.js +1 -1
- package/build/base-ref.js +33 -9
- package/build/bounded-wait.js +174 -0
- package/build/codex-skill-adapter.js +55 -0
- package/build/commands.generated.js +6 -5
- package/build/conductor/bridge-api-client.js +235 -14
- package/build/conductor/bring-up-facts.js +187 -0
- package/build/conductor/claude-hook.js +7 -5
- package/build/conductor/cli.js +28 -0
- package/build/conductor/doctor.js +80 -9
- package/build/conductor/epic-implementer-cli.js +1298 -0
- package/build/conductor/epic-runtime.js +134 -98
- package/build/conductor/errors.js +2 -2
- package/build/conductor/git-ci-types.js +1 -1
- package/build/conductor/git-hooks.js +28 -14
- package/build/conductor/install-doctor.js +11 -5
- package/build/conductor/readiness-cli.js +10 -10
- package/build/conductor/readiness-sections.js +58 -9
- package/build/conductor/readiness.js +120 -4
- package/build/conductor/recovery-cli.js +289 -10
- package/build/conductor/recovery-operations.js +125 -2
- package/build/conductor/repair-contract.js +58 -0
- package/build/conductor/run-branch.js +137 -0
- package/build/conductor/store.js +2 -2
- package/build/conductor/supervisor-runtime.js +1 -1
- package/build/conductor/test-run-branch-vectors.js +165 -0
- package/build/conductor-bin.js +2 -139
- package/build/conductor-claude-hook-bin.js +2 -2
- package/build/conductor-claude-hook-removed-stub-bin.js +31 -0
- package/build/conductor-removed-stub-bin.js +30 -0
- package/build/docs.generated.js +1 -1
- package/build/doctor.js +145 -18
- package/build/drive-epic.js +752 -90
- package/build/epic-implementer-bin.js +145 -0
- package/build/epic-implementer-bundle-cli.js +264 -0
- package/build/epic-implementer-claude-hook-bin.js +3 -0
- package/build/epic-integration-pr.js +5 -3
- package/build/executor/claim-scope.js +104 -0
- package/build/executor/cli.js +14 -25
- package/build/executor/env-file-guard.js +82 -3
- package/build/executor/env.js +6 -0
- package/build/executor/interrupted-worktree.js +60 -0
- package/build/executor/job-errors.js +45 -0
- package/build/executor/job-runner.js +334 -9
- package/build/executor/job-types.js +25 -9
- package/build/executor/merge-tree-classifier.js +171 -0
- package/build/executor/reconcile-overlap-governance.js +129 -0
- package/build/executor/reconcile-overlap-job.js +989 -0
- package/build/executor/reconcile-overlap-types.js +14 -0
- package/build/executor/spawn-job-driver.js +1 -0
- package/build/executor/types.js +2 -0
- package/build/executor/worker-finalization.js +25 -2
- package/build/executor/worker-guard-hook.js +15 -7
- package/build/implement-epic/bridge-client.js +773 -0
- package/build/implement-epic/checkpoint-store.js +542 -0
- package/build/implement-epic/cli.js +3158 -0
- package/build/implement-epic/cut-protocol.js +392 -0
- package/build/implement-epic/lock.js +302 -0
- package/build/implement-epic/pr-state.js +286 -0
- package/build/implement-epic/spawn.js +113 -0
- package/build/index.js +657 -481
- package/build/init.js +72 -8
- package/build/install-bridge-conductor.js +5 -5
- package/build/install-bridge.js +403 -70
- package/build/local-artifact-storage.js +130 -0
- package/build/mcp-host-config.js +22 -60
- package/build/mcp-host-entry-adapter.js +18 -0
- package/build/mcp-host-targets.js +1 -21
- package/build/merge-pull-request.js +1 -1
- package/build/pipelines.generated.js +23 -16
- package/build/plan-epic-conductor-eligibility.js +1 -1
- package/build/plane/cli.js +321 -41
- package/build/plane/manifest.js +209 -1
- package/build/plane/member-roster.js +70 -0
- package/build/plane/preflight.js +128 -12
- package/build/plane/shutdown.js +18 -5
- package/build/plane/status.js +35 -1
- package/build/plane/supervisor.js +546 -164
- package/build/plane/types.js +25 -2
- package/build/polling-policy.js +72 -0
- package/build/readiness-check.js +3 -3
- package/build/readme.generated.js +1 -1
- package/build/review-generation.js +219 -0
- package/build/run-unit-tests-launcher.js +6 -1
- package/build/setup-epic.js +567 -38
- package/build/start-tickets-conductor.js +8 -7
- package/build/ticket-key-utils.js +4 -3
- package/build/ticket-review-artifact-gate.js +461 -0
- package/build/upgrade-cli.js +5 -26
- package/build/version.generated.js +3 -3
- package/build/worker-guard-hook-bin.js +1 -1
- package/docs/CONDUCTOR.md +8 -6
- package/docs/install/mcp-tool-integrations.md +23 -1
- package/package.json +5 -3
- package/pipelines/review-ticket.json +17 -4
|
@@ -0,0 +1,989 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `reconcile_overlap` executor job (BAPI-1152).
|
|
3
|
+
*
|
|
4
|
+
* Two epic children that edit the same file can both reach merge. The server
|
|
5
|
+
* (BAPI-1151) detects that and dispatches this job for the held child. The job
|
|
6
|
+
* MERGES the required epic commit into the child — never a rebase, never a force
|
|
7
|
+
* push — so the recorded child head stays an ancestor of whatever it produces.
|
|
8
|
+
*
|
|
9
|
+
* The phases, in order (`ReconcileOverlapPhase`):
|
|
10
|
+
*
|
|
11
|
+
* validated the payload is proven well-formed before ANY subprocess;
|
|
12
|
+
* classified `git merge-tree --write-tree` decides clean vs conflicted
|
|
13
|
+
* against the object database, before the worktree moves;
|
|
14
|
+
* merged a real `git merge --no-ff --no-commit` of the epic commit;
|
|
15
|
+
* checked the union of both tickets' structured local verification
|
|
16
|
+
* commands runs, with no LLM at all;
|
|
17
|
+
* worker-completed ONLY when the merge conflicted or the checks were red:
|
|
18
|
+
* exactly one worker, holding both tickets' intents, edits
|
|
19
|
+
* the working tree — it never commits and never pushes;
|
|
20
|
+
* governance-scanned the candidate is compared against the governance regions
|
|
21
|
+
* the server read at the INPUT epic head;
|
|
22
|
+
* pushed one fast-forward push, leased on the recorded child head;
|
|
23
|
+
* cleaned every exit that does not push resets the worktree to the
|
|
24
|
+
* recorded child head.
|
|
25
|
+
*
|
|
26
|
+
* Commits are the WRAPPER's in every flow. A clean merge is committed by the
|
|
27
|
+
* wrapper and any worker repair lands in a separate commit on top. A conflicted
|
|
28
|
+
* merge stays in progress (`MERGE_HEAD`) while the worker resolves it; the
|
|
29
|
+
* wrapper then commits the two-parent merge from the resolved conflict paths and
|
|
30
|
+
* commits any further repair separately. The merge commit is never amended.
|
|
31
|
+
*
|
|
32
|
+
* The job returns FACTS, never a verdict (R14 rule 3): the input heads, the
|
|
33
|
+
* classification, whether a worker ran, every edited path and why, the local
|
|
34
|
+
* checks observed, and either the pushed head or the reason nothing was pushed —
|
|
35
|
+
* a governance fact or an observed moved head. The server verifies all of it
|
|
36
|
+
* against the provider (`reconcile_overlap_completion_contract.py`).
|
|
37
|
+
*
|
|
38
|
+
* Runtime prerequisite: the push runs in the executor's OWN process under the
|
|
39
|
+
* machine's local git credential helper — the posture `merge-job.ts` uses for
|
|
40
|
+
* `gh pr merge`. The Bridge GitHub App stays `contents: read`. A helper that
|
|
41
|
+
* refuses the push is reported as `ReconcileOverlapPushUnauthorized`.
|
|
42
|
+
*/
|
|
43
|
+
import path from "node:path";
|
|
44
|
+
import { pollCiChecksForCommit, resolveConductorBridgeApiAccess, } from "../conductor/bridge-api-client.js";
|
|
45
|
+
import { normalizeRepoRelativePath } from "../conductor/file-scope-guard.js";
|
|
46
|
+
import { allRequiredChecksGreen } from "../conductor/local-merge.js";
|
|
47
|
+
import { buildExecutorBaseWorkerEnv } from "./env.js";
|
|
48
|
+
import { ReconcileOverlapBranchMismatch, ReconcileOverlapConflictsUnresolved, ReconcileOverlapMergeFailed, ReconcileOverlapNothingToMerge, ReconcileOverlapPayload as ReconcileOverlapPayloadInvalid, ReconcileOverlapPushRejected, ReconcileOverlapPushUnauthorized, ReconcileOverlapTopologyRefused, ReconcileOverlapVerificationRed, ReconcileOverlapWorkerRewroteHistory, } from "./job-errors.js";
|
|
49
|
+
import { classifyMergeTree } from "./merge-tree-classifier.js";
|
|
50
|
+
import { scanGovernanceRegions } from "./reconcile-overlap-governance.js";
|
|
51
|
+
import { RECONCILE_OVERLAP_PAYLOAD_SCHEMA_VERSION, } from "./reconcile-overlap-types.js";
|
|
52
|
+
import { validateWorkerFinalization } from "./worker-finalization.js";
|
|
53
|
+
export const RECONCILE_OVERLAP_JOB_TYPE = "reconcile_overlap";
|
|
54
|
+
/** Result-envelope bounds, mirrored from the server's result model. */
|
|
55
|
+
export const RECONCILE_EDITED_PATHS_MAX = 40;
|
|
56
|
+
export const RECONCILE_CHECKS_OBSERVED_MAX = 40;
|
|
57
|
+
export const RECONCILE_PATH_MAX_CHARS = 256;
|
|
58
|
+
export const RECONCILE_REASON_MAX_CHARS = 240;
|
|
59
|
+
export const RECONCILE_CHECK_NAME_MAX_CHARS = 200;
|
|
60
|
+
export const RECONCILE_CHECK_CONCLUSION_MAX_CHARS = 32;
|
|
61
|
+
const TRUNCATION_MARKER = "…[truncated]";
|
|
62
|
+
/** Payload bounds (mirrors `reconcile_overlap_payload.py`). */
|
|
63
|
+
const MAX_VERIFICATION_COMMANDS_PER_TICKET = 16;
|
|
64
|
+
const MAX_ARGV = 32;
|
|
65
|
+
const MAX_ARG_CHARS = 1024;
|
|
66
|
+
const MAX_COMMAND_TIMEOUT_SECONDS = 30 * 60;
|
|
67
|
+
const MAX_CHANGED_FILES = 1000;
|
|
68
|
+
const MAX_GOVERNANCE_REGIONS = 64;
|
|
69
|
+
const REF_NAME_MAX_CHARS = 255;
|
|
70
|
+
const CONFLICT_REPORT_DIR = ".conductor/";
|
|
71
|
+
/** The executor's per-worktree artifact directory, never part of a candidate. */
|
|
72
|
+
const CONDUCTOR_ARTIFACT_DIR = ".conductor/";
|
|
73
|
+
/** Command bounds. */
|
|
74
|
+
const GIT_TIMEOUT_MS = 120_000;
|
|
75
|
+
const PUSH_TIMEOUT_MS = 180_000;
|
|
76
|
+
const COMMAND_OUTPUT_TAIL_CHARS = 4_000;
|
|
77
|
+
/** Left in the job budget when the wrapper waits for provider checks. */
|
|
78
|
+
const CI_WAIT_MARGIN_MS = 90_000;
|
|
79
|
+
const CI_WAIT_POLL_INTERVAL_MS = 30_000;
|
|
80
|
+
/** Finalization settling: GitHub updates a PR's head a moment after the push. */
|
|
81
|
+
const FINALIZATION_ATTEMPTS = 6;
|
|
82
|
+
const FINALIZATION_RETRY_DELAY_MS = 2_000;
|
|
83
|
+
/**
|
|
84
|
+
* Paths the wrapper never stages: the executor's own per-worktree artifacts. They
|
|
85
|
+
* are also excluded through `.git/info/exclude`, but a foreign repository's
|
|
86
|
+
* `.gitignore` may not name `.mcp.json`, so the pathspec is the second line.
|
|
87
|
+
*/
|
|
88
|
+
const WRAPPER_STAGE_EXCLUDES = [
|
|
89
|
+
":(exclude).conductor",
|
|
90
|
+
":(exclude).mcp.json",
|
|
91
|
+
":(exclude).cursor/mcp.json",
|
|
92
|
+
":(exclude).claude/settings.local.json",
|
|
93
|
+
];
|
|
94
|
+
const SHA_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
95
|
+
const TAG_RE = /^[A-Z][A-Z0-9_]{0,31}$/;
|
|
96
|
+
const SHA256_HEX_RE = /^[0-9a-f]{64}$/;
|
|
97
|
+
const FAILED_CHECK_CONCLUSIONS = new Set([
|
|
98
|
+
"failure",
|
|
99
|
+
"cancelled",
|
|
100
|
+
"timed_out",
|
|
101
|
+
"action_required",
|
|
102
|
+
"startup_failure",
|
|
103
|
+
"stale",
|
|
104
|
+
]);
|
|
105
|
+
const PUSH_AUTH_FAILURE_RE = /authentication failed|could not read username|permission denied|terminal prompts disabled|invalid username or password|returned error: 403|requested url returned error: 401/i;
|
|
106
|
+
const PUSH_LEASE_FAILURE_RE = /stale info|\[rejected\]|non-fast-forward|fetch first/i;
|
|
107
|
+
class PayloadError extends Error {
|
|
108
|
+
}
|
|
109
|
+
function asRecord(value, field) {
|
|
110
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
111
|
+
throw new PayloadError(`${field} must be an object`);
|
|
112
|
+
}
|
|
113
|
+
return value;
|
|
114
|
+
}
|
|
115
|
+
function requiredSha(value, field) {
|
|
116
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
117
|
+
throw new PayloadError(`${field} is missing or blank`);
|
|
118
|
+
}
|
|
119
|
+
const sha = value.trim().toLowerCase();
|
|
120
|
+
if (!SHA_RE.test(sha))
|
|
121
|
+
throw new PayloadError(`${field} is not a full commit SHA`);
|
|
122
|
+
return sha;
|
|
123
|
+
}
|
|
124
|
+
function refName(value, field) {
|
|
125
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
126
|
+
throw new PayloadError(`${field} is missing or blank`);
|
|
127
|
+
}
|
|
128
|
+
const ref = value.trim();
|
|
129
|
+
// A ref is an argv element and a push destination: whitespace, control
|
|
130
|
+
// characters, a leading dash (option injection), and `..` are all refused.
|
|
131
|
+
if (ref.length > REF_NAME_MAX_CHARS ||
|
|
132
|
+
/[\s\x00-\x1f\x7f~^:?*[\\]/.test(ref) ||
|
|
133
|
+
ref.startsWith("-") ||
|
|
134
|
+
ref.includes("..")) {
|
|
135
|
+
throw new PayloadError(`${field} is not a usable branch name`);
|
|
136
|
+
}
|
|
137
|
+
return ref;
|
|
138
|
+
}
|
|
139
|
+
function pathList(value, field, max) {
|
|
140
|
+
if (!Array.isArray(value))
|
|
141
|
+
throw new PayloadError(`${field} must be a list`);
|
|
142
|
+
if (value.length > max)
|
|
143
|
+
throw new PayloadError(`${field} exceeds ${max} entries`);
|
|
144
|
+
const out = new Set();
|
|
145
|
+
for (const item of value) {
|
|
146
|
+
const normalized = normalizeRepoRelativePath(item);
|
|
147
|
+
if (normalized === null)
|
|
148
|
+
throw new PayloadError(`${field} contains an unsafe path`);
|
|
149
|
+
out.add(normalized);
|
|
150
|
+
}
|
|
151
|
+
return Array.from(out).sort();
|
|
152
|
+
}
|
|
153
|
+
function verificationCommands(value, field) {
|
|
154
|
+
if (value === undefined || value === null)
|
|
155
|
+
return [];
|
|
156
|
+
if (!Array.isArray(value))
|
|
157
|
+
throw new PayloadError(`${field} must be a list`);
|
|
158
|
+
if (value.length > MAX_VERIFICATION_COMMANDS_PER_TICKET) {
|
|
159
|
+
throw new PayloadError(`${field} exceeds ${MAX_VERIFICATION_COMMANDS_PER_TICKET} commands`);
|
|
160
|
+
}
|
|
161
|
+
return value.map((raw, index) => {
|
|
162
|
+
const entry = asRecord(raw, `${field}[${index}]`);
|
|
163
|
+
const name = typeof entry.name === "string" ? entry.name.trim() : "";
|
|
164
|
+
if (!name)
|
|
165
|
+
throw new PayloadError(`${field}[${index}].name is blank`);
|
|
166
|
+
const argv = entry.argv;
|
|
167
|
+
if (!Array.isArray(argv) || argv.length === 0 || argv.length > MAX_ARGV) {
|
|
168
|
+
throw new PayloadError(`${field}[${index}].argv must be a non-empty argv list`);
|
|
169
|
+
}
|
|
170
|
+
for (const arg of argv) {
|
|
171
|
+
if (typeof arg !== "string" || arg.trim().length === 0 || arg.length > MAX_ARG_CHARS || arg.includes("\0")) {
|
|
172
|
+
throw new PayloadError(`${field}[${index}].argv holds an unusable argument`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const timeout = entry.timeout_seconds;
|
|
176
|
+
if (typeof timeout !== "number" ||
|
|
177
|
+
!Number.isInteger(timeout) ||
|
|
178
|
+
timeout < 1 ||
|
|
179
|
+
timeout > MAX_COMMAND_TIMEOUT_SECONDS) {
|
|
180
|
+
throw new PayloadError(`${field}[${index}].timeout_seconds is out of range`);
|
|
181
|
+
}
|
|
182
|
+
return { name: name.slice(0, RECONCILE_CHECK_NAME_MAX_CHARS), argv: argv, timeoutSeconds: timeout };
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
function ticketContext(value, field) {
|
|
186
|
+
const entry = asRecord(value, field);
|
|
187
|
+
const ticketKey = typeof entry.ticket_key === "string" ? entry.ticket_key.trim() : "";
|
|
188
|
+
if (!ticketKey)
|
|
189
|
+
throw new PayloadError(`${field}.ticket_key is missing or blank`);
|
|
190
|
+
return {
|
|
191
|
+
ticketKey,
|
|
192
|
+
changedFiles: pathList(entry.changed_files, `${field}.changed_files`, MAX_CHANGED_FILES),
|
|
193
|
+
declaredTouchedFiles: pathList(entry.declared_touched_files ?? [], `${field}.declared_touched_files`, MAX_CHANGED_FILES),
|
|
194
|
+
verificationCommands: verificationCommands(entry.verification_commands, `${field}.verification_commands`),
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
function governanceRegions(value) {
|
|
198
|
+
if (!Array.isArray(value))
|
|
199
|
+
throw new PayloadError("governance.regions must be a list");
|
|
200
|
+
if (value.length > MAX_GOVERNANCE_REGIONS)
|
|
201
|
+
throw new PayloadError("governance.regions exceeds its bound");
|
|
202
|
+
return value.map((raw, index) => {
|
|
203
|
+
const entry = asRecord(raw, `governance.regions[${index}]`);
|
|
204
|
+
const tag = typeof entry.tag === "string" ? entry.tag : "";
|
|
205
|
+
const regionPath = normalizeRepoRelativePath(entry.path);
|
|
206
|
+
const begin = entry.begin_marker;
|
|
207
|
+
const end = entry.end_marker;
|
|
208
|
+
const sha = entry.content_sha256;
|
|
209
|
+
if (!TAG_RE.test(tag) ||
|
|
210
|
+
regionPath === null ||
|
|
211
|
+
typeof begin !== "string" ||
|
|
212
|
+
typeof end !== "string" ||
|
|
213
|
+
!begin.includes(tag) ||
|
|
214
|
+
!end.includes(tag) ||
|
|
215
|
+
typeof sha !== "string" ||
|
|
216
|
+
!SHA256_HEX_RE.test(sha) ||
|
|
217
|
+
!Number.isInteger(entry.begin_line) ||
|
|
218
|
+
!Number.isInteger(entry.end_line)) {
|
|
219
|
+
throw new PayloadError(`governance.regions[${index}] is malformed`);
|
|
220
|
+
}
|
|
221
|
+
return {
|
|
222
|
+
tag,
|
|
223
|
+
path: regionPath,
|
|
224
|
+
beginLine: entry.begin_line,
|
|
225
|
+
endLine: entry.end_line,
|
|
226
|
+
beginMarker: begin,
|
|
227
|
+
endMarker: end,
|
|
228
|
+
contentSha256: sha,
|
|
229
|
+
};
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
function governanceFindings(value) {
|
|
233
|
+
if (value === undefined || value === null)
|
|
234
|
+
return [];
|
|
235
|
+
if (!Array.isArray(value))
|
|
236
|
+
throw new PayloadError("governance.findings must be a list");
|
|
237
|
+
return value.map((raw, index) => {
|
|
238
|
+
const entry = asRecord(raw, `governance.findings[${index}]`);
|
|
239
|
+
const findingPath = normalizeRepoRelativePath(entry.path);
|
|
240
|
+
if (findingPath === null)
|
|
241
|
+
throw new PayloadError(`governance.findings[${index}].path is unsafe`);
|
|
242
|
+
return { path: findingPath, reason: typeof entry.reason === "string" ? entry.reason.slice(0, 300) : "" };
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Validate the claimed job's payload. Pure — no subprocess, no filesystem. A
|
|
247
|
+
* payload missing either input SHA fails here, so the job never infers a head.
|
|
248
|
+
*/
|
|
249
|
+
export function validateReconcileOverlapPayload(job) {
|
|
250
|
+
try {
|
|
251
|
+
const payload = asRecord(job.payload, "payload");
|
|
252
|
+
if (payload.schema_version !== RECONCILE_OVERLAP_PAYLOAD_SCHEMA_VERSION) {
|
|
253
|
+
throw new PayloadError(`payload.schema_version must be ${RECONCILE_OVERLAP_PAYLOAD_SCHEMA_VERSION}`);
|
|
254
|
+
}
|
|
255
|
+
const childHead = requiredSha(payload.child_head, "payload.child_head");
|
|
256
|
+
const requiredEpicSha = requiredSha(payload.required_epic_sha, "payload.required_epic_sha");
|
|
257
|
+
const epicBranch = refName(payload.epic_branch, "payload.epic_branch");
|
|
258
|
+
// The push destination is the claimed job's TOP-LEVEL expected_branch — the
|
|
259
|
+
// server-bound identity — never a payload field.
|
|
260
|
+
const expectedBranch = refName(job.expected_branch, "expected_branch");
|
|
261
|
+
const prNumber = payload.pr_number;
|
|
262
|
+
if (typeof prNumber !== "number" || !Number.isInteger(prNumber) || prNumber <= 0) {
|
|
263
|
+
throw new PayloadError("payload.pr_number must be a positive integer");
|
|
264
|
+
}
|
|
265
|
+
const requiredChecksRaw = payload.required_checks ?? [];
|
|
266
|
+
if (!Array.isArray(requiredChecksRaw))
|
|
267
|
+
throw new PayloadError("payload.required_checks must be a list");
|
|
268
|
+
const requiredChecks = requiredChecksRaw
|
|
269
|
+
.filter((c) => typeof c === "string" && c.trim().length > 0)
|
|
270
|
+
.map((c) => c.trim());
|
|
271
|
+
const child = ticketContext(payload.child, "payload.child");
|
|
272
|
+
const peer = payload.peer === null || payload.peer === undefined ? null : ticketContext(payload.peer, "payload.peer");
|
|
273
|
+
const governanceRaw = asRecord(payload.governance, "payload.governance");
|
|
274
|
+
const conflictReportPath = normalizeRepoRelativePath(payload.conflict_report_path);
|
|
275
|
+
if (conflictReportPath === null || !conflictReportPath.startsWith(CONFLICT_REPORT_DIR)) {
|
|
276
|
+
throw new PayloadError("payload.conflict_report_path must name a file under .conductor/");
|
|
277
|
+
}
|
|
278
|
+
const prompt = payload.prompt;
|
|
279
|
+
if (typeof prompt !== "string" || prompt.trim().length === 0) {
|
|
280
|
+
throw new PayloadError("payload.prompt is missing or blank");
|
|
281
|
+
}
|
|
282
|
+
const generation = payload.reconcile_generation ?? 0;
|
|
283
|
+
if (typeof generation !== "number" || !Number.isInteger(generation) || generation < 0) {
|
|
284
|
+
throw new PayloadError("payload.reconcile_generation must be a non-negative integer");
|
|
285
|
+
}
|
|
286
|
+
return {
|
|
287
|
+
ok: true,
|
|
288
|
+
value: {
|
|
289
|
+
schemaVersion: RECONCILE_OVERLAP_PAYLOAD_SCHEMA_VERSION,
|
|
290
|
+
childHead,
|
|
291
|
+
requiredEpicSha,
|
|
292
|
+
epicBranch,
|
|
293
|
+
expectedBranch,
|
|
294
|
+
prNumber,
|
|
295
|
+
requiredChecks,
|
|
296
|
+
child,
|
|
297
|
+
peer,
|
|
298
|
+
governance: {
|
|
299
|
+
regions: governanceRegions(governanceRaw.regions ?? []),
|
|
300
|
+
findings: governanceFindings(governanceRaw.findings),
|
|
301
|
+
scannedPaths: pathList(governanceRaw.scanned_paths ?? [], "governance.scanned_paths", MAX_CHANGED_FILES * 2),
|
|
302
|
+
},
|
|
303
|
+
declaredTouchedFilesUnion: pathList(payload.declared_touched_files_union ?? [], "payload.declared_touched_files_union", MAX_CHANGED_FILES * 4),
|
|
304
|
+
conflictReportPath,
|
|
305
|
+
prompt,
|
|
306
|
+
reconcileGeneration: generation,
|
|
307
|
+
},
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
catch (err) {
|
|
311
|
+
if (err instanceof PayloadError)
|
|
312
|
+
return { ok: false, error: err.message };
|
|
313
|
+
return { ok: false, error: "payload could not be validated" };
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
/** Thrown between steps when the supervising phase has been aborted. */
|
|
317
|
+
export class ReconcileOverlapAborted extends Error {
|
|
318
|
+
}
|
|
319
|
+
/** Thrown by a provider-check poller that has no Bridge API access at all. */
|
|
320
|
+
export class ProviderChecksUnobservable extends Error {
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* A provider-check poller that resolves Bridge API access once, on first use —
|
|
324
|
+
* the same read-only CI poll the `merge` job waits on. Without access it throws
|
|
325
|
+
* {@link ProviderChecksUnobservable}, and the wait ends immediately rather than
|
|
326
|
+
* sleeping out the job budget; the server's completion contract then reads the
|
|
327
|
+
* checks itself.
|
|
328
|
+
*/
|
|
329
|
+
export function lazyProviderCheckPoller(accessDeps) {
|
|
330
|
+
let access;
|
|
331
|
+
return async (sha) => {
|
|
332
|
+
if (access === undefined) {
|
|
333
|
+
const resolved = await resolveConductorBridgeApiAccess(accessDeps);
|
|
334
|
+
access = resolved.ok ? resolved.access : null;
|
|
335
|
+
}
|
|
336
|
+
if (access === null)
|
|
337
|
+
throw new ProviderChecksUnobservable("bridge api access unavailable");
|
|
338
|
+
return pollCiChecksForCommit(access, sha);
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
function bounded(text, max) {
|
|
342
|
+
if (text.length <= max)
|
|
343
|
+
return text;
|
|
344
|
+
const keep = max - TRUNCATION_MARKER.length;
|
|
345
|
+
return keep > 0 ? text.slice(0, keep) + TRUNCATION_MARKER : text.slice(0, max);
|
|
346
|
+
}
|
|
347
|
+
function tail(text) {
|
|
348
|
+
return text.length <= COMMAND_OUTPUT_TAIL_CHARS ? text : "…" + text.slice(-COMMAND_OUTPUT_TAIL_CHARS);
|
|
349
|
+
}
|
|
350
|
+
/** The deduplicated union of both tickets' commands, child first. */
|
|
351
|
+
export function verificationCommandUnion(child, peer) {
|
|
352
|
+
const seen = new Set();
|
|
353
|
+
const out = [];
|
|
354
|
+
for (const command of [...child.verificationCommands, ...(peer?.verificationCommands ?? [])]) {
|
|
355
|
+
const key = JSON.stringify(command.argv);
|
|
356
|
+
if (seen.has(key))
|
|
357
|
+
continue;
|
|
358
|
+
seen.add(key);
|
|
359
|
+
out.push(command);
|
|
360
|
+
}
|
|
361
|
+
return out;
|
|
362
|
+
}
|
|
363
|
+
/** Build the fact envelope, bounded before submission. */
|
|
364
|
+
export function buildReconcileEnvelope(payload, facts, extra = {}) {
|
|
365
|
+
const edited = [...facts.editedPaths].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
366
|
+
const checks = facts.checksObserved;
|
|
367
|
+
const envelope = {
|
|
368
|
+
child_head_in: payload.childHead,
|
|
369
|
+
required_epic_sha: payload.requiredEpicSha,
|
|
370
|
+
merge_classification: facts.classification,
|
|
371
|
+
worker_invoked: facts.workerInvoked,
|
|
372
|
+
edited_paths: edited.slice(0, RECONCILE_EDITED_PATHS_MAX).map((e) => ({
|
|
373
|
+
path: bounded(e.path, RECONCILE_PATH_MAX_CHARS),
|
|
374
|
+
reason: bounded(e.reason, RECONCILE_REASON_MAX_CHARS),
|
|
375
|
+
})),
|
|
376
|
+
checks_observed: checks.slice(0, RECONCILE_CHECKS_OBSERVED_MAX).map((c) => ({
|
|
377
|
+
name: bounded(c.name, RECONCILE_CHECK_NAME_MAX_CHARS),
|
|
378
|
+
conclusion: bounded(c.conclusion, RECONCILE_CHECK_CONCLUSION_MAX_CHARS),
|
|
379
|
+
})),
|
|
380
|
+
edited_paths_truncated: Math.max(0, edited.length - RECONCILE_EDITED_PATHS_MAX),
|
|
381
|
+
checks_observed_truncated: Math.max(0, checks.length - RECONCILE_CHECKS_OBSERVED_MAX),
|
|
382
|
+
};
|
|
383
|
+
for (const [key, value] of Object.entries(extra)) {
|
|
384
|
+
if (value !== undefined)
|
|
385
|
+
envelope[key] = value;
|
|
386
|
+
}
|
|
387
|
+
return envelope;
|
|
388
|
+
}
|
|
389
|
+
function anyRequiredCheckFailed(pollResponse, required) {
|
|
390
|
+
if (pollResponse === null || typeof pollResponse !== "object")
|
|
391
|
+
return false;
|
|
392
|
+
let obj = pollResponse;
|
|
393
|
+
const detail = obj.detail;
|
|
394
|
+
if (detail && typeof detail === "object" && Array.isArray(detail.checks)) {
|
|
395
|
+
obj = detail;
|
|
396
|
+
}
|
|
397
|
+
const checks = Array.isArray(obj.checks) ? obj.checks : [];
|
|
398
|
+
const wanted = new Set(required);
|
|
399
|
+
return checks.some((c) => {
|
|
400
|
+
if (!c || typeof c !== "object")
|
|
401
|
+
return false;
|
|
402
|
+
const name = typeof c.name === "string" ? c.name : "";
|
|
403
|
+
const conclusion = typeof c.conclusion === "string" ? c.conclusion.toLowerCase() : "";
|
|
404
|
+
return wanted.has(name) && FAILED_CHECK_CONCLUSIONS.has(conclusion);
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
// ---------------------------------------------------------------------------
|
|
408
|
+
// The driver
|
|
409
|
+
// ---------------------------------------------------------------------------
|
|
410
|
+
/**
|
|
411
|
+
* Build the `reconcile_overlap` spawn driver for a validated payload.
|
|
412
|
+
*
|
|
413
|
+
* Exported so the state machine is unit-testable against a real temporary git
|
|
414
|
+
* repository without the job runner around it.
|
|
415
|
+
*/
|
|
416
|
+
export function createReconcileOverlapDriver(job, payload, seams) {
|
|
417
|
+
const facts = {
|
|
418
|
+
classification: null,
|
|
419
|
+
workerInvoked: false,
|
|
420
|
+
mode: null,
|
|
421
|
+
mergeCommitSha: null,
|
|
422
|
+
preWorkerHead: null,
|
|
423
|
+
checks: new Map(),
|
|
424
|
+
failingCommandNames: [],
|
|
425
|
+
editedReasons: new Map(),
|
|
426
|
+
};
|
|
427
|
+
const commands = verificationCommandUnion(payload.child, payload.peer);
|
|
428
|
+
const workerEnv = buildExecutorBaseWorkerEnv(seams.env);
|
|
429
|
+
const git = async (ctx, args, timeoutMs = GIT_TIMEOUT_MS, env) => {
|
|
430
|
+
if (ctx.signal?.aborted)
|
|
431
|
+
throw new ReconcileOverlapAborted("reconciliation phase aborted");
|
|
432
|
+
return seams.runCommand("git", args, {
|
|
433
|
+
cwd: ctx.worktreePath,
|
|
434
|
+
timeoutMs,
|
|
435
|
+
...(env ? { env } : {}),
|
|
436
|
+
});
|
|
437
|
+
};
|
|
438
|
+
const revParse = async (ctx, ref) => {
|
|
439
|
+
const r = await git(ctx, ["rev-parse", "-q", "--verify", `${ref}^{commit}`]);
|
|
440
|
+
const sha = r.stdout.trim().toLowerCase();
|
|
441
|
+
return r.exitCode === 0 && SHA_RE.test(sha) ? sha : null;
|
|
442
|
+
};
|
|
443
|
+
const isAncestor = async (ctx, ancestor, head) => {
|
|
444
|
+
const r = await git(ctx, ["merge-base", "--is-ancestor", ancestor, head]);
|
|
445
|
+
return r.exitCode === 0;
|
|
446
|
+
};
|
|
447
|
+
const fail = (errorKind, phase, message) => ({
|
|
448
|
+
kind: "fail",
|
|
449
|
+
failure: {
|
|
450
|
+
error_kind: errorKind,
|
|
451
|
+
error_message: bounded(`reconcile_overlap [${phase}]: ${message}`, 500),
|
|
452
|
+
classification: "crashed",
|
|
453
|
+
},
|
|
454
|
+
});
|
|
455
|
+
const classificationKind = () => facts.classification?.kind === "conflicted" ? "conflicted" : "clean";
|
|
456
|
+
const envelope = (extra = {}) => buildReconcileEnvelope(payload, {
|
|
457
|
+
classification: classificationKind(),
|
|
458
|
+
workerInvoked: facts.workerInvoked,
|
|
459
|
+
editedPaths: Array.from(facts.editedReasons.entries()).map(([p, reason]) => ({ path: p, reason })),
|
|
460
|
+
checksObserved: Array.from(facts.checks.entries()).map(([name, conclusion]) => ({ name, conclusion })),
|
|
461
|
+
}, extra);
|
|
462
|
+
const complete = (extra = {}, lastCommitSha) => ({
|
|
463
|
+
kind: "complete",
|
|
464
|
+
result: envelope(extra),
|
|
465
|
+
...(lastCommitSha ? { lastCommitSha } : {}),
|
|
466
|
+
});
|
|
467
|
+
/** Reset to the recorded child head. Never throws. */
|
|
468
|
+
const resetToChildHead = async (ctx) => {
|
|
469
|
+
try {
|
|
470
|
+
const mergeHead = await seams.runCommand("git", ["rev-parse", "-q", "--verify", "MERGE_HEAD"], {
|
|
471
|
+
cwd: ctx.worktreePath,
|
|
472
|
+
timeoutMs: GIT_TIMEOUT_MS,
|
|
473
|
+
});
|
|
474
|
+
if (mergeHead.exitCode === 0) {
|
|
475
|
+
await seams.runCommand("git", ["merge", "--abort"], { cwd: ctx.worktreePath, timeoutMs: GIT_TIMEOUT_MS });
|
|
476
|
+
}
|
|
477
|
+
await seams.runCommand("git", ["reset", "--hard", payload.childHead], {
|
|
478
|
+
cwd: ctx.worktreePath,
|
|
479
|
+
timeoutMs: GIT_TIMEOUT_MS,
|
|
480
|
+
});
|
|
481
|
+
// The existing safe worktree cleanup: removes untracked, non-ignored
|
|
482
|
+
// residue. `.conductor/` is excluded explicitly as well as through
|
|
483
|
+
// `.git/info/exclude`: on a freshly prepared worktree in a foreign
|
|
484
|
+
// repository the exclude line may not be provisioned yet, and the executor's
|
|
485
|
+
// own artifacts (the worker log among them) must survive the reset.
|
|
486
|
+
await seams.runCommand("git", ["clean", "-fd", "-e", CONDUCTOR_ARTIFACT_DIR], {
|
|
487
|
+
cwd: ctx.worktreePath,
|
|
488
|
+
timeoutMs: GIT_TIMEOUT_MS,
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
catch (err) {
|
|
492
|
+
seams.errorLog(`[executor] job ${job.id}: reconcile_overlap reset to the recorded child head failed (${err instanceof Error ? err.message.slice(0, 120) : "unknown error"})`);
|
|
493
|
+
}
|
|
494
|
+
};
|
|
495
|
+
const writeReport = async (ctx, body) => {
|
|
496
|
+
const target = path.join(ctx.worktreePath, ...payload.conflictReportPath.split("/"));
|
|
497
|
+
await seams.mkdir(path.dirname(target), { recursive: true });
|
|
498
|
+
await seams.writeFile(target, body);
|
|
499
|
+
};
|
|
500
|
+
const conflictReport = (failing) => {
|
|
501
|
+
const lines = [
|
|
502
|
+
"# reconcile_overlap report",
|
|
503
|
+
"",
|
|
504
|
+
`Merge classification: ${classificationKind()}`,
|
|
505
|
+
`Child head: ${payload.childHead}`,
|
|
506
|
+
`Required epic SHA: ${payload.requiredEpicSha}`,
|
|
507
|
+
"",
|
|
508
|
+
];
|
|
509
|
+
if (facts.classification?.kind === "conflicted") {
|
|
510
|
+
lines.push("## Conflicts (from git merge-tree --write-tree)", "");
|
|
511
|
+
for (const conflict of facts.classification.conflicts) {
|
|
512
|
+
lines.push(`- ${conflict.path} — ${conflict.descriptor}`);
|
|
513
|
+
for (const message of conflict.messages)
|
|
514
|
+
lines.push(` - ${message}`);
|
|
515
|
+
}
|
|
516
|
+
lines.push("");
|
|
517
|
+
}
|
|
518
|
+
else {
|
|
519
|
+
lines.push("The merge was clean. The executor committed it; fix what the checks below report.", "");
|
|
520
|
+
}
|
|
521
|
+
if (failing.length > 0) {
|
|
522
|
+
lines.push("## Failing local verification commands", "");
|
|
523
|
+
for (const run of failing) {
|
|
524
|
+
lines.push(`### ${run.observation.name} (${run.observation.conclusion})`, "", "```", tail(run.output), "```", "");
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
return lines.join("\n");
|
|
528
|
+
};
|
|
529
|
+
/** Run the union of local verification commands. Never a provider check. */
|
|
530
|
+
const runVerification = async (ctx) => {
|
|
531
|
+
const runs = [];
|
|
532
|
+
for (const command of commands) {
|
|
533
|
+
if (ctx.signal.aborted)
|
|
534
|
+
throw new ReconcileOverlapAborted("reconciliation phase aborted");
|
|
535
|
+
const budget = Math.min(command.timeoutSeconds * 1000, ctx.remainingMs() - CI_WAIT_MARGIN_MS);
|
|
536
|
+
let conclusion;
|
|
537
|
+
let output = "";
|
|
538
|
+
if (budget <= 0) {
|
|
539
|
+
conclusion = "not_run";
|
|
540
|
+
}
|
|
541
|
+
else {
|
|
542
|
+
const started = seams.now();
|
|
543
|
+
try {
|
|
544
|
+
const [file, ...args] = command.argv;
|
|
545
|
+
const result = await seams.runCommand(file, args, {
|
|
546
|
+
cwd: ctx.worktreePath,
|
|
547
|
+
timeoutMs: budget,
|
|
548
|
+
// The SAME secret-free environment a worker gets, including the
|
|
549
|
+
// sentinel database target: a verification command can no more reach
|
|
550
|
+
// the operator's real database than a worker can.
|
|
551
|
+
env: workerEnv,
|
|
552
|
+
});
|
|
553
|
+
output = `${result.stdout}\n${result.stderr}`;
|
|
554
|
+
if (result.exitCode === 0)
|
|
555
|
+
conclusion = "success";
|
|
556
|
+
else
|
|
557
|
+
conclusion = seams.now() - started >= budget - 50 ? "timed_out" : "failure";
|
|
558
|
+
}
|
|
559
|
+
catch {
|
|
560
|
+
conclusion = "error";
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
const observation = { name: command.name, conclusion };
|
|
564
|
+
facts.checks.set(command.name, conclusion);
|
|
565
|
+
runs.push({ observation, green: conclusion === "success", output });
|
|
566
|
+
}
|
|
567
|
+
return runs;
|
|
568
|
+
};
|
|
569
|
+
/** Paths the candidate changes relative to the input epic head. */
|
|
570
|
+
const changedPathsAgainstEpic = async (ctx, candidate) => {
|
|
571
|
+
const r = await git(ctx, ["diff", "--name-only", "-z", "--no-renames", payload.requiredEpicSha, candidate]);
|
|
572
|
+
if (r.exitCode !== 0)
|
|
573
|
+
return null;
|
|
574
|
+
return r.stdout
|
|
575
|
+
.split("\0")
|
|
576
|
+
.map((p) => normalizeRepoRelativePath(p))
|
|
577
|
+
.filter((p) => p !== null);
|
|
578
|
+
};
|
|
579
|
+
const readTreeFile = async (ctx, treeish, filePath) => {
|
|
580
|
+
const r = await git(ctx, ["cat-file", "blob", `${treeish}:${filePath}`]);
|
|
581
|
+
if (r.exitCode !== 0) {
|
|
582
|
+
const exists = await git(ctx, ["cat-file", "-e", `${treeish}:${filePath}`]);
|
|
583
|
+
return exists.exitCode === 0 ? { kind: "unreadable" } : { kind: "absent" };
|
|
584
|
+
}
|
|
585
|
+
return r.stdout.includes("\0") ? { kind: "binary" } : { kind: "text", text: r.stdout };
|
|
586
|
+
};
|
|
587
|
+
/** Governance scan of a candidate tree or commit against the input epic head. */
|
|
588
|
+
const scanCandidate = async (ctx, candidate) => {
|
|
589
|
+
const changed = await changedPathsAgainstEpic(ctx, candidate);
|
|
590
|
+
if (changed === null)
|
|
591
|
+
return "unprovable";
|
|
592
|
+
return scanGovernanceRegions({
|
|
593
|
+
snapshot: payload.governance,
|
|
594
|
+
changedPaths: changed,
|
|
595
|
+
readCandidate: (p) => readTreeFile(ctx, candidate, p),
|
|
596
|
+
readInput: (p) => readTreeFile(ctx, payload.requiredEpicSha, p),
|
|
597
|
+
});
|
|
598
|
+
};
|
|
599
|
+
const unprovableFact = () => ({
|
|
600
|
+
tag: "GOVERNANCE_MARKERS_UNPROVABLE",
|
|
601
|
+
paths: [],
|
|
602
|
+
findings: [{ tag: "GOVERNANCE_MARKERS_UNPROVABLE", paths: [] }],
|
|
603
|
+
truncated: false,
|
|
604
|
+
});
|
|
605
|
+
/** `git write-tree` of the index: the candidate BEFORE a commit exists. */
|
|
606
|
+
const indexTree = async (ctx) => {
|
|
607
|
+
const r = await git(ctx, ["write-tree"]);
|
|
608
|
+
const oid = r.stdout.trim().toLowerCase();
|
|
609
|
+
return r.exitCode === 0 && SHA_RE.test(oid) ? oid : null;
|
|
610
|
+
};
|
|
611
|
+
const governanceGate = async (ctx, candidate) => {
|
|
612
|
+
const scanned = candidate === null ? "unprovable" : await scanCandidate(ctx, candidate);
|
|
613
|
+
if (scanned === null)
|
|
614
|
+
return null;
|
|
615
|
+
await resetToChildHead(ctx);
|
|
616
|
+
return complete({ governance_guard_change_required: scanned === "unprovable" ? unprovableFact() : scanned });
|
|
617
|
+
};
|
|
618
|
+
const commitStaged = async (ctx, message) => {
|
|
619
|
+
const r = await git(ctx, ["commit", "--no-verify", "--no-edit", "-m", message]);
|
|
620
|
+
if (r.exitCode !== 0)
|
|
621
|
+
return null;
|
|
622
|
+
return revParse(ctx, "HEAD");
|
|
623
|
+
};
|
|
624
|
+
const stagedChanges = async (ctx) => {
|
|
625
|
+
const r = await git(ctx, ["diff", "--cached", "--quiet"]);
|
|
626
|
+
return r.exitCode === 1;
|
|
627
|
+
};
|
|
628
|
+
/** Read both remote heads immediately before the push. */
|
|
629
|
+
const readRemoteHeads = async (ctx) => {
|
|
630
|
+
const childRef = `refs/heads/${payload.expectedBranch}`;
|
|
631
|
+
const epicRef = `refs/heads/${payload.epicBranch}`;
|
|
632
|
+
const r = await git(ctx, ["ls-remote", "origin", childRef, epicRef], GIT_TIMEOUT_MS, {
|
|
633
|
+
...seams.env,
|
|
634
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
635
|
+
});
|
|
636
|
+
if (r.exitCode !== 0) {
|
|
637
|
+
await resetToChildHead(ctx);
|
|
638
|
+
return {
|
|
639
|
+
ok: false,
|
|
640
|
+
outcome: PUSH_AUTH_FAILURE_RE.test(r.stderr)
|
|
641
|
+
? fail(ReconcileOverlapPushUnauthorized, "pushed", "the executor's git credential helper refused the remote read")
|
|
642
|
+
: fail(ReconcileOverlapPushRejected, "pushed", "the remote heads could not be read before the push"),
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
let child = null;
|
|
646
|
+
let epic = null;
|
|
647
|
+
for (const line of r.stdout.split("\n")) {
|
|
648
|
+
const [sha, ref] = line.trim().split(/\s+/);
|
|
649
|
+
if (!sha || !ref)
|
|
650
|
+
continue;
|
|
651
|
+
if (ref === childRef)
|
|
652
|
+
child = sha.toLowerCase();
|
|
653
|
+
if (ref === epicRef)
|
|
654
|
+
epic = sha.toLowerCase();
|
|
655
|
+
}
|
|
656
|
+
return { ok: true, child, epic };
|
|
657
|
+
};
|
|
658
|
+
/** Wait for provider checks on the pushed head. The SERVER decides; this only waits. */
|
|
659
|
+
const waitForProviderChecks = async (ctx, sha) => {
|
|
660
|
+
if (payload.requiredChecks.length === 0 || !seams.pollProviderChecks)
|
|
661
|
+
return;
|
|
662
|
+
while (ctx.remainingMs() > CI_WAIT_MARGIN_MS && !ctx.signal.aborted) {
|
|
663
|
+
let response;
|
|
664
|
+
try {
|
|
665
|
+
response = await seams.pollProviderChecks(sha);
|
|
666
|
+
}
|
|
667
|
+
catch (err) {
|
|
668
|
+
if (err instanceof ProviderChecksUnobservable)
|
|
669
|
+
return;
|
|
670
|
+
response = undefined;
|
|
671
|
+
}
|
|
672
|
+
if (response !== undefined) {
|
|
673
|
+
if (allRequiredChecksGreen(response, payload.requiredChecks))
|
|
674
|
+
return;
|
|
675
|
+
if (anyRequiredCheckFailed(response, payload.requiredChecks))
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
await seams.sleep(Math.min(CI_WAIT_POLL_INTERVAL_MS, Math.max(0, ctx.remainingMs() - CI_WAIT_MARGIN_MS)));
|
|
679
|
+
}
|
|
680
|
+
};
|
|
681
|
+
/** Drift check, final governance scan, guarded push, finalization, CI wait. */
|
|
682
|
+
const pushFlow = async (ctx) => {
|
|
683
|
+
const candidate = await revParse(ctx, "HEAD");
|
|
684
|
+
if (candidate === null) {
|
|
685
|
+
await resetToChildHead(ctx);
|
|
686
|
+
return fail(ReconcileOverlapTopologyRefused, "governance-scanned", "the candidate head could not be read");
|
|
687
|
+
}
|
|
688
|
+
// (1) Freshness: both remote heads must still be the dispatched ones. A moved
|
|
689
|
+
// head is an OBSERVATION the server re-dispatches on; it is not a failure.
|
|
690
|
+
const heads = await readRemoteHeads(ctx);
|
|
691
|
+
if (!heads.ok)
|
|
692
|
+
return heads.outcome;
|
|
693
|
+
if (heads.child !== payload.childHead) {
|
|
694
|
+
await resetToChildHead(ctx);
|
|
695
|
+
if (heads.child === null) {
|
|
696
|
+
return fail(ReconcileOverlapPushRejected, "pushed", "the child branch no longer exists on origin");
|
|
697
|
+
}
|
|
698
|
+
return complete({ observed_child_head: heads.child });
|
|
699
|
+
}
|
|
700
|
+
if (heads.epic !== payload.requiredEpicSha) {
|
|
701
|
+
// Includes an epic head that advanced to a descendant: the reconciliation
|
|
702
|
+
// was computed against a commit that is no longer the tip. The residual
|
|
703
|
+
// window between this read and the push is contained by the server, which
|
|
704
|
+
// re-verifies the pushed head's ancestry on the following tick.
|
|
705
|
+
await resetToChildHead(ctx);
|
|
706
|
+
if (heads.epic === null) {
|
|
707
|
+
return fail(ReconcileOverlapPushRejected, "pushed", "the epic branch no longer exists on origin");
|
|
708
|
+
}
|
|
709
|
+
return complete({ observed_epic_head: heads.epic });
|
|
710
|
+
}
|
|
711
|
+
// (2) Final governance scan of the exact tree about to be pushed.
|
|
712
|
+
const gated = await governanceGate(ctx, candidate);
|
|
713
|
+
if (gated !== null)
|
|
714
|
+
return gated;
|
|
715
|
+
// (3) The ONE push: a fast-forward of the child branch, leased on the
|
|
716
|
+
// recorded child head. Never plain --force, never a lease-less lease.
|
|
717
|
+
const destination = `refs/heads/${payload.expectedBranch}`;
|
|
718
|
+
const push = await git(ctx, [
|
|
719
|
+
"push",
|
|
720
|
+
"--no-verify",
|
|
721
|
+
"--porcelain",
|
|
722
|
+
`--force-with-lease=${destination}:${payload.childHead}`,
|
|
723
|
+
"origin",
|
|
724
|
+
`HEAD:${destination}`,
|
|
725
|
+
], PUSH_TIMEOUT_MS, { ...seams.env, GIT_TERMINAL_PROMPT: "0" });
|
|
726
|
+
if (push.exitCode !== 0) {
|
|
727
|
+
const text = `${push.stdout}\n${push.stderr}`;
|
|
728
|
+
if (PUSH_AUTH_FAILURE_RE.test(text)) {
|
|
729
|
+
await resetToChildHead(ctx);
|
|
730
|
+
return fail(ReconcileOverlapPushUnauthorized, "pushed", "the executor's git credential helper refused the push");
|
|
731
|
+
}
|
|
732
|
+
if (PUSH_LEASE_FAILURE_RE.test(text)) {
|
|
733
|
+
const reread = await readRemoteHeads(ctx);
|
|
734
|
+
await resetToChildHead(ctx);
|
|
735
|
+
if (reread.ok && reread.child !== null && reread.child !== payload.childHead) {
|
|
736
|
+
return complete({ observed_child_head: reread.child });
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
else {
|
|
740
|
+
await resetToChildHead(ctx);
|
|
741
|
+
}
|
|
742
|
+
return fail(ReconcileOverlapPushRejected, "pushed", "the remote refused the guarded push");
|
|
743
|
+
}
|
|
744
|
+
// (4) Finalization runs ONCE, after the wrapper's push, against the pushed
|
|
745
|
+
// head: origin must carry it and the PR must be attached at it.
|
|
746
|
+
const finalize = seams.finalize ?? validateWorkerFinalization;
|
|
747
|
+
const finalization = await finalize({
|
|
748
|
+
job,
|
|
749
|
+
branch: payload.expectedBranch,
|
|
750
|
+
worktreePath: ctx.worktreePath,
|
|
751
|
+
result: {},
|
|
752
|
+
runCommand: seams.runCommand,
|
|
753
|
+
headSha: candidate,
|
|
754
|
+
expectedBaseBranch: ctx.effectiveBaseBranch,
|
|
755
|
+
originResolveAttempts: FINALIZATION_ATTEMPTS,
|
|
756
|
+
originResolveRetryDelayMs: FINALIZATION_RETRY_DELAY_MS,
|
|
757
|
+
sleep: seams.sleep,
|
|
758
|
+
});
|
|
759
|
+
if (!finalization.ok)
|
|
760
|
+
return { kind: "fail", failure: finalization.failure };
|
|
761
|
+
// (5) Wait for provider checks at the pushed head, bounded by the job budget.
|
|
762
|
+
await waitForProviderChecks(ctx, candidate);
|
|
763
|
+
return complete({ resulting_head: candidate }, candidate);
|
|
764
|
+
};
|
|
765
|
+
const ensureCommit = async (ctx, sha) => {
|
|
766
|
+
if ((await git(ctx, ["cat-file", "-e", `${sha}^{commit}`])).exitCode === 0)
|
|
767
|
+
return true;
|
|
768
|
+
await git(ctx, ["fetch", "--no-tags", "origin", sha], GIT_TIMEOUT_MS, { ...seams.env, GIT_TERMINAL_PROMPT: "0" });
|
|
769
|
+
return (await git(ctx, ["cat-file", "-e", `${sha}^{commit}`])).exitCode === 0;
|
|
770
|
+
};
|
|
771
|
+
return {
|
|
772
|
+
omitWorkerBranch: true,
|
|
773
|
+
async beforeWorker(ctx) {
|
|
774
|
+
const finish = (outcome) => ({ kind: "finish", outcome });
|
|
775
|
+
if (ctx.branch !== payload.expectedBranch) {
|
|
776
|
+
return finish(fail(ReconcileOverlapBranchMismatch, "validated", "the prepared worktree is not on the job's expected branch"));
|
|
777
|
+
}
|
|
778
|
+
// Fetch the exact server-supplied inputs. The job never infers a head.
|
|
779
|
+
await git(ctx, ["fetch", "--no-tags", "origin", payload.epicBranch], GIT_TIMEOUT_MS, {
|
|
780
|
+
...seams.env,
|
|
781
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
782
|
+
});
|
|
783
|
+
for (const sha of [payload.childHead, payload.requiredEpicSha]) {
|
|
784
|
+
if (!(await ensureCommit(ctx, sha))) {
|
|
785
|
+
return finish(fail(ReconcileOverlapMergeFailed, "classified", `commit ${sha.slice(0, 12)} could not be fetched`));
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
// Classify BEFORE anything moves.
|
|
789
|
+
const classification = await classifyMergeTree(seams.runCommand, ctx.worktreePath, payload.childHead, payload.requiredEpicSha);
|
|
790
|
+
if (classification.kind === "operational_error") {
|
|
791
|
+
return finish(fail(ReconcileOverlapMergeFailed, "classified", classification.reason));
|
|
792
|
+
}
|
|
793
|
+
facts.classification = classification;
|
|
794
|
+
if (classification.kind === "conflicted") {
|
|
795
|
+
for (const conflict of classification.conflicts) {
|
|
796
|
+
facts.editedReasons.set(conflict.path, conflict.descriptor);
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
const head = await revParse(ctx, "HEAD");
|
|
800
|
+
if (head !== payload.childHead) {
|
|
801
|
+
// Worktree preparation reset to origin's branch tip, and that tip is not
|
|
802
|
+
// the head the server dispatched: the child moved. An observation.
|
|
803
|
+
await resetToChildHead(ctx);
|
|
804
|
+
if (head === null) {
|
|
805
|
+
return finish(fail(ReconcileOverlapTopologyRefused, "classified", "the prepared worktree HEAD could not be read"));
|
|
806
|
+
}
|
|
807
|
+
return finish(complete({ observed_child_head: head }));
|
|
808
|
+
}
|
|
809
|
+
if (await isAncestor(ctx, payload.requiredEpicSha, payload.childHead)) {
|
|
810
|
+
return finish(fail(ReconcileOverlapNothingToMerge, "classified", "the child head already contains the required epic commit"));
|
|
811
|
+
}
|
|
812
|
+
// Materialize the real merge. Never a rebase.
|
|
813
|
+
const merge = await git(ctx, ["merge", "--no-ff", "--no-commit", "--no-edit", payload.requiredEpicSha], GIT_TIMEOUT_MS, { ...seams.env, GIT_MERGE_AUTOEDIT: "no" });
|
|
814
|
+
const mergedCleanly = merge.exitCode === 0;
|
|
815
|
+
if (mergedCleanly !== (classification.kind === "clean") || (merge.exitCode !== 0 && merge.exitCode !== 1)) {
|
|
816
|
+
await resetToChildHead(ctx);
|
|
817
|
+
return finish(fail(ReconcileOverlapMergeFailed, "merged", "the materialized merge disagreed with its merge-tree classification"));
|
|
818
|
+
}
|
|
819
|
+
const mergeHead = await revParse(ctx, "MERGE_HEAD");
|
|
820
|
+
if (mergeHead !== payload.requiredEpicSha) {
|
|
821
|
+
await resetToChildHead(ctx);
|
|
822
|
+
return finish(fail(ReconcileOverlapMergeFailed, "merged", "the merge did not record the required epic commit"));
|
|
823
|
+
}
|
|
824
|
+
if (classification.kind === "conflicted") {
|
|
825
|
+
facts.mode = "conflicted";
|
|
826
|
+
facts.workerInvoked = true;
|
|
827
|
+
facts.preWorkerHead = payload.childHead;
|
|
828
|
+
await writeReport(ctx, conflictReport([]));
|
|
829
|
+
return {
|
|
830
|
+
kind: "spawn_worker",
|
|
831
|
+
prompt: payload.prompt,
|
|
832
|
+
// Stricter than the generic check: HEAD is the child head AND the merge
|
|
833
|
+
// of the required epic commit is in progress.
|
|
834
|
+
preSpawnVerification: { ok: true, observedSha: payload.childHead },
|
|
835
|
+
declaredTouchedFiles: payload.declaredTouchedFilesUnion,
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
// Clean: scan the merged index BEFORE the wrapper creates the merge commit.
|
|
839
|
+
const gated = await governanceGate(ctx, await indexTree(ctx));
|
|
840
|
+
if (gated !== null)
|
|
841
|
+
return finish(gated);
|
|
842
|
+
const mergeCommit = await commitStaged(ctx, `Reconcile overlap: merge ${payload.requiredEpicSha.slice(0, 12)} into ${payload.expectedBranch}`);
|
|
843
|
+
if (mergeCommit === null) {
|
|
844
|
+
await resetToChildHead(ctx);
|
|
845
|
+
return finish(fail(ReconcileOverlapMergeFailed, "merged", "the wrapper could not create the merge commit"));
|
|
846
|
+
}
|
|
847
|
+
facts.mergeCommitSha = mergeCommit;
|
|
848
|
+
const runs = await runVerification(ctx);
|
|
849
|
+
const failing = runs.filter((r) => !r.green);
|
|
850
|
+
if (failing.length === 0) {
|
|
851
|
+
// Clean AND green: push with no LLM at all.
|
|
852
|
+
return finish(await pushFlow(ctx));
|
|
853
|
+
}
|
|
854
|
+
facts.failingCommandNames = failing.map((r) => r.observation.name);
|
|
855
|
+
facts.mode = "clean_red";
|
|
856
|
+
facts.workerInvoked = true;
|
|
857
|
+
facts.preWorkerHead = mergeCommit;
|
|
858
|
+
await writeReport(ctx, conflictReport(failing));
|
|
859
|
+
// `.conductor/` holds the executor's own report for the worker; it is not a
|
|
860
|
+
// change to the candidate.
|
|
861
|
+
const status = await git(ctx, ["status", "--porcelain", "--", ".", ...WRAPPER_STAGE_EXCLUDES]);
|
|
862
|
+
if (status.exitCode !== 0 || status.stdout.trim().length > 0) {
|
|
863
|
+
await resetToChildHead(ctx);
|
|
864
|
+
return finish(fail(ReconcileOverlapMergeFailed, "checked", "the worktree was not clean at the merge commit"));
|
|
865
|
+
}
|
|
866
|
+
return {
|
|
867
|
+
kind: "spawn_worker",
|
|
868
|
+
prompt: payload.prompt,
|
|
869
|
+
preSpawnVerification: { ok: true, observedSha: mergeCommit },
|
|
870
|
+
declaredTouchedFiles: payload.declaredTouchedFilesUnion,
|
|
871
|
+
};
|
|
872
|
+
},
|
|
873
|
+
async afterWorker(ctx) {
|
|
874
|
+
// The worker may edit the working tree and nothing else.
|
|
875
|
+
const head = await revParse(ctx, "HEAD");
|
|
876
|
+
const mergeHead = await revParse(ctx, "MERGE_HEAD");
|
|
877
|
+
const expectedMergeHead = facts.mode === "conflicted" ? payload.requiredEpicSha : null;
|
|
878
|
+
if (head !== facts.preWorkerHead || mergeHead !== expectedMergeHead) {
|
|
879
|
+
await resetToChildHead(ctx);
|
|
880
|
+
return fail(ReconcileOverlapWorkerRewroteHistory, "worker-completed", "the worker moved HEAD, committed, or changed the in-progress merge; commits are the wrapper's alone");
|
|
881
|
+
}
|
|
882
|
+
if (facts.mode === "conflicted" && facts.classification?.kind === "conflicted") {
|
|
883
|
+
const conflictPaths = facts.classification.conflicts
|
|
884
|
+
.map((c) => c.path)
|
|
885
|
+
.filter((p) => p !== "(unreported)");
|
|
886
|
+
for (const conflictPath of conflictPaths) {
|
|
887
|
+
const read = await readWorkingFile(ctx, conflictPath);
|
|
888
|
+
if (read !== null && /^(<{7} |={7}$|>{7} )/m.test(read)) {
|
|
889
|
+
await resetToChildHead(ctx);
|
|
890
|
+
return fail(ReconcileOverlapConflictsUnresolved, "worker-completed", `conflict markers remain in ${conflictPath}`);
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
// Stage ONLY the resolved conflict paths: the merge commit is the
|
|
894
|
+
// conflict resolution, and any other repair becomes its own commit.
|
|
895
|
+
if (conflictPaths.length > 0) {
|
|
896
|
+
await git(ctx, ["add", "-A", "--", ...conflictPaths]);
|
|
897
|
+
}
|
|
898
|
+
const unmerged = await git(ctx, ["ls-files", "-u"]);
|
|
899
|
+
if (unmerged.exitCode !== 0 || unmerged.stdout.trim().length > 0) {
|
|
900
|
+
await resetToChildHead(ctx);
|
|
901
|
+
return fail(ReconcileOverlapConflictsUnresolved, "worker-completed", "unmerged index entries remain");
|
|
902
|
+
}
|
|
903
|
+
const gated = await governanceGate(ctx, await indexTree(ctx));
|
|
904
|
+
if (gated !== null)
|
|
905
|
+
return gated;
|
|
906
|
+
const mergeCommit = await commitStaged(ctx, `Reconcile overlap: merge ${payload.requiredEpicSha.slice(0, 12)} into ${payload.expectedBranch}`);
|
|
907
|
+
if (mergeCommit === null) {
|
|
908
|
+
await resetToChildHead(ctx);
|
|
909
|
+
return fail(ReconcileOverlapMergeFailed, "worker-completed", "the wrapper could not create the merge commit");
|
|
910
|
+
}
|
|
911
|
+
facts.mergeCommitSha = mergeCommit;
|
|
912
|
+
}
|
|
913
|
+
// Any remaining worker repair: one ordinary commit ON TOP of the merge.
|
|
914
|
+
await git(ctx, ["add", "-A", "--", ".", ...WRAPPER_STAGE_EXCLUDES]);
|
|
915
|
+
if (await stagedChanges(ctx)) {
|
|
916
|
+
const gated = await governanceGate(ctx, await indexTree(ctx));
|
|
917
|
+
if (gated !== null)
|
|
918
|
+
return gated;
|
|
919
|
+
const repairPaths = await git(ctx, ["diff", "--cached", "--name-only", "-z"]);
|
|
920
|
+
const repairReason = facts.failingCommandNames.length > 0
|
|
921
|
+
? `repair for failing check(s): ${facts.failingCommandNames.join(", ")}`
|
|
922
|
+
: "repair after conflict resolution";
|
|
923
|
+
for (const repaired of repairPaths.stdout.split("\0")) {
|
|
924
|
+
const normalized = normalizeRepoRelativePath(repaired);
|
|
925
|
+
if (normalized && !facts.editedReasons.has(normalized))
|
|
926
|
+
facts.editedReasons.set(normalized, repairReason);
|
|
927
|
+
}
|
|
928
|
+
const repair = await commitStaged(ctx, "Reconcile overlap: repair on top of the merge");
|
|
929
|
+
if (repair === null) {
|
|
930
|
+
await resetToChildHead(ctx);
|
|
931
|
+
return fail(ReconcileOverlapMergeFailed, "worker-completed", "the wrapper could not commit the worker's repair");
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
// Re-run the union of local checks on the final candidate.
|
|
935
|
+
const runs = await runVerification(ctx);
|
|
936
|
+
const failing = runs.filter((r) => !r.green);
|
|
937
|
+
if (failing.length > 0) {
|
|
938
|
+
await resetToChildHead(ctx);
|
|
939
|
+
return fail(ReconcileOverlapVerificationRed, "checked", `local verification still red after the reconciliation: ${failing.map((r) => r.observation.name).join(", ")}`);
|
|
940
|
+
}
|
|
941
|
+
// Topology: a two-parent merge of exactly the dispatched heads, contained in
|
|
942
|
+
// the candidate, which itself contains both input heads.
|
|
943
|
+
const candidate = await revParse(ctx, "HEAD");
|
|
944
|
+
const mergeCommit = facts.mergeCommitSha;
|
|
945
|
+
const parents = mergeCommit ? await git(ctx, ["rev-list", "--parents", "-n", "1", mergeCommit]) : null;
|
|
946
|
+
const parentList = parents?.stdout.trim().split(/\s+/).slice(1) ?? [];
|
|
947
|
+
if (candidate === null ||
|
|
948
|
+
mergeCommit === null ||
|
|
949
|
+
parentList.length !== 2 ||
|
|
950
|
+
parentList[0] !== payload.childHead ||
|
|
951
|
+
parentList[1] !== payload.requiredEpicSha ||
|
|
952
|
+
!(await isAncestor(ctx, mergeCommit, candidate)) ||
|
|
953
|
+
!(await isAncestor(ctx, payload.childHead, candidate)) ||
|
|
954
|
+
!(await isAncestor(ctx, payload.requiredEpicSha, candidate))) {
|
|
955
|
+
await resetToChildHead(ctx);
|
|
956
|
+
return fail(ReconcileOverlapTopologyRefused, "governance-scanned", "the candidate is not a merge of the dispatched heads");
|
|
957
|
+
}
|
|
958
|
+
return pushFlow(ctx);
|
|
959
|
+
},
|
|
960
|
+
async abandon(ctx) {
|
|
961
|
+
await resetToChildHead(ctx);
|
|
962
|
+
},
|
|
963
|
+
};
|
|
964
|
+
/** The working-tree text of a path, or `null` when it was deleted. */
|
|
965
|
+
async function readWorkingFile(ctx, filePath) {
|
|
966
|
+
try {
|
|
967
|
+
return await seams.readFile(path.join(ctx.worktreePath, ...filePath.split("/")));
|
|
968
|
+
}
|
|
969
|
+
catch {
|
|
970
|
+
return null;
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
/**
|
|
975
|
+
* Run a claimed `reconcile_overlap` job. The payload is validated FIRST — a
|
|
976
|
+
* missing or blank input SHA fails the contract before any subprocess spawns and
|
|
977
|
+
* before the worktree is prepared.
|
|
978
|
+
*/
|
|
979
|
+
export async function runReconcileOverlapJob(job, runner, seams) {
|
|
980
|
+
const validated = validateReconcileOverlapPayload(job);
|
|
981
|
+
if (!validated.ok) {
|
|
982
|
+
return runner.fail({
|
|
983
|
+
error_kind: ReconcileOverlapPayloadInvalid,
|
|
984
|
+
error_message: `reconcile_overlap payload rejected: ${validated.error}`,
|
|
985
|
+
classification: "crashed",
|
|
986
|
+
});
|
|
987
|
+
}
|
|
988
|
+
return runner.runLockedSpawn(createReconcileOverlapDriver(job, validated.value, seams));
|
|
989
|
+
}
|