@bridge_gpt/mcp-server 0.2.66 → 0.2.67

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 (100) hide show
  1. package/README.md +252 -77
  2. package/build/agent-capabilities/probes.js +15 -5
  3. package/build/agent-launchers/claude-executor-adapter.js +43 -16
  4. package/build/agent-launchers/executor-adapter.js +7 -4
  5. package/build/agents.generated.js +1 -1
  6. package/build/bounded-wait.js +67 -10
  7. package/build/commands.generated.js +11 -11
  8. package/build/conductor/bridge-api-client.js +143 -1
  9. package/build/conductor/bring-up-facts.js +1 -1
  10. package/build/conductor/doctor.js +3 -3
  11. package/build/conductor/epic-implementer-cli.js +22 -6
  12. package/build/conductor/errors.js +4 -3
  13. package/build/conductor/guided-repair.js +6 -2
  14. package/build/conductor/hook-runtime-qualification.js +1 -1
  15. package/build/conductor/merge-execution-contract.js +27 -8
  16. package/build/conductor/merge-execution-eligibility.js +3 -2
  17. package/build/conductor/pr-ci-producer.js +1 -1
  18. package/build/conductor/readiness-cli.js +18 -2
  19. package/build/conductor/readiness-sections.js +41 -6
  20. package/build/conductor/readiness.js +181 -29
  21. package/build/conductor/recovery-cli.js +200 -8
  22. package/build/conductor/recovery-operations.js +69 -3
  23. package/build/conductor/repair-contract.js +54 -3
  24. package/build/conductor/repository-workflow-remedy.js +125 -0
  25. package/build/conductor/tools.js +2 -2
  26. package/build/conductor-bundle-artifacts.js +8 -0
  27. package/build/docs.generated.js +8 -3
  28. package/build/drive-epic-key.js +6 -5
  29. package/build/drive-epic.js +85 -54
  30. package/build/epic-coherence-input.js +2 -1
  31. package/build/epic-implementer-bin.js +22 -20
  32. package/build/epic-implementer-bundle-cli.js +2 -2
  33. package/build/executor/cli.js +3 -2
  34. package/build/executor/completion-contract.js +48 -5
  35. package/build/executor/completion-supervision.js +4 -0
  36. package/build/executor/env.js +19 -0
  37. package/build/executor/heartbeat.js +1 -1
  38. package/build/executor/http-client.js +20 -0
  39. package/build/executor/job-errors.js +25 -0
  40. package/build/executor/job-runner.js +225 -41
  41. package/build/executor/observation.js +6 -1
  42. package/build/executor/preflight.js +51 -14
  43. package/build/executor/required-work-ledger.js +6 -1
  44. package/build/executor/resume-pre-spawn.js +2 -0
  45. package/build/executor/runner.js +8 -2
  46. package/build/executor/service-unit.js +7 -1
  47. package/build/executor/worker-finalization.js +82 -29
  48. package/build/executor/worker-guard-hook.js +137 -33
  49. package/build/executor/worktree.js +82 -1
  50. package/build/index.js +403 -429
  51. package/build/install-bridge-conductor.js +8 -6
  52. package/build/install-bridge.js +3 -3
  53. package/build/phase-context-git-probe.js +68 -0
  54. package/build/phase-result-artifacts.js +173 -5
  55. package/build/pipelines.generated.js +18 -24
  56. package/build/plane/alembic-head.js +12 -3
  57. package/build/plane/base-worktree-guard.js +147 -0
  58. package/build/plane/cli.js +217 -17
  59. package/build/plane/code-snapshot.js +468 -0
  60. package/build/plane/defaults.js +48 -3
  61. package/build/plane/endpoint.js +41 -0
  62. package/build/plane/manifest.js +120 -1
  63. package/build/plane/member-roster.js +94 -19
  64. package/build/plane/preflight.js +30 -2
  65. package/build/plane/process-cwd.js +67 -0
  66. package/build/plane/restart.js +542 -0
  67. package/build/plane/shutdown.js +31 -0
  68. package/build/plane/status.js +143 -8
  69. package/build/plane/supervisor.js +586 -179
  70. package/build/plane/types.js +54 -2
  71. package/build/printed-remedy-commands.js +37 -0
  72. package/build/readiness-check.js +1 -1
  73. package/build/readme.generated.js +1 -1
  74. package/build/review-generation.js +22 -0
  75. package/build/setup-epic.js +44 -19
  76. package/build/sfcc/config.js +9 -0
  77. package/build/sfcc/credentials.js +100 -38
  78. package/build/sfcc/hostname.js +67 -0
  79. package/build/sfcc/permissions.js +17 -13
  80. package/build/sfcc/register.js +1 -1
  81. package/build/sfcc/tool-wrapper.js +39 -43
  82. package/build/sfcc/write-grants.js +17 -11
  83. package/build/sfcc/write-result.js +4 -3
  84. package/build/sfcc/writes-site-preference.js +20 -8
  85. package/build/start-tickets.js +179 -31
  86. package/build/tool-error-envelope.js +61 -0
  87. package/build/version.generated.js +3 -3
  88. package/build/visual-diff.js +22 -1
  89. package/build/worker-guard-hook-bin.js +3 -6
  90. package/build/worker-guard-hook-main.js +47 -0
  91. package/build/worktree-core.js +130 -4
  92. package/docs/EPIC-IMPLEMENTER-INSTALL.md +849 -0
  93. package/docs/EPIC-IMPLEMENTER.md +662 -0
  94. package/docs/install/mcp-tool-integrations.md +24 -15
  95. package/docs/install/sfcc-integration.md +12 -4
  96. package/package.json +1 -1
  97. package/pipelines/plan-epic.json +0 -5
  98. package/pipelines/review-ticket.json +1 -1
  99. package/docs/CONDUCTOR.md +0 -605
  100. package/pipelines/idea-to-pr.json +0 -49
