@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
|
@@ -30,6 +30,12 @@ const NON_TERMINAL_STATUSES = new Set([
|
|
|
30
30
|
"running",
|
|
31
31
|
"blocked",
|
|
32
32
|
"ready_for_review",
|
|
33
|
+
// BAPI-445: mid-flight pre-implementation spec re-review. Non-terminal so a
|
|
34
|
+
// review verdict / completion signal can still advance it on a later tick, but
|
|
35
|
+
// deliberately NOT a status computeReadySet returns for normal dispatch — a
|
|
36
|
+
// reviewing ticket is mid-flight, not ready. Stranded reviewing tickets are
|
|
37
|
+
// re-driven by reconcile's dedicated reviewing re-entry branch.
|
|
38
|
+
"reviewing",
|
|
33
39
|
]);
|
|
34
40
|
const TERMINAL_SIGNAL_TYPES = new Set([
|
|
35
41
|
"gate.met",
|
|
@@ -37,18 +43,42 @@ const TERMINAL_SIGNAL_TYPES = new Set([
|
|
|
37
43
|
"ci.failed",
|
|
38
44
|
"run.stopped",
|
|
39
45
|
"review.changes_requested",
|
|
46
|
+
// BAPI-445: pre-implementation spec re-review verdicts, scoped to review runs.
|
|
47
|
+
"spec_review.passed",
|
|
48
|
+
"spec_review.changes_requested",
|
|
40
49
|
]);
|
|
41
50
|
function isNonTerminal(status) {
|
|
42
51
|
return NON_TERMINAL_STATUSES.has(status);
|
|
43
52
|
}
|
|
44
|
-
|
|
53
|
+
/**
|
|
54
|
+
* Map a terminal ledger signal to the next ticket status.
|
|
55
|
+
*
|
|
56
|
+
* `isReviewRun` marks signals correlated to a `:review`-suffixed dispatch
|
|
57
|
+
* (BAPI-445 Obstacle 2). The spec re-review verdicts fold explicitly:
|
|
58
|
+
* - `spec_review.passed` → `ready` (verdict: proceed to implementation)
|
|
59
|
+
* - `spec_review.changes_requested` → `blocked` (verdict: reject; reconcile escalates)
|
|
60
|
+
*
|
|
61
|
+
* FAIL-SAFE (BAPI-445): a review run's INCIDENTAL terminals (`run.stopped`,
|
|
62
|
+
* `gate.met`, …) are filtered out of folding entirely by `rebuildObservedState`
|
|
63
|
+
* BEFORE this function is called — a review session merely *ending* must never
|
|
64
|
+
* advance a `reviewing` ticket, or implementation would proceed regardless of
|
|
65
|
+
* the review's outcome (fail-open). So for a review run this is only ever called
|
|
66
|
+
* with a `spec_review.*` verdict; `isReviewRun` is retained for caller clarity.
|
|
67
|
+
* For implementation runs the BAPI-436 mapping is unchanged.
|
|
68
|
+
*/
|
|
69
|
+
export function signalToNextStatus(signalType, isReviewRun = false) {
|
|
70
|
+
void isReviewRun; // review-run incidental terminals are filtered before this call
|
|
71
|
+
if (signalType === "spec_review.passed")
|
|
72
|
+
return "ready";
|
|
73
|
+
if (signalType === "spec_review.changes_requested")
|
|
74
|
+
return "blocked";
|
|
45
75
|
if (signalType === "ci.failed")
|
|
46
76
|
return "blocked";
|
|
47
77
|
if (signalType === "review.changes_requested")
|
|
48
78
|
return "blocked";
|
|
49
79
|
if (signalType === "merge.succeeded")
|
|
50
80
|
return "done";
|
|
51
|
-
return "ready_for_review"; // gate.met and run.stopped: awaiting merge
|
|
81
|
+
return "ready_for_review"; // gate.met and (implementation) run.stopped: awaiting merge
|
|
52
82
|
}
|
|
53
83
|
// ---------------------------------------------------------------------------
|
|
54
84
|
// computeReadySet
|
|
@@ -74,6 +104,13 @@ export function computeReadySet(plan, ticketStatuses) {
|
|
|
74
104
|
}
|
|
75
105
|
return ready;
|
|
76
106
|
}
|
|
107
|
+
/**
|
|
108
|
+
* Default ceiling for spec re-review dispatch attempts on a stranded `reviewing`
|
|
109
|
+
* ticket (BAPI-445). Counts the initial review dispatch plus liveness-recovery
|
|
110
|
+
* re-dispatches; once reached, the reviewing recovery branch escalates instead
|
|
111
|
+
* of re-dispatching. Deliberately independent of the BAPI-441 remediation budget.
|
|
112
|
+
*/
|
|
113
|
+
export const DEFAULT_MAX_SPEC_REVIEW_ATTEMPTS = 3;
|
|
77
114
|
/**
|
|
78
115
|
* Pure, deterministic remediation decision (no I/O, no clock). Given the current
|
|
79
116
|
* budget counters, the ledger-derived worker liveness, and the configured
|
|
@@ -124,11 +161,20 @@ export function extractWorkerLiveness(events, runId, nowMs, windowSeconds) {
|
|
|
124
161
|
*/
|
|
125
162
|
export function rebuildObservedState(postgresState, events, _now) {
|
|
126
163
|
const { epic_run, ticket_statuses, dispatches } = postgresState;
|
|
127
|
-
// Build run_id → ticket_key lookup from dispatch records
|
|
164
|
+
// Build run_id → {ticket_key, isReview} lookup from dispatch records.
|
|
165
|
+
// BAPI-445 (Obstacle 2): a review run's dispatch_key carries a ":review"
|
|
166
|
+
// suffix, so the same run_id map can distinguish a pre-implementation
|
|
167
|
+
// spec-review run from the implementation run. This is correctness-critical:
|
|
168
|
+
// it lets the fold below route a review run's run.stopped to "ready" (review
|
|
169
|
+
// complete → proceed) instead of mis-folding it as implementation completion
|
|
170
|
+
// ("ready_for_review", awaiting merge), which would skip implementation.
|
|
128
171
|
const runIdToTicketKey = new Map();
|
|
129
172
|
for (const dispatch of dispatches) {
|
|
130
173
|
if (dispatch.run_id) {
|
|
131
|
-
runIdToTicketKey.set(dispatch.run_id,
|
|
174
|
+
runIdToTicketKey.set(dispatch.run_id, {
|
|
175
|
+
ticketKey: dispatch.ticket_key,
|
|
176
|
+
isReview: dispatch.dispatch_key.endsWith(":review"),
|
|
177
|
+
});
|
|
132
178
|
}
|
|
133
179
|
}
|
|
134
180
|
// Populate base maps from Postgres
|
|
@@ -154,16 +200,36 @@ export function rebuildObservedState(postgresState, events, _now) {
|
|
|
154
200
|
for (const event of events) {
|
|
155
201
|
if (!TERMINAL_SIGNAL_TYPES.has(event.type))
|
|
156
202
|
continue;
|
|
157
|
-
// Map event → ticket via run_id
|
|
203
|
+
// Map event → ticket via run_id, resolving the dispatch role (review vs.
|
|
204
|
+
// implementation) so the fold can treat review-run terminals distinctly.
|
|
158
205
|
const runId = typeof event.run_id === "string" ? event.run_id : null;
|
|
159
|
-
const
|
|
160
|
-
if (!
|
|
206
|
+
const mapped = runId ? runIdToTicketKey.get(runId) : undefined;
|
|
207
|
+
if (!mapped)
|
|
208
|
+
continue;
|
|
209
|
+
const ticketKey = mapped.ticketKey;
|
|
210
|
+
const isReview = mapped.isReview;
|
|
211
|
+
// BAPI-445 FAIL-SAFE: a review run's INCIDENTAL terminals (run.stopped,
|
|
212
|
+
// gate.met, …) must never advance a `reviewing` ticket — ONLY an explicit
|
|
213
|
+
// spec_review.* verdict does. A review session merely ending must not fold
|
|
214
|
+
// reviewing → ready and dispatch implementation regardless of whether the
|
|
215
|
+
// review requested changes (that would be fail-OPEN). When the verdict is
|
|
216
|
+
// not (yet) emitted, the ticket stays in `reviewing` and the reconcile
|
|
217
|
+
// liveness-recovery / escalation path surfaces it for operator attention —
|
|
218
|
+
// implementation never silently proceeds without a pass verdict.
|
|
219
|
+
const isSpecVerdict = event.type === "spec_review.passed" ||
|
|
220
|
+
event.type === "spec_review.changes_requested";
|
|
221
|
+
if (isReview && !isSpecVerdict)
|
|
161
222
|
continue;
|
|
162
223
|
// Record the blocking reason (latest wins; events are seq-ordered) regardless
|
|
163
224
|
// of fold state, so a ticket blocked on a prior tick still resolves a reason.
|
|
164
225
|
if (event.type === "ci.failed" || event.type === "review.changes_requested") {
|
|
165
226
|
ticketBlockedReasons.set(ticketKey, event.type);
|
|
166
227
|
}
|
|
228
|
+
else if (event.type === "spec_review.changes_requested") {
|
|
229
|
+
// BAPI-445: spec-review rejection. Tagged distinctly so reconcile escalates
|
|
230
|
+
// it without spending the BAPI-441 implementation remediation budget.
|
|
231
|
+
ticketBlockedReasons.set(ticketKey, "spec_review.changes_requested");
|
|
232
|
+
}
|
|
167
233
|
const postgresStatus = ticketStatusMap.get(ticketKey) ?? "planned";
|
|
168
234
|
if (!isNonTerminal(postgresStatus))
|
|
169
235
|
continue;
|
|
@@ -175,16 +241,24 @@ export function rebuildObservedState(postgresState, events, _now) {
|
|
|
175
241
|
pendingMergeEvents.push(event);
|
|
176
242
|
}
|
|
177
243
|
const signalType = event.type;
|
|
178
|
-
const nextStatus = signalToNextStatus(signalType);
|
|
244
|
+
const nextStatus = signalToNextStatus(signalType, isReview);
|
|
179
245
|
if (foldedTicketKeys.has(ticketKey)) {
|
|
180
|
-
// Allow a same-tick upgrade
|
|
181
|
-
// merge.succeeded arrives after gate.met
|
|
246
|
+
// Allow a bounded same-tick upgrade of an already-folded ticket:
|
|
247
|
+
// 1. ready_for_review → done when merge.succeeded arrives after gate.met
|
|
248
|
+
// in the same ledger batch.
|
|
249
|
+
// 2. (BAPI-445) <any review fold> → blocked when a review run's
|
|
250
|
+
// spec_review.changes_requested arrives after its run.stopped in the
|
|
251
|
+
// same batch. A spec-review rejection must dominate plain completion
|
|
252
|
+
// so a changes-requested verdict is never lost to event ordering.
|
|
182
253
|
// First-signal-wins for everything else: if ci.failed arrived first,
|
|
183
254
|
// currentLocalStatus is "blocked" (not "ready_for_review"), so the
|
|
184
255
|
// upgrade guard below is false and merge.succeeded is intentionally
|
|
185
256
|
// dropped — a failed-CI ticket must not be silently advanced to done.
|
|
186
257
|
const currentLocalStatus = ticketStatusMap.get(ticketKey);
|
|
187
|
-
|
|
258
|
+
const mergeUpgrade = currentLocalStatus === "ready_for_review" && nextStatus === "done";
|
|
259
|
+
const specRejectUpgrade = signalType === "spec_review.changes_requested" &&
|
|
260
|
+
currentLocalStatus !== "blocked";
|
|
261
|
+
if (mergeUpgrade || specRejectUpgrade) {
|
|
188
262
|
const existingIdx = unfoldedSignals.findIndex((s) => s.ticket_key === ticketKey);
|
|
189
263
|
if (existingIdx >= 0) {
|
|
190
264
|
unfoldedSignals[existingIdx] = {
|
|
@@ -64,6 +64,18 @@ function isSqliteBusyError(error) {
|
|
|
64
64
|
* the raw error text, stack, and any secret material are discarded.
|
|
65
65
|
*/
|
|
66
66
|
export function toConductorErrorEnvelope(error) {
|
|
67
|
+
// Optional-native-binding degradation (BAPI-451): the conductor `store.ts`
|
|
68
|
+
// throws ConductorPersistenceUnavailableError when `better-sqlite3` could not be
|
|
69
|
+
// loaded. Detected by name (not instanceof) to avoid an import cycle with
|
|
70
|
+
// store.ts. Surfaced as a retryable-shaped 503 with a fixed, secret-free message
|
|
71
|
+
// so the caller learns persistence is unavailable instead of getting an opaque 500.
|
|
72
|
+
if (error instanceof Error && error.name === "ConductorPersistenceUnavailableError") {
|
|
73
|
+
return {
|
|
74
|
+
error: "PERSISTENCE_UNAVAILABLE",
|
|
75
|
+
status: 503,
|
|
76
|
+
message: "Conductor persistence is unavailable (the optional better-sqlite3 native module is not loaded). Conductor coordination features are disabled; core tools are unaffected.",
|
|
77
|
+
};
|
|
78
|
+
}
|
|
67
79
|
if (error instanceof ConductorValidationError) {
|
|
68
80
|
// Validation messages are conductor-authored and name the offending field,
|
|
69
81
|
// never its value — but redact defensively so an adversarial/secret-bearing
|
|
@@ -33,6 +33,16 @@ export const DEFAULT_GATE_NAME = "done";
|
|
|
33
33
|
export const REVIEW_PASSED = "review.passed";
|
|
34
34
|
/** Event type emitted when a reviewer requests changes. */
|
|
35
35
|
export const REVIEW_CHANGES_REQUESTED = "review.changes_requested";
|
|
36
|
+
/**
|
|
37
|
+
* BAPI-445 pre-implementation SPEC re-review verdict event types. Emitted by the
|
|
38
|
+
* spec re-review run (``/review-ticket --auto``), correlated to that review run,
|
|
39
|
+
* and kept distinct from the PR-review {@link REVIEW_PASSED} /
|
|
40
|
+
* {@link REVIEW_CHANGES_REQUESTED} verdicts so a spec-review outcome is never
|
|
41
|
+
* folded as the implementation PR's review state.
|
|
42
|
+
*/
|
|
43
|
+
export const SPEC_REVIEW_PASSED = "spec_review.passed";
|
|
44
|
+
/** Event type emitted when a spec re-review requests changes. */
|
|
45
|
+
export const SPEC_REVIEW_CHANGES_REQUESTED = "spec_review.changes_requested";
|
|
36
46
|
/** Matches ASCII control characters (C0 range plus DEL). */
|
|
37
47
|
const CONTROL_CHAR_RE = /[\u0000-\u001F\u007F]/;
|
|
38
48
|
/** Matches a 40- or 64-character hex string (git SHA-1 / SHA-256 object ids). */
|
|
@@ -79,7 +79,7 @@ export function buildWorktreeChangedEventInput(context, updates, phase) {
|
|
|
79
79
|
* dedupe by `repo + commit_sha`, and emit best-effort. Returns a
|
|
80
80
|
* success-compatible result for every failure mode so the commit is never blocked.
|
|
81
81
|
*/
|
|
82
|
-
export function runPostCommitHookProducer(deps = {}) {
|
|
82
|
+
export async function runPostCommitHookProducer(deps = {}) {
|
|
83
83
|
const getContext = deps.getContext ?? getGitWorktreeContext;
|
|
84
84
|
const readMetadata = deps.readMetadata ?? readHeadCommitMetadata;
|
|
85
85
|
const emitIfNew = deps.emitIfNew ?? emitConductorEventIfNew;
|
|
@@ -90,7 +90,7 @@ export function runPostCommitHookProducer(deps = {}) {
|
|
|
90
90
|
return { ok: true, emitted: false, reason: "no-head-commit" };
|
|
91
91
|
}
|
|
92
92
|
const input = buildCommitCreatedEventInput(context, metadata);
|
|
93
|
-
const decision = emitIfNew(input, {
|
|
93
|
+
const decision = await emitIfNew(input, {
|
|
94
94
|
event_type: "git.commit_created",
|
|
95
95
|
repo: context.repo,
|
|
96
96
|
commit_sha: metadata.sha,
|
|
@@ -109,7 +109,7 @@ export function runPostCommitHookProducer(deps = {}) {
|
|
|
109
109
|
* success-compatible result for every failure mode so the ref update is never
|
|
110
110
|
* blocked.
|
|
111
111
|
*/
|
|
112
|
-
export function runReferenceTransactionHookProducer(args, deps = {}) {
|
|
112
|
+
export async function runReferenceTransactionHookProducer(args, deps = {}) {
|
|
113
113
|
const getContext = deps.getContext ?? getGitWorktreeContext;
|
|
114
114
|
const parseUpdates = deps.parseUpdates ?? parseReferenceTransactionUpdates;
|
|
115
115
|
const emitIfNew = deps.emitIfNew ?? emitConductorEventIfNew;
|
|
@@ -124,7 +124,7 @@ export function runReferenceTransactionHookProducer(args, deps = {}) {
|
|
|
124
124
|
const context = getContext({ cwd: deps.cwd, env: deps.env });
|
|
125
125
|
const input = buildWorktreeChangedEventInput(context, updates, args.phase);
|
|
126
126
|
const refUpdatesHash = stableJsonHash({ phase: args.phase, updates });
|
|
127
|
-
const decision = emitIfNew(input, {
|
|
127
|
+
const decision = await emitIfNew(input, {
|
|
128
128
|
event_type: "worktree.changed",
|
|
129
129
|
repo: context.repo,
|
|
130
130
|
ref_updates_hash: refUpdatesHash,
|
|
@@ -103,9 +103,9 @@ export function makeMergeEventId(eventType, actionKey) {
|
|
|
103
103
|
const h = createHash("sha256").update(`${eventType}:${actionKey}`).digest("hex");
|
|
104
104
|
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20, 32)}`;
|
|
105
105
|
}
|
|
106
|
-
function lookupMergeEventByActionKey(eventType, actionKey, deps) {
|
|
106
|
+
async function lookupMergeEventByActionKey(eventType, actionKey, deps) {
|
|
107
107
|
const openDb = deps.openDb ?? (() => openReadonlyConductorDatabaseIfExists());
|
|
108
|
-
const db = openDb();
|
|
108
|
+
const db = await openDb();
|
|
109
109
|
if (!db)
|
|
110
110
|
return false;
|
|
111
111
|
try {
|
|
@@ -126,7 +126,7 @@ function lookupMergeEventByActionKey(eventType, actionKey, deps) {
|
|
|
126
126
|
* the action key. Only `merge.succeeded` is terminal — `merge.failed` and
|
|
127
127
|
* `merge.dry_run` are never terminal.
|
|
128
128
|
*/
|
|
129
|
-
export function hasTerminalMergeSucceeded(actionKey, deps = {}) {
|
|
129
|
+
export async function hasTerminalMergeSucceeded(actionKey, deps = {}) {
|
|
130
130
|
return lookupMergeEventByActionKey("merge.succeeded", actionKey, deps);
|
|
131
131
|
}
|
|
132
132
|
/**
|
|
@@ -134,7 +134,7 @@ export function hasTerminalMergeSucceeded(actionKey, deps = {}) {
|
|
|
134
134
|
* Dry-run is audit/hygiene only and is NEVER treated as terminal — a later real
|
|
135
135
|
* merge for the same PR/head/gate may still proceed if the repo flag is enabled.
|
|
136
136
|
*/
|
|
137
|
-
export function hasMergeDryRun(actionKey, deps = {}) {
|
|
137
|
+
export async function hasMergeDryRun(actionKey, deps = {}) {
|
|
138
138
|
return lookupMergeEventByActionKey("merge.dry_run", actionKey, deps);
|
|
139
139
|
}
|
|
140
140
|
/**
|
|
@@ -142,7 +142,7 @@ export function hasMergeDryRun(actionKey, deps = {}) {
|
|
|
142
142
|
* action key. Pending approval is NEVER terminal — the supervisor must re-dispatch
|
|
143
143
|
* until the backend reports approval (returning `merge.attempted` + `merge.succeeded`).
|
|
144
144
|
*/
|
|
145
|
-
export function hasMergePendingApproval(actionKey, deps = {}) {
|
|
145
|
+
export async function hasMergePendingApproval(actionKey, deps = {}) {
|
|
146
146
|
return lookupMergeEventByActionKey("merge.pending_approval", actionKey, deps);
|
|
147
147
|
}
|
|
148
148
|
/** Heuristically detect a SQLite duplicate-id / UNIQUE constraint failure. */
|
|
@@ -167,7 +167,7 @@ function isDuplicateConstraintError(error) {
|
|
|
167
167
|
* outcome fields live under `details`). A duplicate UNIQUE-constraint failure is
|
|
168
168
|
* classified as `{ emitted: false, reason: "duplicate" }` rather than thrown.
|
|
169
169
|
*/
|
|
170
|
-
export function emitMergeLedgerEventIfNew(input, deps = {}) {
|
|
170
|
+
export async function emitMergeLedgerEventIfNew(input, deps = {}) {
|
|
171
171
|
const emitEvent = deps.emitEvent ?? emitConductorEvent;
|
|
172
172
|
const eventId = makeMergeEventId(input.type, input.action_key);
|
|
173
173
|
const event = {
|
|
@@ -186,7 +186,7 @@ export function emitMergeLedgerEventIfNew(input, deps = {}) {
|
|
|
186
186
|
},
|
|
187
187
|
};
|
|
188
188
|
try {
|
|
189
|
-
const result = emitEvent(event);
|
|
189
|
+
const result = await emitEvent(event);
|
|
190
190
|
return { emitted: true, event_id: eventId, event: result.event };
|
|
191
191
|
}
|
|
192
192
|
catch (error) {
|
|
@@ -149,7 +149,7 @@ async function observeWithResolved(binding, access, gateConfig, deps) {
|
|
|
149
149
|
reason: "observed",
|
|
150
150
|
};
|
|
151
151
|
// 1. PR opened telemetry (independent of CI / gate).
|
|
152
|
-
const prDecision = emitIfNew(buildPrOpenedEventInput(binding, run_id, worker_id), {
|
|
152
|
+
const prDecision = await emitIfNew(buildPrOpenedEventInput(binding, run_id, worker_id), {
|
|
153
153
|
event_type: "git.pr_opened",
|
|
154
154
|
repo: binding.repo,
|
|
155
155
|
pr_number: binding.pr_number,
|
|
@@ -174,7 +174,7 @@ async function observeWithResolved(binding, access, gateConfig, deps) {
|
|
|
174
174
|
}
|
|
175
175
|
else {
|
|
176
176
|
result.ci_status = ciEvent.type === "ci.passed" ? "passed" : "failed";
|
|
177
|
-
const ciDecision = emitIfNew(ciEvent, {
|
|
177
|
+
const ciDecision = await emitIfNew(ciEvent, {
|
|
178
178
|
event_type: ciEvent.type,
|
|
179
179
|
repo: binding.repo,
|
|
180
180
|
pr_number: binding.pr_number,
|
|
@@ -210,7 +210,7 @@ async function observeWithResolved(binding, access, gateConfig, deps) {
|
|
|
210
210
|
result.gate_met = true;
|
|
211
211
|
const gateEvent = buildGateMetEventInput(binding, evaluation, run_id, worker_id);
|
|
212
212
|
if (gateEvent !== null) {
|
|
213
|
-
const gateDecision = emitIfNew(gateEvent, {
|
|
213
|
+
const gateDecision = await emitIfNew(gateEvent, {
|
|
214
214
|
event_type: "gate.met",
|
|
215
215
|
repo: binding.repo,
|
|
216
216
|
pr_number: binding.pr_number,
|
|
@@ -409,7 +409,7 @@ export async function observePrCiFromPollResponse(commitRef, pollResponse, deps
|
|
|
409
409
|
gate_emitted: false,
|
|
410
410
|
reason: "observed",
|
|
411
411
|
};
|
|
412
|
-
const prDecision = emitIfNew(buildPrOpenedEventInput(binding, run_id, worker_id), {
|
|
412
|
+
const prDecision = await emitIfNew(buildPrOpenedEventInput(binding, run_id, worker_id), {
|
|
413
413
|
event_type: "git.pr_opened",
|
|
414
414
|
repo: binding.repo,
|
|
415
415
|
pr_number: binding.pr_number,
|
|
@@ -423,7 +423,7 @@ export async function observePrCiFromPollResponse(commitRef, pollResponse, deps
|
|
|
423
423
|
return result;
|
|
424
424
|
}
|
|
425
425
|
result.ci_status = ciEvent.type === "ci.passed" ? "passed" : "failed";
|
|
426
|
-
const ciDecision = emitIfNew(ciEvent, {
|
|
426
|
+
const ciDecision = await emitIfNew(ciEvent, {
|
|
427
427
|
event_type: ciEvent.type,
|
|
428
428
|
repo: binding.repo,
|
|
429
429
|
pr_number: binding.pr_number,
|
|
@@ -461,7 +461,7 @@ export async function observePrCiFromPollResponse(commitRef, pollResponse, deps
|
|
|
461
461
|
result.gate_met = true;
|
|
462
462
|
const gateEvent = buildGateMetEventInput(binding, evaluation, run_id, worker_id);
|
|
463
463
|
if (gateEvent !== null) {
|
|
464
|
-
const gateDecision = emitIfNew(gateEvent, {
|
|
464
|
+
const gateDecision = await emitIfNew(gateEvent, {
|
|
465
465
|
event_type: "gate.met",
|
|
466
466
|
repo: binding.repo,
|
|
467
467
|
pr_number: binding.pr_number,
|
|
@@ -99,13 +99,13 @@ export async function observeReviewWithResolved(binding, access, gateConfig, dep
|
|
|
99
99
|
};
|
|
100
100
|
if (evalResult.changesRequested) {
|
|
101
101
|
const event = buildReviewObservationEventInput(binding, snapshot, REVIEW_CHANGES_REQUESTED, evalResult.reason, run_id, worker_id);
|
|
102
|
-
const decision = emitIfNew(event, { event_type: REVIEW_CHANGES_REQUESTED, ...baseDimensions });
|
|
102
|
+
const decision = await emitIfNew(event, { event_type: REVIEW_CHANGES_REQUESTED, ...baseDimensions });
|
|
103
103
|
result.review_changes_requested_emitted = decision.emitted;
|
|
104
104
|
result.reason = "review changes requested";
|
|
105
105
|
}
|
|
106
106
|
else if (evalResult.passed) {
|
|
107
107
|
const event = buildReviewObservationEventInput(binding, snapshot, REVIEW_PASSED, evalResult.reason, run_id, worker_id);
|
|
108
|
-
const decision = emitIfNew(event, { event_type: REVIEW_PASSED, ...baseDimensions });
|
|
108
|
+
const decision = await emitIfNew(event, { event_type: REVIEW_PASSED, ...baseDimensions });
|
|
109
109
|
result.review_passed_emitted = decision.emitted;
|
|
110
110
|
result.reason = "review passed";
|
|
111
111
|
}
|
|
@@ -59,13 +59,13 @@ const LEDGER_SCAN_MAX_PAGES = 200;
|
|
|
59
59
|
* full details object is visible. Malformed/non-matching events are skipped; the
|
|
60
60
|
* scan never throws.
|
|
61
61
|
*/
|
|
62
|
-
export function eventAlreadyExists(dedupeKey, deps = {}) {
|
|
62
|
+
export async function eventAlreadyExists(dedupeKey, deps = {}) {
|
|
63
63
|
const pollEvents = deps.pollEvents ?? ((options) => pollConductorEvents(options));
|
|
64
64
|
let sinceSeq = 1;
|
|
65
65
|
for (let page = 0; page < LEDGER_SCAN_MAX_PAGES; page += 1) {
|
|
66
66
|
let result;
|
|
67
67
|
try {
|
|
68
|
-
result = pollEvents({ since_seq: sinceSeq, data_mode: "full", limit: LEDGER_SCAN_PAGE_LIMIT });
|
|
68
|
+
result = await pollEvents({ since_seq: sinceSeq, data_mode: "full", limit: LEDGER_SCAN_PAGE_LIMIT });
|
|
69
69
|
}
|
|
70
70
|
catch {
|
|
71
71
|
return false;
|
|
@@ -95,10 +95,10 @@ export function eventAlreadyExists(dedupeKey, deps = {}) {
|
|
|
95
95
|
* under `data.details`, then emits. A duplicate-id constraint thrown by a racing
|
|
96
96
|
* producer is classified as a duplicate rather than surfaced as a failure.
|
|
97
97
|
*/
|
|
98
|
-
export function emitConductorEventIfNew(input, dimensions, deps = {}) {
|
|
98
|
+
export async function emitConductorEventIfNew(input, dimensions, deps = {}) {
|
|
99
99
|
const emitEvent = deps.emitEvent ?? emitConductorEvent;
|
|
100
100
|
const dedupeKey = makeProducerDedupeKey(dimensions);
|
|
101
|
-
if (eventAlreadyExists(dedupeKey, deps)) {
|
|
101
|
+
if (await eventAlreadyExists(dedupeKey, deps)) {
|
|
102
102
|
return { emitted: false, reason: "duplicate" };
|
|
103
103
|
}
|
|
104
104
|
const eventId = makeStableProducerEventId(dedupeKey);
|
|
@@ -113,7 +113,7 @@ export function emitConductorEventIfNew(input, dimensions, deps = {}) {
|
|
|
113
113
|
details: { ...existingDetails, dedupe_key: dedupeKey },
|
|
114
114
|
};
|
|
115
115
|
try {
|
|
116
|
-
emitEvent({ ...input, id: eventId, data });
|
|
116
|
+
await emitEvent({ ...input, id: eventId, data });
|
|
117
117
|
return { emitted: true, event_id: eventId };
|
|
118
118
|
}
|
|
119
119
|
catch (error) {
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pre-implementation SPEC re-review verdict production (BAPI-445 Phase 2).
|
|
3
|
+
*
|
|
4
|
+
* Emits the spec re-review verdict (`spec_review.passed` /
|
|
5
|
+
* `spec_review.changes_requested`) into the LOCAL conductor ledger, correlated
|
|
6
|
+
* to the spec-review run via `BAPI_CONDUCTOR_RUN_ID`. The Epic Supervisor's
|
|
7
|
+
* reconcile pass folds these signals: `passed` → `reviewing → ready` (proceed to
|
|
8
|
+
* implementation), `changes_requested` → `blocked` (escalate; do NOT implement).
|
|
9
|
+
*
|
|
10
|
+
* Why this is a LOCAL producer, not a backend webhook change: the conductor
|
|
11
|
+
* ledger is a per-worker SQLite file (`~/.config/bridge/events.db`). The
|
|
12
|
+
* Heroku-hosted Bridge API backend has no access to it. So — exactly like the
|
|
13
|
+
* PR-review producer ({@link ./pr-review-producer}) — the verdict is emitted by
|
|
14
|
+
* the LOCAL spec-review run, whose environment carries `BAPI_CONDUCTOR_RUN_ID`
|
|
15
|
+
* (injected at dispatch time by {@link ../review-tickets}.orchestrateReviewTickets
|
|
16
|
+
* when an epic identity is present). This corrects the original BAPI-445 plan,
|
|
17
|
+
* which proposed emitting from the backend review webhook
|
|
18
|
+
* (`code_writer_jira.py`) — that path cannot reach the worker-local ledger.
|
|
19
|
+
*
|
|
20
|
+
* The verdict family is kept DISTINCT from the PR-review `review.passed` /
|
|
21
|
+
* `review.changes_requested` verdicts (BAPI-440) so a spec-review outcome is
|
|
22
|
+
* never mis-folded as the implementation PR's review state (Obstacle 2).
|
|
23
|
+
*/
|
|
24
|
+
// TODO(BAPI-445 Phase 2 integration): `emitSpecReviewVerdict` is the verdict
|
|
25
|
+
// EMISSION primitive but currently has no caller. Until the review-ticket
|
|
26
|
+
// completion flow derives a pass/changes-requested decision from the critique
|
|
27
|
+
// and calls this function, no `spec_review.*` event is ever written.
|
|
28
|
+
//
|
|
29
|
+
// This is FAIL-SAFE, not fail-open: epic-state.ts `rebuildObservedState` filters
|
|
30
|
+
// a review run's incidental terminals (run.stopped, …) out of folding, so a
|
|
31
|
+
// completed review with NO verdict leaves the ticket in `reviewing` rather than
|
|
32
|
+
// advancing it to `ready` and dispatching implementation. The reconcile
|
|
33
|
+
// liveness-recovery / escalation path then surfaces the stranded ticket for
|
|
34
|
+
// operator attention. Only an explicit `spec_review.passed` proceeds and only
|
|
35
|
+
// `spec_review.changes_requested` blocks — so wiring this producer in is what
|
|
36
|
+
// makes the gate ACTIVE; without it, opted-in tickets hold safely instead of
|
|
37
|
+
// silently implementing against the review's wishes.
|
|
38
|
+
import { SPEC_REVIEW_CHANGES_REQUESTED, SPEC_REVIEW_PASSED } from "./git-ci-types.js";
|
|
39
|
+
import { emitConductorEventIfNew } from "./producer-ledger.js";
|
|
40
|
+
/** Producer + observed-via identifiers stamped onto emitted spec-review events. */
|
|
41
|
+
export const SPEC_REVIEW_PRODUCER = "spec-review-producer";
|
|
42
|
+
/**
|
|
43
|
+
* Build a `spec_review.passed` / `spec_review.changes_requested` event input for
|
|
44
|
+
* a ticket's spec re-review verdict, correlated to the review run via `runId`.
|
|
45
|
+
*/
|
|
46
|
+
export function buildSpecReviewVerdictEventInput(ticketKey, verdict, reason, runId = null, workerId = null) {
|
|
47
|
+
const eventType = verdict === "passed" ? SPEC_REVIEW_PASSED : SPEC_REVIEW_CHANGES_REQUESTED;
|
|
48
|
+
return {
|
|
49
|
+
source: "review",
|
|
50
|
+
type: eventType,
|
|
51
|
+
subject: ticketKey,
|
|
52
|
+
run_id: runId,
|
|
53
|
+
worker_id: workerId,
|
|
54
|
+
producer: SPEC_REVIEW_PRODUCER,
|
|
55
|
+
observed_via: SPEC_REVIEW_PRODUCER,
|
|
56
|
+
data: {
|
|
57
|
+
summary: verdict === "passed"
|
|
58
|
+
? `Spec re-review passed for ${ticketKey}`
|
|
59
|
+
: `Spec re-review requested changes for ${ticketKey}`,
|
|
60
|
+
status: verdict,
|
|
61
|
+
details: { ticket_key: ticketKey, reason },
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Emit a spec re-review verdict for `ticketKey` into the local conductor ledger,
|
|
67
|
+
* correlated to the spec-review run via `BAPI_CONDUCTOR_RUN_ID` (read from the
|
|
68
|
+
* injected env). Idempotent: deduped by `event_type` + `run_id`, so re-emitting
|
|
69
|
+
* the same run's verdict is a no-op. A missing run id still emits (uncorrelated)
|
|
70
|
+
* but is logged in the result so callers can detect the misconfiguration.
|
|
71
|
+
*/
|
|
72
|
+
export async function emitSpecReviewVerdict(ticketKey, verdict, reason, deps = {}) {
|
|
73
|
+
const emitIfNew = deps.emitIfNew ?? emitConductorEventIfNew;
|
|
74
|
+
const run_id = deps.env?.BAPI_CONDUCTOR_RUN_ID?.trim() || null;
|
|
75
|
+
const worker_id = deps.env?.BAPI_CONDUCTOR_WORKER_ID?.trim() || null;
|
|
76
|
+
const eventType = verdict === "passed" ? SPEC_REVIEW_PASSED : SPEC_REVIEW_CHANGES_REQUESTED;
|
|
77
|
+
const event = buildSpecReviewVerdictEventInput(ticketKey, verdict, reason, run_id, worker_id);
|
|
78
|
+
const decision = await emitIfNew(event, {
|
|
79
|
+
event_type: eventType,
|
|
80
|
+
run_id: run_id ?? "",
|
|
81
|
+
});
|
|
82
|
+
return {
|
|
83
|
+
emitted: decision.emitted,
|
|
84
|
+
event_type: eventType,
|
|
85
|
+
run_id,
|
|
86
|
+
reason: run_id ? reason : `${reason} (warning: no BAPI_CONDUCTOR_RUN_ID — verdict uncorrelated)`,
|
|
87
|
+
};
|
|
88
|
+
}
|