rollbridge 0.1.40 → 0.1.41

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.
@@ -4,7 +4,7 @@
4
4
  // binds that stable HTTP port, forwards to the active release's internal web
5
5
  // port and keeps Beacon daemon-wide. The lifecycle socket path must match the
6
6
  // reviewed release-local Velocious jobs-main configuration; the worker appctl
7
- // command remains an illustrative application-specific quiescence control.
7
+ // commands remain illustrative application-specific quiescence/reactivation controls.
8
8
 
9
9
  export default {
10
10
  application: "tensorbuzz",
@@ -68,7 +68,10 @@ export default {
68
68
  VELOCIOUS_BACKGROUND_JOBS_PORT: "{{ports.background-jobs-main}}"
69
69
  },
70
70
  command: "wait-for-it 127.0.0.1:{{ports.beacon}} --strict -- wait-for-it 127.0.0.1:{{ports.background-jobs-main}} --strict -- npx velocious background-jobs-worker",
71
- lifecycle: {quietCommand: "appctl jobs-worker-retire --pid $ROLLBRIDGE_PID"},
71
+ lifecycle: {
72
+ quietCommand: "appctl jobs-worker-retire --pid $ROLLBRIDGE_PID",
73
+ reactivateCommand: "appctl jobs-worker-reactivate --pid $ROLLBRIDGE_PID"
74
+ },
72
75
  nonBlockingDrain: true,
73
76
  gracefulStopMs: "indefinite"
74
77
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rollbridge",
3
- "version": "0.1.40",
3
+ "version": "0.1.41",
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
@@ -213,6 +213,31 @@ export async function runCli(argv) {
213
213
  console.log(JSON.stringify(response, null, 2))
214
214
  })
215
215
 
216
+ program
217
+ .command("recover-generation-transition")
218
+ .description("Restore the exact incumbent and retire a failed pre-commit generation candidate.")
219
+ .option("-c, --config <path>", "Config file path (defaults to rollbridge.js)")
220
+ .requiredOption("--release-path <path>", "Failed candidate release path")
221
+ .requiredOption("--release-id <id>", "Failed candidate release id")
222
+ .requiredOption("--revision <sha>", "Failed candidate revision")
223
+ .requiredOption("--previous-release-id <id>", "Expected authoritative incumbent release id")
224
+ .action(async (options) => {
225
+ const configPath = await resolveConfigPath(options.config)
226
+ const config = await loadConfig(configPath)
227
+ const response = await sendControlCommand({
228
+ command: {
229
+ command: "recover-generation-transition",
230
+ previousReleaseId: options.previousReleaseId,
231
+ releaseId: options.releaseId,
232
+ releasePath: options.releasePath,
233
+ revision: options.revision
234
+ },
235
+ path: config.control.path
236
+ })
237
+
238
+ console.log(JSON.stringify(response, null, 2))
239
+ })
240
+
216
241
  program
217
242
  .command("ensure-daemon")
218
243
  .description("Start the daemon if the control socket is not already accepting commands.")
package/src/config.js CHANGED
@@ -14,7 +14,7 @@ import {pathToFileURL} from "node:url"
14
14
  * @typedef {"proxied" | "companion" | "singleton" | "service"} ProcessPolicy
15
15
  * @typedef {{backoffFactor: number, maxDelayMs: number, maxRestarts: number | undefined, windowMs: number}} RestartConfig
16
16
  * @typedef {{checkIntervalMs: number, limitBytes: number, warnBytes: number}} MemoryConfig
17
- * @typedef {{activateCommand?: string, drainCommand?: string, drainTimeoutMs: number, quietCommand?: string, stopCommand?: string}} LifecycleConfig
17
+ * @typedef {{activateCommand?: string, drainCommand?: string, drainTimeoutMs: number, quietCommand?: string, reactivateCommand?: string, stopCommand?: string}} LifecycleConfig
18
18
  * @typedef {number | "indefinite"} StopTimeoutMs
19
19
  * @typedef {"persistent" | "handoff"} ServiceDeployStrategy
20
20
  * @typedef {{cwd?: string, deployStrategy: ServiceDeployStrategy, env: Record<string, string>, gracefulStopMs: StopTimeoutMs, health?: HealthConfig, id: string, lifecycle: LifecycleConfig, memory?: MemoryConfig, nonBlockingDrain: boolean, outputLines: number, policy: ProcessPolicy, port?: PortRange, replicas: number, restart: RestartConfig, restartDelayMs: number, stopSignal: string, command: string}} ProcessConfig
