@bridge_gpt/mcp-server 0.2.42 → 0.2.44

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.
@@ -34,8 +34,29 @@
34
34
  * the terminal-disposition classifier (`terminal_disposition.py`) can route it as
35
35
  * a resumable, deterministic cause instead of laundering it into a generic crash
36
36
  * that gets retried as a transient upstream failure.
37
+ *
38
+ * BAPI-862: a matching pushed head is NECESSARY but not SUFFICIENT. The BAPI-837
39
+ * run's finding F4 had BAPI-850's implement job push three commits, report plain
40
+ * success, and never open a pull request — every check above passed, so the gap
41
+ * only surfaced hours later as an implement gate polling `pr_not_attached`
42
+ * forever. Finalization therefore also requires an authoritative pull request for
43
+ * the branch: visible, carrying a usable URL, carrying a NON-BLANK description
44
+ * (BAPI-824 folded in — the VCS API never applies `.github/PULL_REQUEST_TEMPLATE.md`,
45
+ * so an omitted body opens a PR with no description at all), and — when the
46
+ * provider reports it — sitting at the same pushed head. Anything short of that
47
+ * is `WorkerFinalizationPrNotAttached`, whose guidance names attachment and never
48
+ * suggests removing or re-writing the branch: the commits ARE the completed
49
+ * implementation, and both of the stale-worktree guard's historical remedies
50
+ * would have destroyed them.
51
+ *
52
+ * This check is deliberately fail-CLOSED, unlike the BAPI-586 base check it now
53
+ * shares a lookup with. "The PR could not be verified" and "there is no PR" are
54
+ * the same thing from the contract's point of view: neither is evidence that the
55
+ * work was published, and laundering either into success is precisely the defect
56
+ * this ticket removes. The reconciler stays the decision-maker — the executor
57
+ * only reports the observation (R14 rule 3).
37
58
  */
38
- import { secretFreeErrorMessage, WorkerFinalizationMissingRemoteBranchAndPr, WorkerFinalizationPrBaseMismatch, WorkerFinalizationSavedButUnfinalized, } from "./job-errors.js";
59
+ import { secretFreeErrorMessage, WorkerFinalizationMissingRemoteBranchAndPr, WorkerFinalizationPrBaseMismatch, WorkerFinalizationPrNotAttached, WorkerFinalizationSavedButUnfinalized, } from "./job-errors.js";
39
60
  import { isImplementationStyleJobType } from "./job-types.js";
40
61
  /** Bounded settling re-check defaults for the authoritative origin-tip lookup. */
41
62
  const DEFAULT_ORIGIN_FINALIZATION_ATTEMPTS = 3;
@@ -115,51 +136,73 @@ async function resolveOriginBranchShaForFinalization(runCommand, worktreePath, b
115
136
  remoteSha = await resolveOriginBranchSha(runCommand, worktreePath, branch);
116
137
  if (remoteSha !== null) {
117
138
  // Existence-only success when HEAD is unknown; exact-match success otherwise.
118
- if (!trimmedHeadSha || remoteSha === trimmedHeadSha)
119
- return remoteSha;
139
+ if (!trimmedHeadSha || remoteSha === trimmedHeadSha) {
140
+ return { sha: remoteSha, attemptsUsed: attempt + 1 };
141
+ }
120
142
  }
121
143
  if (attempt < total - 1)
122
144
  await sleep(retryDelayMs);
123
145
  }
124
- return remoteSha;
146
+ // `attemptsUsed` is reported even on failure so the caller can tell a
147
+ // first-try answer from one that needed the settling window (BAPI-862).
148
+ return { sha: remoteSha, attemptsUsed: total };
149
+ }
150
+ /** The bounded, public projection finalization asks the provider for. */
151
+ const PR_VIEW_JSON_FIELDS = "number,url,baseRefName,body,headRefOid";
152
+ /** Trim a string field to a non-empty value, or null. Never throws on odd JSON. */
153
+ function nonBlankString(value) {
154
+ if (typeof value !== "string")
155
+ return null;
156
+ const trimmed = value.trim();
157
+ return trimmed.length > 0 ? trimmed : null;
125
158
  }
