@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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Deterministic observed→desired reconciliation for the Epic Supervisor
|
|
3
|
-
* (BAPI-408).
|
|
3
|
+
* (BAPI-408, BAPI-436).
|
|
4
4
|
*
|
|
5
5
|
* Executes five steps in order:
|
|
6
6
|
* 1. Fold terminal signals into Postgres via CAS
|
|
@@ -9,10 +9,15 @@
|
|
|
9
9
|
* 4. Action approved merges via C6 delegation
|
|
10
10
|
* 5. Schedule post-action wait hooks
|
|
11
11
|
*
|
|
12
|
+
* Dispatch is purely merge-gated (BAPI-436): dependents are dispatched only
|
|
13
|
+
* after their predecessor reaches "done", which requires a merge.succeeded
|
|
14
|
+
* signal. gate.met and run.stopped fold to the intermediate "ready_for_review"
|
|
15
|
+
* state — dependents do NOT dispatch on these signals.
|
|
16
|
+
*
|
|
12
17
|
* All durable mutations go through injected seams so the logic is testable
|
|
13
18
|
* without real network, ledger, or terminal access.
|
|
14
19
|
*/
|
|
15
|
-
import { computeReadySet } from "./epic-state.js";
|
|
20
|
+
import { computeReadySet, decideRemediation, DEFAULT_MAX_SPEC_REVIEW_ATTEMPTS } from "./epic-state.js";
|
|
16
21
|
import { extractMergeActionIdentityFromGateEvent } from "./merge-ledger.js";
|
|
17
22
|
// ---------------------------------------------------------------------------
|
|
18
23
|
// reconcileEpic
|
|
@@ -21,7 +26,7 @@ import { extractMergeActionIdentityFromGateEvent } from "./merge-ledger.js";
|
|
|
21
26
|
* Execute the deterministic observed→desired reconciliation pass. All I/O is
|
|
22
27
|
* behind injected seams; the ready-set is computed by pure code (no LLM).
|
|
23
28
|
*/
|
|
24
|
-
export async function reconcileEpic(access, observed, plan, deps) {
|
|
29
|
+
export async function reconcileEpic(access, observed, plan, deps, supervisorConfig) {
|
|
25
30
|
const result = {
|
|
26
31
|
signals_folded: 0,
|
|
27
32
|
dispatched: 0,
|
|
@@ -43,6 +48,22 @@ export async function reconcileEpic(access, observed, plan, deps) {
|
|
|
43
48
|
if (casResult.ok) {
|
|
44
49
|
result.signals_folded += 1;
|
|
45
50
|
deps.log(`[epic-reconcile] folded ${signal.signal_type} for ${signal.ticket_key} → ${signal.next_status}`);
|
|
51
|
+
// BAPI-442: fire teardown + Jira transition strictly after merge.succeeded
|
|
52
|
+
// CAS → done. Both are fail-open: errors are logged and never abort the pass.
|
|
53
|
+
if (signal.signal_type === "merge.succeeded") {
|
|
54
|
+
if (supervisorConfig?.teardown_enabled && deps.teardownSeam) {
|
|
55
|
+
await deps.teardownSeam(observed.epic_key, signal.ticket_key).catch((e) => {
|
|
56
|
+
const safeMsg = e instanceof Error ? e.constructor.name : "teardown error";
|
|
57
|
+
deps.log(`[epic-reconcile] teardown error for ${signal.ticket_key}: ${safeMsg}`);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
if (deps.jiraTransitionSeam) {
|
|
61
|
+
await deps.jiraTransitionSeam(observed.epic_key, signal.ticket_key).catch((e) => {
|
|
62
|
+
const safeMsg = e instanceof Error ? e.constructor.name : "jira error";
|
|
63
|
+
deps.log(`[epic-reconcile] jira-transition error for ${signal.ticket_key}: ${safeMsg}`);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
46
67
|
}
|
|
47
68
|
else {
|
|
48
69
|
// CAS conflict: another tick already advanced this ticket — non-fatal
|
|
@@ -61,8 +82,29 @@ export async function reconcileEpic(access, observed, plan, deps) {
|
|
|
61
82
|
}
|
|
62
83
|
// Step 2: Compute the ready-set (pure — never calls LLM)
|
|
63
84
|
const readySet = computeReadySet(plan, observed.ticket_statuses);
|
|
64
|
-
// Step 3: Dispatch each ready ticket idempotently
|
|
85
|
+
// Step 3: Dispatch each ready ticket idempotently.
|
|
86
|
+
//
|
|
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.
|
|
65
98
|
for (const ticketKey of readySet) {
|
|
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;
|
|
107
|
+
}
|
|
66
108
|
let claimResult;
|
|
67
109
|
try {
|
|
68
110
|
claimResult = await deps.claimDispatchKey(observed.epic_key, ticketKey, observed.plan_version);
|
|
@@ -118,6 +160,202 @@ export async function reconcileEpic(access, observed, plan, deps) {
|
|
|
118
160
|
result.warnings.push(`correlate-failed for ${ticketKey}: ${safeMsg}`);
|
|
119
161
|
}
|
|
120
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
|
+
}
|
|
272
|
+
// Step 3.5: Remediation pass (BAPI-441) — re-act on blocked tickets under
|
|
273
|
+
// budget. Keyed off the folded "blocked" status + per-ticket counters, NOT a
|
|
274
|
+
// computeReadySet change (the ready-set still returns only planned tickets).
|
|
275
|
+
// Skipped entirely unless the remediation seams + supervisorConfig are wired.
|
|
276
|
+
const remediationWired = supervisorConfig !== undefined &&
|
|
277
|
+
deps.readWorkerLiveness !== undefined &&
|
|
278
|
+
deps.remediateCas !== undefined &&
|
|
279
|
+
deps.sendNudge !== undefined &&
|
|
280
|
+
deps.resumeDispatch !== undefined;
|
|
281
|
+
if (remediationWired) {
|
|
282
|
+
const cfg = supervisorConfig;
|
|
283
|
+
const readWorkerLiveness = deps.readWorkerLiveness;
|
|
284
|
+
const sendNudge = deps.sendNudge;
|
|
285
|
+
const resumeDispatch = deps.resumeDispatch;
|
|
286
|
+
const remediateCas = deps.remediateCas;
|
|
287
|
+
for (const [ticketKey, status] of observed.ticket_statuses) {
|
|
288
|
+
if (status !== "blocked")
|
|
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;
|
|
294
|
+
// Per-ticket try/catch so a single ticket's failure never aborts the pass.
|
|
295
|
+
try {
|
|
296
|
+
const counters = observed.ticket_remediation_counters?.get(ticketKey) ?? {
|
|
297
|
+
attempts: 0,
|
|
298
|
+
no_progress: 0,
|
|
299
|
+
};
|
|
300
|
+
const liveness = await readWorkerLiveness(observed.epic_key, ticketKey);
|
|
301
|
+
const decision = decideRemediation(counters.attempts, counters.no_progress, liveness.alive, cfg);
|
|
302
|
+
if (decision === "escalate") {
|
|
303
|
+
await escalate(observed.epic_key, `remediation-budget-exhausted:${ticketKey}`);
|
|
304
|
+
deps.log(`[epic-reconcile] remediation escalate ${ticketKey} ` +
|
|
305
|
+
`(attempts=${counters.attempts} no_progress=${counters.no_progress})`);
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
// The attempt being recorded is the next one (1-based).
|
|
309
|
+
const attempt = counters.attempts + 1;
|
|
310
|
+
const attemptKind = decision;
|
|
311
|
+
// The folding reason frames the nudge (message type + digest). Default to
|
|
312
|
+
// the review path when the ledger no longer carries the blocking event.
|
|
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";
|
|
317
|
+
// A nudge needs a worker to address it to. The liveness scan already
|
|
318
|
+
// resolved the worker id from the same heartbeat that proved the worker
|
|
319
|
+
// alive; if it is missing we cannot relay, so skip BEFORE recording an
|
|
320
|
+
// attempt — otherwise the CAS would burn a budget unit with nothing sent.
|
|
321
|
+
if (decision === "nudge" && !liveness.workerId) {
|
|
322
|
+
result.warnings.push(`remediation nudge skipped for ${ticketKey}: alive worker has no worker_id`);
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
// Record the attempt durably FIRST. The remediate endpoint builds the
|
|
326
|
+
// (backend-redacted) review digest for a nudge and returns it, and is
|
|
327
|
+
// idempotent (a 409 replay returns conflict, not throw). An unexpected
|
|
328
|
+
// remediate failure is absorbed as a per-ticket warning so the rest of
|
|
329
|
+
// the pass still runs (crash-replay safe).
|
|
330
|
+
let casOutcome;
|
|
331
|
+
try {
|
|
332
|
+
casOutcome = await remediateCas(observed.epic_key, ticketKey, attemptKind, reason);
|
|
333
|
+
}
|
|
334
|
+
catch (err) {
|
|
335
|
+
const safeMsg = err instanceof Error ? err.constructor.name : "remediate error";
|
|
336
|
+
result.warnings.push(`remediate-cas-failed for ${ticketKey} (${attemptKind}): ${safeMsg}`);
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
if (casOutcome.conflict) {
|
|
340
|
+
// Idempotency replay: the attempt was already recorded (and acted on)
|
|
341
|
+
// on a prior tick. Do not re-act — the crash-replay self-heals here.
|
|
342
|
+
result.warnings.push(`remediation replay swallowed for ${ticketKey} (${attemptKind})`);
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
if (decision === "nudge") {
|
|
346
|
+
await sendNudge(observed.epic_key, ticketKey, attempt, casOutcome.reviewDigest, casOutcome.truncated, reason, liveness.workerId);
|
|
347
|
+
}
|
|
348
|
+
else {
|
|
349
|
+
await resumeDispatch(observed.epic_key, ticketKey, attempt);
|
|
350
|
+
}
|
|
351
|
+
deps.log(`[epic-reconcile] remediation ${decision} ${ticketKey} attempt=${attempt}`);
|
|
352
|
+
}
|
|
353
|
+
catch (err) {
|
|
354
|
+
const safeMsg = err instanceof Error ? err.constructor.name : "remediation error";
|
|
355
|
+
result.warnings.push(`remediation-error for ${ticketKey}: ${safeMsg}`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
121
359
|
// Step 4: Action approved merges via C6 delegation
|
|
122
360
|
for (const event of observed.pending_merge_events) {
|
|
123
361
|
const identity = extractMergeActionIdentityFromGateEvent(event);
|
|
@@ -137,3 +375,79 @@ export async function reconcileEpic(access, observed, plan, deps) {
|
|
|
137
375
|
// Step 5: Post-action waits are scheduled inline above per-dispatch
|
|
138
376
|
return result;
|
|
139
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
|
+
}
|