@bridge_gpt/mcp-server 0.2.62 → 0.2.66

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.
Files changed (113) hide show
  1. package/README.md +66 -15
  2. package/build/agent-launchers/claude-executor-adapter.js +145 -20
  3. package/build/agent-launchers/executor-adapter.js +81 -2
  4. package/build/agents.generated.js +10 -1
  5. package/build/bounded-wait.js +32 -1
  6. package/build/bridge-config.js +104 -0
  7. package/build/codex-bridge-profile.js +10 -1
  8. package/build/commands.generated.js +7 -6
  9. package/build/conductor/bridge-api-client.js +565 -32
  10. package/build/conductor/bring-up-facts.js +3 -1
  11. package/build/conductor/doctor.js +23 -14
  12. package/build/conductor/epic-child-continuation.js +351 -0
  13. package/build/conductor/epic-implementer-cli.js +154 -17
  14. package/build/conductor/git-hooks.js +529 -118
  15. package/build/conductor/guided-repair.js +323 -0
  16. package/build/conductor/hook-runtime-qualification.js +340 -0
  17. package/build/conductor/readiness-cli.js +30 -9
  18. package/build/conductor/readiness-sections.js +5 -4
  19. package/build/conductor/readiness.js +45 -12
  20. package/build/conductor/recovery-cli.js +859 -27
  21. package/build/conductor/recovery-operations.js +574 -5
  22. package/build/conductor/repair-contract.js +69 -3
  23. package/build/conductor-bundle-artifacts.js +1 -0
  24. package/build/credential-store.js +52 -13
  25. package/build/diagnostic-text.js +36 -0
  26. package/build/docs.generated.js +3 -2
  27. package/build/doctor.js +147 -21
  28. package/build/drive-epic-key.js +12 -0
  29. package/build/drive-epic.js +1537 -125
  30. package/build/epic-coherence-input-cli.js +137 -0
  31. package/build/epic-coherence-input.js +920 -0
  32. package/build/epic-coherence-input.test-fixtures.js +175 -0
  33. package/build/epic-implementer-bin.js +41 -33
  34. package/build/epic-implementer-bundle-cli.js +14 -1
  35. package/build/epic-launch-diagnostic.js +31 -0
  36. package/build/epic-launch-profile.js +58 -19
  37. package/build/epic-run-status.js +5 -0
  38. package/build/epic-shared/bridge-client.js +46 -4
  39. package/build/executor/cli.js +39 -0
  40. package/build/executor/completion-contract.js +423 -0
  41. package/build/executor/completion-supervision.js +291 -0
  42. package/build/executor/correction-dispatch.js +60 -0
  43. package/build/executor/credentials.js +3 -1
  44. package/build/executor/deps.js +65 -1
  45. package/build/executor/env.js +12 -0
  46. package/build/executor/heartbeat.js +13 -3
  47. package/build/executor/http-client.js +91 -4
  48. package/build/executor/integration-job.js +6 -1
  49. package/build/executor/job-errors.js +61 -4
  50. package/build/executor/job-log-registry.js +340 -73
  51. package/build/executor/job-runner.js +1550 -55
  52. package/build/executor/job-types.js +9 -6
  53. package/build/executor/preflight.js +189 -1
  54. package/build/executor/process-containment.js +756 -0
  55. package/build/executor/process.js +235 -5
  56. package/build/executor/prompt-spec.js +366 -1
  57. package/build/executor/provider-checks.js +69 -0
  58. package/build/executor/rebase-finalization.js +351 -0
  59. package/build/executor/reconcile-overlap-job.js +77 -84
  60. package/build/executor/required-verification.js +313 -0
  61. package/build/executor/required-work-ledger.js +1121 -0
  62. package/build/executor/results.js +142 -0
  63. package/build/executor/resume-pre-spawn.js +87 -4
  64. package/build/executor/runner.js +168 -0
  65. package/build/executor/stale-artifacts.js +5 -4
  66. package/build/executor/test-declaration.js +20 -0
  67. package/build/executor/types.js +13 -0
  68. package/build/executor/watch-cli.js +35 -8
  69. package/build/executor/worker-command.js +70 -34
  70. package/build/executor/worker-finalization.js +168 -26
  71. package/build/executor/worker-guard-hook.js +75 -14
  72. package/build/executor/worker-log.js +137 -70
  73. package/build/executor/worktree-gc.js +30 -6
  74. package/build/executor/worktree.js +13 -0
  75. package/build/executor-launch.js +658 -0
  76. package/build/index.js +594 -1152
  77. package/build/init.js +107 -4
  78. package/build/install-bridge-conductor.js +231 -41
  79. package/build/install-bridge.js +284 -20
  80. package/build/install-doctor.js +172 -30
  81. package/build/install-reexec.js +69 -7
  82. package/build/macos-terminal-permission-guidance.js +16 -0
  83. package/build/merge-pull-request.js +1 -1
  84. package/build/model-thresholds-tool.js +185 -0
  85. package/build/pipelines.generated.js +9 -8
  86. package/build/plan-discovery.js +137 -0
  87. package/build/plan-epic-implementer-eligibility.js +2 -2
  88. package/build/plane/cli.js +37 -3
  89. package/build/plane/member-logs.js +3 -0
  90. package/build/plane/member-roster.js +7 -1
  91. package/build/plane/shutdown.js +59 -1
  92. package/build/plane/status.js +4 -0
  93. package/build/plane/supervisor.js +146 -0
  94. package/build/plane/worker-exit-code.js +2 -0
  95. package/build/project-root.js +99 -0
  96. package/build/readme.generated.js +1 -1
  97. package/build/review-tickets.js +156 -13
  98. package/build/scripts/executor-protocol-contract-driver.js +13 -3
  99. package/build/setup-epic.js +208 -125
  100. package/build/sfcc/setup-status.js +2 -0
  101. package/build/sfcc/tool-wrapper.js +2 -0
  102. package/build/sfcc/tools-configured.js +96 -0
  103. package/build/start-tickets.js +163 -58
  104. package/build/upgrade-cli.js +109 -24
  105. package/build/version.generated.js +3 -3
  106. package/build/windows-npm-entry.js +103 -0
  107. package/build/worker-guard-hook-bin.js +5 -4
  108. package/build/worktree-core.js +6 -5
  109. package/docs/CONDUCTOR.md +116 -2
  110. package/docs/install/mcp-tool-integrations.md +1 -0
  111. package/docs/install/sfcc-integration.md +12 -7
  112. package/package.json +2 -1
  113. package/pipelines/implement-ticket.json +1 -1
