@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.
- package/README.md +7 -7
- package/build/conduct-epic/bridge-client.js +115 -1
- package/build/conduct-epic/cli.js +351 -33
- package/build/conduct-epic/cut-protocol.js +51 -0
- package/build/doctor.js +190 -0
- package/build/epic-integration-pr.js +280 -0
- package/build/executor/job-runner.js +7 -1
- package/build/executor/merge-job.js +46 -1
- package/build/index.js +71 -24
- package/build/pipelines.generated.js +7 -1
- package/build/plan-epic-conductor-eligibility.js +183 -0
- package/build/readme.generated.js +1 -1
- package/build/setup-epic.js +32 -0
- package/build/sfcc/reads-custom-object-def.js +10 -13
- package/build/sfcc/reads-site-preference.js +5 -5
- package/build/sfcc/reads-system-object.js +4 -4
- package/build/sfcc/writes-custom-object-def.js +7 -7
- package/build/sfcc/writes-site-preference.js +4 -3
- package/build/sfcc/writes-system-object.js +7 -6
- package/build/version.generated.js +3 -3
- package/package.json +3 -2
- package/pipelines/plan-epic.json +5 -0
|
@@ -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,
|
|
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
|
-
|
|
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
|
}
|