@bridge_gpt/mcp-server 0.2.19 → 0.2.20

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 (34) hide show
  1. package/README.md +6 -3
  2. package/build/agents.generated.js +1 -1
  3. package/build/commands.generated.js +4 -3
  4. package/build/conductor/local-merge.js +458 -95
  5. package/build/estimate-epic.js +84 -0
  6. package/build/executor/job-runner.js +151 -17
  7. package/build/executor/merge-job.js +84 -10
  8. package/build/executor/worker-finalization.js +98 -18
  9. package/build/index.js +1837 -397
  10. package/build/pipelines.generated.js +16 -20
  11. package/build/readme.generated.js +1 -1
  12. package/build/sfcc/client.js +192 -50
  13. package/build/sfcc/ocapi-write-faults.js +94 -0
  14. package/build/sfcc/permissions.js +7 -22
  15. package/build/sfcc/register.js +9 -0
  16. package/build/sfcc/write-grants.js +80 -0
  17. package/build/sfcc/write-guard.js +39 -0
  18. package/build/sfcc/write-result.js +47 -0
  19. package/build/sfcc/write-tool-common.js +85 -0
  20. package/build/sfcc/writes-custom-object-def.js +141 -0
  21. package/build/sfcc/writes-object-attribute-payloads.js +97 -0
  22. package/build/sfcc/writes-site-preference-payloads.js +59 -0
  23. package/build/sfcc/writes-site-preference.js +96 -0
  24. package/build/sfcc/writes-system-object-payloads.js +213 -0
  25. package/build/sfcc/writes-system-object.js +348 -0
  26. package/build/sfcc/writes.js +66 -0
  27. package/build/version.generated.js +1 -1
  28. package/package.json +3 -3
  29. package/pipelines/idea-to-ticket.json +7 -0
  30. package/pipelines/review-ticket.json +5 -18
  31. package/public/css/main.min.css +1583 -117
  32. package/public/css/main.min.css.map +1 -1
  33. package/public/js/main.min.js +2792 -449
  34. package/public/js/main.min.js.map +1 -1
@@ -0,0 +1,84 @@
1
+ /**
2
+ * estimate-epic.ts — orchestration for the deterministic `estimate_epic` MCP tool
3
+ * (BAPI-523).
4
+ *
5
+ * `index.ts` stays the thin registration surface; input validation, request
6
+ * shaping, and response formatting live here so they are unit-testable without a
7
+ * real network call or credential store. Mirrors the `visual-diff.ts` pattern:
8
+ * this module never imports `index.ts` — the caller injects `buildUrl`,
9
+ * `getPostHeaders`, and `handleResponse` (index.ts's existing fetch helpers) as
10
+ * deps. `epic_key` and `ticket_keys` are mutually exclusive and validated BEFORE
11
+ * any network call; no `mode` parameter exists — the source is inferred from
12
+ * which key input is supplied. There is no `recreate` parameter: the BAPI-522
13
+ * orchestrator does not currently expose one (see `api/routes/estimate_epic.py`
14
+ * for the backend-side note), so this tool intentionally does not accept one
15
+ * either.
16
+ */
17
+ /**
18
+ * Validate mutually-exclusive/required key inputs before any network call.
19
+ * Returns a human-readable error message, or `null` when the input is valid.
20
+ */
21
+ export function validateEstimateEpicInput(input) {
22
+ const hasEpic = typeof input.epic_key === "string" && input.epic_key.trim().length > 0;
23
+ const hasKeys = Array.isArray(input.ticket_keys);
24
+ if (hasEpic && hasKeys) {
25
+ return "epic_key and ticket_keys are mutually exclusive; supply exactly one, never both.";
26
+ }
27
+ if (!hasEpic && !hasKeys) {
28
+ return "Exactly one of epic_key or ticket_keys is required.";
29
+ }
30
+ if (hasKeys && input.ticket_keys.length === 0) {
31
+ return "ticket_keys was supplied but contained no keys; provide at least one, non-empty ticket_keys.";
32
+ }
33
+ return null;
34
+ }
35
+ /** A structured, JSON-safe validation/error envelope — never a successful estimate payload. */
36
+ export function buildEstimateEpicErrorEnvelope(code, message, extras) {
37
+ return JSON.stringify({ error: code, message, ...(extras ?? {}) }, null, 2);
38
+ }
39
+ /** Serialize a payload with stable two-space indentation for MCP text content. */
40
+ export function toolText(payload) {
41
+ return typeof payload === "string" ? payload : JSON.stringify(payload, null, 2);
42
+ }
43
+ /**
44
+ * Run the `estimate_epic` tool: validate input, POST to the Bridge API
45
+ * `/jira/estimate-epic` endpoint, and return MCP text content. Never throws —
46
+ * every failure (validation, network, backend error) becomes a structured JSON
47
+ * error envelope in the returned text content.
48
+ */
49
+ export async function runEstimateEpic(input, deps) {
50
+ const validationError = validateEstimateEpicInput(input);
51
+ if (validationError) {
52
+ return {
53
+ content: [{ type: "text", text: buildEstimateEpicErrorEnvelope("VALIDATION_ERROR", validationError) }],
54
+ };
55
+ }
56
+ const payload = { repo_name: deps.repoName };
57
+ if (typeof input.epic_key === "string")
58
+ payload.epic_key = input.epic_key;
59
+ if (Array.isArray(input.ticket_keys))
60
+ payload.ticket_keys = input.ticket_keys;
61
+ if (typeof input.allow_partial === "boolean")
62
+ payload.allow_partial = input.allow_partial;
63
+ const fetchImpl = deps.fetchImpl ?? fetch;
64
+ let resp;
65
+ try {
66
+ resp = await fetchImpl(deps.buildUrl("/estimate-epic"), {
67
+ method: "POST",
68
+ headers: await deps.getPostHeaders(),
69
+ body: JSON.stringify(payload),
70
+ });
71
+ }
72
+ catch {
73
+ return {
74
+ content: [
75
+ {
76
+ type: "text",
77
+ text: buildEstimateEpicErrorEnvelope("NETWORK_ERROR", "Failed to reach the Bridge API estimate-epic endpoint."),
78
+ },
79
+ ],
80
+ };
81
+ }
82
+ const text = await deps.handleResponse(resp);
83
+ return { content: [{ type: "text", text }] };
84
+ }
@@ -18,7 +18,7 @@ import os from "node:os";
18
18
  import { buildExecutorWorkerEnv } from "./env.js";
