rollbridge 0.1.40 → 0.1.42

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.
@@ -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
 
@@ -180,18 +180,20 @@ test("validateConfig defaults lifecycle, accepts hooks, and rejects bad values",
180
180
  })
181
181
 
182
182
  // Omitted → no commands, zero drain.
183
- assert.deepEqual(validateLifecycle(undefined).config.processes[0].lifecycle, {drainTimeoutMs: 0})
183
+ assert.deepEqual(validateLifecycle(undefined).config.processes[0].lifecycle, {activateTimeoutMs: 30000, drainTimeoutMs: 0})
184
184
 
185
- const custom = validateLifecycle({drainTimeoutMs: 30000, quietCommand: "kill -TSTP $ROLLBRIDGE_PID", stopCommand: "kill -TERM $ROLLBRIDGE_PID"})
185
+ const custom = validateLifecycle({activateTimeoutMs: 60000, drainTimeoutMs: 30000, quietCommand: "kill -TSTP $ROLLBRIDGE_PID", stopCommand: "kill -TERM $ROLLBRIDGE_PID"})
186
186
 
187
187
  assert.deepEqual(custom.issues, [])
188
188
  assert.equal(custom.config.processes[0].lifecycle.quietCommand, "kill -TSTP $ROLLBRIDGE_PID")
189
189
  assert.equal(custom.config.processes[0].lifecycle.stopCommand, "kill -TERM $ROLLBRIDGE_PID")
190
+ assert.equal(custom.config.processes[0].lifecycle.activateTimeoutMs, 60000)
190
191
  assert.equal(custom.config.processes[0].lifecycle.drainTimeoutMs, 30000)
191
192
 
192
- const invalid = validateLifecycle({drainTimeoutMs: -1, quietCommand: 5})
193
+ const invalid = validateLifecycle({activateTimeoutMs: 0, drainTimeoutMs: -1, quietCommand: 5})
193
194
  const messages = invalid.issues.map((issue) => issue.message)
194
195
 
196
+ assert.ok(messages.includes("processes[0].lifecycle.activateTimeoutMs must be a positive number"), JSON.stringify(messages))
195
197
  assert.ok(messages.includes("processes[0].lifecycle.drainTimeoutMs must be a non-negative number"), JSON.stringify(messages))
196
198
  assert.ok(messages.includes("processes[0].lifecycle.quietCommand must be a string"), JSON.stringify(messages))
197
199
 
@@ -250,6 +252,24 @@ test("validateConfig accepts one durable handoff activation lifecycle and reject
250
252
  {...base.processes[1], id: "jobs-secondary", port: {from: 18200, to: 18299}}
251
253
  ]})
252
254
  assert.ok(duplicate.issues.some((issue) => /at most one lifecycle\.activateCommand/.test(issue.message)))
255
+
256
+ const worker = {
257
+ command: "run worker",
258
+ id: "worker",
259
+ lifecycle: {quietCommand: "worker quiet", reactivateCommand: "worker resume"},
260
+ nonBlockingDrain: true,
261
+ policy: "companion"
262
+ }
263
+ const pairedWorker = validateConfig({...base, processes: [...base.processes, worker]})
264
+
265
+ assert.deepEqual(pairedWorker.issues, [])
266
+ assert.equal(pairedWorker.config.processes[2].lifecycle.reactivateCommand, "worker resume")
267
+
268
+ const unpairedWorker = validateConfig({...base, processes: [...base.processes, {...worker, lifecycle: {quietCommand: "worker quiet"}}]})
269
+ assert.ok(unpairedWorker.issues.some((issue) => /quietCommand requires lifecycle\.reactivateCommand/.test(issue.message)))
270
+
271
+ const unsupportedPlacement = validateConfig({...base, processes: [...base.processes, {...worker, nonBlockingDrain: false}]})
272
+ assert.ok(unsupportedPlacement.issues.some((issue) => /reactivateCommand.*nonBlockingDrain companion/.test(issue.message)))
253
273
  })
254
274
 
255
275
  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"))
@@ -450,14 +450,14 @@ test("activateStrict runs the configured activation command once per call and re
450
450
  const pid = managed.pid
451
451
 
452
452
  assert.ok(pid)
453
- managed.lifecycle = {activateCommand: "jobs activate", drainTimeoutMs: 0}
453
+ managed.lifecycle = {activateCommand: "jobs activate", activateTimeoutMs: 60000, drainTimeoutMs: 0}
454
454
  managed.runHook = async (command, timeoutMs, label, hookPid) => {
455
455
  commands.push({command, label, pid: hookPid, timeoutMs})
456
456
  return undefined
457
457
  }
458
458
 
459
459
  await managed.activateStrict()
460
- assert.deepEqual(commands, [{command: "jobs activate", label: "activate command", pid, timeoutMs: 30000}])
460
+ assert.deepEqual(commands, [{command: "jobs activate", label: "activate command", pid, timeoutMs: 60000}])
461
461
 
462
462
  managed.runHook = async () => new Error("activation rejected")
463
463
  await assert.rejects(() => managed.activateStrict(), /activation rejected/)
@@ -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))