@@ -18,9 +18,12 @@
18
18
  * callers: `recovery-cli.ts` and `plane/shutdown.ts`.
19
19
  */
20
20
  import { randomUUID } from "crypto";
21
- import { adoptCurrentHeadAndUnparkTicket, completeMergeChild, ConductorBridgeApiError, dispatchRemediation, fetchEpicRunState, mergeChild, patchPolicy, resumeRun, safeDiagnosticMessage, scopeCatchUp, scopeReparse, stopEpicRun, unparkEpicTicket, updateEpicRunStatus, } from "./bridge-api-client.js";
21
+ import { adoptCurrentHeadAndUnparkTicket, completeMergeChild, ConductorBridgeApiError, dispatchRemediation, fetchEpicRunState, mergeChild, patchPolicy, resumeRun, safeDiagnosticMessage, scopeCatchUp, scopeReparse, stopEpicRun, unparkEpicTicket, updateEpicRunStatus, fetchChildContinuationStatus, postContinueParkedChild, resumeCiTicket, REVIEW_HEAD_REFUSAL_CODES, } from "./bridge-api-client.js";
22
+ import { clearGuidedReference, guardForBinding, GUIDED_PAYLOAD_CONFLICT_MESSAGE, GUIDED_REFERENCE_STORE_FAILED_MESSAGE, prepareGuidedRequest, } from "./guided-repair.js";
22
23
  import { DEFAULT_GATE_NAME } from "./git-ci-types.js";
24
+ import { canonicalSha256, clearContinuationReference, clearRecoveryReferenceFile, computeContinuationFingerprint, readRecoveryReferenceFile, writeRecoveryReferenceFile, isFullSha, newContinuationRequestId, readContinuationReference, writeContinuationReference, } from "./epic-child-continuation.js";
23
25
  import { resolveLocalMergeCredential, resolveLocalMergeMethod, runApprovedLocalMerge, } from "./local-merge.js";
