@bridge_gpt/mcp-server 0.2.20 → 0.2.23

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.
@@ -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: true,
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 (optional, local)");
372
- lines.push("──────────────────────────────────────────");
373
- const registeredTag = epic_tick.registered ? "[SUCCESS] registered" : "[WARNING] not registered";
374
- lines.push(`registered: ${registeredTag}`);
375
- lines.push(`backend: ${epic_tick.backend ?? "n/a"}`);
376
- lines.push(`next fire: ${epic_tick.next_fire_iso ?? "n/a"}`);
377
- lines.push(`latest run status: ${epic_tick.latest_run_status ?? "n/a"}`);
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
- ["pr", "view", `feature/${ticketKey}`, "--json", "number,headRefOid,state,mergeable,mergeStateStatus"], { cwd: options.cwd ?? process.cwd() });
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
- // Production EpicRuntimeDeps factory
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
- baseBranch: "main",
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
- "number,headRefOid,headRefName,url,state,mergeable,mergeStateStatus",
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, { url: pr.url, head_ref: pr.head_ref }),
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
  }
@@ -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: true,
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 (optional, local)");
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 ? "[SUCCESS] registered" : "[WARNING] not registered";
8422
- lines.push(`registered: ${registeredTag}`);
8423
- lines.push(`backend: ${epic_tick.backend ?? "n/a"}`);
8424
- lines.push(`next fire: ${epic_tick.next_fire_iso ?? "n/a"}`);
8425
- lines.push(`latest run status: ${epic_tick.latest_run_status ?? "n/a"}`);
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]";