rollbridge 0.1.45 → 0.1.47

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.
@@ -0,0 +1,8 @@
1
+ ### Fixed
2
+
3
+ - Add explicit `recover-generation-transition --accept-retired-incumbent`
4
+ recovery that stops the exact failed candidate and durably fences degraded
5
+ incumbent web authority across owner recovery without reactivating either jobs
6
+ generation. A fresh deployment replaces the fence through normal cutover.
7
+ Incompatible owner replacement now accepts the unresolved transition's exact
8
+ retained candidate config without admitting unrelated config authority.
package/docs/cli.md CHANGED
@@ -207,6 +207,20 @@ instruction; see [Config reloads](config.md#config-reloads).
207
207
  - `--ensure-daemon` — start the daemon first if it isn't running (honors the
208
208
  same `--daemon-*` options as `ensure-daemon`).
209
209
 
210
+ ## `recover-generation-transition`
211
+
212
+ `rollbridge recover-generation-transition --release-path <path> --release-id <id>
213
+ --revision <sha> --previous-release-id <id> [--config <path>]
214
+ [--accept-retired-incumbent]`
215
+
216
+ By default, restores the incumbent before clearing an exact failed transition.
217
+ `--accept-retired-incumbent` instead requires an exact `restoring_previous`
218
+ journal, terminal restoration failure, retired candidate, retired incumbent
219
+ coordinator, and live incumbent proxy processes. It safely stops the failed
220
+ candidate and persists a `degraded_active` fence without reactivating either jobs
221
+ generation, reporting `jobsStatus: "degraded"`. Incumbent web survives owner
222
+ recovery, and a fresh normal deployment replaces the fence through normal cutover.
223
+
210
224
  ## `rollback`
211
225
 
212
226
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rollbridge",
3
- "version": "0.1.45",
3
+ "version": "0.1.47",
4
4
  "description": "Zero-downtime process supervisor and local traffic switcher for deploy-managed apps.",
