@bridge_gpt/mcp-server 0.2.2 → 0.2.3
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 +468 -59
- 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 +16 -8
|
@@ -0,0 +1,611 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Epic Supervisor stateless reconciliation tick (BAPI-408).
|
|
3
|
+
*
|
|
4
|
+
* `conductor epic-tick --epic-key <key>` runs one stateless pass:
|
|
5
|
+
* 1. Claim the epic supervision lease (single-writer guard)
|
|
6
|
+
* 2. Drift check (log if fired materially late)
|
|
7
|
+
* 3. Rebuild observed state from Postgres + local ledger
|
|
8
|
+
* 4. Assert plan integrity (fail-closed on hash mismatch)
|
|
9
|
+
* 5. Reconcile observed→desired (deterministic, no LLM on ordering)
|
|
10
|
+
* 6. Budget / dead-state check
|
|
11
|
+
* 7. Persist, release lease, exit
|
|
12
|
+
*
|
|
13
|
+
* Crash/sleep is the expected path: every mutation is an idempotent CAS/lease
|
|
14
|
+
* op so the next wakeup re-derives and continues.
|
|
15
|
+
*
|
|
16
|
+
* This module opens NO Postgres connection and holds NO VCS write credentials.
|
|
17
|
+
* All durable mutations go through the injectable seams that call the sibling
|
|
18
|
+
* Epic Run TS client (already available in bridge-api-client.ts as of BAPI-407).
|
|
19
|
+
*/
|
|
20
|
+
import { resolveConductorBridgeApiAccess, claimEpicSupervisionLease, fetchEpicRunState, advanceEpicTicketStatus, createEpicTicketStatus, recordEpicDispatch, transitionEpicDispatch, fetchParseStatus, triggerRepositoryParse, getEpicPlan, buildEpicDispatchKey, } from "./bridge-api-client.js";
|
|
21
|
+
import { processGateMetMerge } from "./supervisor-merge.js";
|
|
22
|
+
import { rebuildObservedState, } from "./epic-state.js";
|
|
23
|
+
import { reconcileEpic } from "./epic-reconcile.js";
|
|
24
|
+
import { hashPlan } from "./plan.js";
|
|
25
|
+
import { pollConductorEvents } from "./store.js";
|
|
26
|
+
import { dispatchSupervisorNotification } from "./supervisor-notification.js";
|
|
27
|
+
import { makeSupervisorIdempotencyKey } from "./supervisor-ledger.js";
|
|
28
|
+
import { createDefaultStartTicketsDeps, orchestrateStartTickets } from "../start-tickets.js";
|
|
29
|
+
import { orchestrateReviewTickets } from "../review-tickets.js";
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Constants
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
const DEFAULT_LEASE_TTL_SECONDS = 120;
|
|
34
|
+
const DEFAULT_MAX_DRIFT_MS = 30_000;
|
|
35
|
+
const DEFAULT_DISPATCH_KEY_TTL_SECONDS = 300; // independent of supervision lease TTL
|
|
36
|
+
const ACTIVE_WORKER_STATUSES = new Set(["dispatched", "running"]);
|
|
37
|
+
/**
|
|
38
|
+
* Module-level transient map keyed by `"${epicKey}:${ticketKey}"`. Cleared on
|
|
39
|
+
* parse completion or budget exhaustion. Process-restart safe: re-derived from
|
|
40
|
+
* the parse lock status on the next tick.
|
|
41
|
+
*/
|
|
42
|
+
const parseWaitStateMap = new Map();
|
|
43
|
+
function defaultLeaseOwner() {
|
|
44
|
+
return `epic-tick-${process.pid}`;
|
|
45
|
+
}
|
|
46
|
+
async function defaultEscalateOnce(epicKey, reason) {
|
|
47
|
+
process.stderr.write(`[epic-tick] ESCALATION epic=${epicKey} reason=${reason}\n`);
|
|
48
|
+
}
|
|
49
|
+
async function defaultDispatchSeam(_epicKey, ticketKey) {
|
|
50
|
+
throw new Error(`dispatch seam not wired for ticket ${ticketKey}`);
|
|
51
|
+
}
|
|
52
|
+
async function defaultPostActionWaitSeam(_epicKey, _ticketKey) {
|
|
53
|
+
// no-op: parse-after-merge wait is a sibling ticket's concern
|
|
54
|
+
}
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// runEpicTick
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
/**
|
|
59
|
+
* Execute one stateless reconciliation pass for an Epic. Claims the lease,
|
|
60
|
+
* rebuilds observed state, asserts plan integrity, reconciles, and exits.
|
|
61
|
+
* Returns a structured result with a process exit code.
|
|
62
|
+
*
|
|
63
|
+
* Offline / fail-closed: if Bridge API credentials or the backend are
|
|
64
|
+
* unreachable, the tick takes NO privileged action, logs a sanitized
|
|
65
|
+
* diagnostic, and returns exit code 0 (observer mode).
|
|
66
|
+
*/
|
|
67
|
+
export async function runEpicTick(options, deps = {}) {
|
|
68
|
+
const { epic_key, scheduled_at, lease_owner = defaultLeaseOwner(), lease_ttl_seconds = DEFAULT_LEASE_TTL_SECONDS, max_drift_ms = DEFAULT_MAX_DRIFT_MS, } = options;
|
|
69
|
+
const nowFn = deps.now ?? (() => Date.now());
|
|
70
|
+
const log = deps.log ?? ((msg) => console.log(msg));
|
|
71
|
+
const errorLog = deps.errorLog ?? ((msg) => process.stderr.write(`${msg}\n`));
|
|
72
|
+
const escalateOnce = deps.escalateOnce ?? defaultEscalateOnce;
|
|
73
|
+
const dispatchSeam = deps.dispatchSeam ?? defaultDispatchSeam;
|
|
74
|
+
const processMergeFn = deps.processMerge ?? processGateMetMerge;
|
|
75
|
+
const postActionWaitSeam = deps.postActionWaitSeam ?? defaultPostActionWaitSeam;
|
|
76
|
+
const fetchLocalEvents = deps.fetchLocalEvents ?? ((_key) => []);
|
|
77
|
+
const resolveBridgeAccess = deps.resolveBridgeAccess ?? resolveConductorBridgeApiAccess;
|
|
78
|
+
const claimLeaseFn = deps.claimLease ?? claimEpicSupervisionLease;
|
|
79
|
+
const fetchEpicStateFn = deps.fetchEpicState ?? fetchEpicRunState;
|
|
80
|
+
const releaseLease = deps.releaseLease;
|
|
81
|
+
const startMs = nowFn();
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// Step 0: Resolve Bridge API access (offline / fail-closed guard)
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
let access;
|
|
86
|
+
try {
|
|
87
|
+
const accessResult = await resolveBridgeAccess();
|
|
88
|
+
if (!accessResult.ok) {
|
|
89
|
+
errorLog(`[epic-tick] observe-only: bridge access unavailable for epic=${epic_key}: ${accessResult.kind}`);
|
|
90
|
+
return {
|
|
91
|
+
run_id: epic_key,
|
|
92
|
+
status: "complete",
|
|
93
|
+
exit_code: 0,
|
|
94
|
+
reason: "offline: bridge access unavailable",
|
|
95
|
+
last_seq: -1,
|
|
96
|
+
worker_count: 0,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
access = accessResult.access;
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
const safeMsg = err instanceof Error ? err.constructor.name : "init error";
|
|
103
|
+
errorLog(`[epic-tick] observe-only: bridge access resolution failed (${safeMsg}) for epic=${epic_key}`);
|
|
104
|
+
return {
|
|
105
|
+
run_id: epic_key,
|
|
106
|
+
status: "complete",
|
|
107
|
+
exit_code: 0,
|
|
108
|
+
reason: "offline: bridge access resolution failed",
|
|
109
|
+
last_seq: -1,
|
|
110
|
+
worker_count: 0,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// Step 1: Claim the epic supervision lease (single-writer guard)
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
let leaseAcquired = false;
|
|
117
|
+
try {
|
|
118
|
+
const leaseResult = await claimLeaseFn(access, {
|
|
119
|
+
epicKey: epic_key,
|
|
120
|
+
leaseOwner: lease_owner,
|
|
121
|
+
ttlSeconds: lease_ttl_seconds,
|
|
122
|
+
});
|
|
123
|
+
if (!leaseResult.ok) {
|
|
124
|
+
if (leaseResult.kind === "terminal") {
|
|
125
|
+
log(`[epic-tick] epic=${epic_key} is terminal (${leaseResult.row.status}); nothing to do`);
|
|
126
|
+
return {
|
|
127
|
+
run_id: epic_key,
|
|
128
|
+
status: "complete",
|
|
129
|
+
exit_code: 0,
|
|
130
|
+
reason: `observer: epic is terminal (${leaseResult.row.status})`,
|
|
131
|
+
last_seq: -1,
|
|
132
|
+
worker_count: 0,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
// held-by-other
|
|
136
|
+
log(`[epic-tick] lease held by another worker for epic=${epic_key}; exiting as observer`);
|
|
137
|
+
return {
|
|
138
|
+
run_id: epic_key,
|
|
139
|
+
status: "complete",
|
|
140
|
+
exit_code: 0,
|
|
141
|
+
reason: "observer: lease held by another worker",
|
|
142
|
+
last_seq: -1,
|
|
143
|
+
worker_count: 0,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
leaseAcquired = true;
|
|
147
|
+
}
|
|
148
|
+
catch (err) {
|
|
149
|
+
const safeMsg = err instanceof Error ? err.constructor.name : "lease error";
|
|
150
|
+
errorLog(`[epic-tick] observe-only: lease claim failed (${safeMsg}) for epic=${epic_key}`);
|
|
151
|
+
return {
|
|
152
|
+
run_id: epic_key,
|
|
153
|
+
status: "complete",
|
|
154
|
+
exit_code: 0,
|
|
155
|
+
reason: "offline: lease claim failed",
|
|
156
|
+
last_seq: -1,
|
|
157
|
+
worker_count: 0,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
// Steps 2-6: Main reconcile body (lease is held)
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
let exitCode = 0;
|
|
164
|
+
let reason = "reconcile complete";
|
|
165
|
+
let workerCount = 0;
|
|
166
|
+
try {
|
|
167
|
+
// Step 2: Drift check
|
|
168
|
+
if (scheduled_at !== undefined) {
|
|
169
|
+
const driftMs = startMs - scheduled_at;
|
|
170
|
+
if (driftMs > max_drift_ms) {
|
|
171
|
+
log(`[epic-tick] drift warning: epic=${epic_key} fired ${driftMs}ms late (threshold=${max_drift_ms}ms)`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
// Step 3: Rebuild observed state
|
|
175
|
+
let epicRunState;
|
|
176
|
+
try {
|
|
177
|
+
epicRunState = await fetchEpicStateFn(access, epic_key);
|
|
178
|
+
}
|
|
179
|
+
catch (err) {
|
|
180
|
+
const safeMsg = err instanceof Error ? err.constructor.name : "fetch error";
|
|
181
|
+
errorLog(`[epic-tick] state fetch failed (${safeMsg}) for epic=${epic_key}`);
|
|
182
|
+
exitCode = 1;
|
|
183
|
+
reason = "state fetch failed";
|
|
184
|
+
return {
|
|
185
|
+
run_id: epic_key,
|
|
186
|
+
status: "failed",
|
|
187
|
+
exit_code: exitCode,
|
|
188
|
+
reason,
|
|
189
|
+
last_seq: -1,
|
|
190
|
+
worker_count: 0,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
const localEvents = fetchLocalEvents(epic_key);
|
|
194
|
+
const observed = rebuildObservedState(epicRunState, localEvents, nowFn());
|
|
195
|
+
workerCount = [...observed.ticket_statuses.values()].filter((s) => ACTIVE_WORKER_STATUSES.has(s)).length;
|
|
196
|
+
// Step 3.5: Run post-action waits (parse-after-merge)
|
|
197
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
198
|
+
const pamConfig = epicRunState.epic_run.policy_json
|
|
199
|
+
?.post_action_waits
|
|
200
|
+
?.parse_after_merge;
|
|
201
|
+
const isParseWaitEnabled = pamConfig?.enabled === true;
|
|
202
|
+
if (isParseWaitEnabled) {
|
|
203
|
+
const maxWaitMs = typeof pamConfig?.max_wait_ms === "number" ? pamConfig.max_wait_ms : 10 * 60 * 1000;
|
|
204
|
+
const settleMs = 5000;
|
|
205
|
+
const fetchParseStatusFn = deps.fetchParseStatus ?? fetchParseStatus;
|
|
206
|
+
const triggerParseFn = deps.triggerParse ?? triggerRepositoryParse;
|
|
207
|
+
for (let i = 0; i < observed.unfolded_terminal_signals.length; i++) {
|
|
208
|
+
const signal = observed.unfolded_terminal_signals[i];
|
|
209
|
+
if (signal.signal_type !== "merge.succeeded")
|
|
210
|
+
continue;
|
|
211
|
+
const ticketKey = signal.ticket_key;
|
|
212
|
+
const stateKey = `${epic_key}:${ticketKey}`;
|
|
213
|
+
let pState = parseWaitStateMap.get(stateKey);
|
|
214
|
+
if (!pState) {
|
|
215
|
+
pState = {};
|
|
216
|
+
parseWaitStateMap.set(stateKey, pState);
|
|
217
|
+
}
|
|
218
|
+
const revertSignal = () => {
|
|
219
|
+
const origStatus = epicRunState.ticket_statuses.find((ts) => ts.ticket_key === ticketKey)?.status ??
|
|
220
|
+
"running";
|
|
221
|
+
observed.ticket_statuses.set(ticketKey, origStatus);
|
|
222
|
+
observed.unfolded_terminal_signals.splice(i, 1);
|
|
223
|
+
i -= 1;
|
|
224
|
+
};
|
|
225
|
+
const elapsedMs = nowFn() - new Date(signal.event.time).getTime();
|
|
226
|
+
// Budget exhaustion: escalate once, then permanently block
|
|
227
|
+
if (elapsedMs > maxWaitMs) {
|
|
228
|
+
if (!pState.escalated) {
|
|
229
|
+
await escalateOnce(epic_key, `parse-after-merge budget exhausted for ${ticketKey}`);
|
|
230
|
+
pState.escalated = true;
|
|
231
|
+
signal.next_status = "blocked";
|
|
232
|
+
observed.ticket_statuses.set(ticketKey, "blocked");
|
|
233
|
+
// Let the signal remain so the reconcile pass CASes once to blocked
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
// Already escalated: map to blocked and skip redundant CAS
|
|
238
|
+
observed.ticket_statuses.set(ticketKey, "blocked");
|
|
239
|
+
parseWaitStateMap.delete(stateKey);
|
|
240
|
+
observed.unfolded_terminal_signals.splice(i, 1);
|
|
241
|
+
i -= 1;
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
// Poll the parse lock
|
|
246
|
+
let parseStatusResult;
|
|
247
|
+
try {
|
|
248
|
+
parseStatusResult = await fetchParseStatusFn(access);
|
|
249
|
+
}
|
|
250
|
+
catch (err) {
|
|
251
|
+
const safeMsg = err instanceof Error ? err.constructor.name : "fetch error";
|
|
252
|
+
errorLog(`[epic-tick] parse-status check failed (${safeMsg}) for ${ticketKey}; will retry next tick`);
|
|
253
|
+
revertSignal();
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
if (parseStatusResult.status === "in_progress") {
|
|
257
|
+
pState.seenInProgress = true;
|
|
258
|
+
revertSignal();
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
// status === "idle" — evaluate race guard and completion
|
|
262
|
+
if (pState.seenInProgress) {
|
|
263
|
+
// Previously observed in_progress: the parse finished normally
|
|
264
|
+
parseWaitStateMap.delete(stateKey);
|
|
265
|
+
continue; // let the signal proceed to CAS → done
|
|
266
|
+
}
|
|
267
|
+
if (pState.triggeredAt !== undefined) {
|
|
268
|
+
const msSinceTrigger = nowFn() - pState.triggeredAt;
|
|
269
|
+
if (msSinceTrigger < settleMs) {
|
|
270
|
+
// Idle observed before the async job acquired its lock (race window)
|
|
271
|
+
revertSignal();
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
// Settle window elapsed without in_progress: treat as instantaneous completion
|
|
275
|
+
parseWaitStateMap.delete(stateKey);
|
|
276
|
+
continue; // let the signal proceed to CAS → done
|
|
277
|
+
}
|
|
278
|
+
// No trigger yet — fire it now
|
|
279
|
+
try {
|
|
280
|
+
await triggerParseFn(access);
|
|
281
|
+
pState.triggeredAt = nowFn();
|
|
282
|
+
log(`[epic-tick] triggered parse-after-merge for ${ticketKey} in epic=${epic_key}`);
|
|
283
|
+
}
|
|
284
|
+
catch (err) {
|
|
285
|
+
const safeMsg = err instanceof Error ? err.constructor.name : "trigger error";
|
|
286
|
+
errorLog(`[epic-tick] parse trigger failed (${safeMsg}) for ${ticketKey}; will retry next tick`);
|
|
287
|
+
}
|
|
288
|
+
revertSignal();
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
// Step 4: Fetch + assert plan integrity (only if fetchPlan injected)
|
|
292
|
+
const fetchPlanFn = deps.fetchPlan;
|
|
293
|
+
let plan = null;
|
|
294
|
+
if (fetchPlanFn !== undefined) {
|
|
295
|
+
try {
|
|
296
|
+
plan = await fetchPlanFn(epic_key, access);
|
|
297
|
+
}
|
|
298
|
+
catch (err) {
|
|
299
|
+
const safeMsg = err instanceof Error ? err.constructor.name : "plan fetch error";
|
|
300
|
+
errorLog(`[epic-tick] plan fetch failed (${safeMsg}) for epic=${epic_key}; fail-closed`);
|
|
301
|
+
await escalateOnce(epic_key, "plan-unavailable");
|
|
302
|
+
exitCode = 1;
|
|
303
|
+
reason = "plan unavailable";
|
|
304
|
+
return {
|
|
305
|
+
run_id: epic_key,
|
|
306
|
+
status: "failed",
|
|
307
|
+
exit_code: exitCode,
|
|
308
|
+
reason,
|
|
309
|
+
last_seq: -1,
|
|
310
|
+
worker_count: workerCount,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
if (plan !== null) {
|
|
314
|
+
const approvedHash = epicRunState.epic_run.approved_plan_hash;
|
|
315
|
+
if (approvedHash !== null && approvedHash !== plan.plan_hash) {
|
|
316
|
+
errorLog(`[epic-tick] plan hash mismatch for epic=${epic_key}; fail-closed (no dispatch, no merge)`);
|
|
317
|
+
await escalateOnce(epic_key, "plan-hash-mismatch");
|
|
318
|
+
exitCode = 1;
|
|
319
|
+
reason = "plan hash mismatch";
|
|
320
|
+
return {
|
|
321
|
+
run_id: epic_key,
|
|
322
|
+
status: "failed",
|
|
323
|
+
exit_code: exitCode,
|
|
324
|
+
reason,
|
|
325
|
+
last_seq: -1,
|
|
326
|
+
worker_count: workerCount,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
// Step 5: Reconcile observed→desired
|
|
332
|
+
if (plan !== null) {
|
|
333
|
+
const reconcileDeps = {
|
|
334
|
+
casTicketStatus: async (ek, tk, rowVersion, nextStatus, planVersion) => advanceEpicTicketStatus(access, {
|
|
335
|
+
epicKey: ek,
|
|
336
|
+
ticketKey: tk,
|
|
337
|
+
expectedRowVersion: rowVersion,
|
|
338
|
+
nextStatus,
|
|
339
|
+
planVersion,
|
|
340
|
+
}),
|
|
341
|
+
seedTicketStatus: async (ek, tk, planVersion) => {
|
|
342
|
+
await createEpicTicketStatus(access, {
|
|
343
|
+
epicKey: ek,
|
|
344
|
+
ticketKey: tk,
|
|
345
|
+
status: "planned",
|
|
346
|
+
planVersion,
|
|
347
|
+
});
|
|
348
|
+
},
|
|
349
|
+
claimDispatchKey: async (ek, tk, planVersion) => recordEpicDispatch(access, {
|
|
350
|
+
epicKey: ek,
|
|
351
|
+
ticketKey: tk,
|
|
352
|
+
planVersion,
|
|
353
|
+
leaseOwner: lease_owner,
|
|
354
|
+
ttlSeconds: DEFAULT_DISPATCH_KEY_TTL_SECONDS,
|
|
355
|
+
}),
|
|
356
|
+
correlateRunId: async (dispatchKey, runId) => {
|
|
357
|
+
await transitionEpicDispatch(access, {
|
|
358
|
+
dispatchKey,
|
|
359
|
+
nextStatus: "run_spawned",
|
|
360
|
+
runId,
|
|
361
|
+
});
|
|
362
|
+
},
|
|
363
|
+
dispatchSeam: async (ek, tk) => dispatchSeam(ek, tk),
|
|
364
|
+
processMerge: async (acc, event) => processMergeFn(acc, event),
|
|
365
|
+
postActionWaitSeam: async (ek, tk) => postActionWaitSeam(ek, tk),
|
|
366
|
+
escalateOnce: async (ek, reason) => escalateOnce(ek, reason),
|
|
367
|
+
log,
|
|
368
|
+
};
|
|
369
|
+
const reconcileResult = await reconcileEpic(access, observed, plan, reconcileDeps);
|
|
370
|
+
log(`[epic-tick] reconcile done: epic=${epic_key} ` +
|
|
371
|
+
`signals=${reconcileResult.signals_folded} ` +
|
|
372
|
+
`dispatched=${reconcileResult.dispatched} ` +
|
|
373
|
+
`merges=${reconcileResult.merges_actioned}`);
|
|
374
|
+
for (const w of reconcileResult.warnings) {
|
|
375
|
+
errorLog(`[epic-tick] warning: ${w}`);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
else {
|
|
379
|
+
log(`[epic-tick] no plan available for epic=${epic_key}; skipping dispatch and merge steps`);
|
|
380
|
+
}
|
|
381
|
+
// Step 6: Budget / dead-state check
|
|
382
|
+
const epicRun = epicRunState.epic_run;
|
|
383
|
+
if (epicRun.budget_wall_clock_seconds !== null &&
|
|
384
|
+
epicRun.consumed_wall_clock_seconds !== null) {
|
|
385
|
+
const elapsedMs = nowFn() - startMs;
|
|
386
|
+
const consumedTotalMs = epicRun.consumed_wall_clock_seconds * 1000 + elapsedMs;
|
|
387
|
+
if (consumedTotalMs > epicRun.budget_wall_clock_seconds * 1000) {
|
|
388
|
+
errorLog(`[epic-tick] wall-clock budget exhausted for epic=${epic_key}`);
|
|
389
|
+
await escalateOnce(epic_key, "budget-exhausted");
|
|
390
|
+
exitCode = 1;
|
|
391
|
+
reason = "budget exhausted";
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return {
|
|
395
|
+
run_id: epic_key,
|
|
396
|
+
status: exitCode === 0 ? "complete" : "failed",
|
|
397
|
+
exit_code: exitCode,
|
|
398
|
+
reason,
|
|
399
|
+
last_seq: -1,
|
|
400
|
+
worker_count: workerCount,
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
catch (err) {
|
|
404
|
+
const safeMsg = err instanceof Error ? err.constructor.name : "runtime error";
|
|
405
|
+
errorLog(`[epic-tick] unexpected error (${safeMsg}) for epic=${epic_key}`);
|
|
406
|
+
exitCode = 2;
|
|
407
|
+
reason = "unexpected runtime error";
|
|
408
|
+
return {
|
|
409
|
+
run_id: epic_key,
|
|
410
|
+
status: "failed",
|
|
411
|
+
exit_code: exitCode,
|
|
412
|
+
reason,
|
|
413
|
+
last_seq: -1,
|
|
414
|
+
worker_count: workerCount,
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
finally {
|
|
418
|
+
// Step 7: Release the lease unconditionally
|
|
419
|
+
if (leaseAcquired) {
|
|
420
|
+
if (releaseLease !== undefined) {
|
|
421
|
+
try {
|
|
422
|
+
await releaseLease(epic_key);
|
|
423
|
+
log(`[epic-tick] lease released for epic=${epic_key}`);
|
|
424
|
+
}
|
|
425
|
+
catch {
|
|
426
|
+
// Best-effort; TTL expiry is the fallback
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
else {
|
|
430
|
+
log(`[epic-tick] lease will expire via TTL for epic=${epic_key}`);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
// ---------------------------------------------------------------------------
|
|
436
|
+
// Production EpicRuntimeDeps factory
|
|
437
|
+
// ---------------------------------------------------------------------------
|
|
438
|
+
/**
|
|
439
|
+
* Build the production EpicRuntimeDeps for use inside `runEpicTickCommand`.
|
|
440
|
+
*
|
|
441
|
+
* Fail-open: if Bridge API access is unresolvable, returns an empty `{}`
|
|
442
|
+
* object so `runEpicTick` falls back to its observe-only defaults (exit 0).
|
|
443
|
+
* Never throws out of this factory.
|
|
444
|
+
*/
|
|
445
|
+
export async function buildProductionEpicRuntimeDeps(epicKey) {
|
|
446
|
+
let access;
|
|
447
|
+
try {
|
|
448
|
+
const accessResult = await resolveConductorBridgeApiAccess();
|
|
449
|
+
if (!accessResult.ok) {
|
|
450
|
+
process.stderr.write(`[epic-tick] factory: observe-only — bridge access unavailable (${accessResult.kind})\n`);
|
|
451
|
+
return {};
|
|
452
|
+
}
|
|
453
|
+
access = accessResult.access;
|
|
454
|
+
}
|
|
455
|
+
catch {
|
|
456
|
+
process.stderr.write(`[epic-tick] factory: observe-only — bridge access resolution failed\n`);
|
|
457
|
+
return {};
|
|
458
|
+
}
|
|
459
|
+
// Shared closure state populated by fetchPlan and consumed by dispatchSeam.
|
|
460
|
+
let cachedPlanVersion = 0;
|
|
461
|
+
const automationMap = new Map();
|
|
462
|
+
const fetchPlan = async (ek, acc) => {
|
|
463
|
+
let response;
|
|
464
|
+
try {
|
|
465
|
+
response = await getEpicPlan(acc, ek, "approved");
|
|
466
|
+
}
|
|
467
|
+
catch (err) {
|
|
468
|
+
// 404 / no approved plan → null (no-plan mode); other errors bubble up
|
|
469
|
+
// so the fail-closed plan-unavailable path in runEpicTick fires.
|
|
470
|
+
const status = err.status;
|
|
471
|
+
if (status === 404)
|
|
472
|
+
return null;
|
|
473
|
+
throw err;
|
|
474
|
+
}
|
|
475
|
+
if (!response || !response.plan_blob)
|
|
476
|
+
return null;
|
|
477
|
+
const dag = response.plan_blob;
|
|
478
|
+
cachedPlanVersion = response.plan_version;
|
|
479
|
+
// Populate the automation map for the dispatch seam.
|
|
480
|
+
for (const node of dag.nodes) {
|
|
481
|
+
const kind = node.automations?.[0]?.kind;
|
|
482
|
+
if (kind) {
|
|
483
|
+
automationMap.set(node.ticket_key.trim(), kind);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
// Map DAG nodes to EpicTicketNode (drop automations/status/edges).
|
|
487
|
+
const tickets = dag.nodes.map((n) => ({
|
|
488
|
+
ticket_key: n.ticket_key.trim(),
|
|
489
|
+
depends_on: (n.depends_on ?? []).map((k) => k.trim()),
|
|
490
|
+
}));
|
|
491
|
+
// Recompute the hash locally — do NOT trust the server-returned plan_hash.
|
|
492
|
+
// This makes the integrity gate fail-closed: a tampered or drifted blob
|
|
493
|
+
// will hash differently than approved_plan_hash and halt the tick.
|
|
494
|
+
const planHash = hashPlan(dag);
|
|
495
|
+
return { plan_hash: planHash, plan_version: response.plan_version, tickets };
|
|
496
|
+
};
|
|
497
|
+
const dispatchSeam = async (ek, tk) => {
|
|
498
|
+
// Guard: fetchPlan must run before dispatchSeam so cachedPlanVersion and
|
|
499
|
+
// automationMap are populated. A zero version means the factory seam was
|
|
500
|
+
// wired but fetchPlan was never called — fail explicitly rather than silently
|
|
501
|
+
// creating a dispatch key with version 0.
|
|
502
|
+
if (cachedPlanVersion === 0) {
|
|
503
|
+
throw new Error(`dispatchSeam called before fetchPlan for epic ${ek} ticket ${tk}; cachedPlanVersion is 0`);
|
|
504
|
+
}
|
|
505
|
+
const kind = automationMap.get(tk) ?? "start-tickets";
|
|
506
|
+
const identity = {
|
|
507
|
+
epic_key: ek,
|
|
508
|
+
epic_run_id: ek,
|
|
509
|
+
plan_version: cachedPlanVersion,
|
|
510
|
+
dispatch_key: buildEpicDispatchKey(ek, tk, cachedPlanVersion),
|
|
511
|
+
};
|
|
512
|
+
const deps = createDefaultStartTicketsDeps();
|
|
513
|
+
let runId;
|
|
514
|
+
if (kind === "review-tickets") {
|
|
515
|
+
const result = await orchestrateReviewTickets(deps, {
|
|
516
|
+
keys: [tk],
|
|
517
|
+
epic: identity,
|
|
518
|
+
agentName: "claude",
|
|
519
|
+
dryRun: false,
|
|
520
|
+
maxParallel: 1,
|
|
521
|
+
auto: true,
|
|
522
|
+
reviewOverrides: {},
|
|
523
|
+
});
|
|
524
|
+
if (!result.ok) {
|
|
525
|
+
throw new Error(`review-tickets dispatch failed: ${result.error}`);
|
|
526
|
+
}
|
|
527
|
+
runId = result.rows[0]?.runId;
|
|
528
|
+
}
|
|
529
|
+
else {
|
|
530
|
+
const result = await orchestrateStartTickets(deps, {
|
|
531
|
+
keys: [tk],
|
|
532
|
+
epic: identity,
|
|
533
|
+
agentName: "claude",
|
|
534
|
+
dryRun: false,
|
|
535
|
+
autoApprove: true,
|
|
536
|
+
maxParallel: 1,
|
|
537
|
+
refreshMain: false,
|
|
538
|
+
branchOverrides: {},
|
|
539
|
+
baseBranch: "main",
|
|
540
|
+
});
|
|
541
|
+
if (!result.ok) {
|
|
542
|
+
throw new Error(`start-tickets dispatch failed: ${result.error}`);
|
|
543
|
+
}
|
|
544
|
+
runId = result.rows[0]?.runId;
|
|
545
|
+
}
|
|
546
|
+
if (!runId) {
|
|
547
|
+
throw new Error(`dispatch returned no runId for ticket ${tk}`);
|
|
548
|
+
}
|
|
549
|
+
return runId;
|
|
550
|
+
};
|
|
551
|
+
const fetchLocalEvents = (_ek) => {
|
|
552
|
+
// Workers and the epic-tick process share the same local SQLite ledger
|
|
553
|
+
// (~/.config/bridge/events.db). pollConductorEvents opens it read-only.
|
|
554
|
+
const result = pollConductorEvents({ data_mode: "full" });
|
|
555
|
+
return result.events;
|
|
556
|
+
};
|
|
557
|
+
const escalateOnce = async (ek, reason) => {
|
|
558
|
+
const candidate = {
|
|
559
|
+
reason,
|
|
560
|
+
kind: "escalation",
|
|
561
|
+
worker_id: null,
|
|
562
|
+
state: null,
|
|
563
|
+
liveness: null,
|
|
564
|
+
elapsed_ms: 0,
|
|
565
|
+
ambiguous: false,
|
|
566
|
+
context: {},
|
|
567
|
+
};
|
|
568
|
+
// Attempt to extract a ticket key from structured reason strings like
|
|
569
|
+
// "dispatch-orphan:TICKET-123".
|
|
570
|
+
const ticketMatch = /^dispatch-orphan:(.+)$/.exec(reason);
|
|
571
|
+
if (ticketMatch) {
|
|
572
|
+
candidate.worker_id = ticketMatch[1];
|
|
573
|
+
candidate.context = { ticket_key: ticketMatch[1] };
|
|
574
|
+
}
|
|
575
|
+
const assessment = {
|
|
576
|
+
classification: "stuck",
|
|
577
|
+
confidence: 1,
|
|
578
|
+
should_escalate: true,
|
|
579
|
+
reason,
|
|
580
|
+
draft_escalation_text: null,
|
|
581
|
+
source: "degraded",
|
|
582
|
+
};
|
|
583
|
+
const idempotencyKey = makeSupervisorIdempotencyKey({
|
|
584
|
+
run_id: ek,
|
|
585
|
+
worker_id: candidate.worker_id,
|
|
586
|
+
reason,
|
|
587
|
+
kind: "escalation",
|
|
588
|
+
cooldown_window: "epic-escalation",
|
|
589
|
+
});
|
|
590
|
+
await dispatchSupervisorNotification(ek, candidate, assessment, idempotencyKey);
|
|
591
|
+
};
|
|
592
|
+
// postActionWaitSeam: the inline parse-after-merge path in runEpicTick
|
|
593
|
+
// (epic-runtime.ts Step 3.5) is the authoritative execution path when
|
|
594
|
+
// post_action_waits.parse_after_merge.enabled is true. The postActionWaitSeam
|
|
595
|
+
// in reconcile is a scheduling hook for a separate async wait; wiring it to
|
|
596
|
+
// a real action here would cause double-running. Leave it as a no-op.
|
|
597
|
+
const postActionWaitSeam = async (_ek, _tk) => { };
|
|
598
|
+
void epicKey; // epicKey reserved for future factory-level per-epic setup
|
|
599
|
+
// Note: `access` is not closed over by the returned seams — fetchPlan receives
|
|
600
|
+
// credentials as a parameter from runEpicTick's resolver, and dispatchSeam
|
|
601
|
+
// calls createDefaultStartTicketsDeps() which resolves credentials independently.
|
|
602
|
+
// The variable exists only to satisfy the availability guard above.
|
|
603
|
+
void access;
|
|
604
|
+
return {
|
|
605
|
+
fetchPlan,
|
|
606
|
+
dispatchSeam,
|
|
607
|
+
fetchLocalEvents,
|
|
608
|
+
escalateOnce,
|
|
609
|
+
postActionWaitSeam,
|
|
610
|
+
};
|
|
611
|
+
}
|