@bridge_gpt/mcp-server 0.2.42 → 0.2.43
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 +321 -182
- package/build/agents.generated.js +2 -2
- package/build/claude-review-workflow.js +510 -45
- package/build/commands.generated.js +4 -3
- package/build/conduct-epic/cli.js +195 -0
- package/build/conductor/bridge-api-client.js +121 -0
- package/build/conductor/cli.js +63 -0
- package/build/conductor/recovery-cli.js +313 -0
- package/build/conductor/recovery-operations.js +219 -0
- package/build/conductor-bin.js +9 -5
- package/build/docs.generated.js +2 -1
- package/build/doctor.js +13 -3
- package/build/drive-epic.js +375 -0
- package/build/executor/http-client.js +71 -3
- package/build/executor/job-errors.js +9 -0
- package/build/executor/job-runner.js +50 -3
- package/build/executor/observation.js +105 -13
- package/build/executor/runner.js +219 -0
- package/build/executor/worker-finalization.js +233 -56
- package/build/executor/worktree.js +8 -1
- package/build/index.js +2156 -98
- package/build/install-bridge.js +23 -9
- package/build/pipelines.generated.js +304 -14
- package/build/plane/cli.js +73 -7
- package/build/plane/defaults.js +14 -4
- package/build/plane/manifest.js +90 -0
- package/build/plane/preflight.js +19 -0
- package/build/plane/shutdown.js +71 -3
- package/build/readme.generated.js +1 -1
- package/build/run-unit-tests-launcher.js +75 -5
- package/build/setup-epic.js +82 -8
- package/build/version.generated.js +2 -1
- package/build/worktree-core.js +31 -17
- package/package.json +1 -1
- package/pipelines/greenfield-setup.json +286 -0
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `conductor` operator recovery verbs (BAPI-872): `stop-run`, `abandon-run`,
|
|
3
|
+
* `unpark`, `adopt-current-head-and-unpark`.
|
|
4
|
+
*
|
|
5
|
+
* CLI-only by design — this is an explicit ticket non-goal, not an oversight:
|
|
6
|
+
* none of these four command names is ever registered as an MCP tool, tool
|
|
7
|
+
* description, or generated tool-schema input (`index-static.test.ts` guards
|
|
8
|
+
* this). They exist so an operator (or `plane down`, via
|
|
9
|
+
* `recovery-operations.ts`) can recover a stuck run or ticket WITHOUT
|
|
10
|
+
* hand-rolling an HTTP request — the exact thing setup-epic's old raw-PATCH
|
|
11
|
+
* guidance used to ask for.
|
|
12
|
+
*
|
|
13
|
+
* Every ticket command's `row_version` CAS handling is entirely internal to
|
|
14
|
+
* `recovery-operations.ts`. This surface never accepts, echoes, or asks the
|
|
15
|
+
* operator for a version counter — see {@link REJECTED_VERSION_FLAGS}.
|
|
16
|
+
*/
|
|
17
|
+
import { ConductorValidationError, toConductorErrorEnvelope } from "./errors.js";
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Flag parsing
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
const RUN_VALUE_FLAGS = new Set(["--epic-run-id"]);
|
|
22
|
+
const TICKET_VALUE_FLAGS = new Set(["--epic-run-id", "--ticket-key"]);
|
|
23
|
+
const RECOVERY_BOOL_FLAGS = new Set(["--json", "--help"]);
|
|
24
|
+
/**
|
|
25
|
+
* Every spelling of an operator-supplied version counter this surface must
|
|
26
|
+
* refuse outright (BAPI-872) — row_version handling is internal to
|
|
27
|
+
* {@link module:./recovery-operations}, and no ticket command may accept one.
|
|
28
|
+
*/
|
|
29
|
+
const REJECTED_VERSION_FLAGS = new Set([
|
|
30
|
+
"--row-version",
|
|
31
|
+
"--expected-row-version",
|
|
32
|
+
"--row_version",
|
|
33
|
+
"--expected_row_version",
|
|
34
|
+
]);
|
|
35
|
+
/**
|
|
36
|
+
* Tokenize `--flag value` / `--flag=value` / boolean flags for the recovery
|
|
37
|
+
* verbs. A rejected version flag throws BEFORE its value (if any) is ever
|
|
38
|
+
* read, so the offending value is never echoed, normalized, or invited.
|
|
39
|
+
*/
|
|
40
|
+
function tokenizeRecoveryFlags(argv, valueFlags, boolFlags) {
|
|
41
|
+
const values = new Map();
|
|
42
|
+
const bools = new Set();
|
|
43
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
44
|
+
const token = argv[i];
|
|
45
|
+
if (token === "-h") {
|
|
46
|
+
bools.add("--help");
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (!token.startsWith("--")) {
|
|
50
|
+
throw new ConductorValidationError(`Unexpected argument "${token}".`);
|
|
51
|
+
}
|
|
52
|
+
const eq = token.indexOf("=");
|
|
53
|
+
const name = eq >= 0 ? token.slice(0, eq) : token;
|
|
54
|
+
if (REJECTED_VERSION_FLAGS.has(name)) {
|
|
55
|
+
throw new ConductorValidationError(`Flag "${name}" is not supported here — row-version handling is internal to ` +
|
|
56
|
+
"recovery and cannot be supplied by the operator. Omit it; recovery reads the " +
|
|
57
|
+
"current version itself.");
|
|
58
|
+
}
|
|
59
|
+
if (boolFlags.has(name)) {
|
|
60
|
+
bools.add(name);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (!valueFlags.has(name)) {
|
|
64
|
+
throw new ConductorValidationError(`Unknown flag "${name}".`);
|
|
65
|
+
}
|
|
66
|
+
let value;
|
|
67
|
+
if (eq >= 0) {
|
|
68
|
+
value = token.slice(eq + 1);
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
const next = argv[i + 1];
|
|
72
|
+
if (next === undefined) {
|
|
73
|
+
throw new ConductorValidationError(`Flag "${name}" requires a value.`);
|
|
74
|
+
}
|
|
75
|
+
value = next;
|
|
76
|
+
i += 1;
|
|
77
|
+
}
|
|
78
|
+
values.set(name, value);
|
|
79
|
+
}
|
|
80
|
+
return { values, bools };
|
|
81
|
+
}
|
|
82
|
+
function parseRunRecoveryArgs(argv) {
|
|
83
|
+
const { values, bools } = tokenizeRecoveryFlags(argv, RUN_VALUE_FLAGS, RECOVERY_BOOL_FLAGS);
|
|
84
|
+
if (bools.has("--help")) {
|
|
85
|
+
return { epicRunId: "", json: bools.has("--json"), help: true };
|
|
86
|
+
}
|
|
87
|
+
const epicRunId = values.get("--epic-run-id");
|
|
88
|
+
if (epicRunId === undefined || epicRunId.trim().length === 0) {
|
|
89
|
+
throw new ConductorValidationError('Flag "--epic-run-id" is required and must be non-empty.');
|
|
90
|
+
}
|
|
91
|
+
return { epicRunId: epicRunId.trim(), json: bools.has("--json"), help: false };
|
|
92
|
+
}
|
|
93
|
+
function parseTicketRecoveryArgs(argv) {
|
|
94
|
+
const { values, bools } = tokenizeRecoveryFlags(argv, TICKET_VALUE_FLAGS, RECOVERY_BOOL_FLAGS);
|
|
95
|
+
if (bools.has("--help")) {
|
|
96
|
+
return { epicRunId: "", ticketKey: "", json: bools.has("--json"), help: true };
|
|
97
|
+
}
|
|
98
|
+
const epicRunId = values.get("--epic-run-id");
|
|
99
|
+
if (epicRunId === undefined || epicRunId.trim().length === 0) {
|
|
100
|
+
throw new ConductorValidationError('Flag "--epic-run-id" is required and must be non-empty.');
|
|
101
|
+
}
|
|
102
|
+
const ticketKey = values.get("--ticket-key");
|
|
103
|
+
if (ticketKey === undefined || ticketKey.trim().length === 0) {
|
|
104
|
+
throw new ConductorValidationError('Flag "--ticket-key" is required and must be non-empty.');
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
epicRunId: epicRunId.trim(),
|
|
108
|
+
ticketKey: ticketKey.trim(),
|
|
109
|
+
json: bools.has("--json"),
|
|
110
|
+
help: false,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// Shared access resolution
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
/**
|
|
117
|
+
* Resolve Bridge API access through the shared credential-store resolution
|
|
118
|
+
* (never `BAPI_API_KEY` read directly). Prints the sanitized failure itself
|
|
119
|
+
* and returns `null` on failure so the caller can return exit code `1`
|
|
120
|
+
* without duplicating the rendering.
|
|
121
|
+
*/
|
|
122
|
+
async function resolveRecoveryAccess(json) {
|
|
123
|
+
const { resolveConductorBridgeApiAccess } = await import("./bridge-api-client.js");
|
|
124
|
+
const result = await resolveConductorBridgeApiAccess();
|
|
125
|
+
if (!result.ok) {
|
|
126
|
+
if (json) {
|
|
127
|
+
console.log(JSON.stringify({ ok: false, kind: "unauthorized", error: result.error }));
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
console.error(`Error: ${result.error}`);
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
return result.access;
|
|
135
|
+
}
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
// stop-run
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
const STOP_RUN_USAGE = [
|
|
140
|
+
"Usage: conductor stop-run --epic-run-id <id> [--json]",
|
|
141
|
+
"",
|
|
142
|
+
"Stop an epic run: block new dispatch and cancel its queued work. The run",
|
|
143
|
+
"record is preserved (never deleted) so its history stays inspectable.",
|
|
144
|
+
"Idempotent — repeating it once stopped is a safe no-op.",
|
|
145
|
+
].join("\n");
|
|
146
|
+
function renderStopRunResult(result, json) {
|
|
147
|
+
if (json) {
|
|
148
|
+
console.log(JSON.stringify(result));
|
|
149
|
+
return result.ok ? 0 : 1;
|
|
150
|
+
}
|
|
151
|
+
if (result.ok && result.kind === "committed") {
|
|
152
|
+
console.log(`Run ${result.epicRunId}: stopped. Its queued work was cancelled; the run record ` +
|
|
153
|
+
"remains available for inspection.");
|
|
154
|
+
return 0;
|
|
155
|
+
}
|
|
156
|
+
if (result.ok && result.kind === "already-stopped") {
|
|
157
|
+
console.log(`Run ${result.epicRunId}: already stopped; no further action.`);
|
|
158
|
+
return 0;
|
|
159
|
+
}
|
|
160
|
+
if (!result.ok && result.kind === "terminal") {
|
|
161
|
+
console.error(`Run ${result.epicRunId}: terminal (${result.status}) and cannot be stopped.`);
|
|
162
|
+
return 1;
|
|
163
|
+
}
|
|
164
|
+
console.error(`Run ${result.epicRunId}: could not be stopped — ${result.message}.`);
|
|
165
|
+
return 1;
|
|
166
|
+
}
|
|
167
|
+
export async function runStopRunCommand(argv) {
|
|
168
|
+
try {
|
|
169
|
+
const parsed = parseRunRecoveryArgs(argv);
|
|
170
|
+
if (parsed.help) {
|
|
171
|
+
console.log(STOP_RUN_USAGE);
|
|
172
|
+
return 0;
|
|
173
|
+
}
|
|
174
|
+
const access = await resolveRecoveryAccess(parsed.json);
|
|
175
|
+
if (!access)
|
|
176
|
+
return 1;
|
|
177
|
+
const { stopEpicRunRecovery } = await import("./recovery-operations.js");
|
|
178
|
+
const result = await stopEpicRunRecovery(access, { epicRunId: parsed.epicRunId });
|
|
179
|
+
return renderStopRunResult(result, parsed.json);
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
console.error(`Error: ${toConductorErrorEnvelope(error).message}`);
|
|
183
|
+
return 1;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
// abandon-run
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
const ABANDON_RUN_USAGE = [
|
|
190
|
+
"Usage: conductor abandon-run --epic-run-id <id> [--json]",
|
|
191
|
+
"",
|
|
192
|
+
"Abandon an epic run — a TERMINAL, IRREVERSIBLE transition. An abandoned run",
|
|
193
|
+
"can never resume or be reused; start a new run instead. Idempotent —",
|
|
194
|
+
"repeating it on an already-abandoned run is a safe no-op.",
|
|
195
|
+
].join("\n");
|
|
196
|
+
function renderAbandonRunResult(result, json) {
|
|
197
|
+
if (json) {
|
|
198
|
+
console.log(JSON.stringify(result));
|
|
199
|
+
return result.ok ? 0 : 1;
|
|
200
|
+
}
|
|
201
|
+
if (result.ok && result.kind === "abandoned") {
|
|
202
|
+
console.log(`Run ${result.epicRunId}: abandoned (terminal).`);
|
|
203
|
+
return 0;
|
|
204
|
+
}
|
|
205
|
+
if (result.ok && result.kind === "already-abandoned") {
|
|
206
|
+
console.log(`Run ${result.epicRunId}: already abandoned (terminal); no further action.`);
|
|
207
|
+
return 0;
|
|
208
|
+
}
|
|
209
|
+
console.error(`Run ${result.epicRunId}: could not be abandoned — ${result.message}.`);
|
|
210
|
+
return 1;
|
|
211
|
+
}
|
|
212
|
+
export async function runAbandonRunCommand(argv) {
|
|
213
|
+
try {
|
|
214
|
+
const parsed = parseRunRecoveryArgs(argv);
|
|
215
|
+
if (parsed.help) {
|
|
216
|
+
console.log(ABANDON_RUN_USAGE);
|
|
217
|
+
return 0;
|
|
218
|
+
}
|
|
219
|
+
const access = await resolveRecoveryAccess(parsed.json);
|
|
220
|
+
if (!access)
|
|
221
|
+
return 1;
|
|
222
|
+
const { abandonEpicRunRecovery } = await import("./recovery-operations.js");
|
|
223
|
+
const result = await abandonEpicRunRecovery(access, { epicRunId: parsed.epicRunId });
|
|
224
|
+
return renderAbandonRunResult(result, parsed.json);
|
|
225
|
+
}
|
|
226
|
+
catch (error) {
|
|
227
|
+
console.error(`Error: ${toConductorErrorEnvelope(error).message}`);
|
|
228
|
+
return 1;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
// ---------------------------------------------------------------------------
|
|
232
|
+
// unpark / adopt-current-head-and-unpark (shared rendering)
|
|
233
|
+
// ---------------------------------------------------------------------------
|
|
234
|
+
const UNPARK_USAGE = [
|
|
235
|
+
"Usage: conductor unpark --epic-run-id <id> --ticket-key <key> [--json]",
|
|
236
|
+
"",
|
|
237
|
+
"Move a parked (needs_human) ticket back into its gate machine, once the",
|
|
238
|
+
"operator has resolved what parked it. Retries a bounded number of times on",
|
|
239
|
+
"its own if the ticket changes concurrently — never accepts a version counter.",
|
|
240
|
+
].join("\n");
|
|
241
|
+
const ADOPT_CURRENT_HEAD_USAGE = [
|
|
242
|
+
"Usage: conductor adopt-current-head-and-unpark --epic-run-id <id> --ticket-key <key> [--json]",
|
|
243
|
+
"",
|
|
244
|
+
"Recover a ticket parked because a human/external push drifted the PR head",
|
|
245
|
+
"off its anchored commit: adopt the CURRENT PR head and unpark in one step.",
|
|
246
|
+
"Retries a bounded number of times on its own if the ticket changes",
|
|
247
|
+
"concurrently — never accepts a version counter.",
|
|
248
|
+
].join("\n");
|
|
249
|
+
function renderTicketRecoveryResult(result, json) {
|
|
250
|
+
if (json) {
|
|
251
|
+
console.log(JSON.stringify(result));
|
|
252
|
+
return result.ok ? 0 : 1;
|
|
253
|
+
}
|
|
254
|
+
if (result.ok) {
|
|
255
|
+
console.log(`Ticket ${result.ticketKey} (run ${result.epicRunId}): unparked — status: ${result.status}.`);
|
|
256
|
+
return 0;
|
|
257
|
+
}
|
|
258
|
+
if (result.kind === "ticket-not-found") {
|
|
259
|
+
console.error(`Ticket ${result.ticketKey}: not found in run ${result.epicRunId}.`);
|
|
260
|
+
return 1;
|
|
261
|
+
}
|
|
262
|
+
if (result.kind === "concurrent-change-exhausted") {
|
|
263
|
+
console.error(`Ticket ${result.ticketKey} (run ${result.epicRunId}): could not recover — it kept ` +
|
|
264
|
+
"changing concurrently. Re-check the run/ticket status and try again.");
|
|
265
|
+
return 1;
|
|
266
|
+
}
|
|
267
|
+
console.error(`Ticket ${result.ticketKey} (run ${result.epicRunId}): could not recover — ${result.message}.`);
|
|
268
|
+
return 1;
|
|
269
|
+
}
|
|
270
|
+
export async function runUnparkCommand(argv) {
|
|
271
|
+
try {
|
|
272
|
+
const parsed = parseTicketRecoveryArgs(argv);
|
|
273
|
+
if (parsed.help) {
|
|
274
|
+
console.log(UNPARK_USAGE);
|
|
275
|
+
return 0;
|
|
276
|
+
}
|
|
277
|
+
const access = await resolveRecoveryAccess(parsed.json);
|
|
278
|
+
if (!access)
|
|
279
|
+
return 1;
|
|
280
|
+
const { unparkEpicTicketWithRetry } = await import("./recovery-operations.js");
|
|
281
|
+
const result = await unparkEpicTicketWithRetry(access, {
|
|
282
|
+
epicRunId: parsed.epicRunId,
|
|
283
|
+
ticketKey: parsed.ticketKey,
|
|
284
|
+
});
|
|
285
|
+
return renderTicketRecoveryResult(result, parsed.json);
|
|
286
|
+
}
|
|
287
|
+
catch (error) {
|
|
288
|
+
console.error(`Error: ${toConductorErrorEnvelope(error).message}`);
|
|
289
|
+
return 1;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
export async function runAdoptCurrentHeadAndUnparkCommand(argv) {
|
|
293
|
+
try {
|
|
294
|
+
const parsed = parseTicketRecoveryArgs(argv);
|
|
295
|
+
if (parsed.help) {
|
|
296
|
+
console.log(ADOPT_CURRENT_HEAD_USAGE);
|
|
297
|
+
return 0;
|
|
298
|
+
}
|
|
299
|
+
const access = await resolveRecoveryAccess(parsed.json);
|
|
300
|
+
if (!access)
|
|
301
|
+
return 1;
|
|
302
|
+
const { adoptCurrentHeadAndUnparkWithRetry } = await import("./recovery-operations.js");
|
|
303
|
+
const result = await adoptCurrentHeadAndUnparkWithRetry(access, {
|
|
304
|
+
epicRunId: parsed.epicRunId,
|
|
305
|
+
ticketKey: parsed.ticketKey,
|
|
306
|
+
});
|
|
307
|
+
return renderTicketRecoveryResult(result, parsed.json);
|
|
308
|
+
}
|
|
309
|
+
catch (error) {
|
|
310
|
+
console.error(`Error: ${toConductorErrorEnvelope(error).message}`);
|
|
311
|
+
return 1;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Bridge API operator-recovery operations (BAPI-872).
|
|
3
|
+
*
|
|
4
|
+
* Centralizes stop, abandon, and ticket unpark/adopt-current-head-and-unpark so
|
|
5
|
+
* the conductor CLI recovery verbs (`recovery-cli.ts`) and `plane down`
|
|
6
|
+
* (`../plane/shutdown.ts`, wired through `../plane/cli.ts`) share EXACTLY the
|
|
7
|
+
* same stop semantics, and so this module — never a caller — owns every
|
|
8
|
+
* ticket-recovery `row_version` read, retry, and idempotency-key decision.
|
|
9
|
+
*
|
|
10
|
+
* This module is a pure result-classification layer over the typed Bridge API
|
|
11
|
+
* client (`bridge-api-client.ts`). It prints nothing, never touches argv, and
|
|
12
|
+
* never renders operator-facing text — every result here is a discriminated
|
|
13
|
+
* union carrying only semantic outcomes and identifiers, and deliberately never
|
|
14
|
+
* a `row_version` or other CAS-internal value. Rendering belongs to the two
|
|
15
|
+
* callers: `recovery-cli.ts` and `plane/shutdown.ts`.
|
|
16
|
+
*/
|
|
17
|
+
import { randomUUID } from "crypto";
|
|
18
|
+
import { adoptCurrentHeadAndUnparkTicket, ConductorBridgeApiError, fetchEpicRunState, safeDiagnosticMessage, stopEpicRun, unparkEpicTicket, updateEpicRunStatus, } from "./bridge-api-client.js";
|
|
19
|
+
/**
|
|
20
|
+
* Bounded total attempts for a ticket-recovery CAS retry loop. Each attempt
|
|
21
|
+
* begins with a fresh, authoritative state read — so this bounds full
|
|
22
|
+
* read-then-mutate cycles, not raw HTTP calls.
|
|
23
|
+
*/
|
|
24
|
+
export const RECOVERY_TICKET_RETRY_LIMIT = 3;
|
|
25
|
+
/**
|
|
26
|
+
* Stop an epic run through the existing `stop` endpoint, classifying every
|
|
27
|
+
* outcome the endpoint can produce: a freshly committed stop, an idempotent
|
|
28
|
+
* repeat, a terminal refusal, or an unreachable/failed API.
|
|
29
|
+
*
|
|
30
|
+
* State is re-read ONLY on a terminal refusal, to report the run's actual
|
|
31
|
+
* `done`/`abandoned` status — never on the ordinary committed or idempotent
|
|
32
|
+
* paths, which need no second read.
|
|
33
|
+
*/
|
|
34
|
+
export async function stopEpicRunRecovery(access, options) {
|
|
35
|
+
try {
|
|
36
|
+
const response = await stopEpicRun(access, {
|
|
37
|
+
epicRunId: options.epicRunId,
|
|
38
|
+
reason: options.reason,
|
|
39
|
+
});
|
|
40
|
+
return response.committed
|
|
41
|
+
? { ok: true, kind: "committed", epicRunId: options.epicRunId }
|
|
42
|
+
: { ok: true, kind: "already-stopped", epicRunId: options.epicRunId };
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
if (err instanceof ConductorBridgeApiError &&
|
|
46
|
+
err.status === 409 &&
|
|
47
|
+
err.errorCode === "RUN_TERMINAL") {
|
|
48
|
+
const status = await readTerminalRunStatus(access, options.epicRunId);
|
|
49
|
+
return { ok: false, kind: "terminal", epicRunId: options.epicRunId, status };
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
ok: false,
|
|
53
|
+
kind: "unavailable",
|
|
54
|
+
epicRunId: options.epicRunId,
|
|
55
|
+
message: safeDiagnosticMessage(err, "stop request failed"),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Best-effort read of a run's actual status for a terminal-refusal report.
|
|
61
|
+
* Never throws: an unreadable state still renders SOME terminal report rather
|
|
62
|
+
* than losing the stop refusal itself to a secondary read failure.
|
|
63
|
+
*/
|
|
64
|
+
async function readTerminalRunStatus(access, epicRunId) {
|
|
65
|
+
try {
|
|
66
|
+
const state = await fetchEpicRunState(access, epicRunId);
|
|
67
|
+
return state.epic_run.status;
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return "terminal";
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Abandon an epic run through the existing status-CAS PATCH lane
|
|
75
|
+
* (`updateEpicRunStatus`), reading the run's current status first so the PATCH
|
|
76
|
+
* always carries a freshly observed `expectedStatus` — never issued without
|
|
77
|
+
* one. An already-abandoned run is a safe no-op success; a lost CAS (the
|
|
78
|
+
* status moved concurrently) fails safely rather than retrying an unobserved
|
|
79
|
+
* transition or re-issuing an unguarded PATCH.
|
|
80
|
+
*/
|
|
81
|
+
export async function abandonEpicRunRecovery(access, options) {
|
|
82
|
+
let state;
|
|
83
|
+
try {
|
|
84
|
+
state = await fetchEpicRunState(access, options.epicRunId);
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
return {
|
|
88
|
+
ok: false,
|
|
89
|
+
kind: "unavailable",
|
|
90
|
+
epicRunId: options.epicRunId,
|
|
91
|
+
message: safeDiagnosticMessage(err, "could not read run state"),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
const currentStatus = state.epic_run.status;
|
|
95
|
+
if (currentStatus === "abandoned") {
|
|
96
|
+
return { ok: true, kind: "already-abandoned", epicRunId: options.epicRunId };
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
await updateEpicRunStatus(access, {
|
|
100
|
+
epicKey: options.epicRunId,
|
|
101
|
+
status: "abandoned",
|
|
102
|
+
expectedStatus: currentStatus,
|
|
103
|
+
});
|
|
104
|
+
return { ok: true, kind: "abandoned", epicRunId: options.epicRunId };
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
if (err instanceof ConductorBridgeApiError && err.status === 400) {
|
|
108
|
+
// The status-CAS PATCH refused: the run's status moved concurrently since
|
|
109
|
+
// the read above. Never retried here — an unobserved transition must not
|
|
110
|
+
// be guessed at, and this is precisely why abandon reads state fresh on
|
|
111
|
+
// every invocation rather than caching or reusing a prior read.
|
|
112
|
+
return {
|
|
113
|
+
ok: false,
|
|
114
|
+
kind: "concurrent-change",
|
|
115
|
+
epicRunId: options.epicRunId,
|
|
116
|
+
message: "the run's status changed concurrently; re-check its state and retry",
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
ok: false,
|
|
121
|
+
kind: "unavailable",
|
|
122
|
+
epicRunId: options.epicRunId,
|
|
123
|
+
message: safeDiagnosticMessage(err, "abandon request failed"),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* The shared bounded-retry engine behind both ticket recovery operations.
|
|
129
|
+
*
|
|
130
|
+
* Every attempt — including every retry after a CAS conflict — begins with a
|
|
131
|
+
* fresh, authoritative `fetchEpicRunState` read. A conflict's own
|
|
132
|
+
* `current_row_version` is NEVER substituted for that reread: the reread is
|
|
133
|
+
* what proves the version is authoritative, not merely the latest one the
|
|
134
|
+
* conflict payload happened to report. Bounded to
|
|
135
|
+
* {@link RECOVERY_TICKET_RETRY_LIMIT} full read-then-mutate cycles; exhausting
|
|
136
|
+
* it is reported as `concurrent-change-exhausted`, never as an infinite loop.
|
|
137
|
+
*
|
|
138
|
+
* ONE idempotency key is generated per invocation and reused across every
|
|
139
|
+
* retry within it — a distinct recovery-operation call gets its own key.
|
|
140
|
+
*/
|
|
141
|
+
async function ticketRecoveryWithRetry(access, options, mutate) {
|
|
142
|
+
const idempotencyKey = randomUUID();
|
|
143
|
+
for (let attempt = 0; attempt < RECOVERY_TICKET_RETRY_LIMIT; attempt += 1) {
|
|
144
|
+
let state;
|
|
145
|
+
try {
|
|
146
|
+
state = await fetchEpicRunState(access, options.epicRunId);
|
|
147
|
+
}
|
|
148
|
+
catch (err) {
|
|
149
|
+
return {
|
|
150
|
+
ok: false,
|
|
151
|
+
kind: "unavailable",
|
|
152
|
+
epicRunId: options.epicRunId,
|
|
153
|
+
ticketKey: options.ticketKey,
|
|
154
|
+
message: safeDiagnosticMessage(err, "could not read run state"),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
const ticket = state.ticket_statuses.find((t) => t.ticket_key === options.ticketKey);
|
|
158
|
+
if (!ticket) {
|
|
159
|
+
return {
|
|
160
|
+
ok: false,
|
|
161
|
+
kind: "ticket-not-found",
|
|
162
|
+
epicRunId: options.epicRunId,
|
|
163
|
+
ticketKey: options.ticketKey,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
let result;
|
|
167
|
+
try {
|
|
168
|
+
result = await mutate(access, {
|
|
169
|
+
epicRunId: options.epicRunId,
|
|
170
|
+
ticketKey: options.ticketKey,
|
|
171
|
+
expectedRowVersion: ticket.row_version,
|
|
172
|
+
idempotencyKey,
|
|
173
|
+
reason: options.reason,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
catch (err) {
|
|
177
|
+
return {
|
|
178
|
+
ok: false,
|
|
179
|
+
kind: "unavailable",
|
|
180
|
+
epicRunId: options.epicRunId,
|
|
181
|
+
ticketKey: options.ticketKey,
|
|
182
|
+
message: safeDiagnosticMessage(err, "recovery request failed"),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
if (result.ok) {
|
|
186
|
+
return {
|
|
187
|
+
ok: true,
|
|
188
|
+
kind: "unparked",
|
|
189
|
+
epicRunId: options.epicRunId,
|
|
190
|
+
ticketKey: options.ticketKey,
|
|
191
|
+
status: result.ticket_status.status,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
// `result.kind === "cas-conflict"`: a concurrent write moved the ticket's
|
|
195
|
+
// row_version between our read and our mutate. Loop back to a fresh read —
|
|
196
|
+
// never reuse `result.current_row_version` here.
|
|
197
|
+
}
|
|
198
|
+
return {
|
|
199
|
+
ok: false,
|
|
200
|
+
kind: "concurrent-change-exhausted",
|
|
201
|
+
epicRunId: options.epicRunId,
|
|
202
|
+
ticketKey: options.ticketKey,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Unpark a parked `needs_human` ticket, with a bounded authoritative-reread
|
|
207
|
+
* retry on a concurrent `row_version` bump.
|
|
208
|
+
*/
|
|
209
|
+
export async function unparkEpicTicketWithRetry(access, options) {
|
|
210
|
+
return ticketRecoveryWithRetry(access, options, (a, args) => unparkEpicTicket(a, args));
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Adopt the current PR head and unpark a parked ticket, sharing the identical
|
|
214
|
+
* reread-and-retry discipline. The adopt-current-head client — never the
|
|
215
|
+
* ordinary unpark client — is called for every attempt.
|
|
216
|
+
*/
|
|
217
|
+
export async function adoptCurrentHeadAndUnparkWithRetry(access, options) {
|
|
218
|
+
return ticketRecoveryWithRetry(access, options, (a, args) => adoptCurrentHeadAndUnparkTicket(a, args));
|
|
219
|
+
}
|