@bridge_gpt/mcp-server 0.2.9 → 0.2.12
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 +59 -7
- package/build/commands.generated.js +6 -6
- package/build/conductor/bridge-api-client.js +263 -35
- package/build/conductor/cli.js +38 -17
- package/build/conductor/doctor.js +35 -2
- package/build/conductor/done-gate.js +301 -58
- package/build/conductor/epic-reconcile.js +318 -4
- package/build/conductor/epic-runtime.js +382 -18
- package/build/conductor/epic-state.js +188 -15
- package/build/conductor/errors.js +12 -0
- package/build/conductor/git-ci-types.js +16 -0
- package/build/conductor/git-producer.js +4 -4
- package/build/conductor/merge-ledger.js +7 -7
- package/build/conductor/pr-ci-producer.js +118 -19
- package/build/conductor/pr-review-producer.js +116 -0
- package/build/conductor/producer-ledger.js +5 -5
- package/build/conductor/spec-review-producer.js +88 -0
- package/build/conductor/store.js +105 -26
- package/build/conductor/supervisor-ledger.js +2 -2
- package/build/conductor/supervisor-merge.js +5 -5
- package/build/conductor/supervisor-message-relay.js +32 -1
- package/build/conductor/supervisor-runtime.js +10 -10
- package/build/conductor/taxonomy.js +8 -0
- package/build/conductor/tools.js +7 -7
- package/build/conductor-bin.js +12350 -19
- package/build/conductor-claude-hook-bin.js +167 -17
- package/build/decision-page-schema.js +26 -0
- package/build/doctor.js +200 -0
- package/build/index.js +23696 -4351
- package/build/init.js +481 -0
- package/build/install-bridge.js +772 -0
- package/build/mcp-profile.js +43 -0
- package/build/pipelines.generated.js +70 -48
- package/build/readme.generated.js +1 -1
- package/build/start-tickets-conductor.js +1 -0
- package/build/start-tickets.js +186 -10
- package/build/upgrade-cli.js +154 -0
- 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
|
@@ -17,16 +17,20 @@
|
|
|
17
17
|
* All durable mutations go through the injectable seams that call the sibling
|
|
18
18
|
* Epic Run TS client (already available in bridge-api-client.ts as of BAPI-407).
|
|
19
19
|
*/
|
|
20
|
-
import {
|
|
20
|
+
import { spawnSync } from "child_process";
|
|
21
|
+
import { resolveConductorBridgeApiAccess, claimEpicSupervisionLease, fetchEpicRunState, advanceEpicTicketStatus, createEpicTicketStatus, recordEpicDispatch, transitionEpicDispatch, fetchParseStatus, triggerRepositoryParse, getEpicPlan, buildEpicDispatchKey, fetchEffectiveSupervisorConfig, fetchEffectiveSupervisorSetup, fetchPrReviewStatus, remediateEpicTicket, deletePullRequestBranch, transitionJiraStatus, } from "./bridge-api-client.js";
|
|
21
22
|
import { processGateMetMerge } from "./supervisor-merge.js";
|
|
22
|
-
import { rebuildObservedState, } from "./epic-state.js";
|
|
23
|
+
import { rebuildObservedState, extractWorkerLiveness, } from "./epic-state.js";
|
|
23
24
|
import { reconcileEpic } from "./epic-reconcile.js";
|
|
25
|
+
import { buildSupervisorRemediationWorkerMessage } from "./supervisor-message-relay.js";
|
|
26
|
+
import { sendWorkerMessage } from "./store.js";
|
|
24
27
|
import { hashPlan } from "./plan.js";
|
|
25
|
-
import { pollConductorEvents } from "./store.js";
|
|
28
|
+
import { pollConductorEvents, POLL_LIMIT_MAX } from "./store.js";
|
|
26
29
|
import { dispatchSupervisorNotification } from "./supervisor-notification.js";
|
|
27
30
|
import { makeSupervisorIdempotencyKey } from "./supervisor-ledger.js";
|
|
28
31
|
import { createDefaultStartTicketsDeps, orchestrateStartTickets } from "../start-tickets.js";
|
|
29
32
|
import { orchestrateReviewTickets } from "../review-tickets.js";
|
|
33
|
+
import { createStartTicketsConductorContext, provisionConductorHooksForRows, emitStartTicketsRunStarted, } from "../start-tickets-conductor.js";
|
|
30
34
|
// ---------------------------------------------------------------------------
|
|
31
35
|
// Constants
|
|
32
36
|
// ---------------------------------------------------------------------------
|
|
@@ -46,7 +50,7 @@ function defaultLeaseOwner() {
|
|
|
46
50
|
async function defaultEscalateOnce(epicKey, reason) {
|
|
47
51
|
process.stderr.write(`[epic-tick] ESCALATION epic=${epicKey} reason=${reason}\n`);
|
|
48
52
|
}
|
|
49
|
-
async function defaultDispatchSeam(_epicKey, ticketKey) {
|
|
53
|
+
async function defaultDispatchSeam(_epicKey, ticketKey, _attempt = 0) {
|
|
50
54
|
throw new Error(`dispatch seam not wired for ticket ${ticketKey}`);
|
|
51
55
|
}
|
|
52
56
|
async function defaultPostActionWaitSeam(_epicKey, _ticketKey) {
|
|
@@ -71,9 +75,13 @@ export async function runEpicTick(options, deps = {}) {
|
|
|
71
75
|
const errorLog = deps.errorLog ?? ((msg) => process.stderr.write(`${msg}\n`));
|
|
72
76
|
const escalateOnce = deps.escalateOnce ?? defaultEscalateOnce;
|
|
73
77
|
const dispatchSeam = deps.dispatchSeam ?? defaultDispatchSeam;
|
|
78
|
+
// BAPI-445: optional review dispatch seam. When absent the spec re-review
|
|
79
|
+
// sequencing gate stays off (a ready ticket dispatches implementation directly).
|
|
80
|
+
const dispatchReviewSeamFn = deps.dispatchReviewSeam;
|
|
74
81
|
const processMergeFn = deps.processMerge ?? processGateMetMerge;
|
|
75
82
|
const postActionWaitSeam = deps.postActionWaitSeam ?? defaultPostActionWaitSeam;
|
|
76
|
-
const fetchLocalEvents = deps.fetchLocalEvents ??
|
|
83
|
+
const fetchLocalEvents = deps.fetchLocalEvents ??
|
|
84
|
+
((_key, _runIds) => []);
|
|
77
85
|
const resolveBridgeAccess = deps.resolveBridgeAccess ?? resolveConductorBridgeApiAccess;
|
|
78
86
|
const claimLeaseFn = deps.claimLease ?? claimEpicSupervisionLease;
|
|
79
87
|
const fetchEpicStateFn = deps.fetchEpicState ?? fetchEpicRunState;
|
|
@@ -190,7 +198,15 @@ export async function runEpicTick(options, deps = {}) {
|
|
|
190
198
|
worker_count: 0,
|
|
191
199
|
};
|
|
192
200
|
}
|
|
193
|
-
|
|
201
|
+
// Scope the local-ledger read to this epic's dispatched run_ids. The shared
|
|
202
|
+
// ~/.config/bridge/events.db ledger accumulates events for every epic/worker
|
|
203
|
+
// on the machine; rebuildObservedState only folds signals whose run_id maps
|
|
204
|
+
// to one of these dispatches, so scoping the read here avoids loading the
|
|
205
|
+
// entire (up to 50K-row) ledger on every tick.
|
|
206
|
+
const dispatchedRunIds = epicRunState.dispatches
|
|
207
|
+
.map((d) => d.run_id)
|
|
208
|
+
.filter((rid) => typeof rid === "string" && rid.length > 0);
|
|
209
|
+
const localEvents = await fetchLocalEvents(epic_key, dispatchedRunIds);
|
|
194
210
|
const observed = rebuildObservedState(epicRunState, localEvents, nowFn());
|
|
195
211
|
workerCount = [...observed.ticket_statuses.values()].filter((s) => ACTIVE_WORKER_STATUSES.has(s)).length;
|
|
196
212
|
// Step 3.5: Run post-action waits (parse-after-merge)
|
|
@@ -330,6 +346,98 @@ export async function runEpicTick(options, deps = {}) {
|
|
|
330
346
|
}
|
|
331
347
|
// Step 5: Reconcile observed→desired
|
|
332
348
|
if (plan !== null) {
|
|
349
|
+
// BAPI-441: fetch the effective supervisor config (budget ceilings +
|
|
350
|
+
// liveness window) and setup (pr_bindings) once. Fail-open: if the config
|
|
351
|
+
// read fails, remediationConfig stays undefined and reconcile skips the
|
|
352
|
+
// remediation pass entirely (dispatch/merge steps unaffected).
|
|
353
|
+
let remediationConfig;
|
|
354
|
+
let livenessWindowSeconds = 120;
|
|
355
|
+
let prBindings = {};
|
|
356
|
+
try {
|
|
357
|
+
const cfg = await fetchEffectiveSupervisorConfig(access, epic_key);
|
|
358
|
+
remediationConfig = {
|
|
359
|
+
max_remediation_attempts: cfg.max_remediation_attempts,
|
|
360
|
+
max_remediation_no_progress_attempts: cfg.max_remediation_no_progress_attempts,
|
|
361
|
+
auto_rereview_enabled: cfg.auto_rereview_enabled ?? false,
|
|
362
|
+
teardown_enabled: cfg.teardown_enabled ?? false,
|
|
363
|
+
};
|
|
364
|
+
livenessWindowSeconds = cfg.worker_liveness_window_seconds;
|
|
365
|
+
}
|
|
366
|
+
catch (err) {
|
|
367
|
+
const safeMsg = err instanceof Error ? err.constructor.name : "config error";
|
|
368
|
+
errorLog(`[epic-tick] supervisor-config fetch failed (${safeMsg}); skipping remediation for epic=${epic_key}`);
|
|
369
|
+
}
|
|
370
|
+
if (remediationConfig) {
|
|
371
|
+
try {
|
|
372
|
+
const setup = await fetchEffectiveSupervisorSetup(access, epic_key);
|
|
373
|
+
if (setup.pr_bindings && typeof setup.pr_bindings === "object") {
|
|
374
|
+
prBindings = setup.pr_bindings;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
catch (err) {
|
|
378
|
+
const safeMsg = err instanceof Error ? err.constructor.name : "setup error";
|
|
379
|
+
errorLog(`[epic-tick] supervisor-setup fetch failed (${safeMsg}); remediation PR resolution degraded for epic=${epic_key}`);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
// ticket_key → dispatched run_id (the run whose heartbeat liveness reads).
|
|
383
|
+
// Seed from ticket_status.dispatch_run_id, then prefer the most-recent
|
|
384
|
+
// dispatch-ledger run_id per ticket so that after a remediation re-dispatch
|
|
385
|
+
// (a new attempt-scoped epic_dispatch row correlated with the fresh run_id)
|
|
386
|
+
// liveness tracks the NEW worker rather than the stale original.
|
|
387
|
+
const ticketRunIdMap = new Map();
|
|
388
|
+
for (const ts of epicRunState.ticket_statuses) {
|
|
389
|
+
if (ts.dispatch_run_id)
|
|
390
|
+
ticketRunIdMap.set(ts.ticket_key, ts.dispatch_run_id);
|
|
391
|
+
}
|
|
392
|
+
// BAPI-445: split dispatch records by role. `:review`-suffixed dispatch keys
|
|
393
|
+
// are the pre-implementation spec re-review runs; they must be EXCLUDED from
|
|
394
|
+
// the implementation liveness map (so BAPI-441 remediation liveness only ever
|
|
395
|
+
// targets the implementation run) and tracked in a separate review map +
|
|
396
|
+
// per-ticket attempt counter consumed by the reviewing recovery branch.
|
|
397
|
+
const latestDispatchByTicket = new Map();
|
|
398
|
+
const reviewLatestDispatchByTicket = new Map();
|
|
399
|
+
const reviewAttemptCounts = new Map();
|
|
400
|
+
for (const d of epicRunState.dispatches) {
|
|
401
|
+
const isReview = d.dispatch_key.endsWith(":review");
|
|
402
|
+
if (isReview) {
|
|
403
|
+
reviewAttemptCounts.set(d.ticket_key, (reviewAttemptCounts.get(d.ticket_key) ?? 0) + 1);
|
|
404
|
+
}
|
|
405
|
+
if (!d.run_id)
|
|
406
|
+
continue;
|
|
407
|
+
const updatedAt = new Date(d.updated_at).getTime();
|
|
408
|
+
const target = isReview ? reviewLatestDispatchByTicket : latestDispatchByTicket;
|
|
409
|
+
const prev = target.get(d.ticket_key);
|
|
410
|
+
if (!prev || updatedAt >= prev.updatedAt) {
|
|
411
|
+
target.set(d.ticket_key, { runId: d.run_id, updatedAt });
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
for (const [tk, info] of latestDispatchByTicket) {
|
|
415
|
+
ticketRunIdMap.set(tk, info.runId);
|
|
416
|
+
}
|
|
417
|
+
const reviewTicketRunIdMap = new Map();
|
|
418
|
+
for (const [tk, info] of reviewLatestDispatchByTicket) {
|
|
419
|
+
reviewTicketRunIdMap.set(tk, info.runId);
|
|
420
|
+
}
|
|
421
|
+
const resolvePrNumber = (ticketKey) => {
|
|
422
|
+
const raw = prBindings[ticketKey];
|
|
423
|
+
if (typeof raw === "number" && Number.isInteger(raw) && raw >= 1)
|
|
424
|
+
return raw;
|
|
425
|
+
if (raw && typeof raw === "object") {
|
|
426
|
+
const obj = raw;
|
|
427
|
+
const pr = obj.pr_number ?? obj.pr;
|
|
428
|
+
if (typeof pr === "number" && Number.isInteger(pr) && pr >= 1)
|
|
429
|
+
return pr;
|
|
430
|
+
}
|
|
431
|
+
return null;
|
|
432
|
+
};
|
|
433
|
+
const maxSeqForRun = (runId) => {
|
|
434
|
+
let maxSeq = 0;
|
|
435
|
+
for (const ev of localEvents) {
|
|
436
|
+
if (ev.run_id === runId && ev.seq > maxSeq)
|
|
437
|
+
maxSeq = ev.seq;
|
|
438
|
+
}
|
|
439
|
+
return maxSeq;
|
|
440
|
+
};
|
|
333
441
|
const reconcileDeps = {
|
|
334
442
|
casTicketStatus: async (ek, tk, rowVersion, nextStatus, planVersion) => advanceEpicTicketStatus(access, {
|
|
335
443
|
epicKey: ek,
|
|
@@ -346,13 +454,30 @@ export async function runEpicTick(options, deps = {}) {
|
|
|
346
454
|
planVersion,
|
|
347
455
|
});
|
|
348
456
|
},
|
|
349
|
-
claimDispatchKey: async (ek, tk, planVersion) => recordEpicDispatch(access, {
|
|
457
|
+
claimDispatchKey: async (ek, tk, planVersion, role, attempt = 0) => recordEpicDispatch(access, {
|
|
350
458
|
epicKey: ek,
|
|
351
459
|
ticketKey: tk,
|
|
352
460
|
planVersion,
|
|
353
461
|
leaseOwner: lease_owner,
|
|
354
462
|
ttlSeconds: DEFAULT_DISPATCH_KEY_TTL_SECONDS,
|
|
463
|
+
attempt,
|
|
464
|
+
// BAPI-445: a review-role claim appends ":review" to the dispatch key
|
|
465
|
+
// so the run-id maps above can separate review runs from impl runs.
|
|
466
|
+
reviewRole: role === "review",
|
|
355
467
|
}),
|
|
468
|
+
// BAPI-445 spec re-review seams. dispatchReviewSeam is wired only when the
|
|
469
|
+
// factory provides it (gate stays off otherwise); the liveness + attempt
|
|
470
|
+
// accessors read the review-scoped maps built above.
|
|
471
|
+
dispatchReviewSeam: dispatchReviewSeamFn
|
|
472
|
+
? async (ek, tk, attempt = 0) => dispatchReviewSeamFn(ek, tk, attempt)
|
|
473
|
+
: undefined,
|
|
474
|
+
readReviewWorkerLiveness: async (_ek, tk) => {
|
|
475
|
+
const runId = reviewTicketRunIdMap.get(tk);
|
|
476
|
+
if (!runId)
|
|
477
|
+
return { alive: false, workerId: null };
|
|
478
|
+
return extractWorkerLiveness(localEvents, runId, nowFn(), livenessWindowSeconds);
|
|
479
|
+
},
|
|
480
|
+
countReviewAttempts: (tk) => reviewAttemptCounts.get(tk) ?? 0,
|
|
356
481
|
correlateRunId: async (dispatchKey, runId) => {
|
|
357
482
|
await transitionEpicDispatch(access, {
|
|
358
483
|
dispatchKey,
|
|
@@ -360,13 +485,146 @@ export async function runEpicTick(options, deps = {}) {
|
|
|
360
485
|
runId,
|
|
361
486
|
});
|
|
362
487
|
},
|
|
363
|
-
dispatchSeam: async (ek, tk) => dispatchSeam(ek, tk),
|
|
488
|
+
dispatchSeam: async (ek, tk, attempt = 0) => dispatchSeam(ek, tk, attempt),
|
|
364
489
|
processMerge: async (acc, event) => processMergeFn(acc, event),
|
|
365
490
|
postActionWaitSeam: async (ek, tk) => postActionWaitSeam(ek, tk),
|
|
366
491
|
escalateOnce: async (ek, reason) => escalateOnce(ek, reason),
|
|
367
492
|
log,
|
|
493
|
+
// BAPI-442: teardown and Jira-transition seams (fail-open, optional).
|
|
494
|
+
teardownSeam: async (_ek, tk) => {
|
|
495
|
+
// Resolve the PR number for the ticket.
|
|
496
|
+
const prNumber = resolvePrNumber(tk);
|
|
497
|
+
if (prNumber === null) {
|
|
498
|
+
errorLog(`[epic-tick] teardown: no PR binding for ${tk}; skipping`);
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
// Fetch setup to get the expected head SHA if available; fall back to empty.
|
|
502
|
+
let expectedSha = "";
|
|
503
|
+
try {
|
|
504
|
+
const setup = await fetchEffectiveSupervisorSetup(access, epic_key);
|
|
505
|
+
const binding = (setup.pr_bindings ?? {})[tk];
|
|
506
|
+
if (binding && typeof binding === "object") {
|
|
507
|
+
const b = binding;
|
|
508
|
+
if (typeof b.head_sha === "string")
|
|
509
|
+
expectedSha = b.head_sha;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
catch {
|
|
513
|
+
// Best-effort; proceed with empty SHA (endpoint still deletes by PR number)
|
|
514
|
+
}
|
|
515
|
+
try {
|
|
516
|
+
await deletePullRequestBranch(access, prNumber, expectedSha || "");
|
|
517
|
+
log(`[epic-tick] teardown: branch deleted for PR #${prNumber} (ticket=${tk})`);
|
|
518
|
+
}
|
|
519
|
+
catch (err) {
|
|
520
|
+
const safeMsg = err instanceof Error ? err.constructor.name : "error";
|
|
521
|
+
errorLog(`[epic-tick] teardown: branch-delete failed (${safeMsg}) for ${tk}`);
|
|
522
|
+
}
|
|
523
|
+
// Remove local worktree idempotently; errors are benign.
|
|
524
|
+
try {
|
|
525
|
+
spawnSync("git", ["worktree", "remove", "--force", tk], { stdio: "ignore" });
|
|
526
|
+
log(`[epic-tick] teardown: worktree removed for ${tk}`);
|
|
527
|
+
}
|
|
528
|
+
catch {
|
|
529
|
+
// Already removed or never created — idempotent skip.
|
|
530
|
+
}
|
|
531
|
+
},
|
|
532
|
+
jiraTransitionSeam: async (_ek, tk) => {
|
|
533
|
+
try {
|
|
534
|
+
const result = await transitionJiraStatus(access, tk, "auto");
|
|
535
|
+
if (result.status === "skipped") {
|
|
536
|
+
log(`[epic-tick] jira-transition: no matching transition for ${tk} (skipped)`);
|
|
537
|
+
}
|
|
538
|
+
else {
|
|
539
|
+
log(`[epic-tick] jira-transition: transitioned ${tk}`);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
catch (err) {
|
|
543
|
+
const safeMsg = err instanceof Error ? err.constructor.name : "error";
|
|
544
|
+
errorLog(`[epic-tick] jira-transition failed (${safeMsg}) for ${tk}`);
|
|
545
|
+
}
|
|
546
|
+
},
|
|
547
|
+
// BAPI-441 remediation seams.
|
|
548
|
+
readWorkerLiveness: async (_ek, tk) => {
|
|
549
|
+
const runId = ticketRunIdMap.get(tk);
|
|
550
|
+
if (!runId)
|
|
551
|
+
return { alive: false, workerId: null };
|
|
552
|
+
return extractWorkerLiveness(localEvents, runId, nowFn(), livenessWindowSeconds);
|
|
553
|
+
},
|
|
554
|
+
remediateCas: async (ek, tk, attemptKind, reason) => {
|
|
555
|
+
const prNumber = resolvePrNumber(tk);
|
|
556
|
+
if (prNumber === null) {
|
|
557
|
+
throw new Error(`remediate: no PR binding for ${tk}`);
|
|
558
|
+
}
|
|
559
|
+
const reviewStatus = (await fetchPrReviewStatus(access, prNumber));
|
|
560
|
+
const headSha = reviewStatus?.detail?.head_sha ?? null;
|
|
561
|
+
if (!headSha) {
|
|
562
|
+
throw new Error(`remediate: no head_sha for PR ${prNumber}`);
|
|
563
|
+
}
|
|
564
|
+
const rowVersion = observed.ticket_row_versions.get(tk) ?? 0;
|
|
565
|
+
// Deterministic block-state idempotency key: stable for a given durable
|
|
566
|
+
// row_version so a same-tick retry replays (409, swallowed); advances
|
|
567
|
+
// with the next attempt.
|
|
568
|
+
const idempotencyKey = `remediate:${ek}:${tk}:${rowVersion}`;
|
|
569
|
+
const result = await remediateEpicTicket(access, {
|
|
570
|
+
pr_number: prNumber,
|
|
571
|
+
epic_run_id: ek,
|
|
572
|
+
ticket_key: tk,
|
|
573
|
+
expected_row_version: rowVersion,
|
|
574
|
+
head_sha: headSha,
|
|
575
|
+
idempotency_key: idempotencyKey,
|
|
576
|
+
attempt_kind: attemptKind,
|
|
577
|
+
reason,
|
|
578
|
+
});
|
|
579
|
+
if (result.conflict) {
|
|
580
|
+
return { conflict: true, reviewDigest: null, truncated: false };
|
|
581
|
+
}
|
|
582
|
+
return {
|
|
583
|
+
conflict: false,
|
|
584
|
+
reviewDigest: result.response.review_digest,
|
|
585
|
+
truncated: result.response.truncated,
|
|
586
|
+
};
|
|
587
|
+
},
|
|
588
|
+
sendNudge: async (_ek, tk, attempt, reviewDigest, truncated, reason, workerId) => {
|
|
589
|
+
const runId = ticketRunIdMap.get(tk);
|
|
590
|
+
if (!runId)
|
|
591
|
+
throw new Error(`nudge: no run_id for ${tk}`);
|
|
592
|
+
// workerId is resolved by readWorkerLiveness from the same heartbeat
|
|
593
|
+
// scan and null-checked by the reconcile pass before remediateCas, so
|
|
594
|
+
// the two seams stay consistent and no budget is burned on a missing id.
|
|
595
|
+
const input = buildSupervisorRemediationWorkerMessage({
|
|
596
|
+
runId,
|
|
597
|
+
workerId,
|
|
598
|
+
ticketKey: tk,
|
|
599
|
+
reason,
|
|
600
|
+
attempt,
|
|
601
|
+
reviewDigest: reviewDigest ?? "",
|
|
602
|
+
truncated,
|
|
603
|
+
causeSeq: maxSeqForRun(runId),
|
|
604
|
+
});
|
|
605
|
+
sendWorkerMessage(input);
|
|
606
|
+
},
|
|
607
|
+
resumeDispatch: async (ek, tk, attempt) => {
|
|
608
|
+
// Claim an attempt-scoped pending dispatch row FIRST so the spawn's
|
|
609
|
+
// run_spawned correlation (inside orchestrateStartTickets) has a row to
|
|
610
|
+
// transition and the re-dispatched run_id is durably recorded against
|
|
611
|
+
// the ticket. The claim is idempotent (lease-held/already-spawned are
|
|
612
|
+
// returned, not thrown).
|
|
613
|
+
await recordEpicDispatch(access, {
|
|
614
|
+
epicKey: ek,
|
|
615
|
+
ticketKey: tk,
|
|
616
|
+
planVersion: plan.plan_version,
|
|
617
|
+
leaseOwner: lease_owner,
|
|
618
|
+
ttlSeconds: DEFAULT_DISPATCH_KEY_TTL_SECONDS,
|
|
619
|
+
attempt,
|
|
620
|
+
});
|
|
621
|
+
// dispatchSeam returns the new run_id; orchestrate correlates it into
|
|
622
|
+
// the attempt-scoped epic_dispatch row, so the next tick's liveness map
|
|
623
|
+
// (built from the dispatch ledger) tracks the fresh worker.
|
|
624
|
+
await dispatchSeam(ek, tk, attempt);
|
|
625
|
+
},
|
|
368
626
|
};
|
|
369
|
-
const reconcileResult = await reconcileEpic(access, observed, plan, reconcileDeps);
|
|
627
|
+
const reconcileResult = await reconcileEpic(access, observed, plan, reconcileDeps, remediationConfig);
|
|
370
628
|
log(`[epic-tick] reconcile done: epic=${epic_key} ` +
|
|
371
629
|
`signals=${reconcileResult.signals_folded} ` +
|
|
372
630
|
`dispatched=${reconcileResult.dispatched} ` +
|
|
@@ -494,7 +752,7 @@ export async function buildProductionEpicRuntimeDeps(epicKey) {
|
|
|
494
752
|
const planHash = hashPlan(dag);
|
|
495
753
|
return { plan_hash: planHash, plan_version: response.plan_version, tickets };
|
|
496
754
|
};
|
|
497
|
-
const dispatchSeam = async (ek, tk) => {
|
|
755
|
+
const dispatchSeam = async (ek, tk, attempt = 0) => {
|
|
498
756
|
// Guard: fetchPlan must run before dispatchSeam so cachedPlanVersion and
|
|
499
757
|
// automationMap are populated. A zero version means the factory seam was
|
|
500
758
|
// wired but fetchPlan was never called — fail explicitly rather than silently
|
|
@@ -502,6 +760,12 @@ export async function buildProductionEpicRuntimeDeps(epicKey) {
|
|
|
502
760
|
if (cachedPlanVersion === 0) {
|
|
503
761
|
throw new Error(`dispatchSeam called before fetchPlan for epic ${ek} ticket ${tk}; cachedPlanVersion is 0`);
|
|
504
762
|
}
|
|
763
|
+
// BAPI-441: a remediation re-dispatch (attempt > 0) reuses the existing
|
|
764
|
+
// branch/worktree (resume mode) and claims an attempt-scoped dispatch key so
|
|
765
|
+
// it is not deduped against the original epic dispatch.
|
|
766
|
+
const isResume = attempt > 0;
|
|
767
|
+
// The dispatch kind comes from the plan node's automation (start-tickets or
|
|
768
|
+
// review-tickets); default to start-tickets when unspecified.
|
|
505
769
|
const kind = automationMap.get(tk) ?? "start-tickets";
|
|
506
770
|
// Operator dry-run: when BAPI_CONDUCTOR_DISPATCH_DRY_RUN=1, dispatch resolves
|
|
507
771
|
// the spawn command + model routing but opens NO terminal, creates NO worktree,
|
|
@@ -513,7 +777,7 @@ export async function buildProductionEpicRuntimeDeps(epicKey) {
|
|
|
513
777
|
epic_key: ek,
|
|
514
778
|
epic_run_id: ek,
|
|
515
779
|
plan_version: cachedPlanVersion,
|
|
516
|
-
dispatch_key: buildEpicDispatchKey(ek, tk, cachedPlanVersion),
|
|
780
|
+
dispatch_key: buildEpicDispatchKey(ek, tk, cachedPlanVersion, attempt),
|
|
517
781
|
};
|
|
518
782
|
const deps = createDefaultStartTicketsDeps();
|
|
519
783
|
let runId;
|
|
@@ -533,6 +797,14 @@ export async function buildProductionEpicRuntimeDeps(epicKey) {
|
|
|
533
797
|
runId = result.rows[0]?.runId;
|
|
534
798
|
}
|
|
535
799
|
else {
|
|
800
|
+
// BAPI-409 / IH-1: epic dispatch (dispatch_key set) requires the conductor
|
|
801
|
+
// stage to mint a run_id and provision per-worker env/supervisor context.
|
|
802
|
+
// `conductorEnabled: true` alone is necessary but not sufficient — the
|
|
803
|
+
// BAPI-409 guard in orchestrateStartTickets fails closed unless the
|
|
804
|
+
// createConductorContext seam (and its siblings) is injected via the third
|
|
805
|
+
// `overrides` argument, exactly as the packaged start-tickets CLI does. The
|
|
806
|
+
// orchestrator short-circuits on dryRun before using them, so passing them
|
|
807
|
+
// unconditionally is safe; dispatchDryRun preserves the operator dry-run seam.
|
|
536
808
|
const result = await orchestrateStartTickets(deps, {
|
|
537
809
|
keys: [tk],
|
|
538
810
|
epic: identity,
|
|
@@ -543,11 +815,13 @@ export async function buildProductionEpicRuntimeDeps(epicKey) {
|
|
|
543
815
|
refreshMain: false,
|
|
544
816
|
branchOverrides: {},
|
|
545
817
|
baseBranch: "main",
|
|
546
|
-
// Epic dispatch always uses the Conductor system (epic-tick coordinates
|
|
547
|
-
// workers through it), so the message-relay instruction must be present
|
|
548
|
-
// on epic-dispatched worker prompts regardless of the user-facing
|
|
549
|
-
// `--conductor` default.
|
|
550
818
|
conductorEnabled: true,
|
|
819
|
+
// BAPI-441: re-dispatch reuses the existing branch/worktree.
|
|
820
|
+
resumeMode: isResume,
|
|
821
|
+
}, {
|
|
822
|
+
createConductorContext: createStartTicketsConductorContext,
|
|
823
|
+
provisionConductorHooksForRows,
|
|
824
|
+
emitStartTicketsRunStarted,
|
|
551
825
|
});
|
|
552
826
|
if (!result.ok) {
|
|
553
827
|
throw new Error(`start-tickets dispatch failed: ${result.error}`);
|
|
@@ -564,11 +838,96 @@ export async function buildProductionEpicRuntimeDeps(epicKey) {
|
|
|
564
838
|
}
|
|
565
839
|
return runId;
|
|
566
840
|
};
|
|
567
|
-
const
|
|
841
|
+
const dispatchReviewSeam = async (ek, tk, attempt = 0) => {
|
|
842
|
+
// BAPI-445: spawn the pre-implementation spec re-review as the review-tickets
|
|
843
|
+
// automation kind — ALWAYS a review, regardless of the ticket's own plan-node
|
|
844
|
+
// automation kind (which is an implementation kind). fetchPlan must have run
|
|
845
|
+
// first so cachedPlanVersion is populated (same guard as dispatchSeam).
|
|
846
|
+
if (cachedPlanVersion === 0) {
|
|
847
|
+
throw new Error(`dispatchReviewSeam called before fetchPlan for epic ${ek} ticket ${tk}; cachedPlanVersion is 0`);
|
|
848
|
+
}
|
|
849
|
+
const dispatchDryRun = process.env.BAPI_CONDUCTOR_DISPATCH_DRY_RUN === "1";
|
|
850
|
+
// Deliberately omit `dispatch_key` from the identity: the reconcile pass owns
|
|
851
|
+
// the idempotent :review key claim (recordEpicDispatch with reviewRole) and the
|
|
852
|
+
// run_spawned correlation. orchestrateReviewTickets still mints the run_id and
|
|
853
|
+
// injects BAPI_CONDUCTOR_RUN_ID into the review agent's environment, which is
|
|
854
|
+
// exactly the correlation channel the Python review orchestrator reads to stamp
|
|
855
|
+
// the spec_review.* verdict event with this review run's id (Phase 2).
|
|
856
|
+
const identity = {
|
|
857
|
+
epic_key: ek,
|
|
858
|
+
epic_run_id: ek,
|
|
859
|
+
plan_version: cachedPlanVersion,
|
|
860
|
+
};
|
|
861
|
+
const deps = createDefaultStartTicketsDeps();
|
|
862
|
+
const result = await orchestrateReviewTickets(deps, {
|
|
863
|
+
keys: [tk],
|
|
864
|
+
epic: identity,
|
|
865
|
+
agentName: "claude",
|
|
866
|
+
dryRun: dispatchDryRun,
|
|
867
|
+
maxParallel: 1,
|
|
868
|
+
auto: true,
|
|
869
|
+
// Product directive: the spec re-review is `/review-ticket --auto --rounds=2`.
|
|
870
|
+
rounds: 2,
|
|
871
|
+
reviewOverrides: {},
|
|
872
|
+
});
|
|
873
|
+
if (!result.ok) {
|
|
874
|
+
throw new Error(`spec re-review dispatch failed: ${result.error}`);
|
|
875
|
+
}
|
|
876
|
+
let runId = result.rows[0]?.runId;
|
|
877
|
+
if (!runId && dispatchDryRun) {
|
|
878
|
+
// Dry-run produced no real run_id; substitute a synthetic, attempt-scoped id
|
|
879
|
+
// so the reconcile correlate step still records the :review dispatch row.
|
|
880
|
+
runId = `dry-run:review:${ek}:${tk}:${cachedPlanVersion}:r${attempt}`;
|
|
881
|
+
}
|
|
882
|
+
if (!runId) {
|
|
883
|
+
throw new Error(`spec re-review dispatch returned no runId for ticket ${tk}`);
|
|
884
|
+
}
|
|
885
|
+
return runId;
|
|
886
|
+
};
|
|
887
|
+
const fetchLocalEvents = async (_ek, runIds) => {
|
|
568
888
|
// Workers and the epic-tick process share the same local SQLite ledger
|
|
569
889
|
// (~/.config/bridge/events.db). pollConductorEvents opens it read-only.
|
|
570
|
-
|
|
571
|
-
|
|
890
|
+
//
|
|
891
|
+
// Scope the read to this epic's dispatched run_ids. The shared ledger holds
|
|
892
|
+
// events for every epic/worker on the machine (up to RETENTION_MAX_ROWS),
|
|
893
|
+
// but rebuildObservedState only folds signals whose run_id maps to one of
|
|
894
|
+
// these dispatches — so the run_ids filter pushes that scoping into SQL and
|
|
895
|
+
// avoids loading sibling-epic events on every tick. With no known run_ids
|
|
896
|
+
// (first tick before any dispatch) there is nothing to fold, so skip the
|
|
897
|
+
// read entirely.
|
|
898
|
+
//
|
|
899
|
+
// pollConductorEvents returns at most POLL_LIMIT_MAX events per call
|
|
900
|
+
// (default 100, capped at 1000) starting at `since_seq`. rebuildObservedState
|
|
901
|
+
// folds terminal signals (gate.met/run.stopped/merge.succeeded/ci.failed)
|
|
902
|
+
// ONLY from the events it is handed, so a single capped page silently hides
|
|
903
|
+
// recent terminal signals once the (scoped) result grows past one page —
|
|
904
|
+
// done-detection then breaks. Drain the COMPLETE history by paginating on the
|
|
905
|
+
// `next_seq` cursor until a short (or empty) page signals the tail.
|
|
906
|
+
if (runIds !== undefined && runIds.length === 0) {
|
|
907
|
+
return [];
|
|
908
|
+
}
|
|
909
|
+
const runIdsFilter = runIds && runIds.length > 0 ? { run_ids: [...runIds] } : undefined;
|
|
910
|
+
const events = [];
|
|
911
|
+
let sinceSeq = 1;
|
|
912
|
+
// Retention caps (retention_days/retention_max_rows) bound the ledger, but
|
|
913
|
+
// cap total iterations defensively against a non-advancing cursor.
|
|
914
|
+
const MAX_PAGES = 10_000;
|
|
915
|
+
for (let page = 0; page < MAX_PAGES; page += 1) {
|
|
916
|
+
const result = await pollConductorEvents({
|
|
917
|
+
data_mode: "full",
|
|
918
|
+
since_seq: sinceSeq,
|
|
919
|
+
limit: POLL_LIMIT_MAX,
|
|
920
|
+
filter: runIdsFilter,
|
|
921
|
+
});
|
|
922
|
+
events.push(...result.events);
|
|
923
|
+
// Stop on a short/empty page (no more rows) or a cursor that fails to
|
|
924
|
+
// advance (guards against an infinite loop).
|
|
925
|
+
if (result.count < POLL_LIMIT_MAX || result.next_seq <= sinceSeq) {
|
|
926
|
+
break;
|
|
927
|
+
}
|
|
928
|
+
sinceSeq = result.next_seq;
|
|
929
|
+
}
|
|
930
|
+
return events;
|
|
572
931
|
};
|
|
573
932
|
const escalateOnce = async (ek, reason) => {
|
|
574
933
|
const candidate = {
|
|
@@ -620,8 +979,13 @@ export async function buildProductionEpicRuntimeDeps(epicKey) {
|
|
|
620
979
|
return {
|
|
621
980
|
fetchPlan,
|
|
622
981
|
dispatchSeam,
|
|
982
|
+
dispatchReviewSeam,
|
|
623
983
|
fetchLocalEvents,
|
|
624
984
|
escalateOnce,
|
|
625
985
|
postActionWaitSeam,
|
|
986
|
+
// BAPI-442 seams are wired at the reconcileDeps level inside runEpicTick
|
|
987
|
+
// (they need the per-tick `access` and `prBindings` closure). The factory
|
|
988
|
+
// returns the dispatchSeam with isReReview support; the other two seams are
|
|
989
|
+
// defined inline in the reconcileDeps object in runEpicTick.
|
|
626
990
|
};
|
|
627
991
|
}
|