@bridge_gpt/mcp-server 0.2.59 → 0.2.62

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 (78) hide show
  1. package/README.md +181 -51
  2. package/build/agent-guidance.generated.js +1 -1
  3. package/build/agent-notes.js +7 -1
  4. package/build/commands.generated.js +6 -6
  5. package/build/conductor/bridge-api-client.js +406 -4
  6. package/build/conductor/doctor.js +255 -53
  7. package/build/conductor/epic-runtime.js +11 -2
  8. package/build/conductor/install-doctor.js +24 -2
  9. package/build/conductor/local-merge.js +319 -26
  10. package/build/conductor/merge-execution-contract.js +86 -0
  11. package/build/conductor/merge-execution-eligibility.js +130 -0
  12. package/build/conductor/readiness-sections.js +256 -16
  13. package/build/conductor/readiness.js +203 -29
  14. package/build/conductor/recovery-operations.js +8 -2
  15. package/build/connect-bitbucket-api.js +6 -1
  16. package/build/connect-bitbucket.js +398 -88
  17. package/build/credential-store.js +172 -0
  18. package/build/docs.generated.js +2 -2
  19. package/build/doctor.js +277 -15
  20. package/build/drive-epic.js +147 -12
  21. package/build/epic-implementer-bin.js +15 -15
  22. package/build/epic-implementer-claude-hook-bin.js +1 -1
  23. package/build/epic-implementer-env.js +43 -14
  24. package/build/epic-integration-pr.js +61 -15
  25. package/build/epic-launch-profile.js +455 -0
  26. package/build/executor/ci-rerun-job.js +269 -0
  27. package/build/executor/cli.js +40 -0
  28. package/build/executor/credentials.js +28 -1
  29. package/build/executor/env.js +60 -0
  30. package/build/executor/http-client.js +45 -1
  31. package/build/executor/index.js +21 -4
  32. package/build/executor/integration-job.js +759 -0
  33. package/build/executor/job-runner.js +218 -9
  34. package/build/executor/job-types.js +12 -2
  35. package/build/executor/merge-job.js +11 -2
  36. package/build/executor/observation.js +114 -7
  37. package/build/executor/runner.js +101 -1
  38. package/build/executor/service-lifecycle.js +136 -0
  39. package/build/executor/service-unit.js +427 -9
  40. package/build/executor/types.js +48 -0
  41. package/build/index.js +479 -240
  42. package/build/install-bridge-conductor.js +599 -14
  43. package/build/install-bridge.js +448 -46
  44. package/build/mcp-install-state.js +42 -4
  45. package/build/merge-pull-request.js +103 -10
  46. package/build/merge-workflow-codeowners.js +224 -0
  47. package/build/merge-workflow-config.js +187 -0
  48. package/build/merge-workflow-drift.js +97 -0
  49. package/build/merge-workflow-install.js +638 -0
  50. package/build/merge-workflow.js +956 -0
  51. package/build/pipelines.generated.js +3 -3
  52. package/build/plane/cli.js +24 -2
  53. package/build/plane/preflight.js +68 -0
  54. package/build/plane/supervisor.js +6 -0
  55. package/build/readme.generated.js +1 -1
  56. package/build/run-unit-tests-launcher.js +5 -0
  57. package/build/setup-epic.js +263 -38
  58. package/build/version.generated.js +3 -3
  59. package/package.json +3 -3
  60. package/build/conduct-epic/bridge-client.js +0 -773
  61. package/build/conduct-epic/checkpoint-store.js +0 -542
  62. package/build/conduct-epic/cli.js +0 -3145
  63. package/build/conduct-epic/cut-protocol.js +0 -392
  64. package/build/conduct-epic/lock.js +0 -302
  65. package/build/conduct-epic/pr-state.js +0 -286
  66. package/build/conduct-epic/spawn.js +0 -113
  67. package/build/conductor/cli.js +0 -1278
  68. package/build/conductor-bundle-cli.js +0 -256
  69. package/build/direct-ticket-tools.js +0 -70
  70. package/build/executor/reconcile-overlap-governance.js +0 -129
  71. package/build/implement-epic/bridge-client.js +0 -773
  72. package/build/implement-epic/checkpoint-store.js +0 -542
  73. package/build/implement-epic/cli.js +0 -3158
  74. package/build/implement-epic/cut-protocol.js +0 -392
  75. package/build/implement-epic/lock.js +0 -302
  76. package/build/implement-epic/pr-state.js +0 -286
  77. package/build/implement-epic/spawn.js +0 -113
  78. package/build/plan-epic-conductor-eligibility.js +0 -213