@@ -34,9 +34,14 @@ export async function collectGitTelemetry(deps, worktreePath, baseBranch) {
34
34
  /* degraded: omit last_commit_sha */
35
35
  }
36
36
  try {
37
+ // `--no-optional-locks`: a plain `git status` refreshes the index and takes
38
+ // `.git/index.lock`. This runs on every heartbeat against the LIVE worker's
39
+ // worktree, so without it a worker's own `git add`/`commit` can fail with
40
+ // "index.lock: File exists".
37
41
  const status = await deps.runCommand("git", [
38
42
  "-C",
39
43
  worktreePath,
44
+ "--no-optional-locks",
40
45
  "status",
41
46
  "--porcelain",
42
47
  ]);
@@ -293,7 +298,7 @@ const RATE_LIMIT_CONSEQUENCE_TEXT = "This warning is advisory only and triggers
293
298
  "rejection and the server advertised capacity recovery for this job; otherwise " +
294
299
  "the failure follows ordinary failure handling.";
295
300
  /** Fixed runbook reference appended to every rate-limit diagnostic (BAPI-898). */
296
- const RATE_LIMIT_RUNBOOK_REFERENCE = "See docs/claude/epic-implementer-v2-operator-runbook.md §8.";
301
+ const RATE_LIMIT_RUNBOOK_REFERENCE = "See docs/epic-implementer/operator-runbook.md §8.";
297
302
  /**
298
303
  * Render the operator-facing rate-limit line (BAPI-828, BAPI-898).
299
304
  *
@@ -2,7 +2,8 @@ import { DEFAULT_PROBE_TIMEOUT_MS } from "../agent-capabilities/types.js";
2
2
  import { inspectExecutorAgentAdapter, inspectionForFailedResolution, } from "../agent-launchers/executor-adapter-inspection.js";
3
3
  import { resolveExecutorAgentAdapter } from "../agent-launchers/executor-adapter-registry.js";
4
4
  import { resolveCompletionEvidenceCapability } from "../agent-launchers/executor-adapter.js";
5
- import { COMPLETION_EVIDENCE_CAPABILITY, REQUIRED_VERIFICATION_CAPABILITY, runtimeMatchesSupported, } from "./completion-contract.js";
5
+ import { COMPLETION_EVIDENCE_CAPABILITY, REQUIRED_VERIFICATION_CAPABILITY, cliVersionAtLeast, normalizeCliVersion, parseCliVersion, runtimeMatchesSupported, } from "./completion-contract.js";
6
+ import { buildHeadlessAgentEnv } from "./env.js";
6
7
  import { resolveExecutorAgentId } from "./agent-identity.js";
7
8
  import { mkdtemp, rm } from "node:fs/promises";
8
9
  import { tmpdir } from "node:os";
@@ -123,20 +124,34 @@ export async function evaluateCompletionEvidenceQualification(adapter, deps) {
123
124
  if (probed === null) {
124
125
  return { qualified: false, refusal: "runtime identity probe did not run", runtime: null };
125
126
  }
126
- const declared = capability.value.runtime;
127
+ const declared = capability.value.predicate;
127
128
  const mismatches = [];
128
- for (const key of ["adapter", "cli_version", "platform", "event_schema"]) {
129
- if (probed[key] !== declared[key]) {
130
- mismatches.push(`${key} ${String(probed[key]).slice(0, 40)} != ${declared[key]}`);
131
- }
129
+ if (probed.adapter !== declared.adapter) {
130
+ mismatches.push(`adapter ${String(probed.adapter).slice(0, 40)} != ${declared.adapter}`);
131
+ }
132
+ if (probed.event_schema !== declared.event_schema) {
133
+ mismatches.push(`event_schema ${String(probed.event_schema).slice(0, 40)} != ${declared.event_schema}`);
132
134
  }
133
135
  if (mismatches.length > 0) {
134
136
  return {
135
137
  qualified: false,
136
- refusal: bounded(`probed runtime differs from the approved identity: ${mismatches.join("; ")}`),
138
+ refusal: bounded(`probed runtime differs from the declared predicate: ${mismatches.join("; ")}`),
137
139
  runtime: probed,
138
140
  };
139
141
  }
142
+ // Fixed reasons below: the probed version and platform are never echoed, because
143
+ // an unparseable value is arbitrary probe output.
144
+ const version = parseCliVersion(probed.cli_version);
145
+ const minimum = parseCliVersion(declared.min_cli_version);
146
+ if (version === null || minimum === null) {
147
+ return { qualified: false, refusal: "probed CLI version is not a recognized version", runtime: probed };
148
+ }
149
+ if (!cliVersionAtLeast(version, minimum)) {
150
+ return { qualified: false, refusal: "probed CLI version is older than the minimum", runtime: probed };
151
+ }
152
+ if (!declared.platforms.includes(probed.platform)) {
153
+ return { qualified: false, refusal: "probed platform is not a supported platform", runtime: probed };
154
+ }
140
155
  if (!runtimeMatchesSupported(probed)) {
141
156
  return {
142
157
  qualified: false,
@@ -144,7 +159,12 @@ export async function evaluateCompletionEvidenceQualification(adapter, deps) {
144
159
  runtime: probed,
145
160
  };
146
161
  }
147
- return { qualified: true, refusal: null, runtime: probed };
162
+ // Normalized (suffix stripped) so the retained identity and the envelope carry one form.
163
+ const normalized = normalizeCliVersion(probed.cli_version);
164
+ if (normalized === null) {
165
+ return { qualified: false, refusal: "probed CLI version is not a recognized version", runtime: probed };
166
+ }
167
+ return { qualified: true, refusal: null, runtime: { ...probed, cli_version: normalized } };
148
168
  }
149
169
  /** Fail-fast report shape for a preflight that refused before probing. */
150
170
  function refusedPreflight(fatalFindings, warnings, adapter) {
@@ -160,23 +180,29 @@ function refusedPreflight(fatalFindings, warnings, adapter) {
160
180
  adapter,
161
181
  };
162
182
  }
163
- const CORRECTION_HOOK_FIXTURE_ID = "correction-entry-nested-v1";
183
+ export const CORRECTION_HOOK_FIXTURE_ID = "correction-entry-nested-v2";
184
+ const spawnCorrectionHook = (args, input, env) => {
185
+ const result = spawnSync(process.execPath, args, { input, encoding: "utf8", env, timeout: 5000 });
186
+ return { status: result.status, ...(result.error ? { error: result.error } : {}) };
187
+ };
164
188
  /** Exercise the packaged hook process, including a nested-agent entry attempt. */
165
- export function probeInstalledCorrectionHook(worktreePath, binPath = defaultResolveBinPath("worker-guard-hook-bin.js")) {
189
+ export function probeInstalledCorrectionHook(worktreePath, binPath = defaultResolveBinPath("worker-guard-hook-bin.js"), run = spawnCorrectionHook) {
166
190
  const args = [binPath, "--worktree-root", worktreePath,
167
191
  "--correction-intent-version", "1"];
168
192
  const env = { ...process.env, BAPI_CORRECTION_INTENT_VERSION: "1" };
169
193
  const fixtures = [
170
194
  { payload: { tool_name: "Skill", tool_input: { skill: "bridge-implement-ticket" } }, status: 2 },
171
195
  { payload: { tool_name: "Task", tool_input: { prompt: "/implement-ticket BAPI-1 --auto" } }, status: 2 },
172
- { payload: { tool_name: "Read", tool_input: { file_path: "commands/src/implement-ticket.md" } }, status: 2 },
196
+ { payload: { tool_name: "Read", tool_input: { file_path: "commands/src/implement-ticket.md" } }, status: 0 },
197
+ {
198
+ payload: { tool_name: "Bash", tool_input: { command: 'claude -p "/implement-ticket BAPI-1"' } },
199
+ status: 2,
200
+ },
173
201
  { payload: { tool_name: "mcp__bridge_api__request_plan_generation", tool_input: {} }, status: 2 },
174
202
  { payload: { tool_name: "mcp__bridge_api__get_plan", tool_input: { ticket_number: "BAPI-1" } }, status: 0 },
175
203
  ];
176
204
  return fixtures.every(({ payload, status }) => {
177
- const result = spawnSync(process.execPath, args, {
178
- input: JSON.stringify(payload), encoding: "utf8", env, timeout: 5000,
179
- });
205
+ const result = run(args, JSON.stringify(payload), env);
180
206
  return result.error === undefined && result.status === status;
181
207
  });
182
208
  }
@@ -411,8 +437,11 @@ export async function collectExecutorPreflight(options, deps, seams = {}) {
411
437
  if (denyCapability?.supported !== true) {
412
438
  throw new Error("adapter declares no deny-enforcement capability");
413
439
  }
440
+ // The probe gets a worker-shaped environment so a nested-session variable in
441
+ // the executor's own process cannot change its verdict (BAPI-1386).
414
442
  const verdict = await denyCapability.value.probeEnforcement({
415
443
  timeoutMs: resolveDenyProbeTimeoutMs(deps.env),
444
+ env: buildHeadlessAgentEnv(deps.env),
416
445
  });
417
446
  deny = {
418
447
  enforced: verdict.enforced,
@@ -486,6 +515,13 @@ export async function collectExecutorPreflight(options, deps, seams = {}) {
486
515
  if (!completion.qualified && completion.refusal !== null) {
487
516
  warnings.push(`completion evidence not advertised: ${completion.refusal}`);
488
517
  }
518
+ // One notice per qualifying collection, built only from validated fields: the
519
+ // adapter id the predicate matched, the normalized version, and a closed platform id.
520
+ const notices = [];
521
+ if (completion.qualified && completion.runtime !== null) {
522
+ notices.push(`completion evidence advertised: ${completion.runtime.adapter} ` +
523
+ `${completion.runtime.cli_version} ${completion.runtime.platform}`);
524
+ }
489
525
  let correction = {
490
526
  qualified: false, evidence: null,
491
527
  };
@@ -504,6 +540,7 @@ export async function collectExecutorPreflight(options, deps, seams = {}) {
504
540
  ok: fatalFindings.length === 0,
505
541
  fatalFindings,
506
542
  warnings,
543
+ notices,
507
544
  agentVersion,
508
545
  wtVersion,
509
546
  diskFreeGb,
@@ -877,6 +877,10 @@ export function createRequiredWorkLedger(init) {
877
877
  const readyToClose = resultsSeen > 0 && latestIsResult && !turnOpen && liveRequired.length === 0 && snap.required.length === 0;
878
878
  return { readyToClose, liveRequired, liveOptional, activitySeq, latestIsResult };
879
879
  };
880
+ // One predicate for "a required task is still pending", shared by the envelope
881
+ // and the observe-only query so the two cannot drift apart.
882
+ const hasPendingRequiredWork = () => effectiveTasks().some(({ o, state }) => o.required && state === "pending");
883
+ const observeLiveRequiredWork = () => hasPendingRequiredWork() ? "live_required_work" : null;
880
884
  const buildEnvelope = (facts) => {
881
885
  const reasons = new Set(insufficient);
882
886
  if (!lifecycleSupported || !runtimeMatchesSupported(facts.runtime))
@@ -897,7 +901,7 @@ export function createRequiredWorkLedger(init) {
897
901
  reasons.add("forced_kill_no_cli_teardown");
898
902
  const effective = effectiveTasks();
899
903
  // R6: a required task with no terminal after the stream ended is missing evidence.
900
- if (effective.some(({ o, state }) => o.required && state === "pending"))
904
+ if (hasPendingRequiredWork())
901
905
  reasons.add("live_required_work");
902
906
  // R6b: a stale snapshot, or a snapshot-only task nothing ever explained.
903
907
  const snap = liveSnapshotIds();
@@ -993,6 +997,7 @@ export function createRequiredWorkLedger(init) {
993
997
  recordDeclarationRefused: () => {
994
998
  insufficient.add("declaration_refused");
995
999
  },
1000
+ observeLiveRequiredWork,
996
1001
  buildEnvelope,
997
1002
  };
998
1003
  }
@@ -149,6 +149,8 @@ export function fillResumePromptPlaceholders(template, gitLog, gitDiff) {
149
149
  // ---------------------------------------------------------------------------
150
150
  /** The deterministic WIP checkpoint message for a continuation. */
151
151
  export const CONTINUATION_WIP_COMMIT_MESSAGE = "wip: auto-checkpoint before continuation";
152
+ /** BAPI-1367: the WIP checkpoint message when a retry resumes its prior attempt's work. */
153
+ export const PRIOR_ATTEMPT_WIP_COMMIT_MESSAGE = "wip: auto-checkpoint before retry resume";
152
154
  const FULL_SHA = /^[0-9a-f]{40}$/;
153
155
  /** Read the continuation binding from a claimed job's payload (pure). */
154
156
  export function readContinuationBinding(job) {
@@ -70,7 +70,7 @@ export function formatDispatcherUnavailableDiagnostic(liveness) {
70
70
  return (`executor: no fresh reconciler/dispatcher heartbeat is available ` +
71
71
  `(reconciler_liveness=${liveness}); the dispatcher may not be running. ` +
72
72
  "Check DISABLE_SCHEDULER and GET /automation/health; see " +
73
- "docs/claude/epic-implementer-v2-operator-runbook.md. Continuing to poll.");
73
+ "docs/epic-implementer/operator-runbook.md. Continuing to poll.");
74
74
  }
75
75
  /** The recovery diagnostic, emitted once when an absent dispatcher comes back. */
76
76
  export function formatDispatcherRecoveredDiagnostic() {
@@ -94,7 +94,8 @@ export const PROCESS_HEARTBEAT_INTERVAL_MS = 60_000;
94
94
  /** Delivery-failure diagnostic. Bounded, credential-free, stderr-only. */
95
95
  export function formatProcessHeartbeatFailureDiagnostic() {
96
96
  return ("executor: process-heartbeat delivery is failing; the server may report this " +
97
- "executor as stale. Claiming and running jobs are unaffected.");
97
+ "executor as stale. If the server is unreachable, job heartbeats and claims " +
98
+ "can fail too, and a running job can lose its lease.");
98
99
  }
99
100
  /** Emitted once when delivery starts working again. */
100
101
  export function formatProcessHeartbeatRecoveredDiagnostic() {
@@ -819,6 +820,11 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
819
820
  for (const warning of report.warnings) {
820
821
  deps.errorLog(`executor preflight warning: ${warning}`);
821
822
  }
823
+ // BAPI-1386: informational lines (the completion-evidence advertisement) go to
824
+ // stderr like every other diagnostic here, never stdout.
825
+ for (const notice of report.notices ?? []) {
826
+ deps.errorLog(`executor preflight: ${notice}`);
827
+ }
822
828
  if (!report.ok) {
823
829
  deps.errorLog(`executor preflight refused claiming: ${report.fatalFindings.join("; ")}`);
824
830
  }
@@ -929,7 +929,8 @@ export function resolvePackagedExecutorInvocation(deps) {
929
929
  /** User-facing usage for `executor install-service`. */
930
930
  export function getExecutorInstallServiceUsage() {
931
931
  return [
932
- "Usage: mcp-server executor install-service --executor-id <id> --repo <name> [--repo <name> ...] [options]",
932
+ "Usage: mcp-server executor install-service --executor-id <id> --repo <name> [--repo <name> ...]",
933
+ " (--epic-run-id <id> | --repo-wide) [options]",
933
934
  "",
934
935
  "The FILE-GENERATION LEG of durable-host mode. It writes one unit and stops there.",
935
936
  "For the supported composed bring-up — planning, consented write, consented start,",
@@ -947,6 +948,11 @@ export function getExecutorInstallServiceUsage() {
947
948
  " needs a durable identity, so the foreground",
948
949
  " <hostname>-<pid> default is never used here.",
949
950
  " --repo <name> Repo to serve (repeatable; --repos=a,b also works).",
951
+ " --epic-run-id <id> Claim scope: dedicate the unit to an epic run",
952
+ " (repeatable). Or:",
953
+ " --repo-wide Claim scope: claim from every authorized repo,",
954
+ " unscoped by run. Exactly one of the two is required;",
955
+ " the executor refuses to start without one.",
950
956
  " --base-url <url> Bridge API endpoint, or set BAPI_BASE_URL.",
951
957
  " There is NO production default.",
952
958
  "",
@@ -60,6 +60,15 @@
60
60
  * reports its head, publication is `unknown`
61
61
  * (`WorkerFinalizationPublicationUnverified`), never `ok`.
62
62
  *
63
+ * BAPI-1357: "absent" and "could not be checked" are different facts. Only the
64
+ * origin lookup's authoritative "no matching ref" (`git ls-remote --exit-code`
65
+ * exit code 2), or having no expected branch at all, is reported as
66
+ * `WorkerFinalizationMissingRemoteBranchAndPr`; a thrown command, an expired
67
+ * call bound, any other exit, or unusable output leaves publication unknown and is
68
+ * `WorkerFinalizationOriginUnverifiable`, which the server treats as transient.
69
+ * Both keep the `clean_exit` classification the process earned. No-expected-branch
70
+ * and absent-on-origin are still one cause, and may be refined later.
71
+ *
63
72
  * This check is deliberately fail-CLOSED, unlike the BAPI-586 base check it now
64
73
  * shares a lookup with. "The PR could not be verified" and "there is no PR" are
65
74
  * the same thing from the contract's point of view: neither is evidence that the
@@ -67,7 +76,7 @@
67
76
  * this ticket removes. The reconciler stays the decision-maker — the executor
68
77
  * only reports the observation (R14 rule 3).
69
78
  */
70
- import { ReconcileOverlapPushUnfinalized, secretFreeErrorMessage, WorkerFinalizationMissingRemoteBranchAndPr, WorkerFinalizationPrBaseMismatch, WorkerFinalizationPrNotAttached, WorkerFinalizationPublicationUnverified, WorkerFinalizationSavedButUnfinalized, } from "./job-errors.js";
79
+ import { ReconcileOverlapPushUnfinalized, WorkerFinalizationMissingRemoteBranchAndPr, WorkerFinalizationOriginUnverifiable, WorkerFinalizationPrBaseMismatch, WorkerFinalizationPrNotAttached, WorkerFinalizationPublicationUnverified, WorkerFinalizationSavedButUnfinalized, } from "./job-errors.js";
71
80
  import { isImplementationStyleJobType } from "./job-types.js";
72
81
  /** A full, lower-case 40-hex commit SHA. */
73
82
  const FULL_SHA_RE = /^[0-9a-f]{40}$/;
@@ -129,40 +138,56 @@ function parseLsRemoteHeadSha(stdout, expectedRef) {
129
138
  }
130
139
  return null;
131
140
  }
141
+ /** `git ls-remote --exit-code` exits with this status when no ref matched. */
142
+ const LS_REMOTE_NO_MATCHING_REF_EXIT_CODE = 2;
132
143
  /**
133
- * Resolve the origin branch's authoritative current tip SHA (normalized), or
134
- * `null` if the ref is absent. Uses `git ls-remote` against the exact normalized
135
- * ref — never a local `origin/<branch>` remote-tracking ref, which can be stale
136
- * on a recovery worktree. A non-zero exit is treated as "branch not found"
137
- * without copying raw stderr into diagnostics.
144
+ * Resolve the origin branch's authoritative current tip. Uses `git ls-remote`
145
+ * against the exact normalized ref — never a local `origin/<branch>`
146
+ * remote-tracking ref, which can be stale on a recovery worktree.
147
+ *
148
+ * Only exit code 2 (`--exit-code`'s "no matching ref") is `absent`. A thrown
149
+ * command, an expired call bound, every other non-zero exit, and an exit-zero
150
+ * response with no usable SHA for the exact ref are all `unverifiable`: none of
151
+ * them is evidence the branch is missing. Neither stderr nor a thrown error's
152
+ * message is read or copied anywhere.
138
153
  */
139
154
  export async function resolveOriginBranchSha(runCommand, worktreePath, branch, bound) {
140
155
  const normalizedRef = normalizeBranchRef(branch);
141
156
  if (callExpired(bound))
142
- return null;
143
- const result = await runCommand("git", ["ls-remote", "--exit-code", "--heads", "origin", normalizedRef], callOptions(worktreePath, bound));
157
+ return { state: "unverifiable" };
158
+ let result;
159
+ try {
160
+ result = await runCommand("git", ["ls-remote", "--exit-code", "--heads", "origin", normalizedRef], callOptions(worktreePath, bound));
161
+ }
162
+ catch {
163
+ return { state: "unverifiable" };
164
+ }
165
+ if (result.exitCode === LS_REMOTE_NO_MATCHING_REF_EXIT_CODE)
166
+ return { state: "absent" };
144
167
  if (result.exitCode !== 0)
145
- return null;
146
- return parseLsRemoteHeadSha(result.stdout, normalizedRef);
168
+ return { state: "unverifiable" };
169
+ const sha = parseLsRemoteHeadSha(result.stdout, normalizedRef);
170
+ return sha === null ? { state: "unverifiable" } : { state: "found", sha };
147
171
  }
148
172
  /**
149
173
  * Repeatedly resolve the authoritative origin tip until it either satisfies
150
174
  * finalization or the bounded retry budget is exhausted. Stops early (no further
151
175
  * lookups) when the origin tip matches the worker HEAD, or when the branch exists
152
176
  * and the worker HEAD is unavailable (degraded telemetry, existence-only). Retries
153
- * when the tip is `null` (branch not yet visible) or, with a known HEAD, does not
154
- * yet match. Returns the LAST authoritative observation — the caller decides
155
- * loud-failure from that final value, so true negatives are preserved.
177
+ * an observed absence, a found tip that does not yet match a known HEAD, and an
178
+ * unverifiable read (a later read may succeed). Returns the LAST observation with
179
+ * its state intact — an unverifiable read is never turned into an absence here,
180
+ * so the caller decides loud-failure from what was actually established.
156
181
  */
157
182
  async function resolveOriginBranchShaForFinalization(runCommand, worktreePath, branch, trimmedHeadSha, attempts, retryDelayMs, sleep, bound) {
158
183
  const total = Math.max(1, attempts);
159
- let remoteSha = null;
184
+ let lookup = { state: "unverifiable" };
160
185
  for (let attempt = 0; attempt < total; attempt++) {
161
- remoteSha = await resolveOriginBranchSha(runCommand, worktreePath, branch, bound?.());
162
- if (remoteSha !== null) {
186
+ lookup = await resolveOriginBranchSha(runCommand, worktreePath, branch, bound?.());
187
+ if (lookup.state === "found") {
163
188
  // Existence-only success when HEAD is unknown; exact-match success otherwise.
164
- if (!trimmedHeadSha || remoteSha === trimmedHeadSha) {
165
- return { sha: remoteSha, attemptsUsed: attempt + 1 };
189
+ if (!trimmedHeadSha || lookup.sha === trimmedHeadSha) {
190
+ return { lookup, attemptsUsed: attempt + 1 };
166
191
  }
167
192
  }
168
193
  if (attempt < total - 1)
@@ -170,7 +195,7 @@ async function resolveOriginBranchShaForFinalization(runCommand, worktreePath, b
170
195
  }
171
196
  // `attemptsUsed` is reported even on failure so the caller can tell a
172
197
  // first-try answer from one that needed the settling window (BAPI-862).
173
- return { sha: remoteSha, attemptsUsed: total };
198
+ return { lookup, attemptsUsed: total };
174
199
  }
175
200
  /** The bounded, public projection finalization asks the provider for. */
176
201
  const PR_VIEW_JSON_FIELDS = "number,url,baseRefName,body,headRefOid";
@@ -385,12 +410,19 @@ function publicationUnverifiedFailure(job, branch, workerHeadSha, detail) {
385
410
  };
386
411
  }
387
412
  /**
388
- * `detail` describes a state with NO evidence any work reached origin (branch
389
- * unresolved, branch absent from origin, or the origin lookup itself failed).
413
+ * `detail` describes a state with NO evidence any work reached origin, and is
414
+ * used for exactly two cases: no expected branch to verify, or a branch the
415
+ * origin lookup OBSERVED absent (exit code 2). An origin lookup that merely
416
+ * failed is {@link originUnverifiableFailure}, never this. Those two cases stay
417
+ * one cause for now and may be refined later.
390
418
  * `hasPrUrl` only changes the wording — a PR existing is never treated as
391
419
  * evidence of a push, so the message must not claim "no PR URL" when one was
392
420
  * actually present (that would be false and would mislead the operator reading
393
421
  * it toward the wrong recovery step).
422
+ *
423
+ * The classification is `clean_exit` because the worker process did exit
424
+ * cleanly; the finalization failure rides in the error kind, not in a rewritten
425
+ * exit (the same rule the other finalization failures follow).
394
426
  */
395
427
  function missingBranchAndPrFailure(job, detail, hasPrUrl) {
396
428
  const label = job.ticket_key ? `${job.ticket_key} (job ${job.id})` : `job ${job.id}`;
@@ -398,7 +430,23 @@ function missingBranchAndPrFailure(job, detail, hasPrUrl) {
398
430
  return {
399
431
  error_kind: WorkerFinalizationMissingRemoteBranchAndPr,
400
432
  error_message: `${label} exited cleanly but ${prefix} ${detail}`,
401
- classification: "crashed",
433
+ classification: "clean_exit",
434
+ };
435
+ }
436
+ /**
437
+ * BAPI-1357: the origin lookup could not be trusted, so the branch was neither
438
+ * seen nor observed missing. The wording is fixed and bounded: it names only the
439
+ * ticket, job and branch (public identifiers), never a command, stderr, a thrown
440
+ * error's text, a host, or a credential, and it does not claim the branch is
441
+ * absent. The server treats this kind as transient.
442
+ */
443
+ function originUnverifiableFailure(job, branch) {
444
+ const label = job.ticket_key ? `${job.ticket_key} (job ${job.id})` : `job ${job.id}`;
445
+ return {
446
+ error_kind: WorkerFinalizationOriginUnverifiable,
447
+ error_message: `${label} exited cleanly, but whether branch '${branch}' reached origin could not be ` +
448
+ `verified. The branch was not observed to be missing; publication is unknown for this attempt.`,
449
+ classification: "clean_exit",
402
450
  };
403
451
  }
404
452
  /**
@@ -525,19 +573,24 @@ export async function validateWorkerFinalization(input) {
525
573
  try {
526
574
  origin = await resolveOriginBranchShaForFinalization(runCommand, worktreePath, trimmedBranch, trimmedHeadSha, attempts, retryDelayMs, sleep, bound);
527
575
  }
528
- catch (err) {
529
- return {
530
- ok: false,
531
- failure: missingBranchAndPrFailure(job, `branch '${trimmedBranch}' could not be verified on origin: ${secretFreeErrorMessage(err)}`, hasPrUrl),
532
- };
576
+ catch {
577
+ // BAPI-1357: nothing thrown here says anything about the branch, and its text
578
+ // is never rendered — the fixed unverifiable outcome is the whole report.
579
+ return { ok: false, failure: originUnverifiableFailure(job, trimmedBranch) };
580
+ }
581
+ // BAPI-1357: only an authoritative "no matching ref" is an absence. A lookup
582
+ // that could not be trusted stays unknown and never funds the deterministic
583
+ // missing-branch failure.
584
+ if (origin.lookup.state === "unverifiable") {
585
+ return { ok: false, failure: originUnverifiableFailure(job, trimmedBranch) };
533
586
  }
534
- const remoteSha = origin.sha;
535
- if (remoteSha === null) {
587
+ if (origin.lookup.state === "absent") {
536
588
  return {
537
589
  ok: false,
538
590
  failure: missingBranchAndPrFailure(job, `branch '${trimmedBranch}' is not on origin`, hasPrUrl),
539
591
  };
540
592
  }
593
+ const remoteSha = origin.lookup.sha;
541
594
  // BAPI-762: an authoritative origin tip that does not match the worker's HEAD
542
595
  // is positive evidence of RECOVERABLE work — distinct from the sub-cases above,
543
596
  // which have no evidence any work reached origin at all. This is the only