rollbridge 0.1.30 → 0.1.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/daemon.js CHANGED
@@ -23,9 +23,12 @@ const STATE_PERSIST_INTERVAL_MS = 5000
23
23
  * @typedef {{attestation?: string, releaseId: string, releasePath: string, revision: string}} BootstrapIdentity
24
24
  * @typedef {{id: string, process: import("./managed-process.js").ManagedProcessStatus}} ProcessStatus
25
25
  * @typedef {{disruptive: true, mode: "legacy-first-upgrade", reason: string}} OwnerTransition
26
- * @typedef {{activeReleaseId: string | null, application: string, bootstrap: BootstrapIdentity | undefined, control: import("./config.js").ControlConfig, daemonPid: number, daemonRuntime: import("./daemon-runtime.js").DaemonRuntimeIdentity | undefined, ownerRecovery: {configDigest: string} | 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
26
+ * @typedef {"candidate_ready" | "retiring_previous" | "previous_retired" | "activating_candidate" | "committed_pending" | "committed"} GenerationTransitionPhase
27
+ * @typedef {{candidateReleaseId: string, candidateReleasePath: string, candidateRevision: string, configDigest: string, error?: string, phase: GenerationTransitionPhase, previousReleaseId: string | null, startedAt: string, updatedAt: string}} GenerationTransition
28
+ * @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} | 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
27
29
  * @typedef {{configDigest: string, format: number, guardian: {pid?: number, socketPath: string, token: string}, reconnectGraceMs: number}} OwnerRecoveryMetadata
28
- * @typedef {DaemonStatus & {recovery: OwnerRecoveryMetadata}} OwnerRecoverySnapshot
30
+ * @typedef {DaemonStatus & {recovery: OwnerRecoveryMetadata, singletonReleaseIds?: Record<string, string>}} OwnerRecoverySnapshot
31
+ * @typedef {{authority: JsonValue, config: import("./config.js").RollbridgeConfig, releaseConfigs?: Record<string, import("./config.js").RollbridgeConfig>, singletonReleaseIds?: Record<string, string>, snapshot: OwnerRecoverySnapshot}} PrivateOwnerState
29
32
  * @typedef {{boundaryCrossed: boolean, incumbentControl: Awaited<ReturnType<typeof openControlSession>>, incumbentStartTime: string, prepared: {ownerState: JsonValue, replacementId: string}, recoverySnapshot: OwnerRecoverySnapshot}} LegacyOwnerBridge
30
33
  */
31
34
 
@@ -62,7 +65,9 @@ export default class RollbridgeDaemon {
62
65
  this.servicePorts = /** @type {Record<string, number>} */ ({})
63
66
  this.portReservations = /** @type {Set<number>} */ (new Set())
64
67
  this.singletons = /** @type {Map<string, import("./managed-process.js").default>} */ (new Map())
68
+ this.singletonReleaseIds = /** @type {Map<string, string>} */ (new Map())
65
69
  this.activeRelease = /** @type {ReleaseGroup | undefined} */ (undefined)
70
+ this.generationTransition = /** @type {GenerationTransition | undefined} */ (undefined)
66
71
  this.proxy = httpProxy.createProxyServer({ws: true, xfwd: true})
67
72
  this.proxyServer = /** @type {http.Server | undefined} */ (undefined)
68
73
  this.controlServer = /** @type {net.Server | undefined} */ (undefined)
@@ -137,10 +142,42 @@ export default class RollbridgeDaemon {
137
142
  this.watchOwnerReplacementEvents()
138
143
 
139
144
  if (snapshot) {
140
- await this.restoreOwnerState(snapshot, {resumeDrains: false})
145
+ if (!Array.isArray(snapshot.releases) || (snapshot.activeReleaseId !== null && typeof snapshot.activeReleaseId !== "string")) {
146
+ throw new Error("Owner recovery state is partial or corrupt; active release metadata is required.")
147
+ }
148
+ let recoveryConfig = this.config
149
+ let releaseConfigs = /** @type {Record<string, import("./config.js").RollbridgeConfig>} */ ({})
150
+ let singletonReleaseIds = snapshot.singletonReleaseIds || /** @type {Record<string, string>} */ ({})
151
+ let recoverySnapshot = snapshot
152
+
153
+ if ((recovery?.format ?? 0) >= 2) {
154
+ const ownerState = /** @type {PrivateOwnerState} */ (await this.guardian.ownerState())
155
+
156
+ if (!ownerState?.config || !ownerState.releaseConfigs || !ownerState.snapshot) {
157
+ throw new Error("Durable guardian state is missing exact release definitions; refusing partial owner recovery.")
158
+ }
159
+ recoveryConfig = ownerState.config
160
+ releaseConfigs = ownerState.releaseConfigs
161
+ singletonReleaseIds = ownerState.singletonReleaseIds || singletonReleaseIds
162
+ const guardianTransitionAt = Date.parse(ownerState.snapshot.generationTransition?.updatedAt || "")
163
+ const persistedTransitionAt = Date.parse(snapshot.generationTransition?.updatedAt || "")
164
+
165
+ if (Number.isFinite(guardianTransitionAt) && (!Number.isFinite(persistedTransitionAt) || guardianTransitionAt > persistedTransitionAt)) {
166
+ recoverySnapshot = ownerState.snapshot
167
+ }
168
+ }
169
+ await this.restoreOwnerState(recoverySnapshot, {config: recoveryConfig, releaseConfigs, resumeDrains: false, singletonReleaseIds})
170
+ this.persistenceEnabled = true
141
171
  await this.guardian.reconcileInventory()
172
+ if (this.generationTransition && this.generationTransition.phase !== "committed" && !this.generationTransition.error) {
173
+ try {
174
+ await this.resumeGenerationTransition()
175
+ } catch (error) {
176
+ this.logger("release generation transition recovery failed", {error: error instanceof Error ? error.message : String(error), releaseId: this.generationTransition.candidateReleaseId})
177
+ }
178
+ }
142
179
  for (const release of this.releases.values()) {
143
- if (release.state === "draining") void this.drainAndPrune(release, release.config)
180
+ if (release.state === "draining" && this.shouldResumeDrain(release)) void this.drainAndPrune(release, release.config)
144
181
  }
145
182
  }
146
183
  else {
@@ -166,7 +203,7 @@ export default class RollbridgeDaemon {
166
203
  if (this.listenerHandoff || this.ownerRetired) return
167
204
  for (const release of this.releases.values()) {
168
205
  release.resumeDrainAfterOwnerHandoff()
169
- if (release.state === "draining") void this.drainAndPrune(release, release.config)
206
+ if (release.state === "draining" && this.shouldResumeDrain(release)) void this.drainAndPrune(release, release.config)
170
207
  }
171
208
  })
172
209
  }
@@ -182,17 +219,24 @@ export default class RollbridgeDaemon {
182
219
  * @param {import("./config.js").RollbridgeConfig} [options.config] - Owner config for daemon-wide processes.
183
220
  * @param {Record<string, import("./config.js").RollbridgeConfig>} [options.releaseConfigs] - Exact generation configs.
184
221
  * @param {boolean} [options.resumeDrains] - Whether to resume draining generations immediately.
222
+ * @param {Record<string, string>} [options.singletonReleaseIds] - Exact release owner for each singleton registration.
223
+ * @param {boolean} [options.synchronizeLifecycleRoles] - Whether the caller owns guardian role mutation authority.
185
224
  */
186
- async restoreOwnerState(snapshot, {config = this.config, releaseConfigs = /** @type {Record<string, import("./config.js").RollbridgeConfig>} */ ({}), resumeDrains = true} = {}) {
225
+ async restoreOwnerState(snapshot, {config = this.config, releaseConfigs = /** @type {Record<string, import("./config.js").RollbridgeConfig>} */ ({}), resumeDrains = true, singletonReleaseIds = snapshot.singletonReleaseIds || /** @type {Record<string, string>} */ ({}), synchronizeLifecycleRoles = true} = {}) {
187
226
  if (!Array.isArray(snapshot.releases) || (snapshot.activeReleaseId !== null && typeof snapshot.activeReleaseId !== "string")) {
188
227
  throw new Error("Owner recovery state is partial or corrupt; active release metadata is required.")
189
228
  }
229
+ this.generationTransition = snapshot.generationTransition ? {...snapshot.generationTransition} : undefined
190
230
  if (snapshot.activeReleaseId === null && snapshot.releases.length === 0) return
191
231
  this.bootstrap = snapshot.bootstrap ? {...snapshot.bootstrap} : undefined
192
232
  this.ownerTransition = snapshot.ownerTransition ? {...snapshot.ownerTransition} : undefined
233
+ const singletonOwnerReleaseIds = new Set(Object.values(singletonReleaseIds))
193
234
 
194
235
  for (const releaseStatus of snapshot.releases) {
195
- if (releaseStatus.state !== "active" && releaseStatus.state !== "draining") continue
236
+ const transitionCandidate = this.generationTransition?.candidateReleaseId === releaseStatus.releaseId && this.generationTransition.phase !== "committed"
237
+ const singletonOwner = singletonOwnerReleaseIds.has(releaseStatus.releaseId)
238
+
239
+ if (releaseStatus.state !== "active" && releaseStatus.state !== "draining" && !transitionCandidate && !singletonOwner) continue
196
240
  const release = new ReleaseGroup({
197
241
  config: releaseConfigs[releaseStatus.releaseId] || config,
198
242
  logger: this.logger,
@@ -205,7 +249,7 @@ export default class RollbridgeDaemon {
205
249
  shouldStart: () => !this.stopping
206
250
  })
207
251
 
208
- await release.restore(releaseStatus)
252
+ await release.restore(releaseStatus, {synchronizeLifecycleRole: synchronizeLifecycleRoles})
209
253
  this.releases.set(release.releaseId, release)
210
254
  if (release.releaseId === snapshot.activeReleaseId) this.activeRelease = release
211
255
  }
@@ -231,16 +275,21 @@ export default class RollbridgeDaemon {
231
275
  }
232
276
  }
233
277
  for (const singletonStatus of snapshot.singletons) {
234
- const processConfig = config.processes.find((candidate) => candidate.id === singletonStatus.id && candidate.policy === "singleton")
278
+ const singletonReleaseId = singletonReleaseIds[singletonStatus.id] || definitionRelease.releaseId
279
+ const singletonRelease = this.releases.get(singletonReleaseId)
235
280
 
236
- if (!processConfig) throw new Error(`Owner recovery state contains unknown singleton ${singletonStatus.id}.`)
237
- const singleton = definitionRelease.buildProcess(processConfig, {guardianKey: `singleton:${definitionRelease.releaseId}:${singletonStatus.id}`})
281
+ if (!singletonRelease) throw new Error(`Owner recovery state contains singleton ${singletonStatus.id} for unknown release ${singletonReleaseId}.`)
282
+ const processConfig = singletonRelease.config.processes.find((candidate) => candidate.id === singletonStatus.id && candidate.policy === "singleton")
283
+
284
+ if (!processConfig) throw new Error(`Owner recovery state contains unknown singleton ${singletonStatus.id} for release ${singletonReleaseId}.`)
285
+ const singleton = singletonRelease.buildProcess(processConfig, {guardianKey: `singleton:${singletonReleaseId}:${singletonStatus.id}`})
238
286
 
239
287
  await this.recoverGuardianProcess(singleton)
240
288
  this.singletons.set(singletonStatus.id, singleton)
289
+ this.singletonReleaseIds.set(singletonStatus.id, singletonReleaseId)
241
290
  }
242
291
  for (const release of this.releases.values()) {
243
- if (resumeDrains && release.state === "draining") void this.drainAndPrune(release, release.config)
292
+ if (resumeDrains && release.state === "draining" && this.shouldResumeDrain(release)) void this.drainAndPrune(release, release.config)
244
293
  }
245
294
  this.logger("owner state recovered", {activeReleaseId: this.activeRelease?.releaseId ?? null, releases: this.releases.size})
246
295
  }
@@ -252,6 +301,7 @@ export default class RollbridgeDaemon {
252
301
  authority: this.ownerAuthority(),
253
302
  config: this.config,
254
303
  releaseConfigs: Object.fromEntries([...this.releases].map(([releaseId, release]) => [releaseId, release.config])),
304
+ singletonReleaseIds: Object.fromEntries(this.singletonReleaseIds),
255
305
  snapshot: this.status()
256
306
  })
257
307
  }
@@ -289,10 +339,10 @@ export default class RollbridgeDaemon {
289
339
  prepared = legacyBridge.prepared
290
340
  }
291
341
  const preparedStatus = await this.guardian.replacementStatus()
292
- const transfer = /** @type {{config: import("./config.js").RollbridgeConfig, releaseConfigs?: Record<string, import("./config.js").RollbridgeConfig>, snapshot: OwnerRecoverySnapshot}} */ (prepared.ownerState)
342
+ const transfer = /** @type {PrivateOwnerState} */ (prepared.ownerState)
293
343
 
294
344
  if (!transfer?.config || !transfer.snapshot) throw new Error("Committed owner published incomplete replacement state")
295
- await this.restoreOwnerState(transfer.snapshot, {config: transfer.config, releaseConfigs: transfer.releaseConfigs, resumeDrains: false})
345
+ await this.restoreOwnerState(transfer.snapshot, {config: transfer.config, releaseConfigs: transfer.releaseConfigs, resumeDrains: false, singletonReleaseIds: transfer.singletonReleaseIds, synchronizeLifecycleRoles: false})
296
346
  for (const release of this.releases.values()) release.preserveConfigOnRetirement = true
297
347
  this.logger("owner replacement candidate prepared", {activeReleaseId: this.activeRelease?.releaseId ?? null, replacementId: prepared.replacementId})
298
348
 
@@ -300,6 +350,7 @@ export default class RollbridgeDaemon {
300
350
  let stagingControlPath
301
351
  let finalControlPublished = false
302
352
  let listenersYielded = false
353
+ let retiredIncumbentControl = false
303
354
  let retainIncumbentControl = false
304
355
  let incumbentControl = legacyBridge?.incumbentControl
305
356
 
@@ -325,49 +376,69 @@ export default class RollbridgeDaemon {
325
376
  if (legacyBridge) {
326
377
  await this.crossLegacyDisruptiveBoundary(legacyBridge)
327
378
  } else if (preparedStatus.ownerClaimed) {
328
- incumbentControl = await openControlSession(transfer.snapshot.control.path)
329
- const listenerSession = incumbentControl
330
-
331
- incumbentControl.onEvent((event) => this.handleIncumbentListenerEvent(event, listenerSession))
332
- await incumbentControl.request({
333
- command: "yield-owner-listeners",
334
- control: transfer.snapshot.control.path === this.config.control.path,
335
- proxy: true,
336
- replacementId: prepared.replacementId
337
- })
338
- listenersYielded = true
379
+ try {
380
+ incumbentControl = await openControlSession(transfer.snapshot.control.path)
381
+ } catch (error) {
382
+ if (!error || typeof error !== "object" || !("code" in error) || error.code !== "ENOENT") throw error
383
+ retiredIncumbentControl = true
384
+ }
385
+ if (incumbentControl) {
386
+ const listenerSession = incumbentControl
387
+
388
+ incumbentControl.onEvent((event) => this.handleIncumbentListenerEvent(event, listenerSession))
389
+ await incumbentControl.request({
390
+ command: "yield-owner-listeners",
391
+ control: transfer.snapshot.control.path === this.config.control.path,
392
+ proxy: true,
393
+ replacementId: prepared.replacementId
394
+ })
395
+ listenersYielded = true
396
+ }
339
397
  }
340
398
  if (sharedFixedProxy) await this.startProxy()
341
- await fs.rename(stagingControlPath, this.config.control.path)
342
- finalControlPublished = true
343
- this.boundControlPath = this.config.control.path
399
+ if (!retiredIncumbentControl) {
400
+ await fs.rename(stagingControlPath, this.config.control.path)
401
+ finalControlPublished = true
402
+ this.boundControlPath = this.config.control.path
403
+ }
344
404
  const committed = this.guardian.waitForEvent("replacement-committed")
345
405
  const staged = await this.guardian.stageOwnerReplacement(prepared.replacementId, {
346
406
  authority: this.ownerAuthority(),
347
407
  config: this.config,
348
408
  releaseConfigs: Object.fromEntries([...this.releases].map(([releaseId, release]) => [releaseId, release.config])),
409
+ singletonReleaseIds: Object.fromEntries(this.singletonReleaseIds),
349
410
  snapshot: this.status()
350
411
  })
351
412
 
352
413
  if (!staged.committed) {
353
- try {
354
- if (!incumbentControl) throw new Error("Owner replacement incumbent control session is unavailable")
355
- await incumbentControl.request({command: "commit-owner-replacement", replacementId: prepared.replacementId})
356
- } catch (error) {
357
- const status = await this.guardian.replacementStatus()
358
-
359
- if (status.committedReplacementId !== prepared.replacementId || !status.ownerClaimed) throw error
360
- this.logger("owner replacement commit response lost; guardian commit confirmed", {replacementId: prepared.replacementId})
414
+ if (retiredIncumbentControl) {
415
+ await this.guardian.commitRetiredOwnerReplacement(prepared.replacementId)
416
+ } else {
417
+ try {
418
+ if (!incumbentControl) throw new Error("Owner replacement incumbent control session is unavailable")
419
+ await incumbentControl.request({command: "commit-owner-replacement", replacementId: prepared.replacementId})
420
+ } catch (error) {
421
+ const status = await this.guardian.replacementStatus()
422
+
423
+ if (status.committedReplacementId !== prepared.replacementId || !status.ownerClaimed) throw error
424
+ this.logger("owner replacement commit response lost; guardian commit confirmed", {replacementId: prepared.replacementId})
425
+ }
361
426
  }
362
427
  }
363
428
  await committed
364
429
  committedAuthority = true
430
+ if (retiredIncumbentControl) {
431
+ await fs.rename(stagingControlPath, this.config.control.path)
432
+ finalControlPublished = true
433
+ this.boundControlPath = this.config.control.path
434
+ }
435
+ await Promise.all([...this.releases.values()].map((release) => release.synchronizeLifecycleRoles()))
365
436
  if (!legacyBridge && incumbentControl && [...this.releases.values()].some((release) => release.hasTransferredConnections())) {
366
437
  this.incumbentListenerControl = incumbentControl
367
438
  retainIncumbentControl = true
368
439
  }
369
440
  for (const release of this.releases.values()) {
370
- if (release.state === "draining") void this.drainAndPrune(release, release.config)
441
+ if (release.state === "draining" && this.shouldResumeDrain(release)) void this.drainAndPrune(release, release.config)
371
442
  }
372
443
  this.startStatePersistence()
373
444
  await this.persistState({throwOnError: true})
@@ -490,6 +561,7 @@ export default class RollbridgeDaemon {
490
561
  authority: persistedAuthority,
491
562
  config: this.config,
492
563
  releaseConfigs: Object.fromEntries(persisted.releases.map((release) => [release.releaseId, this.config])),
564
+ singletonReleaseIds: Object.fromEntries(persisted.singletons.map((singleton) => [singleton.id, persisted.activeReleaseId]).filter((entry) => entry[1] !== null)),
493
565
  snapshot: persisted
494
566
  }
495
567
  const upgradedIdentity = /** @type {{pid?: number, socketPath: string, token: string}} */ ({
@@ -1003,7 +1075,7 @@ export default class RollbridgeDaemon {
1003
1075
  if (!handoff || this.ownerRetired) return
1004
1076
  for (const release of this.releases.values()) {
1005
1077
  release.resumeDrainAfterOwnerHandoff()
1006
- if (release.state === "draining") void this.drainAndPrune(release, release.config)
1078
+ if (release.state === "draining" && this.shouldResumeDrain(release)) void this.drainAndPrune(release, release.config)
1007
1079
  }
1008
1080
  if (handoff.proxy) {
1009
1081
  await this.startProxy()
@@ -1029,6 +1101,16 @@ export default class RollbridgeDaemon {
1029
1101
  this.assertReloadCompatible(nextConfig)
1030
1102
 
1031
1103
  const newReleaseId = releaseId || revision || new Date().toISOString().replace(/[^0-9]/g, "")
1104
+ const transition = this.generationTransition
1105
+
1106
+ if (transition && transition.phase !== "committed") {
1107
+ this.assertExactGenerationTransition(transition, {config: nextConfig, releaseId: newReleaseId, releasePath, revision: revision || newReleaseId})
1108
+ return await this.resumeGenerationTransition()
1109
+ }
1110
+ if (transition?.phase === "committed" && transition.candidateReleaseId === newReleaseId && this.activeRelease?.releaseId === newReleaseId) {
1111
+ this.assertExactGenerationTransition(transition, {config: nextConfig, releaseId: newReleaseId, releasePath, revision: revision || newReleaseId})
1112
+ return {activeReleaseId: newReleaseId, previousReleaseId: transition.previousReleaseId}
1113
+ }
1032
1114
  const release = new ReleaseGroup({
1033
1115
  config: nextConfig,
1034
1116
  logger: this.logger,
@@ -1061,6 +1143,25 @@ export default class RollbridgeDaemon {
1061
1143
 
1062
1144
  const previousRelease = this.activeRelease
1063
1145
 
1146
+ if (nextConfig.processes.some((processConfig) => processConfig.lifecycle.activateCommand !== undefined)) {
1147
+ this.config = nextConfig
1148
+ this.releases.set(release.releaseId, release)
1149
+ const now = new Date().toISOString()
1150
+
1151
+ this.generationTransition = /** @type {GenerationTransition} */ ({
1152
+ candidateReleaseId: release.releaseId,
1153
+ candidateReleasePath: release.releasePath,
1154
+ candidateRevision: release.revision,
1155
+ configDigest: ownerConfigDigest(nextConfig),
1156
+ phase: "candidate_ready",
1157
+ previousReleaseId: previousRelease?.releaseId ?? null,
1158
+ startedAt: now,
1159
+ updatedAt: now
1160
+ })
1161
+ await this.checkpointGenerationTransition()
1162
+ return await this.resumeGenerationTransition()
1163
+ }
1164
+
1064
1165
  this.config = nextConfig
1065
1166
  this.releases.set(release.releaseId, release)
1066
1167
  release.activate()
@@ -1094,6 +1195,138 @@ export default class RollbridgeDaemon {
1094
1195
  }
1095
1196
  }
1096
1197
 
1198
+ /**
1199
+ * Continues one exact durable generation transition without an internal retry loop.
1200
+ * @returns {Promise<Record<string, JsonValue>>} Deploy result after commit.
1201
+ */
1202
+ async resumeGenerationTransition() {
1203
+ const transition = this.generationTransition
1204
+
1205
+ if (!transition) throw new Error("No release generation transition to resume")
1206
+ const release = this.releases.get(transition.candidateReleaseId)
1207
+ const previousRelease = transition.previousReleaseId ? this.releases.get(transition.previousReleaseId) : undefined
1208
+
1209
+ if (!release) throw new Error(`Generation transition candidate ${transition.candidateReleaseId} is not retained`)
1210
+ if (transition.previousReleaseId && !previousRelease) throw new Error(`Generation transition previous release ${transition.previousReleaseId} is not retained`)
1211
+
1212
+ if (transition.phase === "candidate_ready") {
1213
+ if (previousRelease) await this.updateGenerationTransition("retiring_previous")
1214
+ else await this.updateGenerationTransition("previous_retired")
1215
+ }
1216
+
1217
+ if (transition.phase === "retiring_previous") {
1218
+ try {
1219
+ const retirementConfig = previousRelease?.config
1220
+
1221
+ // Every entry into this journaled phase represents one explicit attempt. Reset the
1222
+ // process-local acknowledgement cache so exact resume can replay an ambiguous or
1223
+ // failed idempotent retirement once; there is no internal retry loop.
1224
+ await previousRelease?.beginRetirement(retirementConfig, {retry: true})
1225
+ } catch (error) {
1226
+ const failure = previousRelease?.retirementError ?? (error instanceof Error ? error.message : String(error))
1227
+
1228
+ await this.failGenerationTransition(failure)
1229
+ this.logger("release retirement quiescence failed", {error: failure, releaseId: previousRelease?.releaseId ?? null})
1230
+ throw error
1231
+ }
1232
+ await this.updateGenerationTransition("previous_retired")
1233
+ }
1234
+
1235
+ if (transition.phase === "previous_retired") await this.updateGenerationTransition("activating_candidate")
1236
+
1237
+ if (transition.phase === "activating_candidate") {
1238
+ try {
1239
+ await release.activateGeneration()
1240
+ } catch (error) {
1241
+ const failure = error instanceof Error ? error.message : String(error)
1242
+
1243
+ await this.failGenerationTransition(failure)
1244
+ this.logger("release generation activation failed", {error: failure, releaseId: release.releaseId})
1245
+ throw error
1246
+ }
1247
+
1248
+ // Activation acknowledgement and the logical proxy commit deliberately share one
1249
+ // synchronous continuation: no awaited failure boundary may leave jobs active while
1250
+ // Rollbridge still points new traffic at the retired generation.
1251
+ release.activate()
1252
+ this.activeRelease = release
1253
+ transition.phase = "committed_pending"
1254
+ transition.error = undefined
1255
+ transition.updatedAt = new Date().toISOString()
1256
+ this.logger("traffic switched", {previousReleaseId: previousRelease?.releaseId ?? null, releaseId: release.releaseId})
1257
+ }
1258
+
1259
+ if (transition.phase === "committed_pending") {
1260
+ await this.checkpointGenerationTransition()
1261
+ this.refreshServiceDefinitions(release)
1262
+ if (previousRelease) {
1263
+ const retirementConfig = previousRelease.config
1264
+
1265
+ void this.drainAndPrune(previousRelease, retirementConfig)
1266
+ }
1267
+ try {
1268
+ await this.replaceSingletons(release)
1269
+ } catch (error) {
1270
+ await this.failGenerationTransition(error instanceof Error ? error.message : String(error))
1271
+ throw error
1272
+ }
1273
+ transition.phase = "committed"
1274
+ transition.error = undefined
1275
+ transition.updatedAt = new Date().toISOString()
1276
+ await this.checkpointGenerationTransition()
1277
+ }
1278
+
1279
+ return {activeReleaseId: release.releaseId, previousReleaseId: previousRelease?.releaseId ?? null}
1280
+ }
1281
+
1282
+ /**
1283
+ * @param {GenerationTransition} transition - Pending or committed exact transition.
1284
+ * @param {{config: import("./config.js").RollbridgeConfig, releaseId: string, releasePath: string, revision: string}} candidate - Requested identity.
1285
+ */
1286
+ assertExactGenerationTransition(transition, candidate) {
1287
+ if (transition.candidateReleaseId !== candidate.releaseId || transition.candidateReleasePath !== candidate.releasePath || transition.candidateRevision !== candidate.revision || transition.configDigest !== ownerConfigDigest(candidate.config)) {
1288
+ throw new Error(`Release generation transition for ${transition.candidateReleaseId} is unresolved at ${transition.phase}; only the exact same release, path, revision, and config authority may resume it`)
1289
+ }
1290
+ }
1291
+
1292
+ /** @param {GenerationTransitionPhase} phase - Durable phase to enter. */
1293
+ async updateGenerationTransition(phase) {
1294
+ if (!this.generationTransition) throw new Error("No release generation transition to update")
1295
+ this.generationTransition.phase = phase
1296
+ this.generationTransition.error = undefined
1297
+ this.generationTransition.updatedAt = new Date().toISOString()
1298
+ await this.checkpointGenerationTransition()
1299
+ }
1300
+
1301
+ /** @param {string} error - Exact failed phase diagnostic. */
1302
+ async failGenerationTransition(error) {
1303
+ if (!this.generationTransition) throw new Error("No release generation transition to fail")
1304
+ this.generationTransition.error = error
1305
+ this.generationTransition.updatedAt = new Date().toISOString()
1306
+ await this.checkpointGenerationTransition()
1307
+ }
1308
+
1309
+ /** Persists and publishes the exact transition boundary before another external effect. */
1310
+ async checkpointGenerationTransition() {
1311
+ // Publish private exact definitions before the public secret-safe state can expose
1312
+ // a newer transition authority. A replacement can then reconstruct every guardian
1313
+ // registration without reading commands or environment values from statePath.
1314
+ await this.publishOwnerState()
1315
+ const write = this.persistState({throwOnError: true})
1316
+
1317
+ if (write) await write
1318
+ }
1319
+
1320
+ /**
1321
+ * @param {ReleaseGroup} release - Retained release.
1322
+ * @returns {boolean} Whether its stop drain may run now.
1323
+ */
1324
+ shouldResumeDrain(release) {
1325
+ const transition = this.generationTransition
1326
+
1327
+ return !transition || transition.phase === "committed_pending" || transition.phase === "committed" || transition.previousReleaseId !== release.releaseId
1328
+ }
1329
+
1097
1330
  /**
1098
1331
  * Relinquishes only daemon authority/listeners after the guardian has committed a
1099
1332
  * prepared replacement. Guardian-owned processes and drains are never stopped.
@@ -1162,6 +1395,7 @@ export default class RollbridgeDaemon {
1162
1395
  * @returns {Promise<Record<string, JsonValue>>} The rollback result.
1163
1396
  */
1164
1397
  async rollback({releaseId} = {}) {
1398
+ this.assertNoUnresolvedGenerationTransition("rollback")
1165
1399
  const target = releaseId ? this.releases.get(releaseId) : this.previousRelease()
1166
1400
 
1167
1401
  if (!target) {
@@ -1285,6 +1519,9 @@ export default class RollbridgeDaemon {
1285
1519
  if (processConfig.policy !== "singleton") continue
1286
1520
 
1287
1521
  const previous = this.singletons.get(processConfig.id)
1522
+ const previousReleaseId = this.singletonReleaseIds.get(processConfig.id)
1523
+
1524
+ if (previous && previousReleaseId === release.releaseId && previous.status().state === "running") continue
1288
1525
 
1289
1526
  if (previous) {
1290
1527
  await previous.stop()
@@ -1294,8 +1531,10 @@ export default class RollbridgeDaemon {
1294
1531
  const singleton = release.buildProcess(processConfig, {guardianKey: `singleton:${release.releaseId}:${processConfig.id}`})
1295
1532
 
1296
1533
  this.singletons.set(processConfig.id, singleton)
1534
+ this.singletonReleaseIds.set(processConfig.id, release.releaseId)
1297
1535
  await singleton.start("deploy")
1298
1536
  }
1537
+ this.pruneStoppedReleases()
1299
1538
  }
1300
1539
 
1301
1540
  /**
@@ -1309,6 +1548,7 @@ export default class RollbridgeDaemon {
1309
1548
  * @returns {Promise<Record<string, JsonValue>>} The ids that were restarted.
1310
1549
  */
1311
1550
  async restartProcesses({policy, processId} = {}) {
1551
+ this.assertNoUnresolvedGenerationTransition("restart")
1312
1552
  if (policy === "proxied" || (processId !== undefined && this.isProxiedId(processId))) {
1313
1553
  throw new Error('The proxied process cannot be restarted in place; use "rollbridge deploy" for a zero-downtime replacement.')
1314
1554
  }
@@ -1322,7 +1562,7 @@ export default class RollbridgeDaemon {
1322
1562
  for (const target of targets) {
1323
1563
  this.logger("process restart requested", {processId: target.id})
1324
1564
  await target.process.stop()
1325
- await target.process.start("manual")
1565
+ await target.process.start("manual", target.process.lifecycle.activateCommand ? "active" : undefined)
1326
1566
  }
1327
1567
 
1328
1568
  return {restarted: targets.map((target) => target.id)}
@@ -1383,6 +1623,7 @@ export default class RollbridgeDaemon {
1383
1623
  * @returns {Promise<void>} Resolves when stopped.
1384
1624
  */
1385
1625
  async stopRelease(releaseId) {
1626
+ this.assertNoUnresolvedGenerationTransition("stop")
1386
1627
  const release = releaseId ? this.releases.get(releaseId) : this.activeRelease
1387
1628
 
1388
1629
  if (!release) throw new Error(`Release not found: ${releaseId || "active"}`)
@@ -1394,6 +1635,15 @@ export default class RollbridgeDaemon {
1394
1635
  this.persistState()
1395
1636
  }
1396
1637
 
1638
+ /** @param {string} operation - Mutating control operation. */
1639
+ assertNoUnresolvedGenerationTransition(operation) {
1640
+ const transition = this.generationTransition
1641
+
1642
+ if (transition && transition.phase !== "committed") {
1643
+ throw new Error(`Cannot ${operation} while release generation transition ${transition.candidateReleaseId} is unresolved at ${transition.phase}; resume the exact deploy first`)
1644
+ }
1645
+ }
1646
+
1397
1647
  /**
1398
1648
  * Drains and stops a retired release in the background, then prunes stopped releases.
1399
1649
  * @param {ReleaseGroup} release - Release to drain and stop.
@@ -1416,9 +1666,13 @@ export default class RollbridgeDaemon {
1416
1666
 
1417
1667
  /** @returns {void} Removes stopped releases beyond the retention policy. */
1418
1668
  pruneStoppedReleases() {
1419
- const statuses = [...this.releases.values()].map((release) => release.status())
1669
+ const singletonOwnerReleaseIds = new Set(this.singletonReleaseIds.values())
1670
+ const statuses = [...this.releases.values()]
1671
+ .filter((release) => !singletonOwnerReleaseIds.has(release.releaseId))
1672
+ .map((release) => release.status())
1420
1673
 
1421
1674
  for (const releaseId of releasesToPrune(statuses, this.config.releaseRetention, Date.now())) {
1675
+ this.releases.get(releaseId)?.releasePortReservations()
1422
1676
  this.releases.delete(releaseId)
1423
1677
  }
1424
1678
  }
@@ -1450,9 +1704,10 @@ export default class RollbridgeDaemon {
1450
1704
  ...status,
1451
1705
  events,
1452
1706
  persistedAt: new Date().toISOString(),
1707
+ ...(this.hasActivationLifecycle() ? {singletonReleaseIds: Object.fromEntries(this.singletonReleaseIds)} : {}),
1453
1708
  ...(this.guardianIdentity ? {recovery: {
1454
1709
  configDigest: this.ownerRecoveryConfigDigest(),
1455
- format: 1,
1710
+ format: this.hasActivationLifecycle() ? 2 : 1,
1456
1711
  guardian: this.guardianIdentity,
1457
1712
  reconnectGraceMs: this.config.ownerRecovery?.reconnectGraceMs
1458
1713
  }} : {})
@@ -1472,6 +1727,11 @@ export default class RollbridgeDaemon {
1472
1727
  return this.pendingWrite
1473
1728
  }
1474
1729
 
1730
+ /** @returns {boolean} Whether the current authority uses explicit generation activation. */
1731
+ hasActivationLifecycle() {
1732
+ return Boolean(this.generationTransition) || this.config.processes.some((processConfig) => processConfig.lifecycle?.activateCommand !== undefined) || [...this.releases.values()].some((release) => release.config.processes.some((processConfig) => processConfig.lifecycle?.activateCommand !== undefined))
1733
+ }
1734
+
1475
1735
  /**
1476
1736
  * On startup, reads any state left by a previous daemon and reports managed processes whose
1477
1737
  * pids are still alive — likely orphans from a daemon that did not shut down cleanly. This is
@@ -1659,6 +1919,8 @@ export default class RollbridgeDaemon {
1659
1919
  // a cleared orphan must not reappear if the OS later recycles its pid for an unrelated process.
1660
1920
  this.orphans = this.orphans.filter((orphan) => isProcessAlive(orphan.pid))
1661
1921
 
1922
+ const singletonOwnerReleaseIds = new Set(this.singletonReleaseIds.values())
1923
+
1662
1924
  return {
1663
1925
  activeReleaseId: this.activeRelease ? this.activeRelease.releaseId : null,
1664
1926
  application: this.config.application,
@@ -1666,6 +1928,7 @@ export default class RollbridgeDaemon {
1666
1928
  control: {...this.config.control},
1667
1929
  daemonPid: process.pid,
1668
1930
  daemonRuntime: this.runtime ? {...this.runtime} : undefined,
1931
+ generationTransition: this.generationTransition ? {...this.generationTransition} : undefined,
1669
1932
  ownerRecovery: this.guardian ? {configDigest: this.ownerRecoveryConfigDigest()} : undefined,
1670
1933
  ownerTransition: this.ownerTransition ? {...this.ownerTransition} : undefined,
1671
1934
  orphans: [...this.orphans],
@@ -1675,7 +1938,7 @@ export default class RollbridgeDaemon {
1675
1938
  upstreamHost: this.config.proxy.upstreamHost
1676
1939
  },
1677
1940
  releaseReferences: [...this.releases.values()]
1678
- .filter((release) => release.state === "active" || release.state === "draining")
1941
+ .filter((release) => release.state === "active" || release.state === "draining" || singletonOwnerReleaseIds.has(release.releaseId) || (this.generationTransition?.phase !== "committed" && this.generationTransition?.candidateReleaseId === release.releaseId))
1679
1942
  .map((release) => ({releaseId: release.releaseId, releasePath: release.releasePath})),
1680
1943
  releases: [...this.releases.values()].map((release) => release.status()),
1681
1944
  services: [...this.services.entries()].map(([id, processInstance]) => ({