@bridge_gpt/mcp-server 0.2.10 → 0.2.13
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 +66 -4
- package/build/commands.generated.js +6 -6
- package/build/conductor/bridge-api-client.js +2 -1
- package/build/conductor/cli.js +16 -16
- package/build/conductor/doctor.js +79 -2
- package/build/conductor/epic-reconcile.js +213 -16
- package/build/conductor/epic-runtime.js +215 -60
- package/build/conductor/epic-state.js +105 -16
- package/build/conductor/errors.js +12 -0
- package/build/conductor/git-ci-types.js +10 -0
- package/build/conductor/git-producer.js +4 -4
- package/build/conductor/local-merge.js +212 -0
- package/build/conductor/merge-ledger.js +7 -7
- package/build/conductor/pr-ci-producer.js +18 -8
- package/build/conductor/pr-review-producer.js +2 -2
- package/build/conductor/producer-ledger.js +5 -5
- package/build/conductor/spec-review-producer.js +88 -0
- package/build/conductor/store.js +100 -25
- package/build/conductor/supervisor-ledger.js +2 -2
- package/build/conductor/supervisor-merge.js +5 -5
- package/build/conductor/supervisor-message-relay.js +1 -1
- package/build/conductor/supervisor-runtime.js +10 -10
- package/build/conductor/taxonomy.js +9 -0
- package/build/conductor/tools.js +5 -5
- package/build/conductor-bin.js +12689 -19
- package/build/conductor-claude-hook-bin.js +167 -17
- package/build/decision-page-schema.js +26 -0
- package/build/doctor.js +203 -0
- package/build/index.js +25084 -3632
- package/build/init.js +57 -0
- package/build/install-bridge.js +80 -0
- package/build/mcp-profile.js +33 -30
- package/build/pipelines.generated.js +70 -48
- package/build/readme.generated.js +1 -1
- package/build/sfcc/client.js +151 -0
- package/build/sfcc/config.js +39 -0
- package/build/sfcc/credentials.js +136 -0
- package/build/sfcc/ocapi-shape.js +77 -0
- package/build/sfcc/output.js +39 -0
- package/build/sfcc/permissions.js +136 -0
- package/build/sfcc/reads-custom-object-def.js +119 -0
- package/build/sfcc/reads-site-preference.js +158 -0
- package/build/sfcc/reads-system-object.js +162 -0
- package/build/sfcc/register.js +73 -0
- package/build/sfcc/setup-status.js +114 -0
- package/build/sfcc/tool-wrapper.js +70 -0
- package/build/start-tickets-conductor.js +9 -1
- package/build/start-tickets.js +47 -4
- package/build/version.generated.js +1 -1
- package/package.json +7 -4
- package/pipelines/check-ci-ticket.json +2 -2
- package/pipelines/implement-ticket.json +2 -2
- package/pipelines/learn-repository.json +84 -42
- package/smoke-test/SMOKE-TEST.md +11 -17
|
@@ -20,6 +20,8 @@
|
|
|
20
20
|
import { spawnSync } from "child_process";
|
|
21
21
|
import { resolveConductorBridgeApiAccess, claimEpicSupervisionLease, fetchEpicRunState, advanceEpicTicketStatus, createEpicTicketStatus, recordEpicDispatch, transitionEpicDispatch, fetchParseStatus, triggerRepositoryParse, getEpicPlan, buildEpicDispatchKey, fetchEffectiveSupervisorConfig, fetchEffectiveSupervisorSetup, fetchPrReviewStatus, remediateEpicTicket, deletePullRequestBranch, transitionJiraStatus, } from "./bridge-api-client.js";
|
|
22
22
|
import { processGateMetMerge } from "./supervisor-merge.js";
|
|
23
|
+
import { makeLocalMergeExecutor, resolveLocalMergeMethod } from "./local-merge.js";
|
|
24
|
+
import { emitConductorEventIfNew } from "./producer-ledger.js";
|
|
23
25
|
import { rebuildObservedState, extractWorkerLiveness, } from "./epic-state.js";
|
|
24
26
|
import { reconcileEpic } from "./epic-reconcile.js";
|
|
25
27
|
import { buildSupervisorRemediationWorkerMessage } from "./supervisor-message-relay.js";
|
|
@@ -38,12 +40,15 @@ const DEFAULT_LEASE_TTL_SECONDS = 120;
|
|
|
38
40
|
const DEFAULT_MAX_DRIFT_MS = 30_000;
|
|
39
41
|
const DEFAULT_DISPATCH_KEY_TTL_SECONDS = 300; // independent of supervision lease TTL
|
|
40
42
|
const ACTIVE_WORKER_STATUSES = new Set(["dispatched", "running"]);
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Module-private helpers
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
41
46
|
/**
|
|
42
|
-
*
|
|
43
|
-
* parse
|
|
44
|
-
* the parse lock status on the next tick.
|
|
47
|
+
* Source + producer tags for the durable `parse.triggered` marker that the
|
|
48
|
+
* parse-after-merge wait emits from the epic-tick process.
|
|
45
49
|
*/
|
|
46
|
-
const
|
|
50
|
+
const PARSE_WAIT_EVENT_SOURCE = "conductor-supervisor";
|
|
51
|
+
const PARSE_WAIT_EVENT_PRODUCER = "epic-parse-wait";
|
|
47
52
|
function defaultLeaseOwner() {
|
|
48
53
|
return `epic-tick-${process.pid}`;
|
|
49
54
|
}
|
|
@@ -75,6 +80,9 @@ export async function runEpicTick(options, deps = {}) {
|
|
|
75
80
|
const errorLog = deps.errorLog ?? ((msg) => process.stderr.write(`${msg}\n`));
|
|
76
81
|
const escalateOnce = deps.escalateOnce ?? defaultEscalateOnce;
|
|
77
82
|
const dispatchSeam = deps.dispatchSeam ?? defaultDispatchSeam;
|
|
83
|
+
// BAPI-445: optional review dispatch seam. When absent the spec re-review
|
|
84
|
+
// sequencing gate stays off (a ready ticket dispatches implementation directly).
|
|
85
|
+
const dispatchReviewSeamFn = deps.dispatchReviewSeam;
|
|
78
86
|
const processMergeFn = deps.processMerge ?? processGateMetMerge;
|
|
79
87
|
const postActionWaitSeam = deps.postActionWaitSeam ?? defaultPostActionWaitSeam;
|
|
80
88
|
const fetchLocalEvents = deps.fetchLocalEvents ??
|
|
@@ -203,7 +211,7 @@ export async function runEpicTick(options, deps = {}) {
|
|
|
203
211
|
const dispatchedRunIds = epicRunState.dispatches
|
|
204
212
|
.map((d) => d.run_id)
|
|
205
213
|
.filter((rid) => typeof rid === "string" && rid.length > 0);
|
|
206
|
-
const localEvents = fetchLocalEvents(epic_key, dispatchedRunIds);
|
|
214
|
+
const localEvents = await fetchLocalEvents(epic_key, dispatchedRunIds);
|
|
207
215
|
const observed = rebuildObservedState(epicRunState, localEvents, nowFn());
|
|
208
216
|
workerCount = [...observed.ticket_statuses.values()].filter((s) => ACTIVE_WORKER_STATUSES.has(s)).length;
|
|
209
217
|
// Step 3.5: Run post-action waits (parse-after-merge)
|
|
@@ -217,17 +225,22 @@ export async function runEpicTick(options, deps = {}) {
|
|
|
217
225
|
const settleMs = 5000;
|
|
218
226
|
const fetchParseStatusFn = deps.fetchParseStatus ?? fetchParseStatus;
|
|
219
227
|
const triggerParseFn = deps.triggerParse ?? triggerRepositoryParse;
|
|
228
|
+
const emitConductorEventFn = deps.emitConductorEvent ?? emitConductorEventIfNew;
|
|
220
229
|
for (let i = 0; i < observed.unfolded_terminal_signals.length; i++) {
|
|
221
230
|
const signal = observed.unfolded_terminal_signals[i];
|
|
222
231
|
if (signal.signal_type !== "merge.succeeded")
|
|
223
232
|
continue;
|
|
224
233
|
const ticketKey = signal.ticket_key;
|
|
225
|
-
const
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
234
|
+
const mergeEvent = signal.event;
|
|
235
|
+
const mergeTimeMs = new Date(mergeEvent.time).getTime();
|
|
236
|
+
// rebuildObservedState only surfaces a merge.succeeded whose run_id maps to
|
|
237
|
+
// a tracked dispatch, so mergeRunId is non-null and in the run-scoped
|
|
238
|
+
// localEvents read — binding the parse.triggered marker to it guarantees a
|
|
239
|
+
// later tick re-reads it. The merged PR head SHA (pre-merge) lives under
|
|
240
|
+
// the merge.* event's data.details; used only as an extra dedupe dimension.
|
|
241
|
+
const mergeRunId = mergeEvent.run_id ?? null;
|
|
242
|
+
const mergeDetails = mergeEvent.data?.details;
|
|
243
|
+
const mergeHeadSha = typeof mergeDetails?.head_sha === "string" ? mergeDetails.head_sha : undefined;
|
|
231
244
|
const revertSignal = () => {
|
|
232
245
|
const origStatus = epicRunState.ticket_statuses.find((ts) => ts.ticket_key === ticketKey)?.status ??
|
|
233
246
|
"running";
|
|
@@ -235,27 +248,72 @@ export async function runEpicTick(options, deps = {}) {
|
|
|
235
248
|
observed.unfolded_terminal_signals.splice(i, 1);
|
|
236
249
|
i -= 1;
|
|
237
250
|
};
|
|
238
|
-
const
|
|
239
|
-
//
|
|
251
|
+
const currentPgStatus = epicRunState.ticket_statuses.find((ts) => ts.ticket_key === ticketKey)?.status ?? null;
|
|
252
|
+
// DURABLE wait state (replaces the former in-memory parseWaitStateMap, which
|
|
253
|
+
// was lost between stateless epic-tick processes → re-triggered the parse on
|
|
254
|
+
// every idle tick and never folded to `done`). The marker is a
|
|
255
|
+
// `parse.triggered` ledger event correlated to this ticket's dispatch run_id
|
|
256
|
+
// and emitted strictly after the merge — so its presence is the skew-free
|
|
257
|
+
// signal that the post-merge parse was already kicked off.
|
|
258
|
+
const parseTriggeredEvent = localEvents.find((e) => e.type === "parse.triggered" &&
|
|
259
|
+
e.subject === ticketKey &&
|
|
260
|
+
e.run_id === mergeRunId &&
|
|
261
|
+
new Date(e.time).getTime() >= mergeTimeMs);
|
|
262
|
+
const elapsedMs = nowFn() - mergeTimeMs;
|
|
263
|
+
// Budget exhaustion (measured from the durable merge.succeeded time):
|
|
264
|
+
// escalate once (gated on the durable Postgres status, not an in-memory
|
|
265
|
+
// flag) then block. A subsequent tick finds it already blocked and drops
|
|
266
|
+
// the signal without re-escalating or re-CASing.
|
|
240
267
|
if (elapsedMs > maxWaitMs) {
|
|
241
|
-
if (
|
|
242
|
-
await escalateOnce(epic_key, `parse-after-merge budget exhausted for ${ticketKey}`);
|
|
243
|
-
pState.escalated = true;
|
|
244
|
-
signal.next_status = "blocked";
|
|
268
|
+
if (currentPgStatus === "blocked") {
|
|
245
269
|
observed.ticket_statuses.set(ticketKey, "blocked");
|
|
246
|
-
// Let the signal remain so the reconcile pass CASes once to blocked
|
|
247
|
-
continue;
|
|
248
|
-
}
|
|
249
|
-
else {
|
|
250
|
-
// Already escalated: map to blocked and skip redundant CAS
|
|
251
|
-
observed.ticket_statuses.set(ticketKey, "blocked");
|
|
252
|
-
parseWaitStateMap.delete(stateKey);
|
|
253
270
|
observed.unfolded_terminal_signals.splice(i, 1);
|
|
254
271
|
i -= 1;
|
|
255
272
|
continue;
|
|
256
273
|
}
|
|
274
|
+
await escalateOnce(epic_key, `parse-after-merge budget exhausted for ${ticketKey}`);
|
|
275
|
+
signal.next_status = "blocked";
|
|
276
|
+
observed.ticket_statuses.set(ticketKey, "blocked");
|
|
277
|
+
// Let the signal remain so the reconcile pass CASes once to blocked.
|
|
278
|
+
continue;
|
|
257
279
|
}
|
|
258
|
-
|
|
280
|
+
if (!parseTriggeredEvent) {
|
|
281
|
+
// No durable trigger for this merge yet — fire the parse and record the
|
|
282
|
+
// marker, then hold at ready_for_review for a later tick to fold.
|
|
283
|
+
try {
|
|
284
|
+
await triggerParseFn(access);
|
|
285
|
+
emitConductorEventFn({
|
|
286
|
+
source: PARSE_WAIT_EVENT_SOURCE,
|
|
287
|
+
type: "parse.triggered",
|
|
288
|
+
subject: ticketKey,
|
|
289
|
+
run_id: mergeRunId,
|
|
290
|
+
worker_id: mergeEvent.worker_id ?? null,
|
|
291
|
+
producer: PARSE_WAIT_EVENT_PRODUCER,
|
|
292
|
+
observed_via: "supervisor",
|
|
293
|
+
time: new Date(nowFn()).toISOString(),
|
|
294
|
+
data: {
|
|
295
|
+
summary: `parse-after-merge triggered for ${ticketKey}`,
|
|
296
|
+
details: {
|
|
297
|
+
epic_key,
|
|
298
|
+
ticket_key: ticketKey,
|
|
299
|
+
...(mergeHeadSha ? { head_sha: mergeHeadSha } : {}),
|
|
300
|
+
},
|
|
301
|
+
},
|
|
302
|
+
}, {
|
|
303
|
+
event_type: "parse.triggered",
|
|
304
|
+
run_id: mergeRunId ?? undefined,
|
|
305
|
+
commit_sha: mergeHeadSha,
|
|
306
|
+
});
|
|
307
|
+
log(`[epic-tick] triggered parse-after-merge for ${ticketKey} in epic=${epic_key}`);
|
|
308
|
+
}
|
|
309
|
+
catch (err) {
|
|
310
|
+
const safeMsg = err instanceof Error ? err.constructor.name : "trigger error";
|
|
311
|
+
errorLog(`[epic-tick] parse trigger failed (${safeMsg}) for ${ticketKey}; will retry next tick`);
|
|
312
|
+
}
|
|
313
|
+
revertSignal();
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
// A durable parse.triggered exists for this merge — poll the live lock.
|
|
259
317
|
let parseStatusResult;
|
|
260
318
|
try {
|
|
261
319
|
parseStatusResult = await fetchParseStatusFn(access);
|
|
@@ -267,38 +325,26 @@ export async function runEpicTick(options, deps = {}) {
|
|
|
267
325
|
continue;
|
|
268
326
|
}
|
|
269
327
|
if (parseStatusResult.status === "in_progress") {
|
|
270
|
-
|
|
328
|
+
// Parse still running — hold.
|
|
271
329
|
revertSignal();
|
|
272
330
|
continue;
|
|
273
331
|
}
|
|
274
|
-
// status === "idle"
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
parseWaitStateMap.delete(stateKey);
|
|
289
|
-
continue; // let the signal proceed to CAS → done
|
|
290
|
-
}
|
|
291
|
-
// No trigger yet — fire it now
|
|
292
|
-
try {
|
|
293
|
-
await triggerParseFn(access);
|
|
294
|
-
pState.triggeredAt = nowFn();
|
|
295
|
-
log(`[epic-tick] triggered parse-after-merge for ${ticketKey} in epic=${epic_key}`);
|
|
296
|
-
}
|
|
297
|
-
catch (err) {
|
|
298
|
-
const safeMsg = err instanceof Error ? err.constructor.name : "trigger error";
|
|
299
|
-
errorLog(`[epic-tick] parse trigger failed (${safeMsg}) for ${ticketKey}; will retry next tick`);
|
|
332
|
+
// status === "idle". If the marker is younger than the settle window, the
|
|
333
|
+
// async parse may not have acquired its lock yet (idle is a false negative)
|
|
334
|
+
// — hold one more tick. Past the settle window, idle means the post-merge
|
|
335
|
+
// parse has completed (or finished instantly), so let the merge.succeeded
|
|
336
|
+
// signal proceed to CAS → done.
|
|
337
|
+
//
|
|
338
|
+
// KNOWN LIMITATION: /jira/parse-status only reports {in_progress, idle} —
|
|
339
|
+
// it cannot distinguish a FAILED parse from a completed one, so an instant
|
|
340
|
+
// parse failure folds to `done` here. Pre-existing; tracked as P2 (surface
|
|
341
|
+
// a parse failure/last_error from parse-status).
|
|
342
|
+
const msSinceTrigger = nowFn() - new Date(parseTriggeredEvent.time).getTime();
|
|
343
|
+
if (msSinceTrigger < settleMs) {
|
|
344
|
+
revertSignal();
|
|
345
|
+
continue;
|
|
300
346
|
}
|
|
301
|
-
|
|
347
|
+
// Completed: do not revert — the signal proceeds to CAS → done.
|
|
302
348
|
}
|
|
303
349
|
}
|
|
304
350
|
// Step 4: Fetch + assert plan integrity (only if fetchPlan injected)
|
|
@@ -386,19 +432,35 @@ export async function runEpicTick(options, deps = {}) {
|
|
|
386
432
|
if (ts.dispatch_run_id)
|
|
387
433
|
ticketRunIdMap.set(ts.ticket_key, ts.dispatch_run_id);
|
|
388
434
|
}
|
|
435
|
+
// BAPI-445: split dispatch records by role. `:review`-suffixed dispatch keys
|
|
436
|
+
// are the pre-implementation spec re-review runs; they must be EXCLUDED from
|
|
437
|
+
// the implementation liveness map (so BAPI-441 remediation liveness only ever
|
|
438
|
+
// targets the implementation run) and tracked in a separate review map +
|
|
439
|
+
// per-ticket attempt counter consumed by the reviewing recovery branch.
|
|
389
440
|
const latestDispatchByTicket = new Map();
|
|
441
|
+
const reviewLatestDispatchByTicket = new Map();
|
|
442
|
+
const reviewAttemptCounts = new Map();
|
|
390
443
|
for (const d of epicRunState.dispatches) {
|
|
444
|
+
const isReview = d.dispatch_key.endsWith(":review");
|
|
445
|
+
if (isReview) {
|
|
446
|
+
reviewAttemptCounts.set(d.ticket_key, (reviewAttemptCounts.get(d.ticket_key) ?? 0) + 1);
|
|
447
|
+
}
|
|
391
448
|
if (!d.run_id)
|
|
392
449
|
continue;
|
|
393
450
|
const updatedAt = new Date(d.updated_at).getTime();
|
|
394
|
-
const
|
|
451
|
+
const target = isReview ? reviewLatestDispatchByTicket : latestDispatchByTicket;
|
|
452
|
+
const prev = target.get(d.ticket_key);
|
|
395
453
|
if (!prev || updatedAt >= prev.updatedAt) {
|
|
396
|
-
|
|
454
|
+
target.set(d.ticket_key, { runId: d.run_id, updatedAt });
|
|
397
455
|
}
|
|
398
456
|
}
|
|
399
457
|
for (const [tk, info] of latestDispatchByTicket) {
|
|
400
458
|
ticketRunIdMap.set(tk, info.runId);
|
|
401
459
|
}
|
|
460
|
+
const reviewTicketRunIdMap = new Map();
|
|
461
|
+
for (const [tk, info] of reviewLatestDispatchByTicket) {
|
|
462
|
+
reviewTicketRunIdMap.set(tk, info.runId);
|
|
463
|
+
}
|
|
402
464
|
const resolvePrNumber = (ticketKey) => {
|
|
403
465
|
const raw = prBindings[ticketKey];
|
|
404
466
|
if (typeof raw === "number" && Number.isInteger(raw) && raw >= 1)
|
|
@@ -435,13 +497,30 @@ export async function runEpicTick(options, deps = {}) {
|
|
|
435
497
|
planVersion,
|
|
436
498
|
});
|
|
437
499
|
},
|
|
438
|
-
claimDispatchKey: async (ek, tk, planVersion) => recordEpicDispatch(access, {
|
|
500
|
+
claimDispatchKey: async (ek, tk, planVersion, role, attempt = 0) => recordEpicDispatch(access, {
|
|
439
501
|
epicKey: ek,
|
|
440
502
|
ticketKey: tk,
|
|
441
503
|
planVersion,
|
|
442
504
|
leaseOwner: lease_owner,
|
|
443
505
|
ttlSeconds: DEFAULT_DISPATCH_KEY_TTL_SECONDS,
|
|
506
|
+
attempt,
|
|
507
|
+
// BAPI-445: a review-role claim appends ":review" to the dispatch key
|
|
508
|
+
// so the run-id maps above can separate review runs from impl runs.
|
|
509
|
+
reviewRole: role === "review",
|
|
444
510
|
}),
|
|
511
|
+
// BAPI-445 spec re-review seams. dispatchReviewSeam is wired only when the
|
|
512
|
+
// factory provides it (gate stays off otherwise); the liveness + attempt
|
|
513
|
+
// accessors read the review-scoped maps built above.
|
|
514
|
+
dispatchReviewSeam: dispatchReviewSeamFn
|
|
515
|
+
? async (ek, tk, attempt = 0) => dispatchReviewSeamFn(ek, tk, attempt)
|
|
516
|
+
: undefined,
|
|
517
|
+
readReviewWorkerLiveness: async (_ek, tk) => {
|
|
518
|
+
const runId = reviewTicketRunIdMap.get(tk);
|
|
519
|
+
if (!runId)
|
|
520
|
+
return { alive: false, workerId: null };
|
|
521
|
+
return extractWorkerLiveness(localEvents, runId, nowFn(), livenessWindowSeconds);
|
|
522
|
+
},
|
|
523
|
+
countReviewAttempts: (tk) => reviewAttemptCounts.get(tk) ?? 0,
|
|
445
524
|
correlateRunId: async (dispatchKey, runId) => {
|
|
446
525
|
await transitionEpicDispatch(access, {
|
|
447
526
|
dispatchKey,
|
|
@@ -450,7 +529,27 @@ export async function runEpicTick(options, deps = {}) {
|
|
|
450
529
|
});
|
|
451
530
|
},
|
|
452
531
|
dispatchSeam: async (ek, tk, attempt = 0) => dispatchSeam(ek, tk, attempt),
|
|
453
|
-
processMerge: async (acc, event) =>
|
|
532
|
+
processMerge: async (acc, event) => {
|
|
533
|
+
// F4: when policy_json.local_merge.enabled is set (opt-in, default OFF)
|
|
534
|
+
// and the caller did not inject a processMerge stub, run the real merge
|
|
535
|
+
// pipeline with a LOCAL executor swapped in for the backend route — the
|
|
536
|
+
// merge happens here using the agent's own `gh` credentials, so the
|
|
537
|
+
// hosted backend never needs global GitHub write scope. Otherwise the
|
|
538
|
+
// default backend-route path is unchanged for all existing users.
|
|
539
|
+
if (deps.processMerge === undefined) {
|
|
540
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
541
|
+
const localCfg = epicRunState.epic_run.policy_json?.local_merge;
|
|
542
|
+
if (localCfg?.enabled === true) {
|
|
543
|
+
return processGateMetMerge(acc, event, {
|
|
544
|
+
merge: makeLocalMergeExecutor({
|
|
545
|
+
method: resolveLocalMergeMethod(localCfg.method),
|
|
546
|
+
approvalRequired: localCfg.approval_required === true,
|
|
547
|
+
}, { env: process.env }),
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
return processMergeFn(acc, event);
|
|
552
|
+
},
|
|
454
553
|
postActionWaitSeam: async (ek, tk) => postActionWaitSeam(ek, tk),
|
|
455
554
|
escalateOnce: async (ek, reason) => escalateOnce(ek, reason),
|
|
456
555
|
log,
|
|
@@ -776,12 +875,21 @@ export async function buildProductionEpicRuntimeDeps(epicKey) {
|
|
|
776
875
|
dryRun: dispatchDryRun,
|
|
777
876
|
autoApprove: true,
|
|
778
877
|
maxParallel: 1,
|
|
779
|
-
|
|
878
|
+
// F-base: a merge-gated dependent MUST cut from the predecessor's merged
|
|
879
|
+
// code. With refreshMain:false the worktree was cut from a STALE local
|
|
880
|
+
// `main` (never fetched/ff'd after the predecessor merged on origin), so
|
|
881
|
+
// dependents built without the predecessor's code — defeating the whole
|
|
882
|
+
// merge-gated handoff. Refresh (fetch origin + ff local base) before cut.
|
|
883
|
+
refreshMain: true,
|
|
780
884
|
branchOverrides: {},
|
|
781
885
|
baseBranch: "main",
|
|
782
886
|
conductorEnabled: true,
|
|
783
887
|
// BAPI-441: re-dispatch reuses the existing branch/worktree.
|
|
784
888
|
resumeMode: isResume,
|
|
889
|
+
// F7: on a FRESH dispatch, refuse a stale leftover `feature/<KEY>` branch
|
|
890
|
+
// (e.g. a prior run's worktree) rather than silently building on it. No
|
|
891
|
+
// effect on resume (which reuses a located worktree via a different path).
|
|
892
|
+
guardStaleWorktree: !isResume,
|
|
785
893
|
}, {
|
|
786
894
|
createConductorContext: createStartTicketsConductorContext,
|
|
787
895
|
provisionConductorHooksForRows,
|
|
@@ -802,7 +910,53 @@ export async function buildProductionEpicRuntimeDeps(epicKey) {
|
|
|
802
910
|
}
|
|
803
911
|
return runId;
|
|
804
912
|
};
|
|
805
|
-
const
|
|
913
|
+
const dispatchReviewSeam = async (ek, tk, attempt = 0) => {
|
|
914
|
+
// BAPI-445: spawn the pre-implementation spec re-review as the review-tickets
|
|
915
|
+
// automation kind — ALWAYS a review, regardless of the ticket's own plan-node
|
|
916
|
+
// automation kind (which is an implementation kind). fetchPlan must have run
|
|
917
|
+
// first so cachedPlanVersion is populated (same guard as dispatchSeam).
|
|
918
|
+
if (cachedPlanVersion === 0) {
|
|
919
|
+
throw new Error(`dispatchReviewSeam called before fetchPlan for epic ${ek} ticket ${tk}; cachedPlanVersion is 0`);
|
|
920
|
+
}
|
|
921
|
+
const dispatchDryRun = process.env.BAPI_CONDUCTOR_DISPATCH_DRY_RUN === "1";
|
|
922
|
+
// Deliberately omit `dispatch_key` from the identity: the reconcile pass owns
|
|
923
|
+
// the idempotent :review key claim (recordEpicDispatch with reviewRole) and the
|
|
924
|
+
// run_spawned correlation. orchestrateReviewTickets still mints the run_id and
|
|
925
|
+
// injects BAPI_CONDUCTOR_RUN_ID into the review agent's environment, which is
|
|
926
|
+
// exactly the correlation channel the Python review orchestrator reads to stamp
|
|
927
|
+
// the spec_review.* verdict event with this review run's id (Phase 2).
|
|
928
|
+
const identity = {
|
|
929
|
+
epic_key: ek,
|
|
930
|
+
epic_run_id: ek,
|
|
931
|
+
plan_version: cachedPlanVersion,
|
|
932
|
+
};
|
|
933
|
+
const deps = createDefaultStartTicketsDeps();
|
|
934
|
+
const result = await orchestrateReviewTickets(deps, {
|
|
935
|
+
keys: [tk],
|
|
936
|
+
epic: identity,
|
|
937
|
+
agentName: "claude",
|
|
938
|
+
dryRun: dispatchDryRun,
|
|
939
|
+
maxParallel: 1,
|
|
940
|
+
auto: true,
|
|
941
|
+
// Product directive: the spec re-review is `/review-ticket --auto --rounds=2`.
|
|
942
|
+
rounds: 2,
|
|
943
|
+
reviewOverrides: {},
|
|
944
|
+
});
|
|
945
|
+
if (!result.ok) {
|
|
946
|
+
throw new Error(`spec re-review dispatch failed: ${result.error}`);
|
|
947
|
+
}
|
|
948
|
+
let runId = result.rows[0]?.runId;
|
|
949
|
+
if (!runId && dispatchDryRun) {
|
|
950
|
+
// Dry-run produced no real run_id; substitute a synthetic, attempt-scoped id
|
|
951
|
+
// so the reconcile correlate step still records the :review dispatch row.
|
|
952
|
+
runId = `dry-run:review:${ek}:${tk}:${cachedPlanVersion}:r${attempt}`;
|
|
953
|
+
}
|
|
954
|
+
if (!runId) {
|
|
955
|
+
throw new Error(`spec re-review dispatch returned no runId for ticket ${tk}`);
|
|
956
|
+
}
|
|
957
|
+
return runId;
|
|
958
|
+
};
|
|
959
|
+
const fetchLocalEvents = async (_ek, runIds) => {
|
|
806
960
|
// Workers and the epic-tick process share the same local SQLite ledger
|
|
807
961
|
// (~/.config/bridge/events.db). pollConductorEvents opens it read-only.
|
|
808
962
|
//
|
|
@@ -831,7 +985,7 @@ export async function buildProductionEpicRuntimeDeps(epicKey) {
|
|
|
831
985
|
// cap total iterations defensively against a non-advancing cursor.
|
|
832
986
|
const MAX_PAGES = 10_000;
|
|
833
987
|
for (let page = 0; page < MAX_PAGES; page += 1) {
|
|
834
|
-
const result = pollConductorEvents({
|
|
988
|
+
const result = await pollConductorEvents({
|
|
835
989
|
data_mode: "full",
|
|
836
990
|
since_seq: sinceSeq,
|
|
837
991
|
limit: POLL_LIMIT_MAX,
|
|
@@ -897,6 +1051,7 @@ export async function buildProductionEpicRuntimeDeps(epicKey) {
|
|
|
897
1051
|
return {
|
|
898
1052
|
fetchPlan,
|
|
899
1053
|
dispatchSeam,
|
|
1054
|
+
dispatchReviewSeam,
|
|
900
1055
|
fetchLocalEvents,
|
|
901
1056
|
escalateOnce,
|
|
902
1057
|
postActionWaitSeam,
|
|
@@ -30,6 +30,12 @@ const NON_TERMINAL_STATUSES = new Set([
|
|
|
30
30
|
"running",
|
|
31
31
|
"blocked",
|
|
32
32
|
"ready_for_review",
|
|
33
|
+
// BAPI-445: mid-flight pre-implementation spec re-review. Non-terminal so a
|
|
34
|
+
// review verdict / completion signal can still advance it on a later tick, but
|
|
35
|
+
// deliberately NOT a status computeReadySet returns for normal dispatch — a
|
|
36
|
+
// reviewing ticket is mid-flight, not ready. Stranded reviewing tickets are
|
|
37
|
+
// re-driven by reconcile's dedicated reviewing re-entry branch.
|
|
38
|
+
"reviewing",
|
|
33
39
|
]);
|
|
34
40
|
const TERMINAL_SIGNAL_TYPES = new Set([
|
|
35
41
|
"gate.met",
|
|
@@ -37,18 +43,42 @@ const TERMINAL_SIGNAL_TYPES = new Set([
|
|
|
37
43
|
"ci.failed",
|
|
38
44
|
"run.stopped",
|
|
39
45
|
"review.changes_requested",
|
|
46
|
+
// BAPI-445: pre-implementation spec re-review verdicts, scoped to review runs.
|
|
47
|
+
"spec_review.passed",
|
|
48
|
+
"spec_review.changes_requested",
|
|
40
49
|
]);
|
|
41
50
|
function isNonTerminal(status) {
|
|
42
51
|
return NON_TERMINAL_STATUSES.has(status);
|
|
43
52
|
}
|
|
44
|
-
|
|
53
|
+
/**
|
|
54
|
+
* Map a terminal ledger signal to the next ticket status.
|
|
55
|
+
*
|
|
56
|
+
* `isReviewRun` marks signals correlated to a `:review`-suffixed dispatch
|
|
57
|
+
* (BAPI-445 Obstacle 2). The spec re-review verdicts fold explicitly:
|
|
58
|
+
* - `spec_review.passed` → `ready` (verdict: proceed to implementation)
|
|
59
|
+
* - `spec_review.changes_requested` → `blocked` (verdict: reject; reconcile escalates)
|
|
60
|
+
*
|
|
61
|
+
* FAIL-SAFE (BAPI-445): a review run's INCIDENTAL terminals (`run.stopped`,
|
|
62
|
+
* `gate.met`, …) are filtered out of folding entirely by `rebuildObservedState`
|
|
63
|
+
* BEFORE this function is called — a review session merely *ending* must never
|
|
64
|
+
* advance a `reviewing` ticket, or implementation would proceed regardless of
|
|
65
|
+
* the review's outcome (fail-open). So for a review run this is only ever called
|
|
66
|
+
* with a `spec_review.*` verdict; `isReviewRun` is retained for caller clarity.
|
|
67
|
+
* For implementation runs the BAPI-436 mapping is unchanged.
|
|
68
|
+
*/
|
|
69
|
+
export function signalToNextStatus(signalType, isReviewRun = false) {
|
|
70
|
+
void isReviewRun; // review-run incidental terminals are filtered before this call
|
|
71
|
+
if (signalType === "spec_review.passed")
|
|
72
|
+
return "ready";
|
|
73
|
+
if (signalType === "spec_review.changes_requested")
|
|
74
|
+
return "blocked";
|
|
45
75
|
if (signalType === "ci.failed")
|
|
46
76
|
return "blocked";
|
|
47
77
|
if (signalType === "review.changes_requested")
|
|
48
78
|
return "blocked";
|
|
49
79
|
if (signalType === "merge.succeeded")
|
|
50
80
|
return "done";
|
|
51
|
-
return "ready_for_review"; // gate.met and run.stopped: awaiting merge
|
|
81
|
+
return "ready_for_review"; // gate.met and (implementation) run.stopped: awaiting merge
|
|
52
82
|
}
|
|
53
83
|
// ---------------------------------------------------------------------------
|
|
54
84
|
// computeReadySet
|
|
@@ -74,6 +104,13 @@ export function computeReadySet(plan, ticketStatuses) {
|
|
|
74
104
|
}
|
|
75
105
|
return ready;
|
|
76
106
|
}
|
|
107
|
+
/**
|
|
108
|
+
* Default ceiling for spec re-review dispatch attempts on a stranded `reviewing`
|
|
109
|
+
* ticket (BAPI-445). Counts the initial review dispatch plus liveness-recovery
|
|
110
|
+
* re-dispatches; once reached, the reviewing recovery branch escalates instead
|
|
111
|
+
* of re-dispatching. Deliberately independent of the BAPI-441 remediation budget.
|
|
112
|
+
*/
|
|
113
|
+
export const DEFAULT_MAX_SPEC_REVIEW_ATTEMPTS = 3;
|
|
77
114
|
/**
|
|
78
115
|
* Pure, deterministic remediation decision (no I/O, no clock). Given the current
|
|
79
116
|
* budget counters, the ledger-derived worker liveness, and the configured
|
|
@@ -124,11 +161,20 @@ export function extractWorkerLiveness(events, runId, nowMs, windowSeconds) {
|
|
|
124
161
|
*/
|
|
125
162
|
export function rebuildObservedState(postgresState, events, _now) {
|
|
126
163
|
const { epic_run, ticket_statuses, dispatches } = postgresState;
|
|
127
|
-
// Build run_id → ticket_key lookup from dispatch records
|
|
164
|
+
// Build run_id → {ticket_key, isReview} lookup from dispatch records.
|
|
165
|
+
// BAPI-445 (Obstacle 2): a review run's dispatch_key carries a ":review"
|
|
166
|
+
// suffix, so the same run_id map can distinguish a pre-implementation
|
|
167
|
+
// spec-review run from the implementation run. This is correctness-critical:
|
|
168
|
+
// it lets the fold below route a review run's run.stopped to "ready" (review
|
|
169
|
+
// complete → proceed) instead of mis-folding it as implementation completion
|
|
170
|
+
// ("ready_for_review", awaiting merge), which would skip implementation.
|
|
128
171
|
const runIdToTicketKey = new Map();
|
|
129
172
|
for (const dispatch of dispatches) {
|
|
130
173
|
if (dispatch.run_id) {
|
|
131
|
-
runIdToTicketKey.set(dispatch.run_id,
|
|
174
|
+
runIdToTicketKey.set(dispatch.run_id, {
|
|
175
|
+
ticketKey: dispatch.ticket_key,
|
|
176
|
+
isReview: dispatch.dispatch_key.endsWith(":review"),
|
|
177
|
+
});
|
|
132
178
|
}
|
|
133
179
|
}
|
|
134
180
|
// Populate base maps from Postgres
|
|
@@ -147,6 +193,14 @@ export function rebuildObservedState(postgresState, events, _now) {
|
|
|
147
193
|
const pendingMergeEvents = [];
|
|
148
194
|
// Track which tickets already have a folded signal (one override per ticket)
|
|
149
195
|
const foldedTicketKeys = new Set();
|
|
196
|
+
// Track which tickets already have a gate.met queued for merge this tick, so a
|
|
197
|
+
// second gate.met for the same ticket never double-enqueues. Distinct from
|
|
198
|
+
// foldedTicketKeys: a prior run.stopped fold (→ ready_for_review) must NOT
|
|
199
|
+
// suppress a later gate.met's merge enqueue (both map to ready_for_review;
|
|
200
|
+
// only gate.met enqueues a merge, and the worker frequently emits run.stopped
|
|
201
|
+
// BEFORE a post-hoc gate.met). Only a fold to a non-mergeable status (blocked)
|
|
202
|
+
// suppresses the merge — handled via the effective-status check below.
|
|
203
|
+
const mergeQueuedTicketKeys = new Set();
|
|
150
204
|
// BAPI-441: per-ticket latest blocking reason (ci.failed / review.changes_requested),
|
|
151
205
|
// tracked across the full ledger so an already-blocked ticket still carries a
|
|
152
206
|
// reason for the remediation pass to frame the nudge.
|
|
@@ -154,37 +208,72 @@ export function rebuildObservedState(postgresState, events, _now) {
|
|
|
154
208
|
for (const event of events) {
|
|
155
209
|
if (!TERMINAL_SIGNAL_TYPES.has(event.type))
|
|
156
210
|
continue;
|
|
157
|
-
// Map event → ticket via run_id
|
|
211
|
+
// Map event → ticket via run_id, resolving the dispatch role (review vs.
|
|
212
|
+
// implementation) so the fold can treat review-run terminals distinctly.
|
|
158
213
|
const runId = typeof event.run_id === "string" ? event.run_id : null;
|
|
159
|
-
const
|
|
160
|
-
if (!
|
|
214
|
+
const mapped = runId ? runIdToTicketKey.get(runId) : undefined;
|
|
215
|
+
if (!mapped)
|
|
216
|
+
continue;
|
|
217
|
+
const ticketKey = mapped.ticketKey;
|
|
218
|
+
const isReview = mapped.isReview;
|
|
219
|
+
// BAPI-445 FAIL-SAFE: a review run's INCIDENTAL terminals (run.stopped,
|
|
220
|
+
// gate.met, …) must never advance a `reviewing` ticket — ONLY an explicit
|
|
221
|
+
// spec_review.* verdict does. A review session merely ending must not fold
|
|
222
|
+
// reviewing → ready and dispatch implementation regardless of whether the
|
|
223
|
+
// review requested changes (that would be fail-OPEN). When the verdict is
|
|
224
|
+
// not (yet) emitted, the ticket stays in `reviewing` and the reconcile
|
|
225
|
+
// liveness-recovery / escalation path surfaces it for operator attention —
|
|
226
|
+
// implementation never silently proceeds without a pass verdict.
|
|
227
|
+
const isSpecVerdict = event.type === "spec_review.passed" ||
|
|
228
|
+
event.type === "spec_review.changes_requested";
|
|
229
|
+
if (isReview && !isSpecVerdict)
|
|
161
230
|
continue;
|
|
162
231
|
// Record the blocking reason (latest wins; events are seq-ordered) regardless
|
|
163
232
|
// of fold state, so a ticket blocked on a prior tick still resolves a reason.
|
|
164
233
|
if (event.type === "ci.failed" || event.type === "review.changes_requested") {
|
|
165
234
|
ticketBlockedReasons.set(ticketKey, event.type);
|
|
166
235
|
}
|
|
236
|
+
else if (event.type === "spec_review.changes_requested") {
|
|
237
|
+
// BAPI-445: spec-review rejection. Tagged distinctly so reconcile escalates
|
|
238
|
+
// it without spending the BAPI-441 implementation remediation budget.
|
|
239
|
+
ticketBlockedReasons.set(ticketKey, "spec_review.changes_requested");
|
|
240
|
+
}
|
|
167
241
|
const postgresStatus = ticketStatusMap.get(ticketKey) ?? "planned";
|
|
168
242
|
if (!isNonTerminal(postgresStatus))
|
|
169
243
|
continue;
|
|
170
|
-
//
|
|
171
|
-
// this tick
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
|
|
244
|
+
// Queue gate.met for merge actioning unless (a) this ticket's effective
|
|
245
|
+
// status this tick is non-mergeable ("blocked" — e.g. a ci.failed that
|
|
246
|
+
// folded earlier in the same batch, or a Postgres-blocked ticket whose
|
|
247
|
+
// ci.failed re-folds every tick), or (b) a gate.met for it is already
|
|
248
|
+
// queued. `postgresStatus` already reflects same-tick folds (the loop
|
|
249
|
+
// mutates `ticketStatusMap`), so a prior run.stopped fold leaves it
|
|
250
|
+
// "ready_for_review" and the merge proceeds — fixing the deadlock where a
|
|
251
|
+
// worker's run.stopped (lower seq) suppressed a later operator/CI gate.met.
|
|
252
|
+
if (event.type === "gate.met" &&
|
|
253
|
+
postgresStatus !== "blocked" &&
|
|
254
|
+
!mergeQueuedTicketKeys.has(ticketKey)) {
|
|
175
255
|
pendingMergeEvents.push(event);
|
|
256
|
+
mergeQueuedTicketKeys.add(ticketKey);
|
|
176
257
|
}
|
|
177
258
|
const signalType = event.type;
|
|
178
|
-
const nextStatus = signalToNextStatus(signalType);
|
|
259
|
+
const nextStatus = signalToNextStatus(signalType, isReview);
|
|
179
260
|
if (foldedTicketKeys.has(ticketKey)) {
|
|
180
|
-
// Allow a same-tick upgrade
|
|
181
|
-
// merge.succeeded arrives after gate.met
|
|
261
|
+
// Allow a bounded same-tick upgrade of an already-folded ticket:
|
|
262
|
+
// 1. ready_for_review → done when merge.succeeded arrives after gate.met
|
|
263
|
+
// in the same ledger batch.
|
|
264
|
+
// 2. (BAPI-445) <any review fold> → blocked when a review run's
|
|
265
|
+
// spec_review.changes_requested arrives after its run.stopped in the
|
|
266
|
+
// same batch. A spec-review rejection must dominate plain completion
|
|
267
|
+
// so a changes-requested verdict is never lost to event ordering.
|
|
182
268
|
// First-signal-wins for everything else: if ci.failed arrived first,
|
|
183
269
|
// currentLocalStatus is "blocked" (not "ready_for_review"), so the
|
|
184
270
|
// upgrade guard below is false and merge.succeeded is intentionally
|
|
185
271
|
// dropped — a failed-CI ticket must not be silently advanced to done.
|
|
186
272
|
const currentLocalStatus = ticketStatusMap.get(ticketKey);
|
|
187
|
-
|
|
273
|
+
const mergeUpgrade = currentLocalStatus === "ready_for_review" && nextStatus === "done";
|
|
274
|
+
const specRejectUpgrade = signalType === "spec_review.changes_requested" &&
|
|
275
|
+
currentLocalStatus !== "blocked";
|
|
276
|
+
if (mergeUpgrade || specRejectUpgrade) {
|
|
188
277
|
const existingIdx = unfoldedSignals.findIndex((s) => s.ticket_key === ticketKey);
|
|
189
278
|
if (existingIdx >= 0) {
|
|
190
279
|
unfoldedSignals[existingIdx] = {
|
|
@@ -64,6 +64,18 @@ function isSqliteBusyError(error) {
|
|
|
64
64
|
* the raw error text, stack, and any secret material are discarded.
|
|
65
65
|
*/
|
|
66
66
|
export function toConductorErrorEnvelope(error) {
|
|
67
|
+
// Optional-native-binding degradation (BAPI-451): the conductor `store.ts`
|
|
68
|
+
// throws ConductorPersistenceUnavailableError when `better-sqlite3` could not be
|
|
69
|
+
// loaded. Detected by name (not instanceof) to avoid an import cycle with
|
|
70
|
+
// store.ts. Surfaced as a retryable-shaped 503 with a fixed, secret-free message
|
|
71
|
+
// so the caller learns persistence is unavailable instead of getting an opaque 500.
|
|
72
|
+
if (error instanceof Error && error.name === "ConductorPersistenceUnavailableError") {
|
|
73
|
+
return {
|
|
74
|
+
error: "PERSISTENCE_UNAVAILABLE",
|
|
75
|
+
status: 503,
|
|
76
|
+
message: "Conductor persistence is unavailable (the optional better-sqlite3 native module is not loaded). Conductor coordination features are disabled; core tools are unaffected.",
|
|
77
|
+
};
|
|
78
|
+
}
|
|
67
79
|
if (error instanceof ConductorValidationError) {
|
|
68
80
|
// Validation messages are conductor-authored and name the offending field,
|
|
69
81
|
// never its value — but redact defensively so an adversarial/secret-bearing
|
|
@@ -33,6 +33,16 @@ export const DEFAULT_GATE_NAME = "done";
|
|
|
33
33
|
export const REVIEW_PASSED = "review.passed";
|
|
34
34
|
/** Event type emitted when a reviewer requests changes. */
|
|
35
35
|
export const REVIEW_CHANGES_REQUESTED = "review.changes_requested";
|
|
36
|
+
/**
|
|
37
|
+
* BAPI-445 pre-implementation SPEC re-review verdict event types. Emitted by the
|
|
38
|
+
* spec re-review run (``/review-ticket --auto``), correlated to that review run,
|
|
39
|
+
* and kept distinct from the PR-review {@link REVIEW_PASSED} /
|
|
40
|
+
* {@link REVIEW_CHANGES_REQUESTED} verdicts so a spec-review outcome is never
|
|
41
|
+
* folded as the implementation PR's review state.
|
|
42
|
+
*/
|
|
43
|
+
export const SPEC_REVIEW_PASSED = "spec_review.passed";
|
|
44
|
+
/** Event type emitted when a spec re-review requests changes. */
|
|
45
|
+
export const SPEC_REVIEW_CHANGES_REQUESTED = "spec_review.changes_requested";
|
|
36
46
|
/** Matches ASCII control characters (C0 range plus DEL). */
|
|
37
47
|
const CONTROL_CHAR_RE = /[\u0000-\u001F\u007F]/;
|
|
38
48
|
/** Matches a 40- or 64-character hex string (git SHA-1 / SHA-256 object ids). */
|