rollbridge 0.1.39 → 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.
@@ -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
  }
@@ -32,7 +33,7 @@ export default class GuardianClient {
32
33
  * @param {{legacyGuardian?: {pid?: number, socketPath: string, token: string}, ownerState?: import("./json.js").JsonValue}} [options] - Optional authenticated legacy backend migration.
33
34
  */
34
35
  async launch(options = {}) {
35
- const child = spawn(process.execPath, [guardianPath, this.socketPath], {detached: true, stdio: ["ignore", "ignore", "ignore", "ipc"]})
36
+ const child = spawn(process.execPath, [guardianPath, this.socketPath], {detached: true, stdio: ["ignore", "inherit", "inherit", "ipc"]})
36
37
 
37
38
  this.pid = child.pid
38
39
  this.guardianExitPromise = new Promise((resolve) => child.once("exit", () => resolve(undefined)))
@@ -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
  /**
@@ -176,7 +178,20 @@ export default class GuardianClient {
176
178
  * @param {import("./json.js").JsonValue} authority - Exact owner authority.
177
179
  */
178
180
  async claimOwner(graceMs, authority) {
179
- await this.request({authority, command: "claim-owner", graceMs})
181
+ await this.request({authority, command: "claim-owner", graceMs, ownerPid: process.pid})
182
+ }
183
+
184
+ /** Confirms that the claimed daemon has completed startup and published its listeners. */
185
+ async ownerReady() {
186
+ await this.request({command: "owner-ready", ownerPid: process.pid})
187
+ }
188
+
189
+ /** @returns {Promise<{daemonRecovery: number, generationReactivation?: number}>} Guardian protocol capabilities. */
190
+ async capabilities() {
191
+ const capabilities = /** @type {{daemonRecovery: number, generationReactivation?: number}} */ (await this.request({command: "capabilities"}))
192
+
193
+ this.generationReactivation = capabilities.generationReactivation === 1
194
+ return capabilities
180
195
  }
181
196
 
182
197
  /** Starts graceful process retirement and relinquishes committed owner authority. */
@@ -221,7 +236,7 @@ export default class GuardianClient {
221
236
  * @returns {Promise<{ownerState: import("./json.js").JsonValue, replacementId: string}>} Prepared transaction.
222
237
  */
223
238
  async prepareOwnerReplacement(authority, nextAuthority) {
224
- return /** @type {{ownerState: import("./json.js").JsonValue, replacementId: string}} */ (await this.request({authority, command: "prepare-owner-replacement", nextAuthority}))
239
+ return /** @type {{ownerState: import("./json.js").JsonValue, replacementId: string}} */ (await this.request({authority, command: "prepare-owner-replacement", nextAuthority, ownerPid: process.pid}))
225
240
  }
226
241
 
227
242
  /**
@@ -251,13 +266,24 @@ export default class GuardianClient {
251
266
  await this.request({command: "commit-retired-owner-replacement", key, replacementId})
252
267
  }
253
268
 
269
+ /**
270
+ * Yields a control-less incumbent's listeners while it still owns authority.
271
+ * @param {string} replacementId - Same-authority staged transaction.
272
+ * @param {string} key - Exact recovered guardian process proving candidate reconstruction.
273
+ */
274
+ async prepareRetiredOwnerListenerHandoff(replacementId, key) {
275
+ await this.request({command: "prepare-retired-owner-listener-handoff", key, replacementId})
276
+ }
277
+
254
278
  /**
255
279
  * Begins acquiring the authenticated legacy backend owner channel at the disruptive boundary.
256
280
  * @param {string} replacementId - Exact prepared candidate transaction.
257
281
  * @param {number} graceMs - Event-driven incumbent disconnect grace.
282
+ * @param {string} statePath - Durable public recovery state path.
283
+ * @param {import("./json.js").JsonValue} recoverySnapshot - Discoverable coordinator state.
258
284
  */
259
- async beginLegacyOwnerClaim(replacementId, graceMs) {
260
- await this.request({command: "begin-legacy-owner-claim", graceMs, replacementId})
285
+ async beginLegacyOwnerClaim(replacementId, graceMs, statePath, recoverySnapshot) {
286
+ await this.request({command: "begin-legacy-owner-claim", graceMs, recoverySnapshot, replacementId, statePath})
261
287
  }
262
288
 
263
289
  /**
@@ -273,6 +299,22 @@ export default class GuardianClient {
273
299
  await this.request({command: "finalize-owner-replacement", replacementId})
274
300
  }
275
301
 
302
+ /** @param {string} replacementId - Committed transaction with complete listener state. */
303
+ async completeOwnerListenerRetirement(replacementId) {
304
+ await this.request({command: "complete-owner-listener-retirement", replacementId})
305
+ }
306
+
307
+ /**
308
+ * @param {string} replacementId - Committed control-less replacement transaction.
309
+ * @param {string} sourceId - Stable listener source identity.
310
+ * @param {string} releaseId - Retained release identity.
311
+ * @param {{http: number, websocket: number}} connections - Exact incumbent listener counts.
312
+ * @param {boolean} [localSource] - Whether the sender physically owns this source.
313
+ */
314
+ async publishOwnerConnectionState(replacementId, sourceId, releaseId, connections, localSource = false) {
315
+ await this.request({command: "publish-owner-connection-state", connections, localSource, releaseId, replacementId, sourceId})
316
+ }
317
+
276
318
  /** @param {string} replacementId - Prepared transaction to validate for listener yield. */
277
319
  async validateOwnerReplacement(replacementId) {
278
320
  await this.request({command: "validate-owner-replacement", replacementId})
@@ -294,9 +336,9 @@ export default class GuardianClient {
294
336
  await this.request({command: "end-owner-mutation", mutationId})
295
337
  }
296
338
 
297
- /** @returns {Promise<{committedReplacementId: string | null, ownerClaimed: boolean}>} Transaction status. */
339
+ /** @returns {Promise<{committedReplacementId: string | null, ownerClaimed: boolean, retirementFailed?: boolean, retirementPending?: boolean, retirementReady?: boolean}>} Transaction status. */
298
340
  async replacementStatus() {
299
- return /** @type {{committedReplacementId: string | null, ownerClaimed: boolean}} */ (await this.request({command: "replacement-status"}))
341
+ return /** @type {{committedReplacementId: string | null, ownerClaimed: boolean, retirementFailed?: boolean, retirementPending?: boolean, retirementReady?: boolean}} */ (await this.request({command: "replacement-status"}))
300
342
  }
301
343
 
302
344
  /** @returns {Promise<import("./json.js").JsonValue>} Current private transfer state. */
@@ -408,6 +450,7 @@ class GuardianProcess extends ManagedProcess {
408
450
  this.cachedStatus = super.status()
409
451
  this.registration = /** @type {Promise<void> | undefined} */ (undefined)
410
452
  this.pendingUpdate = Promise.resolve()
453
+ this.compatibilityReactivated = false
411
454
  }
412
455
 
413
456
  async ensureRegistered() {
@@ -442,19 +485,32 @@ class GuardianProcess extends ManagedProcess {
442
485
  await this.pendingUpdate
443
486
  if (lifecycleRole) this.lifecycleRole = lifecycleRole
444
487
  this.cachedStatus = asProcessStatus(await this.client.request({command: "start", key: this.key, lifecycleRole, reason}))
488
+ if (this.cachedStatus.state === "running") this.compatibilityReactivated = false
445
489
  }
446
490
 
447
- /** @param {import("./managed-process.js").ManagedProcessDefinition} definition - Updated definition. */
448
- updateDefinition(definition) {
449
- const previousProvenance = this.provenance
491
+ /**
492
+ * @param {import("./managed-process.js").ManagedProcessDefinition} definition - Updated definition.
493
+ * @param {import("./json.js").JsonValue} [ownerState] - Private owner state to commit with the definition.
494
+ * @returns {Promise<void>} Resolves once the guardian commits the replacement definition.
495
+ */
496
+ updateDefinition(definition, ownerState) {
450
497
  const registration = this.ensureRegistered()
498
+ const previousUpdate = this.pendingUpdate
499
+ const nextDefinition = serializableDefinition({...definition, id: this.id})
500
+ const nextProvenance = crypto.createHash("sha256").update(JSON.stringify(nextDefinition)).digest("hex")
451
501
 
452
- super.updateDefinition(definition)
453
- this.definition = serializableDefinition(this)
454
- this.provenance = crypto.createHash("sha256").update(JSON.stringify(this.definition)).digest("hex")
455
- this.pendingUpdate = registration.then(async () => {
456
- this.cachedStatus = asProcessStatus(await this.client.request({command: "update", definition: this.definition, key: this.key, previousProvenance, provenance: this.provenance}))
502
+ const previousUpdateSettled = previousUpdate.catch(() => {
503
+ // The prior caller received the update failure and local provenance stayed unchanged,
504
+ // so a later explicit update may retry from the last guardian-committed definition.
505
+ })
506
+
507
+ this.pendingUpdate = Promise.all([registration, previousUpdateSettled]).then(async () => {
508
+ this.cachedStatus = asProcessStatus(await this.client.request({command: "update", definition: nextDefinition, key: this.key, ownerState, previousProvenance: this.provenance, provenance: nextProvenance}))
509
+ super.updateDefinition(definition)
510
+ this.definition = nextDefinition
511
+ this.provenance = nextProvenance
457
512
  })
513
+ return this.pendingUpdate
458
514
  }
459
515
 
460
516
  async quiesce() {
@@ -480,12 +536,52 @@ class GuardianProcess extends ManagedProcess {
480
536
  this.lifecycleRole = "active"
481
537
  }
482
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
+
483
576
  /** @param {import("./managed-process.js").LifecycleRole} role - Exact generation role. */
484
577
  async setLifecycleRole(role) {
485
578
  await this.ensureRegistered()
486
579
  await this.pendingUpdate
487
580
  this.cachedStatus = asProcessStatus(await this.client.request({command: "set-lifecycle-role", key: this.key, lifecycleRole: role}))
488
581
  this.lifecycleRole = role
582
+ if (role === "active" && this.client.generationReactivation === false && this.cachedStatus.state === "quiesced") {
583
+ await this.adoptRetainedActive({activate: false})
584
+ }
489
585
  }
490
586
 
491
587
  async stop(options = {}) {
@@ -501,8 +597,17 @@ class GuardianProcess extends ManagedProcess {
501
597
  /** @param {Record<string, import("./json.js").JsonValue>} event - Guardian event. */
502
598
  onGuardianEvent(event) {
503
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
+ }
504
606
  if (event.event === "process-log") {
505
- this.emit("log", asProcessLog(event.entry))
607
+ const entry = asProcessLog(event.entry)
608
+
609
+ this.cachedStatus = {...this.cachedStatus, logs: [...this.cachedStatus.logs, entry].slice(-this.outputLines)}
610
+ this.emit("log", entry)
506
611
  return
507
612
  }
508
613
  if (event.message === "process started") this.emit("started")
@@ -5,6 +5,7 @@ import {spawn} from "node:child_process"
5
5
  import {processGroupHasLiveMembers, processGroupMembers} from "./process-memory.js"
6
6
 
7
7
  const ACTIVATION_HOOK_TIMEOUT_MS = 30000
8
+ const MAX_BUFFERED_OUTPUT_CHARACTERS = 64 * 1024
8
9
 
9
10
  /**
10
11
  * @typedef {import("./json.js").JsonValue} JsonValue
@@ -54,6 +55,7 @@ export default class ManagedProcess extends EventEmitter {
54
55
  this.state = /** @type {ManagedProcessState} */ ("stopped")
55
56
  this.lastStartReason = /** @type {ManagedProcessStartReason | undefined} */ (undefined)
56
57
  this.logs = /** @type {ManagedProcessLog[]} */ ([])
58
+ this.outputBuffers = {stderr: "", stdout: ""}
57
59
  this.restarts = 0
58
60
  this.recentRestarts = /** @type {number[]} */ ([])
59
61
  this.rssBytes = /** @type {number | undefined} */ (undefined)
@@ -67,9 +69,11 @@ export default class ManagedProcess extends EventEmitter {
67
69
  this.intentionalStop = false
68
70
  this.lifecycleRole = /** @type {LifecycleRole} */ ("candidate")
69
71
  this.intentionalStopSignal = /** @type {ProcessExitSignal | undefined} */ (undefined)
72
+ this.lifecycleRestoreBarrier = /** @type {Promise<void> | undefined} */ (undefined)
70
73
  this.quiescePromise = /** @type {Promise<void> | undefined} */ (undefined)
71
74
  this.quiesceError = /** @type {Error | undefined} */ (undefined)
72
75
  this.stopPromise = /** @type {Promise<void> | undefined} */ (undefined)
76
+ this.operationRevision = 0
73
77
  this.restartTimer = undefined
74
78
  this.child = undefined
75
79
  this.exitPromise = undefined
@@ -84,6 +88,10 @@ export default class ManagedProcess extends EventEmitter {
84
88
  * @returns {Promise<void>} Resolves after spawn and lifecycle-role restoration.
85
89
  */
86
90
  async start(reason = "deploy", lifecycleRole) {
91
+ const operationRevision = ++this.operationRevision
92
+
93
+ if (this.stopPromise) await this.stopPromise
94
+ if (operationRevision !== this.operationRevision) return
87
95
  if (lifecycleRole) this.lifecycleRole = lifecycleRole
88
96
  if (this.child) return
89
97
 
@@ -97,6 +105,7 @@ export default class ManagedProcess extends EventEmitter {
97
105
  this.state = "starting"
98
106
 
99
107
  await new Promise((resolve, reject) => {
108
+ const outputBuffers = {stderr: "", stdout: ""}
100
109
  const child = spawn(this.command, {
101
110
  cwd: this.cwd,
102
111
  detached: true,
@@ -118,18 +127,28 @@ export default class ManagedProcess extends EventEmitter {
118
127
  void (async () => {
119
128
  this.startedAtMs = Date.now()
120
129
  this.lastStartReason = reason
130
+ const lifecycleRestore = this.restoreLifecycleRole()
131
+ const lifecycleRestoreBarrier = lifecycleRestore.then(() => undefined, () => undefined)
132
+
133
+ this.lifecycleRestoreBarrier = lifecycleRestoreBarrier
121
134
  try {
122
- await this.restoreLifecycleRole()
135
+ await lifecycleRestore
123
136
  } catch (error) {
124
137
  this.state = "failed"
125
138
  this.logger("process lifecycle role restoration failed", {error: error instanceof Error ? error.message : String(error), id: this.id, role: this.lifecycleRole})
126
139
  reject(error)
127
140
  return
141
+ } finally {
142
+ if (this.lifecycleRestoreBarrier === lifecycleRestoreBarrier) this.lifecycleRestoreBarrier = undefined
128
143
  }
129
144
  if (this.child !== child) {
130
145
  reject(new Error(`Process ${this.id} exited before lifecycle role ${this.lifecycleRole} was restored`))
131
146
  return
132
147
  }
148
+ if (this.intentionalStop || this.state !== "starting") {
149
+ reject(new Error(`Process ${this.id} was quiesced before lifecycle role ${this.lifecycleRole} was restored`))
150
+ return
151
+ }
133
152
  this.state = "running"
134
153
  this.logger("process started", {command: this.command, id: this.id, pid: child.pid || null, reason})
135
154
  this.startMemoryMonitor()
@@ -148,17 +167,20 @@ export default class ManagedProcess extends EventEmitter {
148
167
  })
149
168
  child.stdout.setEncoding("utf8")
150
169
  child.stderr.setEncoding("utf8")
151
- child.stdout.on("data", (chunk) => this.appendLog("stdout", chunk))
152
- child.stderr.on("data", (chunk) => this.appendLog("stderr", chunk))
170
+ child.stdout.on("data", (chunk) => this.appendLog("stdout", chunk, outputBuffers))
171
+ child.stdout.on("end", () => this.flushLogBuffer("stdout", outputBuffers))
172
+ child.stderr.on("data", (chunk) => this.appendLog("stderr", chunk, outputBuffers))
173
+ child.stderr.on("end", () => this.flushLogBuffer("stderr", outputBuffers))
153
174
  })
154
175
  }
155
176
 
156
177
  /**
157
178
  * Updates the command template used for future restarts without touching the currently running child.
158
179
  * @param {ManagedProcessDefinition} definition - Replacement process definition.
159
- * @returns {void}
180
+ * @param {import("./json.js").JsonValue} [_ownerState] - Private owner state committed atomically by remote implementations.
181
+ * @returns {void | Promise<void>} Definition replacement completion for remote implementations.
160
182
  */
161
- updateDefinition(definition) {
183
+ updateDefinition(definition, _ownerState) {
162
184
  this.command = definition.command
163
185
  this.cwd = definition.cwd
164
186
  this.env = definition.env
@@ -176,10 +198,19 @@ export default class ManagedProcess extends EventEmitter {
176
198
  /**
177
199
  * @param {"stdout" | "stderr"} stream - Stream name.
178
200
  * @param {string} chunk - Output chunk.
201
+ * @param {{stderr: string, stdout: string}} [buffers] - Per-process stream fragments.
179
202
  * @returns {void}
180
203
  */
181
- appendLog(stream, chunk) {
182
- for (const line of String(chunk).split(/\r?\n/)) {
204
+ appendLog(stream, chunk, buffers = this.outputBuffers) {
205
+ const lines = `${buffers[stream]}${String(chunk)}`.split(/\r?\n/)
206
+ let fragment = lines.pop() ?? ""
207
+
208
+ while (fragment.length > MAX_BUFFERED_OUTPUT_CHARACTERS) {
209
+ lines.push(fragment.slice(0, MAX_BUFFERED_OUTPUT_CHARACTERS))
210
+ fragment = fragment.slice(MAX_BUFFERED_OUTPUT_CHARACTERS)
211
+ }
212
+ buffers[stream] = fragment
213
+ for (const line of lines) {
183
214
  if (!line) continue
184
215
 
185
216
  const entry = {at: new Date().toISOString(), line, stream}
@@ -193,6 +224,15 @@ export default class ManagedProcess extends EventEmitter {
193
224
  }
194
225
  }
195
226
 
227
+ /**
228
+ * Retains a final output line that did not end with a newline.
229
+ * @param {"stdout" | "stderr"} stream - Stream name.
230
+ * @param {{stderr: string, stdout: string}} buffers - Per-process stream fragments.
231
+ */
232
+ flushLogBuffer(stream, buffers) {
233
+ if (buffers[stream]) this.appendLog(stream, "\n", buffers)
234
+ }
235
+
196
236
  /**
197
237
  * @param {number | null} code - Exit code.
198
238
  * @param {ProcessExitSignal} signal - Exit signal.
@@ -375,6 +415,7 @@ export default class ManagedProcess extends EventEmitter {
375
415
  * @returns {Promise<void>} Resolves when stopped.
376
416
  */
377
417
  async stop(options = {}) {
418
+ this.operationRevision += 1
378
419
  if (!this.stopPromise) this.stopPromise = this.performStop(options)
379
420
  return await this.stopPromise
380
421
  }
@@ -442,16 +483,17 @@ export default class ManagedProcess extends EventEmitter {
442
483
  clearTimeout(this.restartTimer)
443
484
  this.restartTimer = undefined
444
485
  }
486
+ if (this.lifecycleRestoreBarrier) await this.lifecycleRestoreBarrier
445
487
  if (!this.child?.pid) {
446
488
  this.state = "stopped"
447
- if (this.lifecycle.activateCommand) this.lifecycleRole = "retired"
489
+ if (this.hasRestorableLifecycle()) this.lifecycleRole = "retired"
448
490
  return
449
491
  }
450
492
  this.state = "stopping"
451
493
  if (this.lifecycle.quietCommand) this.quiesceError = await this.runHook(this.lifecycle.quietCommand, this.hookTimeoutMs(), "quiet command")
452
494
  if (!this.quiesceError) {
453
495
  this.state = "quiesced"
454
- if (this.lifecycle.activateCommand) this.lifecycleRole = "retired"
496
+ if (this.hasRestorableLifecycle()) this.lifecycleRole = "retired"
455
497
  }
456
498
  })()
457
499
  return await this.quiescePromise
@@ -475,12 +517,69 @@ export default class ManagedProcess extends EventEmitter {
475
517
  const command = this.lifecycle.activateCommand
476
518
 
477
519
  if (!command) return
478
- const error = await this.runHook(command, ACTIVATION_HOOK_TIMEOUT_MS, "activate command", this.pid)
520
+ const child = this.child
521
+ const pid = this.pid
479
522
 
480
- if (error) throw error
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`)
481
526
  this.lifecycleRole = "active"
482
527
  }
483
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
557
+ const error = await this.runHook(command, ACTIVATION_HOOK_TIMEOUT_MS, "activate command", pid)
558
+
559
+ if (error) throw error
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)
581
+ }
582
+
484
583
  /**
485
584
  * Records the durable desired role without firing a lifecycle command.
486
585
  * @param {LifecycleRole} role - Exact role owned by this process generation.
@@ -491,12 +590,15 @@ export default class ManagedProcess extends EventEmitter {
491
590
 
492
591
  /** Restores an active or retired role after this exact process starts. */
493
592
  async restoreLifecycleRole() {
494
- if (!this.lifecycle.activateCommand || this.lifecycleRole === "candidate") return
495
- 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
496
597
 
497
598
  if (!command) throw new Error(`Process ${this.id} cannot restore lifecycle role ${this.lifecycleRole} without its paired command`)
498
599
  const timeoutMs = this.lifecycleRole === "active" ? ACTIVATION_HOOK_TIMEOUT_MS : this.hookTimeoutMs()
499
- 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)
500
602
 
501
603
  if (error) throw error
502
604
  }
@@ -713,7 +815,7 @@ export default class ManagedProcess extends EventEmitter {
713
815
  id: this.id,
714
816
  lastMemoryRestartAt: this.lastMemoryRestartAtMs === undefined ? undefined : new Date(this.lastMemoryRestartAtMs).toISOString(),
715
817
  lastStartReason: this.lastStartReason,
716
- ...(this.lifecycle.activateCommand ? {lifecycleRole: this.lifecycleRole} : {}),
818
+ ...(this.hasRestorableLifecycle() ? {lifecycleRole: this.lifecycleRole} : {}),
717
819
  logs: this.logs.slice(-this.outputLines),
718
820
  memoryRestarts: this.memoryRestarts,
719
821
  pid: this.pid,