@bridge_gpt/mcp-server 0.2.50 → 0.2.51

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.
@@ -277,6 +277,53 @@ export const SCOPE_LIFECYCLE_LABELS = Object.freeze({
277
277
  ready: "Ready",
278
278
  failed: "Failed",
279
279
  });
280
+ /**
281
+ * The fixed lifecycle label a heartbeat uses when the status READ itself failed
282
+ * (BAPI-963).
283
+ *
284
+ * A read failure is not a lifecycle state, and the raw error is deliberately not
285
+ * interpolated into a heartbeat: a per-poll line repeated for twenty minutes is
286
+ * the worst possible place to smuggle unbounded server text.
287
+ */
288
+ export const SCOPE_BOOTSTRAP_UNREADABLE_STATE = "unreadable";
289
+ /**
290
+ * Format one bootstrap heartbeat line (BAPI-963).
291
+ *
292
+ * Shared with `setup-epic` at this seam so both conductors compute progress the
293
+ * same way; RENDERING stays with each caller, because the pilot writes to its own
294
+ * stderr advisory channel and v2 reports progress server-side.
295
+ *
296
+ * The shape is fixed and grep-friendly — elapsed first, state second — because a
297
+ * ~30-minute seed that printed nothing was externally indistinguishable from a
298
+ * hang (sleeping process, 0% CPU, a frozen `updated_at`). Elapsed seconds are
299
+ * clamped at zero so a clock adjustment cannot render a negative age.
300
+ */
301
+ export function formatScopeBootstrapHeartbeat(elapsedMs, state) {
302
+ const seconds = Math.max(0, Math.floor(elapsedMs / 1000));
303
+ // `lifecycle_state` is a required non-empty string on the wire, not a closed
304
+ // set. Bounding it to the known labels (plus the fixed `unreadable` and a
305
+ // catch-all) keeps an unvalidated server string out of a line that repeats
306
+ // every interval for up to twenty minutes.
307
+ const bounded = state === SCOPE_BOOTSTRAP_UNREADABLE_STATE || state in SCOPE_LIFECYCLE_LABELS
308
+ ? state
309
+ : "unknown";
310
+ return `Seeding scope: elapsed=${seconds}s state=${bounded}`;
311
+ }
312
+ /**
313
+ * Describe the nominal polling window up front (BAPI-963).
314
+ *
315
+ * Computed from the interval and cap rather than hard-coded, so a change to
316
+ * either constant cannot leave the operator-facing duration claim stale. The
317
+ * window is NOMINAL: the observed pilot seed outran even this bound, which is
318
+ * why the wording promises a poll cadence rather than a completion time.
319
+ */
320
+ export function describeScopeBootstrapWindow(intervalMs = SCOPE_BOOTSTRAP_POLL_INTERVAL_MS, maxPolls = SCOPE_BOOTSTRAP_MAX_POLLS) {
321
+ const intervalSeconds = Math.max(1, Math.round(intervalMs / 1000));
322
+ const windowMinutes = Math.max(1, Math.round((intervalMs * maxPolls) / 60_000));
323
+ return (`Seeding the epic's index scope. This copies the repository's whole parse cache and ` +
324
+ `verifies it, and commonly takes many minutes. Progress is reported every ` +
325
+ `${intervalSeconds}s; the poll gives up after about ${windowMinutes} minutes.`);
326
+ }
280
327
  /**
281
328
  * Poll a scope's lifecycle until it is `ready`, `failed`, or the bounded wait
282
329
  * elapses, reporting each NEWLY observed lifecycle transition exactly once, in
@@ -296,6 +343,8 @@ export async function pollIndexScopeLifecycle(deps, access, scopeId, options = {
296
343
  const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
297
344
  const maxPolls = options.maxPolls ?? SCOPE_BOOTSTRAP_MAX_POLLS;
298
345
  const intervalMs = options.intervalMs ?? SCOPE_BOOTSTRAP_POLL_INTERVAL_MS;
346
+ const now = options.now ?? (() => new Date());
347
+ const startedAtMs = now().getTime();
299
348
  let lastState = "unknown";
300
349
  let lastStatus = null;
301
350
  let lastReportedState = null;
@@ -304,10 +353,12 @@ export async function pollIndexScopeLifecycle(deps, access, scopeId, options = {
304
353
  const status = await getIndexScopeStatus(access, scopeId, deps.fetchImpl);
305
354
  if (!status.ok) {
306
355
  lastState = `unreadable (${status.error})`;
356
+ options.onPoll?.(now().getTime() - startedAtMs, SCOPE_BOOTSTRAP_UNREADABLE_STATE);
307
357
  continue;
308
358
  }
309
359
  lastStatus = status.value;
310
360
  lastState = status.value.lifecycle_state;
361
+ options.onPoll?.(now().getTime() - startedAtMs, lastState);
311
362
  if (lastState !== lastReportedState) {
312
363
  lastReportedState = lastState;
313
364
  options.onTransition?.(lastState, status.value);
package/build/doctor.js CHANGED
@@ -28,6 +28,7 @@ import { createBridgeApiUrls } from "./bridge-api-urls.js";
28
28
  import { probeToolSurface } from "./tool-surface-gating.js";
29
29
  import { resolveBapiCredentials } from "./credential-store.js";
30
30
  import { resolveConductorBridgeApiAccess } from "./conductor/bridge-api-client.js";
31
+ import { getLiveRepositoryConductors, getParseDispatcherHealth, } from "./conduct-epic/bridge-client.js";
31
32
  import { resolveConductEpicStateDirectory } from "./conduct-epic/checkpoint-store.js";
32
33
  import { isConductEpicLockOwnerAlive, parseConductEpicLock, } from "./conduct-epic/lock.js";
33
34
  import { resolveRequiredStartTicketsRepoName } from "./start-tickets-repo.js";
@@ -1401,6 +1402,151 @@ export function formatConductEpicDiagnosticReport(diagnostic) {
1401
1402
  lines.push(" lock, rewrites no checkpoint, and never changes the exit code.");
1402
1403
  return lines.join("\n");
1403
1404
  }
1405
+ /**
1406
+ * Collect the operational advisories, read-only.
1407
+ *
1408
+ * Local pilot conductors are identified from the SAME checkpoint-directory and
1409
+ * lock inspection `conduct-epic readiness` uses — `parseConductEpicLock` plus the
1410
+ * injected liveness seam — never from the process table. Only a lock proven live
1411
+ * on THIS host counts; malformed, unreadable, dead, and remote-host locks are
1412
+ * excluded from the live set, with the indeterminate ones counted separately.
1413
+ */
1414
+ export async function collectOperationalAdvisories(deps) {
1415
+ const isAlive = deps.isProcessAlive ?? isConductEpicLockOwnerAlive;
1416
+ const now = deps.now ?? (() => new Date());
1417
+ const resolveAccess = deps.resolveAccess ?? resolveConductorBridgeApiAccess;
1418
+ let dispatcher = null;
1419
+ let conductorObservation = "unavailable";
1420
+ const conductors = [];
1421
+ let repo = null;
1422
+ let access = null;
1423
+ try {
1424
+ access = await resolveAccess({
1425
+ env: deps.env,
1426
+ cwd: deps.cwd,
1427
+ homedir: deps.homedir,
1428
+ platform: deps.platform,
1429
+ readFile: deps.readFile,
1430
+ stat: deps.stat,
1431
+ });
1432
+ }
1433
+ catch {
1434
+ access = null;
1435
+ }
1436
+ if (access !== null && access.ok) {
1437
+ repo = access.access.repoName;
1438
+ const fetchImpl = deps.fetchImpl ?? globalThis.fetch;
1439
+ const health = await getParseDispatcherHealth(access.access, fetchImpl);
1440
+ dispatcher = health.ok
1441
+ ? health.value
1442
+ : { observation: "unavailable", heartbeatState: null, respondingSchedulerRunning: false };
1443
+ const live = await getLiveRepositoryConductors(access.access, now(), fetchImpl);
1444
+ if (live.ok) {
1445
+ conductorObservation = live.value.observation;
1446
+ conductors.push(...live.value.conductors);
1447
+ }
1448
+ }
1449
+ // Local pilot locks. Read through the same path the readiness section uses; a
1450
+ // failure to read the directory is silence, not a claim that none are held.
1451
+ let indeterminateLocks = 0;
1452
+ if (repo !== null) {
1453
+ const stateDirectory = resolveConductEpicStateDirectory(repo, {
1454
+ env: deps.env,
1455
+ homedir: deps.homedir,
1456
+ });
1457
+ let entries = [];
1458
+ try {
1459
+ entries = await deps.readdir(stateDirectory);
1460
+ }
1461
+ catch {
1462
+ entries = [];
1463
+ }
1464
+ for (const entry of entries) {
1465
+ if (!entry.endsWith(".lock"))
1466
+ continue;
1467
+ const epic = entry.slice(0, -".lock".length);
1468
+ let raw;
1469
+ try {
1470
+ raw = await deps.readFile(path.join(stateDirectory, entry));
1471
+ }
1472
+ catch {
1473
+ indeterminateLocks += 1;
1474
+ continue;
1475
+ }
1476
+ const owner = parseConductEpicLock(raw);
1477
+ if (owner === null) {
1478
+ indeterminateLocks += 1;
1479
+ continue;
1480
+ }
1481
+ const local = owner.host === deps.env.HOSTNAME || owner.host === os.hostname();
1482
+ if (!local) {
1483
+ indeterminateLocks += 1;
1484
+ continue;
1485
+ }
1486
+ if (!isAlive(owner.owner_pid))
1487
+ continue;
1488
+ // A pilot lock and a v2 run are two DISTINCT conductor authorities. Even
1489
+ // when both name the same epic they are not deduplicated — that pairing is
1490
+ // precisely the overlap an operator most needs to see.
1491
+ //
1492
+ // The key comes from a FILENAME, so it is bounded against the epic-key shape
1493
+ // before it is rendered; an unrecognized name reports `unknown` rather than
1494
+ // putting arbitrary filesystem text into a diagnostic line.
1495
+ conductors.push({
1496
+ kind: "pilot",
1497
+ epicKey: validateTicketKey(epic, "epic key").ok ? epic : null,
1498
+ state: "lock held",
1499
+ livenessSource: "lock",
1500
+ });
1501
+ }
1502
+ if (conductors.length > 0 && conductorObservation !== "observed") {
1503
+ conductorObservation = "observed";
1504
+ }
1505
+ }
1506
+ return { dispatcher, conductorObservation, conductors, indeterminateLocks };
1507
+ }
1508
+ /** Render the operational-advisories section. Advisory: never changes the exit code. */
1509
+ export function formatOperationalAdvisoryReport(diagnostic) {
1510
+ const lines = ["", "operational advisories (advisory)"];
1511
+ const dispatcher = diagnostic.dispatcher;
1512
+ if (dispatcher === null) {
1513
+ lines.push(" SKIP Parse dispatcher: not checked (no Bridge credential resolved).");
1514
+ }
1515
+ else if (dispatcher.observation === "observed") {
1516
+ lines.push(" OK Parse dispatcher: observable — queued repository parses are being swept.");
1517
+ }
1518
+ else if (dispatcher.observation === "absent") {
1519
+ lines.push(" WARN Parse dispatcher: NOT observed. Queued repository parses wait for the\n" +
1520
+ " `worker:` dyno's parse-queue sweep; nothing else dispatches them.");
1521
+ }
1522
+ else {
1523
+ lines.push(" SKIP Parse dispatcher: unavailable — the liveness read produced no verdict.\n" +
1524
+ " This is not evidence that no dispatcher is running.");
1525
+ }
1526
+ if (diagnostic.conductorObservation === "unavailable") {
1527
+ lines.push(" SKIP Concurrent conductors: unavailable — the repository-scoped read produced\n" +
1528
+ " no verdict.");
1529
+ }
1530
+ else if (diagnostic.conductors.length === 0) {
1531
+ lines.push(" OK Concurrent conductors: none live on this repository.");
1532
+ }
1533
+ else {
1534
+ lines.push(` WARN Concurrent conductors: ${diagnostic.conductors.length} live on this repository.`);
1535
+ for (const conductor of diagnostic.conductors) {
1536
+ lines.push(` ${conductor.kind} epic=${conductor.epicKey ?? "unknown"} ` +
1537
+ `state=${conductor.state} liveness=${conductor.livenessSource}`);
1538
+ }
1539
+ lines.push(" A distinct-epic overlap is informational and may be legitimate: separately\n" +
1540
+ " scoped epics do not share transition authority.");
1541
+ }
1542
+ if (diagnostic.indeterminateLocks > 0) {
1543
+ lines.push(` ${diagnostic.indeterminateLocks} local lock(s) could not be judged (remote host,\n` +
1544
+ " malformed, or unreadable) and are excluded from the live count.");
1545
+ }
1546
+ lines.push(" Read-only: this section issues two Bridge GETs and reads local lock state. It");
1547
+ lines.push(" runs NO command probe, repairs no scheduler, and never changes the exit code.");
1548
+ return lines.join("\n");
1549
+ }
1404
1550
  /**
1405
1551
  * CLI entry for the read-only `doctor` subcommand. Returns a process exit code.
1406
1552
  * Help returns 0; parser errors return 1; otherwise it prints the report and
@@ -1701,6 +1847,50 @@ export async function runDoctorCli(argv, overrides = {}) {
1701
1847
  }));
1702
1848
  }
1703
1849
  }
1850
+ // Advisory operational-advisories section (BAPI-963), placed immediately after
1851
+ // `conduct-epic readiness` because both are read by the same operator at the
1852
+ // same moment — "can I conduct?" then "what else is already running?".
1853
+ //
1854
+ // Gated on `collection.ok` for the same reason the section above is: an
1855
+ // unsupported platform reports nothing. That gate is belt-and-braces here,
1856
+ // because this section issues NO command probe on any path — the two facts it
1857
+ // reports come from read-only Bridge GETs and from local lock state. The
1858
+ // "unsupported platform runs zero probes" pin is therefore untouched by it.
1859
+ //
1860
+ // Advisory with respect to the EXIT CODE: an absent dispatcher, a concurrent
1861
+ // conductor, and an unavailable read all leave doctor's pass/fail exactly as
1862
+ // the required prerequisites computed it.
1863
+ if (overrides.operationalAdvisories !== false && collection.ok) {
1864
+ try {
1865
+ const injectedFs = deps;
1866
+ const advisoryDeps = {
1867
+ env: overrides.operationalAdvisories?.env ?? deps.env,
1868
+ cwd: overrides.operationalAdvisories?.cwd ?? deps.cwd,
1869
+ platform: overrides.operationalAdvisories?.platform ?? deps.platform,
1870
+ homedir: overrides.operationalAdvisories?.homedir ?? injectedFs.homedir ?? os.homedir,
1871
+ readFile: overrides.operationalAdvisories?.readFile ??
1872
+ injectedFs.readFile ??
1873
+ ((p) => readFile(p, "utf-8")),
1874
+ stat: overrides.operationalAdvisories?.stat ?? injectedFs.stat ?? ((p) => stat(p)),
1875
+ readdir: overrides.operationalAdvisories?.readdir ?? ((p) => readdir(p)),
1876
+ resolveAccess: overrides.operationalAdvisories?.resolveAccess,
1877
+ isProcessAlive: overrides.operationalAdvisories?.isProcessAlive,
1878
+ fetchImpl: overrides.operationalAdvisories?.fetchImpl,
1879
+ now: overrides.operationalAdvisories?.now,
1880
+ };
1881
+ log(formatOperationalAdvisoryReport(await collectOperationalAdvisories(advisoryDeps)));
1882
+ }
1883
+ catch {
1884
+ // Any unexpected failure still renders a sanitized advisory section rather
1885
+ // than dropping it or leaking exception text.
1886
+ log(formatOperationalAdvisoryReport({
1887
+ dispatcher: null,
1888
+ conductorObservation: "unavailable",
1889
+ conductors: [],
1890
+ indeterminateLocks: 0,
1891
+ }));
1892
+ }
1893
+ }
1704
1894
  // Opt-in stale-ticket-branch section (BAPI-948). Gated on argv, not on platform
1705
1895
  // support: it runs ONLY when the operator named at least one `--stale-branch`
1706
1896
  // ticket, so an ordinary `doctor` run issues zero extra command probes and
@@ -0,0 +1,280 @@
1
+ /**
2
+ * Shared, non-throwing helper that ensures a draft epic-integration PR exists
3
+ * (BAPI-951).
4
+ *
5
+ * Local `gh` is used deliberately here: the Bridge/server pull-request seam has
6
+ * no `draft` parameter and cannot distinguish GitHub's "no commits between
7
+ * branches" 422 from a credential or provider failure — both of which this
8
+ * helper must classify without throwing.
9
+ *
10
+ * A draft PR still receives the `conductor-ci / gate` required check (BAPI-949's
11
+ * trigger has no draft exclusion), while `claude-review.yml` excludes drafts
12
+ * until the PR is marked ready. So opening the integration PR as a draft earns
13
+ * the epic branch its required CI immediately, without paying for a paid review
14
+ * nobody can act on until a human decides the epic is ready.
15
+ */
16
+ import { execFile as nodeExecFile } from "node:child_process";
17
+ /** Bounded read timeout — matches the existing 5s GitHub probe limit (pr-discovery.ts). */
18
+ export const EPIC_INTEGRATION_PR_READ_TIMEOUT_MS = 5_000;
19
+ /** Separate, larger bounded timeout for the two write operations (`pr create`, `pr ready`). */
20
+ export const EPIC_INTEGRATION_PR_WRITE_TIMEOUT_MS = 20_000;
21
+ /** Bounded result count for the existence probe. */
22
+ const LIST_LIMIT = 20;
23
+ const PR_LIST_JSON_FIELDS = "number,headRefName,baseRefName,isDraft";
24
+ /** The production call sites this helper is invoked from. */
25
+ export const EPIC_INTEGRATION_PR_COMMANDS = [
26
+ "setup-epic",
27
+ "conduct-epic init",
28
+ "conduct-epic catch-up",
29
+ "conduct-epic finish",
30
+ "executor merge",
31
+ ];
32
+ /** Only the sanctioned safe fields. Never raw command output. */
33
+ export function formatEpicIntegrationPullRequestOutcome(outcome) {
34
+ if (outcome.kind === "already_open" || outcome.kind === "created") {
35
+ return {
36
+ kind: outcome.kind,
37
+ reason: outcome.reason,
38
+ pr_number: outcome.prNumber,
39
+ readiness: outcome.readiness,
40
+ };
41
+ }
42
+ return { kind: outcome.kind, reason: outcome.reason };
43
+ }
44
+ /** A concise, epic-specific PR title. Does not depend on `gh`. */
45
+ export function buildEpicIntegrationPullRequestTitle(epicKey) {
46
+ return `${epicKey}: epic integration branch`;
47
+ }
48
+ /** A non-empty, epic-specific PR body. Does not depend on `gh`. */
49
+ export function buildEpicIntegrationPullRequestBody(epicKey, command) {
50
+ return [
51
+ `\`${command}\` opened this pull request automatically.`,
52
+ "",
53
+ `It exists so the \`conductor-ci / gate\` required check runs for the ${epicKey} ` +
54
+ "epic integration branch (BAPI-949). Draft status postpones the paid Claude " +
55
+ "review until a human marks this pull request ready for review.",
56
+ "",
57
+ "Only a human merges an epic integration branch — the conductor never merges it.",
58
+ ].join("\n");
59
+ }
60
+ function isRecord(value) {
61
+ return typeof value === "object" && value !== null && !Array.isArray(value);
62
+ }
63
+ /** Bounded diagnostics to distinguish "no local gh", "not authenticated", and "inconclusive". */
64
+ async function classifyGhUnavailable(gh, cwd) {
65
+ let version;
66
+ try {
67
+ version = await gh(["--version"], { cwd, timeoutMs: EPIC_INTEGRATION_PR_READ_TIMEOUT_MS });
68
+ }
69
+ catch {
70
+ return "gh_unavailable";
71
+ }
72
+ if (version.exitCode !== 0)
73
+ return "gh_unavailable";
74
+ let auth;
75
+ try {
76
+ auth = await gh(["auth", "status"], { cwd, timeoutMs: EPIC_INTEGRATION_PR_READ_TIMEOUT_MS });
77
+ }
78
+ catch {
79
+ return "probe_inconclusive";
80
+ }
81
+ if (auth.exitCode !== 0)
82
+ return "gh_unauthenticated";
83
+ return "probe_inconclusive";
84
+ }
85
+ /**
86
+ * Existence probe: `gh pr list` constrained by BOTH head and base. Only a
87
+ * successful, valid, empty JSON array is confirmed absence — anything else
88
+ * (non-zero exit, unparseable output, a non-array shape, an item with an
89
+ * invalid number, or a record whose head/base do not match) is `unavailable`,
90
+ * never treated as absence.
91
+ */
92
+ async function probeMatchingOpenPr(gh, epicBranch, baseBranch, cwd) {
93
+ const args = [
94
+ "pr",
95
+ "list",
96
+ "--state",
97
+ "open",
98
+ "--head",
99
+ epicBranch,
100
+ "--base",
101
+ baseBranch,
102
+ "--json",
103
+ PR_LIST_JSON_FIELDS,
104
+ "--limit",
105
+ String(LIST_LIMIT),
106
+ ];
107
+ let result;
108
+ try {
109
+ result = await gh(args, { cwd, timeoutMs: EPIC_INTEGRATION_PR_READ_TIMEOUT_MS });
110
+ }
111
+ catch {
112
+ return { kind: "unavailable", reason: await classifyGhUnavailable(gh, cwd) };
113
+ }
114
+ if (result.exitCode !== 0) {
115
+ return { kind: "unavailable", reason: await classifyGhUnavailable(gh, cwd) };
116
+ }
117
+ let parsed;
118
+ try {
119
+ parsed = JSON.parse(result.stdout);
120
+ }
121
+ catch {
122
+ return { kind: "unavailable", reason: "probe_malformed" };
123
+ }
124
+ if (!Array.isArray(parsed)) {
125
+ return { kind: "unavailable", reason: "probe_malformed" };
126
+ }
127
+ if (parsed.length === 0) {
128
+ return { kind: "absent" };
129
+ }
130
+ for (const item of parsed) {
131
+ if (!isRecord(item))
132
+ continue;
133
+ const number = item.number;
134
+ const head = item.headRefName;
135
+ const base = item.baseRefName;
136
+ const isDraft = item.isDraft;
137
+ if (typeof number === "number" &&
138
+ Number.isInteger(number) &&
139
+ number > 0 &&
140
+ head === epicBranch &&
141
+ base === baseBranch &&
142
+ typeof isDraft === "boolean") {
143
+ return { kind: "found", number, isDraft };
144
+ }
145
+ }
146
+ // Records exist but none matches both head and base exactly, or the shape is
147
+ // unexpected — never treated as absence.
148
+ return { kind: "unavailable", reason: "probe_malformed" };
149
+ }
150
+ const NO_COMMITS_BETWEEN_PATTERN = /no commits between/i;
151
+ /** Strict positive numeric suffix of a normal `https://…/pull/<n>` URL, else `null`. */
152
+ function extractPrNumberFromCreateOutput(stdout) {
153
+ const match = stdout.trim().match(/\/pull\/(\d+)\s*$/);
154
+ if (!match)
155
+ return null;
156
+ const n = Number(match[1]);
157
+ return Number.isInteger(n) && n > 0 ? n : null;
158
+ }
159
+ async function createDraftPr(gh, epicKey, epicBranch, baseBranch, command, cwd) {
160
+ const title = buildEpicIntegrationPullRequestTitle(epicKey);
161
+ const body = buildEpicIntegrationPullRequestBody(epicKey, command);
162
+ const args = [
163
+ "pr",
164
+ "create",
165
+ "--draft",
166
+ "--base",
167
+ baseBranch,
168
+ "--head",
169
+ epicBranch,
170
+ "--title",
171
+ title,
172
+ "--body",
173
+ body,
174
+ ];
175
+ let result;
176
+ try {
177
+ result = await gh(args, { cwd, timeoutMs: EPIC_INTEGRATION_PR_WRITE_TIMEOUT_MS });
178
+ }
179
+ catch {
180
+ return { kind: "create_failed" };
181
+ }
182
+ if (result.exitCode === 0) {
183
+ return { kind: "created", numberHint: extractPrNumberFromCreateOutput(result.stdout) };
184
+ }
185
+ const combined = `${result.stdout}\n${result.stderr}`;
186
+ if (NO_COMMITS_BETWEEN_PATTERN.test(combined)) {
187
+ return { kind: "no_commits" };
188
+ }
189
+ return { kind: "create_failed" };
190
+ }
191
+ async function maybeMakeReady(gh, prNumber, isDraft, requestReady, cwd) {
192
+ if (!requestReady)
193
+ return "not_requested";
194
+ if (prNumber === null)
195
+ return "ready_failed";
196
+ if (!isDraft)
197
+ return "already_ready";
198
+ try {
199
+ const result = await gh(["pr", "ready", String(prNumber)], {
200
+ cwd,
201
+ timeoutMs: EPIC_INTEGRATION_PR_WRITE_TIMEOUT_MS,
202
+ });
203
+ return result.exitCode === 0 ? "made_ready" : "ready_failed";
204
+ }
205
+ catch {
206
+ return "ready_failed";
207
+ }
208
+ }
209
+ async function ensureEpicIntegrationPullRequestInner(options) {
210
+ const { epicKey, epicBranch, baseBranch, command, gh, cwd, requestReady } = options;
211
+ const probe = await probeMatchingOpenPr(gh, epicBranch, baseBranch, cwd);
212
+ if (probe.kind === "unavailable") {
213
+ return { kind: "unavailable", reason: probe.reason };
214
+ }
215
+ if (probe.kind === "found") {
216
+ const readiness = await maybeMakeReady(gh, probe.number, probe.isDraft, requestReady, cwd);
217
+ return { kind: "already_open", reason: "already_open", prNumber: probe.number, readiness };
218
+ }
219
+ // Confirmed absent: create.
220
+ const created = await createDraftPr(gh, epicKey, epicBranch, baseBranch, command, cwd);
221
+ if (created.kind === "no_commits") {
222
+ return { kind: "deferred", reason: "no_commits_between_branches" };
223
+ }
224
+ if (created.kind === "created") {
225
+ // Resolve the number through a fresh matching probe first; fall back to the
226
+ // strict URL-derived hint only when the probe cannot confirm it.
227
+ const reprobe = await probeMatchingOpenPr(gh, epicBranch, baseBranch, cwd);
228
+ const prNumber = reprobe.kind === "found" ? reprobe.number : created.numberHint;
229
+ const isDraft = reprobe.kind === "found" ? reprobe.isDraft : true;
230
+ const readiness = await maybeMakeReady(gh, prNumber, isDraft, requestReady, cwd);
231
+ return { kind: "created", reason: "created", prNumber, readiness };
232
+ }
233
+ // Generic create failure: resolve a possible concurrent creator once, never more.
234
+ const race = await probeMatchingOpenPr(gh, epicBranch, baseBranch, cwd);
235
+ if (race.kind === "found") {
236
+ const readiness = await maybeMakeReady(gh, race.number, race.isDraft, requestReady, cwd);
237
+ return { kind: "already_open", reason: "already_open", prNumber: race.number, readiness };
238
+ }
239
+ return { kind: "unavailable", reason: "create_unavailable" };
240
+ }
241
+ /**
242
+ * Ensure a matching draft epic-integration pull request exists. Idempotent and
243
+ * never throws or rejects — every internal failure, including an injected
244
+ * runner throwing, resolves to `{ kind: "unavailable", ... }`.
245
+ */
246
+ export async function ensureEpicIntegrationPullRequest(options) {
247
+ try {
248
+ return await ensureEpicIntegrationPullRequestInner(options);
249
+ }
250
+ catch {
251
+ return { kind: "unavailable", reason: "probe_inconclusive" };
252
+ }
253
+ }
254
+ /**
255
+ * Build the production `gh` runner. Uses `execFile` (never a shell, never
256
+ * interpolated argv), ignores stdin, captures stdout/stderr, and bounds both
257
+ * the timeout and the output buffer. Every process-level failure (missing
258
+ * binary, timeout, spawn error) resolves a non-zero/timed-out result rather
259
+ * than rejecting.
260
+ */
261
+ export function createProductionEpicIntegrationGhRunner(execFileImpl = nodeExecFile) {
262
+ return (args, options) => new Promise((resolve) => {
263
+ execFileImpl("gh", args, {
264
+ cwd: options.cwd,
265
+ timeout: options.timeoutMs,
266
+ maxBuffer: 4 * 1024 * 1024,
267
+ encoding: "utf-8",
268
+ shell: false,
269
+ stdio: ["ignore", "pipe", "pipe"],
270
+ }, (error, stdout, stderr) => {
271
+ if (error) {
272
+ const timedOut = error.killed === true;
273
+ const code = typeof error.code === "number" ? error.code : null;
274
+ resolve({ exitCode: code, stdout: stdout ?? "", stderr: stderr ?? "", timedOut });
275
+ return;
276
+ }
277
+ resolve({ exitCode: 0, stdout: stdout ?? "", stderr: stderr ?? "", timedOut: false });
278
+ });
279
+ });
280
+ }
@@ -392,7 +392,7 @@ async function checkAdapterMcpAdvisoryForPreparedWorktree(worktreePath, deps, ad
392
392
  * `controls.signal` (overall-timeout abort) is threaded into local merge deps
393
393
  * without changing the local `gh` credential model.
394
394
  */
395
- export async function defaultRunMergeForClaimed(job, deps, _options, controls) {
395
+ export async function defaultRunMergeForClaimed(job, deps, options, controls) {
396
396
  const accessResult = await buildConductorMergeAccessForExecutorJob({
397
397
  env: deps.env,
398
398
  cwd: deps.cwd,
@@ -419,6 +419,12 @@ export async function defaultRunMergeForClaimed(job, deps, _options, controls) {
419
419
  // Thread the overall-timeout abort signal into local merge deps WITHOUT
420
420
  // changing the local `gh` credential model (the signal is not a credential).
421
421
  localMergeDeps: buildDefaultMergeLocalDeps(deps, controls?.signal),
422
+ // BAPI-951: the executor's own configured base — resolveExecutorJobBaseBranch
423
+ // (called inside runExecutorMergeJob) resolves the CHILD PR's base from the
424
+ // job's persisted run base, falling back to this value for a legacy job.
425
+ repositoryBaseBranch: options.baseBranch,
426
+ epicIntegrationAdvisoryLog: deps.errorLog,
427
+ cwd: deps.cwd,
422
428
  });
423
429
  }
424
430
  /** An in-memory owned process for the no-op `smoke` acceptance job. */
@@ -23,7 +23,11 @@
23
23
  */
24
24
  import { makeLocalMergeExecutor, resolveLocalMergeMethod, } from "../conductor/local-merge.js";
25
25
  import { resolveConductorBridgeApiAccess, } from "../conductor/bridge-api-client.js";
26
+ import { createProductionEpicIntegrationGhRunner, ensureEpicIntegrationPullRequest, formatEpicIntegrationPullRequestOutcome, } from "../epic-integration-pr.js";
26
27
  import { secretFreeErrorMessage } from "./job-errors.js";
28
+ import { resolveExecutorJobBaseBranch } from "./base-branch.js";
29
+ /** `epic/<KEY>` prefix a child PR's base branch carries under BAPI-949/BAPI-950. */
30
+ const EPIC_BRANCH_PREFIX = "epic/";
27
31
  function positiveIntOrNull(value) {
28
32
  return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : null;
29
33
  }
@@ -173,6 +177,43 @@ export function buildMergeJobFailure(response) {
173
177
  classification: "crashed",
174
178
  };
175
179
  }
180
+ /**
181
+ * Post-merge draft epic-integration-PR retry (BAPI-951). Invoked ONLY after a
182
+ * successful ticket-bound merge whose child PR based on `epic/<EPIC>` — never
183
+ * for a `main`/other-based merge, and never for any failed merge. Wrapped in
184
+ * its own try/catch (the shared helper is already non-throwing, but this
185
+ * guards against a future/injected implementation replacing an already-decided
186
+ * merge outcome). NEVER changes the returned result, the `error_kind`, or the
187
+ * `/complete` payload — only emits a sanitized advisory line.
188
+ */
189
+ async function tryEnsurePostMergeEpicIntegrationPr(job, seams) {
190
+ if (seams.repositoryBaseBranch === undefined)
191
+ return;
192
+ try {
193
+ const baseResolution = resolveExecutorJobBaseBranch(job, seams.repositoryBaseBranch);
194
+ if (!baseResolution.ok)
195
+ return;
196
+ const childBaseBranch = baseResolution.baseBranch;
197
+ if (!childBaseBranch.startsWith(EPIC_BRANCH_PREFIX))
198
+ return;
199
+ const epicKey = childBaseBranch.slice(EPIC_BRANCH_PREFIX.length);
200
+ if (epicKey.length === 0)
201
+ return;
202
+ const gh = seams.epicIntegrationGh ?? createProductionEpicIntegrationGhRunner();
203
+ const outcome = await ensureEpicIntegrationPullRequest({
204
+ epicKey,
205
+ epicBranch: childBaseBranch,
206
+ baseBranch: seams.repositoryBaseBranch,
207
+ command: "executor merge",
208
+ gh,
209
+ cwd: seams.cwd,
210
+ });
211
+ seams.epicIntegrationAdvisoryLog?.(`epic integration pr: ${JSON.stringify(formatEpicIntegrationPullRequestOutcome(outcome))}`);
212
+ }
213
+ catch {
214
+ // Advisory-only: never allowed to affect the already-decided merge outcome.
215
+ }
216
+ }
176
217
  /**
177
218
  * Run the deterministic merge. NEVER ensures/recreates a worktree and NEVER
178
219
  * spawns a worker. Returns a completion result on a succeeded merge, else a
@@ -213,7 +254,11 @@ export async function runExecutorMergeJob(job, seams) {
213
254
  };
214
255
  }
215
256
  if (response.status === "succeeded") {
216
- return { ok: true, result: buildMergeJobResult(response, fields) };
257
+ // Build the successful outcome COMPLETELY first — enrichment can only ever
258
+ // add an advisory log line, never alter what is returned.
259
+ const result = buildMergeJobResult(response, fields);
260
+ await tryEnsurePostMergeEpicIntegrationPr(job, seams);
261
+ return { ok: true, result };
217
262
  }
218
263
  return { ok: false, failure: buildMergeJobFailure(response) };
219
264
  }