@bridge_gpt/mcp-server 0.2.54 → 0.2.55
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 +38 -54
- package/build/agent-guidance.generated.js +3 -0
- package/build/agent-launchers/claude-executor-adapter.js +3 -0
- package/build/agent-notes.js +178 -0
- package/build/agent-registry.js +5 -2
- package/build/agent-utils.js +58 -0
- package/build/agents.generated.js +1 -1
- package/build/codex-skill-adapter.js +55 -0
- package/build/commands.generated.js +5 -4
- package/build/conductor/bridge-api-client.js +199 -6
- package/build/conductor/bring-up-facts.js +187 -0
- package/build/conductor/claude-hook.js +7 -5
- package/build/conductor/cli.js +28 -0
- package/build/conductor/doctor.js +80 -9
- package/build/conductor/epic-implementer-cli.js +1298 -0
- package/build/conductor/epic-runtime.js +1 -1
- package/build/conductor/errors.js +2 -2
- package/build/conductor/git-ci-types.js +1 -1
- package/build/conductor/git-hooks.js +28 -14
- package/build/conductor/install-doctor.js +11 -5
- package/build/conductor/readiness-cli.js +10 -10
- package/build/conductor/readiness-sections.js +58 -9
- package/build/conductor/readiness.js +37 -6
- package/build/conductor/recovery-cli.js +289 -10
- package/build/conductor/recovery-operations.js +125 -2
- package/build/conductor/repair-contract.js +58 -0
- package/build/conductor/store.js +2 -2
- package/build/conductor/supervisor-runtime.js +1 -1
- package/build/conductor-bin.js +2 -139
- package/build/conductor-claude-hook-bin.js +2 -2
- package/build/conductor-claude-hook-removed-stub-bin.js +31 -0
- package/build/conductor-removed-stub-bin.js +30 -0
- package/build/docs.generated.js +1 -1
- package/build/doctor.js +77 -17
- package/build/drive-epic.js +541 -115
- package/build/epic-implementer-bin.js +145 -0
- package/build/epic-implementer-bundle-cli.js +264 -0
- package/build/epic-implementer-claude-hook-bin.js +3 -0
- package/build/epic-integration-pr.js +5 -3
- package/build/executor/env.js +6 -0
- package/build/executor/interrupted-worktree.js +60 -0
- package/build/executor/job-errors.js +45 -0
- package/build/executor/job-runner.js +274 -9
- package/build/executor/job-types.js +25 -9
- package/build/executor/merge-tree-classifier.js +171 -0
- package/build/executor/reconcile-overlap-governance.js +129 -0
- package/build/executor/reconcile-overlap-job.js +989 -0
- package/build/executor/reconcile-overlap-types.js +14 -0
- package/build/executor/spawn-job-driver.js +1 -0
- package/build/executor/types.js +2 -0
- package/build/executor/worker-finalization.js +25 -2
- package/build/executor/worker-guard-hook.js +15 -7
- package/build/implement-epic/bridge-client.js +773 -0
- package/build/implement-epic/checkpoint-store.js +542 -0
- package/build/implement-epic/cli.js +3158 -0
- package/build/implement-epic/cut-protocol.js +392 -0
- package/build/implement-epic/lock.js +302 -0
- package/build/implement-epic/pr-state.js +286 -0
- package/build/implement-epic/spawn.js +113 -0
- package/build/index.js +586 -138
- package/build/init.js +72 -8
- package/build/install-bridge-conductor.js +5 -5
- package/build/install-bridge.js +403 -70
- package/build/mcp-host-config.js +22 -60
- package/build/mcp-host-entry-adapter.js +18 -0
- package/build/mcp-host-targets.js +1 -21
- package/build/merge-pull-request.js +1 -1
- package/build/pipelines.generated.js +7 -7
- package/build/plan-epic-conductor-eligibility.js +1 -1
- package/build/plane/cli.js +36 -5
- package/build/plane/preflight.js +128 -12
- package/build/plane/shutdown.js +4 -4
- package/build/readiness-check.js +3 -3
- package/build/readme.generated.js +1 -1
- package/build/run-unit-tests-launcher.js +1 -1
- package/build/setup-epic.js +69 -31
- package/build/start-tickets-conductor.js +8 -7
- package/build/version.generated.js +3 -3
- package/build/worker-guard-hook-bin.js +1 -1
- package/docs/CONDUCTOR.md +8 -6
- package/package.json +5 -3
|
@@ -1,20 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `conductor` operator recovery verbs
|
|
3
|
-
*
|
|
2
|
+
* `conductor` operator recovery and repair verbs — seven CLI-only commands.
|
|
3
|
+
*
|
|
4
|
+
* The four BAPI-872 recovery verbs: `stop-run`, `abandon-run`, `unpark`,
|
|
5
|
+
* `adopt-current-head-and-unpark`. The three BAPI-1154 run-addressed repair
|
|
6
|
+
* verbs: `scope-reparse`, `dispatch-remediation`, `merge-child`.
|
|
4
7
|
*
|
|
5
8
|
* CLI-only by design — this is an explicit ticket non-goal, not an oversight:
|
|
6
|
-
* none of these
|
|
9
|
+
* none of these seven command names is ever registered as an MCP tool, tool
|
|
7
10
|
* description, or generated tool-schema input (`index-static.test.ts` guards
|
|
8
11
|
* this). They exist so an operator (or `plane down`, via
|
|
9
12
|
* `recovery-operations.ts`) can recover a stuck run or ticket WITHOUT
|
|
10
|
-
* hand-rolling an HTTP request
|
|
11
|
-
* guidance used to ask for.
|
|
13
|
+
* hand-rolling an HTTP request, a DAL call, or a code edit.
|
|
12
14
|
*
|
|
13
15
|
* Every ticket command's `row_version` CAS handling is entirely internal to
|
|
14
16
|
* `recovery-operations.ts`. This surface never accepts, echoes, or asks the
|
|
15
|
-
* operator for a version counter — see {@link REJECTED_VERSION_FLAGS}.
|
|
17
|
+
* operator for a version counter — see {@link REJECTED_VERSION_FLAGS}. Every
|
|
18
|
+
* repair verb takes a mandatory `--epic-run-id`; the SERVER decides whether its
|
|
19
|
+
* target belongs to that run, and a foreign target is refused with nothing
|
|
20
|
+
* written on either side.
|
|
16
21
|
*/
|
|
22
|
+
import { readFile } from "node:fs/promises";
|
|
17
23
|
import { ConductorValidationError, toConductorErrorEnvelope } from "./errors.js";
|
|
24
|
+
import { DRIVER_CONTEXT_MAX_BYTES } from "./repair-contract.js";
|
|
18
25
|
// ---------------------------------------------------------------------------
|
|
19
26
|
// Flag parsing
|
|
20
27
|
// ---------------------------------------------------------------------------
|
|
@@ -63,6 +70,11 @@ function tokenizeRecoveryFlags(argv, valueFlags, boolFlags) {
|
|
|
63
70
|
if (!valueFlags.has(name)) {
|
|
64
71
|
throw new ConductorValidationError(`Unknown flag "${name}".`);
|
|
65
72
|
}
|
|
73
|
+
if (values.has(name)) {
|
|
74
|
+
// A repeated flag is ambiguous — which value did the operator mean? Refused
|
|
75
|
+
// before the second value is read rather than silently keeping the last one.
|
|
76
|
+
throw new ConductorValidationError(`Flag "${name}" may be given only once.`);
|
|
77
|
+
}
|
|
66
78
|
let value;
|
|
67
79
|
if (eq >= 0) {
|
|
68
80
|
value = token.slice(eq + 1);
|
|
@@ -137,7 +149,7 @@ async function resolveRecoveryAccess(json) {
|
|
|
137
149
|
// stop-run
|
|
138
150
|
// ---------------------------------------------------------------------------
|
|
139
151
|
const STOP_RUN_USAGE = [
|
|
140
|
-
"Usage:
|
|
152
|
+
"Usage: epic-implementer stop-run --epic-run-id <id> [--json]",
|
|
141
153
|
"",
|
|
142
154
|
"Stop an epic run: block new dispatch and cancel its queued work. The run",
|
|
143
155
|
"record is preserved (never deleted) so its history stays inspectable.",
|
|
@@ -187,7 +199,7 @@ export async function runStopRunCommand(argv) {
|
|
|
187
199
|
// abandon-run
|
|
188
200
|
// ---------------------------------------------------------------------------
|
|
189
201
|
const ABANDON_RUN_USAGE = [
|
|
190
|
-
"Usage:
|
|
202
|
+
"Usage: epic-implementer abandon-run --epic-run-id <id> [--json]",
|
|
191
203
|
"",
|
|
192
204
|
"Abandon an epic run — a TERMINAL, IRREVERSIBLE transition. An abandoned run",
|
|
193
205
|
"can never resume or be reused; start a new run instead. Idempotent —",
|
|
@@ -232,14 +244,14 @@ export async function runAbandonRunCommand(argv) {
|
|
|
232
244
|
// unpark / adopt-current-head-and-unpark (shared rendering)
|
|
233
245
|
// ---------------------------------------------------------------------------
|
|
234
246
|
const UNPARK_USAGE = [
|
|
235
|
-
"Usage:
|
|
247
|
+
"Usage: epic-implementer unpark --epic-run-id <id> --ticket-key <key> [--json]",
|
|
236
248
|
"",
|
|
237
249
|
"Move a parked (needs_human) ticket back into its gate machine, once the",
|
|
238
250
|
"operator has resolved what parked it. Retries a bounded number of times on",
|
|
239
251
|
"its own if the ticket changes concurrently — never accepts a version counter.",
|
|
240
252
|
].join("\n");
|
|
241
253
|
const ADOPT_CURRENT_HEAD_USAGE = [
|
|
242
|
-
"Usage:
|
|
254
|
+
"Usage: epic-implementer adopt-current-head-and-unpark --epic-run-id <id> --ticket-key <key> [--json]",
|
|
243
255
|
"",
|
|
244
256
|
"Recover a ticket parked because a human/external push drifted the PR head",
|
|
245
257
|
"off its anchored commit: adopt the CURRENT PR head and unpark in one step.",
|
|
@@ -311,3 +323,270 @@ export async function runAdoptCurrentHeadAndUnparkCommand(argv) {
|
|
|
311
323
|
return 1;
|
|
312
324
|
}
|
|
313
325
|
}
|
|
326
|
+
// ---------------------------------------------------------------------------
|
|
327
|
+
// BAPI-1154 — repair verbs: scope-reparse, dispatch-remediation, merge-child
|
|
328
|
+
// ---------------------------------------------------------------------------
|
|
329
|
+
const SCOPE_REPARSE_VALUE_FLAGS = new Set(["--epic-run-id", "--scope-id"]);
|
|
330
|
+
const DISPATCH_REMEDIATION_VALUE_FLAGS = new Set([
|
|
331
|
+
"--epic-run-id",
|
|
332
|
+
"--ticket-key",
|
|
333
|
+
"--context-file",
|
|
334
|
+
]);
|
|
335
|
+
const MERGE_CHILD_VALUE_FLAGS = new Set(["--epic-run-id", "--pr"]);
|
|
336
|
+
function requireFlag(values, flag) {
|
|
337
|
+
const value = values.get(flag);
|
|
338
|
+
if (value === undefined || value.trim().length === 0) {
|
|
339
|
+
throw new ConductorValidationError(`Flag "${flag}" is required and must be non-empty.`);
|
|
340
|
+
}
|
|
341
|
+
return value.trim();
|
|
342
|
+
}
|
|
343
|
+
export function parseScopeReparseArgs(argv) {
|
|
344
|
+
const { values, bools } = tokenizeRecoveryFlags(argv, SCOPE_REPARSE_VALUE_FLAGS, RECOVERY_BOOL_FLAGS);
|
|
345
|
+
if (bools.has("--help")) {
|
|
346
|
+
return { epicRunId: "", scopeId: "", json: bools.has("--json"), help: true };
|
|
347
|
+
}
|
|
348
|
+
return {
|
|
349
|
+
epicRunId: requireFlag(values, "--epic-run-id"),
|
|
350
|
+
scopeId: requireFlag(values, "--scope-id"),
|
|
351
|
+
json: bools.has("--json"),
|
|
352
|
+
help: false,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
export function parseDispatchRemediationArgs(argv) {
|
|
356
|
+
const { values, bools } = tokenizeRecoveryFlags(argv, DISPATCH_REMEDIATION_VALUE_FLAGS, RECOVERY_BOOL_FLAGS);
|
|
357
|
+
if (bools.has("--help")) {
|
|
358
|
+
return { epicRunId: "", ticketKey: "", contextFile: "", json: bools.has("--json"), help: true };
|
|
359
|
+
}
|
|
360
|
+
return {
|
|
361
|
+
epicRunId: requireFlag(values, "--epic-run-id"),
|
|
362
|
+
ticketKey: requireFlag(values, "--ticket-key"),
|
|
363
|
+
contextFile: requireFlag(values, "--context-file"),
|
|
364
|
+
json: bools.has("--json"),
|
|
365
|
+
help: false,
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
export function parseMergeChildArgs(argv) {
|
|
369
|
+
const { values, bools } = tokenizeRecoveryFlags(argv, MERGE_CHILD_VALUE_FLAGS, RECOVERY_BOOL_FLAGS);
|
|
370
|
+
if (bools.has("--help")) {
|
|
371
|
+
return { epicRunId: "", prNumber: 0, json: bools.has("--json"), help: true };
|
|
372
|
+
}
|
|
373
|
+
const epicRunId = requireFlag(values, "--epic-run-id");
|
|
374
|
+
const rawPr = requireFlag(values, "--pr");
|
|
375
|
+
const prNumber = /^[1-9][0-9]*$/.test(rawPr) ? Number(rawPr) : NaN;
|
|
376
|
+
if (!Number.isSafeInteger(prNumber)) {
|
|
377
|
+
throw new ConductorValidationError('Flag "--pr" must be a positive integer pull-request number.');
|
|
378
|
+
}
|
|
379
|
+
return { epicRunId, prNumber, json: bools.has("--json"), help: false };
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Validate and decode a driver-context file's BYTES.
|
|
383
|
+
*
|
|
384
|
+
* Checked in the order that keeps a rejected file from being decoded, resolved
|
|
385
|
+
* against credentials, or transmitted: the byte length first (against the SAME
|
|
386
|
+
* `DRIVER_CONTEXT_MAX_BYTES` the server enforces), then a FATAL UTF-8 decode, then
|
|
387
|
+
* non-whitespace content. Every error names the constraint and never echoes the
|
|
388
|
+
* file's content. Over-cap context is rejected, never truncated.
|
|
389
|
+
*/
|
|
390
|
+
export function decodeDriverContextFile(bytes) {
|
|
391
|
+
if (bytes.byteLength > DRIVER_CONTEXT_MAX_BYTES) {
|
|
392
|
+
throw new ConductorValidationError(`--context-file is ${bytes.byteLength} bytes, over DRIVER_CONTEXT_MAX_BYTES ` +
|
|
393
|
+
`(${DRIVER_CONTEXT_MAX_BYTES} UTF-8 bytes). Shorten it and retry; nothing was sent.`);
|
|
394
|
+
}
|
|
395
|
+
let text;
|
|
396
|
+
try {
|
|
397
|
+
text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
398
|
+
}
|
|
399
|
+
catch {
|
|
400
|
+
throw new ConductorValidationError("--context-file must be valid UTF-8 text; nothing was sent.");
|
|
401
|
+
}
|
|
402
|
+
if (text.trim().length === 0) {
|
|
403
|
+
throw new ConductorValidationError("--context-file is empty or whitespace-only; driver context must say something. " +
|
|
404
|
+
"Nothing was sent.");
|
|
405
|
+
}
|
|
406
|
+
return text;
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* Render a repair outcome. Human mode prints the verb and run first, the target
|
|
410
|
+
* second, ONE labeled `Before → After` pair third, then `committed` and `event
|
|
411
|
+
* recorded` — a replay is a successful no-op (`committed: false`), never a
|
|
412
|
+
* refusal. `--json` prints the server's bounded result object exactly, or a
|
|
413
|
+
* bounded `{ok:false, kind, code, message}` failure. Nothing here can print a
|
|
414
|
+
* prompt, driver context, row version, action key, head SHA, or credential: the
|
|
415
|
+
* results carry none of them.
|
|
416
|
+
*/
|
|
417
|
+
function renderRepairResult(outcome, input, json, extraLines = () => []) {
|
|
418
|
+
if (outcome.ok && outcome.result.committed && !outcome.result.event_recorded) {
|
|
419
|
+
// The ONE warning, and only for a committed action. A replay records nothing
|
|
420
|
+
// by design, so it never warns.
|
|
421
|
+
console.error(`Warning: ${input.verb} on run ${input.epicRunId} (${input.targetLabel}) COMMITTED, ` +
|
|
422
|
+
"but its operator_verb_applied audit event was not recorded. The action stands " +
|
|
423
|
+
"and is not rolled back. You ran it, so you own reconciling the missing audit " +
|
|
424
|
+
"record (the server also reported it to Sentry).");
|
|
425
|
+
}
|
|
426
|
+
if (json) {
|
|
427
|
+
if (outcome.ok) {
|
|
428
|
+
console.log(JSON.stringify(outcome.result));
|
|
429
|
+
return 0;
|
|
430
|
+
}
|
|
431
|
+
console.log(JSON.stringify(outcome.kind === "refused"
|
|
432
|
+
? { ok: false, kind: "refused", code: outcome.code, message: outcome.message }
|
|
433
|
+
: { ok: false, kind: "unavailable", message: outcome.message }));
|
|
434
|
+
return 1;
|
|
435
|
+
}
|
|
436
|
+
if (!outcome.ok) {
|
|
437
|
+
if (outcome.kind === "refused") {
|
|
438
|
+
console.error(`Error: ${input.verb} refused on run ${input.epicRunId} (${input.targetLabel}) — ` +
|
|
439
|
+
`${outcome.code}: ${outcome.message}`);
|
|
440
|
+
}
|
|
441
|
+
else {
|
|
442
|
+
console.error(`Error: ${input.verb} could not complete on run ${input.epicRunId} ` +
|
|
443
|
+
`(${input.targetLabel}) — ${outcome.message}.`);
|
|
444
|
+
}
|
|
445
|
+
return 1;
|
|
446
|
+
}
|
|
447
|
+
const result = outcome.result;
|
|
448
|
+
const lines = [
|
|
449
|
+
`${input.verb} — run ${input.epicRunId}`,
|
|
450
|
+
` target: ${input.targetLabel}`,
|
|
451
|
+
` ${input.stateLabel}: ${result.before ?? "none"} → ${result.after ?? "none"}`,
|
|
452
|
+
...extraLines(result),
|
|
453
|
+
` committed: ${result.committed}${result.committed ? "" : " (already in this state; no change made)"}`,
|
|
454
|
+
` event recorded: ${result.event_recorded}`,
|
|
455
|
+
];
|
|
456
|
+
console.log(lines.join("\n"));
|
|
457
|
+
return 0;
|
|
458
|
+
}
|
|
459
|
+
const SCOPE_REPARSE_USAGE = [
|
|
460
|
+
"Usage: epic-implementer scope-reparse --epic-run-id <id> --scope-id <scope> [--json]",
|
|
461
|
+
"",
|
|
462
|
+
"Re-drive the run's OWN index scope through the retry-reparse lifecycle and",
|
|
463
|
+
"schedule its parse. The scope must be the one bound to the named run, or the",
|
|
464
|
+
"server refuses it (TICKET_NOT_IN_RUN) with nothing written. Prints the scope",
|
|
465
|
+
"lifecycle before → after; it does not wait for the parse to finish.",
|
|
466
|
+
].join("\n");
|
|
467
|
+
export async function runScopeReparseCommand(argv) {
|
|
468
|
+
try {
|
|
469
|
+
const parsed = parseScopeReparseArgs(argv);
|
|
470
|
+
if (parsed.help) {
|
|
471
|
+
console.log(SCOPE_REPARSE_USAGE);
|
|
472
|
+
return 0;
|
|
473
|
+
}
|
|
474
|
+
const access = await resolveRecoveryAccess(parsed.json);
|
|
475
|
+
if (!access)
|
|
476
|
+
return 1;
|
|
477
|
+
const { scopeReparseRepair } = await import("./recovery-operations.js");
|
|
478
|
+
const outcome = await scopeReparseRepair(access, {
|
|
479
|
+
epicRunId: parsed.epicRunId,
|
|
480
|
+
scopeId: parsed.scopeId,
|
|
481
|
+
});
|
|
482
|
+
return renderRepairResult(outcome, {
|
|
483
|
+
verb: "scope-reparse",
|
|
484
|
+
epicRunId: parsed.epicRunId,
|
|
485
|
+
targetLabel: `scope ${parsed.scopeId}`,
|
|
486
|
+
stateLabel: "scope lifecycle",
|
|
487
|
+
}, parsed.json, (result) => [
|
|
488
|
+
` outcome: ${result.outcome}${result.parse_scheduled ? " (parse scheduled)" : ""}`,
|
|
489
|
+
]);
|
|
490
|
+
}
|
|
491
|
+
catch (error) {
|
|
492
|
+
console.error(`Error: ${toConductorErrorEnvelope(error).message}`);
|
|
493
|
+
return 1;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
const DISPATCH_REMEDIATION_USAGE = [
|
|
497
|
+
"Usage: epic-implementer dispatch-remediation --epic-run-id <id> --ticket-key <key> --context-file <path> [--json]",
|
|
498
|
+
"",
|
|
499
|
+
"Dispatch a remediation job for a ticket at code_review (or parked there),",
|
|
500
|
+
"carrying your driver context to the worker. The SERVER chooses the job type",
|
|
501
|
+
"(remediate, or reconcile_overlap for a conflict/overlap ticket) and admits it",
|
|
502
|
+
"through the reconciler's own strict-serial lane, remediation budget, and",
|
|
503
|
+
"dedup key. A parked ticket is resumed in the same step.",
|
|
504
|
+
"",
|
|
505
|
+
`--context-file must be non-empty UTF-8 text of at most DRIVER_CONTEXT_MAX_BYTES`,
|
|
506
|
+
`(${DRIVER_CONTEXT_MAX_BYTES} bytes). It is rejected, never truncated, and is never`,
|
|
507
|
+
"printed, logged, or echoed — it reaches only the worker's prompt.",
|
|
508
|
+
].join("\n");
|
|
509
|
+
export async function runDispatchRemediationCommand(argv, deps = {}) {
|
|
510
|
+
try {
|
|
511
|
+
const parsed = parseDispatchRemediationArgs(argv);
|
|
512
|
+
if (parsed.help) {
|
|
513
|
+
console.log(DISPATCH_REMEDIATION_USAGE);
|
|
514
|
+
return 0;
|
|
515
|
+
}
|
|
516
|
+
// Read and validate the context BEFORE credentials are resolved or the HTTP
|
|
517
|
+
// graph is loaded, so an invalid file costs nothing and reaches no one.
|
|
518
|
+
let bytes;
|
|
519
|
+
try {
|
|
520
|
+
bytes = await (deps.readFile ?? ((p) => readFile(p)))(parsed.contextFile);
|
|
521
|
+
}
|
|
522
|
+
catch {
|
|
523
|
+
throw new ConductorValidationError("--context-file could not be read; nothing was sent.");
|
|
524
|
+
}
|
|
525
|
+
const context = decodeDriverContextFile(bytes);
|
|
526
|
+
const access = await resolveRecoveryAccess(parsed.json);
|
|
527
|
+
if (!access)
|
|
528
|
+
return 1;
|
|
529
|
+
const { dispatchRemediationRepair } = await import("./recovery-operations.js");
|
|
530
|
+
const outcome = await dispatchRemediationRepair(access, {
|
|
531
|
+
epicRunId: parsed.epicRunId,
|
|
532
|
+
ticketKey: parsed.ticketKey,
|
|
533
|
+
context,
|
|
534
|
+
});
|
|
535
|
+
return renderRepairResult(outcome, {
|
|
536
|
+
verb: "dispatch-remediation",
|
|
537
|
+
epicRunId: parsed.epicRunId,
|
|
538
|
+
targetLabel: `ticket ${parsed.ticketKey}`,
|
|
539
|
+
stateLabel: "code_review gate",
|
|
540
|
+
}, parsed.json, (result) => [
|
|
541
|
+
` job: ${result.job_type || "unknown"}${result.job_id !== null ? ` (job ${result.job_id})` : ""}`,
|
|
542
|
+
]);
|
|
543
|
+
}
|
|
544
|
+
catch (error) {
|
|
545
|
+
console.error(`Error: ${toConductorErrorEnvelope(error).message}`);
|
|
546
|
+
return 1;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
const MERGE_CHILD_USAGE = [
|
|
550
|
+
"Usage: epic-implementer merge-child --epic-run-id <id> --pr <number> [--json]",
|
|
551
|
+
"",
|
|
552
|
+
"Merge the run's child pull request through the shared merge service. You give",
|
|
553
|
+
"ONLY the PR number: the server finds the one ticket in the run bound to it",
|
|
554
|
+
"(or refuses, TICKET_NOT_IN_RUN, with nothing written) and composes the head",
|
|
555
|
+
"commit and gate identity itself. Every merge guard applies, and a merge",
|
|
556
|
+
"advances the run's scope on the same path as the MCP merge tool. A PR that is",
|
|
557
|
+
"already merged is a successful no-op.",
|
|
558
|
+
"",
|
|
559
|
+
"When the repository merges locally (the default), your own `gh` performs the",
|
|
560
|
+
"merge — run this from inside the repository's checkout, with `gh auth status`",
|
|
561
|
+
"passing.",
|
|
562
|
+
].join("\n");
|
|
563
|
+
export async function runMergeChildCommand(argv) {
|
|
564
|
+
try {
|
|
565
|
+
const parsed = parseMergeChildArgs(argv);
|
|
566
|
+
if (parsed.help) {
|
|
567
|
+
console.log(MERGE_CHILD_USAGE);
|
|
568
|
+
return 0;
|
|
569
|
+
}
|
|
570
|
+
const access = await resolveRecoveryAccess(parsed.json);
|
|
571
|
+
if (!access)
|
|
572
|
+
return 1;
|
|
573
|
+
const { mergeChildRepair } = await import("./recovery-operations.js");
|
|
574
|
+
const outcome = await mergeChildRepair(access, {
|
|
575
|
+
epicRunId: parsed.epicRunId,
|
|
576
|
+
prNumber: parsed.prNumber,
|
|
577
|
+
});
|
|
578
|
+
return renderRepairResult(outcome, {
|
|
579
|
+
verb: "merge-child",
|
|
580
|
+
epicRunId: parsed.epicRunId,
|
|
581
|
+
targetLabel: `PR #${parsed.prNumber}`,
|
|
582
|
+
stateLabel: "merge",
|
|
583
|
+
}, parsed.json, (result) => [
|
|
584
|
+
...(result.ticket_key ? [` ticket: ${result.ticket_key}`] : []),
|
|
585
|
+
...(result.reason ? [` reason: ${result.reason}`] : []),
|
|
586
|
+
]);
|
|
587
|
+
}
|
|
588
|
+
catch (error) {
|
|
589
|
+
console.error(`Error: ${toConductorErrorEnvelope(error).message}`);
|
|
590
|
+
return 1;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared Bridge API operator-recovery operations (BAPI-872).
|
|
3
3
|
*
|
|
4
|
-
* Centralizes stop, abandon,
|
|
4
|
+
* Centralizes stop, abandon, ticket unpark/adopt-current-head-and-unpark, and
|
|
5
|
+
* (BAPI-1154) the three run-addressed repair verbs — scope-reparse,
|
|
6
|
+
* dispatch-remediation, merge-child — so
|
|
5
7
|
* the conductor CLI recovery verbs (`recovery-cli.ts`) and `plane down`
|
|
6
8
|
* (`../plane/shutdown.ts`, wired through `../plane/cli.ts`) share EXACTLY the
|
|
7
9
|
* same stop semantics, and so this module — never a caller — owns every
|
|
@@ -15,7 +17,9 @@
|
|
|
15
17
|
* callers: `recovery-cli.ts` and `plane/shutdown.ts`.
|
|
16
18
|
*/
|
|
17
19
|
import { randomUUID } from "crypto";
|
|
18
|
-
import { adoptCurrentHeadAndUnparkTicket, ConductorBridgeApiError, fetchEpicRunState, safeDiagnosticMessage, stopEpicRun, unparkEpicTicket, updateEpicRunStatus, } from "./bridge-api-client.js";
|
|
20
|
+
import { adoptCurrentHeadAndUnparkTicket, completeMergeChild, ConductorBridgeApiError, dispatchRemediation, fetchEpicRunState, mergeChild, safeDiagnosticMessage, scopeReparse, stopEpicRun, unparkEpicTicket, updateEpicRunStatus, } from "./bridge-api-client.js";
|
|
21
|
+
import { DEFAULT_GATE_NAME } from "./git-ci-types.js";
|
|
22
|
+
import { resolveLocalMergeMethod, runApprovedLocalMerge } from "./local-merge.js";
|
|
19
23
|
/**
|
|
20
24
|
* Bounded total attempts for a ticket-recovery CAS retry loop. Each attempt
|
|
21
25
|
* begins with a fresh, authoritative state read — so this bounds full
|
|
@@ -217,3 +221,122 @@ export async function unparkEpicTicketWithRetry(access, options) {
|
|
|
217
221
|
export async function adoptCurrentHeadAndUnparkWithRetry(access, options) {
|
|
218
222
|
return ticketRecoveryWithRetry(access, options, (a, args) => adoptCurrentHeadAndUnparkTicket(a, args));
|
|
219
223
|
}
|
|
224
|
+
/**
|
|
225
|
+
* Classify a thrown client error. A 4xx carrying a server `error_code` is a
|
|
226
|
+
* NAMED refusal and keeps that code; everything else (transport, timeout, 5xx
|
|
227
|
+
* without a code, an unparseable body) is `unavailable` with a bounded
|
|
228
|
+
* diagnostic. No raw body, URL, header, or credential ever reaches either shape.
|
|
229
|
+
*/
|
|
230
|
+
function classifyRepairError(err, fallback) {
|
|
231
|
+
if (err instanceof ConductorBridgeApiError && typeof err.status === "number" && err.errorCode) {
|
|
232
|
+
return {
|
|
233
|
+
ok: false,
|
|
234
|
+
kind: "refused",
|
|
235
|
+
code: err.errorCode,
|
|
236
|
+
status: err.status,
|
|
237
|
+
message: err.bodyPreview ?? err.errorCode,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
return { ok: false, kind: "unavailable", message: safeDiagnosticMessage(err, fallback) };
|
|
241
|
+
}
|
|
242
|
+
/** Re-drive the run's own index scope. The server decides ownership. */
|
|
243
|
+
export async function scopeReparseRepair(access, options, deps = {}) {
|
|
244
|
+
try {
|
|
245
|
+
const call = deps.scopeReparse ?? scopeReparse;
|
|
246
|
+
return { ok: true, result: await call(access, options) };
|
|
247
|
+
}
|
|
248
|
+
catch (err) {
|
|
249
|
+
return classifyRepairError(err, "scope-reparse request failed");
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Dispatch a remediation job with driver context. `context` must already be
|
|
254
|
+
* validated against `DRIVER_CONTEXT_MAX_BYTES`; the server selects the job type.
|
|
255
|
+
*/
|
|
256
|
+
export async function dispatchRemediationRepair(access, options, deps = {}) {
|
|
257
|
+
try {
|
|
258
|
+
const call = deps.dispatchRemediation ?? dispatchRemediation;
|
|
259
|
+
return { ok: true, result: await call(access, options) };
|
|
260
|
+
}
|
|
261
|
+
catch (err) {
|
|
262
|
+
return classifyRepairError(err, "dispatch-remediation request failed");
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
/** A local failure reason is forwarded only when it is a bounded token. */
|
|
266
|
+
const LOCAL_MERGE_REASON_RE = /^[a-z0-9_.:-]{1,200}$/;
|
|
267
|
+
function boundedLocalMergeReason(reason) {
|
|
268
|
+
return typeof reason === "string" && LOCAL_MERGE_REASON_RE.test(reason)
|
|
269
|
+
? reason
|
|
270
|
+
: "gh_merge_failed";
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Merge the run's PR-bound child. The operator supplies only the PR number.
|
|
274
|
+
*
|
|
275
|
+
* When the server merges (repository `merge_execution = server`) its answer is
|
|
276
|
+
* final. When the repository merges locally — the default, and the only mode
|
|
277
|
+
* that works on an App without `contents: write` — the server returns an
|
|
278
|
+
* approval, this operation executes it with the operator's own `gh` through the
|
|
279
|
+
* SAME `runApprovedLocalMerge` the MCP merge tool uses (head-drift guard,
|
|
280
|
+
* `--match-head-commit`, and the ambiguity ladder included), and reports what
|
|
281
|
+
* `gh` did to the completion route, whose answer is the result. The approval's
|
|
282
|
+
* head SHA is consumed here and never returned or printed.
|
|
283
|
+
*/
|
|
284
|
+
export async function mergeChildRepair(access, options, deps = {}) {
|
|
285
|
+
let approval;
|
|
286
|
+
try {
|
|
287
|
+
approval = await (deps.mergeChild ?? mergeChild)(access, options);
|
|
288
|
+
}
|
|
289
|
+
catch (err) {
|
|
290
|
+
return classifyRepairError(err, "merge-child request failed");
|
|
291
|
+
}
|
|
292
|
+
const { local_execution: local, ...serverResult } = approval;
|
|
293
|
+
if (local === null) {
|
|
294
|
+
return { ok: true, result: serverResult };
|
|
295
|
+
}
|
|
296
|
+
const runLocal = deps.runLocalMerge ?? runApprovedLocalMerge;
|
|
297
|
+
let succeeded = false;
|
|
298
|
+
let alreadyMerged = false;
|
|
299
|
+
let failureReason = "gh_merge_failed";
|
|
300
|
+
try {
|
|
301
|
+
const localResult = await runLocal({
|
|
302
|
+
repo_name: access.repoName,
|
|
303
|
+
pr_number: options.prNumber,
|
|
304
|
+
expected_head_sha: local.expected_head_sha,
|
|
305
|
+
gate: { name: DEFAULT_GATE_NAME },
|
|
306
|
+
// A correlation label for the local executor's own in-memory evidence,
|
|
307
|
+
// which is discarded — never the server's action key, which the server
|
|
308
|
+
// recomposes itself at completion.
|
|
309
|
+
action_key: `merge-child:${options.epicRunId}:${options.prNumber}`,
|
|
310
|
+
}, { method: resolveLocalMergeMethod(local.merge_method) }, { env: deps.env ?? process.env });
|
|
311
|
+
succeeded = localResult.status === "succeeded";
|
|
312
|
+
alreadyMerged = succeeded && localResult.reason === "already_merged";
|
|
313
|
+
failureReason = boundedLocalMergeReason(localResult.reason);
|
|
314
|
+
}
|
|
315
|
+
catch {
|
|
316
|
+
succeeded = false;
|
|
317
|
+
}
|
|
318
|
+
try {
|
|
319
|
+
const completion = await (deps.completeMergeChild ?? completeMergeChild)(access, {
|
|
320
|
+
epicRunId: options.epicRunId,
|
|
321
|
+
prNumber: options.prNumber,
|
|
322
|
+
result: succeeded ? (alreadyMerged ? "already_merged" : "merged") : "failed",
|
|
323
|
+
...(succeeded ? {} : { reason: failureReason }),
|
|
324
|
+
});
|
|
325
|
+
const { local_execution: _ignored, ...completed } = completion;
|
|
326
|
+
return { ok: true, result: completed };
|
|
327
|
+
}
|
|
328
|
+
catch (err) {
|
|
329
|
+
if (succeeded) {
|
|
330
|
+
// The merge is REAL whether or not the report landed. Reporting a failure
|
|
331
|
+
// here would invite a second merge attempt on a merged pull request.
|
|
332
|
+
return {
|
|
333
|
+
ok: false,
|
|
334
|
+
kind: "unavailable",
|
|
335
|
+
message: "the pull request was merged by your local gh, but the server could not record it " +
|
|
336
|
+
`(${safeDiagnosticMessage(err, "completion request failed")}); re-run merge-child ` +
|
|
337
|
+
"to record it — a merged pull request is answered as already_merged",
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
return classifyRepairError(err, "merge-child completion failed");
|
|
341
|
+
}
|
|
342
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared contract for the run-addressed operator repair verbs (BAPI-1154):
|
|
3
|
+
* `scope-reparse`, `dispatch-remediation`, and `merge-child`.
|
|
4
|
+
*
|
|
5
|
+
* CLI-only, exactly like the BAPI-872 recovery verbs: none of these names is ever
|
|
6
|
+
* registered as an MCP tool (`index-static.test.ts` pins that). This module is the
|
|
7
|
+
* TypeScript mirror of `api/library/epic_conductor/operator_repair_contract.py` —
|
|
8
|
+
* the verb names, the CLOSED refusal-code table, the driver-context byte cap, and
|
|
9
|
+
* the bounded result shapes the Bridge client returns. A parity test keeps the two
|
|
10
|
+
* aligned; the CLI renders the server's code and never infers ownership or state
|
|
11
|
+
* from error prose.
|
|
12
|
+
*
|
|
13
|
+
* `release-job` is deliberately absent: its defect was already fixed by BAPI-1147
|
|
14
|
+
* and the executor's per-job timeout bounds any residual strand.
|
|
15
|
+
*/
|
|
16
|
+
export const VERB_SCOPE_REPARSE = "scope-reparse";
|
|
17
|
+
export const VERB_DISPATCH_REMEDIATION = "dispatch-remediation";
|
|
18
|
+
export const VERB_MERGE_CHILD = "merge-child";
|
|
19
|
+
/** Every repair verb, in the order an operator meets them. */
|
|
20
|
+
export const OPERATOR_REPAIR_VERBS = [
|
|
21
|
+
VERB_SCOPE_REPARSE,
|
|
22
|
+
VERB_DISPATCH_REMEDIATION,
|
|
23
|
+
VERB_MERGE_CHILD,
|
|
24
|
+
];
|
|
25
|
+
/**
|
|
26
|
+
* The ONE cap on driver context, in UTF-8 BYTES — the same number the server
|
|
27
|
+
* enforces (`DRIVER_CONTEXT_MAX_BYTES` in `prompt_specs.py`). Over-cap context is
|
|
28
|
+
* rejected before it is transmitted, never truncated: a code-changing worker must
|
|
29
|
+
* see the operator's complete instruction or none of it.
|
|
30
|
+
*/
|
|
31
|
+
export const DRIVER_CONTEXT_MAX_BYTES = 16384;
|
|
32
|
+
/** The closed refusal-code vocabulary, mirroring the server table. */
|
|
33
|
+
export const OPERATOR_REPAIR_REFUSAL_CODES = [
|
|
34
|
+
"TICKET_NOT_IN_RUN",
|
|
35
|
+
"DRIVER_CONTEXT_INVALID",
|
|
36
|
+
"RUN_NOT_DISPATCHABLE",
|
|
37
|
+
"TICKET_NOT_REMEDIABLE",
|
|
38
|
+
"MERGE_HELD_BEHIND_PEER",
|
|
39
|
+
"REMEDIATION_JOB_IN_FLIGHT",
|
|
40
|
+
"REMEDIATION_LANE_BUSY",
|
|
41
|
+
"REMEDIATION_BUDGET_EXHAUSTED",
|
|
42
|
+
"REMEDIATION_HELD",
|
|
43
|
+
"REMEDIATION_CONTRACT_ERROR",
|
|
44
|
+
"REMEDIATION_NO_PULL_REQUEST",
|
|
45
|
+
"ROW_VERSION_CONFLICT",
|
|
46
|
+
"DISPATCH_ENQUEUE_FAILED",
|
|
47
|
+
"SCOPE_REPARSE_REFUSED",
|
|
48
|
+
"SCOPE_LOOKUP_FAILED",
|
|
49
|
+
"MERGE_HEAD_UNBOUND",
|
|
50
|
+
"MERGE_GATE_UNRESOLVABLE",
|
|
51
|
+
"MERGE_NOT_COMPLETED",
|
|
52
|
+
"MERGE_COMPLETION_REFUSED",
|
|
53
|
+
];
|
|
54
|
+
/** True when `code` is one of the closed repair refusal codes. */
|
|
55
|
+
export function isOperatorRepairRefusalCode(code) {
|
|
56
|
+
return (typeof code === "string" &&
|
|
57
|
+
OPERATOR_REPAIR_REFUSAL_CODES.includes(code));
|
|
58
|
+
}
|
package/build/conductor/store.js
CHANGED
|
@@ -334,7 +334,7 @@ function buildAuxiliaryTablesSql() {
|
|
|
334
334
|
);
|
|
335
335
|
|
|
336
336
|
-- The supervisor projection is actively maintained by the conductor
|
|
337
|
-
-- supervisor runtime (BAPI-396, \`
|
|
337
|
+
-- supervisor runtime (BAPI-396, \`epic-implementer supervise --run-id <id>\`): the
|
|
338
338
|
-- foreground loop upserts a row after every deterministic iteration. It is
|
|
339
339
|
-- the OPERATIONAL state mirror of the run; raw events remain the source of
|
|
340
340
|
-- truth. The existing JSON columns are sufficient for resumable state, so the
|
|
@@ -822,7 +822,7 @@ export function rowToSupervisorProjection(row) {
|
|
|
822
822
|
* Read the supervisor projection for `run_id` (read-only). Returns
|
|
823
823
|
* `{ run_id, status: "unknown", projection: null }` when no projection exists.
|
|
824
824
|
* The projection is maintained by the conductor supervisor runtime
|
|
825
|
-
* (`
|
|
825
|
+
* (`epic-implementer supervise`); this read never derives state from raw events.
|
|
826
826
|
*/
|
|
827
827
|
export async function getSupervisorSnapshot(runId, config = resolveConductorStoreConfig()) {
|
|
828
828
|
const db = await openReadonlyConductorDatabaseIfExists(config);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Foreground supervisor runtime loop (BAPI-396, conductor C4).
|
|
3
3
|
*
|
|
4
|
-
* `
|
|
4
|
+
* `epic-implementer supervise --run-id <id>` runs this run-scoped, visible, ephemeral
|
|
5
5
|
* loop. It polls the conductor ledger, reduces raw events into deterministic
|
|
6
6
|
* worker watchdog state, persists an operational projection, escalates stuck
|
|
7
7
|
* workers (terminal output + idempotent ledger event), and terminates when all
|