5
5
  "keywords": [
6
6
  "deploy",
package/src/cli.js CHANGED
@@ -215,17 +215,19 @@ export async function runCli(argv) {
215
215
 
216
216
  program
217
217
  .command("recover-generation-transition")
218
- .description("Restore the exact incumbent and retire a failed pre-commit generation candidate.")
218
+ .description("Recover an exact failed pre-commit generation transition.")
219
219
  .option("-c, --config <path>", "Config file path (defaults to rollbridge.js)")
220
220
  .requiredOption("--release-path <path>", "Failed candidate release path")
221
221
  .requiredOption("--release-id <id>", "Failed candidate release id")
222
222
  .requiredOption("--revision <sha>", "Failed candidate revision")
223
223
  .requiredOption("--previous-release-id <id>", "Expected authoritative incumbent release id")
224
+ .option("--accept-retired-incumbent", "Persist terminal restoration as degraded incumbent web authority")
224
225
  .action(async (options) => {
225
226
  const configPath = await resolveConfigPath(options.config)
226
227
  const config = await loadConfig(configPath)
227
228
  const response = await sendControlCommand({
228
229
  command: {
230
+ acceptRetiredIncumbent: options.acceptRetiredIncumbent === true,
229
231
  command: "recover-generation-transition",
230
232
  previousReleaseId: options.previousReleaseId,
231
233
  releaseId: options.releaseId,
package/src/daemon.js CHANGED
@@ -24,8 +24,8 @@ const STATE_PERSIST_INTERVAL_MS = 5000
24
24
  * @typedef {{attestation?: string, releaseId: string, releasePath: string, revision: string}} BootstrapIdentity
25
25
  * @typedef {{id: string, process: import("./managed-process.js").ManagedProcessStatus}} ProcessStatus
26
26
  * @typedef {{disruptive: true, mode: "legacy-first-upgrade", reason: string}} OwnerTransition
27
- * @typedef {"candidate_ready" | "retiring_previous" | "previous_retired" | "activating_candidate" | "restoring_previous" | "retiring_failed_candidate" | "committed_pending" | "committed" | "restoring_committed"} GenerationTransitionPhase
28
- * @typedef {{activationError?: string, activationLifecycle?: boolean, candidateReleaseId: string, candidateReleasePath: string, candidateRevision: string, compensationError?: string, configDigest: string, error?: string, journalRevision?: number, phase: GenerationTransitionPhase, previousReleaseId: string | null, startedAt: string, updatedAt: string}} GenerationTransition
27
+ * @typedef {"candidate_ready" | "retiring_previous" | "previous_retired" | "activating_candidate" | "restoring_previous" | "retiring_failed_candidate" | "degraded_active" | "committed_pending" | "committed" | "restoring_committed"} GenerationTransitionPhase
28
+ * @typedef {{activationError?: string, activationLifecycle?: boolean, candidateReleaseId: string, candidateReleasePath: string, candidateRevision: string, compensationError?: string, configDigest: string, degradedIncumbent?: boolean, error?: string, journalRevision?: number, phase: GenerationTransitionPhase, previousReleaseId: string | null, startedAt: string, updatedAt: string}} GenerationTransition
29
29
  * @typedef {{activeReleaseId: string | null, application: string, bootstrap: BootstrapIdentity | undefined, control: import("./config.js").ControlConfig, daemonPid: number, daemonRuntime: import("./daemon-runtime.js").DaemonRuntimeIdentity | undefined, generationTransition?: GenerationTransition, ownerRecovery: {configDigest: string, ready: boolean} | undefined, ownerTransition?: OwnerTransition, orphans: {id: string, pid: number, releaseId: string | null}[], proxy: {host: string, port: number | undefined, upstreamHost: string}, releaseReferences: {releaseId: string, releasePath: string}[], releases: import("./release-group.js").ReleaseStatus[], services: ProcessStatus[], singletons: ProcessStatus[]}} DaemonStatus
30
30
  * @typedef {{configDigest: string, format: number, guardian: {pid?: number, socketPath: string, token: string}, reconnectGraceMs: number}} OwnerRecoveryMetadata
31
31
  * @typedef {DaemonStatus & {recovery: OwnerRecoveryMetadata, serviceReleaseIds?: Record<string, string>, singletonReleaseIds?: Record<string, string>}} OwnerRecoverySnapshot
@@ -127,7 +127,7 @@ export default class RollbridgeDaemon {
127
127
  const transition = this.generationTransition
128
128
  const recoveredReplacementProxy = this.guardian ? await this.finalizeRecoveredOwnerReplacement() : false
129
129
 
130
- if (this.guardian && transition && transition.phase !== "committed" && !transition.error) {
130
+ if (this.guardian && transition && transition.phase !== "committed" && transition.phase !== "degraded_active" && !transition.error) {
131
131
  this.generationTransitionRecovery = true
132
132
  try {
133
133
  await this.executeOwnerMutation("recover generation transition", async () => {
@@ -505,10 +505,8 @@ export default class RollbridgeDaemon {
505
505
  if (!transfer?.config || !transfer.snapshot) throw new Error("Committed owner published incomplete replacement state")
506
506
  const unresolvedTransition = transfer.snapshot.generationTransition
507
507
 
508
- const transferredTransitionConfigDigest = transfer.config ? ownerConfigDigest(transfer.config) : undefined
509
508
  if (unresolvedTransition && unresolvedTransition.phase !== "committed" &&
510
- unresolvedTransition.configDigest !== this.ownerRecoveryConfigDigest() &&
511
- unresolvedTransition.configDigest !== transferredTransitionConfigDigest) {
509
+ !ownerReplacementTransitionAuthorityMatches(transfer, unresolvedTransition, this.ownerRecoveryConfigDigest())) {
512
510
  throw new Error(`Owner replacement cannot change config authority while unresolved generation transition ${unresolvedTransition.candidateReleaseId} remains at ${unresolvedTransition.phase}`)
513
511
  }
514
512
  const registeredProcesses = new Map((await this.guardian.inventory()).map(({key, provenance}) => [key, provenance]))
@@ -1342,6 +1340,7 @@ export default class RollbridgeDaemon {
1342
1340
 
1343
1341
  if (commandName === "recover-generation-transition") {
1344
1342
  return await this.executeOwnerMutation("recover generation transition", async () => await this.recoverGenerationTransition({
1343
+ acceptRetiredIncumbent: data.acceptRetiredIncumbent === true,
1345
1344
  previousReleaseId: requiredString(data.previousReleaseId, "previousReleaseId"),
1346
1345
  releaseId: requiredString(data.releaseId, "releaseId"),
1347
1346
  releasePath: requiredString(data.releasePath, "releasePath"),
@@ -1561,7 +1560,7 @@ export default class RollbridgeDaemon {
1561
1560
  const newReleaseId = releaseId || revision || new Date().toISOString().replace(/[^0-9]/g, "")
1562
1561
  const transition = this.generationTransition
1563
1562
 
1564
- if (transition && transition.phase !== "committed") {
1563
+ if (transition && transition.phase !== "committed" && transition.phase !== "degraded_active") {
1565
1564
  this.assertExactGenerationTransition(transition, {config: nextConfig, releaseId: newReleaseId, releasePath, revision: revision || newReleaseId})
1566
1565
  return await this.resumeGenerationTransition()
1567
1566
  }
@@ -1621,6 +1620,7 @@ export default class RollbridgeDaemon {
1621
1620
  candidateReleasePath: release.releasePath,
1622
1621
  candidateRevision: release.revision,
1623
1622
  configDigest: ownerConfigDigest(nextConfig),
1623
+ degradedIncumbent: transition?.phase === "degraded_active",
1624
1624
  journalRevision: 0,
1625
1625
  phase: activationLifecycle ? "candidate_ready" : "activating_candidate",
1626
1626
  previousReleaseId: previousRelease?.releaseId ?? null,
@@ -1639,6 +1639,7 @@ export default class RollbridgeDaemon {
1639
1639
  const transition = this.generationTransition
1640
1640
 
1641
1641
  if (!transition) throw new Error("No release generation transition to resume")
1642
+ if (transition.phase === "degraded_active") throw new Error("Degraded incumbent authority requires a fresh deployment, not transition resume")
1642
1643
  if (transition.phase === "restoring_previous" || transition.phase === "retiring_failed_candidate") {
1643
1644
  return await this.compensatePreCommitActivationFailure()
1644
1645
  }
@@ -1651,7 +1652,7 @@ export default class RollbridgeDaemon {
1651
1652
  let retirementFailure = activationLifecycle ? undefined : previousRelease?.retirementError
1652
1653
 
1653
1654
  if (transition.phase === "candidate_ready") {
1654
- if (previousRelease) await this.updateGenerationTransition("retiring_previous")
1655
+ if (previousRelease && !transition.degradedIncumbent) await this.updateGenerationTransition("retiring_previous")
1655
1656
  else await this.updateGenerationTransition("previous_retired")
1656
1657
  }
1657
1658
 
@@ -1874,7 +1875,7 @@ export default class RollbridgeDaemon {
1874
1875
 
1875
1876
  /**
1876
1877
  * Authenticated control recovery for one exact failed pre-commit transition.
1877
- * @param {{previousReleaseId: string, releaseId: string, releasePath: string, revision: string}} identity - Exact transition fence.
1878
+ * @param {{acceptRetiredIncumbent?: boolean, previousReleaseId: string, releaseId: string, releasePath: string, revision: string}} identity - Exact transition fence.
1878
1879
  * @returns {Promise<Record<string, JsonValue>>} Structured recovery result.
1879
1880
  */
1880
1881
  async recoverGenerationTransition(identity) {
@@ -1894,9 +1895,12 @@ export default class RollbridgeDaemon {
1894
1895
  if (transition.previousReleaseId !== identity.previousReleaseId) {
1895
1896
  throw new Error(`Generation transition previous release is ${transition.previousReleaseId}; refusing stale recovery for ${identity.previousReleaseId}`)
1896
1897
  }
1897
- if (transition.phase !== "activating_candidate" && transition.phase !== "restoring_previous" && transition.phase !== "retiring_failed_candidate") {
1898
+ if (transition.phase !== "activating_candidate" && transition.phase !== "restoring_previous" && transition.phase !== "retiring_failed_candidate" && transition.phase !== "degraded_active") {
1898
1899
  throw new Error(`Generation transition ${transition.candidateReleaseId} at ${transition.phase} is not a safe failed pre-commit transition`)
1899
1900
  }
1901
+ if (transition.phase === "degraded_active" && !identity.acceptRetiredIncumbent) {
1902
+ throw new Error("Degraded incumbent authority may only be acknowledged with --accept-retired-incumbent or replaced by a fresh deployment")
1903
+ }
1900
1904
  const nextConfig = this.configPath ? await loadConfig(this.configPath) : this.config
1901
1905
 
1902
1906
  this.assertReloadCompatible(nextConfig)
@@ -1909,9 +1913,76 @@ export default class RollbridgeDaemon {
1909
1913
  if (this.activeRelease?.releaseId !== transition.previousReleaseId) {
1910
1914
  throw new Error(`Previous release ${transition.previousReleaseId} is not the authoritative proxy target`)
1911
1915
  }
1916
+ if (identity.acceptRetiredIncumbent) return await this.acceptRetiredIncumbentTransition(transition)
1912
1917
  return await this.compensatePreCommitActivationFailure()
1913
1918
  }
1914
1919
 
1920
+ /**
1921
+ * Stops an exact failed candidate and persists degraded incumbent web authority until cutover.
1922
+ * @param {GenerationTransition} transition - Exact failed transition proved by the caller.
1923
+ * @returns {Promise<Record<string, JsonValue>>} Explicit degraded recovery result.
1924
+ */
1925
+ async acceptRetiredIncumbentTransition(transition) {
1926
+ if (transition.phase !== "restoring_previous" && transition.phase !== "degraded_active") throw new Error(`Retired-incumbent recovery requires exactly restoring_previous; transition ${transition.candidateReleaseId} is at ${transition.phase}`)
1927
+ if (!terminalRetirementFailure(transition.compensationError)) throw new Error("Retired-incumbent recovery requires a recorded restoration failure compatible with terminal retirement")
1928
+ if (!transition.previousReleaseId) throw new Error("Retired-incumbent recovery requires a retained previous release")
1929
+ const candidate = this.releases.get(transition.candidateReleaseId)
1930
+ const previous = this.releases.get(transition.previousReleaseId)
1931
+
1932
+ if (!candidate) throw new Error(`Generation transition candidate ${transition.candidateReleaseId} is not retained`)
1933
+ if (!previous) throw new Error(`Generation transition previous release ${transition.previousReleaseId} is not retained`)
1934
+ if (candidate.releasePath !== transition.candidateReleasePath || candidate.revision !== transition.candidateRevision || ownerConfigDigest(candidate.config) !== transition.configDigest) {
1935
+ throw new Error(`Generation transition candidate ${candidate.releaseId} does not retain its exact path, revision, and config authority`)
1936
+ }
1937
+ if (candidate.state !== "draining" && candidate.state !== "stopped") throw new Error(`Failed candidate ${candidate.releaseId} must be retired before accepting a retired incumbent`)
1938
+ if (this.activeRelease !== previous) throw new Error(`Previous release ${previous.releaseId} is not the authoritative proxy target`)
1939
+ const activationConfig = previous.config.processes.find((processConfig) => processConfig.lifecycle.activateCommand !== undefined)
1940
+ const coordinator = activationConfig ? previous.getProcesses(activationConfig.id)[0]?.process : undefined
1941
+ if (!coordinator || coordinator.status().lifecycleRole !== "retired") throw new Error(`Previous release ${previous.releaseId} does not retain a retired generation coordinator`)
1942
+ if (!releaseOwnsLiveProxyTraffic(previous)) throw new Error(`Previous release ${previous.releaseId} no longer owns live proxy/web traffic`)
1943
+ await candidate.drainAndStop(candidate.config.proxy.drainTimeoutMs, candidate.config)
1944
+ const candidateProcesses = candidate.status().processes
1945
+
1946
+ if (candidate.state !== "stopped" || candidateProcesses.some(({pid, state}) => pid !== undefined || (state !== "stopped" && state !== "failed"))) {
1947
+ throw new Error(`Failed candidate ${candidate.releaseId} did not fully stop during retired-incumbent recovery`)
1948
+ }
1949
+ if (this.activeRelease !== previous || !releaseOwnsLiveProxyTraffic(previous)) throw new Error(`Previous release ${previous.releaseId} lost live proxy/web authority during retired-incumbent recovery`)
1950
+ const result = /** @type {Record<string, JsonValue>} */ ({
1951
+ activeReleaseId: previous.releaseId,
1952
+ candidateReleaseId: candidate.releaseId,
1953
+ failedCandidateStatus: candidate.state,
1954
+ jobsStatus: "degraded",
1955
+ previousReleaseId: previous.releaseId,
1956
+ recoveryStatus: "retired_incumbent_accepted"
1957
+ })
1958
+
1959
+ if (transition.phase === "degraded_active") return result
1960
+ const previousError = transition.error
1961
+ const previousJournalRevision = transition.journalRevision
1962
+ const previousUpdatedAt = transition.updatedAt
1963
+
1964
+ transition.phase = "degraded_active"
1965
+ transition.error = undefined
1966
+ transition.journalRevision = (transition.journalRevision ?? 0) + 1
1967
+ transition.updatedAt = new Date().toISOString()
1968
+ try {
1969
+ await this.checkpointGenerationTransition()
1970
+ } catch (error) {
1971
+ transition.phase = "restoring_previous"
1972
+ transition.error = previousError
1973
+ transition.journalRevision = previousJournalRevision
1974
+ transition.updatedAt = previousUpdatedAt
1975
+ try {
1976
+ await this.publishOwnerState()
1977
+ } catch (publishError) {
1978
+ throw new AggregateError([error, publishError], "retired-incumbent recovery checkpoint and fence restoration failed", {cause: publishError})
1979
+ }
1980
+ throw new Error(`retired-incumbent recovery checkpoint failed: ${error instanceof Error ? error.message : String(error)}`, {cause: error})
1981
+ }
1982
+ this.logger("retired incumbent accepted as jobs-degraded", result)
1983
+ return result
1984
+ }
1985
+
1915
1986
  /**
1916
1987
  * @param {GenerationTransition} transition - Pending or committed exact transition.
1917
1988
  * @param {{config: import("./config.js").RollbridgeConfig, releaseId: string, releasePath: string, revision: string}} candidate - Requested identity.
@@ -2865,6 +2936,54 @@ export function ownerConfigDigest(config) {
2865
2936
  return crypto.createHash("sha256").update(JSON.stringify(config)).digest("hex")
2866
2937
  }
2867
2938
 
2939
+ /**
2940
+ * @param {PrivateOwnerState} transfer - Authenticated private incumbent state.
2941
+ * @param {GenerationTransition} transition - Unresolved generation transition.
2942
+ * @param {string} replacementConfigDigest - Proposed replacement authority.
2943
+ * @returns {boolean} Whether an existing exact config authority admits replacement.
2944
+ */
2945
+ export function ownerReplacementTransitionAuthorityMatches(transfer, transition, replacementConfigDigest) {
2946
+ const retainedRelease = transfer.snapshot.releases.find((release) =>
2947
+ release.releaseId === transition.candidateReleaseId &&
2948
+ release.releasePath === transition.candidateReleasePath &&
2949
+ release.revision === transition.candidateRevision)
2950
+ const retainedConfig = transfer.releaseConfigs?.[transition.candidateReleaseId]
2951
+ const retainedDigest = retainedRelease && retainedConfig ? ownerConfigDigest(retainedConfig) : undefined
2952
+
2953
+ return transition.configDigest === replacementConfigDigest ||
2954
+ transition.configDigest === ownerConfigDigest(transfer.config) ||
2955
+ transition.configDigest === retainedDigest
2956
+ }
2957
+
2958
+ /**
2959
+ * @param {string | undefined} failure - Recorded incumbent restoration failure.
2960
+ * @returns {boolean} Whether the diagnostic can represent terminal external retirement.
2961
+ */
2962
+ function terminalRetirementFailure(failure) {
2963
+ return typeof failure === "string" && (
2964
+ /^Cannot activate .+ generation from retired$/iu.test(failure) ||
2965
+ /^activate command exited non-zero with status \d+$/u.test(failure)
2966
+ )
2967
+ }
2968
+
2969
+ /**
2970
+ * @param {ReleaseGroup} release - Expected authoritative release.
2971
+ * @returns {boolean} Whether every configured proxy process is live.
2972
+ */
2973
+ function releaseOwnsLiveProxyTraffic(release) {
2974
+ const proxiedConfigs = release.config.processes.filter((processConfig) => processConfig.policy === "proxied")
2975
+
2976
+ return proxiedConfigs.length > 0 && proxiedConfigs.every((processConfig) => {
2977
+ const instances = release.getProcesses(processConfig.id)
2978
+
2979
+ return instances.length === processConfig.replicas && instances.every(({process}) => {
2980
+ const {pid, state} = process.status()
2981
+
2982
+ return pid !== undefined && state === "running"
2983
+ })
2984
+ })
2985
+ }
2986
+
2868
2987
  /**
2869
2988
  * Accepts only the two exact authenticated diagnostics emitted by pre-split guardians.
2870
2989
  * @param {string} diagnostic - Guardian response diagnostic.
@@ -44,7 +44,7 @@ test("completion bash prints a sourceable script with commands and option flags"
44
44
  assert.match(output, /compgen -W "daemon deploy rollback recover-generation-transition ensure-daemon status stop restart shutdown validate doctor logs events predeploy-cleanup recover completion"/)
45
45
  // A command's own options are completed after the command.
46
46
  assert.match(output, /deploy\)\n\s+opts="[^"]*--release-path[^"]*"/)
47
- assert.match(output, /recover-generation-transition\)\n\s+opts="--config --release-path --release-id --revision --previous-release-id"/)
47
+ assert.match(output, /recover-generation-transition\)\n\s+opts="--config --release-path --release-id --revision --previous-release-id --accept-retired-incumbent"/)
48
48
  assert.match(output, /ensure-daemon\)\n\s+opts="[^"]*--daemon-runtime-path[^"]*"/)
49
49
  assert.match(output, /restart\)\n\s+opts="[^"]*--policy[^"]*"/)
50
50
  })
@@ -55,7 +55,7 @@ test("completion zsh prints a #compdef script with per-command options", async (
55
55
  assert.match(output, /^#compdef rollbridge/)
56
56
  assert.match(output, /compdef _rollbridge rollbridge/)
57
57
  assert.match(output, /commands=\(daemon deploy rollback recover-generation-transition ensure-daemon status stop restart shutdown validate doctor logs events predeploy-cleanup recover completion\)/)
58
- assert.match(output, /recover-generation-transition\) compadd -- --config --release-path --release-id --revision --previous-release-id/)
58
+ assert.match(output, /recover-generation-transition\) compadd -- --config --release-path --release-id --revision --previous-release-id --accept-retired-incumbent/)
59
59
  assert.match(output, /events\) compadd -- [^\n]*--limit/)
60
60
  })
61
61
 
@@ -11,7 +11,7 @@ import test from "node:test"
11
11
  import {fileURLToPath} from "node:url"
12
12
  import {normalizeConfig} from "../src/config.js"
13
13
  import {openControlSession, sendControlCommand} from "../src/control-client.js"
14
- import RollbridgeDaemon, {isLegacyGuardianPrepareDiagnostic} from "../src/daemon.js"
14
+ import RollbridgeDaemon, {isLegacyGuardianPrepareDiagnostic, ownerConfigDigest, ownerReplacementTransitionAuthorityMatches} from "../src/daemon.js"
15
15
  import GuardianClient from "../src/guardian-client.js"
16
16
  import {findAvailablePort} from "../src/port-allocator.js"
17
17
  import {waitForProcessExit} from "./support/process.js"
@@ -1748,6 +1748,108 @@ test("config-changing owner replacement proceeds after activation compensation c
1748
1748
  }
1749
1749
  })
1750
1750
 
1751
+ test("owner replacement admits only the unresolved transition's exact retained candidate config", () => {
1752
+ const exactConfig = normalizeConfig(config({controlPath: "/tmp/exact.sock", extraCompanion: false, statePath: "/tmp/exact-state.json"}))
1753
+ const unrelatedConfig = normalizeConfig(config({controlPath: "/tmp/unrelated.sock", extraCompanion: true, statePath: "/tmp/exact-state.json"}))
1754
+ const replacementConfig = structuredClone(unrelatedConfig)
1755
+
1756
+ replacementConfig.releaseRetention.keep += 1
1757
+ const transition = /** @type {Parameters<typeof ownerReplacementTransitionAuthorityMatches>[1]} */ (/** @type {import("../src/json.js").JsonValue} */ ({
1758
+ candidateReleaseId: "v2",
1759
+ candidateReleasePath: "/srv/releases/v2",
1760
+ candidateRevision: "revision-v2",
1761
+ configDigest: ownerConfigDigest(exactConfig)
1762
+ }))
1763
+ /**
1764
+ * @param {import("../src/config.js").RollbridgeConfig | undefined} candidateConfig - Candidate authority.
1765
+ * @param {string} [releasePath] - Retained candidate path.
1766
+ * @returns {boolean} Whether replacement is admitted.
1767
+ */
1768
+ const admitted = (candidateConfig, releasePath = "/srv/releases/v2") => ownerReplacementTransitionAuthorityMatches(
1769
+ /** @type {Parameters<typeof ownerReplacementTransitionAuthorityMatches>[0]} */ (/** @type {import("../src/json.js").JsonValue} */ ({
1770
+ config: unrelatedConfig,
1771
+ releaseConfigs: candidateConfig ? {v2: candidateConfig} : {},
1772
+ snapshot: {releases: [{releaseId: "v2", releasePath, revision: "revision-v2"}]}
1773
+ })),
1774
+ transition,
1775
+ ownerConfigDigest(replacementConfig)
1776
+ )
1777
+
1778
+ assert.equal(admitted(exactConfig), true)
1779
+ assert.equal(admitted(unrelatedConfig), false)
1780
+ assert.equal(admitted(exactConfig, "/srv/releases/wrong"), false)
1781
+ })
1782
+
1783
+ test("owner replacement preserves accepted degraded incumbent web authority", async () => {
1784
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-replacement-degraded-active-"))
1785
+ const socketPath = path.join(root, "rollbridge.sock")
1786
+ const statePath = path.join(root, "state.json")
1787
+ const configPath = path.join(root, "rollbridge.cjs")
1788
+ const lifecycleLogPath = path.join(root, "generation.lifecycle")
1789
+ const retiredMarkerPath = path.join(root, "incumbent.retired")
1790
+ const v1Path = path.join(root, "v1")
1791
+ const v2Path = path.join(root, "v2")
1792
+ const proxyPort = await findAvailablePort({host: "127.0.0.1", range: {from: 24000, to: 24999}, usedPorts: new Set()})
1793
+ let owner
1794
+ let replacement
1795
+ const failedConfig = () => {
1796
+ const raw = config({activationLogPath: lifecycleLogPath, controlPath: socketPath, extraCompanion: false, proxyPort, statePath})
1797
+ const processes = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ (raw.processes)
1798
+ const worker = processes.find((processConfig) => processConfig.id === "worker")
1799
+ const workerLifecycle = /** @type {Record<string, import("../src/json.js").JsonValue>} */ (worker?.lifecycle)
1800
+ const generation = processes.find((processConfig) => processConfig.id === "generation-main")
1801
+ const lifecycle = /** @type {Record<string, import("../src/json.js").JsonValue>} */ (generation?.lifecycle)
1802
+
1803
+ delete workerLifecycle.drainCommand
1804
+ workerLifecycle.drainTimeoutMs = 0
1805
+ lifecycle.activateCommand = `[ "$ROLLBRIDGE_RELEASE_ID" != v2 ] || exit 24; [ "$ROLLBRIDGE_RELEASE_ID" != v1 ] || [ ! -f ${JSON.stringify(retiredMarkerPath)} ] || exit 26; printf 'activate:%s\\n' "$ROLLBRIDGE_RELEASE_ID" >> ${JSON.stringify(lifecycleLogPath)}`
1806
+ lifecycle.quietCommand = `touch ${JSON.stringify(retiredMarkerPath)}; printf 'retire:%s\\n' "$ROLLBRIDGE_RELEASE_ID" >> ${JSON.stringify(lifecycleLogPath)}`
1807
+ return raw
1808
+ }
1809
+
1810
+ try {
1811
+ await Promise.all([fs.mkdir(v1Path), fs.mkdir(v2Path)])
1812
+ await writeConfig(configPath, failedConfig())
1813
+ owner = spawn(process.execPath, [binPath, "daemon", "--config", configPath], {stdio: ["ignore", "pipe", "pipe"]})
1814
+ await waitForLog(owner, "control socket listening")
1815
+ await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath: v1Path, revision: "v1"}, path: socketPath})
1816
+ await assert.rejects(sendControlCommand({command: {command: "deploy", releaseId: "v2", releasePath: v2Path, revision: "v2"}, path: socketPath}), /pre-commit compensation failed.*status 26/i)
1817
+ const before = await sendControlCommand({command: {command: "status"}, path: socketPath})
1818
+ const webPid = releaseProcessPid(before, "v1", "web")
1819
+
1820
+ assert.equal(/** @type {{releaseId: string, state: string}[]} */ (before.releases).find(({releaseId}) => releaseId === "v2")?.state, "draining")
1821
+ const recovery = await sendControlCommand({command: {
1822
+ acceptRetiredIncumbent: true,
1823
+ command: "recover-generation-transition",
1824
+ previousReleaseId: "v1",
1825
+ releaseId: "v2",
1826
+ releasePath: v2Path,
1827
+ revision: "v2"
1828
+ }, path: socketPath})
1829
+ const accepted = await sendControlCommand({command: {command: "status"}, path: socketPath})
1830
+
1831
+ assert.equal(recovery.jobsStatus, "degraded")
1832
+ assert.equal(/** @type {{releaseId: string, state: string}[]} */ (accepted.releases).find(({releaseId}) => releaseId === "v2")?.state, "stopped")
1833
+ assert.equal(/** @type {{phase?: string}} */ (accepted.generationTransition).phase, "degraded_active")
1834
+ replacement = spawn(process.execPath, [binPath, "daemon", "--config", configPath, "--replace-owner"], {stdio: ["ignore", "pipe", "pipe"]})
1835
+ await waitForLog(replacement, "owner replacement committed")
1836
+ await new Promise((resolve) => setTimeout(resolve, 250))
1837
+ const recovered = await sendControlCommand({command: {command: "status"}, path: socketPath})
1838
+ const response = await fetch(`http://127.0.0.1:${proxyPort}/release`)
1839
+
1840
+ assert.equal(recovered.activeReleaseId, "v1")
1841
+ assert.equal(/** @type {{phase?: string}} */ (recovered.generationTransition).phase, "degraded_active")
1842
+ assert.equal(releaseProcessPid(recovered, "v1", "web"), webPid)
1843
+ assert.equal(response.status, 200)
1844
+ assert.equal((await response.text()).trim(), "v1")
1845
+ assert.equal(await fs.readFile(lifecycleLogPath, "utf8"), "activate:v1\nretire:v1\nretire:v2\n")
1846
+ } finally {
1847
+ for (const child of [owner, replacement]) if (child && child.exitCode === null && child.signalCode === null) child.kill("SIGKILL")
1848
+ await stopGuardian(statePath)
1849
+ await fs.rm(root, {force: true, recursive: true})
1850
+ }
1851
+ })
1852
+
1751
1853
  test("replacement publishes an unchanged control path only after incumbent retirement", async () => {
1752
1854
  const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-replacement-same-control-"))
1753
1855
  const socketPath = path.join(root, "rollbridge.sock")
@@ -757,6 +757,90 @@ test("candidate activation failure reports restoration failure and exact recover
757
757
  }
758
758
  })
759
759
 
760
+ test("explicit recovery stops the exact failed candidate and fences degraded incumbent authority", async () => {
761
+ const fixture = await createFixture({handoffService: true, handoffServiceActivate: true, handoffServiceActivateFailure: true, nonBlockingDrainWorker: true})
762
+ const daemon = await startDaemon(fixture.config)
763
+
764
+ try {
765
+ await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
766
+ const incumbentCoordinator = daemon.releases.get("v1")?.getProcess("beacon")
767
+
768
+ assert.ok(incumbentCoordinator)
769
+ incumbentCoordinator.reactivateStrict = async () => { throw new Error("Cannot activate background jobs generation from retired") }
770
+ await assert.rejects(
771
+ () => daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"}),
772
+ /Cannot activate background jobs generation from retired/i
773
+ )
774
+
775
+ const candidate = daemon.releases.get("v2")
776
+ const transition = daemon.generationTransition
777
+ const incumbentWebPid = statusRelease(daemon, "v1").processes.find(({id}) => id === "web")?.pid
778
+ const exactRecovery = (overrides = {}) => sendControlCommand({
779
+ command: {
780
+ acceptRetiredIncumbent: true,
781
+ command: "recover-generation-transition",
782
+ previousReleaseId: "v1",
783
+ releaseId: "v2",
784
+ releasePath: fixture.root,
785
+ revision: "v2",
786
+ ...overrides
787
+ },
788
+ path: fixture.config.control.path
789
+ })
790
+
791
+ assert.ok(candidate && transition && incumbentWebPid)
792
+ assert.equal(candidate.state, "draining", "ordinary failed compensation leaves the candidate draining")
793
+
794
+ const retainedCandidateConfig = candidate.config
795
+
796
+ candidate.config = {...candidate.config, releaseRetention: {...candidate.config.releaseRetention, keep: candidate.config.releaseRetention.keep + 1}}
797
+ await assert.rejects(() => exactRecovery(), /does not retain its exact path, revision, and config authority/i)
798
+ candidate.config = retainedCandidateConfig
799
+ await assert.rejects(() => exactRecovery({previousReleaseId: "wrong-v1"}), /refusing stale recovery/i)
800
+ await assert.rejects(() => exactRecovery({revision: "wrong-v2"}), /exact same release, path, revision, and config authority/i)
801
+ transition.phase = "retiring_failed_candidate"
802
+ await assert.rejects(() => exactRecovery(), /exactly restoring_previous/i)
803
+ transition.phase = "restoring_previous"
804
+ const terminalFailure = transition.compensationError
805
+
806
+ transition.compensationError = "incumbent activation was temporarily unavailable"
807
+ await assert.rejects(() => exactRecovery(), /terminal retirement/i)
808
+ transition.compensationError = terminalFailure
809
+ const checkpoint = daemon.checkpointGenerationTransition.bind(daemon)
810
+
811
+ daemon.checkpointGenerationTransition = async () => { throw new Error("injected checkpoint failure") }
812
+ await assert.rejects(() => exactRecovery(), /checkpoint failed: injected checkpoint failure/i)
813
+ assert.equal(daemon.generationTransition, transition)
814
+ daemon.checkpointGenerationTransition = checkpoint
815
+
816
+ const eventsBeforeRecovery = await lifecycleEvents(fixture.lifecycleLogPath)
817
+ const recovery = await exactRecovery()
818
+
819
+ assert.equal(recovery.recoveryStatus, "retired_incumbent_accepted")
820
+ assert.equal(recovery.jobsStatus, "degraded")
821
+ assert.equal(daemon.status().generationTransition?.phase, "degraded_active")
822
+ assert.equal(statusRelease(daemon, "v1").processes.find(({id}) => id === "web")?.pid, incumbentWebPid)
823
+ assert.equal(await fetchText(daemon, "/release"), "v1")
824
+ assert.equal(candidate.state, "stopped")
825
+ assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), eventsBeforeRecovery, "recovery must not activate either retained generation")
826
+ const persisted = /** @type {{generationTransition?: import("../src/json.js").JsonValue} | undefined} */ (await readState(fixture.statePath))
827
+
828
+ assert.equal(/** @type {{phase?: string} | undefined} */ (persisted?.generationTransition)?.phase, "degraded_active")
829
+ await assert.rejects(() => daemon.deploy({releaseId: "bad-v3", releasePath: fixture.root, revision: "bad-v3"}), /health check failed/i)
830
+ assert.equal(daemon.status().generationTransition?.phase, "degraded_active")
831
+ assert.equal(statusRelease(daemon, "v1").processes.find(({id}) => id === "web")?.pid, incumbentWebPid)
832
+ assert.equal(await fetchText(daemon, "/release"), "v1")
833
+ await daemon.deploy({releaseId: "v3", releasePath: fixture.root, revision: "v3"})
834
+ assert.equal(daemon.status().activeReleaseId, "v3")
835
+ assert.equal(daemon.status().generationTransition?.phase, "committed")
836
+ assert.equal(await fetchText(daemon, "/release"), "v3")
837
+ assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "retire:v2", "retire:bad-v3", "activate:v3"], "fresh deployment must not re-retire a degraded incumbent generation")
838
+ } finally {
839
+ await daemon.shutdown()
840
+ await fs.rm(fixture.root, {force: true, recursive: true})
841
+ }
842
+ })
843
+
760
844
  test("candidate activation failure compensates to the incumbent and admits a different later release", async () => {
761
845
  const fixture = await createFixture({handoffService: true, handoffServiceActivate: true, handoffServiceActivateFailure: true, nonBlockingDrainWorker: true, webDependsOnService: true})
762
846
  const daemon = await startDaemon(fixture.config)