@@ -346,7 +346,7 @@ function normalizeLifecycle(value, key, issues) {
346
346
  if (value === undefined || value === null) return {drainTimeoutMs: 0}
347
347
 
348
348
  if (!isPlainObject(value)) {
349
- issues.push({fix: `Set ${key} to a mapping with optional activateCommand, quietCommand, drainCommand, stopCommand, and drainTimeoutMs.`, message: `${key} must be an object`})
349
+ issues.push({fix: `Set ${key} to a mapping with optional activateCommand, quietCommand, reactivateCommand, drainCommand, stopCommand, and drainTimeoutMs.`, message: `${key} must be an object`})
350
350
 
351
351
  return {drainTimeoutMs: 0}
352
352
  }
@@ -357,6 +357,7 @@ function normalizeLifecycle(value, key, issues) {
357
357
 
358
358
  if (value.activateCommand !== undefined) lifecycle.activateCommand = normalizeString(value.activateCommand, `${key}.activateCommand`, issues, {nonEmpty: true})
359
359
  if (value.quietCommand !== undefined) lifecycle.quietCommand = normalizeString(value.quietCommand, `${key}.quietCommand`, issues, {nonEmpty: true})
360
+ if (value.reactivateCommand !== undefined) lifecycle.reactivateCommand = normalizeString(value.reactivateCommand, `${key}.reactivateCommand`, issues, {nonEmpty: true})
360
361
  if (value.drainCommand !== undefined) lifecycle.drainCommand = normalizeString(value.drainCommand, `${key}.drainCommand`, issues, {nonEmpty: true})
361
362
  if (value.stopCommand !== undefined) lifecycle.stopCommand = normalizeString(value.stopCommand, `${key}.stopCommand`, issues, {nonEmpty: true})
362
363
 
@@ -376,6 +377,7 @@ function normalizeLifecycle(value, key, issues) {
376
377
  */
377
378
  function validateActivationLifecycle(processes, ownerRecovery, statePath, issues) {
378
379
  const activated = processes.filter((processConfig) => processConfig.lifecycle.activateCommand !== undefined)
380
+ const reactivated = processes.filter((processConfig) => processConfig.lifecycle.reactivateCommand !== undefined)
379
381
 
380
382
  if (activated.length > 1) {
381
383
  issues.push({fix: "Configure lifecycle.activateCommand on only one release-generation coordinator.", message: "Config may define at most one lifecycle.activateCommand"})
@@ -390,6 +392,23 @@ function validateActivationLifecycle(processes, ownerRecovery, statePath, issues
390
392
  }
391
393
  }
392
394
 
395
+ for (const processConfig of reactivated) {
396
+ if (processConfig.policy !== "companion" || !processConfig.nonBlockingDrain) {
397
+ issues.push({fix: `Set lifecycle.reactivateCommand only on a nonBlockingDrain companion; "${processConfig.id}" is ${processConfig.policy} with nonBlockingDrain: ${processConfig.nonBlockingDrain}.`, message: `Process "${processConfig.id}" can only set lifecycle.reactivateCommand on a nonBlockingDrain companion`})
398
+ }
399
+ if (!processConfig.lifecycle.quietCommand) {
400
+ issues.push({fix: `Add lifecycle.quietCommand to "${processConfig.id}" so worker reactivation has a paired retirement command.`, message: `Process "${processConfig.id}" lifecycle.reactivateCommand requires lifecycle.quietCommand`})
401
+ }
402
+ }
403
+
404
+ if (activated.length > 0) {
405
+ for (const processConfig of processes) {
406
+ if (processConfig.nonBlockingDrain && processConfig.lifecycle.quietCommand && !processConfig.lifecycle.reactivateCommand) {
407
+ issues.push({fix: `Add lifecycle.reactivateCommand to "${processConfig.id}" so failed candidate recovery reverses its external quiet hook.`, message: `Process "${processConfig.id}" lifecycle.quietCommand requires lifecycle.reactivateCommand during generation activation recovery`})
408
+ }
409
+ }
410
+ }
411
+
393
412
  if (activated.length > 0 && (!ownerRecovery || !statePath)) {
394
413
  issues.push({fix: "Configure statePath and ownerRecovery for durable release-generation transition recovery.", message: "lifecycle.activateCommand requires ownerRecovery and statePath"})
395
414
  }
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" | "committed_pending" | "committed" | "restoring_committed"} GenerationTransitionPhase
28
- * @typedef {{activationLifecycle?: boolean, candidateReleaseId: string, candidateReleasePath: string, candidateRevision: 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" | "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
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
@@ -1337,6 +1337,15 @@ export default class RollbridgeDaemon {
1337
1337
  }))
1338
1338
  }
1339
1339
 
1340
+ if (commandName === "recover-generation-transition") {
1341
+ return await this.executeOwnerMutation("recover generation transition", async () => await this.recoverGenerationTransition({
1342
+ previousReleaseId: requiredString(data.previousReleaseId, "previousReleaseId"),
1343
+ releaseId: requiredString(data.releaseId, "releaseId"),
1344
+ releasePath: requiredString(data.releasePath, "releasePath"),
1345
+ revision: requiredString(data.revision, "revision")
1346
+ }))
1347
+ }
1348
+
1340
1349
  if (commandName === "status") {
1341
1350
  return this.status()
1342
1351
  }
@@ -1627,6 +1636,9 @@ export default class RollbridgeDaemon {
1627
1636
  const transition = this.generationTransition
1628
1637
 
1629
1638
  if (!transition) throw new Error("No release generation transition to resume")
1639
+ if (transition.phase === "restoring_previous" || transition.phase === "retiring_failed_candidate") {
1640
+ return await this.compensatePreCommitActivationFailure()
1641
+ }
1630
1642
  const release = this.releases.get(transition.candidateReleaseId)
1631
1643
  const previousRelease = transition.previousReleaseId ? this.releases.get(transition.previousReleaseId) : undefined
1632
1644
 
@@ -1668,7 +1680,23 @@ export default class RollbridgeDaemon {
1668
1680
 
1669
1681
  await this.failGenerationTransition(failure)
1670
1682
  this.logger("release generation activation failed", {error: failure, releaseId: release.releaseId})
1671
- throw error
1683
+ if (!previousRelease || !activationLifecycle) throw error
1684
+
1685
+ let compensation
1686
+
1687
+ try {
1688
+ compensation = await this.compensatePreCommitActivationFailure()
1689
+ } catch (compensationError) {
1690
+ throw new AggregateError(
1691
+ [error, compensationError],
1692
+ `${failure}; pre-commit compensation failed: ${compensationError instanceof Error ? compensationError.message : String(compensationError)}`,
1693
+ {cause: compensationError}
1694
+ )
1695
+ }
1696
+ const compensatedError = /** @type {Error & {compensation?: Record<string, JsonValue>}} */ (new Error(`${failure}; compensation restored incumbent ${compensation.previousReleaseId} as authoritative and retired failed candidate ${compensation.candidateReleaseId}`, {cause: error}))
1697
+
1698
+ compensatedError.compensation = compensation
1699
+ throw compensatedError
1672
1700
  }
1673
1701
 
1674
1702
  // Activation acknowledgement and the logical proxy commit deliberately share one
@@ -1732,6 +1760,155 @@ export default class RollbridgeDaemon {
1732
1760
  }
1733
1761
  }
1734
1762
 
1763
+ /**
1764
+ * Restores the authoritative proxy target and retires only the failed candidate.
1765
+ * Every external effect is preceded by a durable phase checkpoint so recovery can
1766
+ * safely replay the paired lifecycle hooks, which are already idempotent contracts.
1767
+ * @returns {Promise<Record<string, JsonValue>>} Structured recovery result.
1768
+ */
1769
+ async compensatePreCommitActivationFailure() {
1770
+ const transition = this.generationTransition
1771
+
1772
+ if (!transition) throw new Error("No release generation transition to compensate")
1773
+ if (transition.phase !== "activating_candidate" && transition.phase !== "restoring_previous" && transition.phase !== "retiring_failed_candidate") {
1774
+ throw new Error(`Generation transition ${transition.candidateReleaseId} at ${transition.phase} is not safe for pre-commit compensation`)
1775
+ }
1776
+ if (!transition.previousReleaseId) throw new Error("Pre-commit compensation requires a retained previous release")
1777
+ const candidate = this.releases.get(transition.candidateReleaseId)
1778
+ const previous = this.releases.get(transition.previousReleaseId)
1779
+
1780
+ if (!candidate) throw new Error(`Generation transition candidate ${transition.candidateReleaseId} is not retained`)
1781
+ if (!previous) throw new Error(`Generation transition previous release ${transition.previousReleaseId} is not retained`)
1782
+ if (this.activeRelease !== previous) throw new Error(`Previous release ${previous.releaseId} is not the authoritative proxy target`)
1783
+
1784
+ if (transition.phase === "activating_candidate") {
1785
+ if (!transition.error && !transition.activationError) throw new Error("Candidate activation has no recorded failure to compensate")
1786
+ transition.activationError = transition.activationError || transition.error
1787
+ transition.compensationError = undefined
1788
+ await this.updateGenerationTransition("retiring_failed_candidate")
1789
+ }
1790
+
1791
+ if (transition.phase === "retiring_failed_candidate") {
1792
+ try {
1793
+ await candidate.beginRetirement(candidate.config, {retry: true})
1794
+ const candidateServiceIds = [...this.serviceReleaseIds.entries()]
1795
+ .filter(([, releaseId]) => releaseId === candidate.releaseId)
1796
+ .map(([serviceId]) => serviceId)
1797
+
1798
+ await this.stopStartedServices(candidateServiceIds)
1799
+ } catch (error) {
1800
+ const failure = error instanceof Error ? error.message : String(error)
1801
+
1802
+ transition.compensationError = failure
1803
+ await this.failGenerationTransition(`Failed candidate ${candidate.releaseId} retirement failed: ${failure}`)
1804
+ this.logger("release generation compensation candidate retirement failed", {
1805
+ activationError: transition.activationError,
1806
+ error: failure,
1807
+ releaseId: candidate.releaseId
1808
+ })
1809
+ throw new Error(`failed candidate ${candidate.releaseId} retirement failed: ${failure}`, {cause: error})
1810
+ }
1811
+ transition.compensationError = undefined
1812
+ await this.updateGenerationTransition("restoring_previous")
1813
+ this.logger("release generation compensation candidate retired", {releaseId: candidate.releaseId})
1814
+ }
1815
+
1816
+ if (transition.phase === "restoring_previous") {
1817
+ if (candidate.state !== "draining") throw new Error(`Failed candidate ${candidate.releaseId} is not retired before incumbent restoration`)
1818
+ try {
1819
+ await previous.reactivateGeneration()
1820
+ } catch (error) {
1821
+ const failure = error instanceof Error ? error.message : String(error)
1822
+
1823
+ transition.compensationError = failure
1824
+ await this.failGenerationTransition(`Incumbent ${previous.releaseId} restoration failed: ${failure}`)
1825
+ this.logger("release generation compensation restoration failed", {
1826
+ activationError: transition.activationError,
1827
+ error: failure,
1828
+ releaseId: previous.releaseId
1829
+ })
1830
+ throw new Error(`incumbent ${previous.releaseId} restoration failed: ${failure}`, {cause: error})
1831
+ }
1832
+ this.activeRelease = previous
1833
+ transition.compensationError = undefined
1834
+ this.logger("release generation compensation incumbent restored", {releaseId: previous.releaseId})
1835
+ }
1836
+
1837
+ const result = /** @type {Record<string, JsonValue>} */ ({
1838
+ activeReleaseId: previous.releaseId,
1839
+ activationError: transition.activationError,
1840
+ candidateReleaseId: candidate.releaseId,
1841
+ failedCandidateStatus: candidate.state,
1842
+ previousReleaseId: previous.releaseId,
1843
+ recoveryStatus: "recovered"
1844
+ })
1845
+
1846
+ this.config = previous.config
1847
+ this.generationTransition = undefined
1848
+ try {
1849
+ await this.checkpointGenerationTransition()
1850
+ } catch (error) {
1851
+ const failure = error instanceof Error ? error.message : String(error)
1852
+
1853
+ this.generationTransition = transition
1854
+ transition.compensationError = failure
1855
+ transition.error = `Compensation checkpoint clear failed: ${failure}`
1856
+ transition.journalRevision = (transition.journalRevision ?? 0) + 1
1857
+ transition.updatedAt = new Date().toISOString()
1858
+ await this.publishOwnerState().catch((publishError) => {
1859
+ this.logger("release generation compensation fence republish failed", {
1860
+ error: publishError instanceof Error ? publishError.message : String(publishError),
1861
+ releaseId: candidate.releaseId
1862
+ })
1863
+ })
1864
+ this.logger("release generation compensation checkpoint failed", {error: failure, releaseId: candidate.releaseId})
1865
+ throw new Error(`compensation checkpoint clear failed: ${failure}`, {cause: error})
1866
+ }
1867
+ void this.drainAndPrune(candidate, candidate.config)
1868
+ this.logger("release generation compensation completed", result)
1869
+ return result
1870
+ }
1871
+
1872
+ /**
1873
+ * Authenticated control recovery for one exact failed pre-commit transition.
1874
+ * @param {{previousReleaseId: string, releaseId: string, releasePath: string, revision: string}} identity - Exact transition fence.
1875
+ * @returns {Promise<Record<string, JsonValue>>} Structured recovery result.
1876
+ */
1877
+ async recoverGenerationTransition(identity) {
1878
+ const transition = this.generationTransition
1879
+
1880
+ if (!transition) {
1881
+ if (this.activeRelease?.releaseId !== identity.previousReleaseId || this.activeRelease.releaseId === identity.releaseId) {
1882
+ throw new Error("No matching recovered generation transition; the expected incumbent is not authoritative")
1883
+ }
1884
+ return {
1885
+ activeReleaseId: this.activeRelease.releaseId,
1886
+ candidateReleaseId: identity.releaseId,
1887
+ previousReleaseId: identity.previousReleaseId,
1888
+ recoveryStatus: "already_recovered"
1889
+ }
1890
+ }
1891
+ if (transition.previousReleaseId !== identity.previousReleaseId) {
1892
+ throw new Error(`Generation transition previous release is ${transition.previousReleaseId}; refusing stale recovery for ${identity.previousReleaseId}`)
1893
+ }
1894
+ if (transition.phase !== "activating_candidate" && transition.phase !== "restoring_previous" && transition.phase !== "retiring_failed_candidate") {
1895
+ throw new Error(`Generation transition ${transition.candidateReleaseId} at ${transition.phase} is not a safe failed pre-commit transition`)
1896
+ }
1897
+ const nextConfig = this.configPath ? await loadConfig(this.configPath) : this.config
1898
+
1899
+ this.assertReloadCompatible(nextConfig)
1900
+ this.assertExactGenerationTransition(transition, {
1901
+ config: nextConfig,
1902
+ releaseId: identity.releaseId,
1903
+ releasePath: identity.releasePath,
1904
+ revision: identity.revision
1905
+ })
1906
+ if (this.activeRelease?.releaseId !== transition.previousReleaseId) {
1907
+ throw new Error(`Previous release ${transition.previousReleaseId} is not the authoritative proxy target`)
1908
+ }
1909
+ return await this.compensatePreCommitActivationFailure()
1910
+ }
1911
+
1735
1912
  /**
1736
1913
  * @param {GenerationTransition} transition - Pending or committed exact transition.
1737
1914
  * @param {{config: import("./config.js").RollbridgeConfig, releaseId: string, releasePath: string, revision: string}} candidate - Requested identity.
@@ -23,6 +23,7 @@ export default class GuardianClient {
23
23
  this.processes = /** @type {Map<string, GuardianProcess>} */ (new Map())
24
24
  this.reservedProcessKey = /** @type {string | undefined} */ (undefined)
25
25
  this.reservedProcessProvenance = /** @type {string | undefined} */ (undefined)
26
+ this.generationReactivation = /** @type {boolean | undefined} */ (undefined)
26
27
  this.events = /** @type {Map<string, {reject: (error: Error) => void, resolve: (value: Record<string, import("./json.js").JsonValue>) => void}[]>} */ (new Map())
27
28
  this.eventHandlers = /** @type {Map<string, ((event: Record<string, import("./json.js").JsonValue>) => void)[]>} */ (new Map())
28
29
  }
@@ -51,6 +52,7 @@ export default class GuardianClient {
51
52
  if (child.connected) await new Promise((resolve) => child.once("disconnect", () => resolve(undefined)))
52
53
  child.unref()
53
54
  await this.connect()
55
+ this.generationReactivation = true
54
56
  }
55
57
 
56
58
  /**
@@ -184,9 +186,12 @@ export default class GuardianClient {
184
186
  await this.request({command: "owner-ready", ownerPid: process.pid})
185
187
  }
186
188
 
187
- /** @returns {Promise<{daemonRecovery: number}>} Guardian protocol capabilities. */
189
+ /** @returns {Promise<{daemonRecovery: number, generationReactivation?: number}>} Guardian protocol capabilities. */
188
190
  async capabilities() {
189
- return /** @type {{daemonRecovery: number}} */ (await this.request({command: "capabilities"}))
191
+ const capabilities = /** @type {{daemonRecovery: number, generationReactivation?: number}} */ (await this.request({command: "capabilities"}))
192
+
193
+ this.generationReactivation = capabilities.generationReactivation === 1
194
+ return capabilities
190
195
  }
191
196
 
192
197
  /** Starts graceful process retirement and relinquishes committed owner authority. */
@@ -445,6 +450,7 @@ class GuardianProcess extends ManagedProcess {
445
450
  this.cachedStatus = super.status()
446
451
  this.registration = /** @type {Promise<void> | undefined} */ (undefined)
447
452
  this.pendingUpdate = Promise.resolve()
453
+ this.compatibilityReactivated = false
448
454
  }
449
455
 
450
456
  async ensureRegistered() {
@@ -479,6 +485,7 @@ class GuardianProcess extends ManagedProcess {
479
485
  await this.pendingUpdate
480
486
  if (lifecycleRole) this.lifecycleRole = lifecycleRole
481
487
  this.cachedStatus = asProcessStatus(await this.client.request({command: "start", key: this.key, lifecycleRole, reason}))
488
+ if (this.cachedStatus.state === "running") this.compatibilityReactivated = false
482
489
  }
483
490
 
484
491
  /**
@@ -529,12 +536,52 @@ class GuardianProcess extends ManagedProcess {
529
536
  this.lifecycleRole = "active"
530
537
  }
531
538
 
539
+ async reactivateStrict() {
540
+ await this.ensureRegistered()
541
+ await this.pendingUpdate
542
+ const command = this.lifecycle.reactivateCommand ? "reactivate-with-command" : "reactivate"
543
+ try {
544
+ this.cachedStatus = asProcessStatus(await this.client.request({command, key: this.key}))
545
+ this.client.generationReactivation = true
546
+ this.compatibilityReactivated = false
547
+ } catch (error) {
548
+ if (!(error instanceof Error) || error.message !== `Unknown guardian command: ${command}`) throw error
549
+ this.client.generationReactivation = false
550
+ await this.adoptRetainedActive({activate: true})
551
+ }
552
+ this.lifecycleRole = "active"
553
+ }
554
+
555
+ /**
556
+ * Bridges a retained quiesced process owned by a pre-reactivation guardian.
557
+ * @param {{activate: boolean}} options - Whether to run the external activation acknowledgement.
558
+ */
559
+ async adoptRetainedActive({activate}) {
560
+ const before = this.cachedStatus
561
+
562
+ if (!before.pid || (before.state !== "quiesced" && before.state !== "running")) throw new Error(`Process ${this.id} is not retained for compatible reactivation`)
563
+ if (activate) await this.runReactivationHook(before.pid)
564
+ const verified = asProcessStatus(await this.client.request({command: "status", key: this.key}))
565
+
566
+ if (verified.pid !== before.pid || (verified.state !== "quiesced" && verified.state !== "running")) {
567
+ throw new Error(`Process ${this.id} exited before compatible reactivation completed`)
568
+ }
569
+ const adopted = asProcessStatus(await this.client.request({command: "start", key: this.key, lifecycleRole: "active", reason: "deploy"}))
570
+
571
+ if (adopted.pid !== before.pid) throw new Error(`Process ${this.id} changed before compatible reactivation was adopted`)
572
+ this.cachedStatus = asProcessStatus({...adopted, lifecycleRole: "active", state: "running"})
573
+ this.compatibilityReactivated = true
574
+ }
575
+
532
576
  /** @param {import("./managed-process.js").LifecycleRole} role - Exact generation role. */
533
577
  async setLifecycleRole(role) {
534
578
  await this.ensureRegistered()
535
579
  await this.pendingUpdate
536
580
  this.cachedStatus = asProcessStatus(await this.client.request({command: "set-lifecycle-role", key: this.key, lifecycleRole: role}))
537
581
  this.lifecycleRole = role
582
+ if (role === "active" && this.client.generationReactivation === false && this.cachedStatus.state === "quiesced") {
583
+ await this.adoptRetainedActive({activate: false})
584
+ }
538
585
  }
539
586
 
540
587
  async stop(options = {}) {
@@ -550,6 +597,12 @@ class GuardianProcess extends ManagedProcess {
550
597
  /** @param {Record<string, import("./json.js").JsonValue>} event - Guardian event. */
551
598
  onGuardianEvent(event) {
552
599
  if (event.status) this.cachedStatus = asProcessStatus(event.status)
600
+ if (this.compatibilityReactivated && (this.cachedStatus.state === "failed" || this.cachedStatus.state === "stopped") && this.shouldRestart()) {
601
+ this.compatibilityReactivated = false
602
+ void this.start("crash", this.lifecycleRole).catch((error) => {
603
+ this.logger("compatible reactivated process restart failed", {error: error instanceof Error ? error.message : String(error), id: this.id})
604
+ })
605
+ }
553
606
  if (event.event === "process-log") {
554
607
  const entry = asProcessLog(event.entry)
555
608
 
@@ -486,14 +486,14 @@ export default class ManagedProcess extends EventEmitter {
486
486
  if (this.lifecycleRestoreBarrier) await this.lifecycleRestoreBarrier
487
487
  if (!this.child?.pid) {
488
488
  this.state = "stopped"
489
- if (this.lifecycle.activateCommand) this.lifecycleRole = "retired"
489
+ if (this.hasRestorableLifecycle()) this.lifecycleRole = "retired"
490
490
  return
491
491
  }
492
492
  this.state = "stopping"
493
493
  if (this.lifecycle.quietCommand) this.quiesceError = await this.runHook(this.lifecycle.quietCommand, this.hookTimeoutMs(), "quiet command")
494
494
  if (!this.quiesceError) {
495
495
  this.state = "quiesced"
496
- if (this.lifecycle.activateCommand) this.lifecycleRole = "retired"
496
+ if (this.hasRestorableLifecycle()) this.lifecycleRole = "retired"
497
497
  }
498
498
  })()
499
499
  return await this.quiescePromise
@@ -521,11 +521,63 @@ export default class ManagedProcess extends EventEmitter {
521
521
  const pid = this.pid
522
522
 
523
523
  if (!child?.pid || child.pid !== pid || this.state !== "running") throw new Error(`Process ${this.id} is not running for activation`)
524
+ await this.runActivationHook(pid)
525
+ if (this.child !== child || this.pid !== pid || this.state !== "running") throw new Error(`Process ${this.id} exited before activation completed`)
526
+ this.lifecycleRole = "active"
527
+ }
528
+
529
+ /** Reactivates an explicitly retired process without replacing its release-scoped process. */
530
+ async reactivateStrict() {
531
+ const child = this.child
532
+ const pid = this.pid
533
+
534
+ if (!child?.pid || child.pid !== pid || (this.state !== "quiesced" && this.state !== "running")) {
535
+ throw new Error(`Process ${this.id} is not retained for reactivation`)
536
+ }
537
+ await this.runReactivationHook(pid)
538
+ if (this.child !== child || this.pid !== pid) throw new Error(`Process ${this.id} exited before reactivation completed`)
539
+ this.intentionalStop = false
540
+ this.intentionalStopSignal = undefined
541
+ this.quiescePromise = undefined
542
+ this.quiesceError = undefined
543
+ this.state = "running"
544
+ this.lifecycleRole = "active"
545
+ this.startMemoryMonitor()
546
+ }
547
+
548
+ /**
549
+ * Runs the generation activation hook against one exact retained process.
550
+ * @param {number | undefined} pid - Exact process group leader.
551
+ * @returns {Promise<void>} Resolves after acknowledgement.
552
+ */
553
+ async runActivationHook(pid) {
554
+ const command = this.lifecycle.activateCommand
555
+
556
+ if (!command) return
524
557
  const error = await this.runHook(command, ACTIVATION_HOOK_TIMEOUT_MS, "activate command", pid)
525
558
 
526
559
  if (error) throw error
527
- if (this.child !== child || this.pid !== pid || this.state !== "running") throw new Error(`Process ${this.id} exited before activation completed`)
528
- this.lifecycleRole = "active"
560
+ }
561
+
562
+ /**
563
+ * Runs the process-specific reactivation hook, falling back to the generation
564
+ * coordinator's activation hook for the original lifecycle contract.
565
+ * @param {number | undefined} pid - Exact process group leader.
566
+ * @returns {Promise<void>} Resolves after acknowledgement.
567
+ */
568
+ async runReactivationHook(pid) {
569
+ const command = this.lifecycle.reactivateCommand ?? this.lifecycle.activateCommand
570
+
571
+ if (!command) return
572
+ const label = this.lifecycle.reactivateCommand ? "reactivate command" : "activate command"
573
+ const error = await this.runHook(command, ACTIVATION_HOOK_TIMEOUT_MS, label, pid)
574
+
575
+ if (error) throw error
576
+ }
577
+
578
+ /** @returns {boolean} Whether this process owns a durable external lifecycle role. */
579
+ hasRestorableLifecycle() {
580
+ return Boolean(this.lifecycle.activateCommand || this.lifecycle.reactivateCommand)
529
581
  }
530
582
 
531
583
  /**
@@ -538,12 +590,15 @@ export default class ManagedProcess extends EventEmitter {
538
590
 
539
591
  /** Restores an active or retired role after this exact process starts. */
540
592
  async restoreLifecycleRole() {
541
- if (!this.lifecycle.activateCommand || this.lifecycleRole === "candidate") return
542
- const command = this.lifecycleRole === "active" ? this.lifecycle.activateCommand : this.lifecycle.quietCommand
593
+ if (!this.hasRestorableLifecycle() || this.lifecycleRole === "candidate") return
594
+ const command = this.lifecycleRole === "active"
595
+ ? this.lifecycle.reactivateCommand ?? this.lifecycle.activateCommand
596
+ : this.lifecycle.quietCommand
543
597
 
544
598
  if (!command) throw new Error(`Process ${this.id} cannot restore lifecycle role ${this.lifecycleRole} without its paired command`)
545
599
  const timeoutMs = this.lifecycleRole === "active" ? ACTIVATION_HOOK_TIMEOUT_MS : this.hookTimeoutMs()
546
- const error = await this.runHook(command, timeoutMs, `${this.lifecycleRole === "active" ? "activate" : "quiet"} command`, this.pid)
600
+ const activeLabel = this.lifecycle.reactivateCommand ? "reactivate" : "activate"
601
+ const error = await this.runHook(command, timeoutMs, `${this.lifecycleRole === "active" ? activeLabel : "quiet"} command`, this.pid)
547
602
 
548
603
  if (error) throw error
549
604
  }
@@ -760,7 +815,7 @@ export default class ManagedProcess extends EventEmitter {
760
815
  id: this.id,
761
816
  lastMemoryRestartAt: this.lastMemoryRestartAtMs === undefined ? undefined : new Date(this.lastMemoryRestartAtMs).toISOString(),
762
817
  lastStartReason: this.lastStartReason,
763
- ...(this.lifecycle.activateCommand ? {lifecycleRole: this.lifecycleRole} : {}),
818
+ ...(this.hasRestorableLifecycle() ? {lifecycleRole: this.lifecycleRole} : {}),
764
819
  logs: this.logs.slice(-this.outputLines),
765
820
  memoryRestarts: this.memoryRestarts,
766
821
  pid: this.pid,
@@ -259,7 +259,7 @@ async function handleLine(socket, line) {
259
259
  async function execute(request, socket) {
260
260
  if (shuttingDown) throw new Error("Process guardian is shutting down")
261
261
 
262
- if (request.command === "capabilities") return {daemonRecovery: 1}
262
+ if (request.command === "capabilities") return {daemonRecovery: 1, generationReactivation: 1}
263
263
 
264
264
  if (request.command === "owner-replacement-capabilities") {
265
265
  return {commands: ["commit-retired-owner-replacement"], protocol: "owner-replacement", version: 1}
@@ -655,6 +655,9 @@ async function execute(request, socket) {
655
655
  await record.process.start(request.reason, request.lifecycleRole)
656
656
  } else if (request.command === "activate") {
657
657
  await record.process.activateStrict()
658
+ } else if (request.command === "reactivate" || request.command === "reactivate-with-command") {
659
+ await record.process.reactivateStrict()
660
+ record.desired = true
658
661
  } else if (request.command === "quiesce") {
659
662
  record.desired = false
660
663
  await record.process.quiesceStrict()
@@ -279,6 +279,28 @@ export default class ReleaseGroup extends EventEmitter {
279
279
  await instance.process.activateStrict()
280
280
  }
281
281
 
282
+ /** Restores the retained generation coordinator to its active role in place. */
283
+ async reactivateGeneration() {
284
+ if (this.state !== "draining" && this.state !== "active") throw new Error(`Generation ${this.releaseId} is not retained for reactivation`)
285
+ const processConfig = this.config.processes.find((candidate) => candidate.lifecycle.activateCommand !== undefined)
286
+
287
+ if (!processConfig) throw new Error(`Generation ${this.releaseId} has no activation lifecycle`)
288
+ const [coordinator] = this.getProcesses(processConfig.id)
289
+
290
+ if (!coordinator) throw new Error(`Generation activation process ${processConfig.id} is not retained for release ${this.releaseId}`)
291
+ const generationIds = new Set([...this.handoffServiceIds, ...this.nonBlockingDrainIds])
292
+
293
+ for (const [id, processInstance] of this.processes) {
294
+ if (generationIds.has(id) && processInstance !== coordinator.process) await processInstance.reactivateStrict()
295
+ }
296
+ await coordinator.process.reactivateStrict()
297
+ this.state = "active"
298
+ this.activatedAt = new Date().toISOString()
299
+ this.drainStartedAt = undefined
300
+ this.retirementError = undefined
301
+ this.stoppedAt = undefined
302
+ }
303
+
282
304
  /**
283
305
  * Restarts only the exact processes reconstructed for a committed generation.
284
306
  * The caller must prove the durable transition identity before using this path.
@@ -41,9 +41,10 @@ test("completion bash prints a sourceable script with commands and option flags"
41
41
 
42
42
  assert.notEqual(code, 1)
43
43
  assert.match(output, /complete -F _rollbridge rollbridge/)
44
- assert.match(output, /compgen -W "daemon deploy rollback ensure-daemon status stop restart shutdown validate doctor logs events predeploy-cleanup recover completion"/)
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
48
  assert.match(output, /ensure-daemon\)\n\s+opts="[^"]*--daemon-runtime-path[^"]*"/)
48
49
  assert.match(output, /restart\)\n\s+opts="[^"]*--policy[^"]*"/)
49
50
  })
@@ -53,7 +54,8 @@ test("completion zsh prints a #compdef script with per-command options", async (
53
54
 
54
55
  assert.match(output, /^#compdef rollbridge/)
55
56
  assert.match(output, /compdef _rollbridge rollbridge/)
56
- assert.match(output, /commands=\(daemon deploy rollback ensure-daemon status stop restart shutdown validate doctor logs events predeploy-cleanup recover completion\)/)
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/)
57
59
  assert.match(output, /events\) compadd -- [^\n]*--limit/)
58
60
  })
59
61
 
@@ -27,6 +27,7 @@ test("TensorBuzz example config loads", async () => {
27
27
  ["web", "proxied"]
28
28
  ]
29
29
  )
30
+ assert.equal(config.processes[2].lifecycle.reactivateCommand, "appctl jobs-worker-reactivate --pid $ROLLBRIDGE_PID")
30
31
  assert.equal(config.processes[3].env.VELOCIOUS_BACKGROUND_JOBS_PORT, "{{ports.background-jobs-main}}")
31
32
  })
32
33
 
@@ -250,6 +250,24 @@ test("validateConfig accepts one durable handoff activation lifecycle and reject
250
250
  {...base.processes[1], id: "jobs-secondary", port: {from: 18200, to: 18299}}
251
251
  ]})
252
252
  assert.ok(duplicate.issues.some((issue) => /at most one lifecycle\.activateCommand/.test(issue.message)))
253
+
254
+ const worker = {
255
+ command: "run worker",
256
+ id: "worker",
257
+ lifecycle: {quietCommand: "worker quiet", reactivateCommand: "worker resume"},
258
+ nonBlockingDrain: true,
259
+ policy: "companion"
260
+ }
261
+ const pairedWorker = validateConfig({...base, processes: [...base.processes, worker]})
262
+
263
+ assert.deepEqual(pairedWorker.issues, [])
264
+ assert.equal(pairedWorker.config.processes[2].lifecycle.reactivateCommand, "worker resume")
265
+
266
+ const unpairedWorker = validateConfig({...base, processes: [...base.processes, {...worker, lifecycle: {quietCommand: "worker quiet"}}]})
267
+ assert.ok(unpairedWorker.issues.some((issue) => /quietCommand requires lifecycle\.reactivateCommand/.test(issue.message)))
268
+
269
+ const unsupportedPlacement = validateConfig({...base, processes: [...base.processes, {...worker, nonBlockingDrain: false}]})
270
+ assert.ok(unsupportedPlacement.issues.some((issue) => /reactivateCommand.*nonBlockingDrain companion/.test(issue.message)))
253
271
  })
254
272
 
255
273
  test("validateConfig accepts indefinite graceful stop windows", () => {
@@ -18,7 +18,7 @@ test("guardian bootstrap capability is absent from process argv", async () => {
18
18
  const fixture = await createGuardian()
19
19
 
20
20
  try {
21
- assert.deepEqual(await fixture.client.capabilities(), {daemonRecovery: 1})
21
+ assert.deepEqual(await fixture.client.capabilities(), {daemonRecovery: 1, generationReactivation: 1})
22
22
  const commandLine = await fs.readFile(`/proc/${fixture.client.pid}/cmdline`, "utf8")
23
23
  const environment = await fs.readFile(`/proc/${fixture.client.pid}/environ`, "utf8")
24
24
  const status = await fs.readFile(`/proc/${fixture.client.pid}/status`, "utf8")
@@ -70,6 +70,87 @@ test("guardian runs a strict activation lifecycle command for the exact register
70
70
  }
71
71
  })
72
72
 
73
+ test("client reactivates a retained process through a guardian without the reactivation command", async () => {
74
+ const fixture = await createGuardian()
75
+ const lifecyclePath = path.join(fixture.root, "lifecycle.log")
76
+ const processInstance = fixture.client.process("compatible-reactivation", {
77
+ ...definition("compatible-reactivation"),
78
+ lifecycle: {
79
+ activateCommand: `printf 'activate\n' >> ${JSON.stringify(lifecyclePath)}`,
80
+ drainTimeoutMs: 0,
81
+ quietCommand: `printf 'retire\n' >> ${JSON.stringify(lifecyclePath)}`
82
+ },
83
+ shouldRestart: () => true
84
+ })
85
+ const request = fixture.client.request.bind(fixture.client)
86
+
87
+ fixture.client.request = async command => {
88
+ if (command.command === "reactivate") throw new Error("Unknown guardian command: reactivate")
89
+ return await request(command)
90
+ }
91
+
92
+ try {
93
+ await processInstance.start()
94
+ await processInstance.activateStrict()
95
+ const pid = processInstance.status().pid
96
+
97
+ await processInstance.quiesceStrict()
98
+ await processInstance.reactivateStrict()
99
+
100
+ assert.equal(processInstance.status().pid, pid)
101
+ assert.equal(processInstance.status().state, "running")
102
+ assert.equal(processInstance.status().lifecycleRole, "active")
103
+ assert.equal(await fs.readFile(lifecyclePath, "utf8"), "activate\nretire\nactivate\n")
104
+
105
+ const restarted = once(processInstance, "started")
106
+
107
+ assert.ok(pid)
108
+ process.kill(-pid, "SIGKILL")
109
+ await restarted
110
+ assert.notEqual(processInstance.status().pid, pid)
111
+ assert.equal(processInstance.status().state, "running")
112
+ assert.equal(processInstance.status().lifecycleRole, "active")
113
+ assert.equal(await fs.readFile(lifecyclePath, "utf8"), "activate\nretire\nactivate\nactivate\n")
114
+ } finally {
115
+ await cleanupGuardian(fixture)
116
+ }
117
+ })
118
+
119
+ test("client reverses a worker quiet hook through a pre-reactivation guardian", async () => {
120
+ const fixture = await createGuardian()
121
+ const lifecyclePath = path.join(fixture.root, "worker-lifecycle.log")
122
+ const processInstance = fixture.client.process("compatible-worker-reactivation", {
123
+ ...definition("compatible-worker-reactivation"),
124
+ lifecycle: {
125
+ drainTimeoutMs: 0,
126
+ quietCommand: `printf 'quiet\n' >> ${JSON.stringify(lifecyclePath)}`,
127
+ reactivateCommand: `printf 'resume\n' >> ${JSON.stringify(lifecyclePath)}`
128
+ },
129
+ shouldRestart: () => true
130
+ })
131
+ const request = fixture.client.request.bind(fixture.client)
132
+
133
+ fixture.client.request = async command => {
134
+ if (command.command === "reactivate-with-command") throw new Error("Unknown guardian command: reactivate-with-command")
135
+ return await request(command)
136
+ }
137
+
138
+ try {
139
+ await processInstance.start()
140
+ const pid = processInstance.status().pid
141
+
142
+ await processInstance.quiesceStrict()
143
+ await processInstance.reactivateStrict()
144
+
145
+ assert.equal(processInstance.status().pid, pid)
146
+ assert.equal(processInstance.status().state, "running")
147
+ assert.equal(processInstance.status().lifecycleRole, "active")
148
+ assert.equal(await fs.readFile(lifecyclePath, "utf8"), "quiet\nresume\n")
149
+ } finally {
150
+ await cleanupGuardian(fixture)
151
+ }
152
+ })
153
+
73
154
  test("guardian atomically updates process provenance with private owner state", async () => {
74
155
  const fixture = await createGuardian()
75
156
  const processInstance = fixture.client.process("service", definition("service"))
@@ -505,6 +505,33 @@ test("activateStrict rejects an activation request when its process is not runni
505
505
  assert.equal(hookRan, false)
506
506
  })
507
507
 
508
+ test("reactivateStrict restores a retained quiesced process only after activation succeeds", async () => {
509
+ const managed = buildLongLived(() => false)
510
+ const hooks = /** @type {string[]} */ ([])
511
+
512
+ managed.lifecycle = {activateCommand: "jobs activate", drainTimeoutMs: 0, quietCommand: "jobs retire"}
513
+ managed.runHook = async (_command, _timeoutMs, label) => {
514
+ hooks.push(label)
515
+ if (label === "activate command" && hooks.length === 2) return new Error("restoration rejected")
516
+ return undefined
517
+ }
518
+
519
+ try {
520
+ await managed.start()
521
+ await managed.quiesceStrict()
522
+ await assert.rejects(() => managed.reactivateStrict(), /restoration rejected/)
523
+ assert.equal(managed.status().state, "quiesced")
524
+ assert.equal(managed.status().lifecycleRole, "retired")
525
+
526
+ await managed.reactivateStrict()
527
+ assert.equal(managed.status().state, "running")
528
+ assert.equal(managed.status().lifecycleRole, "active")
529
+ assert.deepEqual(hooks, ["quiet command", "activate command", "activate command"])
530
+ } finally {
531
+ await managed.stop()
532
+ }
533
+ })
534
+
508
535
  test("quiesce waits for active-role restoration before retiring a restarted process", async () => {
509
536
  const managed = buildLongLived(() => false)
510
537
  const hooks = /** @type {string[]} */ ([])
@@ -748,7 +748,7 @@ test("guardian recovery becomes ready before replaying a gated generation hook",
748
748
  }
749
749
  })
750
750
 
751
- test("owner recovery preserves a failed generation transition without firing hooks until exact resume", async () => {
751
+ test("owner recovery preserves a completed activation compensation without replaying hooks", async () => {
752
752
  const fixture = await createFixture({activationFailureRelease: "v2"})
753
753
  let owner = spawnDaemon(fixture.configPath)
754
754
 
@@ -762,7 +762,7 @@ test("owner recovery preserves a failed generation transition without firing hoo
762
762
  sendControlCommand({command: {command: "deploy", releaseId: "v2", releasePath: v2Path, revision: "v2"}, path: fixture.socketPath}),
763
763
  /activate command exited non-zero/
764
764
  )
765
- assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1"])
765
+ assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "retire:v2", "activate:v1"])
766
766
 
767
767
  owner.kill("SIGKILL")
768
768
  await once(owner, "exit")
@@ -771,13 +771,9 @@ test("owner recovery preserves a failed generation transition without firing hoo
771
771
 
772
772
  const recovered = /** @type {DaemonStatus} */ (await sendControlCommand({command: {command: "status"}, path: fixture.socketPath}))
773
773
 
774
- assert.equal(recovered.generationTransition?.phase, "activating_candidate")
775
- assert.match(String(recovered.generationTransition?.error), /activate command exited non-zero/)
776
- assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1"], "owner recovery alone must not retry a failed hook")
777
-
778
- await fs.writeFile(fixture.activationGatePath, "allow\n")
779
- await sendControlCommand({command: {command: "deploy", releaseId: "v2", releasePath: v2Path, revision: "v2"}, path: fixture.socketPath})
780
- assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "activate:v2"])
774
+ assert.equal(recovered.activeReleaseId, "v1")
775
+ assert.equal(recovered.generationTransition, undefined)
776
+ assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "retire:v2", "activate:v1"], "owner recovery must not replay completed compensation hooks")
781
777
 
782
778
  const shutdown = sendControlCommand({command: {command: "shutdown"}, path: fixture.socketPath})
783
779
  await Promise.all([v1Path, v2Path].map((releasePath) => fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n")))
@@ -789,17 +785,14 @@ test("owner recovery preserves a failed generation transition without firing hoo
789
785
  }
790
786
  })
791
787
 
792
- test("owner recovery replays one journaled ambiguous activation by exact generation identity", async () => {
793
- const fixture = await createFixture({activationFailureRelease: "v2"})
788
+ test("owner recovery replays one journaled ambiguous first-generation activation by exact identity", async () => {
789
+ const fixture = await createFixture({activationFailureRelease: "v1"})
794
790
  let owner = spawnDaemon(fixture.configPath)
795
791
 
796
792
  try {
797
793
  await waitForLog(owner, "control socket listening")
798
794
  const v1Path = await prepareRelease(fixture.root, "v1")
799
- const v2Path = await prepareRelease(fixture.root, "v2")
800
-
801
- await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath: v1Path, revision: "v1"}, path: fixture.socketPath})
802
- await assert.rejects(sendControlCommand({command: {command: "deploy", releaseId: "v2", releasePath: v2Path, revision: "v2"}, path: fixture.socketPath}))
795
+ await assert.rejects(sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath: v1Path, revision: "v1"}, path: fixture.socketPath}))
803
796
  owner.kill("SIGKILL")
804
797
  await once(owner, "exit")
805
798
 
@@ -818,12 +811,12 @@ test("owner recovery replays one journaled ambiguous activation by exact generat
818
811
 
819
812
  const recovered = /** @type {DaemonStatus} */ (await sendControlCommand({command: {command: "status"}, path: fixture.socketPath}))
820
813
 
821
- assert.equal(recovered.activeReleaseId, "v2")
814
+ assert.equal(recovered.activeReleaseId, "v1")
822
815
  assert.equal(recovered.generationTransition?.phase, "committed")
823
- assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "activate:v2"])
816
+ assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1"])
824
817
 
825
818
  const shutdown = sendControlCommand({command: {command: "shutdown"}, path: fixture.socketPath})
826
- await Promise.all([v1Path, v2Path].map((releasePath) => fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n")))
819
+ await fs.writeFile(path.join(v1Path, "worker.fifo"), "drained\n")
827
820
  await shutdown
828
821
  } finally {
829
822
  await killChild(owner)
@@ -1649,7 +1649,7 @@ test("pruned release connection completion closes the incumbent listener session
1649
1649
  assert.equal(daemon.incumbentListenerControl, session)
1650
1650
  })
1651
1651
 
1652
- test("same-authority owner replacement preserves a failed generation transition without retrying its hook", async () => {
1652
+ test("same-authority owner replacement preserves completed activation compensation without replaying hooks", async () => {
1653
1653
  const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-replacement-failed-generation-"))
1654
1654
  const oldSocketPath = path.join(root, "old.sock")
1655
1655
  const statePath = path.join(root, "state.json")
@@ -1677,18 +1677,15 @@ test("same-authority owner replacement preserves a failed generation transition
1677
1677
  await waitForLog(owner, "control socket listening")
1678
1678
  await sendControlCommand({command: {command: "deploy", releaseId: "v1", releasePath: v1Path, revision: "v1"}, path: oldSocketPath})
1679
1679
  await assert.rejects(sendControlCommand({command: {command: "deploy", releaseId: "v2", releasePath: v2Path, revision: "v2"}, path: oldSocketPath}), /activate command exited non-zero/)
1680
- assert.equal(await fs.readFile(lifecycleLogPath, "utf8"), "activate:v1\nretire:v1\n")
1680
+ assert.equal(await fs.readFile(lifecycleLogPath, "utf8"), "activate:v1\nretire:v1\nretire:v2\nactivate:v1\n")
1681
1681
 
1682
1682
  await writeConfig(configPath, failedConfig(oldSocketPath, false))
1683
1683
  candidate = spawn(process.execPath, [binPath, "daemon", "--config", configPath, "--replace-owner"], {stdio: ["ignore", "pipe", "pipe"]})
1684
1684
  await waitForLog(candidate, "owner replacement committed")
1685
1685
  const status = await sendControlCommand({command: {command: "status"}, path: oldSocketPath})
1686
- const generationTransition = status.generationTransition
1687
-
1688
- assert.ok(generationTransition && typeof generationTransition === "object" && !Array.isArray(generationTransition))
1689
- assert.equal(generationTransition.phase, "activating_candidate")
1690
- assert.match(String(generationTransition.error), /activate command exited non-zero/)
1691
- assert.equal(await fs.readFile(lifecycleLogPath, "utf8"), "activate:v1\nretire:v1\n", "replacement must preserve, not retry, the failed activation")
1686
+ assert.equal(status.activeReleaseId, "v1")
1687
+ assert.equal(status.generationTransition, undefined)
1688
+ assert.equal(await fs.readFile(lifecycleLogPath, "utf8"), "activate:v1\nretire:v1\nretire:v2\nactivate:v1\n", "replacement must not replay completed compensation hooks")
1692
1689
 
1693
1690
  const shutdown = sendControlCommand({command: {command: "shutdown"}, path: oldSocketPath})
1694
1691
  await Promise.all([v1Path, v2Path].map((releasePath) => fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n")))
@@ -1700,7 +1697,7 @@ test("same-authority owner replacement preserves a failed generation transition
1700
1697
  }
1701
1698
  })
1702
1699
 
1703
- test("config-changing owner replacement rejects an unresolved generation transition", async () => {
1700
+ test("config-changing owner replacement proceeds after activation compensation clears the transition", async () => {
1704
1701
  const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-owner-replacement-unresolved-config-"))
1705
1702
  const oldSocketPath = path.join(root, "old.sock")
1706
1703
  const newSocketPath = path.join(root, "new.sock")
@@ -1734,16 +1731,13 @@ test("config-changing owner replacement rejects an unresolved generation transit
1734
1731
  candidate = spawn(process.execPath, [binPath, "daemon", "--config", configPath, "--replace-owner"], {stdio: ["ignore", "pipe", "pipe"]})
1735
1732
  const result = await collectUntilExitOrLog(candidate, "owner replacement committed")
1736
1733
 
1737
- assert.equal(result.message, undefined, result.output)
1738
- assert.match(result.output, /config authority.*unresolved generation transition/i)
1739
- const status = await sendControlCommand({command: {command: "status"}, path: oldSocketPath})
1740
- const generationTransition = status.generationTransition
1734
+ assert.equal(result.message, "owner replacement committed", result.output)
1735
+ const status = await sendControlCommand({command: {command: "status"}, path: newSocketPath})
1741
1736
 
1742
- assert.ok(generationTransition && typeof generationTransition === "object" && !Array.isArray(generationTransition))
1743
- assert.equal(generationTransition.phase, "activating_candidate")
1744
- assert.match(String(generationTransition.error), /activate command exited non-zero/)
1745
- assert.equal(await fs.readFile(lifecycleLogPath, "utf8"), "activate:v1\nretire:v1\n")
1746
- const shutdown = sendControlCommand({command: {command: "shutdown"}, path: oldSocketPath})
1737
+ assert.equal(status.activeReleaseId, "v1")
1738
+ assert.equal(status.generationTransition, undefined)
1739
+ assert.equal(await fs.readFile(lifecycleLogPath, "utf8"), "activate:v1\nretire:v1\nretire:v2\nactivate:v1\n")
1740
+ const shutdown = sendControlCommand({command: {command: "shutdown"}, path: newSocketPath})
1747
1741
 
1748
1742
  await Promise.all([v1Path, v2Path].map((releasePath) => fs.writeFile(path.join(releasePath, "worker.fifo"), "drained\n")))
1749
1743
  await shutdown
@@ -1928,7 +1922,7 @@ async function removeDaemonRecoveryCapability(packagePath, {abortedPath, prepare
1928
1922
  const guardianPath = path.join(packagePath, "src", "process-guardian.js")
1929
1923
  const daemonPath = path.join(packagePath, "src", "daemon.js")
1930
1924
  const source = await fs.readFile(guardianPath, "utf8")
1931
- const capability = " if (request.command === \"capabilities\") return {daemonRecovery: 1}\n\n"
1925
+ const capability = " if (request.command === \"capabilities\") return {daemonRecovery: 1, generationReactivation: 1}\n\n"
1932
1926
  const incumbentAbortNotification = " if (ownerClient && !ownerClient.destroyed) ownerClient.write(`${JSON.stringify({event: \"replacement-aborted\", reason})}\\n`)\n"
1933
1927
  const legacyCapability = ` if (request.command === "capabilities") {
1934
1928
  while (!fsSync.existsSync(${JSON.stringify(preparedPath)})) await new Promise((resolve) => setTimeout(resolve, 5))
@@ -660,25 +660,248 @@ test("retirement failure retains the exact transition, blocks other deploys, and
660
660
  }
661
661
  })
662
662
 
663
- test("candidate activation failure never reactivates old and exact resume activates only the candidate", async () => {
663
+ test("candidate activation failure reports restoration failure and exact recovery clears the fence", async () => {
664
664
  const fixture = await createFixture({handoffService: true, handoffServiceActivate: true, handoffServiceActivateFailure: true, nonBlockingDrainWorker: true, webDependsOnService: true})
665
665
  const daemon = await startDaemon(fixture.config)
666
666
 
667
667
  try {
668
668
  await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
669
- await assert.rejects(() => daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"}), /activate command exited non-zero/)
669
+ const incumbentCoordinator = daemon.releases.get("v1")?.getProcess("beacon")
670
+ const reactivate = incumbentCoordinator?.reactivateStrict.bind(incumbentCoordinator)
671
+
672
+ assert.ok(incumbentCoordinator && reactivate)
673
+ incumbentCoordinator.reactivateStrict = async () => { throw new Error("incumbent restoration rejected") }
674
+ await assert.rejects(
675
+ () => daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"}),
676
+ error => {
677
+ assert.ok(error instanceof AggregateError)
678
+ assert.match(error.message, /activate command exited non-zero/)
679
+ assert.match(error.message, /incumbent v1 restoration failed: incumbent restoration rejected/i)
680
+ return true
681
+ }
682
+ )
670
683
 
671
684
  const failed = daemon.status()
672
685
 
673
686
  assert.equal(failed.activeReleaseId, "v1")
674
- assert.equal(failed.generationTransition?.phase, "activating_candidate")
675
- assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1"])
687
+ assert.equal(failed.generationTransition?.phase, "restoring_previous")
688
+ assert.match(String(failed.generationTransition?.activationError), /activate command exited non-zero/)
689
+ assert.match(String(failed.generationTransition?.compensationError), /incumbent restoration rejected/)
690
+ const failedEvents = daemon.eventLog.recent()
691
+ const activationEvent = failedEvents.find((event) => event.message === "release generation activation failed")
692
+ const restorationEvent = failedEvents.find((event) => event.message === "release generation compensation restoration failed")
693
+
694
+ assert.match(String(activationEvent?.data.error), /activate command exited non-zero/)
695
+ assert.match(String(restorationEvent?.data.activationError), /activate command exited non-zero/)
696
+ assert.match(String(restorationEvent?.data.error), /incumbent restoration rejected/)
697
+ assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "retire:v2"])
698
+ await assert.rejects(() => daemon.deploy({releaseId: "v3", releasePath: fixture.root, revision: "v3"}), /transition.*v2.*unresolved/i)
676
699
 
677
- await fs.writeFile(fixture.activationGatePath, "allow\n")
678
- await daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"})
700
+ incumbentCoordinator.reactivateStrict = reactivate
701
+ const recovery = await sendControlCommand({
702
+ command: {
703
+ command: "recover-generation-transition",
704
+ previousReleaseId: "v1",
705
+ releaseId: "v2",
706
+ releasePath: fixture.root,
707
+ revision: "v2"
708
+ },
709
+ path: fixture.config.control.path
710
+ })
679
711
 
680
- assert.equal(daemon.status().activeReleaseId, "v2")
681
- assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "activate:v2"])
712
+ assert.equal(recovery.recoveryStatus, "recovered")
713
+ assert.equal(daemon.status().activeReleaseId, "v1")
714
+ assert.equal(daemon.status().generationTransition, undefined)
715
+ const persisted = /** @type {{generationTransition?: import("../src/json.js").JsonValue} | undefined} */ (await readState(fixture.statePath))
716
+
717
+ assert.equal(persisted?.generationTransition, undefined)
718
+ assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "retire:v2", "activate:v1"])
719
+
720
+ const idempotent = await sendControlCommand({
721
+ command: {
722
+ command: "recover-generation-transition",
723
+ previousReleaseId: "v1",
724
+ releaseId: "v2",
725
+ releasePath: fixture.root,
726
+ revision: "v2"
727
+ },
728
+ path: fixture.config.control.path
729
+ })
730
+
731
+ assert.equal(idempotent.recoveryStatus, "already_recovered")
732
+ await daemon.deploy({releaseId: "v3", releasePath: fixture.root, revision: "v3"})
733
+ await assert.rejects(
734
+ () => sendControlCommand({
735
+ command: {
736
+ command: "recover-generation-transition",
737
+ previousReleaseId: "v1",
738
+ releaseId: "v3",
739
+ releasePath: fixture.root,
740
+ revision: "v3"
741
+ },
742
+ path: fixture.config.control.path
743
+ }),
744
+ /not a safe failed pre-commit transition/i
745
+ )
746
+ } finally {
747
+ await daemon.shutdown()
748
+ await fs.rm(fixture.root, {force: true, recursive: true})
749
+ }
750
+ })
751
+
752
+ test("candidate activation failure compensates to the incumbent and admits a different later release", async () => {
753
+ const fixture = await createFixture({handoffService: true, handoffServiceActivate: true, handoffServiceActivateFailure: true, nonBlockingDrainWorker: true, webDependsOnService: true})
754
+ const daemon = await startDaemon(fixture.config)
755
+
756
+ try {
757
+ await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
758
+ await assert.rejects(
759
+ () => daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"}),
760
+ /activate command exited non-zero.*compensation restored incumbent v1 as authoritative and retired failed candidate v2/i
761
+ )
762
+
763
+ const compensated = daemon.status()
764
+
765
+ assert.equal(compensated.activeReleaseId, "v1")
766
+ assert.equal(compensated.generationTransition, undefined)
767
+ assert.equal(await fetchText(daemon, "/release"), "v1")
768
+ assert.equal(statusRelease(daemon, "v1").processes.find((processStatus) => processStatus.id === "worker")?.state, "running")
769
+ assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), ["activate:v1", "retire:v1", "retire:v2", "activate:v1"])
770
+
771
+ await daemon.deploy({releaseId: "v3", releasePath: fixture.root, revision: "v3"})
772
+
773
+ assert.equal(daemon.status().activeReleaseId, "v3")
774
+ assert.equal(await fetchText(daemon, "/release"), "v3")
775
+ } finally {
776
+ await daemon.shutdown()
777
+ await fs.rm(fixture.root, {force: true, recursive: true})
778
+ }
779
+ })
780
+
781
+ test("ambiguous candidate activation retires the candidate before reactivating the incumbent", async () => {
782
+ const fixture = await createFixture({handoffService: true, handoffServiceActivate: true, handoffServiceActivateAmbiguousFailure: true, nonBlockingDrainWorker: true, webDependsOnService: true})
783
+ const daemon = await startDaemon(fixture.config)
784
+
785
+ try {
786
+ await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
787
+ await assert.rejects(
788
+ () => daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"}),
789
+ /activate command exited non-zero.*compensation restored incumbent v1 as authoritative and retired failed candidate v2/i
790
+ )
791
+
792
+ assert.deepEqual(await lifecycleEvents(fixture.lifecycleLogPath), [
793
+ "activate:v1",
794
+ "retire:v1",
795
+ "activate:v2",
796
+ "retire:v2",
797
+ "activate:v1"
798
+ ])
799
+ assert.equal(daemon.status().activeReleaseId, "v1")
800
+ assert.equal(daemon.status().generationTransition, undefined)
801
+ } finally {
802
+ await daemon.shutdown()
803
+ await fs.rm(fixture.root, {force: true, recursive: true})
804
+ }
805
+ })
806
+
807
+ test("candidate activation recovery reverses a worker-specific quiet hook before reporting active", async () => {
808
+ const fixture = await createFixture({handoffService: true, handoffServiceActivate: true, handoffServiceActivateFailure: true, nonBlockingDrainWorker: true, webDependsOnService: true, workerReactivationLifecycle: true})
809
+ const daemon = await startDaemon(fixture.config)
810
+
811
+ try {
812
+ await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
813
+ await assert.rejects(
814
+ () => daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"}),
815
+ /compensation restored incumbent v1 as authoritative/i
816
+ )
817
+
818
+ const events = await lifecycleEvents(fixture.lifecycleLogPath)
819
+ const candidateRetired = events.indexOf("worker-retire:v2")
820
+ const workerReactivated = events.indexOf("worker-reactivate:v1")
821
+
822
+ assert.ok(candidateRetired >= 0, JSON.stringify(events))
823
+ assert.ok(workerReactivated > candidateRetired, JSON.stringify(events))
824
+ assert.equal(statusRelease(daemon, "v1").processes.find((processStatus) => processStatus.id === "worker")?.state, "running")
825
+ assert.equal(daemon.status().activeReleaseId, "v1")
826
+ assert.equal(daemon.status().generationTransition, undefined)
827
+ } finally {
828
+ await daemon.shutdown()
829
+ await fs.rm(fixture.root, {force: true, recursive: true})
830
+ }
831
+ })
832
+
833
+ test("candidate activation recovery keeps the fence when a worker-specific resume hook fails", async () => {
834
+ const fixture = await createFixture({handoffService: true, handoffServiceActivate: true, handoffServiceActivateFailure: true, nonBlockingDrainWorker: true, webDependsOnService: true, workerReactivationFailure: true, workerReactivationLifecycle: true})
835
+ const daemon = await startDaemon(fixture.config)
836
+
837
+ try {
838
+ await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
839
+ await assert.rejects(
840
+ () => daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"}),
841
+ error => {
842
+ const failure = /** @type {Error} */ (error)
843
+
844
+ assert.match(failure.message, /activate command exited non-zero/)
845
+ assert.match(failure.message, /reactivate command exited non-zero/)
846
+ return true
847
+ }
848
+ )
849
+
850
+ const status = daemon.status()
851
+ const restorationEvent = daemon.eventLog.recent().find((event) => event.message === "release generation compensation restoration failed")
852
+
853
+ assert.equal(status.activeReleaseId, "v1")
854
+ assert.equal(status.generationTransition?.phase, "restoring_previous")
855
+ assert.match(String(status.generationTransition?.activationError), /activate command exited non-zero/)
856
+ assert.match(String(status.generationTransition?.compensationError), /reactivate command exited non-zero/)
857
+ assert.equal(statusRelease(daemon, "v1").processes.find((processStatus) => processStatus.id === "worker")?.state, "quiesced")
858
+ assert.match(String(restorationEvent?.data.activationError), /activate command exited non-zero/)
859
+ assert.match(String(restorationEvent?.data.error), /reactivate command exited non-zero/)
860
+ await assert.rejects(() => daemon.deploy({releaseId: "v3", releasePath: fixture.root, revision: "v3"}), /transition.*v2.*unresolved/i)
861
+ } finally {
862
+ await daemon.shutdown()
863
+ await fs.rm(fixture.root, {force: true, recursive: true})
864
+ }
865
+ })
866
+
867
+ test("compensation keeps the fence when the cleared checkpoint cannot be persisted", async () => {
868
+ const fixture = await createFixture({handoffService: true, handoffServiceActivate: true, handoffServiceActivateFailure: true, nonBlockingDrainWorker: true, webDependsOnService: true})
869
+ const daemon = await startDaemon(fixture.config)
870
+
871
+ try {
872
+ await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
873
+ const checkpoint = daemon.checkpointGenerationTransition.bind(daemon)
874
+
875
+ daemon.checkpointGenerationTransition = async () => {
876
+ if (!daemon.generationTransition) throw new Error("cleared checkpoint unavailable")
877
+ await checkpoint()
878
+ }
879
+ await assert.rejects(
880
+ () => daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"}),
881
+ /activate command exited non-zero.*compensation checkpoint clear failed: cleared checkpoint unavailable/i
882
+ )
883
+
884
+ assert.equal(daemon.status().activeReleaseId, "v1")
885
+ assert.equal(daemon.status().generationTransition?.phase, "restoring_previous")
886
+ const persisted = /** @type {{generationTransition?: {phase?: string}} | undefined} */ (await readState(fixture.statePath))
887
+
888
+ assert.equal(persisted?.generationTransition?.phase, "restoring_previous")
889
+ await assert.rejects(() => daemon.deploy({releaseId: "v3", releasePath: fixture.root, revision: "v3"}), /transition.*v2.*unresolved/i)
890
+
891
+ daemon.checkpointGenerationTransition = checkpoint
892
+ const recovery = await sendControlCommand({
893
+ command: {
894
+ command: "recover-generation-transition",
895
+ previousReleaseId: "v1",
896
+ releaseId: "v2",
897
+ releasePath: fixture.root,
898
+ revision: "v2"
899
+ },
900
+ path: fixture.config.control.path
901
+ })
902
+
903
+ assert.equal(recovery.recoveryStatus, "recovered")
904
+ assert.equal(daemon.status().generationTransition, undefined)
682
905
  } finally {
683
906
  await daemon.shutdown()
684
907
  await fs.rm(fixture.root, {force: true, recursive: true})
@@ -691,6 +914,10 @@ test("unresolved generation transition fences stop, restart, and rollback mutati
691
914
 
692
915
  try {
693
916
  await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
917
+ const incumbentCoordinator = daemon.releases.get("v1")?.getProcess("beacon")
918
+
919
+ assert.ok(incumbentCoordinator)
920
+ incumbentCoordinator.reactivateStrict = async () => { throw new Error("incumbent restoration rejected") }
694
921
  await assert.rejects(() => daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"}), /activate command exited non-zero/)
695
922
 
696
923
  await assert.rejects(() => daemon.stopRelease("v2"), /cannot stop.*generation transition.*unresolved/i)
@@ -698,9 +925,7 @@ test("unresolved generation transition fences stop, restart, and rollback mutati
698
925
  await assert.rejects(() => daemon.rollback({releaseId: "v2"}), /cannot rollback.*generation transition.*unresolved/i)
699
926
 
700
927
  assert.notEqual(statusRelease(daemon, "v2").processes.find((entry) => entry.id === "web")?.state, "stopped")
701
- await fs.writeFile(fixture.activationGatePath, "allow\n")
702
- await daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"})
703
- assert.equal(await fetchText(daemon, "/release"), "v2")
928
+ assert.equal(await fetchText(daemon, "/release"), "v1")
704
929
  } finally {
705
930
  await daemon.shutdown()
706
931
  await fs.rm(fixture.root, {force: true, recursive: true})
@@ -1654,7 +1879,7 @@ test("deploy can ensure the daemon before sending the release command", async ()
1654
1879
  })
1655
1880
 
1656
1881
  /**
1657
- * @param {{companionReplicas?: number, handoffService?: boolean, handoffServiceActivate?: boolean, handoffServiceActivateFailure?: boolean | string, handoffServiceQuiet?: boolean, handoffServiceQuietFailure?: boolean, includeCompanion?: boolean, includeService?: boolean, includeSingleton?: boolean, memoryLimitBytes?: number, nonBlockingDrainWorker?: boolean, persistState?: boolean, proxyHost?: string, singletonCwd?: string, webCommand?: string, webDependsOnService?: boolean, webHealthTimeoutMs?: number, workerStopDelayMs?: number}} [options] - Fixture options.
1882
+ * @param {{companionReplicas?: number, handoffService?: boolean, handoffServiceActivate?: boolean, handoffServiceActivateAmbiguousFailure?: boolean, handoffServiceActivateFailure?: boolean | string, handoffServiceQuiet?: boolean, handoffServiceQuietFailure?: boolean, includeCompanion?: boolean, includeService?: boolean, includeSingleton?: boolean, memoryLimitBytes?: number, nonBlockingDrainWorker?: boolean, persistState?: boolean, proxyHost?: string, singletonCwd?: string, webCommand?: string, webDependsOnService?: boolean, webHealthTimeoutMs?: number, workerReactivationFailure?: boolean, workerReactivationLifecycle?: boolean, workerStopDelayMs?: number}} [options] - Fixture options.
1658
1883
  * @returns {Promise<{activationGatePath: string, config: import("../src/config.js").RollbridgeConfig, lifecycleLogPath: string, retirementGatePath: string, root: string, serviceLogPath: string, serviceQuietPath: string, singletonLogPath: string, statePath: string}>} Fixture data.
1659
1884
  */
1660
1885
  async function createFixture(options = {}) {
@@ -1672,7 +1897,7 @@ async function createFixture(options = {}) {
1672
1897
  if (options.includeService || options.handoffService) {
1673
1898
  const activationFailureRelease = typeof options.handoffServiceActivateFailure === "string" ? options.handoffServiceActivateFailure : "v2"
1674
1899
  const lifecycle = options.handoffServiceActivate ? {
1675
- activateCommand: `${options.handoffServiceActivateFailure ? `[ "$ROLLBRIDGE_RELEASE_ID" != ${JSON.stringify(activationFailureRelease)} ] || [ -f ${JSON.stringify(activationGatePath)} ] || exit 24; ` : ""}printf 'activate:%s\\n' "$ROLLBRIDGE_RELEASE_ID" >> ${JSON.stringify(lifecycleLogPath)}`,
1900
+ activateCommand: `${options.handoffServiceActivateFailure && !options.handoffServiceActivateAmbiguousFailure ? `[ "$ROLLBRIDGE_RELEASE_ID" != ${JSON.stringify(activationFailureRelease)} ] || [ -f ${JSON.stringify(activationGatePath)} ] || exit 24; ` : ""}printf 'activate:%s\\n' "$ROLLBRIDGE_RELEASE_ID" >> ${JSON.stringify(lifecycleLogPath)}${options.handoffServiceActivateAmbiguousFailure ? `; [ "$ROLLBRIDGE_RELEASE_ID" != ${JSON.stringify(activationFailureRelease)} ] || [ -f ${JSON.stringify(activationGatePath)} ] || exit 24` : ""}`,
1676
1901
  quietCommand: `${options.handoffServiceQuietFailure ? `[ -f ${JSON.stringify(retirementGatePath)} ] || exit 23; ` : ""}printf 'retire:%s\\n' "$ROLLBRIDGE_RELEASE_ID" >> ${JSON.stringify(lifecycleLogPath)}`
1677
1902
  } : options.handoffServiceQuiet || options.handoffServiceQuietFailure ? {
1678
1903
  quietCommand: options.handoffServiceQuietFailure ? "exit 23" : `printf '%s\\n' "$ROLLBRIDGE_RELEASE_ID" >> ${JSON.stringify(serviceQuietPath)}`
@@ -1713,6 +1938,10 @@ async function createFixture(options = {}) {
1713
1938
  processes.push({
1714
1939
  command: `${JSON.stringify(process.execPath)} -e ${JSON.stringify(`process.on('SIGTERM', () => setTimeout(() => process.exit(0), ${options.workerStopDelayMs || 0})); setInterval(() => {}, 1000)`)}`,
1715
1940
  id: "worker",
1941
+ ...(options.workerReactivationLifecycle ? {lifecycle: {
1942
+ quietCommand: `printf 'worker-retire:%s\\n' "$ROLLBRIDGE_RELEASE_ID" >> ${JSON.stringify(lifecycleLogPath)}`,
1943
+ reactivateCommand: `${options.workerReactivationFailure ? "exit 25; " : ""}printf 'worker-reactivate:%s\\n' "$ROLLBRIDGE_RELEASE_ID" >> ${JSON.stringify(lifecycleLogPath)}`
1944
+ }} : {}),
1716
1945
  nonBlockingDrain: true,
1717
1946
  policy: "companion"
1718
1947
  })