@bridge_gpt/mcp-server 0.2.10 → 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 +3 -3
- 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 +1 -1
- package/build/conductor/epic-reconcile.js +213 -16
- package/build/conductor/epic-runtime.js +89 -6
- package/build/conductor/epic-state.js +85 -11
- 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/merge-ledger.js +7 -7
- package/build/conductor/pr-ci-producer.js +6 -6
- 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 +97 -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 +5 -0
- package/build/conductor/tools.js +5 -5
- 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 +23705 -3630
- package/build/install-bridge.js +80 -0
- package/build/pipelines.generated.js +70 -48
- package/build/readme.generated.js +1 -1
- 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
package/build/conductor/cli.js
CHANGED
|
@@ -336,13 +336,13 @@ export function parseEmitEventArgs(argv, deps = {}) {
|
|
|
336
336
|
return { input, json: bools.has("--json"), help: false };
|
|
337
337
|
}
|
|
338
338
|
/** Run the `emit-event` command. Prints the inserted event summary. */
|
|
339
|
-
export function runEmitEventCommand(argv, deps = {}) {
|
|
339
|
+
export async function runEmitEventCommand(argv, deps = {}) {
|
|
340
340
|
const parsed = parseEmitEventArgs(argv, deps);
|
|
341
341
|
if (parsed.help) {
|
|
342
342
|
console.log(getConductorUsage());
|
|
343
343
|
return 0;
|
|
344
344
|
}
|
|
345
|
-
const result = emitConductorEvent(parsed.input);
|
|
345
|
+
const result = await emitConductorEvent(parsed.input);
|
|
346
346
|
if (parsed.json) {
|
|
347
347
|
console.log(JSON.stringify(result));
|
|
348
348
|
}
|
|
@@ -442,13 +442,13 @@ export function parseSendMessageArgs(argv, deps = {}) {
|
|
|
442
442
|
* Run `send-message`. Prints compact JSON when `--json` is set; otherwise a
|
|
443
443
|
* sanitized human summary (message id / status / type only — NEVER the payload).
|
|
444
444
|
*/
|
|
445
|
-
export function runSendMessageCommand(argv, deps = {}) {
|
|
445
|
+
export async function runSendMessageCommand(argv, deps = {}) {
|
|
446
446
|
const parsed = parseSendMessageArgs(argv, deps);
|
|
447
447
|
if (parsed.help) {
|
|
448
448
|
console.log(getConductorUsage());
|
|
449
449
|
return 0;
|
|
450
450
|
}
|
|
451
|
-
const result = sendWorkerMessage(parsed.input);
|
|
451
|
+
const result = await sendWorkerMessage(parsed.input);
|
|
452
452
|
if (parsed.json) {
|
|
453
453
|
console.log(JSON.stringify(result));
|
|
454
454
|
}
|
|
@@ -495,13 +495,13 @@ export function parseCheckMessagesArgs(argv) {
|
|
|
495
495
|
* Run `check-messages`. Prints compact JSON when `--json` is set; otherwise a
|
|
496
496
|
* sanitized human summary (counts + per-message id/type only — NEVER payloads).
|
|
497
497
|
*/
|
|
498
|
-
export function runCheckMessagesCommand(argv) {
|
|
498
|
+
export async function runCheckMessagesCommand(argv) {
|
|
499
499
|
const parsed = parseCheckMessagesArgs(argv);
|
|
500
500
|
if (parsed.help) {
|
|
501
501
|
console.log(getConductorUsage());
|
|
502
502
|
return 0;
|
|
503
503
|
}
|
|
504
|
-
const result = checkWorkerMessages(parsed.input);
|
|
504
|
+
const result = await checkWorkerMessages(parsed.input);
|
|
505
505
|
if (parsed.json) {
|
|
506
506
|
console.log(JSON.stringify(result));
|
|
507
507
|
return 0;
|
|
@@ -590,7 +590,7 @@ export function parseGitHookArgs(argv) {
|
|
|
590
590
|
* warning) so a conductor producer failure never blocks the git commit/ref update
|
|
591
591
|
* the hook is attached to.
|
|
592
592
|
*/
|
|
593
|
-
export function runGitHookCommand(argv) {
|
|
593
|
+
export async function runGitHookCommand(argv) {
|
|
594
594
|
let parsed;
|
|
595
595
|
try {
|
|
596
596
|
parsed = parseGitHookArgs(argv);
|
|
@@ -602,7 +602,7 @@ export function runGitHookCommand(argv) {
|
|
|
602
602
|
}
|
|
603
603
|
try {
|
|
604
604
|
if (parsed.subcommand === "post-commit") {
|
|
605
|
-
runPostCommitHookProducer();
|
|
605
|
+
await runPostCommitHookProducer();
|
|
606
606
|
return 0;
|
|
607
607
|
}
|
|
608
608
|
// reference-transaction: read the captured updates from the stdin file.
|
|
@@ -625,7 +625,7 @@ export function runGitHookCommand(argv) {
|
|
|
625
625
|
}
|
|
626
626
|
}
|
|
627
627
|
}
|
|
628
|
-
runReferenceTransactionHookProducer({ phase: parsed.phase ?? "", stdin });
|
|
628
|
+
await runReferenceTransactionHookProducer({ phase: parsed.phase ?? "", stdin });
|
|
629
629
|
return 0;
|
|
630
630
|
}
|
|
631
631
|
catch {
|
|
@@ -996,13 +996,13 @@ export async function runSuperviseCommand(argv) {
|
|
|
996
996
|
return result.exit_code;
|
|
997
997
|
}
|
|
998
998
|
/** Run the explicit `purge` command. Prints deleted row counts. */
|
|
999
|
-
export function runPurgeCommand(argv) {
|
|
999
|
+
export async function runPurgeCommand(argv) {
|
|
1000
1000
|
const { bools } = tokenizeFlags(argv, new Set(), DIAGNOSTIC_BOOL_FLAGS);
|
|
1001
1001
|
if (bools.has("--help")) {
|
|
1002
1002
|
console.log(getConductorUsage());
|
|
1003
1003
|
return 0;
|
|
1004
1004
|
}
|
|
1005
|
-
const result = purgeConductorLedger();
|
|
1005
|
+
const result = await purgeConductorLedger();
|
|
1006
1006
|
if (bools.has("--json")) {
|
|
1007
1007
|
console.log(JSON.stringify(result));
|
|
1008
1008
|
return 0;
|
|
@@ -1035,7 +1035,7 @@ export async function runConductorCli(argv) {
|
|
|
1035
1035
|
try {
|
|
1036
1036
|
switch (parsed.command) {
|
|
1037
1037
|
case "emit-event":
|
|
1038
|
-
return runEmitEventCommand(parsed.argv);
|
|
1038
|
+
return await runEmitEventCommand(parsed.argv);
|
|
1039
1039
|
case "supervise":
|
|
1040
1040
|
return await runSuperviseCommand(parsed.argv);
|
|
1041
1041
|
case "epic-tick":
|
|
@@ -1045,17 +1045,17 @@ export async function runConductorCli(argv) {
|
|
|
1045
1045
|
case "epic-status":
|
|
1046
1046
|
return await runEpicStatusCommand(parsed.argv);
|
|
1047
1047
|
case "send-message":
|
|
1048
|
-
return runSendMessageCommand(parsed.argv);
|
|
1048
|
+
return await runSendMessageCommand(parsed.argv);
|
|
1049
1049
|
case "check-messages":
|
|
1050
|
-
return runCheckMessagesCommand(parsed.argv);
|
|
1050
|
+
return await runCheckMessagesCommand(parsed.argv);
|
|
1051
1051
|
case "doctor":
|
|
1052
1052
|
return await runDoctorCommand(parsed.argv);
|
|
1053
1053
|
case "purge":
|
|
1054
|
-
return runPurgeCommand(parsed.argv);
|
|
1054
|
+
return await runPurgeCommand(parsed.argv);
|
|
1055
1055
|
case "install-git-hooks":
|
|
1056
1056
|
return runInstallGitHooksCommand(parsed.argv);
|
|
1057
1057
|
case "git-hook":
|
|
1058
|
-
return runGitHookCommand(parsed.argv);
|
|
1058
|
+
return await runGitHookCommand(parsed.argv);
|
|
1059
1059
|
default:
|
|
1060
1060
|
console.error('Error: Unknown command. Run "conductor --help" for usage.');
|
|
1061
1061
|
return 1;
|
|
@@ -116,7 +116,7 @@ export async function buildConductorDoctorReport(deps = {}) {
|
|
|
116
116
|
const epicTick = await inspectEpicTickSchedule(deps.scheduleDeps, deps.orchestrateList);
|
|
117
117
|
const mcp_profile = inspectMcpProfile(deps.env ?? process.env, epicTick);
|
|
118
118
|
return {
|
|
119
|
-
ledger: doctorLedger(),
|
|
119
|
+
ledger: await doctorLedger(),
|
|
120
120
|
git_hooks: inspectHooks(deps.hooksDeps),
|
|
121
121
|
epic_tick: epicTick,
|
|
122
122
|
mcp_profile,
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* All durable mutations go through injected seams so the logic is testable
|
|
18
18
|
* without real network, ledger, or terminal access.
|
|
19
19
|
*/
|
|
20
|
-
import { computeReadySet, decideRemediation } from "./epic-state.js";
|
|
20
|
+
import { computeReadySet, decideRemediation, DEFAULT_MAX_SPEC_REVIEW_ATTEMPTS } from "./epic-state.js";
|
|
21
21
|
import { extractMergeActionIdentityFromGateEvent } from "./merge-ledger.js";
|
|
22
22
|
// ---------------------------------------------------------------------------
|
|
23
23
|
// reconcileEpic
|
|
@@ -84,21 +84,26 @@ export async function reconcileEpic(access, observed, plan, deps, supervisorConf
|
|
|
84
84
|
const readySet = computeReadySet(plan, observed.ticket_statuses);
|
|
85
85
|
// Step 3: Dispatch each ready ticket idempotently.
|
|
86
86
|
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
87
|
+
// BAPI-445 — pre-implementation spec re-review sequencing gate (Phase 1). When
|
|
88
|
+
// `auto_rereview_enabled` is on and the review seam is wired, a `planned` ready
|
|
89
|
+
// ticket must first pass a spec re-review: this loop advances it
|
|
90
|
+
// planned → reviewing, spawns the review run under a `:review` dispatch role,
|
|
91
|
+
// correlates its run_id, and STOPS — implementation does NOT dispatch in the
|
|
92
|
+
// same tick (honouring the product directive: "wait for review to complete
|
|
93
|
+
// before implementation begins"). The reviewing → ready (or → blocked) fold
|
|
94
|
+
// happens on a later tick once the review completes/votes; only THEN does this
|
|
95
|
+
// loop dispatch implementation. Gating only on `planned` (not `ready`) is
|
|
96
|
+
// deliberate: a `ready` ticket already passed review (reviewing → ready) or the
|
|
97
|
+
// flag is off, so re-gating it would loop forever.
|
|
98
98
|
for (const ticketKey of readySet) {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
99
|
+
const currentStatus = observed.ticket_statuses.get(ticketKey) ?? "planned";
|
|
100
|
+
if (supervisorConfig?.auto_rereview_enabled &&
|
|
101
|
+
deps.dispatchReviewSeam &&
|
|
102
|
+
currentStatus === "planned") {
|
|
103
|
+
await enterSpecReview(observed, deps, result, escalate, ticketKey);
|
|
104
|
+
// Either the review was dispatched, or a benign race/failure was logged;
|
|
105
|
+
// in every case implementation must NOT dispatch this tick.
|
|
106
|
+
continue;
|
|
102
107
|
}
|
|
103
108
|
let claimResult;
|
|
104
109
|
try {
|
|
@@ -155,6 +160,115 @@ export async function reconcileEpic(access, observed, plan, deps, supervisorConf
|
|
|
155
160
|
result.warnings.push(`correlate-failed for ${ticketKey}: ${safeMsg}`);
|
|
156
161
|
}
|
|
157
162
|
}
|
|
163
|
+
// Step 3.2: Reviewing recovery (BAPI-445 Phase 1 re-entry). computeReadySet
|
|
164
|
+
// never surfaces a `reviewing` ticket (it is mid-flight, not ready), so a
|
|
165
|
+
// ticket whose spec-review run died before completing would be stranded
|
|
166
|
+
// forever without this pass. Iterate `reviewing` tickets explicitly (mirroring
|
|
167
|
+
// how the remediation pass iterates `blocked` tickets): if the review run is
|
|
168
|
+
// dead and the dedicated review-attempt cap is not yet exhausted, reclaim an
|
|
169
|
+
// attempt-scoped `:review` dispatch key and re-dispatch the review; once the
|
|
170
|
+
// cap is exhausted, escalate. This uses its OWN attempt counter — it never
|
|
171
|
+
// touches the BAPI-441 remediation budget.
|
|
172
|
+
if (supervisorConfig?.auto_rereview_enabled &&
|
|
173
|
+
deps.dispatchReviewSeam &&
|
|
174
|
+
deps.readReviewWorkerLiveness) {
|
|
175
|
+
const dispatchReviewSeam = deps.dispatchReviewSeam;
|
|
176
|
+
const readReviewWorkerLiveness = deps.readReviewWorkerLiveness;
|
|
177
|
+
const maxReviewAttempts = supervisorConfig.max_spec_review_attempts ?? DEFAULT_MAX_SPEC_REVIEW_ATTEMPTS;
|
|
178
|
+
for (const [ticketKey, status] of observed.ticket_statuses) {
|
|
179
|
+
if (status !== "reviewing")
|
|
180
|
+
continue;
|
|
181
|
+
try {
|
|
182
|
+
const liveness = await readReviewWorkerLiveness(observed.epic_key, ticketKey);
|
|
183
|
+
if (liveness.alive)
|
|
184
|
+
continue; // review still running — wait across ticks
|
|
185
|
+
const priorAttempts = deps.countReviewAttempts
|
|
186
|
+
? deps.countReviewAttempts(ticketKey)
|
|
187
|
+
: 0;
|
|
188
|
+
if (priorAttempts >= maxReviewAttempts) {
|
|
189
|
+
await escalate(observed.epic_key, `spec-review-liveness-exhausted:${ticketKey}`);
|
|
190
|
+
deps.log(`[epic-reconcile] spec-review liveness exhausted for ${ticketKey} ` +
|
|
191
|
+
`(attempts=${priorAttempts} max=${maxReviewAttempts}) → escalate`);
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
// Reclaim a fresh attempt-scoped review dispatch key. `priorAttempts`
|
|
195
|
+
// (>= 1 once the initial review dispatched) doubles as the next attempt
|
|
196
|
+
// index, so the key differs from every prior review run's key.
|
|
197
|
+
const attempt = priorAttempts;
|
|
198
|
+
let claim;
|
|
199
|
+
try {
|
|
200
|
+
claim = await deps.claimDispatchKey(observed.epic_key, ticketKey, observed.plan_version, "review", attempt);
|
|
201
|
+
}
|
|
202
|
+
catch (err) {
|
|
203
|
+
const safeMsg = err instanceof Error ? err.constructor.name : "claim error";
|
|
204
|
+
result.warnings.push(`review-reclaim-error for ${ticketKey}: ${safeMsg}`);
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
if (!claim.ok) {
|
|
208
|
+
result.warnings.push(`review-dispatch-key lease-held for ${ticketKey}`);
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (claim.kind !== "claimed") {
|
|
212
|
+
if (claim.kind === "already-exists" && claim.dispatch.run_id === null) {
|
|
213
|
+
result.warnings.push(`review-dispatch-orphan: ${ticketKey} has pending review dispatch key with no run_id`);
|
|
214
|
+
await escalate(observed.epic_key, `dispatch-orphan:${ticketKey}`);
|
|
215
|
+
}
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
const dispatchKey = claim.dispatch.dispatch_key;
|
|
219
|
+
let runId;
|
|
220
|
+
try {
|
|
221
|
+
runId = await dispatchReviewSeam(observed.epic_key, ticketKey, attempt);
|
|
222
|
+
}
|
|
223
|
+
catch (err) {
|
|
224
|
+
const safeMsg = err instanceof Error ? err.constructor.name : "dispatch error";
|
|
225
|
+
result.warnings.push(`review-redispatch-failed for ${ticketKey}: ${safeMsg}`);
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
try {
|
|
229
|
+
await deps.correlateRunId(dispatchKey, runId);
|
|
230
|
+
deps.log(`[epic-reconcile] re-dispatched spec re-review for ${ticketKey} ` +
|
|
231
|
+
`run_id=${runId} attempt=${attempt}`);
|
|
232
|
+
}
|
|
233
|
+
catch (err) {
|
|
234
|
+
const safeMsg = err instanceof Error ? err.constructor.name : "correlate error";
|
|
235
|
+
result.warnings.push(`review-correlate-failed for ${ticketKey}: ${safeMsg}`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
catch (err) {
|
|
239
|
+
const safeMsg = err instanceof Error ? err.constructor.name : "reviewing recovery error";
|
|
240
|
+
result.warnings.push(`reviewing-recovery-error for ${ticketKey}: ${safeMsg}`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
// Step 3.4: Spec-review verdict gate (BAPI-445 Phase 2). A ticket blocked by a
|
|
245
|
+
// changes-requested SPEC review is a spec problem, not a code bug: escalate it
|
|
246
|
+
//
|
|
247
|
+
// NOTE: this block is reachable only once the verdict EMISSION primitive
|
|
248
|
+
// (`emitSpecReviewVerdict` in spec-review-producer.ts) is wired into the
|
|
249
|
+
// review-ticket completion flow so a `spec_review.changes_requested` event is
|
|
250
|
+
// folded to `blocked` with this reason. Until that Phase 2 integration lands,
|
|
251
|
+
// no ticket carries the `spec_review.changes_requested` blocked reason and this
|
|
252
|
+
// pass is a no-op. That is FAIL-SAFE: a completed review with no verdict does
|
|
253
|
+
// NOT advance `reviewing → ready` (epic-state filters review-run incidental
|
|
254
|
+
// terminals), so implementation never proceeds without an explicit pass —
|
|
255
|
+
// a stranded `reviewing` ticket is escalated by the recovery branch above.
|
|
256
|
+
// once for operator attention and EXCLUDE it from the BAPI-441 remediation pass
|
|
257
|
+
// below so it never spends the implementation remediation budget (no nudge, no
|
|
258
|
+
// redispatch, no budget counter increment). Runs independently of the
|
|
259
|
+
// remediation seams so a spec rejection is always surfaced.
|
|
260
|
+
const specRejectedTickets = new Set();
|
|
261
|
+
for (const [ticketKey, status] of observed.ticket_statuses) {
|
|
262
|
+
if (status !== "blocked")
|
|
263
|
+
continue;
|
|
264
|
+
if (observed.ticket_blocked_reasons?.get(ticketKey) !== "spec_review.changes_requested") {
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
specRejectedTickets.add(ticketKey);
|
|
268
|
+
await escalate(observed.epic_key, `spec-review-rejected:${ticketKey}`);
|
|
269
|
+
deps.log(`[epic-reconcile] spec-review changes-requested for ${ticketKey} → escalate ` +
|
|
270
|
+
`(remediation budget bypassed)`);
|
|
271
|
+
}
|
|
158
272
|
// Step 3.5: Remediation pass (BAPI-441) — re-act on blocked tickets under
|
|
159
273
|
// budget. Keyed off the folded "blocked" status + per-ticket counters, NOT a
|
|
160
274
|
// computeReadySet change (the ready-set still returns only planned tickets).
|
|
@@ -173,6 +287,10 @@ export async function reconcileEpic(access, observed, plan, deps, supervisorConf
|
|
|
173
287
|
for (const [ticketKey, status] of observed.ticket_statuses) {
|
|
174
288
|
if (status !== "blocked")
|
|
175
289
|
continue;
|
|
290
|
+
// BAPI-445: a spec-review rejection was already escalated in Step 3.4 and
|
|
291
|
+
// must never enter the implementation remediation budget. Skip it here.
|
|
292
|
+
if (specRejectedTickets.has(ticketKey))
|
|
293
|
+
continue;
|
|
176
294
|
// Per-ticket try/catch so a single ticket's failure never aborts the pass.
|
|
177
295
|
try {
|
|
178
296
|
const counters = observed.ticket_remediation_counters?.get(ticketKey) ?? {
|
|
@@ -192,7 +310,10 @@ export async function reconcileEpic(access, observed, plan, deps, supervisorConf
|
|
|
192
310
|
const attemptKind = decision;
|
|
193
311
|
// The folding reason frames the nudge (message type + digest). Default to
|
|
194
312
|
// the review path when the ledger no longer carries the blocking event.
|
|
195
|
-
|
|
313
|
+
// spec_review.changes_requested is impossible here (skipped above) but
|
|
314
|
+
// narrow explicitly so the remediation seams keep their tight reason type.
|
|
315
|
+
const blockedReason = observed.ticket_blocked_reasons?.get(ticketKey);
|
|
316
|
+
const reason = blockedReason === "ci.failed" ? "ci.failed" : "review.changes_requested";
|
|
196
317
|
// A nudge needs a worker to address it to. The liveness scan already
|
|
197
318
|
// resolved the worker id from the same heartbeat that proved the worker
|
|
198
319
|
// alive; if it is missing we cannot relay, so skip BEFORE recording an
|
|
@@ -254,3 +375,79 @@ export async function reconcileEpic(access, observed, plan, deps, supervisorConf
|
|
|
254
375
|
// Step 5: Post-action waits are scheduled inline above per-dispatch
|
|
255
376
|
return result;
|
|
256
377
|
}
|
|
378
|
+
// ---------------------------------------------------------------------------
|
|
379
|
+
// enterSpecReview (BAPI-445 Phase 1 — planned → reviewing + spawn review run)
|
|
380
|
+
// ---------------------------------------------------------------------------
|
|
381
|
+
/**
|
|
382
|
+
* Advance a `planned` ready ticket into the pre-implementation spec re-review:
|
|
383
|
+
* CAS planned → reviewing, claim the `:review` dispatch key, spawn the review
|
|
384
|
+
* run, and correlate its run_id. Every failure is absorbed as a per-ticket
|
|
385
|
+
* warning so the rest of the reconcile pass still runs.
|
|
386
|
+
*
|
|
387
|
+
* Deliberately does NOT mutate the local `observed.ticket_statuses` map: the CAS
|
|
388
|
+
* is durable in Postgres (the NEXT tick's snapshot shows `reviewing`), and
|
|
389
|
+
* leaving the local status as `planned` keeps the same-tick reviewing-recovery
|
|
390
|
+
* pass from mistaking this freshly-spawned review for a stranded one and
|
|
391
|
+
* double-dispatching it.
|
|
392
|
+
*/
|
|
393
|
+
async function enterSpecReview(observed, deps, result, escalate, ticketKey) {
|
|
394
|
+
const dispatchReviewSeam = deps.dispatchReviewSeam;
|
|
395
|
+
if (!dispatchReviewSeam)
|
|
396
|
+
return; // caller-guarded; defensive
|
|
397
|
+
// CAS planned → reviewing (durable). Idempotency: if another tick already
|
|
398
|
+
// advanced the ticket, the CAS conflicts and we bail benignly.
|
|
399
|
+
const rowVersion = observed.ticket_row_versions.get(ticketKey) ?? 0;
|
|
400
|
+
let cas;
|
|
401
|
+
try {
|
|
402
|
+
cas = await deps.casTicketStatus(observed.epic_key, ticketKey, rowVersion, "reviewing", observed.plan_version);
|
|
403
|
+
}
|
|
404
|
+
catch (err) {
|
|
405
|
+
const safeMsg = err instanceof Error ? err.constructor.name : "cas error";
|
|
406
|
+
result.warnings.push(`review-cas-error for ${ticketKey}: ${safeMsg}`);
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
if (!cas.ok) {
|
|
410
|
+
result.warnings.push(`review-cas-conflict advancing ${ticketKey} → reviewing`);
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
// Claim the :review dispatch key (attempt 0), then spawn + correlate.
|
|
414
|
+
let claim;
|
|
415
|
+
try {
|
|
416
|
+
claim = await deps.claimDispatchKey(observed.epic_key, ticketKey, observed.plan_version, "review", 0);
|
|
417
|
+
}
|
|
418
|
+
catch (err) {
|
|
419
|
+
const safeMsg = err instanceof Error ? err.constructor.name : "claim error";
|
|
420
|
+
result.warnings.push(`review-claim-error for ${ticketKey}: ${safeMsg}`);
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
if (!claim.ok) {
|
|
424
|
+
result.warnings.push(`review-dispatch-key lease-held for ${ticketKey}`);
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
if (claim.kind !== "claimed") {
|
|
428
|
+
if (claim.kind === "already-exists" && claim.dispatch.run_id === null) {
|
|
429
|
+
result.warnings.push(`review-dispatch-orphan: ${ticketKey} has pending review dispatch key with no run_id`);
|
|
430
|
+
await escalate(observed.epic_key, `dispatch-orphan:${ticketKey}`);
|
|
431
|
+
}
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
const dispatchKey = claim.dispatch.dispatch_key;
|
|
435
|
+
let runId;
|
|
436
|
+
try {
|
|
437
|
+
runId = await dispatchReviewSeam(observed.epic_key, ticketKey, 0);
|
|
438
|
+
}
|
|
439
|
+
catch (err) {
|
|
440
|
+
const safeMsg = err instanceof Error ? err.constructor.name : "dispatch error";
|
|
441
|
+
result.warnings.push(`review-dispatch-failed for ${ticketKey}: ${safeMsg}`);
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
try {
|
|
445
|
+
await deps.correlateRunId(dispatchKey, runId);
|
|
446
|
+
deps.log(`[epic-reconcile] dispatched spec re-review for ${ticketKey} ` +
|
|
447
|
+
`run_id=${runId} (planned → reviewing; implementation deferred)`);
|
|
448
|
+
}
|
|
449
|
+
catch (err) {
|
|
450
|
+
const safeMsg = err instanceof Error ? err.constructor.name : "correlate error";
|
|
451
|
+
result.warnings.push(`review-correlate-failed for ${ticketKey}: ${safeMsg}`);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
@@ -75,6 +75,9 @@ export async function runEpicTick(options, deps = {}) {
|
|
|
75
75
|
const errorLog = deps.errorLog ?? ((msg) => process.stderr.write(`${msg}\n`));
|
|
76
76
|
const escalateOnce = deps.escalateOnce ?? defaultEscalateOnce;
|
|
77
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;
|
|
78
81
|
const processMergeFn = deps.processMerge ?? processGateMetMerge;
|
|
79
82
|
const postActionWaitSeam = deps.postActionWaitSeam ?? defaultPostActionWaitSeam;
|
|
80
83
|
const fetchLocalEvents = deps.fetchLocalEvents ??
|
|
@@ -203,7 +206,7 @@ export async function runEpicTick(options, deps = {}) {
|
|
|
203
206
|
const dispatchedRunIds = epicRunState.dispatches
|
|
204
207
|
.map((d) => d.run_id)
|
|
205
208
|
.filter((rid) => typeof rid === "string" && rid.length > 0);
|
|
206
|
-
const localEvents = fetchLocalEvents(epic_key, dispatchedRunIds);
|
|
209
|
+
const localEvents = await fetchLocalEvents(epic_key, dispatchedRunIds);
|
|
207
210
|
const observed = rebuildObservedState(epicRunState, localEvents, nowFn());
|
|
208
211
|
workerCount = [...observed.ticket_statuses.values()].filter((s) => ACTIVE_WORKER_STATUSES.has(s)).length;
|
|
209
212
|
// Step 3.5: Run post-action waits (parse-after-merge)
|
|
@@ -386,19 +389,35 @@ export async function runEpicTick(options, deps = {}) {
|
|
|
386
389
|
if (ts.dispatch_run_id)
|
|
387
390
|
ticketRunIdMap.set(ts.ticket_key, ts.dispatch_run_id);
|
|
388
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.
|
|
389
397
|
const latestDispatchByTicket = new Map();
|
|
398
|
+
const reviewLatestDispatchByTicket = new Map();
|
|
399
|
+
const reviewAttemptCounts = new Map();
|
|
390
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
|
+
}
|
|
391
405
|
if (!d.run_id)
|
|
392
406
|
continue;
|
|
393
407
|
const updatedAt = new Date(d.updated_at).getTime();
|
|
394
|
-
const
|
|
408
|
+
const target = isReview ? reviewLatestDispatchByTicket : latestDispatchByTicket;
|
|
409
|
+
const prev = target.get(d.ticket_key);
|
|
395
410
|
if (!prev || updatedAt >= prev.updatedAt) {
|
|
396
|
-
|
|
411
|
+
target.set(d.ticket_key, { runId: d.run_id, updatedAt });
|
|
397
412
|
}
|
|
398
413
|
}
|
|
399
414
|
for (const [tk, info] of latestDispatchByTicket) {
|
|
400
415
|
ticketRunIdMap.set(tk, info.runId);
|
|
401
416
|
}
|
|
417
|
+
const reviewTicketRunIdMap = new Map();
|
|
418
|
+
for (const [tk, info] of reviewLatestDispatchByTicket) {
|
|
419
|
+
reviewTicketRunIdMap.set(tk, info.runId);
|
|
420
|
+
}
|
|
402
421
|
const resolvePrNumber = (ticketKey) => {
|
|
403
422
|
const raw = prBindings[ticketKey];
|
|
404
423
|
if (typeof raw === "number" && Number.isInteger(raw) && raw >= 1)
|
|
@@ -435,13 +454,30 @@ export async function runEpicTick(options, deps = {}) {
|
|
|
435
454
|
planVersion,
|
|
436
455
|
});
|
|
437
456
|
},
|
|
438
|
-
claimDispatchKey: async (ek, tk, planVersion) => recordEpicDispatch(access, {
|
|
457
|
+
claimDispatchKey: async (ek, tk, planVersion, role, attempt = 0) => recordEpicDispatch(access, {
|
|
439
458
|
epicKey: ek,
|
|
440
459
|
ticketKey: tk,
|
|
441
460
|
planVersion,
|
|
442
461
|
leaseOwner: lease_owner,
|
|
443
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",
|
|
444
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,
|
|
445
481
|
correlateRunId: async (dispatchKey, runId) => {
|
|
446
482
|
await transitionEpicDispatch(access, {
|
|
447
483
|
dispatchKey,
|
|
@@ -802,7 +838,53 @@ export async function buildProductionEpicRuntimeDeps(epicKey) {
|
|
|
802
838
|
}
|
|
803
839
|
return runId;
|
|
804
840
|
};
|
|
805
|
-
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) => {
|
|
806
888
|
// Workers and the epic-tick process share the same local SQLite ledger
|
|
807
889
|
// (~/.config/bridge/events.db). pollConductorEvents opens it read-only.
|
|
808
890
|
//
|
|
@@ -831,7 +913,7 @@ export async function buildProductionEpicRuntimeDeps(epicKey) {
|
|
|
831
913
|
// cap total iterations defensively against a non-advancing cursor.
|
|
832
914
|
const MAX_PAGES = 10_000;
|
|
833
915
|
for (let page = 0; page < MAX_PAGES; page += 1) {
|
|
834
|
-
const result = pollConductorEvents({
|
|
916
|
+
const result = await pollConductorEvents({
|
|
835
917
|
data_mode: "full",
|
|
836
918
|
since_seq: sinceSeq,
|
|
837
919
|
limit: POLL_LIMIT_MAX,
|
|
@@ -897,6 +979,7 @@ export async function buildProductionEpicRuntimeDeps(epicKey) {
|
|
|
897
979
|
return {
|
|
898
980
|
fetchPlan,
|
|
899
981
|
dispatchSeam,
|
|
982
|
+
dispatchReviewSeam,
|
|
900
983
|
fetchLocalEvents,
|
|
901
984
|
escalateOnce,
|
|
902
985
|
postActionWaitSeam,
|