@@ -0,0 +1,759 @@
1
+ /**
2
+ * Local and durable executors consume durable integration actions (BAPI-1259).
3
+ *
4
+ * The BAPI-1248 run finished every child and then blocked, because the only
5
+ * actor that tried to take the integration pull request out of draft was the
6
+ * reconciler, holding the GitHub App's read-only token. This module is the
7
+ * LOCAL half of the replacement: an executor — scoped to one run or
8
+ * repository-wide — claims a run-level integration action from Bridge, performs
9
+ * it under the customer's explicitly configured credential, and reports facts.
10
+ *
11
+ * ## What it does
12
+ *
13
+ * * `sync_base` — merges the EXACT expected base SHA into the EXACT run-bound
14
+ * epic branch through the provider's merge API. No checkout, no force.
15
+ * * `mark_ready` — finds the ONE open pull request from the epic branch to the
16
+ * base (creating it only after conclusive absence), confirms its head is the
17
+ * expected epic head, and takes it out of draft.
18
+ * * `correct` — runs ONE worker through the executor's existing locked spawn
19
+ * path under a driver that prepares the epic branch, verifies the worker's
20
+ * edits, makes the one commit, and performs the one push. The worker has no
21
+ * push authority and no operational database.
22
+ *
23
+ * None of them merges a pull request.
24
+ *
25
+ * ## Authority
26
+ *
27
+ * Every provider write runs with the child environment
28
+ * {@link buildLocalMergeChildEnv} builds from the credential
29
+ * {@link resolveLocalMergeCredential} resolves (`BAPI_LOCAL_MERGE_TOKEN`, or
30
+ * `BAPI_MACHINE_MERGE_TOKEN` for a machine-token run). Inherited `GH_TOKEN`,
31
+ * `GITHUB_TOKEN`, and an ambient `gh` login are stripped from that environment,
32
+ * so they cannot rescue a missing configured credential: a missing credential is
33
+ * reported as `authority_unavailable` and nothing is written. The credential
34
+ * never reaches argv, a report, or a log line; every captured output passes
35
+ * through {@link sanitizeLocalMergeOutput}.
36
+ *
37
+ * ## Revalidation and facts
38
+ *
39
+ * Immediately before any write the executor re-fetches the action under its
40
+ * lease (so a superseded action, a changed policy generation, or a stopped run
41
+ * stops it) and re-reads the live epic head (so a moved branch is never
42
+ * written). A lost response is resolved by reading the provider back and
43
+ * matching the result to THIS action — never by repeating the write. The
44
+ * report carries only closed-vocabulary facts. The reconciler re-observes the
45
+ * repository itself; nothing reported here completes a run or passes a gate,
46
+ * and this module chooses no next ticket and no gate.
47
+ */
48
+ import { buildLocalMergeChildEnv, resolveLocalMergeCredential, sanitizeLocalMergeOutput, } from "../conductor/local-merge.js";
49
+ import { createProductionEpicIntegrationGhRunner, ensureEpicIntegrationPullRequest, } from "../epic-integration-pr.js";
50
+ import { INTEGRATION_ACTION_OPERATIONS, INTEGRATION_ACTION_OUTCOMES, INTEGRATION_ACTION_PROTOCOL_VERSION, } from "./types.js";
51
+ /** The route prefix, mirrored from `api/routes/__init__.py`. */
52
+ export const INTEGRATION_ACTION_ROUTE_PREFIX = "/epic-integration-actions";
53
+ /** Heartbeat cadence. The server lease is three minutes. */
54
+ export const INTEGRATION_ACTION_HEARTBEAT_INTERVAL_MS = 45_000;
55
+ const READ_TIMEOUT_MS = 20_000;
56
+ const WRITE_TIMEOUT_MS = 60_000;
57
+ const GIT_TIMEOUT_MS = 120_000;
58
+ const PUSH_TIMEOUT_MS = 120_000;
59
+ const SHA_RE = /^[0-9a-f]{40}$/;
60
+ const DETAIL_CODE_RE = /^[a-z0-9_]{1,64}$/;
61
+ const PROVIDER_REPOSITORY_RE = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
62
+ const MAX_DECLARED_FILES = 500;
63
+ /**
64
+ * The integration-action transport. Separate from the `/executor/jobs` client:
65
+ * an integration action is not an ordinary job, and sharing the client would
66
+ * invite sharing its job-type vocabulary. The API key is chosen per repository
67
+ * and never appears in an error string.
68
+ */
69
+ export function createIntegrationActionHttpClient(config) {
70
+ const base = config.baseUrl.replace(/\/+$/, "") + INTEGRATION_ACTION_ROUTE_PREFIX;
71
+ async function post(path, body, repoName) {
72
+ const res = await config.fetch(`${base}${path}`, {
73
+ method: "POST",
74
+ headers: {
75
+ "Content-Type": "application/json",
76
+ "X-API-Key": config.apiKeyByRepo?.[repoName] ?? config.apiKey,
77
+ "X-Bridge-MCP-Version": config.mcpVersion,
78
+ },
79
+ body: JSON.stringify(body),
80
+ });
81
+ return { status: res.status, text: await res.text() };
82
+ }
83
+ const authority = (action) => ({
84
+ protocol_version: INTEGRATION_ACTION_PROTOCOL_VERSION,
85
+ repo_name: action.repo_name,
86
+ action_id: action.action_id,
87
+ claim_token: action.claim_token,
88
+ });
89
+ return {
90
+ async claim(request) {
91
+ let res;
92
+ try {
93
+ res = await post("/claim", request, request.repo_name);
94
+ }
95
+ catch {
96
+ return { kind: "retryable", error: "integration action claim network error" };
97
+ }
98
+ if (res.status === 204)
99
+ return { kind: "none" };
100
+ if (res.status === 200) {
101
+ try {
102
+ const parsed = JSON.parse(res.text);
103
+ if (!parsed || typeof parsed.claim_token !== "string") {
104
+ return { kind: "fatal", error: "integration action claim returned a malformed body" };
105
+ }
106
+ return { kind: "claimed", action: parsed };
107
+ }
108
+ catch {
109
+ return { kind: "fatal", error: "integration action claim returned invalid JSON" };
110
+ }
111
+ }
112
+ if (res.status === 401 || res.status === 403) {
113
+ return {
114
+ kind: "fatal",
115
+ error: `integration action claim rejected (HTTP ${res.status})`,
116
+ authHttpStatus: res.status,
117
+ };
118
+ }
119
+ if (res.status === 404 || res.status === 422) {
120
+ // An older server without the route, or a contract it refuses.
121
+ return { kind: "fatal", error: `integration action claim rejected (HTTP ${res.status})` };
122
+ }
123
+ return { kind: "retryable", error: `integration action claim failed (HTTP ${res.status})` };
124
+ },
125
+ async fetch(action) {
126
+ try {
127
+ const res = await post("/fetch", authority(action), action.repo_name);
128
+ if (res.status === 200) {
129
+ return { kind: "ok", work: JSON.parse(res.text) };
130
+ }
131
+ if (res.status >= 500)
132
+ return { kind: "retryable" };
133
+ return { kind: "unavailable" };
134
+ }
135
+ catch {
136
+ return { kind: "retryable" };
137
+ }
138
+ },
139
+ async heartbeat(action) {
140
+ try {
141
+ const res = await post("/heartbeat", authority(action), action.repo_name);
142
+ if (res.status === 200)
143
+ return "renewed";
144
+ if (res.status === 409 || res.status === 404 || res.status === 401 || res.status === 403) {
145
+ return "lost";
146
+ }
147
+ return "retryable";
148
+ }
149
+ catch {
150
+ return "retryable";
151
+ }
152
+ },
153
+ async report(action, report) {
154
+ try {
155
+ const res = await post("/report", {
156
+ ...authority(action),
157
+ outcome: report.outcome,
158
+ result_epic_head_sha: report.result_epic_head_sha ?? null,
159
+ pr_number: report.pr_number ?? null,
160
+ detail_code: report.detail_code ?? null,
161
+ }, action.repo_name);
162
+ if (res.status === 200) {
163
+ try {
164
+ const recorded = JSON.parse(res.text).recorded;
165
+ return recorded === "stale_fact" ? "stale_fact" : "report";
166
+ }
167
+ catch {
168
+ return "report";
169
+ }
170
+ }
171
+ if (res.status >= 500)
172
+ return "retryable";
173
+ return "unavailable";
174
+ }
175
+ catch {
176
+ return "retryable";
177
+ }
178
+ },
179
+ };
180
+ }
181
+ // ---------------------------------------------------------------------------
182
+ // Validation
183
+ // ---------------------------------------------------------------------------
184
+ /** Reject an action this executor must not perform, before any side effect. */
185
+ export function validateIntegrationAction(action) {
186
+ if (action.protocol_version !== INTEGRATION_ACTION_PROTOCOL_VERSION) {
187
+ return { ok: false, reason: "protocol_version_unsupported" };
188
+ }
189
+ if (action.actor !== "local")
190
+ return { ok: false, reason: "actor_not_local" };
191
+ if (!INTEGRATION_ACTION_OPERATIONS.includes(action.operation)) {
192
+ return { ok: false, reason: "operation_unsupported" };
193
+ }
194
+ if (typeof action.epic_branch !== "string" || !action.epic_branch.startsWith("epic/")) {
195
+ return { ok: false, reason: "epic_branch_invalid" };
196
+ }
197
+ if (typeof action.base_branch !== "string" || action.base_branch.trim() === "") {
198
+ return { ok: false, reason: "base_branch_invalid" };
199
+ }
200
+ if (action.base_branch === action.epic_branch) {
201
+ return { ok: false, reason: "base_branch_invalid" };
202
+ }
203
+ if (!SHA_RE.test(action.expected_epic_head_sha) || !SHA_RE.test(action.expected_base_sha)) {
204
+ return { ok: false, reason: "expected_head_invalid" };
205
+ }
206
+ if (!PROVIDER_REPOSITORY_RE.test(action.provider_repository)) {
207
+ return { ok: false, reason: "provider_repository_invalid" };
208
+ }
209
+ if (action.operation === "correct") {
210
+ if (action.correction_kind !== "base_conflict" && action.correction_kind !== "ci_failure") {
211
+ return { ok: false, reason: "correction_kind_invalid" };
212
+ }
213
+ if (typeof action.correction_prompt !== "string" || action.correction_prompt.trim() === "") {
214
+ return { ok: false, reason: "correction_prompt_missing" };
215
+ }
216
+ }
217
+ return { ok: true };
218
+ }
219
+ /** The work fields that must be byte-identical between the claim and a refetch. */
220
+ const IMMUTABLE_WORK_FIELDS = [
221
+ "action_id",
222
+ "operation",
223
+ "repo_name",
224
+ "provider_repository",
225
+ "epic_run_id",
226
+ "epic_branch",
227
+ "base_branch",
228
+ "expected_epic_head_sha",
229
+ "expected_base_sha",
230
+ "correction_kind",
231
+ ];
232
+ function sameWork(a, b) {
233
+ return IMMUTABLE_WORK_FIELDS.every((field) => (a[field] ?? null) === (b[field] ?? null));
234
+ }
235
+ function safeDetail(code) {
236
+ return DETAIL_CODE_RE.test(code) ? code : "detail_unavailable";
237
+ }
238
+ /** Run a credentialed command; the captured text is sanitized before anything reads it. */
239
+ async function runCredentialed(deps, credential, env, file, args, options) {
240
+ try {
241
+ const result = await deps.runCommand(file, args, {
242
+ cwd: options.cwd ?? deps.cwd,
243
+ timeoutMs: options.timeoutMs,
244
+ env,
245
+ });
246
+ return {
247
+ exitCode: result.exitCode,
248
+ stdout: sanitizeLocalMergeOutput(result.stdout ?? "", [credential.token]),
249
+ stderr: sanitizeLocalMergeOutput(result.stderr ?? "", [credential.token]),
250
+ };
251
+ }
252
+ catch {
253
+ return { exitCode: -1, stdout: "", stderr: "" };
254
+ }
255
+ }
256
+ /** The first HTTP status in `gh api --include` output (or a `gh` error line). */
257
+ export function parseGhHttpStatus(stdout, stderr) {
258
+ const head = stdout.match(/^HTTP\/[\d.]+\s+(\d{3})/m);
259
+ if (head)
260
+ return Number(head[1]);
261
+ const err = `${stderr}\n${stdout}`.match(/\(HTTP (\d{3})\)|HTTP (\d{3}):/);
262
+ if (err)
263
+ return Number(err[1] ?? err[2]);
264
+ return null;
265
+ }
266
+ /** The JSON body after the header block of `gh api --include` output. */
267
+ function parseIncludedBody(stdout) {
268
+ const split = stdout.split(/\r?\n\r?\n/);
269
+ if (split.length < 2)
270
+ return null;
271
+ try {
272
+ return JSON.parse(split.slice(1).join("\n\n"));
273
+ }
274
+ catch {
275
+ return null;
276
+ }
277
+ }
278
+ function providerReads(deps, credential, env, repository) {
279
+ return {
280
+ async branchHead(branch) {
281
+ const r = await runCredentialed(deps, credential, env, "gh", ["api", `repos/${repository}/branches/${branch}`, "--jq", ".commit.sha"], { timeoutMs: READ_TIMEOUT_MS });
282
+ const sha = r.stdout.trim().toLowerCase();
283
+ return r.exitCode === 0 && SHA_RE.test(sha) ? sha : null;
284
+ },
285
+ async parents(sha) {
286
+ const r = await runCredentialed(deps, credential, env, "gh", ["api", `repos/${repository}/commits/${sha}`, "--jq", "[.parents[].sha] | join(\" \")"], { timeoutMs: READ_TIMEOUT_MS });
287
+ if (r.exitCode !== 0)
288
+ return null;
289
+ const list = r.stdout.trim().toLowerCase().split(/\s+/).filter((p) => p.length > 0);
290
+ return list.every((p) => SHA_RE.test(p)) ? list : null;
291
+ },
292
+ };
293
+ }
294
+ function startLeaseHeartbeat(client, action, deps, intervalMs) {
295
+ let lost = false;
296
+ let stopped = false;
297
+ const beat = async () => {
298
+ const result = await client.heartbeat(action);
299
+ if (result === "lost")
300
+ lost = true;
301
+ return result;
302
+ };
303
+ const loop = (async () => {
304
+ while (!stopped && !lost) {
305
+ await deps.sleep(intervalMs);
306
+ if (stopped || lost)
307
+ break;
308
+ await beat();
309
+ }
310
+ })();
311
+ return {
312
+ lost: () => lost,
313
+ beat,
314
+ async stop() {
315
+ stopped = true;
316
+ // Never block shutdown on a sleeping beat: the loop exits on its next wake.
317
+ await Promise.race([loop, Promise.resolve()]);
318
+ },
319
+ };
320
+ }
321
+ async function performSyncBase(action, deps, credential, env, reads) {
322
+ const message = `Merge ${action.base_branch} into ${action.epic_branch} ` +
323
+ `(${action.epic_key} integration preparation)`;
324
+ const r = await runCredentialed(deps, credential, env, "gh", [
325
+ "api", "--include", "--method", "POST", `repos/${action.provider_repository}/merges`,
326
+ "-f", `base=${action.epic_branch}`,
327
+ "-f", `head=${action.expected_base_sha}`,
328
+ "-f", `commit_message=${message}`,
329
+ ], { timeoutMs: WRITE_TIMEOUT_MS });
330
+ const status = parseGhHttpStatus(r.stdout, r.stderr);
331
+ const expectedParents = [action.expected_epic_head_sha, action.expected_base_sha];
332
+ const matches = (parents) => parents !== null && parents.length === 2 &&
333
+ parents[0] === expectedParents[0] && parents[1] === expectedParents[1];
334
+ if (status === 201) {
335
+ const body = parseIncludedBody(r.stdout);
336
+ const sha = typeof body?.sha === "string" ? body.sha.toLowerCase() : "";
337
+ if (SHA_RE.test(sha) && matches(await reads.parents(sha))) {
338
+ return { outcome: "applied", result_epic_head_sha: sha };
339
+ }
340
+ return { outcome: "head_drift", detail_code: "merge_parents_unexpected" };
341
+ }
342
+ if (status === 204) {
343
+ return { outcome: "already_satisfied", result_epic_head_sha: action.expected_epic_head_sha };
344
+ }
345
+ if (status === 409)
346
+ return { outcome: "conflict", detail_code: "base_merge_conflict" };
347
+ if (status === 401 || status === 403 || status === 404) {
348
+ // The provider refused the configured credential: missing authority, which
349
+ // blocks with its own reason. `authority_lost` is reserved for a lease or
350
+ // approved work that changed under this actor.
351
+ return { outcome: "authority_unavailable", detail_code: `provider_http_${status}` };
352
+ }
353
+ // A lost or unrecognized response: read the branch back and match it to THIS
354
+ // action. Never repeat the write.
355
+ const live = await reads.branchHead(action.epic_branch);
356
+ if (live !== null && live !== action.expected_epic_head_sha && matches(await reads.parents(live))) {
357
+ return { outcome: "applied", result_epic_head_sha: live, detail_code: "read_back_confirmed" };
358
+ }
359
+ return { outcome: "indeterminate", detail_code: "merge_response_lost" };
360
+ }
361
+ function mapPullRequestOutcome(outcome) {
362
+ if (outcome.kind === "already_open" || outcome.kind === "created") {
363
+ const pr = outcome.prNumber ?? null;
364
+ switch (outcome.readiness) {
365
+ case "made_ready":
366
+ return { outcome: "applied", pr_number: pr };
367
+ case "already_ready":
368
+ return { outcome: "already_satisfied", pr_number: pr };
369
+ case "ready_refused_head_drift":
370
+ return { outcome: "head_drift", pr_number: pr, detail_code: "pr_head_moved" };
371
+ default:
372
+ return { outcome: "failed", pr_number: pr, detail_code: "ready_failed" };
373
+ }
374
+ }
375
+ if (outcome.kind === "deferred") {
376
+ return { outcome: "failed", detail_code: "no_commits_between_branches" };
377
+ }
378
+ switch (outcome.reason) {
379
+ case "probe_ambiguous":
380
+ return { outcome: "pr_ambiguous", detail_code: "probe_ambiguous" };
381
+ case "gh_unauthenticated":
382
+ return { outcome: "authority_unavailable", detail_code: "gh_unauthenticated" };
383
+ case "gh_unavailable":
384
+ return { outcome: "authority_unavailable", detail_code: "local_gh_unavailable" };
385
+ default:
386
+ return { outcome: "indeterminate", detail_code: safeDetail(outcome.reason) };
387
+ }
388
+ }
389
+ async function performMarkReady(action, deps, env, seams) {
390
+ const createGh = seams.createGhRunner ?? ((e) => createProductionEpicIntegrationGhRunner(undefined, e));
391
+ const outcome = await ensureEpicIntegrationPullRequest({
392
+ epicKey: action.epic_key,
393
+ epicBranch: action.epic_branch,
394
+ baseBranch: action.base_branch,
395
+ command: "executor integration",
396
+ gh: createGh(env),
397
+ cwd: deps.cwd,
398
+ requestReady: true,
399
+ expectedHeadSha: action.expected_epic_head_sha,
400
+ repo: action.provider_repository,
401
+ });
402
+ return mapPullRequestOutcome(outcome);
403
+ }
404
+ /**
405
+ * Perform one claimed integration action and report its facts.
406
+ *
407
+ * Never throws. Every refusal is reported (a report on a lease that is already
408
+ * gone is recorded by the server as a stale fact, which is still useful to the
409
+ * reconciler's re-observation).
410
+ */
411
+ export async function runIntegrationAction(action, client, deps, seams = {}) {
412
+ const finish = async (report) => {
413
+ const bounded = {
414
+ outcome: INTEGRATION_ACTION_OUTCOMES.includes(report.outcome)
415
+ ? report.outcome
416
+ : "failed",
417
+ result_epic_head_sha: report.result_epic_head_sha && SHA_RE.test(report.result_epic_head_sha)
418
+ ? report.result_epic_head_sha
419
+ : null,
420
+ pr_number: typeof report.pr_number === "number" && Number.isInteger(report.pr_number) && report.pr_number > 0
421
+ ? report.pr_number
422
+ : null,
423
+ detail_code: report.detail_code ? safeDetail(report.detail_code) : null,
424
+ };
425
+ const reported = await client.report(action, bounded);
426
+ deps.errorLog(`executor: integration action ${action.operation} for ${action.epic_key}: ` +
427
+ `outcome=${bounded.outcome}` +
428
+ (bounded.detail_code ? ` detail=${bounded.detail_code}` : "") +
429
+ ` reported=${reported}`);
430
+ return { report: bounded, reported };
431
+ };
432
+ const valid = validateIntegrationAction(action);
433
+ if (!valid.ok)
434
+ return finish({ outcome: "failed", detail_code: valid.reason });
435
+ const kind = action.credential_kind === "machine_token" ? "machine_token" : "local";
436
+ const credential = resolveLocalMergeCredential(deps.env, kind);
437
+ if (credential === null) {
438
+ // An ambient gh login, GH_TOKEN, or GITHUB_TOKEN does NOT substitute.
439
+ return finish({ outcome: "authority_unavailable", detail_code: `${kind}_merge_credential_missing` });
440
+ }
441
+ const env = buildLocalMergeChildEnv(deps.env, credential, { GIT_TERMINAL_PROMPT: "0" });
442
+ const reads = providerReads(deps, credential, env, action.provider_repository);
443
+ const lease = startLeaseHeartbeat(client, action, deps, seams.heartbeatIntervalMs ?? INTEGRATION_ACTION_HEARTBEAT_INTERVAL_MS);
444
+ let report;
445
+ try {
446
+ // --- Revalidate immediately before any write ------------------------
447
+ const refetched = await client.fetch(action);
448
+ if (refetched.kind !== "ok") {
449
+ report = {
450
+ outcome: refetched.kind === "unavailable" ? "authority_lost" : "indeterminate",
451
+ detail_code: refetched.kind === "unavailable" ? "lease_not_held" : "fetch_unavailable",
452
+ };
453
+ }
454
+ else if (!sameWork(action, refetched.work)) {
455
+ report = { outcome: "authority_lost", detail_code: "work_changed" };
456
+ }
457
+ else {
458
+ const live = await reads.branchHead(action.epic_branch);
459
+ if (live === null) {
460
+ report = { outcome: "indeterminate", detail_code: "epic_head_unreadable" };
461
+ }
462
+ else if (live !== action.expected_epic_head_sha) {
463
+ report = { outcome: "head_drift", detail_code: "epic_head_moved" };
464
+ }
465
+ else if (lease.lost()) {
466
+ report = { outcome: "authority_lost", detail_code: "lease_lost" };
467
+ }
468
+ else if (action.operation === "sync_base") {
469
+ report = await performSyncBase(action, deps, credential, env, reads);
470
+ }
471
+ else if (action.operation === "mark_ready") {
472
+ report = await performMarkReady(action, deps, env, seams);
473
+ }
474
+ else if (seams.runCorrection === undefined) {
475
+ report = { outcome: "failed", detail_code: "correction_unsupported" };
476
+ }
477
+ else {
478
+ const driver = createIntegrationCorrectionDriver(action, {
479
+ runCommand: deps.runCommand,
480
+ credential,
481
+ env,
482
+ errorLog: deps.errorLog,
483
+ });
484
+ const corrected = await seams.runCorrection(action, driver, () => lease.beat());
485
+ report = {
486
+ outcome: corrected.outcome,
487
+ result_epic_head_sha: corrected.resultSha ?? null,
488
+ detail_code: corrected.detailCode ?? null,
489
+ };
490
+ }
491
+ }
492
+ }
493
+ catch {
494
+ report = { outcome: "indeterminate", detail_code: "executor_error" };
495
+ }
496
+ finally {
497
+ await lease.stop();
498
+ }
499
+ return finish(report);
500
+ }
501
+ /**
502
+ * The driver for ONE `correct` action. Mirrors `reconcile_overlap`'s driver
503
+ * (BAPI-1152): it prepares the worktree before the worker, and afterwards it
504
+ * alone verifies, commits, and pushes. The worker is launched without a
505
+ * worker-branch identity, so the deterministic guard denies every push it
506
+ * attempts.
507
+ */
508
+ export function createIntegrationCorrectionDriver(action, deps) {
509
+ const isConflict = action.correction_kind === "base_conflict";
510
+ const expected = action.expected_epic_head_sha;
511
+ const baseSha = action.expected_base_sha;
512
+ const remoteUrl = `https://github.com/${action.provider_repository}.git`;
513
+ const destination = `refs/heads/${action.epic_branch}`;
514
+ const git = async (ctx, args, timeoutMs = GIT_TIMEOUT_MS, env) => {
515
+ try {
516
+ const r = await deps.runCommand("git", args, {
517
+ cwd: ctx.worktreePath,
518
+ timeoutMs,
519
+ ...(env ? { env } : {}),
520
+ });
521
+ return {
522
+ exitCode: r.exitCode,
523
+ stdout: sanitizeLocalMergeOutput(r.stdout ?? "", [deps.credential.token]),
524
+ stderr: sanitizeLocalMergeOutput(r.stderr ?? "", [deps.credential.token]),
525
+ };
526
+ }
527
+ catch {
528
+ return { exitCode: -1, stdout: "", stderr: "" };
529
+ }
530
+ };
531
+ const revParse = async (ctx, ref) => {
532
+ const r = await git(ctx, ["rev-parse", "-q", "--verify", `${ref}^{commit}`]);
533
+ const sha = r.stdout.trim().toLowerCase();
534
+ return r.exitCode === 0 && SHA_RE.test(sha) ? sha : null;
535
+ };
536
+ const complete = (result) => ({
537
+ kind: "complete",
538
+ result: {
539
+ outcome: result.outcome,
540
+ ...(result.resultSha ? { result_sha: result.resultSha } : {}),
541
+ ...(result.detailCode ? { detail_code: result.detailCode } : {}),
542
+ },
543
+ ...(result.resultSha ? { lastCommitSha: result.resultSha } : {}),
544
+ });
545
+ const restore = async (ctx) => {
546
+ await git(ctx, ["merge", "--abort"]);
547
+ await git(ctx, ["reset", "--hard", expected]);
548
+ };
549
+ /** The ONE push: a plain fast-forward, never forced, under the configured credential. */
550
+ const push = async (ctx, candidate) => {
551
+ const pushed = await git(ctx, [
552
+ "-c", "credential.helper=",
553
+ "-c", "credential.helper=!gh auth git-credential",
554
+ "push", "--no-verify", "--porcelain", remoteUrl, `HEAD:${destination}`,
555
+ ], PUSH_TIMEOUT_MS, deps.env);
556
+ if (pushed.exitCode === 0) {
557
+ return complete({ outcome: "applied", resultSha: candidate });
558
+ }
559
+ const text = `${pushed.stdout}\n${pushed.stderr}`;
560
+ if (/non-fast-forward|fetch first|\[rejected\]/i.test(text)) {
561
+ await restore(ctx);
562
+ return complete({ outcome: "head_drift", detailCode: "push_rejected_non_fast_forward" });
563
+ }
564
+ if (/403|denied|Authentication failed|could not read Username/i.test(text)) {
565
+ await restore(ctx);
566
+ return complete({ outcome: "authority_unavailable", detailCode: "push_unauthorized" });
567
+ }
568
+ // Unknown result: read the remote back and match it to THIS commit.
569
+ const remote = await git(ctx, ["ls-remote", remoteUrl, destination], GIT_TIMEOUT_MS, deps.env);
570
+ const remoteHead = remote.stdout.trim().split(/\s+/)[0]?.toLowerCase() ?? "";
571
+ if (remote.exitCode === 0 && remoteHead === candidate) {
572
+ return complete({ outcome: "applied", resultSha: candidate, detailCode: "read_back_confirmed" });
573
+ }
574
+ await restore(ctx);
575
+ return complete({ outcome: "indeterminate", detailCode: "push_result_unknown" });
576
+ };
577
+ const declaredFiles = async (ctx) => {
578
+ const args = isConflict
579
+ ? ["diff", "--name-only", "--diff-filter=U", "-z"]
580
+ : ["diff", "--name-only", "-z", `${baseSha}...${expected}`];
581
+ const r = await git(ctx, args);
582
+ if (r.exitCode !== 0)
583
+ return [];
584
+ return r.stdout.split("\0").filter((p) => p.length > 0).slice(0, MAX_DECLARED_FILES);
585
+ };
586
+ return {
587
+ omitWorkerBranch: true,
588
+ async beforeWorker(ctx) {
589
+ const finish = (outcome) => ({ kind: "finish", outcome });
590
+ if (ctx.branch !== action.epic_branch) {
591
+ return finish(complete({ outcome: "failed", detailCode: "worktree_branch_mismatch" }));
592
+ }
593
+ const head = await revParse(ctx, "HEAD");
594
+ if (head !== expected) {
595
+ await restore(ctx);
596
+ return finish(complete({ outcome: "head_drift", detailCode: "epic_head_moved" }));
597
+ }
598
+ await git(ctx, ["fetch", "--no-tags", "origin", action.base_branch], GIT_TIMEOUT_MS);
599
+ if ((await git(ctx, ["cat-file", "-e", `${baseSha}^{commit}`])).exitCode !== 0) {
600
+ return finish(complete({ outcome: "indeterminate", detailCode: "base_commit_unavailable" }));
601
+ }
602
+ if (isConflict) {
603
+ const merge = await git(ctx, ["merge", "--no-ff", "--no-commit", "--no-edit", baseSha], GIT_TIMEOUT_MS, { ...deps.env, GIT_MERGE_AUTOEDIT: "no" });
604
+ if (merge.exitCode === 0) {
605
+ // The base merged cleanly here after all: commit it, no worker needed.
606
+ const committed = await git(ctx, [
607
+ "commit", "--no-verify", "--no-edit", "-m",
608
+ `Merge ${action.base_branch} into ${action.epic_branch}`,
609
+ ]);
610
+ const candidate = await revParse(ctx, "HEAD");
611
+ if (committed.exitCode !== 0 || candidate === null) {
612
+ await restore(ctx);
613
+ return finish(complete({ outcome: "failed", detailCode: "merge_commit_failed" }));
614
+ }
615
+ return finish(await push(ctx, candidate));
616
+ }
617
+ if (merge.exitCode !== 1 || (await revParse(ctx, "MERGE_HEAD")) !== baseSha) {
618
+ await restore(ctx);
619
+ return finish(complete({ outcome: "failed", detailCode: "merge_start_failed" }));
620
+ }
621
+ }
622
+ return {
623
+ kind: "spawn_worker",
624
+ prompt: action.correction_prompt ?? "",
625
+ // Stricter than the generic check: HEAD is the expected epic head and,
626
+ // for a conflict, the merge of the expected base SHA is in progress.
627
+ preSpawnVerification: { ok: true, observedSha: expected },
628
+ declaredTouchedFiles: await declaredFiles(ctx),
629
+ };
630
+ },
631
+ async afterWorker(ctx) {
632
+ // The worker may edit the working tree and nothing else.
633
+ const head = await revParse(ctx, "HEAD");
634
+ const mergeHead = await revParse(ctx, "MERGE_HEAD");
635
+ if (head !== expected || mergeHead !== (isConflict ? baseSha : null)) {
636
+ await restore(ctx);
637
+ return complete({ outcome: "failed", detailCode: "worker_rewrote_history" });
638
+ }
639
+ await git(ctx, ["add", "-A", "--", "."]);
640
+ if (isConflict) {
641
+ const unmerged = await git(ctx, ["ls-files", "-u"]);
642
+ const markers = await git(ctx, ["grep", "--cached", "-n", "-I", "-E", "^(<{7}|>{7})( |$)"]);
643
+ if (unmerged.exitCode !== 0 || unmerged.stdout.trim().length > 0 || markers.exitCode === 0) {
644
+ await restore(ctx);
645
+ return complete({ outcome: "failed", detailCode: "conflicts_unresolved" });
646
+ }
647
+ }
648
+ else {
649
+ const staged = await git(ctx, ["diff", "--cached", "--quiet"]);
650
+ if (staged.exitCode === 0) {
651
+ await restore(ctx);
652
+ return complete({ outcome: "failed", detailCode: "correction_no_change" });
653
+ }
654
+ }
655
+ const message = isConflict
656
+ ? `Merge ${action.base_branch} into ${action.epic_branch}`
657
+ : `${action.epic_key}: fix failing required checks on the integration branch`;
658
+ const committed = await git(ctx, ["commit", "--no-verify", "--no-edit", "-m", message]);
659
+ const candidate = await revParse(ctx, "HEAD");
660
+ if (committed.exitCode !== 0 || candidate === null) {
661
+ await restore(ctx);
662
+ return complete({ outcome: "failed", detailCode: "commit_failed" });
663
+ }
664
+ // The commit's shape is the authority: exactly the two authorized parents
665
+ // for a conflict merge, exactly the expected head for a CI fix.
666
+ const parents = await git(ctx, ["rev-parse", `${candidate}^@`]);
667
+ const parentList = parents.stdout.trim().toLowerCase().split(/\s+/).filter((p) => p);
668
+ const shapeOk = isConflict
669
+ ? parentList.length === 2 && parentList[0] === expected && parentList[1] === baseSha
670
+ : parentList.length === 1 && parentList[0] === expected;
671
+ if (parents.exitCode !== 0 || !shapeOk) {
672
+ await restore(ctx);
673
+ return complete({ outcome: "failed", detailCode: "correction_shape_invalid" });
674
+ }
675
+ return push(ctx, candidate);
676
+ },
677
+ async abandon(ctx) {
678
+ try {
679
+ await restore(ctx);
680
+ }
681
+ catch {
682
+ /* must never throw */
683
+ }
684
+ },
685
+ };
686
+ }
687
+ // ---------------------------------------------------------------------------
688
+ // Correction: the synthetic job and its lease adapter
689
+ // ---------------------------------------------------------------------------
690
+ /**
691
+ * The synthetic recovery job a correction runs as inside the locked spawn path.
692
+ *
693
+ * `rebase` is the recovery type whose worktree continues from the pushed
694
+ * `origin/<branch>` head — here the epic branch — with the stale-branch guard
695
+ * off. The job never reaches `/executor/jobs`: its `httpClient` is the lease
696
+ * adapter below, and `id` 0 is never a server row id.
697
+ */
698
+ export function buildIntegrationCorrectionJob(action, timeoutSeconds) {
699
+ return {
700
+ id: 0,
701
+ claim_token: action.claim_token,
702
+ repo_name: action.repo_name,
703
+ job_type: "rebase",
704
+ ticket_key: action.epic_key,
705
+ epic_run_id: action.epic_run_id,
706
+ payload: { prompt: action.correction_prompt ?? "", base_branch: action.base_branch },
707
+ expected_branch: action.epic_branch,
708
+ expected_head_sha: action.expected_epic_head_sha,
709
+ lease_expires_at: action.lease_expires_at,
710
+ attempt_count: 1,
711
+ max_attempts: 1,
712
+ timeout_seconds: timeoutSeconds,
713
+ };
714
+ }
715
+ /**
716
+ * An `ExecutorHttpClient` whose heartbeats renew the integration action's lease
717
+ * and whose terminal mutations are CAPTURED rather than sent: the correction
718
+ * reports through the integration action's own report, once, after the spawn.
719
+ */
720
+ export function createIntegrationCorrectionLeaseAdapter(heartbeat) {
721
+ let completion = null;
722
+ let failure = null;
723
+ const client = {
724
+ async claim() {
725
+ return { kind: "none", reconcilerLiveness: "unknown" };
726
+ },
727
+ async heartbeat(_job, _payload) {
728
+ const result = await heartbeat();
729
+ if (result === "renewed")
730
+ return "updated";
731
+ if (result === "lost")
732
+ return "stale_claim";
733
+ return "retry_later";
734
+ },
735
+ async complete(_job, c) {
736
+ completion = c;
737
+ return "updated";
738
+ },
739
+ async fail(_job, f) {
740
+ failure = f;
741
+ return "updated";
742
+ },
743
+ };
744
+ const captured = () => {
745
+ const result = completion?.result;
746
+ const outcome = result?.outcome;
747
+ if (typeof outcome === "string" && INTEGRATION_ACTION_OUTCOMES.includes(outcome)) {
748
+ const sha = typeof result?.result_sha === "string" ? result.result_sha : undefined;
749
+ const detail = typeof result?.detail_code === "string" ? result.detail_code : undefined;
750
+ return { outcome: outcome, resultSha: sha, detailCode: detail };
751
+ }
752
+ if (failure !== null) {
753
+ const kind = failure.classification === "timeout" ? "correction_timeout" : "correction_failed";
754
+ return { outcome: "failed", detailCode: kind };
755
+ }
756
+ return { outcome: "indeterminate", detailCode: "correction_unreported" };
757
+ };
758
+ return { client, captured };
759
+ }