@bridge_gpt/mcp-server 0.2.21 → 0.2.24
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 +144 -18
- package/build/base-ref.js +151 -0
- package/build/commands.generated.js +6 -4
- package/build/conductor/bridge-api-client.js +44 -3
- package/build/conductor/doctor.js +33 -22
- package/build/conductor/epic-runtime.js +101 -5
- package/build/conductor/pr-ci-producer.js +21 -2
- package/build/conductor/pr-discovery.js +12 -2
- package/build/conductor-bin.js +50 -20
- package/build/credential-store.js +564 -64
- package/build/decision-page-template.js +9 -4
- package/build/docs.generated.js +5 -0
- package/build/executor/base-branch.js +50 -0
- package/build/executor/env.js +12 -1
- package/build/executor/job-errors.js +1 -0
- package/build/executor/job-runner.js +38 -7
- package/build/executor/test-clock.js +6 -1
- package/build/executor/worker-finalization.js +88 -1
- package/build/executor/worktree.js +21 -1
- package/build/index.js +2741 -702
- package/build/init.js +29 -0
- package/build/install-bridge.js +1076 -114
- package/build/pipelines.generated.js +2 -2
- package/build/pr-base-contract.js +36 -0
- package/build/readme.generated.js +1 -1
- package/build/setup-epic.js +483 -0
- package/build/sfcc/log-gate.js +85 -0
- package/build/sfcc/log-query.js +170 -0
- package/build/sfcc/register.js +10 -0
- package/build/sfcc/setup-status.js +33 -3
- package/build/start-tickets.js +164 -75
- package/build/version.generated.js +1 -1
- package/build/worktree-core.js +62 -10
- package/{CONDUCTOR.md → docs/CONDUCTOR.md} +88 -29
- package/docs/install/github-app.md +189 -0
- package/docs/install/mcp-tool-integrations.md +305 -0
- package/docs/install/sfcc-integration.md +140 -0
- package/package.json +5 -5
- package/public/js/main.min.js +55 -10
- package/public/js/main.min.js.map +1 -1
- package/smoke-test/SMOKE-TEST.md +3 -2
|
@@ -31,7 +31,8 @@ export async function resolveConductorBridgeApiAccess(deps = {}) {
|
|
|
31
31
|
const platform = deps.platform ?? process.platform;
|
|
32
32
|
const readFileImpl = deps.readFile ?? ((p) => readFile(p, "utf-8"));
|
|
33
33
|
const statImpl = deps.stat ?? ((p) => stat(p));
|
|
34
|
-
const repoName =
|
|
34
|
+
const repoName = deps.repoName?.trim() ||
|
|
35
|
+
(await resolveStartTicketsRepoName({ env, cwd, readFile: readFileImpl }));
|
|
35
36
|
if (!repoName) {
|
|
36
37
|
return {
|
|
37
38
|
ok: false,
|
|
@@ -670,6 +671,38 @@ export async function fetchActiveEpicRuns(access, fetchImpl = globalThis.fetch)
|
|
|
670
671
|
}
|
|
671
672
|
return [];
|
|
672
673
|
}
|
|
674
|
+
/**
|
|
675
|
+
* POST `/jira/epic-runs/runs` to create the durable epic run row.
|
|
676
|
+
*
|
|
677
|
+
* The server is idempotent: if a non-terminal run already exists for this
|
|
678
|
+
* `(repo_name, epic_key)` it returns that run (HTTP 200) instead of minting a
|
|
679
|
+
* second one, and does not re-charge the automation-start debit. A second active
|
|
680
|
+
* run would wedge the epic permanently — every later store-plan / approve-plan
|
|
681
|
+
* call would 409 on "Multiple active runs" — so callers must NOT implement their
|
|
682
|
+
* own "POST and tolerate a conflict" retry.
|
|
683
|
+
*
|
|
684
|
+
* The API key travels ONLY in the `X-API-Key` header, never in the URL.
|
|
685
|
+
*/
|
|
686
|
+
export async function createEpicRun(access, request, fetchImpl = globalThis.fetch) {
|
|
687
|
+
requireNonEmptyString(request.epicKey);
|
|
688
|
+
const body = {
|
|
689
|
+
repo_name: access.repoName,
|
|
690
|
+
epic_key: request.epicKey,
|
|
691
|
+
status: request.status ?? "planning",
|
|
692
|
+
current_plan_version: request.currentPlanVersion ?? 0,
|
|
693
|
+
};
|
|
694
|
+
if (request.policyJson !== undefined)
|
|
695
|
+
body.policy_json = request.policyJson;
|
|
696
|
+
if (request.budgetWallClockSeconds !== undefined) {
|
|
697
|
+
body.budget_wall_clock_seconds = request.budgetWallClockSeconds;
|
|
698
|
+
}
|
|
699
|
+
if (request.budgetCostCents !== undefined) {
|
|
700
|
+
body.budget_cost_cents = request.budgetCostCents;
|
|
701
|
+
}
|
|
702
|
+
const url = buildConductorJiraUrl(access.baseUrl, `${EPIC_RUNS_API_PREFIX}/runs`);
|
|
703
|
+
const parsed = await fetchConductorJsonPostWithTimeout(url, conductorPostHeaders(access), JSON.stringify(body), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
704
|
+
return parsed;
|
|
705
|
+
}
|
|
673
706
|
/**
|
|
674
707
|
* PATCH `/jira/epic-runs/runs/{identifier}` to transition an epic run's
|
|
675
708
|
* lifecycle status. Drives the same backend `update_epic_run` CAS path the
|
|
@@ -909,8 +942,16 @@ export async function approveEpicPlan(access, request, fetchImpl = globalThis.fe
|
|
|
909
942
|
}
|
|
910
943
|
catch (error) {
|
|
911
944
|
if (error instanceof ConductorBridgeApiError && error.status === 409) {
|
|
912
|
-
//
|
|
913
|
-
//
|
|
945
|
+
// The server overloads 409: "a later version is already approved"
|
|
946
|
+
// (benign — the caller is simply behind) and "multiple active runs"
|
|
947
|
+
// (the epic is WEDGED and every later plan call will 409 forever).
|
|
948
|
+
// Collapsing both to "superseded" reports success on a broken epic, so
|
|
949
|
+
// discriminate on the body. `bodyPreview` carries the server's bare-string
|
|
950
|
+
// FastAPI `detail`, which is exactly where that distinction lives.
|
|
951
|
+
const preview = error.bodyPreview ?? "";
|
|
952
|
+
if (/multiple active runs/i.test(preview)) {
|
|
953
|
+
return { ok: false, kind: "conflict", reason: "multiple_active_runs" };
|
|
954
|
+
}
|
|
914
955
|
return { ok: false, kind: "conflict", reason: "superseded" };
|
|
915
956
|
}
|
|
916
957
|
throw error;
|
|
@@ -146,35 +146,36 @@ export async function inspectEpicTickSchedule(deps, orchestrateListOverride) {
|
|
|
146
146
|
const report = await doList(resolvedDeps);
|
|
147
147
|
const entry = report.entries.find((e) => commandLabel(e.metadata) === "epic-tick");
|
|
148
148
|
if (!entry) {
|
|
149
|
+
// The healthy state. Epic Conductor v2 reconciles server-side; there is
|
|
150
|
+
// nothing for an operator to schedule locally.
|
|
149
151
|
return {
|
|
150
152
|
registered: false,
|
|
151
153
|
backend: null,
|
|
152
154
|
next_fire_iso: null,
|
|
153
155
|
latest_run_status: null,
|
|
154
|
-
degraded:
|
|
155
|
-
warnings: [
|
|
156
|
-
"No epic-tick schedule is registered. Run: " +
|
|
157
|
-
"npx -y @bridge_gpt/mcp-server schedule-run create --in 1m --command epic-tick -- --epic-key <EPIC-KEY>",
|
|
158
|
-
],
|
|
156
|
+
degraded: false,
|
|
157
|
+
warnings: [],
|
|
159
158
|
};
|
|
160
159
|
}
|
|
160
|
+
// A registered epic-tick schedule is a dead timer: the v1 command throws
|
|
161
|
+
// EPIC_TICK_V1_FROZEN on every fire (see conductor/errors.ts). Anyone who has
|
|
162
|
+
// one registered followed the old advice and is now firing a no-op on a timer.
|
|
161
163
|
const m = entry.metadata;
|
|
162
164
|
const latest = runStatus(m);
|
|
163
|
-
const degraded = entry.status !== "active";
|
|
164
|
-
const warnings = [];
|
|
165
|
-
if (degraded) {
|
|
166
|
-
const msg = entry.status === "backend-unavailable"
|
|
167
|
-
? `epic-tick schedule status is "backend-unavailable": the OS scheduler backend is unreachable. Check that the scheduler daemon is running.`
|
|
168
|
-
: `epic-tick schedule status is "${entry.status}" (expected "active"); re-register if stale.`;
|
|
169
|
-
warnings.push(msg);
|
|
170
|
-
}
|
|
171
165
|
return {
|
|
172
166
|
registered: true,
|
|
173
167
|
backend: m.backend ?? null,
|
|
174
168
|
next_fire_iso: m.run_at_iso ?? null,
|
|
175
169
|
latest_run_status: latest || null,
|
|
176
|
-
degraded,
|
|
177
|
-
warnings
|
|
170
|
+
degraded: true,
|
|
171
|
+
warnings: [
|
|
172
|
+
"An epic-tick schedule is registered, but the v1 `conductor epic-tick` " +
|
|
173
|
+
"path is frozen (EPIC_TICK_V1_FROZEN) — it advances nothing. Cancel it: " +
|
|
174
|
+
"`npx -y @bridge_gpt/mcp-server schedule-run cancel --id " +
|
|
175
|
+
`${entry.metadata.id ?? "<id>"}\`. ` +
|
|
176
|
+
"Epic Conductor v2 reconciles server-side; run jobs locally with " +
|
|
177
|
+
"`npx -y @bridge_gpt/mcp-server executor --repo <name>`.",
|
|
178
|
+
],
|
|
178
179
|
};
|
|
179
180
|
}
|
|
180
181
|
catch (err) {
|
|
@@ -368,13 +369,19 @@ export function formatConductorDoctorReport(report) {
|
|
|
368
369
|
lines.push(` - ${w}`);
|
|
369
370
|
}
|
|
370
371
|
lines.push("");
|
|
371
|
-
lines.push("Epic Supervisor Schedule (
|
|
372
|
-
lines.push("
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
372
|
+
lines.push("Epic Supervisor Schedule (v1 epic-tick — frozen)");
|
|
373
|
+
lines.push("───────────────────────────────────────────────");
|
|
374
|
+
// Inverted on purpose: no schedule is the healthy state. Epic Conductor v2
|
|
375
|
+
// reconciles server-side, so a registered epic-tick unit is a dead timer.
|
|
376
|
+
const registeredTag = epic_tick.registered
|
|
377
|
+
? "[WARNING] registered — dead timer, remove it"
|
|
378
|
+
: "[SUCCESS] none registered (v1 is frozen)";
|
|
379
|
+
lines.push(`epic-tick schedule: ${registeredTag}`);
|
|
380
|
+
if (epic_tick.registered) {
|
|
381
|
+
lines.push(`backend: ${epic_tick.backend ?? "n/a"}`);
|
|
382
|
+
lines.push(`next fire: ${epic_tick.next_fire_iso ?? "n/a"}`);
|
|
383
|
+
lines.push(`latest run status: ${epic_tick.latest_run_status ?? "n/a"}`);
|
|
384
|
+
}
|
|
378
385
|
lines.push(`degraded: ${epic_tick.degraded}`);
|
|
379
386
|
if (epic_tick.warnings.length > 0) {
|
|
380
387
|
lines.push("epic-tick warnings:");
|
|
@@ -382,6 +389,10 @@ export function formatConductorDoctorReport(report) {
|
|
|
382
389
|
lines.push(` - ${w}`);
|
|
383
390
|
}
|
|
384
391
|
lines.push("");
|
|
392
|
+
lines.push("Epic Conductor v2 reconciles epics server-side — nothing to schedule");
|
|
393
|
+
lines.push("locally. To execute claimed jobs on this machine, run:");
|
|
394
|
+
lines.push(" npx -y @bridge_gpt/mcp-server executor --repo <name>");
|
|
395
|
+
lines.push("");
|
|
385
396
|
lines.push("MCP Profile (optional, local)");
|
|
386
397
|
lines.push("─────────────────────────────");
|
|
387
398
|
const profileTag = mcp_profile.degraded ? "[WARNING] degraded" : "[OK]";
|
|
@@ -38,6 +38,7 @@ import { makeSupervisorIdempotencyKey } from "./supervisor-ledger.js";
|
|
|
38
38
|
import { createDefaultStartTicketsDeps, orchestrateStartTickets } from "../start-tickets.js";
|
|
39
39
|
import { orchestrateReviewTickets } from "../review-tickets.js";
|
|
40
40
|
import { createStartTicketsConductorContext, provisionConductorHooksForRows, emitStartTicketsRunStarted, } from "../start-tickets-conductor.js";
|
|
41
|
+
import { validateBranchName } from "../base-ref.js";
|
|
41
42
|
// ---------------------------------------------------------------------------
|
|
42
43
|
// Constants
|
|
43
44
|
// ---------------------------------------------------------------------------
|
|
@@ -92,11 +93,14 @@ export function parsePrBindingFromGhJson(stdout) {
|
|
|
92
93
|
// BAPI-494: parse mergeability defensively from the same JSON object — unknown
|
|
93
94
|
// values become null and never reject an otherwise valid open PR binding.
|
|
94
95
|
const mergeability = parseGhPrMergeabilityFields(pr);
|
|
96
|
+
const rawBase = pr.baseRefName;
|
|
97
|
+
const baseRef = typeof rawBase === "string" && rawBase.trim().length > 0 ? rawBase.trim() : undefined;
|
|
95
98
|
return {
|
|
96
99
|
prNumber: num,
|
|
97
100
|
headSha: sha,
|
|
98
101
|
mergeable: mergeability.mergeable,
|
|
99
102
|
mergeStateStatus: mergeability.mergeStateStatus,
|
|
103
|
+
...(baseRef !== undefined ? { baseRef } : {}),
|
|
100
104
|
};
|
|
101
105
|
}
|
|
102
106
|
return null;
|
|
@@ -120,7 +124,8 @@ export function resolveTicketPrBindingFromGh(ticketKey, options = {}) {
|
|
|
120
124
|
const ghRes = runGh(
|
|
121
125
|
// BAPI-494: mergeability fields added to the SAME per-ticket binding call — the
|
|
122
126
|
// done-gate reads mergeability inside this existing call, spawning no new gh process.
|
|
123
|
-
|
|
127
|
+
// BAPI-586: baseRefName added to the SAME call so the done-gate can detect a wrong-base PR.
|
|
128
|
+
["pr", "view", `feature/${ticketKey}`, "--json", "number,headRefOid,state,mergeable,mergeStateStatus,baseRefName"], { cwd: options.cwd ?? process.cwd() });
|
|
124
129
|
if (ghRes.ok && ghRes.stdout.trim()) {
|
|
125
130
|
const parsed = parsePrBindingFromGhJson(ghRes.stdout);
|
|
126
131
|
// Normalize the head SHA to lowercase so downstream head-scoped comparisons
|
|
@@ -432,6 +437,22 @@ export async function runConductorDoneGatePass(ticketStatuses, deps) {
|
|
|
432
437
|
}
|
|
433
438
|
continue;
|
|
434
439
|
}
|
|
440
|
+
// BAPI-586: block a wrong-base PR at reconciliation BEFORE any CI/review
|
|
441
|
+
// evaluation. The comparison uses the ACTUAL observed base (`prBinding.baseRef`,
|
|
442
|
+
// from the SAME gh binding call) rather than delegating to observePrCiOnce —
|
|
443
|
+
// that call receives EXPLICIT pr_number/head_sha and so takes the no-discovery
|
|
444
|
+
// binding path, where `base_ref` is never populated. Fail-open when the base is
|
|
445
|
+
// unknown (`baseRef` absent) so a correctly-based PR is NEVER falsely blocked;
|
|
446
|
+
// only a CONFIRMED mismatch skips the ticket. The executor finalization guard
|
|
447
|
+
// remains the primary layer; this is the reconciliation backstop.
|
|
448
|
+
if (deps.expectedBaseBranch &&
|
|
449
|
+
typeof prBinding.baseRef === "string" &&
|
|
450
|
+
prBinding.baseRef !== deps.expectedBaseBranch) {
|
|
451
|
+
deps.log(`[epic-tick] done-gate poll for ${ticketKey}: pr-base-mismatch — PR #${prBinding.prNumber} ` +
|
|
452
|
+
`targets base '${prBinding.baseRef}' but the run base is '${deps.expectedBaseBranch}'; ` +
|
|
453
|
+
`not emitting gate.met (rebuild from origin/${deps.expectedBaseBranch}, do not retarget the PR in the UI)`);
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
435
456
|
const perTicketEnv = {
|
|
436
457
|
...deps.env,
|
|
437
458
|
...(runId ? { BAPI_CONDUCTOR_RUN_ID: runId } : {}),
|
|
@@ -1070,6 +1091,16 @@ export async function runEpicTick(options, deps = {}) {
|
|
|
1070
1091
|
// tickets, gated by quiescence. Invert the impl-dispatch maps so ledger
|
|
1071
1092
|
// events can be attributed back to a ticket for local-first PR binding.
|
|
1072
1093
|
const dispatchedBackstopPolicy = resolveDispatchedBackstopPolicy(epicRunState.epic_run.policy_json);
|
|
1094
|
+
// BAPI-586: resolve the run's configured base branch so the done-gate pass
|
|
1095
|
+
// can catch a wrong-base PR (one not targeting the run base) at
|
|
1096
|
+
// reconciliation time — the backstop to the executor's own finalization
|
|
1097
|
+
// guard. A malformed configured base defaults to undefined (base check
|
|
1098
|
+
// skipped) rather than failing the whole tick; dispatch already fails
|
|
1099
|
+
// closed on a malformed base separately.
|
|
1100
|
+
const runBaseResolution = resolveConfiguredRunBaseBranch(epicRunState.epic_run.policy_json);
|
|
1101
|
+
const doneGateExpectedBaseBranch = runBaseResolution.ok
|
|
1102
|
+
? runBaseResolution.baseBranch
|
|
1103
|
+
: undefined;
|
|
1073
1104
|
const ticketForRunId = new Map();
|
|
1074
1105
|
for (const [tk, rid] of ticketRunIdMap)
|
|
1075
1106
|
ticketForRunId.set(rid, tk);
|
|
@@ -1088,6 +1119,10 @@ export async function runEpicTick(options, deps = {}) {
|
|
|
1088
1119
|
});
|
|
1089
1120
|
await runConductorDoneGatePass(observed.ticket_statuses, {
|
|
1090
1121
|
observePrCi: observePrCiSeamFn,
|
|
1122
|
+
// BAPI-586: the run base so the done-gate pass fails a wrong-base PR
|
|
1123
|
+
// before CI/review evaluation (reconciliation backstop to executor
|
|
1124
|
+
// finalization).
|
|
1125
|
+
expectedBaseBranch: doneGateExpectedBaseBranch,
|
|
1091
1126
|
resolvePrBinding,
|
|
1092
1127
|
// BAPI-525 Change B: local-first binding + quiescence gate for the new
|
|
1093
1128
|
// dispatched/running admission (policy default OFF ⇒ no behavior change).
|
|
@@ -1491,9 +1526,41 @@ export async function runEpicTick(options, deps = {}) {
|
|
|
1491
1526
|
}
|
|
1492
1527
|
}
|
|
1493
1528
|
}
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1529
|
+
/**
|
|
1530
|
+
* BAPI-586: resolve the epic run's configured base branch from `policy_json`
|
|
1531
|
+
* (`base_branch`, else `baseBranch`), mirroring the server-side reconciler's
|
|
1532
|
+
* spec-review base resolution. The authoritative run base — NOT a hard-coded
|
|
1533
|
+
* `main` — is what every fresh dispatch and PR must target.
|
|
1534
|
+
*
|
|
1535
|
+
* - absent/empty → `main` (the legacy/default value).
|
|
1536
|
+
* - present and a valid branch name → that branch (e.g. `develop`).
|
|
1537
|
+
* - present but malformed (non-string, `..`, `.lock`, control chars, leading `-`)
|
|
1538
|
+
* → a contract failure so dispatch fails CLOSED rather than emitting a malformed
|
|
1539
|
+
* base (a wrong base is exactly the BAPI-586 defect).
|
|
1540
|
+
*/
|
|
1541
|
+
export function resolveConfiguredRunBaseBranch(policyJson) {
|
|
1542
|
+
const DEFAULT_BASE = "main";
|
|
1543
|
+
if (!policyJson || typeof policyJson !== "object") {
|
|
1544
|
+
return { ok: true, baseBranch: DEFAULT_BASE };
|
|
1545
|
+
}
|
|
1546
|
+
const raw = policyJson.base_branch ??
|
|
1547
|
+
policyJson.baseBranch;
|
|
1548
|
+
if (raw === undefined || raw === null) {
|
|
1549
|
+
return { ok: true, baseBranch: DEFAULT_BASE };
|
|
1550
|
+
}
|
|
1551
|
+
if (typeof raw !== "string") {
|
|
1552
|
+
return { ok: false, error: "epic run policy base_branch is present but is not a string." };
|
|
1553
|
+
}
|
|
1554
|
+
const trimmed = raw.trim();
|
|
1555
|
+
if (trimmed.length === 0) {
|
|
1556
|
+
return { ok: true, baseBranch: DEFAULT_BASE };
|
|
1557
|
+
}
|
|
1558
|
+
const validationError = validateBranchName(trimmed);
|
|
1559
|
+
if (validationError) {
|
|
1560
|
+
return { ok: false, error: `epic run policy base_branch is invalid: ${validationError}` };
|
|
1561
|
+
}
|
|
1562
|
+
return { ok: true, baseBranch: trimmed };
|
|
1563
|
+
}
|
|
1497
1564
|
/**
|
|
1498
1565
|
* Build the production EpicRuntimeDeps for use inside `runEpicTickCommand`.
|
|
1499
1566
|
*
|
|
@@ -1515,6 +1582,20 @@ export async function buildProductionEpicRuntimeDeps(epicKey) {
|
|
|
1515
1582
|
process.stderr.write(`[epic-tick] factory: observe-only — bridge access resolution failed\n`);
|
|
1516
1583
|
return {};
|
|
1517
1584
|
}
|
|
1585
|
+
// BAPI-586: resolve the run's configured base branch ONCE (from the epic run's
|
|
1586
|
+
// policy_json) so every fresh dispatch cuts from — and every PR targets — the
|
|
1587
|
+
// authoritative run base rather than a hard-coded `main`. Fail-open on a fetch
|
|
1588
|
+
// error (observe-only would already default to `main`); a malformed configured
|
|
1589
|
+
// base is captured as an error and re-raised at dispatch time so a bad base
|
|
1590
|
+
// never silently dispatches.
|
|
1591
|
+
let cachedRunBase = { ok: true, baseBranch: "main" };
|
|
1592
|
+
try {
|
|
1593
|
+
const runState = await fetchEpicRunState(access, epicKey);
|
|
1594
|
+
cachedRunBase = resolveConfiguredRunBaseBranch(runState.epic_run.policy_json);
|
|
1595
|
+
}
|
|
1596
|
+
catch {
|
|
1597
|
+
cachedRunBase = { ok: true, baseBranch: "main" };
|
|
1598
|
+
}
|
|
1518
1599
|
// Shared closure state populated by fetchPlan and consumed by dispatchSeam.
|
|
1519
1600
|
let cachedPlanVersion = 0;
|
|
1520
1601
|
const automationMap = new Map();
|
|
@@ -1579,6 +1660,12 @@ export async function buildProductionEpicRuntimeDeps(epicKey) {
|
|
|
1579
1660
|
if (cachedPlanVersion === 0) {
|
|
1580
1661
|
throw new Error(`dispatchSeam called before fetchPlan for epic ${ek} ticket ${tk}; cachedPlanVersion is 0`);
|
|
1581
1662
|
}
|
|
1663
|
+
// BAPI-586: fail CLOSED on a malformed configured run base rather than
|
|
1664
|
+
// dispatching a worker (and opening a PR) against a bad base.
|
|
1665
|
+
if (!cachedRunBase.ok) {
|
|
1666
|
+
throw new Error(`invalid configured base branch for epic ${ek}: ${cachedRunBase.error}`);
|
|
1667
|
+
}
|
|
1668
|
+
const runBaseBranch = cachedRunBase.baseBranch;
|
|
1582
1669
|
// BAPI-441: a remediation re-dispatch (attempt > 0) reuses the existing
|
|
1583
1670
|
// branch/worktree (resume mode) and claims an attempt-scoped dispatch key so
|
|
1584
1671
|
// it is not deduped against the original epic dispatch.
|
|
@@ -1601,6 +1688,9 @@ export async function buildProductionEpicRuntimeDeps(epicKey) {
|
|
|
1601
1688
|
epic_run_id: ek,
|
|
1602
1689
|
plan_version: cachedPlanVersion,
|
|
1603
1690
|
dispatch_key: buildEpicDispatchKey(ek, tk, cachedPlanVersion, attempt),
|
|
1691
|
+
// BAPI-586: carry the authoritative run base so orchestrateStartTickets
|
|
1692
|
+
// seeds worktrees from — and workers target their PR at — the run base.
|
|
1693
|
+
base_branch: runBaseBranch,
|
|
1604
1694
|
...(declaredTouchedFiles && declaredTouchedFiles.length > 0
|
|
1605
1695
|
? { declared_touched_files: declaredTouchedFiles }
|
|
1606
1696
|
: {}),
|
|
@@ -1655,8 +1745,14 @@ export async function buildProductionEpicRuntimeDeps(epicKey) {
|
|
|
1655
1745
|
// checkout. Interactive start-tickets keeps the old refreshBaseBranch path.
|
|
1656
1746
|
nonMutatingBase: true,
|
|
1657
1747
|
branchOverrides: {},
|
|
1658
|
-
|
|
1748
|
+
// BAPI-586: the authoritative run base (retains "main" as the default when
|
|
1749
|
+
// no custom base is configured), not a hard-coded literal. Also carried on
|
|
1750
|
+
// `identity.base_branch` above; orchestrateStartTickets reconciles both.
|
|
1751
|
+
baseBranch: runBaseBranch,
|
|
1659
1752
|
conductorEnabled: true,
|
|
1753
|
+
// Epic dispatch always runs the plain implementation workflow; the
|
|
1754
|
+
// review-and-implement workflow is a manual/front-door-only seam.
|
|
1755
|
+
workflow: "implement",
|
|
1660
1756
|
// BAPI-441: re-dispatch reuses the existing branch/worktree.
|
|
1661
1757
|
resumeMode: isResume,
|
|
1662
1758
|
// F7: on a FRESH dispatch, refuse a stale leftover `feature/<KEY>` branch
|
|
@@ -128,7 +128,7 @@ function defaultSleep(ms) {
|
|
|
128
128
|
* and {@link waitForDoneGate} so the gate config / binding / access are each
|
|
129
129
|
* resolved exactly once by the caller.
|
|
130
130
|
*/
|
|
131
|
-
async function observeWithResolved(binding, access, gateConfig, deps) {
|
|
131
|
+
async function observeWithResolved(binding, access, gateConfig, deps, expectedBaseBranch) {
|
|
132
132
|
// The event-WRITE sink defaults to the in-process store; the worker gate path
|
|
133
133
|
// injects a CLI-subprocess emitter (BAPI-527). When a caller supplies its own
|
|
134
134
|
// `emitIfNew` (e.g. unit tests / conductor-owned callers), that fully overrides
|
|
@@ -169,6 +169,25 @@ async function observeWithResolved(binding, access, gateConfig, deps) {
|
|
|
169
169
|
head_sha: binding.head_sha,
|
|
170
170
|
});
|
|
171
171
|
result.pr_opened_emitted = prDecision.emitted;
|
|
172
|
+
// 1a. BAPI-586: fail CLOSED on a wrong-base PR BEFORE evaluating CI or review.
|
|
173
|
+
// A PR that targets a branch other than the run base never triggers the
|
|
174
|
+
// review/CI workflows scoped to that base, so polling for their evidence would
|
|
175
|
+
// hang forever. Compare exact logical branch names (never merge-base or local
|
|
176
|
+
// ancestry). This guard is observational and idempotent: it emits no gate event,
|
|
177
|
+
// performs no retarget/rewrite, and returns the same result for repeated passes.
|
|
178
|
+
const expectedBase = typeof expectedBaseBranch === "string" ? expectedBaseBranch.trim() : "";
|
|
179
|
+
if (expectedBase) {
|
|
180
|
+
const observedBase = typeof binding.base_ref === "string" ? binding.base_ref.trim() : "";
|
|
181
|
+
if (observedBase !== expectedBase) {
|
|
182
|
+
const actual = observedBase.length > 0 ? observedBase : "(unresolved)";
|
|
183
|
+
result.gate_met = false;
|
|
184
|
+
result.reason =
|
|
185
|
+
`pr-base-mismatch: PR #${binding.pr_number} targets base '${actual}' but the run base is ` +
|
|
186
|
+
`'${expectedBase}'. Rebuild the branch from fresh origin/${expectedBase} and cherry-pick only ` +
|
|
187
|
+
`this ticket's commits; do not retarget the PR base in the GitHub UI.`;
|
|
188
|
+
return result;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
172
191
|
// 2. Poll CI for the bound head SHA. A poll failure is NOT a CI failure event.
|
|
173
192
|
let rawPoll;
|
|
174
193
|
try {
|
|
@@ -279,7 +298,7 @@ export async function observePrCiOnce(params = {}, deps = {}) {
|
|
|
279
298
|
rawConfig = undefined; // fail closed
|
|
280
299
|
}
|
|
281
300
|
const gateConfig = parseDoneGateConfig(rawConfig);
|
|
282
|
-
return observeWithResolved(bindingResult.binding, accessResult.access, gateConfig, deps);
|
|
301
|
+
return observeWithResolved(bindingResult.binding, accessResult.access, gateConfig, deps, params.expectedBaseBranch);
|
|
283
302
|
}
|
|
284
303
|
function clampInt(value, fallback, min, max) {
|
|
285
304
|
if (typeof value !== "number" || !Number.isFinite(value))
|
|
@@ -36,7 +36,8 @@ const GH_PR_VIEW_ARGS = [
|
|
|
36
36
|
"view",
|
|
37
37
|
"--json",
|
|
38
38
|
// BAPI-494: mergeability fields added to the SAME one-shot call — no new gh process.
|
|
39
|
-
|
|
39
|
+
// BAPI-586: baseRefName added to the same call so a wrong-base PR is detectable.
|
|
40
|
+
"number,headRefOid,headRefName,baseRefName,url,state,mergeable,mergeStateStatus",
|
|
40
41
|
];
|
|
41
42
|
/**
|
|
42
43
|
* Perform a one-shot `gh pr view` lookup for the current branch's PR. Returns a
|
|
@@ -73,6 +74,9 @@ export function discoverPrWithGhCli(options = {}, deps = {}) {
|
|
|
73
74
|
if (typeof record.headRefName === "string" && record.headRefName.trim().length > 0) {
|
|
74
75
|
discovered.head_ref = record.headRefName.trim();
|
|
75
76
|
}
|
|
77
|
+
if (typeof record.baseRefName === "string" && record.baseRefName.trim().length > 0) {
|
|
78
|
+
discovered.base_ref = record.baseRefName.trim();
|
|
79
|
+
}
|
|
76
80
|
if (typeof record.url === "string" && record.url.trim().length > 0) {
|
|
77
81
|
discovered.url = record.url.trim();
|
|
78
82
|
}
|
|
@@ -89,6 +93,8 @@ function makeBinding(repo, prNumber, headSha, extra = {}) {
|
|
|
89
93
|
binding.url = extra.url;
|
|
90
94
|
if (extra.head_ref !== undefined)
|
|
91
95
|
binding.head_ref = extra.head_ref;
|
|
96
|
+
if (extra.base_ref !== undefined)
|
|
97
|
+
binding.base_ref = extra.base_ref;
|
|
92
98
|
return binding;
|
|
93
99
|
}
|
|
94
100
|
/**
|
|
@@ -140,6 +146,10 @@ export function resolvePrHeadBinding(input = {}, deps = {}) {
|
|
|
140
146
|
}
|
|
141
147
|
return {
|
|
142
148
|
ok: true,
|
|
143
|
-
binding: makeBinding(repo, prNumber, localSha, {
|
|
149
|
+
binding: makeBinding(repo, prNumber, localSha, {
|
|
150
|
+
url: pr.url,
|
|
151
|
+
head_ref: pr.head_ref,
|
|
152
|
+
base_ref: pr.base_ref,
|
|
153
|
+
}),
|
|
144
154
|
};
|
|
145
155
|
}
|
package/build/conductor-bin.js
CHANGED
|
@@ -5695,6 +5695,7 @@ __export(bridge_api_client_exports, {
|
|
|
5695
5695
|
buildConductorVcsUrl: () => buildConductorVcsUrl,
|
|
5696
5696
|
buildEpicDispatchKey: () => buildEpicDispatchKey,
|
|
5697
5697
|
claimEpicSupervisionLease: () => claimEpicSupervisionLease,
|
|
5698
|
+
createEpicRun: () => createEpicRun,
|
|
5698
5699
|
createEpicTicketStatus: () => createEpicTicketStatus,
|
|
5699
5700
|
deletePullRequestBranch: () => deletePullRequestBranch,
|
|
5700
5701
|
extractSanitizedErrorDiagnostics: () => extractSanitizedErrorDiagnostics,
|
|
@@ -5731,7 +5732,7 @@ async function resolveConductorBridgeApiAccess(deps = {}) {
|
|
|
5731
5732
|
const platform = deps.platform ?? process.platform;
|
|
5732
5733
|
const readFileImpl = deps.readFile ?? ((p) => readFile(p, "utf-8"));
|
|
5733
5734
|
const statImpl = deps.stat ?? ((p) => stat(p));
|
|
5734
|
-
const repoName = await resolveStartTicketsRepoName({ env, cwd, readFile: readFileImpl });
|
|
5735
|
+
const repoName = deps.repoName?.trim() || await resolveStartTicketsRepoName({ env, cwd, readFile: readFileImpl });
|
|
5735
5736
|
if (!repoName) {
|
|
5736
5737
|
return {
|
|
5737
5738
|
ok: false,
|
|
@@ -6161,6 +6162,31 @@ async function fetchActiveEpicRuns(access, fetchImpl = globalThis.fetch) {
|
|
|
6161
6162
|
}
|
|
6162
6163
|
return [];
|
|
6163
6164
|
}
|
|
6165
|
+
async function createEpicRun(access, request, fetchImpl = globalThis.fetch) {
|
|
6166
|
+
requireNonEmptyString(request.epicKey);
|
|
6167
|
+
const body = {
|
|
6168
|
+
repo_name: access.repoName,
|
|
6169
|
+
epic_key: request.epicKey,
|
|
6170
|
+
status: request.status ?? "planning",
|
|
6171
|
+
current_plan_version: request.currentPlanVersion ?? 0
|
|
6172
|
+
};
|
|
6173
|
+
if (request.policyJson !== void 0) body.policy_json = request.policyJson;
|
|
6174
|
+
if (request.budgetWallClockSeconds !== void 0) {
|
|
6175
|
+
body.budget_wall_clock_seconds = request.budgetWallClockSeconds;
|
|
6176
|
+
}
|
|
6177
|
+
if (request.budgetCostCents !== void 0) {
|
|
6178
|
+
body.budget_cost_cents = request.budgetCostCents;
|
|
6179
|
+
}
|
|
6180
|
+
const url = buildConductorJiraUrl(access.baseUrl, `${EPIC_RUNS_API_PREFIX}/runs`);
|
|
6181
|
+
const parsed = await fetchConductorJsonPostWithTimeout(
|
|
6182
|
+
url,
|
|
6183
|
+
conductorPostHeaders(access),
|
|
6184
|
+
JSON.stringify(body),
|
|
6185
|
+
CONDUCTOR_FETCH_TIMEOUT_MS,
|
|
6186
|
+
fetchImpl
|
|
6187
|
+
);
|
|
6188
|
+
return parsed;
|
|
6189
|
+
}
|
|
6164
6190
|
async function updateEpicRunStatus(access, request, fetchImpl = globalThis.fetch) {
|
|
6165
6191
|
requireNonEmptyString(request.epicKey);
|
|
6166
6192
|
const url = buildConductorJiraUrl(access.baseUrl, epicRunApiPath(request.epicKey));
|
|
@@ -6383,6 +6409,10 @@ async function approveEpicPlan(access, request, fetchImpl = globalThis.fetch) {
|
|
|
6383
6409
|
return parsed;
|
|
6384
6410
|
} catch (error) {
|
|
6385
6411
|
if (error instanceof ConductorBridgeApiError && error.status === 409) {
|
|
6412
|
+
const preview = error.bodyPreview ?? "";
|
|
6413
|
+
if (/multiple active runs/i.test(preview)) {
|
|
6414
|
+
return { ok: false, kind: "conflict", reason: "multiple_active_runs" };
|
|
6415
|
+
}
|
|
6386
6416
|
return { ok: false, kind: "conflict", reason: "superseded" };
|
|
6387
6417
|
}
|
|
6388
6418
|
throw error;
|
|
@@ -8250,27 +8280,21 @@ async function inspectEpicTickSchedule(deps, orchestrateListOverride) {
|
|
|
8250
8280
|
backend: null,
|
|
8251
8281
|
next_fire_iso: null,
|
|
8252
8282
|
latest_run_status: null,
|
|
8253
|
-
degraded:
|
|
8254
|
-
warnings: [
|
|
8255
|
-
"No epic-tick schedule is registered. Run: npx -y @bridge_gpt/mcp-server schedule-run create --in 1m --command epic-tick -- --epic-key <EPIC-KEY>"
|
|
8256
|
-
]
|
|
8283
|
+
degraded: false,
|
|
8284
|
+
warnings: []
|
|
8257
8285
|
};
|
|
8258
8286
|
}
|
|
8259
8287
|
const m = entry.metadata;
|
|
8260
8288
|
const latest = runStatus(m);
|
|
8261
|
-
const degraded = entry.status !== "active";
|
|
8262
|
-
const warnings = [];
|
|
8263
|
-
if (degraded) {
|
|
8264
|
-
const msg = entry.status === "backend-unavailable" ? `epic-tick schedule status is "backend-unavailable": the OS scheduler backend is unreachable. Check that the scheduler daemon is running.` : `epic-tick schedule status is "${entry.status}" (expected "active"); re-register if stale.`;
|
|
8265
|
-
warnings.push(msg);
|
|
8266
|
-
}
|
|
8267
8289
|
return {
|
|
8268
8290
|
registered: true,
|
|
8269
8291
|
backend: m.backend ?? null,
|
|
8270
8292
|
next_fire_iso: m.run_at_iso ?? null,
|
|
8271
8293
|
latest_run_status: latest || null,
|
|
8272
|
-
degraded,
|
|
8273
|
-
warnings
|
|
8294
|
+
degraded: true,
|
|
8295
|
+
warnings: [
|
|
8296
|
+
`An epic-tick schedule is registered, but the v1 \`conductor epic-tick\` path is frozen (EPIC_TICK_V1_FROZEN) \u2014 it advances nothing. Cancel it: \`npx -y @bridge_gpt/mcp-server schedule-run cancel --id ${entry.metadata.id ?? "<id>"}\`. Epic Conductor v2 reconciles server-side; run jobs locally with \`npx -y @bridge_gpt/mcp-server executor --repo <name>\`.`
|
|
8297
|
+
]
|
|
8274
8298
|
};
|
|
8275
8299
|
} catch (err) {
|
|
8276
8300
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -8416,19 +8440,25 @@ function formatConductorDoctorReport(report) {
|
|
|
8416
8440
|
for (const w of git_hooks.warnings) lines.push(` - ${w}`);
|
|
8417
8441
|
}
|
|
8418
8442
|
lines.push("");
|
|
8419
|
-
lines.push("Epic Supervisor Schedule (
|
|
8420
|
-
lines.push("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
8421
|
-
const registeredTag = epic_tick.registered ? "[
|
|
8422
|
-
lines.push(`
|
|
8423
|
-
|
|
8424
|
-
|
|
8425
|
-
|
|
8443
|
+
lines.push("Epic Supervisor Schedule (v1 epic-tick \u2014 frozen)");
|
|
8444
|
+
lines.push("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
8445
|
+
const registeredTag = epic_tick.registered ? "[WARNING] registered \u2014 dead timer, remove it" : "[SUCCESS] none registered (v1 is frozen)";
|
|
8446
|
+
lines.push(`epic-tick schedule: ${registeredTag}`);
|
|
8447
|
+
if (epic_tick.registered) {
|
|
8448
|
+
lines.push(`backend: ${epic_tick.backend ?? "n/a"}`);
|
|
8449
|
+
lines.push(`next fire: ${epic_tick.next_fire_iso ?? "n/a"}`);
|
|
8450
|
+
lines.push(`latest run status: ${epic_tick.latest_run_status ?? "n/a"}`);
|
|
8451
|
+
}
|
|
8426
8452
|
lines.push(`degraded: ${epic_tick.degraded}`);
|
|
8427
8453
|
if (epic_tick.warnings.length > 0) {
|
|
8428
8454
|
lines.push("epic-tick warnings:");
|
|
8429
8455
|
for (const w of epic_tick.warnings) lines.push(` - ${w}`);
|
|
8430
8456
|
}
|
|
8431
8457
|
lines.push("");
|
|
8458
|
+
lines.push("Epic Conductor v2 reconciles epics server-side \u2014 nothing to schedule");
|
|
8459
|
+
lines.push("locally. To execute claimed jobs on this machine, run:");
|
|
8460
|
+
lines.push(" npx -y @bridge_gpt/mcp-server executor --repo <name>");
|
|
8461
|
+
lines.push("");
|
|
8432
8462
|
lines.push("MCP Profile (optional, local)");
|
|
8433
8463
|
lines.push("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
8434
8464
|
const profileTag = mcp_profile.degraded ? "[WARNING] degraded" : "[OK]";
|