@bridge_gpt/mcp-server 0.2.9 → 0.2.10
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 +56 -4
- package/build/conductor/bridge-api-client.js +262 -35
- package/build/conductor/cli.js +22 -1
- package/build/conductor/doctor.js +34 -1
- package/build/conductor/done-gate.js +301 -58
- package/build/conductor/epic-reconcile.js +121 -4
- package/build/conductor/epic-runtime.js +298 -17
- package/build/conductor/epic-state.js +108 -9
- package/build/conductor/git-ci-types.js +6 -0
- package/build/conductor/pr-ci-producer.js +114 -15
- package/build/conductor/pr-review-producer.js +116 -0
- package/build/conductor/store.js +8 -1
- package/build/conductor/supervisor-message-relay.js +31 -0
- package/build/conductor/taxonomy.js +3 -0
- package/build/conductor/tools.js +2 -2
- package/build/index.js +356 -1086
- package/build/init.js +481 -0
- package/build/install-bridge.js +692 -0
- package/build/mcp-profile.js +43 -0
- 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 +2 -2
|
@@ -1,44 +1,63 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Pure, deterministic observed-state rebuild + ready-set computation for the
|
|
3
|
-
* Epic Supervisor (BAPI-408).
|
|
3
|
+
* Epic Supervisor (BAPI-408, BAPI-436).
|
|
4
4
|
*
|
|
5
5
|
* This module has NO I/O, NO timers, and NO LLM calls. Every time-dependent
|
|
6
6
|
* function takes an explicit `now` (epoch ms) so tests are wall-clock
|
|
7
7
|
* independent. Truth precedence: raw local ledger events override non-terminal
|
|
8
8
|
* Postgres states.
|
|
9
|
+
*
|
|
10
|
+
* Status mapping (BAPI-436 — merge-gated dependent dispatch):
|
|
11
|
+
* gate.met → ready_for_review (implementation done; awaiting merge)
|
|
12
|
+
* run.stopped → ready_for_review (worker session ended; awaiting merge)
|
|
13
|
+
* merge.succeeded → done (merged; dependents may now dispatch)
|
|
14
|
+
* ci.failed → blocked (unchanged)
|
|
9
15
|
*/
|
|
10
16
|
// ---------------------------------------------------------------------------
|
|
11
17
|
// Status sets (module-private)
|
|
12
18
|
// ---------------------------------------------------------------------------
|
|
13
19
|
const NOT_STARTED_STATUS = "planned";
|
|
14
20
|
const DONE_STATUSES = new Set(["done"]);
|
|
21
|
+
// "blocked" is intentionally non-terminal: a merge.succeeded arriving in a
|
|
22
|
+
// subsequent tick (cross-tick) can still advance a blocked ticket to done.
|
|
23
|
+
// This is the expected path when an operator merges a PR despite failed CI.
|
|
24
|
+
// Contrast with the same-tick case: ci.failed wins over merge.succeeded in
|
|
25
|
+
// the same ledger batch (first-signal-wins; see the foldedTicketKeys guard).
|
|
15
26
|
const NON_TERMINAL_STATUSES = new Set([
|
|
16
27
|
"planned",
|
|
17
28
|
"ready",
|
|
18
29
|
"dispatched",
|
|
19
30
|
"running",
|
|
20
31
|
"blocked",
|
|
32
|
+
"ready_for_review",
|
|
21
33
|
]);
|
|
22
34
|
const TERMINAL_SIGNAL_TYPES = new Set([
|
|
23
35
|
"gate.met",
|
|
24
36
|
"merge.succeeded",
|
|
25
37
|
"ci.failed",
|
|
26
38
|
"run.stopped",
|
|
39
|
+
"review.changes_requested",
|
|
27
40
|
]);
|
|
28
41
|
function isNonTerminal(status) {
|
|
29
42
|
return NON_TERMINAL_STATUSES.has(status);
|
|
30
43
|
}
|
|
31
|
-
function signalToNextStatus(signalType) {
|
|
44
|
+
export function signalToNextStatus(signalType) {
|
|
32
45
|
if (signalType === "ci.failed")
|
|
33
46
|
return "blocked";
|
|
34
|
-
|
|
47
|
+
if (signalType === "review.changes_requested")
|
|
48
|
+
return "blocked";
|
|
49
|
+
if (signalType === "merge.succeeded")
|
|
50
|
+
return "done";
|
|
51
|
+
return "ready_for_review"; // gate.met and run.stopped: awaiting merge
|
|
35
52
|
}
|
|
36
53
|
// ---------------------------------------------------------------------------
|
|
37
54
|
// computeReadySet
|
|
38
55
|
// ---------------------------------------------------------------------------
|
|
39
56
|
/**
|
|
40
57
|
* Pure deterministic ready-set computation. Returns ticket keys that:
|
|
41
|
-
* 1. Have status "planned" (not yet started)
|
|
58
|
+
* 1. Have status "planned" (not yet started) or "ready" (crash-recovery —
|
|
59
|
+
* a ticket already advanced to ready on a prior tick that crashed before
|
|
60
|
+
* dispatch must not be silently dropped), AND
|
|
42
61
|
* 2. Whose full `depends_on` list is satisfied (all deps have "done" status).
|
|
43
62
|
*
|
|
44
63
|
* Never calls an LLM or performs I/O. Goal 8 invariant.
|
|
@@ -47,7 +66,7 @@ export function computeReadySet(plan, ticketStatuses) {
|
|
|
47
66
|
const ready = [];
|
|
48
67
|
for (const ticket of plan.tickets) {
|
|
49
68
|
const currentStatus = ticketStatuses.get(ticket.ticket_key) ?? "planned";
|
|
50
|
-
if (currentStatus !== NOT_STARTED_STATUS)
|
|
69
|
+
if (currentStatus !== NOT_STARTED_STATUS && currentStatus !== "ready")
|
|
51
70
|
continue;
|
|
52
71
|
const allDepsResolved = ticket.depends_on.every((dep) => DONE_STATUSES.has(ticketStatuses.get(dep) ?? "planned"));
|
|
53
72
|
if (allDepsResolved)
|
|
@@ -55,6 +74,46 @@ export function computeReadySet(plan, ticketStatuses) {
|
|
|
55
74
|
}
|
|
56
75
|
return ready;
|
|
57
76
|
}
|
|
77
|
+
/**
|
|
78
|
+
* Pure, deterministic remediation decision (no I/O, no clock). Given the current
|
|
79
|
+
* budget counters, the ledger-derived worker liveness, and the configured
|
|
80
|
+
* ceilings, decide whether to NUDGE (worker still alive), RE-DISPATCH (worker
|
|
81
|
+
* gone, budget remaining), or ESCALATE (budget exhausted).
|
|
82
|
+
*
|
|
83
|
+
* Budget exhaustion is checked FIRST so an at-ceiling ticket always escalates
|
|
84
|
+
* regardless of liveness. A counter at-or-above its ceiling exhausts the budget.
|
|
85
|
+
*/
|
|
86
|
+
export function decideRemediation(attempts, noProgressAttempts, alive, config) {
|
|
87
|
+
if (attempts >= config.max_remediation_attempts ||
|
|
88
|
+
noProgressAttempts >= config.max_remediation_no_progress_attempts) {
|
|
89
|
+
return "escalate";
|
|
90
|
+
}
|
|
91
|
+
return alive ? "nudge" : "redispatch";
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Pure liveness extraction (no I/O, explicit `nowMs`). Finds the most recent
|
|
95
|
+
* `message.delivered`/`message.acked` heartbeat event for `runId` and reports
|
|
96
|
+
* the worker alive when that heartbeat's age is within
|
|
97
|
+
* `windowSeconds`. An empty ledger (no heartbeat for the run) defaults to
|
|
98
|
+
* `{ alive: false, workerId: null }` (fail-closed: never misjudge a worker
|
|
99
|
+
* alive without evidence).
|
|
100
|
+
*/
|
|
101
|
+
export function extractWorkerLiveness(events, runId, nowMs, windowSeconds) {
|
|
102
|
+
let latest = null;
|
|
103
|
+
for (const ev of events) {
|
|
104
|
+
if (ev.run_id !== runId)
|
|
105
|
+
continue;
|
|
106
|
+
if (ev.type !== "message.delivered" && ev.type !== "message.acked")
|
|
107
|
+
continue;
|
|
108
|
+
if (!latest || new Date(ev.time).getTime() > new Date(latest.time).getTime()) {
|
|
109
|
+
latest = ev;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (!latest)
|
|
113
|
+
return { alive: false, workerId: null };
|
|
114
|
+
const age = nowMs - new Date(latest.time).getTime();
|
|
115
|
+
return { alive: age <= windowSeconds * 1000, workerId: latest.worker_id ?? null };
|
|
116
|
+
}
|
|
58
117
|
// ---------------------------------------------------------------------------
|
|
59
118
|
// rebuildObservedState
|
|
60
119
|
// ---------------------------------------------------------------------------
|
|
@@ -75,14 +134,23 @@ export function rebuildObservedState(postgresState, events, _now) {
|
|
|
75
134
|
// Populate base maps from Postgres
|
|
76
135
|
const ticketStatusMap = new Map();
|
|
77
136
|
const ticketRowVersionMap = new Map();
|
|
137
|
+
const ticketRemediationMap = new Map();
|
|
78
138
|
for (const ts of ticket_statuses) {
|
|
79
139
|
ticketStatusMap.set(ts.ticket_key, ts.status);
|
|
80
140
|
ticketRowVersionMap.set(ts.ticket_key, ts.row_version);
|
|
141
|
+
ticketRemediationMap.set(ts.ticket_key, {
|
|
142
|
+
attempts: ts.remediation_attempts ?? 0,
|
|
143
|
+
no_progress: ts.remediation_no_progress_attempts ?? 0,
|
|
144
|
+
});
|
|
81
145
|
}
|
|
82
146
|
const unfoldedSignals = [];
|
|
83
147
|
const pendingMergeEvents = [];
|
|
84
148
|
// Track which tickets already have a folded signal (one override per ticket)
|
|
85
149
|
const foldedTicketKeys = new Set();
|
|
150
|
+
// BAPI-441: per-ticket latest blocking reason (ci.failed / review.changes_requested),
|
|
151
|
+
// tracked across the full ledger so an already-blocked ticket still carries a
|
|
152
|
+
// reason for the remediation pass to frame the nudge.
|
|
153
|
+
const ticketBlockedReasons = new Map();
|
|
86
154
|
for (const event of events) {
|
|
87
155
|
if (!TERMINAL_SIGNAL_TYPES.has(event.type))
|
|
88
156
|
continue;
|
|
@@ -91,16 +159,45 @@ export function rebuildObservedState(postgresState, events, _now) {
|
|
|
91
159
|
const ticketKey = runId ? runIdToTicketKey.get(runId) : undefined;
|
|
92
160
|
if (!ticketKey)
|
|
93
161
|
continue;
|
|
94
|
-
|
|
95
|
-
|
|
162
|
+
// Record the blocking reason (latest wins; events are seq-ordered) regardless
|
|
163
|
+
// of fold state, so a ticket blocked on a prior tick still resolves a reason.
|
|
164
|
+
if (event.type === "ci.failed" || event.type === "review.changes_requested") {
|
|
165
|
+
ticketBlockedReasons.set(ticketKey, event.type);
|
|
96
166
|
}
|
|
97
167
|
const postgresStatus = ticketStatusMap.get(ticketKey) ?? "planned";
|
|
98
168
|
if (!isNonTerminal(postgresStatus))
|
|
99
169
|
continue;
|
|
100
|
-
if
|
|
101
|
-
|
|
170
|
+
// Only queue for merge actioning if this ticket hasn't already been folded
|
|
171
|
+
// this tick. Without this guard, two gate.met events for the same ticket
|
|
172
|
+
// would both enqueue, and a ci.failed → gate.met sequence would enqueue a
|
|
173
|
+
// merge action for a ticket whose effective status is "blocked".
|
|
174
|
+
if (event.type === "gate.met" && !foldedTicketKeys.has(ticketKey)) {
|
|
175
|
+
pendingMergeEvents.push(event);
|
|
176
|
+
}
|
|
102
177
|
const signalType = event.type;
|
|
103
178
|
const nextStatus = signalToNextStatus(signalType);
|
|
179
|
+
if (foldedTicketKeys.has(ticketKey)) {
|
|
180
|
+
// Allow a same-tick upgrade from ready_for_review → done when
|
|
181
|
+
// merge.succeeded arrives after gate.met in the same ledger batch.
|
|
182
|
+
// First-signal-wins for everything else: if ci.failed arrived first,
|
|
183
|
+
// currentLocalStatus is "blocked" (not "ready_for_review"), so the
|
|
184
|
+
// upgrade guard below is false and merge.succeeded is intentionally
|
|
185
|
+
// dropped — a failed-CI ticket must not be silently advanced to done.
|
|
186
|
+
const currentLocalStatus = ticketStatusMap.get(ticketKey);
|
|
187
|
+
if (currentLocalStatus === "ready_for_review" && nextStatus === "done") {
|
|
188
|
+
const existingIdx = unfoldedSignals.findIndex((s) => s.ticket_key === ticketKey);
|
|
189
|
+
if (existingIdx >= 0) {
|
|
190
|
+
unfoldedSignals[existingIdx] = {
|
|
191
|
+
...unfoldedSignals[existingIdx],
|
|
192
|
+
next_status: nextStatus,
|
|
193
|
+
signal_type: signalType,
|
|
194
|
+
event,
|
|
195
|
+
};
|
|
196
|
+
ticketStatusMap.set(ticketKey, nextStatus);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
104
201
|
const rowVersion = ticketRowVersionMap.get(ticketKey) ?? 0;
|
|
105
202
|
unfoldedSignals.push({
|
|
106
203
|
ticket_key: ticketKey,
|
|
@@ -119,6 +216,8 @@ export function rebuildObservedState(postgresState, events, _now) {
|
|
|
119
216
|
plan_version: epic_run.current_plan_version,
|
|
120
217
|
ticket_statuses: ticketStatusMap,
|
|
121
218
|
ticket_row_versions: ticketRowVersionMap,
|
|
219
|
+
ticket_remediation_counters: ticketRemediationMap,
|
|
220
|
+
ticket_blocked_reasons: ticketBlockedReasons,
|
|
122
221
|
unfolded_terminal_signals: unfoldedSignals,
|
|
123
222
|
pending_merge_events: pendingMergeEvents,
|
|
124
223
|
};
|
|
@@ -25,8 +25,14 @@ export const GIT_CI_PRODUCER = "git-pr-ci-producer";
|
|
|
25
25
|
export const GIT_HOOK_PRODUCER = "git-hook";
|
|
26
26
|
/** The single v1 done-gate condition type. */
|
|
27
27
|
export const REQUIRED_CI_CHECKS_GREEN = "required_ci_checks_green";
|
|
28
|
+
/** The v2 review-state done-gate condition type. */
|
|
29
|
+
export const REVIEW_STATE = "review_state";
|
|
28
30
|
/** Default gate name surfaced in `gate.met` event data. */
|
|
29
31
|
export const DEFAULT_GATE_NAME = "done";
|
|
32
|
+
/** Event type emitted when the configured review source is satisfied. */
|
|
33
|
+
export const REVIEW_PASSED = "review.passed";
|
|
34
|
+
/** Event type emitted when a reviewer requests changes. */
|
|
35
|
+
export const REVIEW_CHANGES_REQUESTED = "review.changes_requested";
|
|
30
36
|
/** Matches ASCII control characters (C0 range plus DEL). */
|
|
31
37
|
const CONTROL_CHAR_RE = /[\u0000-\u001F\u007F]/;
|
|
32
38
|
/** Matches a 40- or 64-character hex string (git SHA-1 / SHA-256 object ids). */
|
|
@@ -11,7 +11,16 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { GIT_CI_PRODUCER, } from "./git-ci-types.js";
|
|
13
13
|
import { evaluateDoneGate, normalizeCiSnapshot, parseDoneGateConfig } from "./done-gate.js";
|
|
14
|
-
import {
|
|
14
|
+
import { observeReviewWithResolved } from "./pr-review-producer.js";
|
|
15
|
+
import { fetchEffectiveSupervisorSetup, fetchActiveEpicRuns, fetchEpicRunState, pollCiChecksForCommit, resolveConductorBridgeApiAccess, } from "./bridge-api-client.js";
|
|
16
|
+
/** Default gate-config fetcher: reads done_gate_config from the supervisor setup. */
|
|
17
|
+
async function _fetchGateConfigDefault(access) {
|
|
18
|
+
const setup = await fetchEffectiveSupervisorSetup(access);
|
|
19
|
+
// Fail closed when no row exists (source="none")
|
|
20
|
+
if (setup.source === "none")
|
|
21
|
+
return undefined;
|
|
22
|
+
return setup.done_gate_config ?? undefined;
|
|
23
|
+
}
|
|
15
24
|
import { resolvePrHeadBinding } from "./pr-discovery.js";
|
|
16
25
|
import { emitConductorEventIfNew } from "./producer-ledger.js";
|
|
17
26
|
const PRODUCER_OBSERVED_VIA = "pr-ci-producer";
|
|
@@ -24,7 +33,7 @@ export const WAIT_FOR_GATE_POLL_INTERVAL_MIN_MS = 500;
|
|
|
24
33
|
// Event builders
|
|
25
34
|
// ---------------------------------------------------------------------------
|
|
26
35
|
/** Build a `git.pr_opened` event bound to repo + PR number + head SHA. */
|
|
27
|
-
export function buildPrOpenedEventInput(binding) {
|
|
36
|
+
export function buildPrOpenedEventInput(binding, runId = null, workerId = null) {
|
|
28
37
|
const details = {
|
|
29
38
|
repo: binding.repo,
|
|
30
39
|
pr_number: binding.pr_number,
|
|
@@ -43,6 +52,8 @@ export function buildPrOpenedEventInput(binding) {
|
|
|
43
52
|
source: "git",
|
|
44
53
|
type: "git.pr_opened",
|
|
45
54
|
subject: binding.subject,
|
|
55
|
+
run_id: runId,
|
|
56
|
+
worker_id: workerId,
|
|
46
57
|
producer: GIT_CI_PRODUCER,
|
|
47
58
|
observed_via: PRODUCER_OBSERVED_VIA,
|
|
48
59
|
data,
|
|
@@ -54,7 +65,7 @@ export function buildPrOpenedEventInput(binding) {
|
|
|
54
65
|
* `ci.passed` requires every polled check complete and green; `ci.failed` requires
|
|
55
66
|
* every polled check complete with at least one not green.
|
|
56
67
|
*/
|
|
57
|
-
export function buildCiObservationEventInput(binding, snapshot) {
|
|
68
|
+
export function buildCiObservationEventInput(binding, snapshot, runId = null, workerId = null) {
|
|
58
69
|
if (snapshot.checks.length === 0)
|
|
59
70
|
return null;
|
|
60
71
|
const allComplete = snapshot.checks.every((c) => c.complete);
|
|
@@ -74,6 +85,8 @@ export function buildCiObservationEventInput(binding, snapshot) {
|
|
|
74
85
|
source: "ci",
|
|
75
86
|
type,
|
|
76
87
|
subject: binding.subject,
|
|
88
|
+
run_id: runId,
|
|
89
|
+
worker_id: workerId,
|
|
77
90
|
producer: GIT_CI_PRODUCER,
|
|
78
91
|
observed_via: PRODUCER_OBSERVED_VIA,
|
|
79
92
|
data: {
|
|
@@ -87,13 +100,15 @@ export function buildCiObservationEventInput(binding, snapshot) {
|
|
|
87
100
|
* Build a canonical `gate.met` event from a successful {@link GateEvaluationResult}.
|
|
88
101
|
* Returns `null` when the gate was not met (no event data to emit).
|
|
89
102
|
*/
|
|
90
|
-
export function buildGateMetEventInput(binding, evaluation) {
|
|
103
|
+
export function buildGateMetEventInput(binding, evaluation, runId = null, workerId = null) {
|
|
91
104
|
if (!evaluation.met || !evaluation.gateEventData)
|
|
92
105
|
return null;
|
|
93
106
|
return {
|
|
94
107
|
source: "conductor",
|
|
95
108
|
type: "gate.met",
|
|
96
109
|
subject: binding.subject,
|
|
110
|
+
run_id: runId,
|
|
111
|
+
worker_id: workerId,
|
|
97
112
|
producer: GIT_CI_PRODUCER,
|
|
98
113
|
observed_via: PRODUCER_OBSERVED_VIA,
|
|
99
114
|
data: { ...evaluation.gateEventData },
|
|
@@ -112,6 +127,18 @@ async function observeWithResolved(binding, access, gateConfig, deps) {
|
|
|
112
127
|
const emitIfNew = deps.emitIfNew ?? emitConductorEventIfNew;
|
|
113
128
|
const pollCi = deps.pollCi ?? pollCiChecksForCommit;
|
|
114
129
|
const now = deps.now ?? (() => new Date().toISOString());
|
|
130
|
+
// Resolve run_id/worker_id for attribution. Env takes precedence; fall back
|
|
131
|
+
// to the injected resolution seam when the env identity is absent.
|
|
132
|
+
let run_id = deps.env?.BAPI_CONDUCTOR_RUN_ID?.trim() || null;
|
|
133
|
+
const worker_id = deps.env?.BAPI_CONDUCTOR_WORKER_ID?.trim() || null;
|
|
134
|
+
if (run_id === null && deps.resolveRunId) {
|
|
135
|
+
try {
|
|
136
|
+
run_id = await deps.resolveRunId(access, binding) ?? null;
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
run_id = null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
115
142
|
const result = {
|
|
116
143
|
binding,
|
|
117
144
|
pr_opened_emitted: false,
|
|
@@ -122,7 +149,7 @@ async function observeWithResolved(binding, access, gateConfig, deps) {
|
|
|
122
149
|
reason: "observed",
|
|
123
150
|
};
|
|
124
151
|
// 1. PR opened telemetry (independent of CI / gate).
|
|
125
|
-
const prDecision = emitIfNew(buildPrOpenedEventInput(binding), {
|
|
152
|
+
const prDecision = emitIfNew(buildPrOpenedEventInput(binding, run_id, worker_id), {
|
|
126
153
|
event_type: "git.pr_opened",
|
|
127
154
|
repo: binding.repo,
|
|
128
155
|
pr_number: binding.pr_number,
|
|
@@ -141,7 +168,7 @@ async function observeWithResolved(binding, access, gateConfig, deps) {
|
|
|
141
168
|
}
|
|
142
169
|
const snapshot = normalizeCiSnapshot(rawPoll);
|
|
143
170
|
// 3. CI observation telemetry (only for terminal states).
|
|
144
|
-
const ciEvent = buildCiObservationEventInput(binding, snapshot);
|
|
171
|
+
const ciEvent = buildCiObservationEventInput(binding, snapshot, run_id, worker_id);
|
|
145
172
|
if (ciEvent === null) {
|
|
146
173
|
result.ci_status = "pending";
|
|
147
174
|
}
|
|
@@ -161,13 +188,27 @@ async function observeWithResolved(binding, access, gateConfig, deps) {
|
|
|
161
188
|
result.reason = `gate inactive: ${gateConfig.reason}`;
|
|
162
189
|
return result;
|
|
163
190
|
}
|
|
164
|
-
|
|
191
|
+
// 4a. Observe review state (alongside CI) for composite gate evaluation.
|
|
192
|
+
// A review poll failure must not block the CI observation — fail open here.
|
|
193
|
+
let reviewSnapshot = null;
|
|
194
|
+
try {
|
|
195
|
+
const reviewObservation = await observeReviewWithResolved(binding, access, gateConfig, {
|
|
196
|
+
emitIfNew: deps.emitIfNew ?? emitConductorEventIfNew,
|
|
197
|
+
env: deps.env,
|
|
198
|
+
});
|
|
199
|
+
reviewSnapshot = reviewObservation.snapshot;
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
// fail open: a review observation error does not block CI/gate evaluation
|
|
203
|
+
reviewSnapshot = null;
|
|
204
|
+
}
|
|
205
|
+
const evaluation = evaluateDoneGate(gateConfig, binding, snapshot, now(), reviewSnapshot);
|
|
165
206
|
if (!evaluation.met) {
|
|
166
207
|
result.reason = evaluation.reason;
|
|
167
208
|
return result;
|
|
168
209
|
}
|
|
169
210
|
result.gate_met = true;
|
|
170
|
-
const gateEvent = buildGateMetEventInput(binding, evaluation);
|
|
211
|
+
const gateEvent = buildGateMetEventInput(binding, evaluation, run_id, worker_id);
|
|
171
212
|
if (gateEvent !== null) {
|
|
172
213
|
const gateDecision = emitIfNew(gateEvent, {
|
|
173
214
|
event_type: "gate.met",
|
|
@@ -190,7 +231,7 @@ async function observeWithResolved(binding, access, gateConfig, deps) {
|
|
|
190
231
|
export async function observePrCiOnce(params = {}, deps = {}) {
|
|
191
232
|
const resolveBinding = deps.resolveBinding ?? resolvePrHeadBinding;
|
|
192
233
|
const resolveAccess = deps.resolveAccess ?? (() => resolveConductorBridgeApiAccess({ env: deps.env, cwd: deps.cwd }));
|
|
193
|
-
const fetchGateConfig = deps.fetchGateConfig ??
|
|
234
|
+
const fetchGateConfig = deps.fetchGateConfig ?? _fetchGateConfigDefault;
|
|
194
235
|
const bindingResult = resolveBinding({ repoName: params.repoName, prNumber: params.prNumber, headSha: params.headSha, cwd: params.cwd ?? deps.cwd, env: deps.env }, deps.bindingDeps ?? {});
|
|
195
236
|
if (!bindingResult.ok) {
|
|
196
237
|
return {
|
|
@@ -239,7 +280,7 @@ function clampInt(value, fallback, min, max) {
|
|
|
239
280
|
export async function waitForDoneGate(params = {}, deps = {}) {
|
|
240
281
|
const resolveBinding = deps.resolveBinding ?? resolvePrHeadBinding;
|
|
241
282
|
const resolveAccess = deps.resolveAccess ?? (() => resolveConductorBridgeApiAccess({ env: deps.env, cwd: params.worktreePath ?? deps.cwd }));
|
|
242
|
-
const fetchGateConfig = deps.fetchGateConfig ??
|
|
283
|
+
const fetchGateConfig = deps.fetchGateConfig ?? _fetchGateConfigDefault;
|
|
243
284
|
const sleep = deps.sleep ?? defaultSleep;
|
|
244
285
|
const now = deps.now ?? (() => new Date().toISOString());
|
|
245
286
|
const timeoutMs = clampInt(params.timeoutMs, WAIT_FOR_GATE_TIMEOUT_DEFAULT_MS, 0, WAIT_FOR_GATE_TIMEOUT_MAX_MS);
|
|
@@ -355,6 +396,10 @@ export async function observePrCiFromPollResponse(commitRef, pollResponse, deps
|
|
|
355
396
|
reason: "commit ref does not match PR head",
|
|
356
397
|
};
|
|
357
398
|
}
|
|
399
|
+
// Env-stamp identity; fall back to the resolution seam when env is absent.
|
|
400
|
+
// Access resolution for the seam is attempted inline within the gate block.
|
|
401
|
+
let run_id = deps.env?.BAPI_CONDUCTOR_RUN_ID?.trim() || null;
|
|
402
|
+
const worker_id = deps.env?.BAPI_CONDUCTOR_WORKER_ID?.trim() || null;
|
|
358
403
|
const result = {
|
|
359
404
|
binding,
|
|
360
405
|
pr_opened_emitted: false,
|
|
@@ -364,7 +409,7 @@ export async function observePrCiFromPollResponse(commitRef, pollResponse, deps
|
|
|
364
409
|
gate_emitted: false,
|
|
365
410
|
reason: "observed",
|
|
366
411
|
};
|
|
367
|
-
const prDecision = emitIfNew(buildPrOpenedEventInput(binding), {
|
|
412
|
+
const prDecision = emitIfNew(buildPrOpenedEventInput(binding, run_id, worker_id), {
|
|
368
413
|
event_type: "git.pr_opened",
|
|
369
414
|
repo: binding.repo,
|
|
370
415
|
pr_number: binding.pr_number,
|
|
@@ -372,7 +417,7 @@ export async function observePrCiFromPollResponse(commitRef, pollResponse, deps
|
|
|
372
417
|
});
|
|
373
418
|
result.pr_opened_emitted = prDecision.emitted;
|
|
374
419
|
const snapshot = normalizeCiSnapshot(pollResponse);
|
|
375
|
-
const ciEvent = buildCiObservationEventInput(binding, snapshot);
|
|
420
|
+
const ciEvent = buildCiObservationEventInput(binding, snapshot, run_id, worker_id);
|
|
376
421
|
if (ciEvent === null) {
|
|
377
422
|
result.ci_status = "pending";
|
|
378
423
|
return result;
|
|
@@ -388,13 +433,23 @@ export async function observePrCiFromPollResponse(commitRef, pollResponse, deps
|
|
|
388
433
|
result.ci_emitted = ciDecision.emitted;
|
|
389
434
|
// Best-effort gate evaluation when access + config are available.
|
|
390
435
|
const resolveAccess = deps.resolveAccess ?? (() => resolveConductorBridgeApiAccess({ env: deps.env, cwd: deps.cwd }));
|
|
391
|
-
const fetchGateConfig = deps.fetchGateConfig ??
|
|
436
|
+
const fetchGateConfig = deps.fetchGateConfig ?? _fetchGateConfigDefault;
|
|
392
437
|
try {
|
|
393
438
|
const accessResult = await resolveAccess();
|
|
394
439
|
if (accessResult.ok) {
|
|
440
|
+
const access = accessResult.access;
|
|
441
|
+
// If run_id is still null, try the resolution seam now that we have access.
|
|
442
|
+
if (run_id === null && deps.resolveRunId) {
|
|
443
|
+
try {
|
|
444
|
+
run_id = await deps.resolveRunId(access, binding) ?? null;
|
|
445
|
+
}
|
|
446
|
+
catch {
|
|
447
|
+
run_id = null;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
395
450
|
let rawConfig;
|
|
396
451
|
try {
|
|
397
|
-
rawConfig = await fetchGateConfig(
|
|
452
|
+
rawConfig = await fetchGateConfig(access);
|
|
398
453
|
}
|
|
399
454
|
catch {
|
|
400
455
|
rawConfig = undefined;
|
|
@@ -404,7 +459,7 @@ export async function observePrCiFromPollResponse(commitRef, pollResponse, deps
|
|
|
404
459
|
const evaluation = evaluateDoneGate(gateConfig, binding, snapshot, now());
|
|
405
460
|
if (evaluation.met) {
|
|
406
461
|
result.gate_met = true;
|
|
407
|
-
const gateEvent = buildGateMetEventInput(binding, evaluation);
|
|
462
|
+
const gateEvent = buildGateMetEventInput(binding, evaluation, run_id, worker_id);
|
|
408
463
|
if (gateEvent !== null) {
|
|
409
464
|
const gateDecision = emitIfNew(gateEvent, {
|
|
410
465
|
event_type: "gate.met",
|
|
@@ -425,3 +480,47 @@ export async function observePrCiFromPollResponse(commitRef, pollResponse, deps
|
|
|
425
480
|
}
|
|
426
481
|
return result;
|
|
427
482
|
}
|
|
483
|
+
// ---------------------------------------------------------------------------
|
|
484
|
+
// Dispatch run_id resolution seam (Approach B: binding → ticket → dispatch)
|
|
485
|
+
// ---------------------------------------------------------------------------
|
|
486
|
+
/** Extract a Jira ticket key from a git branch name, e.g. `feature/BAPI-437-slug` → `BAPI-437`. */
|
|
487
|
+
function extractTicketKeyFromRef(headRef) {
|
|
488
|
+
if (!headRef)
|
|
489
|
+
return null;
|
|
490
|
+
const match = /([A-Z][A-Z0-9]+-\d+)/i.exec(headRef);
|
|
491
|
+
return match ? match[1].toUpperCase() : null;
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* Resolve the dispatch `run_id` for a PR binding by extracting the ticket key
|
|
495
|
+
* from `binding.head_ref`, listing active epic runs, and searching each epic's
|
|
496
|
+
* dispatch rows for a match. Returns `null` when the lookup yields no result or
|
|
497
|
+
* any step fails — the producer never crashes on a resolution failure.
|
|
498
|
+
*
|
|
499
|
+
* Intended to be injected as `PrCiProducerDeps.resolveRunId` at call sites
|
|
500
|
+
* that lack `BAPI_CONDUCTOR_RUN_ID` in the environment (git-hook / CI-poll
|
|
501
|
+
* contexts). A production `fetchImpl` is optional; defaults to the global fetch.
|
|
502
|
+
*/
|
|
503
|
+
export async function resolveDispatchRunIdForBinding(access, binding, fetchImpl = fetch) {
|
|
504
|
+
const ticketKey = extractTicketKeyFromRef(binding.head_ref);
|
|
505
|
+
if (!ticketKey)
|
|
506
|
+
return null;
|
|
507
|
+
let activeRuns;
|
|
508
|
+
try {
|
|
509
|
+
activeRuns = await fetchActiveEpicRuns(access, fetchImpl);
|
|
510
|
+
}
|
|
511
|
+
catch {
|
|
512
|
+
return null;
|
|
513
|
+
}
|
|
514
|
+
for (const run of activeRuns) {
|
|
515
|
+
try {
|
|
516
|
+
const state = await fetchEpicRunState(access, run.epic_key, fetchImpl);
|
|
517
|
+
const dispatch = state.dispatches.find((d) => d.ticket_key === ticketKey && d.run_id !== null);
|
|
518
|
+
if (dispatch?.run_id)
|
|
519
|
+
return dispatch.run_id;
|
|
520
|
+
}
|
|
521
|
+
catch {
|
|
522
|
+
// Skip this epic on any error; try the next one.
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
return null;
|
|
526
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PR review event production (BAPI-440).
|
|
3
|
+
*
|
|
4
|
+
* Polls the backend-owned review-status endpoint and emits `review.passed` /
|
|
5
|
+
* `review.changes_requested` exactly once per stable review state (deduped by
|
|
6
|
+
* review_state_hash). A poll failure never emits `review.changes_requested`
|
|
7
|
+
* (mirroring the CI rule that a failed poll is never a failed check).
|
|
8
|
+
*
|
|
9
|
+
* This module is pure of VCS credentials — the backend endpoint owns them.
|
|
10
|
+
*/
|
|
11
|
+
import { GIT_CI_PRODUCER, REVIEW_CHANGES_REQUESTED, REVIEW_PASSED, } from "./git-ci-types.js";
|
|
12
|
+
import { evaluateReviewCondition, normalizeReviewSnapshot } from "./done-gate.js";
|
|
13
|
+
import { fetchPrReviewStatus } from "./bridge-api-client.js";
|
|
14
|
+
import { emitConductorEventIfNew } from "./producer-ledger.js";
|
|
15
|
+
export const REVIEW_PRODUCER_OBSERVED_VIA = "pr-review-producer";
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Event builders
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
/**
|
|
20
|
+
* Build a `review.passed` or `review.changes_requested` event input from a
|
|
21
|
+
* normalized snapshot, or `null` when neither outcome is determinable.
|
|
22
|
+
*/
|
|
23
|
+
export function buildReviewObservationEventInput(binding, snapshot, eventType, reason, runId = null, workerId = null) {
|
|
24
|
+
return {
|
|
25
|
+
source: "review",
|
|
26
|
+
type: eventType,
|
|
27
|
+
subject: binding.subject,
|
|
28
|
+
run_id: runId,
|
|
29
|
+
worker_id: workerId,
|
|
30
|
+
producer: GIT_CI_PRODUCER,
|
|
31
|
+
observed_via: REVIEW_PRODUCER_OBSERVED_VIA,
|
|
32
|
+
data: {
|
|
33
|
+
summary: eventType === REVIEW_PASSED
|
|
34
|
+
? `Review passed for ${binding.subject}`
|
|
35
|
+
: `Review changes requested for ${binding.subject}`,
|
|
36
|
+
status: eventType === REVIEW_PASSED ? "passed" : "changes_requested",
|
|
37
|
+
details: {
|
|
38
|
+
repo: binding.repo,
|
|
39
|
+
pr_number: binding.pr_number,
|
|
40
|
+
head_sha: binding.head_sha,
|
|
41
|
+
review_decision: snapshot.review_decision,
|
|
42
|
+
approvals: snapshot.approvals,
|
|
43
|
+
sticky_verdict: snapshot.sticky_verdict,
|
|
44
|
+
review_state_hash: snapshot.review_state_hash,
|
|
45
|
+
reason,
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Observe the PR review state for an already-resolved binding/access/config.
|
|
52
|
+
* Polls the backend endpoint, normalizes the snapshot, evaluates the review
|
|
53
|
+
* condition, and emits the appropriate event through the dedupe layer.
|
|
54
|
+
*
|
|
55
|
+
* A poll failure or unavailable envelope returns `null` snapshot and emits
|
|
56
|
+
* nothing — it is never a `review.changes_requested`.
|
|
57
|
+
*/
|
|
58
|
+
export async function observeReviewWithResolved(binding, access, gateConfig, deps = {}) {
|
|
59
|
+
const fetchStatus = deps.fetchReviewStatus ?? fetchPrReviewStatus;
|
|
60
|
+
const emitIfNew = deps.emitIfNew ?? emitConductorEventIfNew;
|
|
61
|
+
const run_id = deps.env?.BAPI_CONDUCTOR_RUN_ID?.trim() || null;
|
|
62
|
+
const worker_id = deps.env?.BAPI_CONDUCTOR_WORKER_ID?.trim() || null;
|
|
63
|
+
const result = {
|
|
64
|
+
snapshot: null,
|
|
65
|
+
review_passed_emitted: false,
|
|
66
|
+
review_changes_requested_emitted: false,
|
|
67
|
+
reason: "observed",
|
|
68
|
+
};
|
|
69
|
+
// Find the review_state condition from the gate config (if present).
|
|
70
|
+
const reviewCondition = gateConfig.conditions.find((c) => c.type === "review_state") ?? null;
|
|
71
|
+
// CI-only gate: no review condition, skip the backend round-trip entirely.
|
|
72
|
+
if (reviewCondition === null) {
|
|
73
|
+
result.reason = "no-review-condition";
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
76
|
+
// Poll the backend endpoint. A thrown error means unavailable — fail open,
|
|
77
|
+
// emit nothing (mirroring CI producer rule: "a failed poll is never a failure").
|
|
78
|
+
let rawStatus;
|
|
79
|
+
try {
|
|
80
|
+
rawStatus = await fetchStatus(access, binding.pr_number);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
result.reason = "review-poll-failed";
|
|
84
|
+
return result;
|
|
85
|
+
}
|
|
86
|
+
const snapshot = normalizeReviewSnapshot(rawStatus);
|
|
87
|
+
result.snapshot = snapshot;
|
|
88
|
+
if (snapshot === null) {
|
|
89
|
+
result.reason = "review-snapshot-unavailable";
|
|
90
|
+
return result;
|
|
91
|
+
}
|
|
92
|
+
// Evaluate the review condition (reviewCondition is non-null — null is handled above).
|
|
93
|
+
const evalResult = evaluateReviewCondition(reviewCondition, snapshot);
|
|
94
|
+
const baseDimensions = {
|
|
95
|
+
repo: binding.repo,
|
|
96
|
+
pr_number: binding.pr_number,
|
|
97
|
+
head_sha: binding.head_sha,
|
|
98
|
+
review_state_hash: snapshot.review_state_hash,
|
|
99
|
+
};
|
|
100
|
+
if (evalResult.changesRequested) {
|
|
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 });
|
|
103
|
+
result.review_changes_requested_emitted = decision.emitted;
|
|
104
|
+
result.reason = "review changes requested";
|
|
105
|
+
}
|
|
106
|
+
else if (evalResult.passed) {
|
|
107
|
+
const event = buildReviewObservationEventInput(binding, snapshot, REVIEW_PASSED, evalResult.reason, run_id, worker_id);
|
|
108
|
+
const decision = emitIfNew(event, { event_type: REVIEW_PASSED, ...baseDimensions });
|
|
109
|
+
result.review_passed_emitted = decision.emitted;
|
|
110
|
+
result.reason = "review passed";
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
result.reason = `review not yet passed: ${evalResult.reason}`;
|
|
114
|
+
}
|
|
115
|
+
return result;
|
|
116
|
+
}
|
package/build/conductor/store.js
CHANGED
|
@@ -32,7 +32,7 @@ const RETENTION_MAX_ROWS_DEFAULT = 50_000;
|
|
|
32
32
|
const RETENTION_MAX_ROWS_MIN = 100;
|
|
33
33
|
const RETENTION_MAX_ROWS_MAX = 10_000_000;
|
|
34
34
|
const POLL_LIMIT_DEFAULT = 100;
|
|
35
|
-
const POLL_LIMIT_MAX = 1000;
|
|
35
|
+
export const POLL_LIMIT_MAX = 1000;
|
|
36
36
|
// Message relay per-type cooldown bounds (BAPI-397). A value <= 0 disables the
|
|
37
37
|
// cooldown; positive values are clamped to [1s, 24h].
|
|
38
38
|
const MESSAGE_COOLDOWN_DEFAULT_MS = 300_000;
|
|
@@ -552,6 +552,13 @@ function buildFilterClause(filter) {
|
|
|
552
552
|
conditions.push("run_id = @f_run_id");
|
|
553
553
|
params.f_run_id = filter.run_id;
|
|
554
554
|
}
|
|
555
|
+
if (filter.run_ids && filter.run_ids.length > 0) {
|
|
556
|
+
const placeholders = filter.run_ids.map((_r, i) => `@f_run_ids_${i}`);
|
|
557
|
+
filter.run_ids.forEach((r, i) => {
|
|
558
|
+
params[`f_run_ids_${i}`] = r;
|
|
559
|
+
});
|
|
560
|
+
conditions.push(`run_id IN (${placeholders.join(", ")})`);
|
|
561
|
+
}
|
|
555
562
|
if (filter.worker_id) {
|
|
556
563
|
conditions.push("worker_id = @f_worker_id");
|
|
557
564
|
params.f_worker_id = filter.worker_id;
|
|
@@ -47,6 +47,37 @@ export function buildSupervisorEscalationWorkerMessage(candidate, assessment, st
|
|
|
47
47
|
producer: "worker-message-relay",
|
|
48
48
|
};
|
|
49
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* Build the remediation NUDGE relay message for a blocked epic ticket whose
|
|
52
|
+
* worker is still alive. Mirrors {@link buildSupervisorEscalationWorkerMessage}'s
|
|
53
|
+
* allowlisted top-level keys (`summary`, `status`, `details`) and `cause_seq`
|
|
54
|
+
* idempotency contract. `status` is fixed to `"remediation_requested"`; all
|
|
55
|
+
* remediation detail (reason, attempt, the bounded review digest, and its
|
|
56
|
+
* truncation flag) is nested under `details`. The `review_digest` text is passed
|
|
57
|
+
* through unchanged — redaction/size-capping is the backend's responsibility.
|
|
58
|
+
*/
|
|
59
|
+
export function buildSupervisorRemediationWorkerMessage(input) {
|
|
60
|
+
const messageType = input.reason === "ci.failed" ? "supervisor.ci_failed" : "supervisor.changes_requested";
|
|
61
|
+
const summaryLabel = input.reason === "ci.failed" ? "ci failed" : "review changes requested";
|
|
62
|
+
return {
|
|
63
|
+
run_id: input.runId,
|
|
64
|
+
worker_id: input.workerId,
|
|
65
|
+
type: messageType,
|
|
66
|
+
cause_seq: input.causeSeq,
|
|
67
|
+
payload: {
|
|
68
|
+
summary: `${summaryLabel}: ${input.ticketKey}`,
|
|
69
|
+
status: "remediation_requested",
|
|
70
|
+
details: {
|
|
71
|
+
reason: input.reason,
|
|
72
|
+
attempt: input.attempt,
|
|
73
|
+
review_digest: input.reviewDigest,
|
|
74
|
+
truncated: input.truncated,
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
source: "conductor-supervisor",
|
|
78
|
+
producer: "worker-message-relay",
|
|
79
|
+
};
|
|
80
|
+
}
|
|
50
81
|
/**
|
|
51
82
|
* Build the escalation message and enqueue it through {@link sendWorkerMessage}.
|
|
52
83
|
* The store result already encodes the expected idempotency/cooldown outcomes
|
|
@@ -35,6 +35,9 @@ export const SEMANTIC_EVENT_TYPES = [
|
|
|
35
35
|
"merge.succeeded",
|
|
36
36
|
"merge.failed",
|
|
37
37
|
"merge.pending_approval",
|
|
38
|
+
// BAPI-440 PR review-state telemetry event types.
|
|
39
|
+
"review.passed",
|
|
40
|
+
"review.changes_requested",
|
|
38
41
|
];
|
|
39
42
|
/**
|
|
40
43
|
* Type guard: returns `true` only when `value` is one of the exact taxonomy
|