@bridge_gpt/mcp-server 0.2.16 → 0.2.18
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/build/agents.generated.js +2 -2
- package/build/commands.generated.js +6 -6
- package/build/conductor/bridge-api-client.js +191 -11
- package/build/conductor/claude-hook.js +22 -4
- package/build/conductor/cli.js +11 -13
- package/build/conductor/done-gate.js +5 -0
- package/build/conductor/epic-reconcile.js +62 -13
- package/build/conductor/epic-runtime.js +447 -35
- package/build/conductor/epic-state.js +517 -63
- package/build/conductor/errors.js +41 -0
- package/build/conductor/event-accessors.js +234 -0
- package/build/conductor/file-scope-guard.js +201 -0
- package/build/conductor/github-mergeability.js +85 -0
- package/build/conductor/local-merge.js +47 -1
- package/build/conductor/merge-identity.js +41 -0
- package/build/conductor/merge-ledger.js +13 -68
- package/build/conductor/plan.js +12 -2
- package/build/conductor/pr-discovery.js +11 -1
- package/build/conductor/supervisor-config.js +4 -39
- package/build/conductor/supervisor-escalation.js +10 -26
- package/build/conductor/supervisor-ledger.js +5 -12
- package/build/conductor/supervisor-message-relay.js +2 -5
- package/build/conductor/supervisor-notification.js +1 -1
- package/build/conductor/supervisor-runtime.js +12 -54
- package/build/conductor/supervisor-state.js +4 -18
- package/build/conductor/supervisor-types.js +2 -2
- package/build/conductor/taxonomy.js +4 -0
- package/build/conductor-bin.js +2333 -666
- package/build/conductor-claude-hook-bin.js +4 -2
- package/build/doctor.js +32 -0
- package/build/index.js +10125 -8522
- package/build/install-bridge.js +25 -8
- package/build/install-doctor.js +387 -0
- package/build/pipelines.generated.js +30 -5
- package/build/regression-check.js +53 -1
- package/build/review-tickets.js +175 -21
- package/build/start-tickets-conductor.js +22 -6
- package/build/start-tickets-prereqs.js +33 -3
- package/build/start-tickets.js +122 -22
- package/build/version.generated.js +1 -1
- package/package.json +5 -5
- package/pipelines/review-ticket.json +24 -2
- package/public/css/main.min.css +3272 -1
- package/public/css/main.min.css.map +1 -1
- package/smoke-test/SMOKE-TEST.md +4 -2
|
@@ -18,12 +18,16 @@
|
|
|
18
18
|
* Epic Run TS client (already available in bridge-api-client.ts as of BAPI-407).
|
|
19
19
|
*/
|
|
20
20
|
import { spawnSync } from "child_process";
|
|
21
|
-
import { resolveConductorBridgeApiAccess, claimEpicSupervisionLease, fetchEpicRunState, advanceEpicTicketStatus, createEpicTicketStatus, recordEpicDispatch, transitionEpicDispatch, fetchParseStatus, triggerRepositoryParse, getEpicPlan, buildEpicDispatchKey, fetchEffectiveSupervisorConfig, fetchEffectiveSupervisorSetup,
|
|
21
|
+
import { resolveConductorBridgeApiAccess, claimEpicSupervisionLease, fetchEpicRunState, advanceEpicTicketStatus, createEpicTicketStatus, updateEpicRunStatus, recordEpicDispatch, transitionEpicDispatch, fetchParseStatus, triggerRepositoryParse, getEpicPlan, buildEpicDispatchKey, fetchEffectiveSupervisorConfig, fetchEffectiveSupervisorSetup, remediateEpicTicket, deletePullRequestBranch, transitionJiraStatus, safeDiagnosticMessage, } from "./bridge-api-client.js";
|
|
22
22
|
import { processGateMetMerge } from "./supervisor-merge.js";
|
|
23
|
+
import { observePrCiOnce } from "./pr-ci-producer.js";
|
|
24
|
+
import { runGhCommand } from "./pr-discovery.js";
|
|
25
|
+
import { isPrMergeConflict, parseGhPrMergeabilityFields, } from "./github-mergeability.js";
|
|
23
26
|
import { makeLocalMergeExecutor, resolveLocalMergeMethod } from "./local-merge.js";
|
|
24
27
|
import { emitConductorEventIfNew } from "./producer-ledger.js";
|
|
25
28
|
import { rebuildObservedState, extractWorkerLiveness, } from "./epic-state.js";
|
|
26
29
|
import { reconcileEpic } from "./epic-reconcile.js";
|
|
30
|
+
import { normalizeDeclaredTouchedFiles } from "./file-scope-guard.js";
|
|
27
31
|
import { buildSupervisorRemediationWorkerMessage } from "./supervisor-message-relay.js";
|
|
28
32
|
import { sendWorkerMessage } from "./store.js";
|
|
29
33
|
import { hashPlan } from "./plan.js";
|
|
@@ -49,6 +53,12 @@ const ACTIVE_WORKER_STATUSES = new Set(["dispatched", "running"]);
|
|
|
49
53
|
*/
|
|
50
54
|
const PARSE_WAIT_EVENT_SOURCE = "conductor-supervisor";
|
|
51
55
|
const PARSE_WAIT_EVENT_PRODUCER = "epic-parse-wait";
|
|
56
|
+
/**
|
|
57
|
+
* BAPI-494: source + producer tags for the durable `merge.conflict` marker the
|
|
58
|
+
* done-gate pass emits when a `ready_for_review` ticket's PR is un-mergeable.
|
|
59
|
+
*/
|
|
60
|
+
const MERGE_CONFLICT_EVENT_SOURCE = "conductor-supervisor";
|
|
61
|
+
const MERGE_CONFLICT_EVENT_PRODUCER = "epic-mergeability";
|
|
52
62
|
function defaultLeaseOwner() {
|
|
53
63
|
return `epic-tick-${process.pid}`;
|
|
54
64
|
}
|
|
@@ -61,6 +71,191 @@ async function defaultDispatchSeam(_epicKey, ticketKey, _attempt = 0) {
|
|
|
61
71
|
async function defaultPostActionWaitSeam(_epicKey, _ticketKey) {
|
|
62
72
|
// no-op: parse-after-merge wait is a sibling ticket's concern
|
|
63
73
|
}
|
|
74
|
+
export function parsePrBindingFromGhJson(stdout) {
|
|
75
|
+
let pr;
|
|
76
|
+
try {
|
|
77
|
+
pr = JSON.parse(stdout);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
const num = pr.number;
|
|
83
|
+
const sha = pr.headRefOid;
|
|
84
|
+
const state = typeof pr.state === "string" ? pr.state : "";
|
|
85
|
+
if (typeof num === "number" &&
|
|
86
|
+
Number.isInteger(num) &&
|
|
87
|
+
num >= 1 &&
|
|
88
|
+
typeof sha === "string" &&
|
|
89
|
+
/^[0-9a-f]{7,40}$/i.test(sha) &&
|
|
90
|
+
state.toUpperCase() === "OPEN") {
|
|
91
|
+
// BAPI-494: parse mergeability defensively from the same JSON object — unknown
|
|
92
|
+
// values become null and never reject an otherwise valid open PR binding.
|
|
93
|
+
const mergeability = parseGhPrMergeabilityFields(pr);
|
|
94
|
+
return {
|
|
95
|
+
prNumber: num,
|
|
96
|
+
headSha: sha,
|
|
97
|
+
mergeable: mergeability.mergeable,
|
|
98
|
+
mergeStateStatus: mergeability.mergeStateStatus,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Shared gh-discovery PR-binding resolver: resolve BOTH the PR number and the
|
|
105
|
+
* CURRENT head SHA for a ticket from its `feature/<KEY>` branch via
|
|
106
|
+
* `gh pr view ... --json number,headRefOid,state`. This is the single
|
|
107
|
+
* gh-discovery path used by BOTH the Conductor done-gate pass and the
|
|
108
|
+
* remediation CAS dispatch (BAPI-487 Requirement 1). It intentionally does NOT
|
|
109
|
+
* consult `epic_supervisor_setup.pr_bindings` — nothing auto-populates that map,
|
|
110
|
+
* so the legacy `pr_bindings` seam always returned null and stranded remediation.
|
|
111
|
+
*
|
|
112
|
+
* Fail-closed: returns null for any failed `gh` invocation, empty stdout, or a
|
|
113
|
+
* closed/merged PR / malformed number / non-hex head SHA (all enforced by
|
|
114
|
+
* {@link parsePrBindingFromGhJson}). `options.runGh` is injectable for pure unit
|
|
115
|
+
* tests; `options.cwd` defaults to the Conductor's current working directory.
|
|
116
|
+
*/
|
|
117
|
+
export function resolveTicketPrBindingFromGh(ticketKey, options = {}) {
|
|
118
|
+
const runGh = options.runGh ?? runGhCommand;
|
|
119
|
+
const ghRes = runGh(
|
|
120
|
+
// BAPI-494: mergeability fields added to the SAME per-ticket binding call — the
|
|
121
|
+
// done-gate reads mergeability inside this existing call, spawning no new gh process.
|
|
122
|
+
["pr", "view", `feature/${ticketKey}`, "--json", "number,headRefOid,state,mergeable,mergeStateStatus"], { cwd: options.cwd ?? process.cwd() });
|
|
123
|
+
if (ghRes.ok && ghRes.stdout.trim()) {
|
|
124
|
+
const parsed = parsePrBindingFromGhJson(ghRes.stdout);
|
|
125
|
+
// Normalize the head SHA to lowercase so downstream head-scoped comparisons
|
|
126
|
+
// (blocked-head vs. current-head, gate.met dedupe) are case-stable.
|
|
127
|
+
return parsed
|
|
128
|
+
? { ...parsed, headSha: parsed.headSha.toLowerCase() }
|
|
129
|
+
: null;
|
|
130
|
+
}
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* For each `ready_for_review` ticket — and (BAPI-487) each `blocked` ticket whose
|
|
135
|
+
* live PR head has advanced past the head that blocked it — ask the conductor to
|
|
136
|
+
* evaluate the composite done-gate itself via {@link observePrCiOnce}, the
|
|
137
|
+
* correctness backstop for FINDING 6 where a worker never self-emits `gate.met`.
|
|
138
|
+
* `observePrCiOnce` binds to the CURRENT head SHA, fails closed on any
|
|
139
|
+
* unavailable/ambiguous/stale-head signal, and idempotently emits `gate.met`
|
|
140
|
+
* (deduped by ticket+PR+head_sha) so the fold + merge-enqueue happens exactly once
|
|
141
|
+
* regardless of whether the worker also emitted it.
|
|
142
|
+
*
|
|
143
|
+
* A `blocked` ticket is re-evaluated ONLY when its PR binding resolves AND either
|
|
144
|
+
* the recorded blocked head is unknown (fail-closed recovery) or the current head
|
|
145
|
+
* differs from it — a ticket still blocked on its current head is left blocked so
|
|
146
|
+
* a same-head failure is never bypassed.
|
|
147
|
+
*
|
|
148
|
+
* Fully fail-closed and non-blocking: a per-ticket observation error is caught and
|
|
149
|
+
* logged so it can never crash or stall the reconcile loop; tickets that are
|
|
150
|
+
* neither `ready_for_review` nor advanced-head `blocked`, and tickets without a
|
|
151
|
+
* PR binding, are skipped.
|
|
152
|
+
*/
|
|
153
|
+
export async function runConductorDoneGatePass(ticketStatuses, deps) {
|
|
154
|
+
for (const [ticketKey, status] of ticketStatuses) {
|
|
155
|
+
if (status !== "ready_for_review" && status !== "blocked")
|
|
156
|
+
continue;
|
|
157
|
+
const prBinding = deps.resolvePrBinding(ticketKey);
|
|
158
|
+
if (prBinding === null) {
|
|
159
|
+
deps.log(`[epic-tick] done-gate poll for ${ticketKey}: skipped (no PR binding)`);
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
// BAPI-487: gate a `blocked` ticket's re-evaluation on its head advancing.
|
|
163
|
+
if (status === "blocked") {
|
|
164
|
+
const blockedHead = deps.resolveBlockedHeadSha?.(ticketKey) ?? null;
|
|
165
|
+
if (blockedHead === null) {
|
|
166
|
+
deps.log(`[epic-tick] done-gate re-eval for ${ticketKey}: blocked with no recorded head; ` +
|
|
167
|
+
`polling current head ${prBinding.headSha} fail-closed as recovery`);
|
|
168
|
+
}
|
|
169
|
+
else if (blockedHead.toLowerCase() === prBinding.headSha.toLowerCase()) {
|
|
170
|
+
deps.log(`[epic-tick] done-gate poll for ${ticketKey}: skipped (still blocked on current head ${prBinding.headSha})`);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
deps.log(`[epic-tick] done-gate re-eval for ${ticketKey}: blocked head ${blockedHead} ` +
|
|
175
|
+
`superseded by current head ${prBinding.headSha}; re-evaluating`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// Stamp the ticket's run_id AND worker_id onto the emitted gate.met via a
|
|
179
|
+
// per-ticket env override (observePrCiOnce reads BAPI_CONDUCTOR_RUN_ID/
|
|
180
|
+
// _WORKER_ID from env, env taking precedence). run_id correlates the fold to
|
|
181
|
+
// the ticket; a non-empty worker_id is required for the merge to enqueue
|
|
182
|
+
// (merge-ledger extractMergeActionIdentityFromGateEvent). Both must be present
|
|
183
|
+
// or the conductor-emitted gate.met folds/merges to nothing.
|
|
184
|
+
const runId = deps.resolveRunId?.(ticketKey) ?? null;
|
|
185
|
+
const workerId = deps.resolveWorkerId?.(ticketKey) ?? null;
|
|
186
|
+
// BAPI-494: a `ready_for_review` PR that is already un-mergeable
|
|
187
|
+
// (CONFLICTING/DIRTY) can never be re-reviewed (claude-review won't verdict an
|
|
188
|
+
// unmergeable branch) or merged — so it would sit at ready_for_review forever.
|
|
189
|
+
// Route it into the head-scoped `merge.conflict` blocking path (folded next
|
|
190
|
+
// tick, remediated as a resume-mode redispatch) BEFORE re-polling CI, and skip
|
|
191
|
+
// CI observation for this ticket. Only `ready_for_review` conflicts are emitted
|
|
192
|
+
// here; a `blocked` ticket already has routing + stale-head re-evaluation.
|
|
193
|
+
if (status === "ready_for_review" && isPrMergeConflict(prBinding)) {
|
|
194
|
+
try {
|
|
195
|
+
await deps.emitConflictSignal?.({
|
|
196
|
+
ticketKey,
|
|
197
|
+
repoName: deps.access.repoName,
|
|
198
|
+
prNumber: prBinding.prNumber,
|
|
199
|
+
headSha: prBinding.headSha,
|
|
200
|
+
mergeable: prBinding.mergeable,
|
|
201
|
+
mergeStateStatus: prBinding.mergeStateStatus,
|
|
202
|
+
runId,
|
|
203
|
+
workerId,
|
|
204
|
+
});
|
|
205
|
+
deps.log(`[epic-tick] done-gate conflict for ${ticketKey}: PR #${prBinding.prNumber} ` +
|
|
206
|
+
`not mergeable at head ${prBinding.headSha}; emitted merge.conflict`);
|
|
207
|
+
}
|
|
208
|
+
catch (err) {
|
|
209
|
+
const safeMsg = err instanceof Error ? err.constructor.name : "conflict emit error";
|
|
210
|
+
deps.errorLog(`[epic-tick] done-gate conflict-signal failed (${safeMsg}) for ${ticketKey}; continuing`);
|
|
211
|
+
}
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
const perTicketEnv = {
|
|
215
|
+
...deps.env,
|
|
216
|
+
...(runId ? { BAPI_CONDUCTOR_RUN_ID: runId } : {}),
|
|
217
|
+
...(workerId ? { BAPI_CONDUCTOR_WORKER_ID: workerId } : {}),
|
|
218
|
+
};
|
|
219
|
+
try {
|
|
220
|
+
const observeResult = await deps.observePrCi({
|
|
221
|
+
repoName: deps.access.repoName,
|
|
222
|
+
prNumber: prBinding.prNumber,
|
|
223
|
+
headSha: prBinding.headSha,
|
|
224
|
+
}, {
|
|
225
|
+
env: perTicketEnv,
|
|
226
|
+
resolveAccess: async () => ({ ok: true, access: deps.access }),
|
|
227
|
+
});
|
|
228
|
+
deps.log(`[epic-tick] done-gate poll for ${ticketKey}: ${observeResult.reason}`);
|
|
229
|
+
}
|
|
230
|
+
catch (err) {
|
|
231
|
+
const safeMsg = err instanceof Error ? err.constructor.name : "observe error";
|
|
232
|
+
deps.errorLog(`[epic-tick] done-gate poll failed (${safeMsg}) for ${ticketKey}; continuing`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
// N-3 run self-completion (BAPI-507)
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
/**
|
|
240
|
+
* BAPI-507 (N-3): should the epic run self-complete this tick? True ONLY when the
|
|
241
|
+
* approved plan has at least one ticket AND every plan ticket's effective status
|
|
242
|
+
* is exactly `"done"` in `observed.ticket_statuses`. Any node that is
|
|
243
|
+
* `abandoned` / `blocked` / `ready_for_review` / `running` / `reviewing` /
|
|
244
|
+
* `ready` / `dispatched` / `planned`, missing from the status map, or any other
|
|
245
|
+
* non-`done` value makes this return `false`. Conservative done-only policy: a
|
|
246
|
+
* plan ending in a `done` + `abandoned` mix does NOT auto-complete — an operator
|
|
247
|
+
* decides how to close a run with abandoned nodes. Pure: no I/O, no clock.
|
|
248
|
+
*/
|
|
249
|
+
function shouldSelfCompleteEpicRun(plan, observed) {
|
|
250
|
+
if (plan.tickets.length === 0)
|
|
251
|
+
return false;
|
|
252
|
+
for (const ticket of plan.tickets) {
|
|
253
|
+
if (observed.ticket_statuses.get(ticket.ticket_key) !== "done") {
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return true;
|
|
258
|
+
}
|
|
64
259
|
// ---------------------------------------------------------------------------
|
|
65
260
|
// runEpicTick
|
|
66
261
|
// ---------------------------------------------------------------------------
|
|
@@ -91,6 +286,28 @@ export async function runEpicTick(options, deps = {}) {
|
|
|
91
286
|
const claimLeaseFn = deps.claimLease ?? claimEpicSupervisionLease;
|
|
92
287
|
const fetchEpicStateFn = deps.fetchEpicState ?? fetchEpicRunState;
|
|
93
288
|
const releaseLease = deps.releaseLease;
|
|
289
|
+
const env = deps.env ?? process.env;
|
|
290
|
+
const observePrCiSeamFn = deps.observePrCiSeam ?? observePrCiOnce;
|
|
291
|
+
// BAPI-507 (N-3): default self-completion seam CASes the run active → done via
|
|
292
|
+
// the existing PATCH /runs/{id} path, addressing the run by its concrete UUID.
|
|
293
|
+
const completeEpicRunFn = deps.completeEpicRun ??
|
|
294
|
+
(async (acc, epicRunId) => {
|
|
295
|
+
await updateEpicRunStatus(acc, {
|
|
296
|
+
epicKey: epicRunId,
|
|
297
|
+
status: "done",
|
|
298
|
+
expectedStatus: "active",
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
// BAPI-494: a single durable event emitter reused by BOTH the parse-after-merge
|
|
302
|
+
// marker block and the done-gate `merge.conflict` wiring, so there is one
|
|
303
|
+
// injectable ledger path (not a second hardcoded emitter).
|
|
304
|
+
const emitConductorEventFn = deps.emitConductorEvent ?? emitConductorEventIfNew;
|
|
305
|
+
// FINDING 1 (BAPI-463): a dry-run tick must exercise the full dispatch-resolution
|
|
306
|
+
// path (ready-set → resolved command + model routing) yet persist NO authoritative
|
|
307
|
+
// dispatch state. Without this guard, `correlateRunId` writes a `run_spawned` row
|
|
308
|
+
// under the REAL dispatch_key, and the next real tick's `claimDispatchKey` returns
|
|
309
|
+
// `already-spawned`, silently skipping the real worker spawn.
|
|
310
|
+
const isDryRun = env.BAPI_CONDUCTOR_DISPATCH_DRY_RUN === "1";
|
|
94
311
|
const startMs = nowFn();
|
|
95
312
|
// ---------------------------------------------------------------------------
|
|
96
313
|
// Step 0: Resolve Bridge API access (offline / fail-closed guard)
|
|
@@ -225,7 +442,6 @@ export async function runEpicTick(options, deps = {}) {
|
|
|
225
442
|
const settleMs = 5000;
|
|
226
443
|
const fetchParseStatusFn = deps.fetchParseStatus ?? fetchParseStatus;
|
|
227
444
|
const triggerParseFn = deps.triggerParse ?? triggerRepositoryParse;
|
|
228
|
-
const emitConductorEventFn = deps.emitConductorEvent ?? emitConductorEventIfNew;
|
|
229
445
|
for (let i = 0; i < observed.unfolded_terminal_signals.length; i++) {
|
|
230
446
|
const signal = observed.unfolded_terminal_signals[i];
|
|
231
447
|
if (signal.signal_type !== "merge.succeeded")
|
|
@@ -393,6 +609,12 @@ export async function runEpicTick(options, deps = {}) {
|
|
|
393
609
|
// liveness window) and setup (pr_bindings) once. Fail-open: if the config
|
|
394
610
|
// read fails, remediationConfig stays undefined and reconcile skips the
|
|
395
611
|
// remediation pass entirely (dispatch/merge steps unaffected).
|
|
612
|
+
//
|
|
613
|
+
// BAPI-487: `prBindings` is retained ONLY for the legacy teardown seam
|
|
614
|
+
// (which needs just the PR number). Both the done-gate observation and the
|
|
615
|
+
// remediation CAS resolve their PR binding via gh-discovery
|
|
616
|
+
// (resolveTicketPrBindingFromGh), never from this map — nothing
|
|
617
|
+
// auto-populates `pr_bindings`, so relying on it stranded remediation.
|
|
396
618
|
let remediationConfig;
|
|
397
619
|
let livenessWindowSeconds = 120;
|
|
398
620
|
let prBindings = {};
|
|
@@ -461,6 +683,14 @@ export async function runEpicTick(options, deps = {}) {
|
|
|
461
683
|
for (const [tk, info] of reviewLatestDispatchByTicket) {
|
|
462
684
|
reviewTicketRunIdMap.set(tk, info.runId);
|
|
463
685
|
}
|
|
686
|
+
// Legacy PR-number-only resolver from `pr_bindings`. BAPI-487: this is now
|
|
687
|
+
// consumed ONLY by the teardownSeam, which needs just the number and is
|
|
688
|
+
// outside this ticket's remediation critical path. Both the done-gate
|
|
689
|
+
// observation and the remediation CAS resolve their PR binding via
|
|
690
|
+
// gh-discovery (resolveTicketPrBindingFromGh / resolvePrBinding) instead,
|
|
691
|
+
// because nothing auto-populates `pr_bindings` (confirmed NULL for repo
|
|
692
|
+
// `bapi`), so this legacy path always returns null. Teardown is left on it
|
|
693
|
+
// deliberately rather than silently migrated.
|
|
464
694
|
const resolvePrNumber = (ticketKey) => {
|
|
465
695
|
const raw = prBindings[ticketKey];
|
|
466
696
|
if (typeof raw === "number" && Number.isInteger(raw) && raw >= 1)
|
|
@@ -473,6 +703,92 @@ export async function runEpicTick(options, deps = {}) {
|
|
|
473
703
|
}
|
|
474
704
|
return null;
|
|
475
705
|
};
|
|
706
|
+
// gh-discovery PR binding used by BOTH the done-gate pass and the
|
|
707
|
+
// remediation CAS (BAPI-487 Requirement 1). Delegates to the shared
|
|
708
|
+
// `resolveTicketPrBindingFromGh` helper so the two seams cannot diverge; it
|
|
709
|
+
// resolves the number AND the current head SHA from `feature/<KEY>` and
|
|
710
|
+
// fail-closes unless the PR is OPEN with a well-formed number + head SHA.
|
|
711
|
+
const resolvePrBinding = (ticketKey) => resolveTicketPrBindingFromGh(ticketKey, { cwd: process.cwd() });
|
|
712
|
+
// ---------------------------------------------------------------------
|
|
713
|
+
// Step 4.5: Conductor-driven done-gate for ready_for_review tickets
|
|
714
|
+
// (FINDING 6, BAPI-463) — the correctness backstop for the hands-off run.
|
|
715
|
+
//
|
|
716
|
+
// Workers do not reliably self-emit `gate.met` (a ticket can reach
|
|
717
|
+
// ready_for_review via `run.stopped` alone), so the merge enqueue — gated on
|
|
718
|
+
// `gate.met` only — would stall forever awaiting a worker signal. Here the
|
|
719
|
+
// CONDUCTOR evaluates the composite done-gate itself via the existing
|
|
720
|
+
// `observePrCiOnce`, which binds to the resolved CURRENT head SHA, evaluates
|
|
721
|
+
// the pure fail-closed done-gate (required-CI green + current-head review
|
|
722
|
+
// verdict), and idempotently emits `gate.met` (deduped by ticket+PR+head_sha)
|
|
723
|
+
// so this tick or the next one folds it and enqueues the merge exactly once.
|
|
724
|
+
// Worker-emitted `gate.met` stays a valid fast-path; this pass never removes it.
|
|
725
|
+
// The pass is best-effort and fully fail-closed: an observation error is
|
|
726
|
+
// caught per-ticket so it can never crash or block the reconcile loop, and
|
|
727
|
+
// `observePrCiOnce` emits nothing on an unavailable/ambiguous/stale-head signal.
|
|
728
|
+
// run_id → worker_id from the ledger (first worker_id seen per run) so the
|
|
729
|
+
// conductor-emitted gate.met carries the real worker's id; fall back to a
|
|
730
|
+
// synthetic conductor id so the merge-identity presence check always passes.
|
|
731
|
+
const runIdToWorkerId = new Map();
|
|
732
|
+
for (const ev of localEvents) {
|
|
733
|
+
if (ev.run_id && ev.worker_id && !runIdToWorkerId.has(ev.run_id)) {
|
|
734
|
+
runIdToWorkerId.set(ev.run_id, ev.worker_id);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
const resolveTicketRunId = (tk) => ticketRunIdMap.get(tk) ?? latestDispatchByTicket.get(tk)?.runId ?? null;
|
|
738
|
+
const resolveDoneGateWorkerId = (tk) => {
|
|
739
|
+
const rid = resolveTicketRunId(tk);
|
|
740
|
+
if (rid && runIdToWorkerId.has(rid))
|
|
741
|
+
return runIdToWorkerId.get(rid);
|
|
742
|
+
return `conductor:${tk}`;
|
|
743
|
+
};
|
|
744
|
+
await runConductorDoneGatePass(observed.ticket_statuses, {
|
|
745
|
+
observePrCi: observePrCiSeamFn,
|
|
746
|
+
resolvePrBinding,
|
|
747
|
+
// BAPI-487: re-evaluate a blocked ticket only when its PR head advanced
|
|
748
|
+
// past the head recorded on its latest blocking signal.
|
|
749
|
+
resolveBlockedHeadSha: (tk) => observed.ticket_blocked_heads?.get(tk) ?? null,
|
|
750
|
+
resolveRunId: resolveTicketRunId,
|
|
751
|
+
resolveWorkerId: resolveDoneGateWorkerId,
|
|
752
|
+
// BAPI-494: convert a detected conflict into a durable, head-scoped
|
|
753
|
+
// `merge.conflict` ledger event stamped with the ticket's dispatch run/worker
|
|
754
|
+
// so the fold correlates it and the remediation pass redispatches. Emitted via
|
|
755
|
+
// the shared injectable emitter, idempotent per conflict head. Folded next tick
|
|
756
|
+
// (this pass runs after rebuildObservedState), matching the gate.met latency.
|
|
757
|
+
emitConflictSignal: (input) => {
|
|
758
|
+
emitConductorEventFn({
|
|
759
|
+
source: MERGE_CONFLICT_EVENT_SOURCE,
|
|
760
|
+
type: "merge.conflict",
|
|
761
|
+
subject: input.ticketKey,
|
|
762
|
+
run_id: input.runId,
|
|
763
|
+
worker_id: input.workerId,
|
|
764
|
+
producer: MERGE_CONFLICT_EVENT_PRODUCER,
|
|
765
|
+
observed_via: "supervisor",
|
|
766
|
+
time: new Date(nowFn()).toISOString(),
|
|
767
|
+
data: {
|
|
768
|
+
summary: `PR #${input.prNumber} for ${input.ticketKey} is not mergeable`,
|
|
769
|
+
status: "blocked",
|
|
770
|
+
reason: "merge.conflict",
|
|
771
|
+
details: {
|
|
772
|
+
epic_key,
|
|
773
|
+
ticket_key: input.ticketKey,
|
|
774
|
+
repo: input.repoName,
|
|
775
|
+
pr_number: input.prNumber,
|
|
776
|
+
head_sha: input.headSha,
|
|
777
|
+
mergeable: input.mergeable,
|
|
778
|
+
mergeStateStatus: input.mergeStateStatus,
|
|
779
|
+
},
|
|
780
|
+
},
|
|
781
|
+
}, {
|
|
782
|
+
event_type: "merge.conflict",
|
|
783
|
+
run_id: input.runId ?? undefined,
|
|
784
|
+
commit_sha: input.headSha,
|
|
785
|
+
});
|
|
786
|
+
},
|
|
787
|
+
access,
|
|
788
|
+
env,
|
|
789
|
+
log,
|
|
790
|
+
errorLog,
|
|
791
|
+
});
|
|
476
792
|
const maxSeqForRun = (runId) => {
|
|
477
793
|
let maxSeq = 0;
|
|
478
794
|
for (const ev of localEvents) {
|
|
@@ -489,25 +805,57 @@ export async function runEpicTick(options, deps = {}) {
|
|
|
489
805
|
nextStatus,
|
|
490
806
|
planVersion,
|
|
491
807
|
}),
|
|
492
|
-
seedTicketStatus: async (
|
|
808
|
+
seedTicketStatus: async (_ek, tk, planVersion) => {
|
|
809
|
+
// A6/BAPI-507: address the run by its concrete epic_run_id — the
|
|
810
|
+
// endpoint path is /runs/{epic_run_id}/tickets. Passing the UUID (not
|
|
811
|
+
// the Jira epic key `_ek`) skips the epic-key→active-run resolution and
|
|
812
|
+
// its multi-active-run CONFLICT edge. The backend read-back makes a
|
|
813
|
+
// re-seed of an existing row a clean 2xx no-op.
|
|
493
814
|
await createEpicTicketStatus(access, {
|
|
494
|
-
epicKey:
|
|
815
|
+
epicKey: epicRunState.epic_run.epic_run_id,
|
|
495
816
|
ticketKey: tk,
|
|
496
817
|
status: "planned",
|
|
497
818
|
planVersion,
|
|
498
819
|
});
|
|
499
820
|
},
|
|
500
|
-
claimDispatchKey: async (ek, tk, planVersion, role, attempt = 0) =>
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
821
|
+
claimDispatchKey: async (ek, tk, planVersion, role, attempt = 0) => {
|
|
822
|
+
// FINDING 1 (BAPI-463): in dry-run mode, DO NOT persist a real dispatch
|
|
823
|
+
// claim. Return a synthetic "claimed" result (run_id null) so the reconcile
|
|
824
|
+
// pass proceeds to resolve the command + model routing (still exercised) but
|
|
825
|
+
// no authoritative `run_spawned` row is written under the real dispatch_key.
|
|
826
|
+
if (isDryRun) {
|
|
827
|
+
const suffix = `${role === "review" ? ":review" : ""}${attempt > 0 ? `:r${attempt}` : ""}`;
|
|
828
|
+
const nowIso = new Date(nowFn()).toISOString();
|
|
829
|
+
log(`[DRY RUN] claiming dispatch key skipped for ${tk} (epic=${ek}, role=${role ?? "implementation"}, attempt=${attempt}); no run_spawned row persisted`);
|
|
830
|
+
return {
|
|
831
|
+
ok: true,
|
|
832
|
+
kind: "claimed",
|
|
833
|
+
dispatch: {
|
|
834
|
+
dispatch_key: `dry-run:${ek}:${tk}:v${planVersion}${suffix}`,
|
|
835
|
+
epic_run_id: ek,
|
|
836
|
+
ticket_key: tk,
|
|
837
|
+
plan_version: planVersion,
|
|
838
|
+
status: "pending",
|
|
839
|
+
run_id: null,
|
|
840
|
+
lease_owner: lease_owner,
|
|
841
|
+
lease_expires_at: null,
|
|
842
|
+
created_at: nowIso,
|
|
843
|
+
updated_at: nowIso,
|
|
844
|
+
},
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
return recordEpicDispatch(access, {
|
|
848
|
+
epicKey: ek,
|
|
849
|
+
ticketKey: tk,
|
|
850
|
+
planVersion,
|
|
851
|
+
leaseOwner: lease_owner,
|
|
852
|
+
ttlSeconds: DEFAULT_DISPATCH_KEY_TTL_SECONDS,
|
|
853
|
+
attempt,
|
|
854
|
+
// BAPI-445: a review-role claim appends ":review" to the dispatch key
|
|
855
|
+
// so the run-id maps above can separate review runs from impl runs.
|
|
856
|
+
reviewRole: role === "review",
|
|
857
|
+
});
|
|
858
|
+
},
|
|
511
859
|
// BAPI-445 spec re-review seams. dispatchReviewSeam is wired only when the
|
|
512
860
|
// factory provides it (gate stays off otherwise); the liveness + attempt
|
|
513
861
|
// accessors read the review-scoped maps built above.
|
|
@@ -522,6 +870,13 @@ export async function runEpicTick(options, deps = {}) {
|
|
|
522
870
|
},
|
|
523
871
|
countReviewAttempts: (tk) => reviewAttemptCounts.get(tk) ?? 0,
|
|
524
872
|
correlateRunId: async (dispatchKey, runId) => {
|
|
873
|
+
// FINDING 1 (BAPI-463): dry-run must not transition the dispatch row to
|
|
874
|
+
// `run_spawned`. Skipping this keeps the real dispatch_key un-claimed so a
|
|
875
|
+
// subsequent real tick spawns the worker instead of hitting `already-spawned`.
|
|
876
|
+
if (isDryRun) {
|
|
877
|
+
log(`[DRY RUN] correlate run_id skipped for dispatch_key=${dispatchKey} (run_id=${runId}); no run_spawned transition persisted`);
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
525
880
|
await transitionEpicDispatch(access, {
|
|
526
881
|
dispatchKey,
|
|
527
882
|
nextStatus: "run_spawned",
|
|
@@ -615,19 +970,30 @@ export async function runEpicTick(options, deps = {}) {
|
|
|
615
970
|
return extractWorkerLiveness(localEvents, runId, nowFn(), livenessWindowSeconds);
|
|
616
971
|
},
|
|
617
972
|
remediateCas: async (ek, tk, attemptKind, reason) => {
|
|
618
|
-
|
|
619
|
-
|
|
973
|
+
// BAPI-487 Requirement 1: resolve the PR number + head SHA via the same
|
|
974
|
+
// gh-discovery the done-gate pass uses, NOT the never-populated
|
|
975
|
+
// `pr_bindings` map (which stranded remediation on `remediate: no PR
|
|
976
|
+
// binding`). resolvePrBinding returns both the number and the current
|
|
977
|
+
// head SHA, so the separate legacy review-status head-SHA fetch is gone.
|
|
978
|
+
const prBinding = resolvePrBinding(tk);
|
|
979
|
+
if (prBinding === null) {
|
|
620
980
|
throw new Error(`remediate: no PR binding for ${tk}`);
|
|
621
981
|
}
|
|
622
|
-
const
|
|
623
|
-
const headSha =
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
982
|
+
const prNumber = prBinding.prNumber;
|
|
983
|
+
const headSha = prBinding.headSha;
|
|
984
|
+
// BAPI-500: remediation runs AFTER the fold step in the SAME reconcile
|
|
985
|
+
// pass. When this ticket folded to `blocked` this tick, the block CAS
|
|
986
|
+
// already bumped its row_version to snapshot+1, so prefer the confirmed
|
|
987
|
+
// same-pass post-fold version; fall back to the tick-start snapshot when
|
|
988
|
+
// the ticket was not folded this tick, then to 0. Sending the stale
|
|
989
|
+
// tick-start snapshot is exactly the deterministic 400 loop this fixes.
|
|
990
|
+
const rowVersion = observed.ticket_post_fold_row_versions?.get(tk) ??
|
|
991
|
+
observed.ticket_row_versions.get(tk) ??
|
|
992
|
+
0;
|
|
628
993
|
// Deterministic block-state idempotency key: stable for a given durable
|
|
629
994
|
// row_version so a same-tick retry replays (409, swallowed); advances
|
|
630
|
-
// with the next attempt.
|
|
995
|
+
// with the next attempt. Derived from the SAME local `rowVersion` sent
|
|
996
|
+
// as `expected_row_version` so the key and the CAS token stay in lockstep.
|
|
631
997
|
const idempotencyKey = `remediate:${ek}:${tk}:${rowVersion}`;
|
|
632
998
|
const result = await remediateEpicTicket(access, {
|
|
633
999
|
pr_number: prNumber,
|
|
@@ -695,6 +1061,24 @@ export async function runEpicTick(options, deps = {}) {
|
|
|
695
1061
|
for (const w of reconcileResult.warnings) {
|
|
696
1062
|
errorLog(`[epic-tick] warning: ${w}`);
|
|
697
1063
|
}
|
|
1064
|
+
// Step 5.5 (N-3 / BAPI-507): self-complete the run once every approved-plan
|
|
1065
|
+
// ticket is durably `done`. Deferred while any terminal signal folded THIS
|
|
1066
|
+
// tick is still unpersisted (`unfolded_terminal_signals`) so the run closes
|
|
1067
|
+
// on a SUBSEQUENT tick — after Postgres durably reflects all-done — rather
|
|
1068
|
+
// than from a same-tick local projection. Fail-open: a stale CAS (a
|
|
1069
|
+
// concurrent terminal transition already closed the run, or the top-of-tick
|
|
1070
|
+
// terminal guard will) is bounded operational noise, never a tick failure.
|
|
1071
|
+
if (shouldSelfCompleteEpicRun(plan, observed) &&
|
|
1072
|
+
observed.unfolded_terminal_signals.length === 0) {
|
|
1073
|
+
try {
|
|
1074
|
+
await completeEpicRunFn(access, epicRunState.epic_run.epic_run_id);
|
|
1075
|
+
log(`[epic-tick] epic=${epic_key} self-completed: all plan tickets done`);
|
|
1076
|
+
}
|
|
1077
|
+
catch (err) {
|
|
1078
|
+
errorLog(`[epic-tick] self-completion CAS failed for epic=${epic_key}: ` +
|
|
1079
|
+
`${safeDiagnosticMessage(err, "self-complete error")}`);
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
698
1082
|
}
|
|
699
1083
|
else {
|
|
700
1084
|
log(`[epic-tick] no plan available for epic=${epic_key}; skipping dispatch and merge steps`);
|
|
@@ -780,6 +1164,10 @@ export async function buildProductionEpicRuntimeDeps(epicKey) {
|
|
|
780
1164
|
// Shared closure state populated by fetchPlan and consumed by dispatchSeam.
|
|
781
1165
|
let cachedPlanVersion = 0;
|
|
782
1166
|
const automationMap = new Map();
|
|
1167
|
+
// BAPI-507 (N-2): per-ticket normalized declared touched-file set, sourced from
|
|
1168
|
+
// the plan DAG node's `touched_files`. Consumed by dispatchSeam to inject the
|
|
1169
|
+
// declared file scope into the implementation worker's environment.
|
|
1170
|
+
const touchedFilesMap = new Map();
|
|
783
1171
|
const fetchPlan = async (ek, acc) => {
|
|
784
1172
|
let response;
|
|
785
1173
|
try {
|
|
@@ -797,18 +1185,32 @@ export async function buildProductionEpicRuntimeDeps(epicKey) {
|
|
|
797
1185
|
return null;
|
|
798
1186
|
const dag = response.plan_blob;
|
|
799
1187
|
cachedPlanVersion = response.plan_version;
|
|
800
|
-
// Populate the automation
|
|
1188
|
+
// Populate the automation + declared-touched-files maps for the dispatch seam.
|
|
801
1189
|
for (const node of dag.nodes) {
|
|
1190
|
+
const nodeKey = node.ticket_key.trim();
|
|
802
1191
|
const kind = node.automations?.[0]?.kind;
|
|
803
1192
|
if (kind) {
|
|
804
|
-
automationMap.set(
|
|
1193
|
+
automationMap.set(nodeKey, kind);
|
|
1194
|
+
}
|
|
1195
|
+
// BAPI-507 (N-2): normalize defensively (trim, drop blanks, dedupe, sort;
|
|
1196
|
+
// no filesystem access). Missing/non-array touched_files → unspecified, so
|
|
1197
|
+
// the ticket simply gets no declared scope rather than failing dispatch.
|
|
1198
|
+
const declared = normalizeDeclaredTouchedFiles(node.touched_files);
|
|
1199
|
+
if (declared.length > 0) {
|
|
1200
|
+
touchedFilesMap.set(nodeKey, declared);
|
|
805
1201
|
}
|
|
806
1202
|
}
|
|
807
|
-
// Map DAG nodes to EpicTicketNode
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
1203
|
+
// Map DAG nodes to EpicTicketNode, preserving each node's normalized declared
|
|
1204
|
+
// touched-file set (drop automations/status/edges).
|
|
1205
|
+
const tickets = dag.nodes.map((n) => {
|
|
1206
|
+
const nodeKey = n.ticket_key.trim();
|
|
1207
|
+
const declared = touchedFilesMap.get(nodeKey);
|
|
1208
|
+
return {
|
|
1209
|
+
ticket_key: nodeKey,
|
|
1210
|
+
depends_on: (n.depends_on ?? []).map((k) => k.trim()),
|
|
1211
|
+
...(declared && declared.length > 0 ? { touched_files: declared } : {}),
|
|
1212
|
+
};
|
|
1213
|
+
});
|
|
812
1214
|
// Recompute the hash locally — do NOT trust the server-returned plan_hash.
|
|
813
1215
|
// This makes the integrity gate fail-closed: a tampered or drifted blob
|
|
814
1216
|
// will hash differently than approved_plan_hash and halt the tick.
|
|
@@ -836,11 +1238,18 @@ export async function buildProductionEpicRuntimeDeps(epicKey) {
|
|
|
836
1238
|
// path (ready-set → claim key → dispatch → correlate run_id) still exercises;
|
|
837
1239
|
// dry-run rows carry no runId, so a synthetic one is substituted for correlation.
|
|
838
1240
|
const dispatchDryRun = process.env.BAPI_CONDUCTOR_DISPATCH_DRY_RUN === "1";
|
|
1241
|
+
// BAPI-507 (N-2): thread the ticket's declared file scope into the
|
|
1242
|
+
// IMPLEMENTATION dispatch identity only. A `review-tickets` node does not open
|
|
1243
|
+
// an implementation PR, so it gets no file-scope env (Step 8.5).
|
|
1244
|
+
const declaredTouchedFiles = kind === "review-tickets" ? undefined : touchedFilesMap.get(tk);
|
|
839
1245
|
const identity = {
|
|
840
1246
|
epic_key: ek,
|
|
841
1247
|
epic_run_id: ek,
|
|
842
1248
|
plan_version: cachedPlanVersion,
|
|
843
1249
|
dispatch_key: buildEpicDispatchKey(ek, tk, cachedPlanVersion, attempt),
|
|
1250
|
+
...(declaredTouchedFiles && declaredTouchedFiles.length > 0
|
|
1251
|
+
? { declared_touched_files: declaredTouchedFiles }
|
|
1252
|
+
: {}),
|
|
844
1253
|
};
|
|
845
1254
|
const deps = createDefaultStartTicketsDeps();
|
|
846
1255
|
let runId;
|
|
@@ -853,6 +1262,10 @@ export async function buildProductionEpicRuntimeDeps(epicKey) {
|
|
|
853
1262
|
maxParallel: 1,
|
|
854
1263
|
auto: true,
|
|
855
1264
|
reviewOverrides: {},
|
|
1265
|
+
// BAPI-474: the Conductor epic-dispatch path stays git-fetch-free — it
|
|
1266
|
+
// already dispatches into the correct worktree/branch context, so the
|
|
1267
|
+
// fresh-base materialization (an interactive-CLI concern) is unneeded here.
|
|
1268
|
+
noRefreshBase: true,
|
|
856
1269
|
});
|
|
857
1270
|
if (!result.ok) {
|
|
858
1271
|
throw new Error(`review-tickets dispatch failed: ${result.error}`);
|
|
@@ -941,6 +1354,9 @@ export async function buildProductionEpicRuntimeDeps(epicKey) {
|
|
|
941
1354
|
// Product directive: the spec re-review is `/review-ticket --auto --rounds=2`.
|
|
942
1355
|
rounds: 2,
|
|
943
1356
|
reviewOverrides: {},
|
|
1357
|
+
// BAPI-474: see the sibling dispatchSeam comment — Conductor dispatch stays
|
|
1358
|
+
// git-fetch-free.
|
|
1359
|
+
noRefreshBase: true,
|
|
944
1360
|
});
|
|
945
1361
|
if (!result.ok) {
|
|
946
1362
|
throw new Error(`spec re-review dispatch failed: ${result.error}`);
|
|
@@ -1009,7 +1425,6 @@ export async function buildProductionEpicRuntimeDeps(epicKey) {
|
|
|
1009
1425
|
state: null,
|
|
1010
1426
|
liveness: null,
|
|
1011
1427
|
elapsed_ms: 0,
|
|
1012
|
-
ambiguous: false,
|
|
1013
1428
|
context: {},
|
|
1014
1429
|
};
|
|
1015
1430
|
// Attempt to extract a ticket key from structured reason strings like
|
|
@@ -1022,10 +1437,7 @@ export async function buildProductionEpicRuntimeDeps(epicKey) {
|
|
|
1022
1437
|
const assessment = {
|
|
1023
1438
|
classification: "stuck",
|
|
1024
1439
|
confidence: 1,
|
|
1025
|
-
should_escalate: true,
|
|
1026
1440
|
reason,
|
|
1027
|
-
draft_escalation_text: null,
|
|
1028
|
-
source: "degraded",
|
|
1029
1441
|
};
|
|
1030
1442
|
const idempotencyKey = makeSupervisorIdempotencyKey({
|
|
1031
1443
|
run_id: ek,
|