@bridge_gpt/mcp-server 0.2.2 → 0.2.4
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 +97 -15
- package/build/agent-config-credential-migration.js +272 -0
- package/build/agents.generated.js +1 -1
- package/build/chain-orchestrator.js +16 -1
- package/build/commands.generated.js +9 -7
- package/build/conductor/bridge-api-client.js +625 -0
- package/build/conductor/claude-hook.js +251 -0
- package/build/conductor/cli.js +1048 -0
- package/build/conductor/data-normalization.js +114 -0
- package/build/conductor/doctor.js +164 -0
- package/build/conductor/done-gate.js +325 -0
- package/build/conductor/epic-reconcile.js +139 -0
- package/build/conductor/epic-runtime.js +611 -0
- package/build/conductor/epic-state.js +125 -0
- package/build/conductor/errors.js +85 -0
- package/build/conductor/git-ci-types.js +129 -0
- package/build/conductor/git-hooks.js +218 -0
- package/build/conductor/git-inspection.js +185 -0
- package/build/conductor/git-producer.js +137 -0
- package/build/conductor/merge-ledger.js +198 -0
- package/build/conductor/paths.js +224 -0
- package/build/conductor/plan.js +77 -0
- package/build/conductor/pr-ci-producer.js +427 -0
- package/build/conductor/pr-discovery.js +135 -0
- package/build/conductor/producer-ledger.js +125 -0
- package/build/conductor/redaction.js +112 -0
- package/build/conductor/store.js +1156 -0
- package/build/conductor/supervisor-config.js +150 -0
- package/build/conductor/supervisor-escalation.js +244 -0
- package/build/conductor/supervisor-judgment-python.js +141 -0
- package/build/conductor/supervisor-judgment.js +215 -0
- package/build/conductor/supervisor-ledger.js +119 -0
- package/build/conductor/supervisor-merge.js +127 -0
- package/build/conductor/supervisor-message-relay.js +61 -0
- package/build/conductor/supervisor-notification.js +39 -0
- package/build/conductor/supervisor-runtime.js +351 -0
- package/build/conductor/supervisor-state.js +572 -0
- package/build/conductor/supervisor-types.js +16 -0
- package/build/conductor/taxonomy.js +58 -0
- package/build/conductor/tools.js +367 -0
- package/build/conductor/types.js +9 -0
- package/build/conductor-bin.js +21 -0
- package/build/conductor-claude-hook-bin.js +21 -0
- package/build/credential-store.js +175 -4
- package/build/credentials-cli.js +223 -0
- package/build/decision-page-schema.js +60 -0
- package/build/decision-page-template.js +262 -10
- package/build/doctor.js +5 -1
- package/build/index.js +554 -66
- package/build/pipeline-orchestrator.js +5 -1
- package/build/pipeline-utils.js +45 -5
- package/build/pipelines.generated.js +37 -9
- package/build/readme.generated.js +1 -1
- package/build/review-tickets.js +596 -0
- package/build/scheduled-prompt.js +16 -10
- package/build/start-tickets-conductor.js +496 -0
- package/build/start-tickets-prereqs.js +32 -23
- package/build/start-tickets-repo.js +49 -0
- package/build/start-tickets.js +682 -81
- package/build/version.generated.js +1 -1
- package/design-assets/favicon/android-chrome-192x192.png +0 -0
- package/design-assets/favicon/android-chrome-512x512.png +0 -0
- package/design-assets/favicon/apple-touch-icon.png +0 -0
- package/design-assets/favicon/favicon-16x16.png +0 -0
- package/design-assets/favicon/favicon-32x32.png +0 -0
- package/design-assets/favicon/favicon.ico +0 -0
- package/design-assets/favicon/site.webmanifest +1 -0
- package/design-assets/just-logo-rough-draft.png +0 -0
- package/package.json +17 -5
- package/pipelines/idea-to-ticket.json +5 -0
- package/pipelines/plan-epic.json +16 -1
- package/pipelines/review-ticket.json +2 -1
- package/public/css/main.min.css +2 -0
- package/public/css/main.min.css.map +1 -0
- package/public/fonts/OFL.txt +93 -0
- package/public/fonts/SourceSansPro-Black.ttf +0 -0
- package/public/fonts/SourceSansPro-BlackItalic.ttf +0 -0
- package/public/fonts/SourceSansPro-Bold.ttf +0 -0
- package/public/fonts/SourceSansPro-BoldItalic.ttf +0 -0
- package/public/fonts/SourceSansPro-ExtraLight.ttf +0 -0
- package/public/fonts/SourceSansPro-ExtraLightItalic.ttf +0 -0
- package/public/fonts/SourceSansPro-Italic.ttf +0 -0
- package/public/fonts/SourceSansPro-Light.ttf +0 -0
- package/public/fonts/SourceSansPro-LightItalic.ttf +0 -0
- package/public/fonts/SourceSansPro-Regular.ttf +0 -0
- package/public/fonts/SourceSansPro-SemiBold.ttf +0 -0
- package/public/fonts/SourceSansPro-SemiBoldItalic.ttf +0 -0
- package/public/img/bridge-logo-160x51.webp +0 -0
- package/public/img/bridge-logo-300x92.webp +0 -0
- package/public/img/favicon/android-chrome-192x192.png +0 -0
- package/public/img/favicon/android-chrome-512x512.png +0 -0
- package/public/img/favicon/apple-touch-icon.png +0 -0
- package/public/img/favicon/favicon-16x16.png +0 -0
- package/public/img/favicon/favicon-32x32.png +0 -0
- package/public/img/favicon/favicon.ico +0 -0
- package/public/img/favicon/site.webmanifest +1 -0
- package/public/img/installation/bitbucket/app-password-1.png +0 -0
- package/public/img/installation/bitbucket/app-password-2.png +0 -0
- package/public/img/installation/bitbucket/create-token-1.png +0 -0
- package/public/img/installation/bitbucket/create-token-2.png +0 -0
- package/public/img/installation/bitbucket/webhook-1.png +0 -0
- package/public/img/installation/github/github-review-webhook.png +0 -0
- package/public/img/installation/jira/credentials/api-key.png +0 -0
- package/public/img/installation/jira/webhook/create-rule.png +0 -0
- package/public/img/installation/jira/webhook/project-settings.png +0 -0
- package/public/img/installation/jira/webhook/rule-create-1.png +0 -0
- package/public/img/installation/jira/webhook/rule-create-2.png +0 -0
- package/public/img/installation/jira/webhook/rule-create-3.png +0 -0
- package/public/img/installation/pinecone/pinecone-api-key.png +0 -0
- package/public/img/installation/pinecone/pinecone-index.png +0 -0
- package/public/js/main.min.js +2 -0
- package/public/js/main.min.js.map +1 -0
- package/smoke-test/SMOKE-TEST.md +17 -9
|
@@ -0,0 +1,572 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic supervisor state reduction + housekeeping (BAPI-396, conductor
|
|
3
|
+
* C4).
|
|
4
|
+
*
|
|
5
|
+
* This module converts raw conductor ledger events into per-worker watchdog
|
|
6
|
+
* state and run-level projection state. It is PURE and deterministic: it has no
|
|
7
|
+
* I/O, no timers, and no LLM calls. Every time-dependent function takes an
|
|
8
|
+
* explicit `now` (epoch ms) so tests are wall-clock independent.
|
|
9
|
+
*
|
|
10
|
+
* Truth precedence (enforced here): raw events are the source of truth. A
|
|
11
|
+
* `supervisor.assessment` event is audit-only and NEVER overrides a worker's
|
|
12
|
+
* raw-derived state. Terminal states (`complete` / `failed`) are sticky and are
|
|
13
|
+
* only replaced by a stronger explicit raw terminal event.
|
|
14
|
+
*/
|
|
15
|
+
/** Marker stored on the projection summary so hydration can validate it. */
|
|
16
|
+
export const SUPERVISOR_SUMMARY_KIND = "supervisor_projection_summary";
|
|
17
|
+
const TERMINAL_STATES = new Set(["complete", "failed"]);
|
|
18
|
+
/** Is this worker in a sticky terminal state? */
|
|
19
|
+
function isTerminalState(state) {
|
|
20
|
+
return TERMINAL_STATES.has(state);
|
|
21
|
+
}
|
|
22
|
+
/** Safe ISO -> epoch ms (returns null on unparseable input). */
|
|
23
|
+
function isoToMs(value) {
|
|
24
|
+
if (typeof value !== "string" || value.length === 0)
|
|
25
|
+
return null;
|
|
26
|
+
const ms = Date.parse(value);
|
|
27
|
+
return Number.isFinite(ms) ? ms : null;
|
|
28
|
+
}
|
|
29
|
+
/** Epoch ms -> ISO string. */
|
|
30
|
+
function msToIso(now) {
|
|
31
|
+
return new Date(now).toISOString();
|
|
32
|
+
}
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Construction & hydration
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
/**
|
|
37
|
+
* Initialize an empty {@link SupervisorRunState} scoped to exactly one run. The
|
|
38
|
+
* global deadline is `now + config.global_timeout_ms`.
|
|
39
|
+
*/
|
|
40
|
+
export function createEmptySupervisorRunState(runId, config, now) {
|
|
41
|
+
const startedIso = msToIso(now);
|
|
42
|
+
return {
|
|
43
|
+
run_id: runId,
|
|
44
|
+
status: "unknown",
|
|
45
|
+
last_seq: 0,
|
|
46
|
+
last_event_time: null,
|
|
47
|
+
workers: {},
|
|
48
|
+
gates: {},
|
|
49
|
+
latest_assessment: null,
|
|
50
|
+
escalations: [],
|
|
51
|
+
llm_budget: {
|
|
52
|
+
enabled: config.llm_enabled,
|
|
53
|
+
max_calls: config.llm_max_calls,
|
|
54
|
+
used_calls: 0,
|
|
55
|
+
},
|
|
56
|
+
started_at: startedIso,
|
|
57
|
+
updated_at: startedIso,
|
|
58
|
+
global_deadline_at: msToIso(now + config.global_timeout_ms),
|
|
59
|
+
roster_discovered: false,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/** Is a parsed summary object a plausible supervisor summary? */
|
|
63
|
+
function isValidSupervisorSummary(value) {
|
|
64
|
+
return (value !== null &&
|
|
65
|
+
typeof value === "object" &&
|
|
66
|
+
!Array.isArray(value) &&
|
|
67
|
+
value.kind === SUPERVISOR_SUMMARY_KIND);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Hydrate run state from a supervisor snapshot's projection summary. ALWAYS
|
|
71
|
+
* enforces the requested `runId` — a summary persisted under a different run is
|
|
72
|
+
* never allowed to take ownership. Falls back to {@link
|
|
73
|
+
* createEmptySupervisorRunState} when the snapshot is missing or malformed.
|
|
74
|
+
*/
|
|
75
|
+
export function hydrateSupervisorRunStateFromSnapshot(snapshot, runId, config, now) {
|
|
76
|
+
const empty = createEmptySupervisorRunState(runId, config, now);
|
|
77
|
+
const summary = snapshot?.projection?.summary;
|
|
78
|
+
if (!isValidSupervisorSummary(summary)) {
|
|
79
|
+
return empty;
|
|
80
|
+
}
|
|
81
|
+
// Merge persisted fields on top of the empty scaffold, then HARD-OVERRIDE the
|
|
82
|
+
// run id so a cross-run summary can never be hydrated under this run.
|
|
83
|
+
const hydrated = {
|
|
84
|
+
...empty,
|
|
85
|
+
status: typeof summary.status === "string" ? summary.status : empty.status,
|
|
86
|
+
last_seq: typeof summary.last_seq === "number" && summary.last_seq >= 0 ? summary.last_seq : empty.last_seq,
|
|
87
|
+
last_event_time: typeof summary.last_event_time === "string" ? summary.last_event_time : null,
|
|
88
|
+
workers: isPlainRecord(summary.workers) ? summary.workers : {},
|
|
89
|
+
gates: isPlainRecord(summary.gates) ? summary.gates : {},
|
|
90
|
+
latest_assessment: summary.latest_assessment && typeof summary.latest_assessment === "object"
|
|
91
|
+
? summary.latest_assessment
|
|
92
|
+
: null,
|
|
93
|
+
escalations: Array.isArray(summary.escalations)
|
|
94
|
+
? summary.escalations
|
|
95
|
+
: [],
|
|
96
|
+
llm_budget: summary.llm_budget && typeof summary.llm_budget === "object"
|
|
97
|
+
? {
|
|
98
|
+
enabled: config.llm_enabled,
|
|
99
|
+
max_calls: config.llm_max_calls,
|
|
100
|
+
used_calls: typeof summary.llm_budget.used_calls === "number"
|
|
101
|
+
? summary.llm_budget.used_calls
|
|
102
|
+
: 0,
|
|
103
|
+
}
|
|
104
|
+
: empty.llm_budget,
|
|
105
|
+
started_at: typeof summary.started_at === "string" ? summary.started_at : empty.started_at,
|
|
106
|
+
global_deadline_at: typeof summary.global_deadline_at === "string" ? summary.global_deadline_at : empty.global_deadline_at,
|
|
107
|
+
roster_discovered: summary.roster_discovered === true,
|
|
108
|
+
run_id: runId,
|
|
109
|
+
};
|
|
110
|
+
return hydrated;
|
|
111
|
+
}
|
|
112
|
+
function isPlainRecord(value) {
|
|
113
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Return the existing worker state for `workerId`, creating it if absent. A
|
|
117
|
+
* roster-discovered worker starts `not_started`; a worker first seen via an
|
|
118
|
+
* arbitrary event starts `unknown`. Existing workers are returned as-is —
|
|
119
|
+
* terminal states are preserved (never downgraded by mere rediscovery).
|
|
120
|
+
*/
|
|
121
|
+
export function ensureWorkerState(state, workerId, options = {}) {
|
|
122
|
+
const existing = state.workers[workerId];
|
|
123
|
+
if (existing) {
|
|
124
|
+
if (options.ticketKey && !existing.ticket_key) {
|
|
125
|
+
existing.ticket_key = options.ticketKey;
|
|
126
|
+
}
|
|
127
|
+
return existing;
|
|
128
|
+
}
|
|
129
|
+
const created = {
|
|
130
|
+
worker_id: workerId,
|
|
131
|
+
ticket_key: options.ticketKey ?? null,
|
|
132
|
+
state: options.fromRoster ? "not_started" : "unknown",
|
|
133
|
+
liveness: "unknown",
|
|
134
|
+
first_seen_seq: options.seq ?? null,
|
|
135
|
+
last_event_seq: options.seq ?? null,
|
|
136
|
+
last_event_time: null,
|
|
137
|
+
last_progress_time: null,
|
|
138
|
+
last_heartbeat_time: null,
|
|
139
|
+
blocked_reason: null,
|
|
140
|
+
terminal_reason: null,
|
|
141
|
+
observed_event_types: [],
|
|
142
|
+
};
|
|
143
|
+
state.workers[workerId] = created;
|
|
144
|
+
return created;
|
|
145
|
+
}
|
|
146
|
+
/** Record that a worker observed `eventType` (deduped, bounded list). */
|
|
147
|
+
function noteObservedType(worker, eventType) {
|
|
148
|
+
if (!worker.observed_event_types.includes(eventType)) {
|
|
149
|
+
worker.observed_event_types.push(eventType);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/** Extract a normalized `data.details` object from an event, if present. */
|
|
153
|
+
function eventDetails(event) {
|
|
154
|
+
const details = event.data?.details;
|
|
155
|
+
return isPlainRecord(details) ? details : {};
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Extract a worker roster from a `run.started` event. Supports rosters at
|
|
159
|
+
* `data.details.workers`, `data.workers`, or `data.raw.workers`. Each entry is
|
|
160
|
+
* read for `worker_id` and `ticket_key` only (compact, secret-free).
|
|
161
|
+
*/
|
|
162
|
+
function extractRoster(event) {
|
|
163
|
+
const candidates = [
|
|
164
|
+
eventDetails(event).workers,
|
|
165
|
+
event.data?.workers,
|
|
166
|
+
isPlainRecord(event.data?.raw) ? event.data.raw.workers : undefined,
|
|
167
|
+
];
|
|
168
|
+
for (const candidate of candidates) {
|
|
169
|
+
if (Array.isArray(candidate)) {
|
|
170
|
+
const roster = [];
|
|
171
|
+
for (const entry of candidate) {
|
|
172
|
+
if (!isPlainRecord(entry))
|
|
173
|
+
continue;
|
|
174
|
+
const workerId = entry.worker_id;
|
|
175
|
+
if (typeof workerId !== "string" || workerId.length === 0)
|
|
176
|
+
continue;
|
|
177
|
+
const ticketKey = entry.ticket_key;
|
|
178
|
+
roster.push({
|
|
179
|
+
worker_id: workerId,
|
|
180
|
+
ticket_key: typeof ticketKey === "string" ? ticketKey : null,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
if (roster.length > 0)
|
|
184
|
+
return roster;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return [];
|
|
188
|
+
}
|
|
189
|
+
/** Lowercased `data.status` from an event, or "". */
|
|
190
|
+
function eventStatus(event) {
|
|
191
|
+
const status = event.data?.status;
|
|
192
|
+
return typeof status === "string" ? status.trim().toLowerCase() : "";
|
|
193
|
+
}
|
|
194
|
+
/** Lowercased `data.reason` (or details.reason) from an event, or "". */
|
|
195
|
+
function eventReason(event) {
|
|
196
|
+
const reason = event.data?.reason ?? eventDetails(event).reason;
|
|
197
|
+
return typeof reason === "string" ? reason.trim().toLowerCase() : "";
|
|
198
|
+
}
|
|
199
|
+
const PROGRESS_EVENT_TYPES = new Set([
|
|
200
|
+
"tool.intent",
|
|
201
|
+
"worktree.changed",
|
|
202
|
+
"git.commit_created",
|
|
203
|
+
]);
|
|
204
|
+
const BLOCKED_STATUS_TOKENS = new Set(["blocked", "waiting_for_input", "needs_input"]);
|
|
205
|
+
/** Statuses on `run.stopped` that mean the worker failed (vs. completed). */
|
|
206
|
+
const FAILED_STATUS_TOKENS = new Set(["failed", "error", "errored", "aborted", "cancelled", "canceled"]);
|
|
207
|
+
/**
|
|
208
|
+
* Apply ONE raw conductor event to the supervisor state, mutating and returning
|
|
209
|
+
* it. Events whose `run_id` does not match are ignored WITHOUT advancing
|
|
210
|
+
* `last_seq`. Matching events advance `last_seq` (monotonic) and update
|
|
211
|
+
* `last_event_time`. Raw events are authoritative; `supervisor.assessment` is
|
|
212
|
+
* treated as audit input only.
|
|
213
|
+
*/
|
|
214
|
+
export function applyConductorEventToSupervisorState(state, event, now) {
|
|
215
|
+
// Run scoping: ignore foreign-run events entirely.
|
|
216
|
+
if (event.run_id !== state.run_id) {
|
|
217
|
+
return state;
|
|
218
|
+
}
|
|
219
|
+
const eventType = event.type;
|
|
220
|
+
const eventTimeMs = isoToMs(event.time) ?? now;
|
|
221
|
+
const eventTimeIso = event.time ?? msToIso(now);
|
|
222
|
+
// Advance the cursor and run-level last event time.
|
|
223
|
+
if (typeof event.seq === "number" && event.seq > state.last_seq) {
|
|
224
|
+
state.last_seq = event.seq;
|
|
225
|
+
}
|
|
226
|
+
state.last_event_time = eventTimeIso;
|
|
227
|
+
if (state.status === "unknown")
|
|
228
|
+
state.status = "active";
|
|
229
|
+
// run.started: discover the roster. Workers are created `not_started`.
|
|
230
|
+
if (eventType === "run.started") {
|
|
231
|
+
const roster = extractRoster(event);
|
|
232
|
+
if (roster.length > 0)
|
|
233
|
+
state.roster_discovered = true;
|
|
234
|
+
for (const member of roster) {
|
|
235
|
+
const worker = ensureWorkerState(state, member.worker_id, {
|
|
236
|
+
fromRoster: true,
|
|
237
|
+
ticketKey: member.ticket_key,
|
|
238
|
+
seq: event.seq,
|
|
239
|
+
});
|
|
240
|
+
noteObservedType(worker, eventType);
|
|
241
|
+
worker.last_event_seq = event.seq ?? worker.last_event_seq;
|
|
242
|
+
worker.last_event_time = eventTimeIso;
|
|
243
|
+
}
|
|
244
|
+
state.updated_at = msToIso(now);
|
|
245
|
+
return state;
|
|
246
|
+
}
|
|
247
|
+
// supervisor.assessment and message.sent are SUPERVISOR-side audit events and
|
|
248
|
+
// must NOT advance the target worker's liveness/stall anchor. message.sent is
|
|
249
|
+
// emitted scoped to the *target* worker (so the relay can attribute it), so
|
|
250
|
+
// without this guard the very act of sending an escalation to a stalled worker
|
|
251
|
+
// would stamp that worker with a fresh last_event_time — making it look `alive`
|
|
252
|
+
// on the next housekeeping pass and resetting its stall anchor, defeating the
|
|
253
|
+
// watchdog. (message.delivered / message.acked ARE the worker's own activity and
|
|
254
|
+
// are handled as liveness signals in the worker switch below — BAPI-397.)
|
|
255
|
+
if (eventType === "supervisor.assessment" || eventType === "message.sent") {
|
|
256
|
+
state.updated_at = msToIso(now);
|
|
257
|
+
return state;
|
|
258
|
+
}
|
|
259
|
+
// Events with a worker_id mutate that worker; otherwise run-level only.
|
|
260
|
+
const workerId = event.worker_id;
|
|
261
|
+
if (typeof workerId !== "string" || workerId.length === 0) {
|
|
262
|
+
applyRunLevelEvent(state, event, eventType);
|
|
263
|
+
state.updated_at = msToIso(now);
|
|
264
|
+
return state;
|
|
265
|
+
}
|
|
266
|
+
const worker = ensureWorkerState(state, workerId, { seq: event.seq });
|
|
267
|
+
noteObservedType(worker, eventType);
|
|
268
|
+
worker.last_event_seq = event.seq ?? worker.last_event_seq;
|
|
269
|
+
worker.last_event_time = eventTimeIso;
|
|
270
|
+
switch (eventType) {
|
|
271
|
+
case "run.heartbeat": {
|
|
272
|
+
worker.last_heartbeat_time = eventTimeIso;
|
|
273
|
+
if (!isTerminalState(worker.state) && worker.state !== "blocked") {
|
|
274
|
+
// A heartbeat alone is not "progress" but it does promote a not_started
|
|
275
|
+
// or unknown worker to active.
|
|
276
|
+
if (worker.state === "not_started" || worker.state === "unknown") {
|
|
277
|
+
worker.state = "active";
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
break;
|
|
281
|
+
}
|
|
282
|
+
case "agent.notification": {
|
|
283
|
+
const status = eventStatus(event);
|
|
284
|
+
const reason = eventReason(event);
|
|
285
|
+
if (BLOCKED_STATUS_TOKENS.has(status) || BLOCKED_STATUS_TOKENS.has(reason)) {
|
|
286
|
+
if (!isTerminalState(worker.state)) {
|
|
287
|
+
worker.state = "blocked";
|
|
288
|
+
worker.blocked_reason = status || reason || "blocked";
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
else if (!isTerminalState(worker.state) && worker.state === "not_started") {
|
|
292
|
+
// An ambiguous notification still indicates the worker is alive.
|
|
293
|
+
worker.state = "active";
|
|
294
|
+
}
|
|
295
|
+
break;
|
|
296
|
+
}
|
|
297
|
+
case "tool.intent":
|
|
298
|
+
case "worktree.changed":
|
|
299
|
+
case "git.commit_created": {
|
|
300
|
+
if (PROGRESS_EVENT_TYPES.has(eventType)) {
|
|
301
|
+
worker.last_progress_time = eventTimeIso;
|
|
302
|
+
if (!isTerminalState(worker.state)) {
|
|
303
|
+
// Deterministic progress clears a blocked/stalled marker and keeps the
|
|
304
|
+
// worker active.
|
|
305
|
+
if (worker.state === "not_started" || worker.state === "unknown" || worker.state === "stalled" || worker.state === "blocked") {
|
|
306
|
+
worker.state = "active";
|
|
307
|
+
}
|
|
308
|
+
worker.blocked_reason = null;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
break;
|
|
312
|
+
}
|
|
313
|
+
case "gate.met": {
|
|
314
|
+
if (!isTerminalState(worker.state)) {
|
|
315
|
+
worker.state = "candidate_done";
|
|
316
|
+
}
|
|
317
|
+
break;
|
|
318
|
+
}
|
|
319
|
+
case "ci.passed": {
|
|
320
|
+
if (!isTerminalState(worker.state)) {
|
|
321
|
+
worker.state = "verifying";
|
|
322
|
+
}
|
|
323
|
+
worker.last_progress_time = eventTimeIso;
|
|
324
|
+
break;
|
|
325
|
+
}
|
|
326
|
+
case "ci.failed": {
|
|
327
|
+
// Only an explicit, semantically-terminal worker-scoped CI failure marks a
|
|
328
|
+
// worker failed; otherwise keep it nonterminal (CI may retry).
|
|
329
|
+
const reason = eventReason(event);
|
|
330
|
+
const status = eventStatus(event);
|
|
331
|
+
if (status === "terminal" || reason === "terminal" || reason === "give_up") {
|
|
332
|
+
worker.state = "failed";
|
|
333
|
+
worker.terminal_reason = reason || "ci_failed";
|
|
334
|
+
}
|
|
335
|
+
else if (!isTerminalState(worker.state)) {
|
|
336
|
+
worker.last_progress_time = eventTimeIso;
|
|
337
|
+
}
|
|
338
|
+
break;
|
|
339
|
+
}
|
|
340
|
+
case "run.stopped": {
|
|
341
|
+
const status = eventStatus(event);
|
|
342
|
+
const reason = eventReason(event);
|
|
343
|
+
if (FAILED_STATUS_TOKENS.has(status) || FAILED_STATUS_TOKENS.has(reason)) {
|
|
344
|
+
worker.state = "failed";
|
|
345
|
+
worker.terminal_reason = reason || status || "failed";
|
|
346
|
+
}
|
|
347
|
+
else {
|
|
348
|
+
worker.state = "complete";
|
|
349
|
+
worker.terminal_reason = reason || status || "complete";
|
|
350
|
+
}
|
|
351
|
+
break;
|
|
352
|
+
}
|
|
353
|
+
case "message.delivered":
|
|
354
|
+
case "message.acked": {
|
|
355
|
+
// BAPI-397: a worker that polled + acked a supervisor relay message is
|
|
356
|
+
// demonstrably alive. Treat it as a LIVENESS signal (promote a
|
|
357
|
+
// not_started/unknown worker to active, like a heartbeat) but NOT as
|
|
358
|
+
// deterministic implementation progress: message relay types are
|
|
359
|
+
// deliberately absent from PROGRESS_EVENT_TYPES, so last_progress_time is
|
|
360
|
+
// never advanced here. Terminal states stay sticky (the guard below) and a
|
|
361
|
+
// blocked/stalled worker is not silently revived by an ack.
|
|
362
|
+
if (!isTerminalState(worker.state) &&
|
|
363
|
+
(worker.state === "not_started" || worker.state === "unknown")) {
|
|
364
|
+
worker.state = "active";
|
|
365
|
+
}
|
|
366
|
+
break;
|
|
367
|
+
}
|
|
368
|
+
case "merge.succeeded": {
|
|
369
|
+
// BAPI-398: a successful autonomous merge is the worker's payoff — mark it
|
|
370
|
+
// complete with an auto-merge terminal reason.
|
|
371
|
+
worker.state = "complete";
|
|
372
|
+
worker.terminal_reason = worker.terminal_reason || "merge_succeeded";
|
|
373
|
+
break;
|
|
374
|
+
}
|
|
375
|
+
case "merge.failed": {
|
|
376
|
+
// BAPI-398: merge.failed is RETRYABLE (head-SHA drift / CI-not-green /
|
|
377
|
+
// provider conflict can self-resolve on a new head SHA + action key). Record
|
|
378
|
+
// it as observed (noteObservedType above) but never mark the worker terminal.
|
|
379
|
+
break;
|
|
380
|
+
}
|
|
381
|
+
case "merge.dry_run": {
|
|
382
|
+
// BAPI-398: dry-run is audit-only (repo flag off / fail-closed). Keep the
|
|
383
|
+
// worker nonterminal.
|
|
384
|
+
break;
|
|
385
|
+
}
|
|
386
|
+
case "merge.pending_approval": {
|
|
387
|
+
// BAPI-413: pending_approval is nonterminal — the worker must stay active
|
|
388
|
+
// until the human redeems the token and the backend returns merge.succeeded.
|
|
389
|
+
break;
|
|
390
|
+
}
|
|
391
|
+
default:
|
|
392
|
+
break;
|
|
393
|
+
}
|
|
394
|
+
state.updated_at = msToIso(now);
|
|
395
|
+
return state;
|
|
396
|
+
}
|
|
397
|
+
/** Apply a run-level (no worker_id) event to gate/verification metadata. */
|
|
398
|
+
function applyRunLevelEvent(state, event, eventType) {
|
|
399
|
+
switch (eventType) {
|
|
400
|
+
case "gate.met":
|
|
401
|
+
state.gates.gate_met = true;
|
|
402
|
+
break;
|
|
403
|
+
case "ci.passed":
|
|
404
|
+
state.gates.ci = "passed";
|
|
405
|
+
break;
|
|
406
|
+
case "ci.failed":
|
|
407
|
+
state.gates.ci = "failed";
|
|
408
|
+
break;
|
|
409
|
+
case "git.pr_opened":
|
|
410
|
+
state.gates.pr_opened = true;
|
|
411
|
+
break;
|
|
412
|
+
case "merge.succeeded":
|
|
413
|
+
case "merge.failed":
|
|
414
|
+
case "merge.dry_run":
|
|
415
|
+
case "merge.pending_approval":
|
|
416
|
+
// BAPI-398/413: run-level merge events (no worker_id) are recorded compactly
|
|
417
|
+
// under gate metadata WITHOUT creating a worker. Worker-scoped merge events
|
|
418
|
+
// are handled in the main reducer; merges are normally worker-scoped.
|
|
419
|
+
state.gates.merge = eventType.slice("merge.".length);
|
|
420
|
+
break;
|
|
421
|
+
default:
|
|
422
|
+
break;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
// ---------------------------------------------------------------------------
|
|
426
|
+
// Liveness & housekeeping (Step 6)
|
|
427
|
+
// ---------------------------------------------------------------------------
|
|
428
|
+
/**
|
|
429
|
+
* Classify a worker's LIVENESS from timestamps only — independent of its
|
|
430
|
+
* progress (watchdog) state. Terminal workers return a stable `alive` liveness
|
|
431
|
+
* (they are done, not dead). A worker with no observed timestamps is `unknown`.
|
|
432
|
+
*/
|
|
433
|
+
export function classifyWorkerLiveness(worker, config, now) {
|
|
434
|
+
if (isTerminalState(worker.state))
|
|
435
|
+
return "alive";
|
|
436
|
+
const lastSignalMs = mostRecentSignalMs(worker);
|
|
437
|
+
if (lastSignalMs === null)
|
|
438
|
+
return "unknown";
|
|
439
|
+
const elapsed = now - lastSignalMs;
|
|
440
|
+
if (elapsed >= config.liveness.dead_after_ms)
|
|
441
|
+
return "dead";
|
|
442
|
+
if (elapsed >= config.liveness.stalled_after_ms)
|
|
443
|
+
return "stalled";
|
|
444
|
+
if (elapsed >= config.liveness.quiet_after_ms)
|
|
445
|
+
return "quiet";
|
|
446
|
+
return "alive";
|
|
447
|
+
}
|
|
448
|
+
/** Most recent of heartbeat / event / progress timestamps (ms), or null. */
|
|
449
|
+
function mostRecentSignalMs(worker) {
|
|
450
|
+
const candidates = [
|
|
451
|
+
isoToMs(worker.last_heartbeat_time),
|
|
452
|
+
isoToMs(worker.last_event_time),
|
|
453
|
+
isoToMs(worker.last_progress_time),
|
|
454
|
+
].filter((v) => v !== null);
|
|
455
|
+
if (candidates.length === 0)
|
|
456
|
+
return null;
|
|
457
|
+
return Math.max(...candidates);
|
|
458
|
+
}
|
|
459
|
+
/** Reference time for a worker's CURRENT-state stall budget. */
|
|
460
|
+
function stateAnchorMs(worker) {
|
|
461
|
+
// For long active/verifying work, prefer recent progress so a long but
|
|
462
|
+
// PROGRESSING worker is never falsely stalled. Fall back to last event time,
|
|
463
|
+
// then heartbeat.
|
|
464
|
+
if (worker.state === "active" || worker.state === "verifying") {
|
|
465
|
+
return mostRecentSignalMs(worker);
|
|
466
|
+
}
|
|
467
|
+
// not_started/unknown/candidate_done/blocked/stalled: anchor on last event.
|
|
468
|
+
return (isoToMs(worker.last_event_time) ??
|
|
469
|
+
isoToMs(worker.last_heartbeat_time) ??
|
|
470
|
+
isoToMs(worker.last_progress_time));
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* Recompute liveness for every worker and apply STATE-SPECIFIC stall
|
|
474
|
+
* thresholds. A worker is moved to `stalled` only when its CURRENT state's
|
|
475
|
+
* threshold has elapsed — there is no flat timeout. Terminal workers are never
|
|
476
|
+
* touched. Active/verifying workers with recent progress are protected from
|
|
477
|
+
* false-positive stalls because their anchor is the most recent signal.
|
|
478
|
+
*/
|
|
479
|
+
export function applySupervisorHousekeeping(state, config, now) {
|
|
480
|
+
for (const worker of Object.values(state.workers)) {
|
|
481
|
+
worker.liveness = classifyWorkerLiveness(worker, config, now);
|
|
482
|
+
if (isTerminalState(worker.state) || worker.state === "stalled") {
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
const threshold = config.stall_thresholds_ms[worker.state];
|
|
486
|
+
const anchor = stateAnchorMs(worker);
|
|
487
|
+
if (anchor === null)
|
|
488
|
+
continue;
|
|
489
|
+
if (now - anchor >= threshold) {
|
|
490
|
+
worker.state = "stalled";
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
state.updated_at = msToIso(now);
|
|
494
|
+
return state;
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* A run is terminal ONLY when a worker roster has been discovered AND every
|
|
498
|
+
* known worker is `complete` or `failed`. With no roster discovered, the run is
|
|
499
|
+
* not terminal (the global timeout is the separate backstop).
|
|
500
|
+
*/
|
|
501
|
+
export function isSupervisorRunTerminal(state) {
|
|
502
|
+
const workers = Object.values(state.workers);
|
|
503
|
+
if (workers.length === 0)
|
|
504
|
+
return false;
|
|
505
|
+
if (!state.roster_discovered)
|
|
506
|
+
return false;
|
|
507
|
+
return workers.every((w) => isTerminalState(w.state));
|
|
508
|
+
}
|
|
509
|
+
/** Has the configured global deadline passed at `now`? Independent of stalls. */
|
|
510
|
+
export function hasSupervisorGlobalTimeoutElapsed(state, now) {
|
|
511
|
+
const deadlineMs = isoToMs(state.global_deadline_at);
|
|
512
|
+
if (deadlineMs === null)
|
|
513
|
+
return false;
|
|
514
|
+
return now >= deadlineMs;
|
|
515
|
+
}
|
|
516
|
+
// ---------------------------------------------------------------------------
|
|
517
|
+
// Projection serialization (Step 7)
|
|
518
|
+
// ---------------------------------------------------------------------------
|
|
519
|
+
/** Compact, secret-free per-worker summary stored in `active_workers`. */
|
|
520
|
+
function compactWorker(worker) {
|
|
521
|
+
return {
|
|
522
|
+
worker_id: worker.worker_id,
|
|
523
|
+
ticket_key: worker.ticket_key,
|
|
524
|
+
state: worker.state,
|
|
525
|
+
liveness: worker.liveness,
|
|
526
|
+
last_event_seq: worker.last_event_seq,
|
|
527
|
+
last_event_time: worker.last_event_time,
|
|
528
|
+
last_progress_time: worker.last_progress_time,
|
|
529
|
+
last_heartbeat_time: worker.last_heartbeat_time,
|
|
530
|
+
blocked_reason: worker.blocked_reason,
|
|
531
|
+
terminal_reason: worker.terminal_reason,
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* Convert run state into a {@link SupervisorProjectionInput}. `active_workers`
|
|
536
|
+
* holds compact worker summaries (never raw event data); `gates` holds gate
|
|
537
|
+
* metadata; `assessment` holds the latest assessment or null; and `summary`
|
|
538
|
+
* holds the FULL resumable supervisor state (worker watchdog states, liveness,
|
|
539
|
+
* last seq, escalation history, LLM budget usage, run start/deadline metadata).
|
|
540
|
+
*
|
|
541
|
+
* The serialized summary is secret-free by construction: it is built only from
|
|
542
|
+
* the compact state fields above — never from raw payloads, secrets, or LLM
|
|
543
|
+
* prompts.
|
|
544
|
+
*/
|
|
545
|
+
export function toSupervisorProjectionInput(state) {
|
|
546
|
+
const summary = {
|
|
547
|
+
kind: SUPERVISOR_SUMMARY_KIND,
|
|
548
|
+
run_id: state.run_id,
|
|
549
|
+
status: state.status,
|
|
550
|
+
last_seq: state.last_seq,
|
|
551
|
+
last_event_time: state.last_event_time,
|
|
552
|
+
workers: state.workers,
|
|
553
|
+
gates: state.gates,
|
|
554
|
+
latest_assessment: state.latest_assessment,
|
|
555
|
+
escalations: state.escalations,
|
|
556
|
+
llm_budget: state.llm_budget,
|
|
557
|
+
started_at: state.started_at,
|
|
558
|
+
updated_at: state.updated_at,
|
|
559
|
+
global_deadline_at: state.global_deadline_at,
|
|
560
|
+
roster_discovered: state.roster_discovered,
|
|
561
|
+
};
|
|
562
|
+
return {
|
|
563
|
+
run_id: state.run_id,
|
|
564
|
+
status: state.status,
|
|
565
|
+
last_seq: state.last_seq,
|
|
566
|
+
last_event_time: state.last_event_time,
|
|
567
|
+
active_workers: Object.values(state.workers).map(compactWorker),
|
|
568
|
+
gates: state.gates,
|
|
569
|
+
assessment: state.latest_assessment,
|
|
570
|
+
summary,
|
|
571
|
+
};
|
|
572
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared DTO / state-vocabulary contracts for the conductor supervisor runtime
|
|
3
|
+
* (BAPI-396, conductor C4).
|
|
4
|
+
*
|
|
5
|
+
* These are pure TypeScript type declarations with NO runtime behavior so every
|
|
6
|
+
* supervisor module (config, reducer, escalation, judgment, runtime) can depend
|
|
7
|
+
* on them freely without import cycles or side effects.
|
|
8
|
+
*
|
|
9
|
+
* Vocabulary split — the supervisor tracks two ORTHOGONAL axes per worker:
|
|
10
|
+
* - {@link WatchdogState}: the worker's progress through the run lifecycle
|
|
11
|
+
* (derived from raw ledger events; raw events are the source of truth).
|
|
12
|
+
* - {@link WorkerLiveness}: how recently the worker produced ANY signal
|
|
13
|
+
* (derived purely from timestamps). Liveness is NOT progress: a worker can
|
|
14
|
+
* be `active` (progress) yet `quiet` (liveness) during a long build.
|
|
15
|
+
*/
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conductor semantic event taxonomy.
|
|
3
|
+
*
|
|
4
|
+
* This module is the SINGLE SOURCE OF TRUTH for the set of accepted conductor
|
|
5
|
+
* event types. The SQLite `events` table CHECK constraint, the Zod tool input
|
|
6
|
+
* enums, and the CLI parser all derive their accepted vocabulary from
|
|
7
|
+
* {@link SEMANTIC_EVENT_TYPES} here. It is deliberately framework-agnostic and
|
|
8
|
+
* has no I/O or store dependencies so it can be imported from the store, the
|
|
9
|
+
* tools, the CLI, and unit tests alike.
|
|
10
|
+
*/
|
|
11
|
+
import { ConductorValidationError } from "./errors.js";
|
|
12
|
+
/**
|
|
13
|
+
* The exact, ordered set of accepted semantic event types. The order is part of
|
|
14
|
+
* the contract (a unit test pins it) so downstream artifacts — the schema CHECK
|
|
15
|
+
* constraint and the Zod enum — render deterministically.
|
|
16
|
+
*/
|
|
17
|
+
export const SEMANTIC_EVENT_TYPES = [
|
|
18
|
+
"run.started",
|
|
19
|
+
"run.heartbeat",
|
|
20
|
+
"run.stopped",
|
|
21
|
+
"agent.notification",
|
|
22
|
+
"tool.intent",
|
|
23
|
+
"worktree.changed",
|
|
24
|
+
"git.commit_created",
|
|
25
|
+
"git.pr_opened",
|
|
26
|
+
"ci.passed",
|
|
27
|
+
"ci.failed",
|
|
28
|
+
"gate.met",
|
|
29
|
+
"supervisor.assessment",
|
|
30
|
+
"message.sent",
|
|
31
|
+
"message.delivered",
|
|
32
|
+
"message.acked",
|
|
33
|
+
"merge.dry_run",
|
|
34
|
+
"merge.attempted",
|
|
35
|
+
"merge.succeeded",
|
|
36
|
+
"merge.failed",
|
|
37
|
+
"merge.pending_approval",
|
|
38
|
+
];
|
|
39
|
+
/**
|
|
40
|
+
* Type guard: returns `true` only when `value` is one of the exact taxonomy
|
|
41
|
+
* strings. Rejects non-strings, casing variants, and surrounding whitespace.
|
|
42
|
+
*/
|
|
43
|
+
export function isSemanticEventType(value) {
|
|
44
|
+
return (typeof value === "string" &&
|
|
45
|
+
SEMANTIC_EVENT_TYPES.includes(value));
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Narrow `value` to a {@link SemanticEventType}, throwing a
|
|
49
|
+
* {@link ConductorValidationError} for any non-taxonomy value. The error names
|
|
50
|
+
* the invalid event-type problem but never echoes unrelated payload data.
|
|
51
|
+
*/
|
|
52
|
+
export function assertSemanticEventType(value) {
|
|
53
|
+
if (isSemanticEventType(value)) {
|
|
54
|
+
return value;
|
|
55
|
+
}
|
|
56
|
+
const rendered = typeof value === "string" ? value : typeof value;
|
|
57
|
+
throw new ConductorValidationError(`Unknown conductor event type "${rendered}". Allowed types: ${SEMANTIC_EVENT_TYPES.join(", ")}.`);
|
|
58
|
+
}
|