159
+ /** The "no PR observed" reading, used for every unavailability. */
160
+ const PR_LOOKUP_ABSENT = {
161
+ found: false,
162
+ baseRef: null,
163
+ url: null,
164
+ body: null,
165
+ headRefOid: null,
166
+ };
126
167
  /**
127
- * BAPI-586: resolve the base branch of the PR for `branch` via `gh pr view`.
128
- * Fail-open on any unavailability (gh missing, non-zero exit, unparseable JSON)
129
- * `{ found: false }`, so a wrong-base guard never blocks a job on transport
130
- * failure; the reconciliation observation (epic-runtime) is the second layer.
131
- * When a PR IS visible, `baseRef` is the trimmed `baseRefName`, or null if that
132
- * field is absent/non-string (which the caller treats as a fail-closed mismatch —
133
- * missing base evidence is never a match). Never copies raw stderr.
168
+ * Resolve the pull request for `branch` via a single `gh pr view`. Parses ONLY
169
+ * the bounded public fields in {@link PR_VIEW_JSON_FIELDS}; every failure mode
170
+ * collapses to {@link PR_LOOKUP_ABSENT} without copying raw stderr, an
171
+ * authenticated remote, or the command line into anything the caller can render.
134
172
  */
135
- async function resolvePrBaseRef(runCommand, worktreePath, branch) {
173
+ async function resolvePrForFinalization(runCommand, worktreePath, branch) {
136
174
  const args = ["pr", "view"];
137
175
  if (branch)
138
176
  args.push(branch);
139
- args.push("--json", "number,baseRefName");
177
+ args.push("--json", PR_VIEW_JSON_FIELDS);
140
178
  let result;
141
179
  try {
142
180
  result = await runCommand("gh", args, { cwd: worktreePath });
143
181
  }
144
182
  catch {
145
- return { found: false, baseRef: null };
183
+ return PR_LOOKUP_ABSENT;
146
184
  }
147
185
  if (result.exitCode !== 0)
148
- return { found: false, baseRef: null };
186
+ return PR_LOOKUP_ABSENT;
149
187
  let parsed;
150
188
  try {
151
189
  parsed = JSON.parse(result.stdout);
152
190
  }
153
191
  catch {
154
- return { found: false, baseRef: null };
192
+ return PR_LOOKUP_ABSENT;
155
193
  }
156
194
  if (!parsed || typeof parsed !== "object")
157
- return { found: false, baseRef: null };
195
+ return PR_LOOKUP_ABSENT;
158
196
  const obj = parsed;
159
- const prNumber = typeof obj.number === "number" ? obj.number : undefined;
160
- const rawBase = obj.baseRefName;
161
- const baseRef = typeof rawBase === "string" && rawBase.trim().length > 0 ? rawBase.trim() : null;
162
- return { found: true, prNumber, baseRef };
197
+ const rawHead = nonBlankString(obj.headRefOid);
198
+ return {
199
+ found: true,
200
+ prNumber: typeof obj.number === "number" ? obj.number : undefined,
201
+ baseRef: nonBlankString(obj.baseRefName),
202
+ url: nonBlankString(obj.url),
203
+ body: nonBlankString(obj.body),
204
+ headRefOid: rawHead === null ? null : rawHead.toLowerCase(),
205
+ };
163
206
  }
164
207
  /**
165
208
  * BAPI-586: bounded, secret-free failure for a PR that targets a branch other
@@ -183,6 +226,102 @@ function prBaseMismatchFailure(job, prNumber, expectedBase, actualBase) {
183
226
  classification: "crashed",
184
227
  };
185
228
  }
229
+ /**
230
+ * BAPI-586's base check over one PR observation, or `null` when it does not fire.
231
+ *
232
+ * Extracted (BAPI-862) so it can run against a REFRESHED observation as well as
233
+ * the first one. Semantics are unchanged: no expected base, or no PR visible,
234
+ * means no verdict at all (fail-open on unavailability), while a visible PR with
235
+ * absent or malformed base data is a fail-CLOSED mismatch.
236
+ */
237
+ function evaluatePrBase(job, pr, expectedBase) {
238
+ if (!expectedBase || !pr.found)
239
+ return null;
240
+ if (pr.baseRef === null) {
241
+ return prBaseMismatchFailure(job, pr.prNumber, expectedBase, "(unresolved)");
242
+ }
243
+ if (pr.baseRef !== expectedBase) {
244
+ return prBaseMismatchFailure(job, pr.prNumber, expectedBase, pr.baseRef);
245
+ }
246
+ return null;
247
+ }
248
+ /**
249
+ * Evaluate one PR observation against the implement-completion contract.
250
+ *
251
+ * Reads STRUCTURED fields only — never a rendered message, never worker prose.
252
+ * `workerHeadSha` is the already-normalized worker HEAD that the authoritative
253
+ * origin tip has ALREADY been proven to match, so a `headRefOid` disagreement
254
+ * means the provider has not yet reflected the push, not that the push is in
255
+ * doubt.
256
+ */
257
+ function evaluatePrContract(pr, workerHeadSha) {
258
+ if (!pr.found) {
259
+ return {
260
+ ok: false,
261
+ detail: "no pull request could be observed for that branch",
262
+ retryable: true,
263
+ };
264
+ }
265
+ if (pr.url === null) {
266
+ return {
267
+ ok: false,
268
+ detail: "its pull request reports no usable URL",
269
+ retryable: false,
270
+ };
271
+ }
272
+ if (pr.body === null) {
273
+ // BAPI-824, folded in here. The VCS API never applies the repository's
274
+ // PULL_REQUEST_TEMPLATE.md, so a pull request opened without a composed body
275
+ // has NO description at all. The server-side `create_pull_request` contract
276
+ // already rejects a blank body with a 422; this second layer catches a pull
277
+ // request opened some other way — by hand, by an older client, or by a future
278
+ // recovery path — before it can satisfy the success contract.
279
+ return {
280
+ ok: false,
281
+ detail: "its pull request has an empty description",
282
+ retryable: false,
283
+ };
284
+ }
285
+ if (workerHeadSha && pr.headRefOid !== null && pr.headRefOid !== workerHeadSha) {
286
+ return {
287
+ ok: false,
288
+ detail: `its pull request is still at ${pr.headRefOid.slice(0, 12)} and does not yet carry that commit`,
289
+ retryable: true,
290
+ };
291
+ }
292
+ return { ok: true };
293
+ }
294
+ /**
295
+ * BAPI-862: the pushed-but-unattached failure.
296
+ *
297
+ * The message is built ONLY from public identifiers — ticket key, job id, branch,
298
+ * truncated SHAs, the run base — plus a fixed detail phrase, so no provider
299
+ * output, URL, or credential can ride along. Two properties are load-bearing
300
+ * rather than stylistic:
301
+ *
302
+ * 1. It names `pr_not_attached`, the reason code the implement gate already uses,
303
+ * so the executor failure and the reconciler's observation are searchable as
304
+ * one state instead of two vocabularies for the same thing.
305
+ * 2. It recommends attachment and says plainly that removing or re-writing the
306
+ * branch destroys completed work. In the F4 incident the pushed branch WAS the
307
+ * finished implementation, and both remedies the stale-worktree guard offered
308
+ * would have thrown it away.
309
+ */
310
+ function prNotAttachedFailure(job, branch, workerHeadSha, expectedBase, detail) {
311
+ const label = job.ticket_key ? `${job.ticket_key} (job ${job.id})` : `job ${job.id}`;
312
+ const headLabel = workerHeadSha ? ` at ${workerHeadSha.slice(0, 12)}` : "";
313
+ const baseLabel = expectedBase ? ` targeting '${expectedBase}'` : "";
314
+ return {
315
+ error_kind: WorkerFinalizationPrNotAttached,
316
+ error_message: `${label} exited cleanly and its HEAD${headLabel} is durable on origin branch '${branch}', ` +
317
+ `but ${detail}. This is the pr_not_attached state: the implementation is COMPLETE and ` +
318
+ `published, only its pull request is missing. Recovery: open or attach a pull request for ` +
319
+ `'${branch}'${baseLabel} with a non-empty description. Removing or re-writing that branch ` +
320
+ `would destroy finished work — the commits on it are the implementation.`,
321
+ classification: "crashed",
322
+ last_commit_sha: workerHeadSha || undefined,
323
+ };
324
+ }
186
325
  /**
187
326
  * `detail` describes a state with NO evidence any work reached origin (branch
188
327
  * unresolved, branch absent from origin, or the origin lookup itself failed).
@@ -224,47 +363,26 @@ function savedButUnfinalizedFailure(job, branch, originSha, workerHeadSha) {
224
363
  }
225
364
  /**
226
365
  * Validate that an implementation-style job's clean exit actually published its
227
- * work to origin. Non-implementation-style jobs (verdict jobs) bypass the git
228
- * check entirely. A `pr_url` on the completion result is NOT proof of a push
229
- * (BAPI-762) — the authoritative origin-tip-vs-HEAD comparison below always runs,
230
- * so an unpushed local commit is detected whether or not a pull request exists.
366
+ * work to origin AND left an attached pull request. Non-implementation-style jobs
367
+ * (verdict jobs) bypass both checks entirely. A `pr_url` on the completion result
368
+ * is NOT proof of a push (BAPI-762) — the authoritative origin-tip-vs-HEAD
369
+ * comparison below always runs, so an unpushed local commit is detected whether
370
+ * or not the worker claims a pull request; and symmetrically, a pushed head is
371
+ * not proof of a pull request (BAPI-862), so the attachment contract runs after
372
+ * it. Both halves read only durable provider/git facts the executor observed
373
+ * itself.
231
374
  */
232
375
  export async function validateWorkerFinalization(input) {
233
376
  const { job, branch, worktreePath, result, runCommand, headSha } = input;
234
377
  if (!isImplementationStyleJobType(job.job_type)) {
235
378
  return { ok: true };
236
379
  }
237
- // BAPI-586: a wrong-base PR (opened against a dependency's feature branch or the
238
- // repo default instead of the run base) never fires `claude-review.yml` and
239
- // strands the ticket at `code_review`. When the run base is known, query the
240
- // PR's `baseRefName` and fail loud on any mismatch BEFORE accepting the clean
241
- // exit — even when a `pr_url` is present (a PR existing is not proof of a
242
- // correct base). Fail-open when no PR is visible (gh unavailable / no PR):
243
- // reconciliation is the backstop. A PR with absent/malformed base data is a
244
- // fail-CLOSED mismatch — missing base evidence is never treated as a match.
245
- const expectedBase = typeof input.expectedBaseBranch === "string" ? input.expectedBaseBranch.trim() : "";
246
- if (expectedBase) {
247
- const branchForPr = typeof branch === "string" ? branch.trim() : "";
248
- const prBase = await resolvePrBaseRef(runCommand, worktreePath, branchForPr);
249
- if (prBase.found) {
250
- if (prBase.baseRef === null) {
251
- return {
252
- ok: false,
253
- failure: prBaseMismatchFailure(job, prBase.prNumber, expectedBase, "(unresolved)"),
254
- };
255
- }
256
- if (prBase.baseRef !== expectedBase) {
257
- return {
258
- ok: false,
259
- failure: prBaseMismatchFailure(job, prBase.prNumber, expectedBase, prBase.baseRef),
260
- };
261
- }
262
- }
263
- }
264
380
  const hasPrUrl = Boolean(extractPrUrl(result));
265
381
  // BAPI-762: `branch`/`headSha` are normalized once here and reused through the
266
382
  // rest of the check — including by the diagnostics below — instead of being
267
- // re-derived at each failure site.
383
+ // re-derived at each failure site. This purely local resolution runs FIRST
384
+ // (BAPI-862) so an unresolvable branch is reported without issuing a single
385
+ // provider or git query against the wrong subject.
268
386
  const trimmedBranch = typeof branch === "string" ? branch.trim() : "";
269
387
  if (!trimmedBranch) {
270
388
  return {
@@ -272,6 +390,27 @@ export async function validateWorkerFinalization(input) {
272
390
  failure: missingBranchAndPrFailure(job, "no expected branch to verify on origin", hasPrUrl),
273
391
  };
274
392
  }
393
+ // BAPI-862 folded the two PR-reading guards onto ONE `gh pr view`: the base
394
+ // check below and the attachment contract at the end of this function read the
395
+ // same observation. The lookup runs for every implementation-style job now —
396
+ // not only when a run base is known — because the attachment contract is
397
+ // unconditional. Their dispositions still differ on purpose (see `PrLookup`),
398
+ // and the ORDER is unchanged: a wrong-base PR is still reported before any
399
+ // origin lookup, exactly as BAPI-586 specified.
400
+ let pr = await resolvePrForFinalization(runCommand, worktreePath, trimmedBranch);
401
+ // BAPI-586: a wrong-base PR (opened against a dependency's feature branch or the
402
+ // repo default instead of the run base) never fires `claude-review.yml` and
403
+ // strands the ticket at `code_review`. When the run base is known, fail loud on
404
+ // any mismatch BEFORE the origin lookup below — even when a `pr_url` is present
405
+ // (a PR existing is not proof of a correct base). Still fail-OPEN when no PR is
406
+ // visible: reconciliation is the backstop for that, and BAPI-862's attachment
407
+ // contract at the end of this function is what now refuses to call it a success.
408
+ // A PR with absent/malformed base data stays a fail-CLOSED mismatch — missing
409
+ // base evidence is never treated as a match.
410
+ const expectedBase = typeof input.expectedBaseBranch === "string" ? input.expectedBaseBranch.trim() : "";
411
+ const baseVerdict = evaluatePrBase(job, pr, expectedBase);
412
+ if (baseVerdict)
413
+ return { ok: false, failure: baseVerdict };
275
414
  // Recovery/resume jobs fetch `origin/<branch>` before the worker even starts,
276
415
  // so the ref merely existing is not proof this session's commit landed. When
277
416
  // the worker's own HEAD is known, require the authoritative origin tip to match
@@ -286,9 +425,9 @@ export async function validateWorkerFinalization(input) {
286
425
  const attempts = normalizeAttempts(input.originResolveAttempts);
287
426
  const retryDelayMs = normalizeRetryDelay(input.originResolveRetryDelayMs);
288
427
  const sleep = input.sleep ?? sleepMs;
289
- let remoteSha;
428
+ let origin;
290
429
  try {
291
- remoteSha = await resolveOriginBranchShaForFinalization(runCommand, worktreePath, trimmedBranch, trimmedHeadSha, attempts, retryDelayMs, sleep);
430
+ origin = await resolveOriginBranchShaForFinalization(runCommand, worktreePath, trimmedBranch, trimmedHeadSha, attempts, retryDelayMs, sleep);
292
431
  }
293
432
  catch (err) {
294
433
  return {
@@ -296,6 +435,7 @@ export async function validateWorkerFinalization(input) {
296
435
  failure: missingBranchAndPrFailure(job, `branch '${trimmedBranch}' could not be verified on origin: ${secretFreeErrorMessage(err)}`, hasPrUrl),
297
436
  };
298
437
  }
438
+ const remoteSha = origin.sha;
299
439
  if (remoteSha === null) {
300
440
  return {
301
441
  ok: false,
@@ -314,5 +454,42 @@ export async function validateWorkerFinalization(input) {
314
454
  failure: savedButUnfinalizedFailure(job, trimmedBranch, remoteSha, trimmedHeadSha),
315
455
  };
316
456
  }
317
- return { ok: true };
457
+ // BAPI-862: the work is provably ON ORIGIN. That was the whole of the success
458
+ // contract until finding F4 showed it was not enough — an implement job can
459
+ // satisfy everything above and still leave nothing for the reconciler to bind
460
+ // to. The last obligation is an authoritative, usable pull request.
461
+ //
462
+ // Placed LAST on purpose: an unpushed or mismatched head is a different and
463
+ // more urgent failure, and reporting "no pull request" for work that never
464
+ // reached origin would send an operator to the wrong remedy. So every
465
+ // pre-existing failure keeps precedence and this one only fires once the push
466
+ // is proven.
467
+ // The snapshot above was taken BEFORE the origin lookup, because BAPI-586
468
+ // requires a wrong-base PR to be reported ahead of any git call. When the
469
+ // origin lookup then needed the settling window, that snapshot predates the
470
+ // very push we just confirmed and is stale by construction — so refresh it
471
+ // once, for free, rather than making the contract's own retry budget spend its
472
+ // first cycle recovering from known staleness. The refreshed observation is
473
+ // re-checked against the base too: if the stale snapshot saw no PR at all, the
474
+ // base check fail-opened on it, and the PR that has since appeared must not
475
+ // reach success without that check.
476
+ if (origin.attemptsUsed > 1) {
477
+ pr = await resolvePrForFinalization(runCommand, worktreePath, trimmedBranch);
478
+ const refreshedBaseVerdict = evaluatePrBase(job, pr, expectedBase);
479
+ if (refreshedBaseVerdict)
480
+ return { ok: false, failure: refreshedBaseVerdict };
481
+ }
482
+ let contract = evaluatePrContract(pr, trimmedHeadSha);
483
+ for (let attempt = 1; attempt < attempts && !contract.ok && contract.retryable; attempt++) {
484
+ await sleep(retryDelayMs);
485
+ pr = await resolvePrForFinalization(runCommand, worktreePath, trimmedBranch);
486
+ contract = evaluatePrContract(pr, trimmedHeadSha);
487
+ }
488
+ if (!contract.ok) {
489
+ return {
490
+ ok: false,
491
+ failure: prNotAttachedFailure(job, trimmedBranch, trimmedHeadSha, expectedBase, contract.detail),
492
+ };
493
+ }
494
+ return { ok: true, prUrl: pr.url ?? undefined, prNumber: pr.prNumber };
318
495
  }
@@ -183,7 +183,14 @@ export async function ensureExecutorWorktree(job, options, deps, policy = {}) {
183
183
  };
184
184
  }
185
185
  const baseSha = resolvedBase.base_sha;
186
- const row = await createWorktreeForTicket(toWorktreeCoreDeps(deps), key, { [key]: branch }, options.worktrunkBinary, baseSha, guardStaleWorktree, { alignExistingBranchTo: baseSha, verifyHeadMatches: baseSha });
186
+ const row = await createWorktreeForTicket(toWorktreeCoreDeps(deps), key, { [key]: branch }, options.worktrunkBinary, baseSha, guardStaleWorktree, {
187
+ alignExistingBranchTo: baseSha,
188
+ verifyHeadMatches: baseSha,
189
+ // BAPI-862: the guard is ON for this fresh-dispatch path, and this is the
190
+ // path finding F4 hit — a re-dispatched implement whose branch already
191
+ // carried the previous, successful attempt's pushed commits.
192
+ staleBranchClassification: policy.staleBranchClassification,
193
+ });
187
194
  if (row.status === "created" && typeof row.path === "string") {
188
195
  // BAPI-731: `baseSha` is already the pinned immutable commit this fresh
189
196
  // worktree was cut from and verified against (`verifyHeadMatches`), so it is