26
+ import { RESUME_RUN_UNCONFIRMED_MESSAGE } from "./repair-contract.js";
24
27
  /**
25
28
  * Bounded total attempts for a ticket-recovery CAS retry loop. Each attempt
26
29
  * begins with a fresh, authoritative state read — so this bounds full
@@ -179,6 +182,17 @@ async function ticketRecoveryWithRetry(access, options, mutate) {
179
182
  });
180
183
  }
181
184
  catch (err) {
185
+ const refusal = reviewHeadRefusal(err);
186
+ if (refusal !== null) {
187
+ return {
188
+ ok: false,
189
+ kind: "refused",
190
+ epicRunId: options.epicRunId,
191
+ ticketKey: options.ticketKey,
192
+ errorCode: refusal.errorCode,
193
+ message: refusal.message,
194
+ };
195
+ }
182
196
  return {
183
197
  ok: false,
184
198
  kind: "unavailable",
@@ -188,12 +202,14 @@ async function ticketRecoveryWithRetry(access, options, mutate) {
188
202
  };
189
203
  }
190
204
  if (result.ok) {
205
+ const recovery = result.ticket_status.review_head_recovery;
191
206
  return {
192
207
  ok: true,
193
208
  kind: "unparked",
194
209
  epicRunId: options.epicRunId,
195
210
  ticketKey: options.ticketKey,
196
211
  status: result.ticket_status.status,
212
+ ...(recovery ? { reviewHeadRecovery: recovery } : {}),
197
213
  };
198
214
  }
199
215
  // `result.kind === "cas-conflict"`: a concurrent write moved the ticket's
@@ -207,6 +223,31 @@ async function ticketRecoveryWithRetry(access, options, mutate) {
207
223
  ticketKey: options.ticketKey,
208
224
  };
209
225
  }
226
+ /**
227
+ * BAPI-1310 (E1): fixed operator sentences for the review-head refusal codes.
228
+ * Chosen by code so no server or provider text reaches the terminal.
229
+ */
230
+ const REVIEW_HEAD_REFUSAL_MESSAGES = {
231
+ REVIEW_HEAD_ADOPTION_REQUIRED: "the ticket is parked on an unexplained review-head change; accept the current " +
232
+ "head with `epic-implementer adopt-current-head-and-unpark` (keeps spent repair " +
233
+ "allowances, resumes at CI). A generic unpark or remediation is refused",
234
+ REVIEW_HEAD_EPISODE_CONFLICT: "the ticket is no longer on the stop this request was made for; re-check its " +
235
+ "status (explain-run) before trying again",
236
+ REVIEW_HEAD_STATE_INVALID: "the ticket's stored recovery state cannot support an allowance-preserving " +
237
+ "adoption",
238
+ REVIEW_HEAD_BINDING_CHANGED: "the ticket's pull request could not be confirmed open with an unchanged identity",
239
+ REVIEW_HEAD_LIVE_WORK: "an executor job is, or may be, active for this ticket; retry when it has finished",
240
+ REVIEW_HEAD_UNAVAILABLE: "the current head or the ticket specification could not be read; retry shortly",
241
+ };
242
+ /** Map a thrown client error to an E1 refusal, or `null` for anything else. */
243
+ function reviewHeadRefusal(err) {
244
+ if (!(err instanceof ConductorBridgeApiError) || err.status !== 409)
245
+ return null;
246
+ const code = err.errorCode;
247
+ if (typeof code !== "string" || !REVIEW_HEAD_REFUSAL_CODES.includes(code))
248
+ return null;
249
+ return { errorCode: code, message: REVIEW_HEAD_REFUSAL_MESSAGES[code] ?? "refused" };
250
+ }
210
251
  /**
211
252
  * Unpark a parked `needs_human` ticket, with a bounded authoritative-reread
212
253
  * retry on a concurrent `row_version` bump.
@@ -385,9 +426,17 @@ export async function patchPolicyRepair(access, options, deps = {}) {
385
426
  }
386
427
  }
387
428
  /**
388
- * Resume a paused run, or a quarantined one with `clearQuarantine`. The server
389
- * re-evaluates activation and owns the control-generation CAS retry; nothing here
390
- * reads or sends a generation.
429
+ * Resume a paused run, a quarantined one with `clearQuarantine`, or a run held by
430
+ * an eligible integration block. The server re-evaluates activation and owns the
431
+ * control-generation CAS retry; nothing here reads or sends a generation. It makes
432
+ * exactly ONE mutation call and returns a parsed success verbatim — no read, no
433
+ * retry, no poll, and no legacy `/resume` fallback.
434
+ *
435
+ * Only a structured 4xx is an authoritative refusal (the server decided and wrote
436
+ * nothing). A 5xx — even one carrying an `error_code` — a timeout, a dropped
437
+ * connection, or an unparseable/unexpected 2xx cannot prove whether the resume
438
+ * committed, so each is `unavailable` with {@link RESUME_RUN_UNCONFIRMED_MESSAGE}.
439
+ * Other repair verbs keep {@link classifyRepairError} unchanged.
391
440
  */
