@bridge_gpt/mcp-server 0.2.16 → 0.2.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/agents.generated.js +2 -2
- package/build/commands.generated.js +6 -6
- package/build/conductor/bridge-api-client.js +191 -11
- package/build/conductor/claude-hook.js +22 -4
- package/build/conductor/cli.js +11 -13
- package/build/conductor/done-gate.js +5 -0
- package/build/conductor/epic-reconcile.js +62 -13
- package/build/conductor/epic-runtime.js +447 -35
- package/build/conductor/epic-state.js +517 -63
- package/build/conductor/errors.js +41 -0
- package/build/conductor/event-accessors.js +234 -0
- package/build/conductor/file-scope-guard.js +201 -0
- package/build/conductor/github-mergeability.js +85 -0
- package/build/conductor/local-merge.js +47 -1
- package/build/conductor/merge-identity.js +41 -0
- package/build/conductor/merge-ledger.js +13 -68
- package/build/conductor/plan.js +12 -2
- package/build/conductor/pr-discovery.js +11 -1
- package/build/conductor/supervisor-config.js +4 -39
- package/build/conductor/supervisor-escalation.js +10 -26
- package/build/conductor/supervisor-ledger.js +5 -12
- package/build/conductor/supervisor-message-relay.js +2 -5
- package/build/conductor/supervisor-notification.js +1 -1
- package/build/conductor/supervisor-runtime.js +12 -54
- package/build/conductor/supervisor-state.js +4 -18
- package/build/conductor/supervisor-types.js +2 -2
- package/build/conductor/taxonomy.js +4 -0
- package/build/conductor-bin.js +2333 -666
- package/build/conductor-claude-hook-bin.js +4 -2
- package/build/doctor.js +32 -0
- package/build/index.js +10125 -8522
- package/build/install-bridge.js +25 -8
- package/build/install-doctor.js +387 -0
- package/build/pipelines.generated.js +30 -5
- package/build/regression-check.js +53 -1
- package/build/review-tickets.js +175 -21
- package/build/start-tickets-conductor.js +22 -6
- package/build/start-tickets-prereqs.js +33 -3
- package/build/start-tickets.js +122 -22
- package/build/version.generated.js +1 -1
- package/package.json +5 -5
- package/pipelines/review-ticket.json +24 -2
- package/public/css/main.min.css +3272 -1
- package/public/css/main.min.css.map +1 -1
- package/smoke-test/SMOKE-TEST.md +4 -2
|
@@ -9,10 +9,20 @@
|
|
|
9
9
|
*
|
|
10
10
|
* Status mapping (BAPI-436 — merge-gated dependent dispatch):
|
|
11
11
|
* gate.met → ready_for_review (implementation done; awaiting merge)
|
|
12
|
-
* run.stopped → ready_for_review (
|
|
12
|
+
* run.stopped → ready_for_review (PR-BOUND / session-terminal fold — see below)
|
|
13
13
|
* merge.succeeded → done (merged; dependents may now dispatch)
|
|
14
14
|
* ci.failed → blocked (unchanged)
|
|
15
|
+
*
|
|
16
|
+
* BAPI-507 (N-1): `run.stopped → ready_for_review` is a PR-BOUND fold, not a
|
|
17
|
+
* generic turn-boundary fold. Claude Code's `Stop`/`SubagentStop` hooks fire on
|
|
18
|
+
* every assistant turn / subagent completion — long before a branch is pushed or
|
|
19
|
+
* a PR opened — so an implementation-run `run.stopped` only folds a ticket to
|
|
20
|
+
* `ready_for_review` when that ticket already has a PR binding in the same event
|
|
21
|
+
* batch (`git.pr_opened` / `gate.met` / `merge.succeeded`). Without a binding the
|
|
22
|
+
* ticket keeps its prior non-review status, matching the done-gate's own
|
|
23
|
+
* PR-binding requirement.
|
|
15
24
|
*/
|
|
25
|
+
import { getHeadSha } from "./event-accessors.js";
|
|
16
26
|
// ---------------------------------------------------------------------------
|
|
17
27
|
// Status sets (module-private)
|
|
18
28
|
// ---------------------------------------------------------------------------
|
|
@@ -43,6 +53,8 @@ const TERMINAL_SIGNAL_TYPES = new Set([
|
|
|
43
53
|
"ci.failed",
|
|
44
54
|
"run.stopped",
|
|
45
55
|
"review.changes_requested",
|
|
56
|
+
// BAPI-494: an un-mergeable PR folds to `blocked`, head-scoped to the conflict head.
|
|
57
|
+
"merge.conflict",
|
|
46
58
|
// BAPI-445: pre-implementation spec re-review verdicts, scoped to review runs.
|
|
47
59
|
"spec_review.passed",
|
|
48
60
|
"spec_review.changes_requested",
|
|
@@ -50,6 +62,14 @@ const TERMINAL_SIGNAL_TYPES = new Set([
|
|
|
50
62
|
function isNonTerminal(status) {
|
|
51
63
|
return NON_TERMINAL_STATUSES.has(status);
|
|
52
64
|
}
|
|
65
|
+
const HEAD_SCOPED_BLOCKING_SIGNALS = new Set([
|
|
66
|
+
"ci.failed",
|
|
67
|
+
"review.changes_requested",
|
|
68
|
+
"merge.conflict",
|
|
69
|
+
]);
|
|
70
|
+
function isHeadScopedBlockingSignal(type) {
|
|
71
|
+
return HEAD_SCOPED_BLOCKING_SIGNALS.has(type);
|
|
72
|
+
}
|
|
53
73
|
/**
|
|
54
74
|
* Map a terminal ledger signal to the next ticket status.
|
|
55
75
|
*
|
|
@@ -76,6 +96,8 @@ export function signalToNextStatus(signalType, isReviewRun = false) {
|
|
|
76
96
|
return "blocked";
|
|
77
97
|
if (signalType === "review.changes_requested")
|
|
78
98
|
return "blocked";
|
|
99
|
+
if (signalType === "merge.conflict")
|
|
100
|
+
return "blocked"; // BAPI-494
|
|
79
101
|
if (signalType === "merge.succeeded")
|
|
80
102
|
return "done";
|
|
81
103
|
return "ready_for_review"; // gate.met and (implementation) run.stopped: awaiting merge
|
|
@@ -91,6 +113,13 @@ export function signalToNextStatus(signalType, isReviewRun = false) {
|
|
|
91
113
|
* 2. Whose full `depends_on` list is satisfied (all deps have "done" status).
|
|
92
114
|
*
|
|
93
115
|
* Never calls an LLM or performs I/O. Goal 8 invariant.
|
|
116
|
+
*
|
|
117
|
+
* Planner file-overlap serialization (BAPI-495) is encoded upstream as
|
|
118
|
+
* explicit `depends_on` entries in the stored plan DAG (see
|
|
119
|
+
* `apply_file_overlap_serialization` in `api/library/db/epic_runs.py`) —
|
|
120
|
+
* sibling tickets with overlapping predicted touched files are serialized
|
|
121
|
+
* before this function ever sees the plan. This function deliberately takes
|
|
122
|
+
* no file-prediction input and adds no overlap-guard logic of its own.
|
|
94
123
|
*/
|
|
95
124
|
export function computeReadySet(plan, ticketStatuses) {
|
|
96
125
|
const ready = [];
|
|
@@ -152,8 +181,226 @@ export function extractWorkerLiveness(events, runId, nowMs, windowSeconds) {
|
|
|
152
181
|
return { alive: age <= windowSeconds * 1000, workerId: latest.worker_id ?? null };
|
|
153
182
|
}
|
|
154
183
|
// ---------------------------------------------------------------------------
|
|
184
|
+
// Head-scoped blocking (BAPI-487)
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
// BAPI-493: PR head SHA extraction now lives in the typed accessor layer
|
|
187
|
+
// (`event-accessors.getHeadSha`), which reads ONLY the canonical head path under a
|
|
188
|
+
// ledger event's details and fails closed exactly as the retired local
|
|
189
|
+
// `extractEventHeadSha` did (trimmed 7–40 char hex, lowercased). Every head read in
|
|
190
|
+
// this module goes through `getHeadSha(event)` — there is no hand-rolled details
|
|
191
|
+
// head cast in this file anymore.
|
|
192
|
+
/**
|
|
193
|
+
* Event types that carry an observable implementation-PR head SHA for a ticket
|
|
194
|
+
* (BAPI-487). Used to compute the latest observed head per ticket so a blocking
|
|
195
|
+
* signal emitted for a superseded head can be detected and cleared. Includes the
|
|
196
|
+
* blocking types (`ci.failed`/`review.changes_requested`) as head observations,
|
|
197
|
+
* but staleness is anchored on a `gate.met` for a DIFFERENT head so a delayed
|
|
198
|
+
* old-head rejection cannot masquerade as the current head. `review.passed` /
|
|
199
|
+
* `ci.passed` are head observations only and are deliberately NOT added to
|
|
200
|
+
* {@link TERMINAL_SIGNAL_TYPES} — they never fold a ticket status.
|
|
201
|
+
*
|
|
202
|
+
* BAPI-493: the literal list is statically constrained with
|
|
203
|
+
* `satisfies readonly HeadBearingSemanticEventType[]` so it can never drift from
|
|
204
|
+
* the payload model's head-bearing set — every member here declares `head_sha` in
|
|
205
|
+
* its `EventDetailsByType` payload, so replacing the ad-hoc head reader with the
|
|
206
|
+
* typed `getHeadSha` accessor cannot silently break BAPI-487 stale-block clearing.
|
|
207
|
+
*/
|
|
208
|
+
const HEAD_OBSERVATION_TYPE_LIST = [
|
|
209
|
+
"git.pr_opened",
|
|
210
|
+
"ci.passed",
|
|
211
|
+
"ci.failed",
|
|
212
|
+
"review.passed",
|
|
213
|
+
"review.changes_requested",
|
|
214
|
+
"gate.met",
|
|
215
|
+
"merge.succeeded",
|
|
216
|
+
// BAPI-494: merge.conflict carries a details head SHA, so it is a head
|
|
217
|
+
// observation (its head can supersede a stale block and be superseded by a rebase).
|
|
218
|
+
"merge.conflict",
|
|
219
|
+
];
|
|
220
|
+
const HEAD_OBSERVATION_TYPES = new Set(HEAD_OBSERVATION_TYPE_LIST);
|
|
221
|
+
/**
|
|
222
|
+
* Classify a terminal signal's head scope (BAPI-493). Uses `getHeadSha(event)` as
|
|
223
|
+
* the only head source. Centralizes the stale/current/clear-gate head comparisons
|
|
224
|
+
* the old ad-hoc `staleBlockedTickets`/`staleClearHead` checks performed inline.
|
|
225
|
+
*/
|
|
226
|
+
export function classifyTerminalHeadScope(ctx) {
|
|
227
|
+
const { signalType, eventHeadSha, ticketKey, staleBlockedTickets, staleClearHead } = ctx;
|
|
228
|
+
// BAPI-494: merge.conflict joins ci.failed/review.changes_requested as a head-scoped
|
|
229
|
+
// blocking signal — same stale-on-superseded-head classification.
|
|
230
|
+
if (isHeadScopedBlockingSignal(signalType)) {
|
|
231
|
+
if (eventHeadSha !== null && staleBlockedTickets.has(ticketKey)) {
|
|
232
|
+
const clearHead = staleClearHead(ticketKey);
|
|
233
|
+
if (clearHead !== null && clearHead !== eventHeadSha)
|
|
234
|
+
return "stale_blocking_signal";
|
|
235
|
+
}
|
|
236
|
+
return "current_or_unproven";
|
|
237
|
+
}
|
|
238
|
+
if (signalType === "gate.met") {
|
|
239
|
+
if (eventHeadSha !== null && staleBlockedTickets.has(ticketKey)) {
|
|
240
|
+
const clearHead = staleClearHead(ticketKey);
|
|
241
|
+
if (clearHead !== null && clearHead === eventHeadSha)
|
|
242
|
+
return "stale_block_clear_gate";
|
|
243
|
+
}
|
|
244
|
+
return "current_or_unproven";
|
|
245
|
+
}
|
|
246
|
+
return "not_head_scoped";
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Single exhaustive terminal-signal precedence table (BAPI-493), replacing the five
|
|
250
|
+
* ad-hoc same-tick fold-upgrade booleans that previously lived inline in
|
|
251
|
+
* `rebuildObservedState` (merge upgrade, spec-review reject, review reject, ci-failed
|
|
252
|
+
* re-block, and stale-head clear). `satisfies Record<TerminalSignalType, ...>` makes
|
|
253
|
+
* adding a new terminal signal a compile error until its precedence is defined here.
|
|
254
|
+
*
|
|
255
|
+
* `foldExists` is true when the ticket already has a same-tick fold. When false the
|
|
256
|
+
* signal is the FIRST fold of the tick and selects normally (the earlier
|
|
257
|
+
* postgres-non-terminal + review-role + stale-skip guards already filtered out the
|
|
258
|
+
* signals that must not fold at all). When true, only a dominating signal may
|
|
259
|
+
* replace the existing fold; everything else is explicitly dropped.
|
|
260
|
+
*/
|
|
261
|
+
const TERMINAL_PRECEDENCE = {
|
|
262
|
+
"gate.met": ({ foldExists, headScope, currentLocalStatus }) => {
|
|
263
|
+
if (!foldExists)
|
|
264
|
+
return { action: "select" };
|
|
265
|
+
// A later gate.met on the ticket's newer (passed) head clears a block that a
|
|
266
|
+
// superseded old head created earlier in the same batch (BAPI-487).
|
|
267
|
+
if (headScope === "stale_block_clear_gate" && currentLocalStatus === "blocked") {
|
|
268
|
+
return { action: "replace", reason: "stale-block-clear gate on the current head" };
|
|
269
|
+
}
|
|
270
|
+
return { action: "drop", reason: "gate.met does not override an existing same-tick fold" };
|
|
271
|
+
},
|
|
272
|
+
"merge.succeeded": ({ foldExists, currentLocalStatus }) => {
|
|
273
|
+
if (!foldExists)
|
|
274
|
+
return { action: "select" };
|
|
275
|
+
// ready_for_review → done when merge.succeeded arrives after gate.met/run.stopped
|
|
276
|
+
// in the same batch. Never overrides a same-tick `blocked` fold.
|
|
277
|
+
if (currentLocalStatus === "ready_for_review") {
|
|
278
|
+
return { action: "replace", reason: "merge.succeeded upgrades ready_for_review to done" };
|
|
279
|
+
}
|
|
280
|
+
return { action: "drop", reason: "merge.succeeded does not override a non-ready_for_review fold" };
|
|
281
|
+
},
|
|
282
|
+
"ci.failed": ({ foldExists, headScope, currentLocalStatus }) => {
|
|
283
|
+
if (headScope === "stale_blocking_signal") {
|
|
284
|
+
return { action: "drop", reason: "stale ci.failed on a superseded head" };
|
|
285
|
+
}
|
|
286
|
+
if (!foldExists)
|
|
287
|
+
return { action: "select" };
|
|
288
|
+
// A current/unprovable ci.failed re-blocks a prior gate.met/run.stopped fold so
|
|
289
|
+
// the current failure is not silently dropped (starving remediation).
|
|
290
|
+
if (currentLocalStatus !== "blocked") {
|
|
291
|
+
return { action: "replace", reason: "current-head ci.failed dominates a prior non-blocked fold" };
|
|
292
|
+
}
|
|
293
|
+
return { action: "drop", reason: "ticket already blocked this tick" };
|
|
294
|
+
},
|
|
295
|
+
"review.changes_requested": ({ foldExists, headScope, currentLocalStatus }) => {
|
|
296
|
+
if (headScope === "stale_blocking_signal") {
|
|
297
|
+
return { action: "drop", reason: "stale review.changes_requested on a superseded head" };
|
|
298
|
+
}
|
|
299
|
+
if (!foldExists)
|
|
300
|
+
return { action: "select" };
|
|
301
|
+
if (currentLocalStatus !== "blocked") {
|
|
302
|
+
return { action: "replace", reason: "current-head review.changes_requested dominates a prior non-blocked fold" };
|
|
303
|
+
}
|
|
304
|
+
return { action: "drop", reason: "ticket already blocked this tick" };
|
|
305
|
+
},
|
|
306
|
+
"merge.conflict": ({ foldExists, headScope, currentLocalStatus }) => {
|
|
307
|
+
// BAPI-494: same head-scoped precedence as ci.failed/review.changes_requested. A
|
|
308
|
+
// stale conflict on a superseded head is dropped; a current conflict dominates a
|
|
309
|
+
// prior gate.met/run.stopped fold in the SAME batch (else the conflict is silently
|
|
310
|
+
// dropped and the ticket strands at ready_for_review).
|
|
311
|
+
if (headScope === "stale_blocking_signal") {
|
|
312
|
+
return { action: "drop", reason: "stale merge.conflict on a superseded head" };
|
|
313
|
+
}
|
|
314
|
+
if (!foldExists)
|
|
315
|
+
return { action: "select" };
|
|
316
|
+
if (currentLocalStatus !== "blocked") {
|
|
317
|
+
return { action: "replace", reason: "current-head merge.conflict dominates a prior non-blocked fold" };
|
|
318
|
+
}
|
|
319
|
+
return { action: "drop", reason: "ticket already blocked this tick" };
|
|
320
|
+
},
|
|
321
|
+
"spec_review.changes_requested": ({ foldExists, currentLocalStatus }) => {
|
|
322
|
+
if (!foldExists)
|
|
323
|
+
return { action: "select" };
|
|
324
|
+
// A spec-review rejection dominates plain review completion so a changes-requested
|
|
325
|
+
// verdict is never lost to event ordering.
|
|
326
|
+
if (currentLocalStatus !== "blocked") {
|
|
327
|
+
return { action: "replace", reason: "spec-review rejection dominates a prior non-blocked review fold" };
|
|
328
|
+
}
|
|
329
|
+
return { action: "drop", reason: "ticket already blocked this tick" };
|
|
330
|
+
},
|
|
331
|
+
"spec_review.passed": ({ foldExists }) => {
|
|
332
|
+
if (!foldExists)
|
|
333
|
+
return { action: "select" };
|
|
334
|
+
return { action: "drop", reason: "spec_review.passed does not override an existing same-tick fold" };
|
|
335
|
+
},
|
|
336
|
+
"run.stopped": ({ foldExists }) => {
|
|
337
|
+
if (!foldExists)
|
|
338
|
+
return { action: "select" };
|
|
339
|
+
return { action: "drop", reason: "run.stopped does not override an existing same-tick fold" };
|
|
340
|
+
},
|
|
341
|
+
};
|
|
342
|
+
/**
|
|
343
|
+
* Resolve the fold decision for a terminal signal via {@link TERMINAL_PRECEDENCE}.
|
|
344
|
+
* Every terminal signal is explicitly classified as `select`, `replace`, or `drop`
|
|
345
|
+
* — there is no implicit fall-through or unclassified terminal.
|
|
346
|
+
*/
|
|
347
|
+
export function resolveTerminalFoldDecision(table, signalType, headScope, foldExists, currentLocalStatus, nextStatus) {
|
|
348
|
+
const rule = table[signalType];
|
|
349
|
+
return rule({ headScope, foldExists, currentLocalStatus, nextStatus });
|
|
350
|
+
}
|
|
351
|
+
// ---------------------------------------------------------------------------
|
|
155
352
|
// rebuildObservedState
|
|
156
353
|
// ---------------------------------------------------------------------------
|
|
354
|
+
/**
|
|
355
|
+
* BAPI-507 (N-1): event types that PROVE an implementation ticket has a bound PR
|
|
356
|
+
* — the precondition for folding a per-turn `run.stopped` to `ready_for_review`.
|
|
357
|
+
*/
|
|
358
|
+
const PR_BINDING_EVENT_TYPES = new Set([
|
|
359
|
+
"git.pr_opened",
|
|
360
|
+
"gate.met",
|
|
361
|
+
"merge.succeeded",
|
|
362
|
+
]);
|
|
363
|
+
/**
|
|
364
|
+
* BAPI-507 (N-1): does this event prove a PR binding exists for its ticket? True
|
|
365
|
+
* only for an implementation-run `git.pr_opened` / `gate.met` / `merge.succeeded`
|
|
366
|
+
* carrying observable PR/head details (a normalized head SHA, or a `pr_number` in
|
|
367
|
+
* the event details). Never true for a bare `run.stopped`, which is what this
|
|
368
|
+
* gate is protecting against.
|
|
369
|
+
*/
|
|
370
|
+
export function eventCarriesPrBinding(event) {
|
|
371
|
+
if (!PR_BINDING_EVENT_TYPES.has(event.type))
|
|
372
|
+
return false;
|
|
373
|
+
if (getHeadSha(event) !== null)
|
|
374
|
+
return true;
|
|
375
|
+
// Canonical details location is `event.data.details` (same path getHeadSha
|
|
376
|
+
// reads), NOT a top-level `event.details` — which ConductorEventFull never has.
|
|
377
|
+
const details = event.data?.details;
|
|
378
|
+
if (details && typeof details === "object") {
|
|
379
|
+
const pr = details.pr_number;
|
|
380
|
+
if (typeof pr === "number" && Number.isFinite(pr))
|
|
381
|
+
return true;
|
|
382
|
+
}
|
|
383
|
+
return false;
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* BAPI-507 (N-1): pre-scan an event batch and return the set of IMPLEMENTATION
|
|
387
|
+
* ticket keys that have a PR binding somewhere in the batch (order-independent).
|
|
388
|
+
* Review-run events are excluded so a review dispatch never binds an
|
|
389
|
+
* implementation ticket. This is what gates the `run.stopped` fold below.
|
|
390
|
+
*/
|
|
391
|
+
export function buildPrBoundTickets(events, runIdToTicketKey) {
|
|
392
|
+
const bound = new Set();
|
|
393
|
+
for (const event of events) {
|
|
394
|
+
if (!eventCarriesPrBinding(event))
|
|
395
|
+
continue;
|
|
396
|
+
const runId = typeof event.run_id === "string" ? event.run_id : null;
|
|
397
|
+
const mapped = runId ? runIdToTicketKey.get(runId) : undefined;
|
|
398
|
+
if (!mapped || mapped.isReview)
|
|
399
|
+
continue;
|
|
400
|
+
bound.add(mapped.ticketKey);
|
|
401
|
+
}
|
|
402
|
+
return bound;
|
|
403
|
+
}
|
|
157
404
|
/**
|
|
158
405
|
* Rebuild the merged observed state from the durable Postgres desired state
|
|
159
406
|
* and raw local ledger events. Local sticky terminal signals override
|
|
@@ -177,6 +424,10 @@ export function rebuildObservedState(postgresState, events, _now) {
|
|
|
177
424
|
});
|
|
178
425
|
}
|
|
179
426
|
}
|
|
427
|
+
// BAPI-507 (N-1): implementation tickets that have a PR binding in this event
|
|
428
|
+
// batch. Order-independent pre-scan so a `run.stopped` appearing before its
|
|
429
|
+
// `git.pr_opened` still folds. Gates the run.stopped fold in the loop below.
|
|
430
|
+
const prBoundTickets = buildPrBoundTickets(events, runIdToTicketKey);
|
|
180
431
|
// Populate base maps from Postgres
|
|
181
432
|
const ticketStatusMap = new Map();
|
|
182
433
|
const ticketRowVersionMap = new Map();
|
|
@@ -191,20 +442,145 @@ export function rebuildObservedState(postgresState, events, _now) {
|
|
|
191
442
|
}
|
|
192
443
|
const unfoldedSignals = [];
|
|
193
444
|
const pendingMergeEvents = [];
|
|
445
|
+
// BAPI-500: predicted per-ticket row version AFTER a same-pass fold CAS. Only
|
|
446
|
+
// the first-fold ("select") path records it (tick-start snapshot + 1), keyed by
|
|
447
|
+
// ticket to preserve the one-CAS-per-ticket invariant. reconcileEpic later
|
|
448
|
+
// confirms it from the real CAS response (or clears it on conflict/throw), so
|
|
449
|
+
// the follow-on remediation CAS in the same pass CASes against snapshot+1.
|
|
450
|
+
const ticketPostFoldRowVersionMap = new Map();
|
|
194
451
|
// Track which tickets already have a folded signal (one override per ticket)
|
|
195
452
|
const foldedTicketKeys = new Set();
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
|
|
453
|
+
// BAPI-493 (A2/F6): merge-enqueue dedupe is keyed by `(ticket, head_sha)`, NOT by
|
|
454
|
+
// ticket alone. A stale-head gate.met can no longer claim the single per-ticket
|
|
455
|
+
// merge slot and suppress the current-head gate.met — only the LATEST gated head
|
|
456
|
+
// for a ticket is eligible to enqueue (see the enqueue guard below), and the
|
|
457
|
+
// composite key prevents the same selected head from being enqueued twice.
|
|
458
|
+
// Distinct from foldedTicketKeys: a prior run.stopped fold (→ ready_for_review)
|
|
459
|
+
// must NOT suppress a later gate.met's merge enqueue (both map to
|
|
460
|
+
// ready_for_review; only gate.met enqueues a merge, and the worker frequently
|
|
461
|
+
// emits run.stopped BEFORE a post-hoc gate.met). Only a fold to a non-mergeable
|
|
462
|
+
// status (blocked) suppresses the merge — handled via the effective-status check
|
|
463
|
+
// below.
|
|
464
|
+
const mergeQueuedTicketHeadKeys = new Set();
|
|
465
|
+
/** Deterministic composite merge-queue key: `(ticket, head_sha)`. */
|
|
466
|
+
const mergeQueueKey = (ticketKey, headSha) => `${ticketKey}${headSha}`;
|
|
204
467
|
// BAPI-441: per-ticket latest blocking reason (ci.failed / review.changes_requested),
|
|
205
468
|
// tracked across the full ledger so an already-blocked ticket still carries a
|
|
206
469
|
// reason for the remediation pass to frame the nudge.
|
|
207
470
|
const ticketBlockedReasons = new Map();
|
|
471
|
+
// BAPI-487: head-scoped block detection. Pre-scan the full ledger once (before
|
|
472
|
+
// the fold loop) to find, per IMPLEMENTATION ticket:
|
|
473
|
+
// - the latest head-bearing observation (HEAD_OBSERVATION_TYPES),
|
|
474
|
+
// - the latest `gate.met` head (the definitive "good, passed" head), and
|
|
475
|
+
// - the latest blocking-signal head (ci.failed / review.changes_requested).
|
|
476
|
+
// A blocking signal on a head that a newer gate.met (or a strictly-later
|
|
477
|
+
// observation of a different head) has superseded must no longer hold the
|
|
478
|
+
// ticket `blocked`. Review-role dispatches (pre-implementation spec re-review)
|
|
479
|
+
// are excluded so their heads never clear an implementation PR block.
|
|
480
|
+
const latestObservedHeadByTicket = new Map();
|
|
481
|
+
// BAPI-493 (E-25): the gated-head prescan retains the FULL event reference (not
|
|
482
|
+
// just { headSha, seq }) so the A2/F6 merge-enqueue below can push the exact
|
|
483
|
+
// event `processGateMetMerge` needs to extract the full merge identity from.
|
|
484
|
+
const latestGatedHeadByTicket = new Map();
|
|
485
|
+
const latestBlockingHeadByTicket = new Map();
|
|
486
|
+
for (const event of events) {
|
|
487
|
+
const runId = typeof event.run_id === "string" ? event.run_id : null;
|
|
488
|
+
const mapped = runId ? runIdToTicketKey.get(runId) : undefined;
|
|
489
|
+
if (!mapped || mapped.isReview)
|
|
490
|
+
continue;
|
|
491
|
+
const headSha = getHeadSha(event);
|
|
492
|
+
if (!headSha)
|
|
493
|
+
continue;
|
|
494
|
+
const ticketKey = mapped.ticketKey;
|
|
495
|
+
if (HEAD_OBSERVATION_TYPES.has(event.type)) {
|
|
496
|
+
const prev = latestObservedHeadByTicket.get(ticketKey);
|
|
497
|
+
if (!prev || event.seq >= prev.seq) {
|
|
498
|
+
latestObservedHeadByTicket.set(ticketKey, { headSha, seq: event.seq });
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
if (event.type === "gate.met") {
|
|
502
|
+
const prev = latestGatedHeadByTicket.get(ticketKey);
|
|
503
|
+
if (!prev || event.seq >= prev.seq) {
|
|
504
|
+
latestGatedHeadByTicket.set(ticketKey, { headSha, seq: event.seq, event });
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
if (isHeadScopedBlockingSignal(event.type)) {
|
|
508
|
+
const prev = latestBlockingHeadByTicket.get(ticketKey);
|
|
509
|
+
if (!prev || event.seq >= prev.seq) {
|
|
510
|
+
latestBlockingHeadByTicket.set(ticketKey, {
|
|
511
|
+
headSha,
|
|
512
|
+
seq: event.seq,
|
|
513
|
+
reason: event.type,
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
// Head_sha of the latest blocking signal per ticket (paired with
|
|
519
|
+
// ticketBlockedReasons). Absent when the blocking event carried no valid head.
|
|
520
|
+
const ticketBlockedHeads = new Map();
|
|
521
|
+
for (const [ticketKey, blocking] of latestBlockingHeadByTicket) {
|
|
522
|
+
ticketBlockedHeads.set(ticketKey, blocking.headSha);
|
|
523
|
+
}
|
|
524
|
+
// A ticket is stale-blocked when its latest blocking head has been superseded
|
|
525
|
+
// by a CAUSALLY-LATER observation of a DIFFERENT head:
|
|
526
|
+
// (a) a gate.met AFTER the blocking event, on a different head, or
|
|
527
|
+
// (b) any head observation AFTER the blocking event, on a different head.
|
|
528
|
+
// Both require `> blocking.seq`: a gate.met/observation that PRECEDES the
|
|
529
|
+
// blocking event proves nothing about staleness — an old gate.met on head A
|
|
530
|
+
// followed by a genuinely newer ci.failed on head B (B never gated) must leave
|
|
531
|
+
// B's failure in force, or the current failure would be silently dropped by the
|
|
532
|
+
// fold-skip below and the ticket starved of remediation. When staleness cannot
|
|
533
|
+
// be proven from the ledger (e.g. a delayed old-head rejection whose head was
|
|
534
|
+
// never observed earlier), the reducer conservatively KEEPS the block; the
|
|
535
|
+
// live done-gate re-evaluation pass (runConductorDoneGatePass), which resolves
|
|
536
|
+
// the real current PR head via gh, is the backstop that clears a truly stale
|
|
537
|
+
// block once the head has provably advanced.
|
|
538
|
+
const staleBlockedTickets = new Set();
|
|
539
|
+
for (const [ticketKey, blocking] of latestBlockingHeadByTicket) {
|
|
540
|
+
const gated = latestGatedHeadByTicket.get(ticketKey);
|
|
541
|
+
const observed = latestObservedHeadByTicket.get(ticketKey);
|
|
542
|
+
const gatedSupersedes = gated !== undefined &&
|
|
543
|
+
gated.seq > blocking.seq &&
|
|
544
|
+
gated.headSha !== blocking.headSha;
|
|
545
|
+
const observedSupersedes = observed !== undefined &&
|
|
546
|
+
observed.seq > blocking.seq &&
|
|
547
|
+
observed.headSha !== blocking.headSha;
|
|
548
|
+
if (gatedSupersedes || observedSupersedes) {
|
|
549
|
+
staleBlockedTickets.add(ticketKey);
|
|
550
|
+
// If the durable base status is `blocked` on this superseded head, clear it
|
|
551
|
+
// locally to ready_for_review BEFORE the fold loop so this tick's
|
|
552
|
+
// remediation/merge logic does not re-act on the stale head.
|
|
553
|
+
if (ticketStatusMap.get(ticketKey) === "blocked") {
|
|
554
|
+
ticketStatusMap.set(ticketKey, "ready_for_review");
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
// The head that clears a stale block for a ticket: the passed gate.met head,
|
|
559
|
+
// falling back to the latest observed head.
|
|
560
|
+
const staleClearHead = (ticketKey) => latestGatedHeadByTicket.get(ticketKey)?.headSha ??
|
|
561
|
+
latestObservedHeadByTicket.get(ticketKey)?.headSha ??
|
|
562
|
+
null;
|
|
563
|
+
// Remove EVERY pending merge event previously queued for a ticket once a current
|
|
564
|
+
// (non-stale) blocking signal folds it to `blocked` this tick (BAPI-493). A blocked
|
|
565
|
+
// ticket must have no pending merge — including the case where the block lands on a
|
|
566
|
+
// DIFFERENT, non-stale head than the earlier-selected gate.met (e.g. gate on head A
|
|
567
|
+
// enqueued, then a genuinely newer ci.failed on head B blocks the ticket): the
|
|
568
|
+
// stale-head gate must not still be merged. Provably-stale blocks are dropped
|
|
569
|
+
// earlier and never reach here, so this only fires for real blocks. Also drops the
|
|
570
|
+
// composite dedupe keys so a later current-head gate can re-enqueue.
|
|
571
|
+
const dropPendingMergesForTicket = (ticketKey) => {
|
|
572
|
+
for (let i = pendingMergeEvents.length - 1; i >= 0; i--) {
|
|
573
|
+
const pending = pendingMergeEvents[i];
|
|
574
|
+
const pendingRunId = typeof pending.run_id === "string" ? pending.run_id : null;
|
|
575
|
+
const pendingTicket = pendingRunId ? runIdToTicketKey.get(pendingRunId)?.ticketKey : undefined;
|
|
576
|
+
if (pendingTicket !== ticketKey)
|
|
577
|
+
continue;
|
|
578
|
+
const pendingHead = getHeadSha(pending);
|
|
579
|
+
if (pendingHead)
|
|
580
|
+
mergeQueuedTicketHeadKeys.delete(mergeQueueKey(ticketKey, pendingHead));
|
|
581
|
+
pendingMergeEvents.splice(i, 1);
|
|
582
|
+
}
|
|
583
|
+
};
|
|
208
584
|
for (const event of events) {
|
|
209
585
|
if (!TERMINAL_SIGNAL_TYPES.has(event.type))
|
|
210
586
|
continue;
|
|
@@ -228,12 +604,44 @@ export function rebuildObservedState(postgresState, events, _now) {
|
|
|
228
604
|
event.type === "spec_review.changes_requested";
|
|
229
605
|
if (isReview && !isSpecVerdict)
|
|
230
606
|
continue;
|
|
607
|
+
const signalType = event.type;
|
|
608
|
+
// BAPI-507 (N-1): an implementation-run `run.stopped` only folds a ticket to
|
|
609
|
+
// `ready_for_review` when a PR binding for that ticket exists in this batch.
|
|
610
|
+
// Claude Code's Stop/SubagentStop hooks fire on every turn/subagent — long
|
|
611
|
+
// before a branch is pushed — so an unbound run.stopped must NOT persist the
|
|
612
|
+
// ticket as ready_for_review. Review-run terminals are already filtered above.
|
|
613
|
+
if (signalType === "run.stopped" &&
|
|
614
|
+
!isReview &&
|
|
615
|
+
!prBoundTickets.has(ticketKey)) {
|
|
616
|
+
continue;
|
|
617
|
+
}
|
|
618
|
+
const nextStatus = signalToNextStatus(signalType, isReview);
|
|
619
|
+
// BAPI-493: head reads and stale/current/clear-gate classification flow through
|
|
620
|
+
// the typed accessor + single head-scope classifier — no ad-hoc head casts.
|
|
621
|
+
const eventHeadSha = getHeadSha(event);
|
|
622
|
+
const headScope = classifyTerminalHeadScope({
|
|
623
|
+
ticketKey,
|
|
624
|
+
signalType,
|
|
625
|
+
nextStatus,
|
|
626
|
+
currentLocalStatus: ticketStatusMap.get(ticketKey),
|
|
627
|
+
eventHeadSha,
|
|
628
|
+
staleBlockedTickets,
|
|
629
|
+
staleClearHead,
|
|
630
|
+
});
|
|
631
|
+
// BAPI-487: a PROVABLY-stale blocking signal (ci.failed / review.changes_requested
|
|
632
|
+
// on a head a newer gate/observation superseded) is dropped entirely — it neither
|
|
633
|
+
// records a blocked reason nor folds. A missing/invalid head or a not-provably-stale
|
|
634
|
+
// ticket falls through and folds normally (fail-closed / backward compatible); the
|
|
635
|
+
// live done-gate re-evaluation is the backstop for staleness the ledger can't prove.
|
|
636
|
+
if (headScope === "stale_blocking_signal")
|
|
637
|
+
continue;
|
|
231
638
|
// Record the blocking reason (latest wins; events are seq-ordered) regardless
|
|
232
639
|
// of fold state, so a ticket blocked on a prior tick still resolves a reason.
|
|
233
|
-
if (
|
|
234
|
-
|
|
640
|
+
if (isHeadScopedBlockingSignal(signalType)) {
|
|
641
|
+
// BAPI-494: ci.failed / review.changes_requested / merge.conflict.
|
|
642
|
+
ticketBlockedReasons.set(ticketKey, signalType);
|
|
235
643
|
}
|
|
236
|
-
else if (
|
|
644
|
+
else if (signalType === "spec_review.changes_requested") {
|
|
237
645
|
// BAPI-445: spec-review rejection. Tagged distinctly so reconcile escalates
|
|
238
646
|
// it without spending the BAPI-441 implementation remediation budget.
|
|
239
647
|
ticketBlockedReasons.set(ticketKey, "spec_review.changes_requested");
|
|
@@ -241,63 +649,107 @@ export function rebuildObservedState(postgresState, events, _now) {
|
|
|
241
649
|
const postgresStatus = ticketStatusMap.get(ticketKey) ?? "planned";
|
|
242
650
|
if (!isNonTerminal(postgresStatus))
|
|
243
651
|
continue;
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
if (
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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.
|
|
268
|
-
// First-signal-wins for everything else: if ci.failed arrived first,
|
|
269
|
-
// currentLocalStatus is "blocked" (not "ready_for_review"), so the
|
|
270
|
-
// upgrade guard below is false and merge.succeeded is intentionally
|
|
271
|
-
// dropped — a failed-CI ticket must not be silently advanced to done.
|
|
272
|
-
const currentLocalStatus = ticketStatusMap.get(ticketKey);
|
|
273
|
-
const mergeUpgrade = currentLocalStatus === "ready_for_review" && nextStatus === "done";
|
|
274
|
-
const specRejectUpgrade = signalType === "spec_review.changes_requested" &&
|
|
275
|
-
currentLocalStatus !== "blocked";
|
|
276
|
-
if (mergeUpgrade || specRejectUpgrade) {
|
|
277
|
-
const existingIdx = unfoldedSignals.findIndex((s) => s.ticket_key === ticketKey);
|
|
278
|
-
if (existingIdx >= 0) {
|
|
279
|
-
unfoldedSignals[existingIdx] = {
|
|
280
|
-
...unfoldedSignals[existingIdx],
|
|
281
|
-
next_status: nextStatus,
|
|
282
|
-
signal_type: signalType,
|
|
283
|
-
event,
|
|
284
|
-
};
|
|
285
|
-
ticketStatusMap.set(ticketKey, nextStatus);
|
|
652
|
+
// BAPI-493 (A2/F6): enqueue the LATEST-head gate.met for merge, deduped by
|
|
653
|
+
// `(ticket, head_sha)`. The event must BE the latest gated head for its ticket
|
|
654
|
+
// (reference-equal to the prescan's retained event) so a stale-head gate.met can
|
|
655
|
+
// no longer claim the per-ticket slot and suppress the current-head one. Fail
|
|
656
|
+
// closed on a missing head. `postgresStatus` already reflects same-tick folds, so
|
|
657
|
+
// a prior run.stopped fold leaves it "ready_for_review" and the merge proceeds;
|
|
658
|
+
// BAPI-487: a stale-block-clearing gate on the newer head may enqueue even when
|
|
659
|
+
// the base status was `blocked`.
|
|
660
|
+
if (signalType === "gate.met" && eventHeadSha !== null) {
|
|
661
|
+
const latestGate = latestGatedHeadByTicket.get(ticketKey);
|
|
662
|
+
const isLatestGatedEvent = latestGate !== undefined && latestGate.event === event;
|
|
663
|
+
const gateMetClearsStale = headScope === "stale_block_clear_gate";
|
|
664
|
+
if (isLatestGatedEvent &&
|
|
665
|
+
(postgresStatus !== "blocked" || gateMetClearsStale)) {
|
|
666
|
+
const key = mergeQueueKey(ticketKey, eventHeadSha);
|
|
667
|
+
if (!mergeQueuedTicketHeadKeys.has(key)) {
|
|
668
|
+
pendingMergeEvents.push(event);
|
|
669
|
+
mergeQueuedTicketHeadKeys.add(key);
|
|
286
670
|
}
|
|
287
671
|
}
|
|
672
|
+
}
|
|
673
|
+
// BAPI-493 (D-2, subsumes A3/F7): one exhaustive head-scoped precedence model
|
|
674
|
+
// replaces the five ad-hoc upgrade booleans. Every terminal signal resolves to
|
|
675
|
+
// exactly one of select / replace / drop.
|
|
676
|
+
const foldExists = foldedTicketKeys.has(ticketKey);
|
|
677
|
+
const currentLocalStatus = ticketStatusMap.get(ticketKey);
|
|
678
|
+
const decision = resolveTerminalFoldDecision(TERMINAL_PRECEDENCE, signalType, headScope, foldExists, currentLocalStatus, nextStatus);
|
|
679
|
+
if (decision.action === "drop")
|
|
680
|
+
continue;
|
|
681
|
+
if (decision.action === "select") {
|
|
682
|
+
// BAPI-500: skip a redundant blocked→blocked fold. When the ticket's
|
|
683
|
+
// effective status this tick is already `blocked` and this signal would
|
|
684
|
+
// only re-fold it to `blocked`, a status CAS persists no new status and
|
|
685
|
+
// only churns `row_version` (and re-logs the fold every tick). Skip ONLY
|
|
686
|
+
// the unfolded-signal push (the thing that drives the CAS) and the
|
|
687
|
+
// post-fold prediction — but still mark the ticket folded this tick so
|
|
688
|
+
// same-tick fold precedence is preserved: a later `gate.met`/`merge.succeeded`
|
|
689
|
+
// in the SAME batch is still dropped (upholding the documented
|
|
690
|
+
// ci.failed-wins invariant), exactly as when the redundant fold was pushed.
|
|
691
|
+
// The blocking reason/head side maps above are already populated, so the
|
|
692
|
+
// remediation pass still frames the blocked ticket correctly; and because
|
|
693
|
+
// no post-fold version is recorded, remediation CASes against the tick-start
|
|
694
|
+
// snapshot, which for an un-refolded durable block IS the current version.
|
|
695
|
+
// (Stale old-head blocks were cleared to `ready_for_review` before the fold
|
|
696
|
+
// loop, so a genuinely stale block never reaches this guard.) `foldedTicketKeys`
|
|
697
|
+
// is rebuilt per tick, so this does not affect the cross-tick path where a
|
|
698
|
+
// later `merge.succeeded` advances an already-blocked ticket to `done`.
|
|
699
|
+
if (nextStatus === "blocked" && currentLocalStatus === "blocked") {
|
|
700
|
+
foldedTicketKeys.add(ticketKey);
|
|
701
|
+
continue;
|
|
702
|
+
}
|
|
703
|
+
const rowVersion = ticketRowVersionMap.get(ticketKey) ?? 0;
|
|
704
|
+
// BAPI-500: predict the post-fold row version (snapshot + 1) so a follow-on
|
|
705
|
+
// remediation CAS this same pass uses the version the block CAS will write.
|
|
706
|
+
ticketPostFoldRowVersionMap.set(ticketKey, rowVersion + 1);
|
|
707
|
+
unfoldedSignals.push({
|
|
708
|
+
ticket_key: ticketKey,
|
|
709
|
+
postgres_row_version: rowVersion,
|
|
710
|
+
next_status: nextStatus,
|
|
711
|
+
signal_type: signalType,
|
|
712
|
+
event,
|
|
713
|
+
});
|
|
714
|
+
ticketStatusMap.set(ticketKey, nextStatus);
|
|
715
|
+
foldedTicketKeys.add(ticketKey);
|
|
288
716
|
continue;
|
|
289
717
|
}
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
718
|
+
// decision.action === "replace": a dominating signal overrides a same-tick fold.
|
|
719
|
+
// Preserve the one-CAS-per-ticket invariant (at most one unfolded signal each).
|
|
720
|
+
const existingIdx = unfoldedSignals.findIndex((s) => s.ticket_key === ticketKey);
|
|
721
|
+
if (existingIdx >= 0) {
|
|
722
|
+
unfoldedSignals[existingIdx] = {
|
|
723
|
+
...unfoldedSignals[existingIdx],
|
|
724
|
+
next_status: nextStatus,
|
|
725
|
+
signal_type: signalType,
|
|
726
|
+
event,
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
else {
|
|
730
|
+
// A3/F7: a ticket already PERSISTED at ready_for_review with a later
|
|
731
|
+
// merge.succeeded (and no unfolded signal created earlier this tick) must
|
|
732
|
+
// still produce a fresh unfolded signal to `done` rather than silently
|
|
733
|
+
// dropping the transition. Use the current row version for the CAS guard.
|
|
734
|
+
const rowVersion = ticketRowVersionMap.get(ticketKey) ?? 0;
|
|
735
|
+
unfoldedSignals.push({
|
|
736
|
+
ticket_key: ticketKey,
|
|
737
|
+
postgres_row_version: rowVersion,
|
|
738
|
+
next_status: nextStatus,
|
|
739
|
+
signal_type: signalType,
|
|
740
|
+
event,
|
|
741
|
+
});
|
|
742
|
+
}
|
|
299
743
|
ticketStatusMap.set(ticketKey, nextStatus);
|
|
300
744
|
foldedTicketKeys.add(ticketKey);
|
|
745
|
+
// BAPI-493/BAPI-494: a current (non-stale) blocking signal — ci.failed,
|
|
746
|
+
// review.changes_requested, or merge.conflict — that just folded the ticket to
|
|
747
|
+
// `blocked` must evict ANY pending merge queued for that ticket this tick —
|
|
748
|
+
// regardless of whether the block's head matches the queued gate's head — or a
|
|
749
|
+
// now-superseded gate could still be merged despite the block winning the fold.
|
|
750
|
+
if (isHeadScopedBlockingSignal(signalType)) {
|
|
751
|
+
dropPendingMergesForTicket(ticketKey);
|
|
752
|
+
}
|
|
301
753
|
}
|
|
302
754
|
return {
|
|
303
755
|
epic_key: epic_run.epic_key,
|
|
@@ -305,8 +757,10 @@ export function rebuildObservedState(postgresState, events, _now) {
|
|
|
305
757
|
plan_version: epic_run.current_plan_version,
|
|
306
758
|
ticket_statuses: ticketStatusMap,
|
|
307
759
|
ticket_row_versions: ticketRowVersionMap,
|
|
760
|
+
ticket_post_fold_row_versions: ticketPostFoldRowVersionMap,
|
|
308
761
|
ticket_remediation_counters: ticketRemediationMap,
|
|
309
762
|
ticket_blocked_reasons: ticketBlockedReasons,
|
|
763
|
+
ticket_blocked_heads: ticketBlockedHeads,
|
|
310
764
|
unfolded_terminal_signals: unfoldedSignals,
|
|
311
765
|
pending_merge_events: pendingMergeEvents,
|
|
312
766
|
};
|