fix(release): preserve durable recovery capacity

This commit is contained in:
2026-07-22 19:48:55 +02:00
parent 22f5c2ff4e
commit 4c16069f88
4 changed files with 537 additions and 33 deletions

View File

@@ -1006,10 +1006,12 @@
renderReleaseRunStoreUnavailable(runsResult.error);
} else {
renderReleaseRunList(runsResult.data?.runs || []);
const pendingRecoveryResult = await recoverPendingReleaseRun();
if (pendingRecoveryResult === null && releaseState.currentRun?.run_id) {
const pendingCreateResult = await recoverPendingReleaseRun();
const pendingCommandResult = await recoverPendingRunCommands();
const recoveryAttempted = pendingCreateResult !== null || pendingCommandResult !== null;
if (!recoveryAttempted && releaseState.currentRun?.run_id) {
await loadReleaseRun(releaseState.currentRun.run_id, { restoreSelection: false });
} else if (pendingRecoveryResult === null) {
} else if (!recoveryAttempted) {
renderIdlePlan();
}
}
@@ -1262,6 +1264,7 @@
releaseState.currentRun = resumed;
renderReleaseRun(resumed);
} catch (error) {
if (deterministicRunCommandError(error)) clearInFlightRunCommand(commandKey, commandRequestId);
elements.runStatus.textContent = "error";
elements.runOutput.insertAdjacentHTML("afterbegin", `<div class="action"><h3>${pill("error", "block")} Resume failed</h3><p>${escapeHtml(error.message)}</p></div>`);
} finally {
@@ -1280,6 +1283,7 @@
releaseState.currentRun = retried;
renderReleaseRun(retried);
} catch (error) {
if (deterministicRunCommandError(error)) clearInFlightRunCommand(commandKey, commandRequestId);
elements.runOutput.insertAdjacentHTML("afterbegin", `<div class="action"><h3>${pill("error", "block")} Retry preparation failed</h3><p>${escapeHtml(error.message)}</p></div>`);
}
}
@@ -1300,6 +1304,7 @@
releaseState.currentRun = reconciled;
renderReleaseRun(reconciled);
} catch (error) {
if (deterministicRunCommandError(error)) clearInFlightRunCommand(commandKey, commandRequestId);
elements.runOutput.insertAdjacentHTML("afterbegin", `<div class="action"><h3>${pill("error", "block")} Reconciliation failed</h3><p>${escapeHtml(error.message)}</p></div>`);
button.disabled = false;
}
@@ -1315,6 +1320,88 @@
}
}
function pendingRunCommandRequest(commandKey, requestIdToReplay) {
if (typeof commandKey !== "string" || typeof requestIdToReplay !== "string") return null;
if (commandKey.startsWith("resume:")) {
const runId = commandKey.slice("resume:".length);
if (!runId || runId.includes(":")) return null;
return {
path: `/api/release-runs/${encodeURIComponent(runId)}/resume`,
body: { request_id: requestIdToReplay },
};
}
if (commandKey.startsWith("retry:")) {
const remainder = commandKey.slice("retry:".length);
const separator = remainder.indexOf(":");
const runId = remainder.slice(0, separator);
const stepId = remainder.slice(separator + 1);
if (separator < 1 || !stepId) return null;
return {
path: `/api/release-runs/${encodeURIComponent(runId)}/steps/${encodeURIComponent(stepId)}/retry`,
body: { request_id: requestIdToReplay },
};
}
if (commandKey.startsWith("reconcile:")) {
const remainder = commandKey.slice("reconcile:".length);
const runSeparator = remainder.indexOf(":");
const outcomeSeparator = remainder.lastIndexOf(":");
const runId = remainder.slice(0, runSeparator);
const stepId = remainder.slice(runSeparator + 1, outcomeSeparator);
const outcome = remainder.slice(outcomeSeparator + 1);
if (runSeparator < 1 || outcomeSeparator <= runSeparator + 1 || !["effect_absent", "effect_succeeded", "unresolved"].includes(outcome)) return null;
return {
path: `/api/release-runs/${encodeURIComponent(runId)}/steps/${encodeURIComponent(stepId)}/reconcile`,
body: { request_id: requestIdToReplay, outcome, confirm: "RECONCILE" },
};
}
return null;
}
async function recoverPendingRunCommands() {
const commands = readInFlightRunCommands();
const entries = Object.entries(commands);
if (!entries.length) return null;
let attempted = false;
let recoveredRun = null;
let pendingError = null;
let rejectedError = null;
for (const [commandKey, requestIdToReplay] of entries) {
const command = pendingRunCommandRequest(commandKey, requestIdToReplay);
if (!command) {
clearInFlightRunCommand(commandKey, requestIdToReplay);
continue;
}
attempted = true;
try {
recoveredRun = await postJson(command.path, command.body);
clearInFlightRunCommand(commandKey, requestIdToReplay);
} catch (error) {
if (deterministicRunCommandError(error)) {
clearInFlightRunCommand(commandKey, requestIdToReplay);
rejectedError = error;
} else {
pendingError = error;
}
}
}
if (recoveredRun) {
releaseState.currentRun = recoveredRun;
restoreReleaseRunSelection(recoveredRun);
renderReleaseRun(recoveredRun);
elements.releaseRun.value = recoveredRun.run_id;
}
if (pendingError) {
elements.runOutput.insertAdjacentHTML("afterbegin", `<div class="action"><h3>${pill("pending", "warn")} Saved command recovery is still pending</h3><p>${escapeHtml(pendingError.message)}</p><p class="action-meta">The same private request identifier will be replayed after refresh.</p></div>`);
} else if (rejectedError) {
elements.runOutput.insertAdjacentHTML("afterbegin", `<div class="action"><h3>${pill("rejected", "block")} Saved command was not accepted</h3><p>${escapeHtml(rejectedError.message)}</p><p class="action-meta">The server returned a deterministic client conflict; the stale request identifier was cleared.</p></div>`);
}
return attempted ? Boolean(recoveredRun) : null;
}
function deterministicRunCommandError(error) {
return error?.status >= 400 && error.status < 500;
}
function inFlightRunCommandId(commandKey, prefix) {
const commands = readInFlightRunCommands();
if (typeof commands[commandKey] === "string") return commands[commandKey];