@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.
- package/README.md +66 -15
- package/build/agent-launchers/claude-executor-adapter.js +145 -20
- package/build/agent-launchers/executor-adapter.js +81 -2
- package/build/agents.generated.js +10 -1
- package/build/bounded-wait.js +32 -1
- package/build/bridge-config.js +104 -0
- package/build/codex-bridge-profile.js +10 -1
- package/build/commands.generated.js +7 -6
- package/build/conductor/bridge-api-client.js +565 -32
- package/build/conductor/bring-up-facts.js +3 -1
- package/build/conductor/doctor.js +23 -14
- package/build/conductor/epic-child-continuation.js +351 -0
- package/build/conductor/epic-implementer-cli.js +154 -17
- package/build/conductor/git-hooks.js +529 -118
- package/build/conductor/guided-repair.js +323 -0
- package/build/conductor/hook-runtime-qualification.js +340 -0
- package/build/conductor/readiness-cli.js +30 -9
- package/build/conductor/readiness-sections.js +5 -4
- package/build/conductor/readiness.js +45 -12
- package/build/conductor/recovery-cli.js +859 -27
- package/build/conductor/recovery-operations.js +574 -5
- package/build/conductor/repair-contract.js +69 -3
- package/build/conductor-bundle-artifacts.js +1 -0
- package/build/credential-store.js +52 -13
- package/build/diagnostic-text.js +36 -0
- package/build/docs.generated.js +3 -2
- package/build/doctor.js +147 -21
- package/build/drive-epic-key.js +12 -0
- package/build/drive-epic.js +1537 -125
- package/build/epic-coherence-input-cli.js +137 -0
- package/build/epic-coherence-input.js +920 -0
- package/build/epic-coherence-input.test-fixtures.js +175 -0
- package/build/epic-implementer-bin.js +41 -33
- package/build/epic-implementer-bundle-cli.js +14 -1
- package/build/epic-launch-diagnostic.js +31 -0
- package/build/epic-launch-profile.js +58 -19
- package/build/epic-run-status.js +5 -0
- package/build/epic-shared/bridge-client.js +46 -4
- package/build/executor/cli.js +39 -0
- package/build/executor/completion-contract.js +423 -0
- package/build/executor/completion-supervision.js +291 -0
- package/build/executor/correction-dispatch.js +60 -0
- package/build/executor/credentials.js +3 -1
- package/build/executor/deps.js +65 -1
- package/build/executor/env.js +12 -0
- package/build/executor/heartbeat.js +13 -3
- package/build/executor/http-client.js +91 -4
- package/build/executor/integration-job.js +6 -1
- package/build/executor/job-errors.js +61 -4
- package/build/executor/job-log-registry.js +340 -73
- package/build/executor/job-runner.js +1550 -55
- package/build/executor/job-types.js +9 -6
- package/build/executor/preflight.js +189 -1
- package/build/executor/process-containment.js +756 -0
- package/build/executor/process.js +235 -5
- package/build/executor/prompt-spec.js +366 -1
- package/build/executor/provider-checks.js +69 -0
- package/build/executor/rebase-finalization.js +351 -0
- package/build/executor/reconcile-overlap-job.js +77 -84
- package/build/executor/required-verification.js +313 -0
- package/build/executor/required-work-ledger.js +1121 -0
- package/build/executor/results.js +142 -0
- package/build/executor/resume-pre-spawn.js +87 -4
- package/build/executor/runner.js +168 -0
- package/build/executor/stale-artifacts.js +5 -4
- package/build/executor/test-declaration.js +20 -0
- package/build/executor/types.js +13 -0
- package/build/executor/watch-cli.js +35 -8
- package/build/executor/worker-command.js +70 -34
- package/build/executor/worker-finalization.js +168 -26
- package/build/executor/worker-guard-hook.js +75 -14
- package/build/executor/worker-log.js +137 -70
- package/build/executor/worktree-gc.js +30 -6
- package/build/executor/worktree.js +13 -0
- package/build/executor-launch.js +658 -0
- package/build/index.js +594 -1152
- package/build/init.js +107 -4
- package/build/install-bridge-conductor.js +231 -41
- package/build/install-bridge.js +284 -20
- package/build/install-doctor.js +172 -30
- package/build/install-reexec.js +69 -7
- package/build/macos-terminal-permission-guidance.js +16 -0
- package/build/merge-pull-request.js +1 -1
- package/build/model-thresholds-tool.js +185 -0
- package/build/pipelines.generated.js +9 -8
- package/build/plan-discovery.js +137 -0
- package/build/plan-epic-implementer-eligibility.js +2 -2
- package/build/plane/cli.js +37 -3
- package/build/plane/member-logs.js +3 -0
- package/build/plane/member-roster.js +7 -1
- package/build/plane/shutdown.js +59 -1
- package/build/plane/status.js +4 -0
- package/build/plane/supervisor.js +146 -0
- package/build/plane/worker-exit-code.js +2 -0
- package/build/project-root.js +99 -0
- package/build/readme.generated.js +1 -1
- package/build/review-tickets.js +156 -13
- package/build/scripts/executor-protocol-contract-driver.js +13 -3
- package/build/setup-epic.js +208 -125
- package/build/sfcc/setup-status.js +2 -0
- package/build/sfcc/tool-wrapper.js +2 -0
- package/build/sfcc/tools-configured.js +96 -0
- package/build/start-tickets.js +163 -58
- package/build/upgrade-cli.js +109 -24
- package/build/version.generated.js +3 -3
- package/build/windows-npm-entry.js +103 -0
- package/build/worker-guard-hook-bin.js +5 -4
- package/build/worktree-core.js +6 -5
- package/docs/CONDUCTOR.md +116 -2
- package/docs/install/mcp-tool-integrations.md +1 -0
- package/docs/install/sfcc-integration.md +12 -7
- package/package.json +2 -1
- 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,
|
|
389
|
-
* re-evaluates activation and owns the
|
|
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
|
-
|
|
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
|
}
|