19
19
  import { runHeartbeatLoop } from "./heartbeat.js";
20
20
  import { isExecutorNamedError, toExecutorFailure, secretFreeErrorMessage, MissingVerdictArtifact, } from "./job-errors.js";
21
- import { buildConductorMergeAccessForExecutorJob, buildDefaultMergeLocalDeps, runExecutorMergeJob, } from "./merge-job.js";
21
+ import { buildConductorMergeAccessForExecutorJob, buildDefaultMergeLocalDeps, runExecutorMergeJob, MERGE_RETRYABLE, MERGE_FAILED, } from "./merge-job.js";
22
22
  import { createObservationState } from "./observation.js";
23
23
  import { provisionExecutorDenyLayer } from "./permissions.js";
24
24
  import { runProcessWithTimeout } from "./process.js";
@@ -89,8 +89,13 @@ function buildJobLogRegistryDeps(deps) {
89
89
  platform: deps.platform,
90
90
  };
91
91
  }
92
- /** Default merge dispatch: resolve Bridge API access, then run the deterministic merge. */
93
- async function defaultRunMergeForClaimed(job, deps, _options) {
92
+ /**
93
+ * Default merge dispatch: resolve Bridge API access, then run the deterministic
94
+ * merge. Exported for unit tests (access-resolution failure → retryable). The
95
+ * `controls.signal` (overall-timeout abort) is threaded into local merge deps
96
+ * without changing the local `gh` credential model.
97
+ */
98
+ export async function defaultRunMergeForClaimed(job, deps, _options, controls) {
94
99
  const accessResult = await buildConductorMergeAccessForExecutorJob({
95
100
  env: deps.env,
96
101
  cwd: deps.cwd,
@@ -100,10 +105,13 @@ async function defaultRunMergeForClaimed(job, deps, _options) {
100
105
  stat: deps.stat,
101
106
  });
102
107
  if (!accessResult.ok) {
108
+ // Access resolution feeds only the executor's read-only CI re-poll; a missing
109
+ // or temporarily-unavailable Bridge API access is transient infrastructure, so
110
+ // it is RETRYABLE (BAPI-572). The local `gh` credential model is unchanged.
103
111
  return {
104
112
  ok: false,
105
113
  failure: {
106
- error_kind: "MergeAccessUnavailable",
114
+ error_kind: MERGE_RETRYABLE,
107
115
  error_message: accessResult.error,
108
116
  classification: "crashed",
109
117
  },
@@ -111,7 +119,9 @@ async function defaultRunMergeForClaimed(job, deps, _options) {
111
119
  }
112
120
  return runExecutorMergeJob(job, {
113
121
  access: accessResult.access,
114
- localMergeDeps: buildDefaultMergeLocalDeps(deps),
122
+ // Thread the overall-timeout abort signal into local merge deps WITHOUT
123
+ // changing the local `gh` credential model (the signal is not a credential).
124
+ localMergeDeps: buildDefaultMergeLocalDeps(deps, controls?.signal),
115
125
  });
116
126
  }
117
127
  /** An in-memory owned process for the no-op `smoke` acceptance job. */
@@ -192,7 +202,7 @@ export async function runClaimedJob(job, httpClient, options, deps, _report, sea
192
202
  // ensure/recreate or worker-spawn logic — it ensures no worktree and spawns no
193
203
  // worker (TDD §5/§9).
194
204
  if (job.job_type === "merge") {
195
- return runMergeJob(job, httpClient, options, deps, ownership, seams);
205
+ return runMergeJob(job, httpClient, options, deps, ownership, observation, seams);
196
206
  }
197
207
  if (job.job_type === "smoke") {
198
208
  return runSmokeJob(job, httpClient, options, deps, ownership, observation);
@@ -213,23 +223,146 @@ export async function runClaimedJob(job, httpClient, options, deps, _report, sea
213
223
  return { status: "failed", reason: "unsupported_job_type" };
214
224
  }
215
225
  /**
216
- * Run a deterministic `merge` job: NO worktree ensured, NO worker spawned. The
217
- * local merge executor's outcome drives `/complete` (success) or `/fail`
218
- * (deterministic failure); stale/retry/invalid outcomes reuse T3a handling.
226
+ * Adapt a `runMerge(...)` promise into an {@link OwnedChildProcess} so the merge
227
+ * flow is supervised by the SAME heartbeat/deadman/timeout path as spawn/smoke
228
+ * jobs even though it spawns no worker. `wait()` resolves when the merge outcome
229
+ * is available (exit_code 0 on `ok`, 1 otherwise); `kill()` aborts the internal
230
+ * {@link AbortController} (cancelling in-flight local `gh` work) and resolves the
231
+ * owned process as killed. The stored outcome/error let the runner map the
232
+ * supervised result to `/complete` or `/fail` afterwards (killed/timeout is read
233
+ * from the supervised `ProcessRunResult`/`ownership`, not from this adapter).
219
234
  */
220
- async function runMergeJob(job, httpClient, options, deps, ownership, seams) {
221
- const runMerge = seams.runMerge ?? defaultRunMergeForClaimed;
235
+ function createMergeFlowProcess(startMerge) {
236
+ const controller = new AbortController();
222
237
  let outcome;
223
- try {
224
- outcome = await runMerge(job, deps, options);
238
+ let error;
239
+ let done = false;
240
+ let settleWait = () => { };
241
+ const waitPromise = new Promise((resolve) => {
242
+ settleWait = resolve;
243
+ });
244
+ // Start the merge flow immediately; capture its resolution/rejection.
245
+ startMerge(controller.signal).then((result) => {
246
+ outcome = result;
247
+ if (!done) {
248
+ done = true;
249
+ settleWait({ exitCode: result.ok ? 0 : 1, signal: null });
250
+ }
251
+ }, (err) => {
252
+ error = err;
253
+ if (!done) {
254
+ done = true;
255
+ settleWait({ exitCode: 1, signal: null });
256
+ }
257
+ });
258
+ const proc = {
259
+ pid: undefined,
260
+ stdout: null,
261
+ stderr: null,
262
+ async wait() {
263
+ return waitPromise;
264
+ },
265
+ kill(signal) {
266
+ if (done)
267
+ return;
268
+ controller.abort();
269
+ done = true;
270
+ settleWait({ exitCode: null, signal });
271
+ },
272
+ };
273
+ return {
274
+ proc,
275
+ getOutcome: () => outcome,
276
+ getError: () => error,
277
+ };
278
+ }
279
+ /**
280
+ * Run a deterministic `merge` job: NO worktree ensured, NO worker spawned. Unlike
281
+ * the pre-BAPI-572 version, the merge flow now runs under the SHARED heartbeat/
282
+ * deadman/timeout supervision path (via {@link superviseProcess}) so a live merge
283
+ * heartbeats (the janitor never re-queues a healthy multi-minute merge) and the
284
+ * whole flow honors `payload.timeout_seconds`. A timeout is reported as a
285
+ * RETRYABLE failure and a late merge resolution after timeout/abandonment is
286
+ * ignored. The local merge executor's outcome otherwise drives `/complete`
287
+ * (success) or `/fail`; stale/retry/invalid terminal outcomes reuse T3a handling.
288
+ */
289
+ async function runMergeJob(job, httpClient, options, deps, ownership, observation, seams) {
290
+ // Resolve + enforce payload.timeout_seconds BEFORE starting any merge work.
291
+ const timeout = resolveJobTimeoutSeconds(job, options.defaultJobTimeoutSeconds);
292
+ if (!timeout.ok) {
293
+ await httpClient.fail(job, {
294
+ error_kind: "ContractError.Timeout",
295
+ error_message: timeout.error,
296
+ classification: "crashed",
297
+ });
298
+ return { status: "failed", reason: "timeout_contract" };
225
299
  }
226
- catch (err) {
300
+ const timeoutSeconds = timeout.timeoutSeconds;
301
+ const runMerge = seams.runMerge ?? defaultRunMergeForClaimed;
302
+ const flow = createMergeFlowProcess((signal) => runMerge(job, deps, options, { signal }));
303
+ const procResult = await superviseProcess({
304
+ job,
305
+ httpClient,
306
+ options,
307
+ deps,
308
+ ownership,
309
+ observation,
310
+ proc: flow.proc,
311
+ timeoutSeconds,
312
+ collectTelemetry: async () => ({}),
313
+ });
314
+ // Ownership abandonment (stale-claim / dead-man) wins: never send a terminal
315
+ // mutation for a claim we no longer own, and never process a late outcome.
316
+ if (ownership.abandoned) {
317
+ return { status: "abandoned", reason: ownership.abandonReason };
318
+ }
319
+ // Overall merge flow timed out: fail the active claim as RETRYABLE and ignore
320
+ // any later merge promise resolution (the abort has already been signalled).
321
+ if (procResult.classification === "timeout") {
227
322
  const terminal = await sendTerminalMutationWithRetry({
228
323
  kind: "fail",
229
324
  send: () => httpClient.fail(job, {
230
- error_kind: "MergeError",
231
- error_message: secretFreeErrorMessage(err),
325
+ error_kind: MERGE_RETRYABLE,
326
+ error_message: `merge flow timed out after ${timeoutSeconds} seconds`,
327
+ classification: "timeout",
328
+ telemetry: observation.snapshot(),
329
+ }),
330
+ deps,
331
+ options,
332
+ ownership,
333
+ log: deps.log,
334
+ });
335
+ return terminalToRunResult(terminal, "failed");
336
+ }
337
+ // A thrown error inside the merge flow maps to a bounded MergeFailed.
338
+ const flowError = flow.getError();
339
+ if (flowError !== undefined) {
340
+ const terminal = await sendTerminalMutationWithRetry({
341
+ kind: "fail",
342
+ send: () => httpClient.fail(job, {
343
+ error_kind: MERGE_FAILED,
344
+ error_message: secretFreeErrorMessage(flowError),
232
345
  classification: "crashed",
346
+ telemetry: observation.snapshot(),
347
+ }),
348
+ deps,
349
+ options,
350
+ ownership,
351
+ log: deps.log,
352
+ });
353
+ return terminalToRunResult(terminal, "failed");
354
+ }
355
+ const outcome = flow.getOutcome();
356
+ if (outcome === undefined) {
357
+ // Clean process result with no stored outcome — an internal invariant break;
358
+ // fail loud rather than silently reporting success.
359
+ const terminal = await sendTerminalMutationWithRetry({
360
+ kind: "fail",
361
+ send: () => httpClient.fail(job, {
362
+ error_kind: MERGE_FAILED,
363
+ error_message: "merge flow completed without an outcome",
364
+ classification: "crashed",
365
+ telemetry: observation.snapshot(),
233
366
  }),
234
367
  deps,
235
368
  options,
@@ -244,6 +377,7 @@ async function runMergeJob(job, httpClient, options, deps, ownership, seams) {
244
377
  exit_code: 0,
245
378
  classification: "clean_exit",
246
379
  result: outcome.result,
380
+ telemetry: observation.snapshot(),
247
381
  };
248
382
  const terminal = await sendTerminalMutationWithRetry({
249
383
  kind: "complete",
@@ -257,7 +391,7 @@ async function runMergeJob(job, httpClient, options, deps, ownership, seams) {
257
391
  }
258
392
  const terminal = await sendTerminalMutationWithRetry({
259
393
  kind: "fail",
260
- send: () => httpClient.fail(job, outcome.failure),
394
+ send: () => httpClient.fail(job, { ...outcome.failure, telemetry: observation.snapshot() }),
261
395
  deps,
262
396
  options,
263
397
  ownership,
@@ -10,6 +10,16 @@
10
10
  * GitHub, so no action-key idempotency protocol is needed. On success the merge
11
11
  * outcome (accepted / merge commit) is posted at `/complete`; gate advancement is
12
12
  * left entirely to the merge OBSERVER (T4/T5) — this module never advances a gate.
13
+ *
14
+ * BAPI-572: the local `gh` auth model is unchanged (no App credentials — locked
15
+ * decision). Two behaviors were hardened here:
16
+ * - Ambiguous merge failures are resolved by re-reading the merged state inside
17
+ * {@link makeLocalMergeExecutor}, so an already-merged PR at the expected head
18
+ * maps to a SUCCESS result (with the merge commit SHA), not a failure.
19
+ * - Merge-domain failures no longer collapse into one `error_kind` — they emit a
20
+ * machine-readable {@link MERGE_RETRYABLE}, {@link MERGE_CONFLICT}, or
21
+ * {@link MERGE_FAILED} `error_kind` (via {@link classifyMergeFailureErrorKind})
22
+ * so the companion re-fire lane can route retry-vs-rebase without string parsing.
13
23
  */
14
24
  import { makeLocalMergeExecutor, resolveLocalMergeMethod, } from "../conductor/local-merge.js";
15
25
  import { resolveConductorBridgeApiAccess, } from "../conductor/bridge-api-client.js";
@@ -17,6 +27,10 @@ import { secretFreeErrorMessage } from "./job-errors.js";
17
27
  function positiveIntOrNull(value) {
18
28
  return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : null;
19
29
  }
30
+ /** Read an optional non-negative integer (preserving 0), else `undefined`. */
31
+ function nonNegativeIntOrUndefined(value) {
32
+ return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : undefined;
33
+ }
20
34
  function asString(value) {
21
35
  return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
22
36
  }
@@ -40,9 +54,11 @@ export function readMergeJobPayloadFields(job) {
40
54
  ? payload.required_checks.filter((c) => typeof c === "string")
41
55
  : [];
42
56
  const actionKey = asString(payload.action_key) ?? `merge:pr-${prNumber}:${expectedHeadSha}`;
57
+ const ciWaitTimeoutMs = nonNegativeIntOrUndefined(payload.ci_wait_timeout_ms);
58
+ const ciWaitPollIntervalMs = nonNegativeIntOrUndefined(payload.ci_wait_poll_interval_ms);
43
59
  return {
44
60
  ok: true,
45
- fields: { prNumber, expectedHeadSha, method, requiredChecks, actionKey },
61
+ fields: { prNumber, expectedHeadSha, method, requiredChecks, actionKey, ciWaitTimeoutMs, ciWaitPollIntervalMs },
46
62
  };
47
63
  }
48
64
  /** Construct the {@link ConductorMergeRequest} for the local merge executor. */
@@ -97,11 +113,62 @@ export function buildMergeJobResult(response, fields) {
97
113
  }
98
114
  return result;
99
115
  }
100
- /** Convert a non-succeeded local-merge response into a bounded executor failure. */
116
+ /**
117
+ * Machine-readable merge-domain error kinds (BAPI-572). The companion re-fire
118
+ * lane routes retry-vs-rebase on these values, NOT on free text:
119
+ * - {@link MERGE_RETRYABLE}: transient gh/network/auth-refresh, `ci_not_green` —
120
+ * safe to re-fire the same merge later.
121
+ * - {@link MERGE_CONFLICT}: needs a rebase before it can merge.
122
+ * - {@link MERGE_FAILED}: deterministic other failure (bad state, missing input).
123
+ */
124
+ export const MERGE_RETRYABLE = "MergeRetryable";
125
+ export const MERGE_CONFLICT = "MergeConflict";
126
+ export const MERGE_FAILED = "MergeFailed";
127
+ /** Reasons whose failure is transient/infrastructure and safe to retry. */
128
+ const RETRYABLE_MERGE_REASONS = new Set([
129
+ "gh_pr_view_timeout",
130
+ "gh_pr_view_failed",
131
+ "gh_pr_view_unparseable",
132
+ "ci_poll_failed",
133
+ "ci_not_green",
134
+ "gh_merge_timeout",
135
+ "gh_merge_failed",
136
+ "merge_aborted",
137
+ ]);
138
+ /**
139
+ * Classify a non-succeeded merge response into a machine-readable `error_kind`.
140
+ * A `merge.conflict` ledger event (or a `gh_merge_conflict` reason) is a conflict;
141
+ * transient/infrastructure reasons are retryable; everything else — including a
142
+ * missing/malformed reason — is a deterministic {@link MERGE_FAILED}.
143
+ *
144
+ * BAPI-577 Part A: the CI re-poll now emits distinct `ci_poll_*` reasons
145
+ * (`ci_poll_timeout`, `ci_poll_unauthorized`, `ci_poll_http_<status>`, …) instead of a
146
+ * single `ci_poll_failed`. All of them are transient infrastructure reads, so any
147
+ * `ci_poll_`-prefixed reason is retryable — a prefix match keeps unbounded HTTP-status
148
+ * variants covered so a distinct reason never accidentally downgrades to
149
+ * {@link MERGE_FAILED}. Conflict classification is checked first so it is never masked.
150
+ */
151
+ export function classifyMergeFailureErrorKind(response) {
152
+ const hasConflictEvent = response.ledger_events.some((ev) => ev.type === "merge.conflict");
153
+ const reason = asString(response.reason ?? undefined);
154
+ if (hasConflictEvent || reason === "gh_merge_conflict")
155
+ return MERGE_CONFLICT;
156
+ if (reason && (reason.startsWith("ci_poll_") || RETRYABLE_MERGE_REASONS.has(reason))) {
157
+ return MERGE_RETRYABLE;
158
+ }
159
+ return MERGE_FAILED;
160
+ }
161
+ /**
162
+ * Convert a non-succeeded local-merge response into a bounded, secret-free
163
+ * executor failure. `error_kind` is machine-readable (retryable/conflict/failed);
164
+ * `error_message` includes only the sanitized merge status and reason (never raw
165
+ * `gh` output). `classification` stays within the executor's vocabulary; the
166
+ * `error_kind` is the routing signal.
167
+ */
101
168
  export function buildMergeJobFailure(response) {
102
169
  const reason = asString(response.reason ?? undefined) ?? response.status;
103
170
  return {
104
- error_kind: "MergeFailed",
171
+ error_kind: classifyMergeFailureErrorKind(response),
105
172
  error_message: secretFreeErrorMessage(new Error(`local merge ${response.status}: ${reason}`)),
106
173
  classification: "crashed",
107
174
  };
@@ -126,7 +193,11 @@ export async function runExecutorMergeJob(job, seams) {
126
193
  const fields = resolution.fields;
127
194
  const request = buildConductorMergeRequestForExecutorJob(fields, seams.access.repoName);
128
195
  const make = seams.makeExecutor ?? makeLocalMergeExecutor;
129
- const executor = make({ method: fields.method }, seams.localMergeDeps);
196
+ const executor = make({
197
+ method: fields.method,
198
+ ciWaitTimeoutMs: fields.ciWaitTimeoutMs,
199
+ ciWaitPollIntervalMs: fields.ciWaitPollIntervalMs,
200
+ }, seams.localMergeDeps);
130
201
  let response;
131
202
  try {
132
203
  response = await executor(seams.access, request);
@@ -146,10 +217,13 @@ export async function runExecutorMergeJob(job, seams) {
146
217
  }
147
218
  return { ok: false, failure: buildMergeJobFailure(response) };
148
219
  }
149
- /** Build the default local-merge deps for production wiring (no real I/O here). */
150
- export function buildDefaultMergeLocalDeps(deps) {
151
- // Local `gh` auth lives in the ambient environment; makeLocalMergeExecutor
152
- // overlays it and pins the no-noise/non-interactive flags. The App token is
153
- // never placed here.
154
- return { env: deps.env };
220
+ /**
221
+ * Build the default local-merge deps for production wiring (no real I/O here).
222
+ * An optional {@link AbortSignal} is threaded through so the job-runner's overall
223
+ * merge timeout can cancel in-flight local `gh` subprocess work. The signal is
224
+ * NOT a credential and never reaches `gh` argv/env — the local `gh` auth model is
225
+ * unchanged (no App token is ever placed here).
226
+ */
227
+ export function buildDefaultMergeLocalDeps(deps, signal) {
228
+ return { env: deps.env, signal };
155
229
  }
@@ -7,19 +7,41 @@
7
7
  * advisory pre-push suite) and returns before it completes. Without this check
8
8
  * the executor still reports `clean_exit`/`succeeded`, and the gap is only ever
9
9
  * caught by the reconciler's 3h watchdog. `validateWorkerFinalization` converts
10
- * that condition into an explicit executor failure instead, using a local
11
- * `git ls-remote` check (no Bridge API credentials, no conductor identity).
10
+ * that condition into an explicit executor failure instead, using an authoritative
11
+ * local `git ls-remote` check (no Bridge API credentials, no conductor identity)
12
+ * that reads the real origin tip — never a stale local `origin/<branch>` tracking
13
+ * ref. That lookup is optionally repeated a few times with a short bounded delay
14
+ * before reporting `WorkerFinalizationMissingRemoteBranchAndPr`, so a recovery
15
+ * job whose push has landed but whose remote visibility settles just after the
16
+ * worker exits is not spuriously failed (BAPI-566 Bug A).
12
17
  *
13
18
  * For `resume`/`remediate`/`ci_fix`/`rebase`, the branch is fetched from origin
14
19
  * BEFORE the worker starts (worktree.ts's recovery reuse path, resume-pre-spawn's
15
20
  * "recreate ONLY from the pushed branch" path) — so the ref existing on origin is
16
21
  * not proof THIS session's commit landed; a stale pre-existing branch would pass
17
- * an existence-only check. The check instead compares the origin branch's tip SHA
18
- * against the worker's own HEAD commit (`headSha`, from git telemetry) whenever
19
- * that comparison is available.
22
+ * an existence-only check. The check instead compares the authoritative origin
23
+ * branch tip SHA against the worker's own HEAD commit (`headSha`, from git
24
+ * telemetry) whenever that comparison is available.
20
25
  */
21
26
  import { secretFreeErrorMessage, WorkerFinalizationMissingRemoteBranchAndPr } from "./job-errors.js";
22
27
  import { isImplementationStyleJobType } from "./job-types.js";
28
+ /** Bounded settling re-check defaults for the authoritative origin-tip lookup. */
29
+ const DEFAULT_ORIGIN_FINALIZATION_ATTEMPTS = 3;
30
+ const DEFAULT_ORIGIN_FINALIZATION_RETRY_DELAY_MS = 500;
31
+ /** Real-timer sleep; overridable via the `sleep` seam for deterministic tests. */
32
+ function sleepMs(ms) {
33
+ return new Promise((resolve) => setTimeout(resolve, ms));
34
+ }
35
+ function normalizeAttempts(value) {
36
+ return typeof value === "number" && Number.isInteger(value) && value > 0
37
+ ? value
38
+ : DEFAULT_ORIGIN_FINALIZATION_ATTEMPTS;
39
+ }
40
+ function normalizeRetryDelay(value) {
41
+ return typeof value === "number" && Number.isFinite(value) && value >= 0
42
+ ? Math.floor(value)
43
+ : DEFAULT_ORIGIN_FINALIZATION_RETRY_DELAY_MS;
44
+ }
23
45
  function extractPrUrl(result) {
24
46
  const raw = result.pr_url;
25
47
  return typeof raw === "string" && raw.trim().length > 0 ? raw.trim() : undefined;
@@ -28,13 +50,66 @@ function extractPrUrl(result) {
28
50
  function normalizeBranchRef(branch) {
29
51
  return branch.startsWith("refs/heads/") ? branch : `refs/heads/${branch}`;
30
52
  }
31
- /** Resolve the origin branch's current tip SHA, or `null` if the ref is absent. */
32
- async function resolveOriginBranchSha(runCommand, worktreePath, branch) {
33
- const result = await runCommand("git", ["ls-remote", "--exit-code", "--heads", "origin", normalizeBranchRef(branch)], { cwd: worktreePath });
53
+ /**
54
+ * Scan `git ls-remote` stdout and return the SHA for EXACTLY `expectedRef`.
55
+ * `ls-remote` lines are `<sha>\t<ref>`; only an exact ref match counts, so a
56
+ * prefix/suffix/child ref (`…-old`, `…/retry`) is never accepted. The returned
57
+ * SHA is trimmed and lower-cased so callers compare against a normalized worker
58
+ * HEAD. Raw stderr and credentials are never touched here.
59
+ */
60
+ function parseLsRemoteHeadSha(stdout, expectedRef) {
61
+ for (const line of stdout.split("\n")) {
62
+ const trimmed = line.trim();
63
+ if (trimmed.length === 0)
64
+ continue;
65
+ const parts = trimmed.split(/\s+/);
66
+ if (parts.length < 2)
67
+ continue;
68
+ const [sha, ref] = parts;
69
+ if (ref === expectedRef) {
70
+ const normalized = sha.trim().toLowerCase();
71
+ return normalized.length > 0 ? normalized : null;
72
+ }
73
+ }
74
+ return null;
75
+ }
76
+ /**
77
+ * Resolve the origin branch's authoritative current tip SHA (normalized), or
78
+ * `null` if the ref is absent. Uses `git ls-remote` against the exact normalized
79
+ * ref — never a local `origin/<branch>` remote-tracking ref, which can be stale
80
+ * on a recovery worktree. A non-zero exit is treated as "branch not found"
81
+ * without copying raw stderr into diagnostics.
82
+ */
83
+ export async function resolveOriginBranchSha(runCommand, worktreePath, branch) {
84
+ const normalizedRef = normalizeBranchRef(branch);
85
+ const result = await runCommand("git", ["ls-remote", "--exit-code", "--heads", "origin", normalizedRef], { cwd: worktreePath });
34
86
  if (result.exitCode !== 0)
35
87
  return null;
36
- const sha = result.stdout.trim().split(/\s+/)[0];
37
- return sha && sha.length > 0 ? sha : null;
88
+ return parseLsRemoteHeadSha(result.stdout, normalizedRef);
89
+ }
90
+ /**
91
+ * Repeatedly resolve the authoritative origin tip until it either satisfies
92
+ * finalization or the bounded retry budget is exhausted. Stops early (no further
93
+ * lookups) when the origin tip matches the worker HEAD, or when the branch exists
94
+ * and the worker HEAD is unavailable (degraded telemetry, existence-only). Retries
95
+ * when the tip is `null` (branch not yet visible) or, with a known HEAD, does not
96
+ * yet match. Returns the LAST authoritative observation — the caller decides
97
+ * loud-failure from that final value, so true negatives are preserved.
98
+ */
99
+ async function resolveOriginBranchShaForFinalization(runCommand, worktreePath, branch, trimmedHeadSha, attempts, retryDelayMs, sleep) {
100
+ const total = Math.max(1, attempts);
101
+ let remoteSha = null;
102
+ for (let attempt = 0; attempt < total; attempt++) {
103
+ remoteSha = await resolveOriginBranchSha(runCommand, worktreePath, branch);
104
+ if (remoteSha !== null) {
105
+ // Existence-only success when HEAD is unknown; exact-match success otherwise.
106
+ if (!trimmedHeadSha || remoteSha === trimmedHeadSha)
107
+ return remoteSha;
108
+ }
109
+ if (attempt < total - 1)
110
+ await sleep(retryDelayMs);
111
+ }
112
+ return remoteSha;
38
113
  }
39
114
  function missingBranchAndPrFailure(job, detail) {
40
115
  const label = job.ticket_key ? `${job.ticket_key} (job ${job.id})` : `job ${job.id}`;
@@ -64,9 +139,21 @@ export async function validateWorkerFinalization(input) {
64
139
  failure: missingBranchAndPrFailure(job, "produced no PR URL and no expected branch to verify on origin"),
65
140
  };
66
141
  }
142
+ // Recovery/resume jobs fetch `origin/<branch>` before the worker even starts,
143
+ // so the ref merely existing is not proof this session's commit landed. When
144
+ // the worker's own HEAD is known, require the authoritative origin tip to match
145
+ // it exactly — a stale pre-existing branch (this session's push never happened)
146
+ // fails loud instead of passing. When HEAD is unresolved (degraded telemetry),
147
+ // fall back to the existence-only check rather than inventing a new failure
148
+ // mode. The authoritative lookup is retried a few times so a push that settles
149
+ // just after the worker exits still finalizes (BAPI-566 Bug A).
150
+ const trimmedHeadSha = typeof headSha === "string" ? headSha.trim().toLowerCase() : "";
151
+ const attempts = normalizeAttempts(input.originResolveAttempts);
152
+ const retryDelayMs = normalizeRetryDelay(input.originResolveRetryDelayMs);
153
+ const sleep = input.sleep ?? sleepMs;
67
154
  let remoteSha;
68
155
  try {
69
- remoteSha = await resolveOriginBranchSha(runCommand, worktreePath, trimmedBranch);
156
+ remoteSha = await resolveOriginBranchShaForFinalization(runCommand, worktreePath, trimmedBranch, trimmedHeadSha, attempts, retryDelayMs, sleep);
70
157
  }
71
158
  catch (err) {
72
159
  return {
@@ -80,13 +167,6 @@ export async function validateWorkerFinalization(input) {
80
167
  failure: missingBranchAndPrFailure(job, `produced no PR URL and branch '${trimmedBranch}' is not on origin`),
81
168
  };
82
169
  }
83
- // Recovery/resume jobs fetch `origin/<branch>` before the worker even starts,
84
- // so the ref merely existing is not proof this session's commit landed. When
85
- // the worker's own HEAD is known, require the origin tip to match it exactly —
86
- // a stale pre-existing branch (this session's push never happened) fails loud
87
- // instead of passing. When HEAD is unresolved (degraded telemetry), fall back
88
- // to the existence-only check rather than inventing a new failure mode.
89
- const trimmedHeadSha = typeof headSha === "string" ? headSha.trim() : "";
90
170
  if (trimmedHeadSha && remoteSha !== trimmedHeadSha) {
91
171
  return {
92
172
  ok: false,