@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.
Files changed (103) hide show
  1. package/README.md +116 -56
  2. package/build/agent-guidance.generated.js +3 -0
  3. package/build/agent-launchers/claude-executor-adapter.js +3 -0
  4. package/build/agent-launchers/claude.js +3 -3
  5. package/build/agent-launchers/prompt.js +8 -11
  6. package/build/agent-notes.js +178 -0
  7. package/build/agent-registry.js +5 -2
  8. package/build/agent-utils.js +58 -0
  9. package/build/agents.generated.js +1 -1
  10. package/build/base-ref.js +33 -9
  11. package/build/bounded-wait.js +174 -0
  12. package/build/codex-skill-adapter.js +55 -0
  13. package/build/commands.generated.js +6 -5
  14. package/build/conductor/bridge-api-client.js +235 -14
  15. package/build/conductor/bring-up-facts.js +187 -0
  16. package/build/conductor/claude-hook.js +7 -5
  17. package/build/conductor/cli.js +28 -0
  18. package/build/conductor/doctor.js +80 -9
  19. package/build/conductor/epic-implementer-cli.js +1298 -0
  20. package/build/conductor/epic-runtime.js +134 -98
  21. package/build/conductor/errors.js +2 -2
  22. package/build/conductor/git-ci-types.js +1 -1
  23. package/build/conductor/git-hooks.js +28 -14
  24. package/build/conductor/install-doctor.js +11 -5
  25. package/build/conductor/readiness-cli.js +10 -10
  26. package/build/conductor/readiness-sections.js +58 -9
  27. package/build/conductor/readiness.js +120 -4
  28. package/build/conductor/recovery-cli.js +289 -10
  29. package/build/conductor/recovery-operations.js +125 -2
  30. package/build/conductor/repair-contract.js +58 -0
  31. package/build/conductor/run-branch.js +137 -0
  32. package/build/conductor/store.js +2 -2
  33. package/build/conductor/supervisor-runtime.js +1 -1
  34. package/build/conductor/test-run-branch-vectors.js +165 -0
  35. package/build/conductor-bin.js +2 -139
  36. package/build/conductor-claude-hook-bin.js +2 -2
  37. package/build/conductor-claude-hook-removed-stub-bin.js +31 -0
  38. package/build/conductor-removed-stub-bin.js +30 -0
  39. package/build/docs.generated.js +1 -1
  40. package/build/doctor.js +145 -18
  41. package/build/drive-epic.js +752 -90
  42. package/build/epic-implementer-bin.js +145 -0
  43. package/build/epic-implementer-bundle-cli.js +264 -0
  44. package/build/epic-implementer-claude-hook-bin.js +3 -0
  45. package/build/epic-integration-pr.js +5 -3
  46. package/build/executor/claim-scope.js +104 -0
  47. package/build/executor/cli.js +14 -25
  48. package/build/executor/env-file-guard.js +82 -3
  49. package/build/executor/env.js +6 -0
  50. package/build/executor/interrupted-worktree.js +60 -0
  51. package/build/executor/job-errors.js +45 -0
  52. package/build/executor/job-runner.js +334 -9
  53. package/build/executor/job-types.js +25 -9
  54. package/build/executor/merge-tree-classifier.js +171 -0
  55. package/build/executor/reconcile-overlap-governance.js +129 -0
  56. package/build/executor/reconcile-overlap-job.js +989 -0
  57. package/build/executor/reconcile-overlap-types.js +14 -0
  58. package/build/executor/spawn-job-driver.js +1 -0
  59. package/build/executor/types.js +2 -0
  60. package/build/executor/worker-finalization.js +25 -2
  61. package/build/executor/worker-guard-hook.js +15 -7
  62. package/build/implement-epic/bridge-client.js +773 -0
  63. package/build/implement-epic/checkpoint-store.js +542 -0
  64. package/build/implement-epic/cli.js +3158 -0
  65. package/build/implement-epic/cut-protocol.js +392 -0
  66. package/build/implement-epic/lock.js +302 -0
  67. package/build/implement-epic/pr-state.js +286 -0
  68. package/build/implement-epic/spawn.js +113 -0
  69. package/build/index.js +657 -481
  70. package/build/init.js +72 -8
  71. package/build/install-bridge-conductor.js +5 -5
  72. package/build/install-bridge.js +403 -70
  73. package/build/local-artifact-storage.js +130 -0
  74. package/build/mcp-host-config.js +22 -60
  75. package/build/mcp-host-entry-adapter.js +18 -0
  76. package/build/mcp-host-targets.js +1 -21
  77. package/build/merge-pull-request.js +1 -1
  78. package/build/pipelines.generated.js +23 -16
  79. package/build/plan-epic-conductor-eligibility.js +1 -1
  80. package/build/plane/cli.js +321 -41
  81. package/build/plane/manifest.js +209 -1
  82. package/build/plane/member-roster.js +70 -0
  83. package/build/plane/preflight.js +128 -12
  84. package/build/plane/shutdown.js +18 -5
  85. package/build/plane/status.js +35 -1
  86. package/build/plane/supervisor.js +546 -164
  87. package/build/plane/types.js +25 -2
  88. package/build/polling-policy.js +72 -0
  89. package/build/readiness-check.js +3 -3
  90. package/build/readme.generated.js +1 -1
  91. package/build/review-generation.js +219 -0
  92. package/build/run-unit-tests-launcher.js +6 -1
  93. package/build/setup-epic.js +567 -38
  94. package/build/start-tickets-conductor.js +8 -7
  95. package/build/ticket-key-utils.js +4 -3
  96. package/build/ticket-review-artifact-gate.js +461 -0
  97. package/build/upgrade-cli.js +5 -26
  98. package/build/version.generated.js +3 -3
  99. package/build/worker-guard-hook-bin.js +1 -1
  100. package/docs/CONDUCTOR.md +8 -6
  101. package/docs/install/mcp-tool-integrations.md +23 -1
  102. package/package.json +5 -3
  103. 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
+ }