392
441
  export async function resumeRunRepair(access, options, deps = {}) {
393
442
  try {
@@ -395,6 +444,526 @@ export async function resumeRunRepair(access, options, deps = {}) {
395
444
  return { ok: true, result: await call(access, options) };
396
445
  }
397
446
  catch (err) {
398
- return classifyRepairError(err, "resume-run request failed");
447
+ if (err instanceof ConductorBridgeApiError &&
448
+ typeof err.status === "number" &&
449
+ err.status >= 400 &&
450
+ err.status < 500 &&
451
+ err.errorCode) {
452
+ return classifyRepairError(err, "resume-run request failed");
453
+ }
454
+ return { ok: false, kind: "unavailable", message: RESUME_RUN_UNCONFIRMED_MESSAGE };
455
+ }
456
+ }
457
+ // ---------------------------------------------------------------------------
458
+ // BAPI-1333 — guided (binding-pinned) repairs
459
+ // ---------------------------------------------------------------------------
460
+ /** The server's refusal code for a guided repair whose pinned subject moved. */
461
+ export const REPAIR_SUBJECT_STALE_CODE = "REPAIR_SUBJECT_STALE";
462
+ /**
463
+ * Send ONE guided repair request, pinned to its displayed binding.
464
+ *
465
+ * The reference (request id, binding digest, payload digest) is durably stored
466
+ * BEFORE the first POST; a store failure sends nothing and never falls back to
467
+ * an unguarded or resetting verb. Only a structured 4xx is a definitive answer;
468
+ * a 5xx, timeout, dropped connection, or unparseable 2xx is `unconfirmed`, and
469
+ * the reference survives it so a new CLI process re-sends the same request.
470
+ */
471
+ export async function guidedRepairOnce(options, deps = {}) {
472
+ const prepared = await prepareGuidedRequest(options.binding, options.payloadDigest, deps);
473
+ if (prepared.kind === "store_failed") {
474
+ return { kind: "unavailable", message: GUIDED_REFERENCE_STORE_FAILED_MESSAGE };
475
+ }
476
+ if (prepared.kind === "payload_conflict") {
477
+ return { kind: "unavailable", message: GUIDED_PAYLOAD_CONFLICT_MESSAGE };
478
+ }
479
+ const { requestId, referenceReused } = prepared;
480
+ let result;
481
+ try {
482
+ result = await options.send(guardForBinding(options.binding, requestId));
483
+ }
484
+ catch (err) {
485
+ if (err instanceof ConductorBridgeApiError &&
486
+ typeof err.status === "number" &&
487
+ err.status >= 400 &&
488
+ err.status < 500 &&
489
+ err.errorCode) {
490
+ await clearGuidedReference(options.binding, deps);
491
+ const message = err.bodyPreview ?? err.errorCode;
492
+ if (err.errorCode === REPAIR_SUBJECT_STALE_CODE) {
493
+ return { kind: "stale", code: err.errorCode, message, requestId, referenceReused };
494
+ }
495
+ return { kind: "refused", code: err.errorCode, status: err.status, message, requestId };
496
+ }
497
+ return { kind: "unconfirmed", message: options.unconfirmedMessage, requestId, referenceReused };
498
+ }
499
+ await clearGuidedReference(options.binding, deps);
500
+ return result.committed
501
+ ? { kind: "committed", result, requestId, referenceReused }
502
+ : { kind: "no_op", result, requestId, referenceReused };
503
+ }
504
+ const AUTHORITATIVE_OUTCOMES = new Set([
505
+ "committed",
506
+ "already_continued",
507
+ "stale_historical_episode",
508
+ "refused",
509
+ "unsupported",
510
+ ]);
511
+ /**
512
+ * Look the request's receipt up exactly once. Returns a decided result only when
513
+ * a durable receipt for THIS request id proves the commit; anything else is
514
+ * `null` (still unconfirmed). Never re-reads CAS tokens and never mutates.
515
+ */
516
+ async function lookupOwnReceipt(access, reference, fetchStatus) {
517
+ let status;
518
+ try {
519
+ status = await fetchStatus(access, {
520
+ epicRunId: reference.bindings.epicRunId,
521
+ ticketKey: reference.bindings.ticketKey,
522
+ requestId: reference.requestId,
523
+ });
524
+ }
525
+ catch {
526
+ return null;
527
+ }
528
+ if (!status.found || status.receipt === null)
529
+ return null;
530
+ if (status.receipt.request_id !== reference.requestId)
531
+ return null;
532
+ const outcome = status.outcome === "stale_historical_episode" ? "stale_historical_episode" : "committed";
533
+ return {
534
+ outcome,
535
+ reason_code: outcome === "committed" ? null : "stale_episode",
536
+ explanation: outcome === "committed"
537
+ ? "The receipt proves this request's continuation committed."
538
+ : "The receipt proves this request's continuation committed; the child has since parked again.",
539
+ replayed: true,
540
+ retryable: false,
541
+ receipt: status.receipt,
542
+ current: status.current,
543
+ };
544
+ }
545
+ /**
546
+ * Continue a parked child through the server's atomic continuation, at most one
547
+ * POST per invocation.
548
+ *
549
+ * A pending recovery reference for this child is REUSED as-is — same request id,
550
+ * same bindings — so a lost response is resolved by a replay the server answers
551
+ * from its receipt, and a restarted process can never approve a new head or
552
+ * episode by re-reading the child. Only when no reference exists are the
553
+ * bindings read (once), fingerprinted, and stored BEFORE the POST is sent.
554
+ *
555
+ * After a transport failure, a malformed success body, or a 5xx, exactly one
556
+ * receipt lookup follows. No second POST, no new request id, no polling for
557
+ * completion, and never a fallback to unpark.
558
+ */
559
+ export async function continueChildOnce(access, options, deps = {}) {
560
+ const fetchStatus = deps.fetchStatus ?? fetchChildContinuationStatus;
561
+ const post = deps.post ?? postContinueParkedChild;
562
+ const store = deps.store ?? {};
563
+ let reference = await readContinuationReference(options.epicRunId, options.ticketKey, store);
564
+ if (reference !== null && reference.bindings.repoName !== access.repoName) {
565
+ reference = null;
566
+ }
567
+ const reusedReference = reference !== null;
568
+ if (reference === null) {
569
+ let status;
570
+ try {
571
+ status = await fetchStatus(access, {
572
+ epicRunId: options.epicRunId,
573
+ ticketKey: options.ticketKey,
574
+ });
575
+ }
576
+ catch (err) {
577
+ return { kind: "unavailable", message: safeDiagnosticMessage(err, "could not read the child") };
578
+ }
579
+ const current = status.current;
580
+ if (!current.parked ||
581
+ !current.continuation_available ||
582
+ current.parked_gate === null ||
583
+ current.park_generation === null) {
584
+ return { kind: "not_continuable", current };
585
+ }
586
+ const head = current.expected_head_sha === null ? null : current.expected_head_sha.toLowerCase();
587
+ if (head !== null && !isFullSha(head)) {
588
+ return { kind: "unavailable", message: "the child's bound head is not a full SHA" };
589
+ }
590
+ const bindings = {
591
+ repoName: access.repoName,
592
+ epicRunId: status.epic_run_id,
593
+ ticketKey: status.ticket_key,
594
+ parkedGate: current.parked_gate,
595
+ expectedRowVersion: current.row_version,
596
+ expectedHeadSha: head,
597
+ sourceParkGeneration: current.park_generation,
598
+ };
599
+ reference = {
600
+ version: 1,
601
+ bindings,
602
+ requestId: (deps.newRequestId ?? newContinuationRequestId)(),
603
+ requestFingerprint: computeContinuationFingerprint(bindings),
604
+ createdAt: new Date().toISOString(),
605
+ };
606
+ try {
607
+ // Also keyed by the caller's own addressing so the next invocation (which
608
+ // may use the epic key rather than the resolved id) finds it.
609
+ await writeContinuationReference(reference, store);
610
+ if (options.epicRunId !== bindings.epicRunId || options.ticketKey !== bindings.ticketKey) {
611
+ await writeContinuationReference(reference, store, {
612
+ epicRunId: options.epicRunId,
613
+ ticketKey: options.ticketKey,
614
+ });
615
+ }
616
+ }
617
+ catch {
618
+ return {
619
+ kind: "unavailable",
620
+ message: "could not store the local recovery reference, so the request was not sent",
621
+ };
622
+ }
623
+ }
624
+ const ref = reference;
625
+ const clear = async () => {
626
+ await clearContinuationReference(options.epicRunId, options.ticketKey, store);
627
+ await clearContinuationReference(ref.bindings.epicRunId, ref.bindings.ticketKey, store);
628
+ };
629
+ const outcome = await post(access, {
630
+ bindings: ref.bindings,
631
+ requestId: ref.requestId,
632
+ requestFingerprint: ref.requestFingerprint,
633
+ reason: options.reason,
634
+ });
635
+ if (outcome.kind === "result" && AUTHORITATIVE_OUTCOMES.has(outcome.result.outcome)) {
636
+ await clear();
637
+ return { kind: "decided", requestId: ref.requestId, reusedReference, result: outcome.result };
638
+ }
639
+ if (outcome.kind === "refused_http") {
640
+ await clear();
641
+ return {
642
+ kind: "refused_http",
643
+ requestId: ref.requestId,
644
+ status: outcome.status,
645
+ errorCode: outcome.errorCode,
646
+ };
647
+ }
648
+ // Uncertain (transport, malformed 2xx, 5xx, or the server's own unconfirmed).
649
+ const transport = outcome.kind === "uncertain"
650
+ ? outcome.status !== undefined
651
+ ? `${outcome.reason} (status ${outcome.status})`
652
+ : outcome.reason
653
+ : "indeterminate commit";
654
+ const proven = await lookupOwnReceipt(access, ref, fetchStatus);
655
+ if (proven !== null) {
656
+ await clear();
657
+ return { kind: "decided", requestId: ref.requestId, reusedReference, result: proven };
658
+ }
659
+ return { kind: "unconfirmed", requestId: ref.requestId, reusedReference, transport };
660
+ }
661
+ /**
662
+ * Read a child's current episode and, when a local recovery reference exists (or
663
+ * `requestId` is given), that request's receipt. One bounded GET; read-only. A
664
+ * reference is cleared only when the lookup PROVES its commit; `not_found` keeps
665
+ * it, because the absence of a receipt right now does not prove the request will
666
+ * never have committed if it is still in flight.
667
+ */
668
+ export async function lookupContinuationReceipt(access, options, deps = {}) {
669
+ const fetchStatus = deps.fetchStatus ?? fetchChildContinuationStatus;
670
+ const store = deps.store ?? {};
671
+ const reference = options.requestId
672
+ ? null
673
+ : await readContinuationReference(options.epicRunId, options.ticketKey, store);
674
+ const requestId = options.requestId ?? reference?.requestId ?? null;
675
+ let status;
676
+ try {
677
+ status = await fetchStatus(access, {
678
+ epicRunId: reference?.bindings.epicRunId ?? options.epicRunId,
679
+ ticketKey: reference?.bindings.ticketKey ?? options.ticketKey,
680
+ ...(requestId ? { requestId } : {}),
681
+ });
682
+ }
683
+ catch (err) {
684
+ return { kind: "unavailable", message: safeDiagnosticMessage(err, "could not read the child") };
685
+ }
686
+ if (status.found && status.receipt !== null) {
687
+ if (reference !== null && status.receipt.request_id === reference.requestId) {
688
+ await clearContinuationReference(options.epicRunId, options.ticketKey, store);
689
+ await clearContinuationReference(reference.bindings.epicRunId, reference.bindings.ticketKey, store);
690
+ }
691
+ return { kind: "found", requestId, status };
692
+ }
693
+ return { kind: "not_found", requestId, status };
694
+ }
695
+ // ---------------------------------------------------------------------------
696
+ // BAPI-1326 — `resume-ci`: a pinned, bounded, allowance-preserving CI resume
697
+ // ---------------------------------------------------------------------------
698
+ /** The recovery-reference namespace of pinned `resume-ci` operations. */
699
+ export const RESUME_CI_REFERENCE_NAMESPACE = "epic-ci-resumes";
700
+ /**
701
+ * Bounded POSTs per invocation. Every POST of one invocation carries the SAME
702
+ * operation key and stop binding; only the row version may be refreshed, and
703
+ * only while every original binding still matches.
704
+ */
705
+ export const RESUME_CI_POST_LIMIT = 3;
706
+ /** The pause before a retry. Injectable so tests never sleep. */
707
+ const RESUME_CI_RETRY_DELAY_MS = 250;
708
+ const FULL_SHA_PATTERN = /^[0-9a-f]{40}$/;
709
+ const FINGERPRINT_PATTERN = /^[0-9a-f]{64}$/;
710
+ const CI_STOP_REASON_VALUES = new Set(["wait_expired", "verdict_unavailable", "repair_exhausted"]);
711
+ function isRecordValue(value) {
712
+ return value !== null && typeof value === "object" && !Array.isArray(value);
713
+ }
714
+ function isNonNegativeInteger(value) {
715
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
716
+ }
717
+ /** Strictly validate a stop binding; `null` for anything short of the contract. */
718
+ export function parseCiStopBinding(value) {
719
+ if (!isRecordValue(value))
720
+ return null;
721
+ const checks = value.required_checks;
722
+ if (!isNonNegativeInteger(value.park_generation) ||
723
+ !CI_STOP_REASON_VALUES.has(value.reason) ||
724
+ typeof value.accepted_head_sha !== "string" || !FULL_SHA_PATTERN.test(value.accepted_head_sha) ||
725
+ !isNonNegativeInteger(value.pr_number) || value.pr_number < 1 ||
726
+ typeof value.base_ref !== "string" || !value.base_ref || value.base_ref.length > 200 ||
727
+ !Array.isArray(checks) || checks.length > 64 ||
728
+ !checks.every((c) => typeof c === "string" && c.length > 0 && c.length <= 200) ||
729
+ (value.required_checks_source !== "run" && value.required_checks_source !== "plan") ||
730
+ typeof value.required_checks_fingerprint !== "string" ||
731
+ !FINGERPRINT_PATTERN.test(value.required_checks_fingerprint) ||
732
+ !(value.spec_hash === null || value.spec_hash === undefined ||
733
+ (typeof value.spec_hash === "string" && value.spec_hash.length > 0 && value.spec_hash.length <= 128))) {
734
+ return null;
735
+ }
736
+ return {
737
+ park_generation: value.park_generation,
738
+ reason: value.reason,
739
+ accepted_head_sha: value.accepted_head_sha,
740
+ pr_number: value.pr_number,
741
+ base_ref: value.base_ref,
742
+ required_checks: [...checks],
743
+ required_checks_source: value.required_checks_source,
744
+ required_checks_fingerprint: value.required_checks_fingerprint,
745
+ spec_hash: (value.spec_hash ?? null),
746
+ };
747
+ }
748
+ /**
749
+ * Capture the ACTIVE typed CI stop's binding from authenticated ticket state,
750
+ * mirroring the server's selection: the ticket is `needs_human`, its single
751
+ * parked gate is `ci`, and `gates.ci.ci_stop` is a complete typed record for the
752
+ * gate's current park generation. Never derived from the park's reason text.
753
+ */
754
+ export function captureCiStopBinding(ticket) {
755
+ // The client union predates the v2 gate statuses; the server sends them anyway.
756
+ if (ticket.status !== "needs_human")
757
+ return { ok: false, reason: "not_parked" };
758
+ const gates = isRecordValue(ticket.gates) ? ticket.gates : {};
759
+ const parked = Object.entries(gates).filter(([, state]) => isRecordValue(state) && state.status === "needs_human");
760
+ if (parked.length !== 1 || parked[0][0] !== "ci")
761
+ return { ok: false, reason: "not_ci_park" };
762
+ const ci = parked[0][1];
763
+ const stop = ci.ci_stop;
764
+ if (!isRecordValue(stop) || !isRecordValue(stop.evidence))
765
+ return { ok: false, reason: "no_typed_stop" };
766
+ const generation = isNonNegativeInteger(ci.park_generation) ? ci.park_generation : 0;
767
+ if (stop.park_generation !== generation)
768
+ return { ok: false, reason: "stop_superseded" };
769
+ const evidence = stop.evidence;
770
+ const binding = parseCiStopBinding({
771
+ park_generation: stop.park_generation,
772
+ reason: stop.reason,
773
+ accepted_head_sha: stop.accepted_head_sha,
774
+ pr_number: evidence.pr_number,
775
+ base_ref: evidence.base_ref,
776
+ required_checks: evidence.required_checks,
777
+ required_checks_source: evidence.required_checks_source,
778
+ required_checks_fingerprint: evidence.required_checks_fingerprint,
779
+ spec_hash: evidence.spec_hash ?? null,
780
+ });
781
+ return binding === null ? { ok: false, reason: "no_typed_stop" } : { ok: true, binding };
782
+ }
783
+ /** The fingerprint of a pinned operation's identity (the row version excluded). */
784
+ export function computeCiResumeFingerprint(ref) {
785
+ return canonicalSha256({
786
+ v: 1,
787
+ operation: "resume_ci",
788
+ target: { repo_name: ref.repoName, epic_run_id: ref.epicRunId, ticket_key: ref.ticketKey },
789
+ operation_key: ref.operationKey,
790
+ stop_binding: ref.stopBinding,
791
+ });
792
+ }
793
+ function parseCiResumeReference(raw) {
794
+ if (!isRecordValue(raw) || raw.version !== 1 || raw.operation !== "resume_ci")
795
+ return null;
796
+ const binding = parseCiStopBinding(raw.stopBinding);
797
+ if (binding === null ||
798
+ typeof raw.repoName !== "string" || !raw.repoName ||
799
+ typeof raw.epicRunId !== "string" || !raw.epicRunId ||
800
+ typeof raw.ticketKey !== "string" || !raw.ticketKey ||
801
+ !isNonNegativeInteger(raw.expectedRowVersion) ||
802
+ typeof raw.operationKey !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$/.test(raw.operationKey) ||
803
+ typeof raw.fingerprint !== "string") {
804
+ return null;
805
+ }
806
+ const ref = {
807
+ version: 1,
808
+ operation: "resume_ci",
809
+ repoName: raw.repoName,
810
+ epicRunId: raw.epicRunId,
811
+ ticketKey: raw.ticketKey,
812
+ expectedRowVersion: raw.expectedRowVersion,
813
+ operationKey: raw.operationKey,
814
+ stopBinding: binding,
815
+ fingerprint: raw.fingerprint,
816
+ createdAt: typeof raw.createdAt === "string" ? raw.createdAt : "",
817
+ };
818
+ // A reference whose fingerprint no longer matches its own identity was
819
+ // tampered with or truncated; it never authorizes a replay.
820
+ return computeCiResumeFingerprint(ref) === ref.fingerprint ? ref : null;
821
+ }
822
+ function sameBinding(a, b) {
823
+ return canonicalSha256(a) === canonicalSha256(b);
824
+ }
825
+ async function readCiResumeReference(key, store) {
826
+ return parseCiResumeReference(await readRecoveryReferenceFile(RESUME_CI_REFERENCE_NAMESPACE, key, store));
827
+ }
828
+ /**
829
+ * Resume a ticket parked on a qualified CI stop, with its operation pinned.
830
+ *
831
+ * A pinned reference for this ticket is REUSED as-is — same operation key, same
832
+ * stop binding — so a lost response or a restarted process is answered from the
833
+ * server's durable receipt, and a later park is never silently targeted. Only
834
+ * when no reference exists is the binding read (once) from authenticated ticket
835
+ * state, and the reference is stored BEFORE the first POST.
836
+ *
837
+ * At most {@link RESUME_CI_POST_LIMIT} POSTs, all with the same key and binding.
838
+ * A row-version conflict refreshes the row version only while every original
839
+ * binding still matches; otherwise the result is `stale_reference` and nothing
840
+ * more is sent. There is no fallback to unpark, continue-child, or any other
841
+ * mutation.
842
+ */
843
+ export async function resumeCiOnce(access, options, deps = {}) {
844
+ const fetchState = deps.fetchState ?? fetchEpicRunState;
845
+ const post = deps.post ?? resumeCiTicket;
846
+ const store = deps.store ?? {};
847
+ const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
848
+ const callerKey = { epicRunId: options.epicRunId, ticketKey: options.ticketKey };
849
+ let reference = await readCiResumeReference(callerKey, store);
850
+ if (reference !== null && reference.repoName !== access.repoName)
851
+ reference = null;
852
+ const reusedReference = reference !== null;
853
+ const persist = async (ref) => {
854
+ await writeRecoveryReferenceFile(RESUME_CI_REFERENCE_NAMESPACE, callerKey, ref, store);
855
+ if (ref.epicRunId !== callerKey.epicRunId || ref.ticketKey !== callerKey.ticketKey) {
856
+ await writeRecoveryReferenceFile(RESUME_CI_REFERENCE_NAMESPACE, { epicRunId: ref.epicRunId, ticketKey: ref.ticketKey }, ref, store);
857
+ }
858
+ };
859
+ const readTicket = async () => {
860
+ try {
861
+ const state = await fetchState(access, options.epicRunId);
862
+ return state.ticket_statuses.find((t) => t.ticket_key === options.ticketKey) ?? null;
863
+ }
864
+ catch {
865
+ return "unavailable";
866
+ }
867
+ };
868
+ if (reference === null) {
869
+ const ticket = await readTicket();
870
+ if (ticket === "unavailable") {
871
+ return { kind: "unavailable", message: "could not read the ticket's state, so nothing was sent" };
872
+ }
873
+ if (ticket === null)
874
+ return { kind: "not_eligible", reason: "ticket_not_found" };
875
+ const captured = captureCiStopBinding(ticket);
876
+ if (!captured.ok)
877
+ return { kind: "not_eligible", reason: captured.reason };
878
+ const identity = {
879
+ repoName: access.repoName,
880
+ epicRunId: ticket.epic_run_id,
881
+ ticketKey: ticket.ticket_key,
882
+ operationKey: (deps.newOperationKey ?? randomUUID)(),
883
+ stopBinding: captured.binding,
884
+ };
885
+ reference = {
886
+ version: 1,
887
+ operation: "resume_ci",
888
+ ...identity,
889
+ expectedRowVersion: ticket.row_version,
890
+ fingerprint: computeCiResumeFingerprint(identity),
891
+ createdAt: (deps.now ?? (() => new Date()))().toISOString(),
892
+ };
893
+ try {
894
+ await persist(reference);
895
+ }
896
+ catch {
897
+ return {
898
+ kind: "unavailable",
899
+ message: "could not store the local recovery reference, so the request was not sent",
900
+ };
901
+ }
902
+ }
903
+ const clear = async (ref) => {
904
+ await clearRecoveryReferenceFile(RESUME_CI_REFERENCE_NAMESPACE, callerKey, store);
905
+ await clearRecoveryReferenceFile(RESUME_CI_REFERENCE_NAMESPACE, { epicRunId: ref.epicRunId, ticketKey: ref.ticketKey }, store);
906
+ };
907
+ let ref = reference;
908
+ let last = null;
909
+ for (let attempt = 1; attempt <= RESUME_CI_POST_LIMIT; attempt += 1) {
910
+ if (attempt > 1)
911
+ await sleep(RESUME_CI_RETRY_DELAY_MS * (attempt - 1));
912
+ const outcome = await post(access, {
913
+ epicRunId: ref.epicRunId,
914
+ ticketKey: ref.ticketKey,
915
+ expectedRowVersion: ref.expectedRowVersion,
916
+ operationKey: ref.operationKey,
917
+ stopBinding: ref.stopBinding,
918
+ });
919
+ last = outcome;
920
+ if (outcome.kind === "result") {
921
+ await clear(ref);
922
+ return { kind: outcome.result.outcome, result: outcome.result, reusedReference };
923
+ }
924
+ if (outcome.kind === "uncertain")
925
+ continue;
926
+ if (outcome.kind === "refused" && outcome.errorCode === "CI_RESUME_BUSY")
927
+ continue;
928
+ if (outcome.kind === "refused" && outcome.errorCode === "ROW_VERSION_CONFLICT") {
929
+ // Refresh the row version ONLY while the stop is still the same one.
930
+ const ticket = await readTicket();
931
+ if (ticket === "unavailable" || ticket === null) {
932
+ return { kind: "refused", errorCode: outcome.errorCode, status: outcome.status, reusedReference };
933
+ }
934
+ const current = captureCiStopBinding(ticket);
935
+ if (!current.ok || !sameBinding(current.binding, ref.stopBinding)) {
936
+ return { kind: "stale_reference", reusedReference };
937
+ }
938
+ ref = { ...ref, expectedRowVersion: ticket.row_version };
939
+ try {
940
+ await persist(ref);
941
+ }
942
+ catch {
943
+ return {
944
+ kind: "unavailable",
945
+ message: "could not update the local recovery reference, so the retry was not sent",
946
+ };
947
+ }
948
+ continue;
949
+ }
950
+ // Every other refusal or rejection is authoritative: nothing was written,
951
+ // and this operation is finished.
952
+ await clear(ref);
953
+ return {
954
+ kind: "refused",
955
+ errorCode: outcome.errorCode ?? "REQUEST_REJECTED",
956
+ status: outcome.status,
957
+ reusedReference,
958
+ };
959
+ }
960
+ if (last !== null && last.kind === "refused") {
961
+ // Busy or row-version churn for the whole budget: nothing was written, and
962
+ // the pinned reference is kept for the next invocation.
963
+ return { kind: "refused", errorCode: last.errorCode, status: last.status, reusedReference };
399
964
  }
965
+ const transport = last !== null && last.kind === "uncertain"
966
+ ? last.status !== undefined ? `${last.reason} (status ${last.status})` : last.reason
967
+ : "indeterminate";
968
+ return { kind: "unconfirmed", transport, reusedReference, attempts: RESUME_CI_POST_LIMIT };
400
969
  }