rollbridge 0.1.12 → 0.1.13
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/README.md +16 -0
- package/docs/cli.md +23 -0
- package/package.json +1 -1
- package/src/cli.js +58 -0
- package/src/daemon.js +26 -8
- package/src/release-group.js +7 -1
- package/src/state-store.js +8 -0
- package/test/daemon-bootstrap.test.js +363 -0
- package/test/fixtures/dummy-app.js +24 -1
- package/tmp/worker-control/rollbridge-bootstrap/activity-3.jsonl +28 -0
- package/tmp/worker-control/rollbridge-bootstrap/current-head-review-activity.jsonl +13 -0
- package/tmp/worker-control/rollbridge-bootstrap/current-head-review-transcript.jsonl +1 -0
- package/tmp/worker-control/rollbridge-bootstrap/fs-probe-10.jsonl +1 -0
- package/tmp/worker-control/rollbridge-bootstrap/plan.md +9 -0
- package/tmp/worker-control/rollbridge-bootstrap/repair-activity.jsonl +3 -0
- package/tmp/worker-control/rollbridge-bootstrap/repair-fresh-activity.jsonl +25 -0
- package/tmp/worker-control/rollbridge-bootstrap/repair-fresh-transcript.jsonl +1 -0
- package/tmp/worker-control/rollbridge-bootstrap/repair-transcript.jsonl +1 -0
- package/tmp/worker-control/rollbridge-bootstrap/review-activity.jsonl +14 -0
- package/tmp/worker-control/rollbridge-bootstrap/review-transcript.jsonl +1 -0
- package/tmp/worker-control/rollbridge-bootstrap/terminal-repair-activity.jsonl +3 -0
- package/tmp/worker-control/rollbridge-bootstrap/terminal-repair-fresh-activity.jsonl +38 -0
- package/tmp/worker-control/rollbridge-bootstrap/terminal-repair-fresh-transcript.jsonl +1 -0
- package/tmp/worker-control/rollbridge-bootstrap/terminal-repair-transcript.jsonl +1 -0
- package/tmp/worker-control/rollbridge-bootstrap/transcript-2.jsonl +1 -0
- package/tmp/worker-control/rollbridge-bootstrap/transcript-3.jsonl +1 -0
- package/tmp/worker-control/rollbridge-bootstrap/transcript.jsonl +1 -0
package/README.md
CHANGED
|
@@ -399,6 +399,22 @@ Start the daemon:
|
|
|
399
399
|
rollbridge daemon --config rollbridge.js
|
|
400
400
|
```
|
|
401
401
|
|
|
402
|
+
Start the daemon and bootstrap an exact prepared release before leaving it in
|
|
403
|
+
the foreground (for example from a boot-time service manager):
|
|
404
|
+
|
|
405
|
+
```bash
|
|
406
|
+
rollbridge daemon --config /srv/ticket-server/rollbridge.js \
|
|
407
|
+
--release-path /srv/ticket-server/releases/20260813090000/ticket-server \
|
|
408
|
+
--release-id 20260813090000 --revision abc123
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
The four bootstrap inputs are all-or-nothing and use absolute config/release
|
|
412
|
+
paths. Rollbridge binds its proxy and control listeners, activates the release
|
|
413
|
+
through the normal deploy path, and stays foreground. A failed activation stops
|
|
414
|
+
only processes started by that attempt and exits non-zero; persisted processes
|
|
415
|
+
from a previous daemon are reported as orphans and are never recovered or killed
|
|
416
|
+
implicitly; their live PID records remain in `statePath` for explicit recovery.
|
|
417
|
+
|
|
402
418
|
Start the daemon only when it is not already running:
|
|
403
419
|
|
|
404
420
|
```bash
|
package/docs/cli.md
CHANGED
|
@@ -24,6 +24,7 @@ process-policy details.
|
|
|
24
24
|
|
|
25
25
|
```
|
|
26
26
|
rollbridge daemon [--config <path>]
|
|
27
|
+
[--release-path <path> --release-id <id> --revision <sha>]
|
|
27
28
|
```
|
|
28
29
|
|
|
29
30
|
Runs the supervisor in the foreground: binds the stable proxy port and the
|
|
@@ -32,6 +33,28 @@ processes, closes the servers, removes the control socket, and exits `0`.
|
|
|
32
33
|
Structured JSON log lines are written to stdout. Run it under a process manager
|
|
33
34
|
such as systemd (see `examples/rollbridge.service`).
|
|
34
35
|
|
|
36
|
+
For boot/crash recovery, pass an explicit absolute `--config` and all three
|
|
37
|
+
release options together. Rollbridge validates every bootstrap input before it
|
|
38
|
+
binds listeners or starts processes, then binds the listeners and activates that
|
|
39
|
+
exact release through the same deploy path used by the control socket: services,
|
|
40
|
+
companions, the proxied process and health check, traffic switching, singletons,
|
|
41
|
+
and service-template refresh. It then remains in the foreground with the normal
|
|
42
|
+
signal behavior.
|
|
43
|
+
|
|
44
|
+
Bootstrap paths must be absolute and normalized, the release path must be an
|
|
45
|
+
accessible directory, and release id/revision values accept letters, numbers,
|
|
46
|
+
dots, underscores, and hyphens (maximum 200 characters, beginning with a letter
|
|
47
|
+
or number). Supplying only some bootstrap options, or an invalid value, exits
|
|
48
|
+
non-zero before listeners start. Activation failure emits a structured
|
|
49
|
+
`bootstrap activation failed` event, cleans up processes owned by that attempt,
|
|
50
|
+
and exits non-zero without inventing an active release. `statePath` entries from
|
|
51
|
+
a previous daemon remain advisory orphans: bootstrap never runs recovery and
|
|
52
|
+
never signals those processes, and retains their live PID records in `statePath`
|
|
53
|
+
for explicit recovery.
|
|
54
|
+
|
|
55
|
+
With no release options, daemon behavior is unchanged: it starts listener-only
|
|
56
|
+
and waits for control-socket deployments.
|
|
57
|
+
|
|
35
58
|
## `ensure-daemon`
|
|
36
59
|
|
|
37
60
|
```
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -30,7 +30,11 @@ export async function runCli(argv) {
|
|
|
30
30
|
program
|
|
31
31
|
.command("daemon")
|
|
32
32
|
.option("-c, --config <path>", "Config file path (defaults to rollbridge.js)")
|
|
33
|
+
.option("--release-path <path>", "Bootstrap release path (requires --config, --release-id, and --revision)")
|
|
34
|
+
.option("--release-id <id>", "Bootstrap release id (requires --config, --release-path, and --revision)")
|
|
35
|
+
.option("--revision <sha>", "Bootstrap revision (requires --config, --release-path, and --release-id)")
|
|
33
36
|
.action(async (options) => {
|
|
37
|
+
const bootstrap = await validateDaemonBootstrapOptions(options)
|
|
34
38
|
const configPath = await resolveConfigPath(options.config)
|
|
35
39
|
const config = await loadConfig(configPath)
|
|
36
40
|
const daemon = new RollbridgeDaemon({config, configPath})
|
|
@@ -44,6 +48,17 @@ export async function runCli(argv) {
|
|
|
44
48
|
|
|
45
49
|
process.once("SIGINT", () => { void shutdown() })
|
|
46
50
|
process.once("SIGTERM", () => { void shutdown() })
|
|
51
|
+
|
|
52
|
+
if (bootstrap) {
|
|
53
|
+
try {
|
|
54
|
+
await daemon.deploy(bootstrap)
|
|
55
|
+
} catch {
|
|
56
|
+
daemon.logger("bootstrap activation failed", {releaseId: bootstrap.releaseId, status: "error"})
|
|
57
|
+
await daemon.shutdown()
|
|
58
|
+
process.exitCode = 1
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
}
|
|
47
62
|
})
|
|
48
63
|
|
|
49
64
|
program
|
|
@@ -696,6 +711,49 @@ async function validateConfigFile(configPath) {
|
|
|
696
711
|
}
|
|
697
712
|
}
|
|
698
713
|
|
|
714
|
+
/**
|
|
715
|
+
* Validates the daemon's optional all-or-nothing bootstrap release interface before
|
|
716
|
+
* config loading or listener startup.
|
|
717
|
+
* @param {{config?: string, releaseId?: string, releasePath?: string, revision?: string}} options - Daemon CLI options.
|
|
718
|
+
* @returns {Promise<{releaseId: string, releasePath: string, revision: string} | undefined>} Validated bootstrap metadata.
|
|
719
|
+
*/
|
|
720
|
+
async function validateDaemonBootstrapOptions(options) {
|
|
721
|
+
const bootstrapValues = [options.releasePath, options.releaseId, options.revision]
|
|
722
|
+
const bootstrapRequested = bootstrapValues.some((value) => value !== undefined)
|
|
723
|
+
|
|
724
|
+
if (!bootstrapRequested) return undefined
|
|
725
|
+
|
|
726
|
+
if (!options.config || bootstrapValues.some((value) => value === undefined)) {
|
|
727
|
+
throw new Error("Daemon bootstrap options --config, --release-path, --release-id, and --revision must be provided together.")
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
if (!path.isAbsolute(options.config)) throw new Error("Daemon bootstrap --config must be an absolute path.")
|
|
731
|
+
if (path.normalize(options.config) !== options.config) throw new Error("Daemon bootstrap --config must be normalized and must not contain unsafe traversal segments.")
|
|
732
|
+
if (!path.isAbsolute(/** @type {string} */ (options.releasePath))) throw new Error("Daemon bootstrap --release-path must be an absolute path.")
|
|
733
|
+
if (path.normalize(/** @type {string} */ (options.releasePath)) !== options.releasePath) throw new Error("Daemon bootstrap --release-path must be normalized and must not contain unsafe traversal segments.")
|
|
734
|
+
|
|
735
|
+
const releaseId = /** @type {string} */ (options.releaseId)
|
|
736
|
+
const revision = /** @type {string} */ (options.revision)
|
|
737
|
+
const safeIdentifier = /^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/
|
|
738
|
+
|
|
739
|
+
if (!safeIdentifier.test(releaseId)) throw new Error("Daemon bootstrap --release-id must be a non-empty safe identifier containing only letters, numbers, dots, underscores, and hyphens.")
|
|
740
|
+
if (!safeIdentifier.test(revision)) throw new Error("Daemon bootstrap --revision must be a non-empty safe identifier containing only letters, numbers, dots, underscores, and hyphens.")
|
|
741
|
+
|
|
742
|
+
let releaseStat
|
|
743
|
+
|
|
744
|
+
try {
|
|
745
|
+
releaseStat = await fsPromises.stat(/** @type {string} */ (options.releasePath))
|
|
746
|
+
} catch (error) {
|
|
747
|
+
const reason = error instanceof Error ? error.message : String(error)
|
|
748
|
+
|
|
749
|
+
throw new Error(`Daemon bootstrap --release-path is not accessible: ${reason}`, {cause: error})
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
if (!releaseStat.isDirectory()) throw new Error("Daemon bootstrap --release-path must name a directory.")
|
|
753
|
+
|
|
754
|
+
return {releaseId, releasePath: /** @type {string} */ (options.releasePath), revision}
|
|
755
|
+
}
|
|
756
|
+
|
|
699
757
|
/**
|
|
700
758
|
* Starts a daemon when needed and waits until it accepts status commands.
|
|
701
759
|
* @param {object} args - Options.
|
package/src/daemon.js
CHANGED
|
@@ -56,6 +56,7 @@ export default class RollbridgeDaemon {
|
|
|
56
56
|
this.statePath = config.statePath
|
|
57
57
|
this.persistTimer = /** @type {ReturnType<typeof setInterval> | undefined} */ (undefined)
|
|
58
58
|
this.pendingWrite = /** @type {Promise<void> | undefined} */ (undefined)
|
|
59
|
+
this.startingReleases = /** @type {Set<ReleaseGroup>} */ (new Set())
|
|
59
60
|
// Still-alive managed processes left by a previous daemon (from statePath), captured at
|
|
60
61
|
// startup and surfaced in status(). The daemon cannot re-manage them, only report them.
|
|
61
62
|
this.orphans = /** @type {{id: string, pid: number, releaseId: string | null}[]} */ ([])
|
|
@@ -334,19 +335,26 @@ export default class RollbridgeDaemon {
|
|
|
334
335
|
releaseId: newReleaseId,
|
|
335
336
|
releasePath,
|
|
336
337
|
revision,
|
|
337
|
-
servicePorts: this.servicePorts
|
|
338
|
+
servicePorts: this.servicePorts,
|
|
339
|
+
shouldStart: () => !this.stopping
|
|
338
340
|
})
|
|
339
341
|
|
|
340
342
|
this.logger("deploy starting", {releaseId: newReleaseId, releasePath, revision})
|
|
341
343
|
const startedServices = /** @type {string[]} */ ([])
|
|
342
344
|
|
|
345
|
+
this.startingReleases.add(release)
|
|
346
|
+
|
|
343
347
|
try {
|
|
344
348
|
await this.ensureServices(release, startedServices)
|
|
345
349
|
await release.start()
|
|
350
|
+
if (this.stopping) throw new Error("Rollbridge is shutting down")
|
|
346
351
|
} catch (error) {
|
|
347
352
|
this.logger("deploy failed", {error: error instanceof Error ? error.message : String(error), releaseId: newReleaseId})
|
|
353
|
+
await release.stop()
|
|
348
354
|
await this.stopStartedServices(startedServices)
|
|
349
355
|
throw error
|
|
356
|
+
} finally {
|
|
357
|
+
this.startingReleases.delete(release)
|
|
350
358
|
}
|
|
351
359
|
|
|
352
360
|
const previousRelease = this.activeRelease
|
|
@@ -467,6 +475,7 @@ export default class RollbridgeDaemon {
|
|
|
467
475
|
await release.allocatePorts()
|
|
468
476
|
|
|
469
477
|
for (const processConfig of release.config.processes) {
|
|
478
|
+
if (this.stopping) throw new Error("Rollbridge is shutting down")
|
|
470
479
|
if (processConfig.policy !== "service" || processConfig.deployStrategy === "handoff") continue
|
|
471
480
|
if (this.services.has(processConfig.id)) continue
|
|
472
481
|
|
|
@@ -486,6 +495,8 @@ export default class RollbridgeDaemon {
|
|
|
486
495
|
delete this.servicePorts[processConfig.id]
|
|
487
496
|
throw error
|
|
488
497
|
}
|
|
498
|
+
|
|
499
|
+
if (this.stopping) throw new Error("Rollbridge is shutting down")
|
|
489
500
|
}
|
|
490
501
|
}
|
|
491
502
|
|
|
@@ -532,12 +543,14 @@ export default class RollbridgeDaemon {
|
|
|
532
543
|
*/
|
|
533
544
|
async replaceSingletons(release) {
|
|
534
545
|
for (const processConfig of this.config.processes) {
|
|
546
|
+
if (this.stopping) throw new Error("Rollbridge is shutting down")
|
|
535
547
|
if (processConfig.policy !== "singleton") continue
|
|
536
548
|
|
|
537
549
|
const previous = this.singletons.get(processConfig.id)
|
|
538
550
|
|
|
539
551
|
if (previous) {
|
|
540
552
|
await previous.stop()
|
|
553
|
+
if (this.stopping) throw new Error("Rollbridge is shutting down")
|
|
541
554
|
}
|
|
542
555
|
|
|
543
556
|
const singleton = release.buildProcess(processConfig)
|
|
@@ -688,9 +701,7 @@ export default class RollbridgeDaemon {
|
|
|
688
701
|
if (!this.statePath || this.stopping) return
|
|
689
702
|
|
|
690
703
|
const statePath = this.statePath
|
|
691
|
-
|
|
692
|
-
// this daemon's own managed state, and is recomputed from the persisted processes on restart.
|
|
693
|
-
const {orphans: _orphans, ...status} = this.status()
|
|
704
|
+
const status = this.status()
|
|
694
705
|
const snapshot = {...status, events: this.eventLog.recent(), persistedAt: new Date().toISOString()}
|
|
695
706
|
|
|
696
707
|
// Serialize writes (and track the tail) so shutdown can wait for an in-flight write before
|
|
@@ -742,17 +753,24 @@ export default class RollbridgeDaemon {
|
|
|
742
753
|
this.proxy.close()
|
|
743
754
|
await Promise.allSettled([...this.services.values()].map((processInstance) => processInstance.stop()))
|
|
744
755
|
await Promise.allSettled([...this.singletons.values()].map((processInstance) => processInstance.stop()))
|
|
756
|
+
await Promise.allSettled([...this.startingReleases].map((release) => release.stop()))
|
|
745
757
|
await Promise.allSettled([...this.releases.values()].map((release) => release.stop()))
|
|
746
758
|
await this.closeServer(this.proxyServer)
|
|
747
759
|
await this.closeServer(this.controlServer)
|
|
748
760
|
await fs.rm(this.config.control.path, {force: true})
|
|
749
761
|
|
|
750
|
-
//
|
|
751
|
-
//
|
|
752
|
-
//
|
|
762
|
+
// Wait for any in-flight write first so it can't recreate or overwrite the final state (no
|
|
763
|
+
// new writes start: stopping is set and the persist timer is cleared above). Prior-daemon
|
|
764
|
+
// orphans are not owned by this daemon, so retain their records until they are confirmed gone.
|
|
753
765
|
if (this.statePath) {
|
|
754
766
|
if (this.pendingWrite) await this.pendingWrite
|
|
755
|
-
|
|
767
|
+
const orphans = this.orphans.filter((orphan) => isProcessAlive(orphan.pid))
|
|
768
|
+
|
|
769
|
+
if (orphans.length > 0) {
|
|
770
|
+
await writeState(this.statePath, {activeReleaseId: null, orphans, releases: [], services: [], singletons: []})
|
|
771
|
+
} else {
|
|
772
|
+
await clearState(this.statePath)
|
|
773
|
+
}
|
|
756
774
|
}
|
|
757
775
|
}
|
|
758
776
|
|
package/src/release-group.js
CHANGED
|
@@ -40,8 +40,9 @@ export default class ReleaseGroup extends EventEmitter {
|
|
|
40
40
|
* @param {string} args.releasePath - Release path.
|
|
41
41
|
* @param {string | undefined} args.revision - Revision.
|
|
42
42
|
* @param {Record<string, number>} [args.servicePorts] - Ports already owned by daemon-wide services.
|
|
43
|
+
* @param {() => boolean} [args.shouldStart] - Whether bootstrap may create another process.
|
|
43
44
|
*/
|
|
44
|
-
constructor({config, logger, releaseId, releasePath, revision, servicePorts = {}}) {
|
|
45
|
+
constructor({config, logger, releaseId, releasePath, revision, servicePorts = {}, shouldStart = () => true}) {
|
|
45
46
|
super()
|
|
46
47
|
|
|
47
48
|
this.config = config
|
|
@@ -57,6 +58,7 @@ export default class ReleaseGroup extends EventEmitter {
|
|
|
57
58
|
this.nonBlockingDrainIds = /** @type {Set<string>} */ (new Set())
|
|
58
59
|
this.ports = /** @type {Record<string, number>} */ ({})
|
|
59
60
|
this.servicePorts = servicePorts
|
|
61
|
+
this.shouldStart = shouldStart
|
|
60
62
|
this.portsAllocated = false
|
|
61
63
|
this.drainStartedAt = /** @type {string | undefined} */ (undefined)
|
|
62
64
|
this.activatedAt = /** @type {string | undefined} */ (undefined)
|
|
@@ -72,6 +74,8 @@ export default class ReleaseGroup extends EventEmitter {
|
|
|
72
74
|
|
|
73
75
|
for (const processConfig of this.releaseProcessStartOrder()) {
|
|
74
76
|
for (let index = 0; index < processConfig.replicas; index += 1) {
|
|
77
|
+
if (!this.shouldStart()) throw new Error("Rollbridge is shutting down")
|
|
78
|
+
|
|
75
79
|
const instanceId = replicaInstanceId(processConfig, index)
|
|
76
80
|
const processInstance = this.buildProcess(processConfig, {count: processConfig.replicas, index, instanceId})
|
|
77
81
|
|
|
@@ -87,6 +91,8 @@ export default class ReleaseGroup extends EventEmitter {
|
|
|
87
91
|
host: this.config.proxy.upstreamHost,
|
|
88
92
|
port: this.ports[processConfig.id]
|
|
89
93
|
})
|
|
94
|
+
|
|
95
|
+
if (!this.shouldStart()) throw new Error("Rollbridge is shutting down")
|
|
90
96
|
}
|
|
91
97
|
}
|
|
92
98
|
} catch (error) {
|
package/src/state-store.js
CHANGED
|
@@ -86,6 +86,14 @@ export function liveProcesses(state, alive = isProcessAlive) {
|
|
|
86
86
|
try {
|
|
87
87
|
const snapshot = /** @type {import("./daemon.js").DaemonStatus} */ (state)
|
|
88
88
|
|
|
89
|
+
if (Array.isArray(snapshot.orphans)) {
|
|
90
|
+
for (const orphan of snapshot.orphans) {
|
|
91
|
+
if (typeof orphan.id === "string" && typeof orphan.pid === "number" && (typeof orphan.releaseId === "string" || orphan.releaseId === null) && alive(orphan.pid)) {
|
|
92
|
+
live.push({id: orphan.id, pid: orphan.pid, releaseId: orphan.releaseId})
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
89
97
|
for (const release of snapshot.releases) {
|
|
90
98
|
for (const process of release.processes) {
|
|
91
99
|
if (typeof process.pid === "number" && alive(process.pid)) live.push({id: process.id, pid: process.pid, releaseId: release.releaseId})
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import assert from "node:assert/strict"
|
|
4
|
+
import {spawn} from "node:child_process"
|
|
5
|
+
import {once} from "node:events"
|
|
6
|
+
import fs from "node:fs/promises"
|
|
7
|
+
import os from "node:os"
|
|
8
|
+
import path from "node:path"
|
|
9
|
+
import test from "node:test"
|
|
10
|
+
import {fileURLToPath} from "node:url"
|
|
11
|
+
import {sendControlCommand} from "../src/control-client.js"
|
|
12
|
+
import {isProcessAlive, liveProcesses, readState, writeState} from "../src/state-store.js"
|
|
13
|
+
|
|
14
|
+
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
|
15
|
+
const binPath = path.join(currentDir, "..", "bin", "rollbridge")
|
|
16
|
+
const dummyAppPath = path.join(currentDir, "fixtures", "dummy-app.js")
|
|
17
|
+
|
|
18
|
+
test("daemon bootstrap requires complete, safe, absolute inputs before binding listeners", async (t) => {
|
|
19
|
+
const cases = [
|
|
20
|
+
{args: ["--config", "CONFIG", "--release-path", "RELEASE", "--release-id", "v1"], message: /must be provided together/},
|
|
21
|
+
{args: ["--config", "relative/config.js", "--release-path", "RELEASE", "--release-id", "v1", "--revision", "abc123"], message: /--config must be an absolute path/},
|
|
22
|
+
{args: ["--config", "CONFIG", "--release-path", "relative/release", "--release-id", "v1", "--revision", "abc123"], message: /--release-path must be an absolute path/},
|
|
23
|
+
{args: ["--config", "CONFIG", "--release-path", "RELEASE", "--release-id", "unsafe id", "--revision", "abc123"], message: /--release-id/},
|
|
24
|
+
{args: ["--config", "CONFIG", "--release-path", "RELEASE", "--release-id", "v1", "--revision", "unsafe revision"], message: /--revision/}
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
for (const testCase of cases) {
|
|
28
|
+
await t.test(testCase.message.source, async () => {
|
|
29
|
+
const fixture = await createFixture()
|
|
30
|
+
const args = testCase.args.map((arg) => arg === "CONFIG" ? fixture.configPath : arg === "RELEASE" ? fixture.root : arg)
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const result = await runDaemon(args)
|
|
34
|
+
|
|
35
|
+
assert.notEqual(result.code, 0)
|
|
36
|
+
assert.match(result.stderr, testCase.message)
|
|
37
|
+
await assert.rejects(() => fs.stat(fixture.socketPath), {code: "ENOENT"})
|
|
38
|
+
} finally {
|
|
39
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test("daemon bootstrap activates the exact release through the foreground daemon", async () => {
|
|
46
|
+
const fixture = await createFixture()
|
|
47
|
+
const child = spawnDaemon(fixture, {releaseId: "release-42", revision: "abc123"})
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
await waitForLog(child, "traffic switched")
|
|
51
|
+
const status = await sendControlCommand({command: {command: "status"}, path: fixture.socketPath})
|
|
52
|
+
const activeRelease = assertRelease(status, "release-42")
|
|
53
|
+
|
|
54
|
+
assert.equal(activeRelease.releasePath, fixture.root)
|
|
55
|
+
assert.equal(activeRelease.revision, "abc123")
|
|
56
|
+
assert.ok(status.proxy && typeof status.proxy === "object" && !Array.isArray(status.proxy) && typeof status.proxy.port === "number")
|
|
57
|
+
assert.equal((await fetch(`http://127.0.0.1:${status.proxy.port}/release`).then((response) => response.text())).trim(), "release-42")
|
|
58
|
+
|
|
59
|
+
child.kill("SIGTERM")
|
|
60
|
+
assert.equal((await once(child, "exit"))[0], 0)
|
|
61
|
+
} finally {
|
|
62
|
+
if (child.exitCode === null) child.kill("SIGKILL")
|
|
63
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
64
|
+
}
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test("plain daemon startup remains listener-only with no active release", async () => {
|
|
68
|
+
const fixture = await createFixture()
|
|
69
|
+
const child = spawn(process.execPath, [binPath, "daemon", "--config", fixture.configPath], {stdio: ["pipe", "pipe", "pipe"]})
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
await waitForLog(child, "control socket listening")
|
|
73
|
+
const status = await sendControlCommand({command: {command: "status"}, path: fixture.socketPath})
|
|
74
|
+
|
|
75
|
+
assert.equal(status.activeReleaseId, null)
|
|
76
|
+
assert.deepEqual(status.releases, [])
|
|
77
|
+
|
|
78
|
+
child.kill("SIGTERM")
|
|
79
|
+
assert.equal((await once(child, "exit"))[0], 0)
|
|
80
|
+
} finally {
|
|
81
|
+
if (child.exitCode === null) child.kill("SIGKILL")
|
|
82
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
83
|
+
}
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test("SIGTERM during bootstrap activation follows the daemon shutdown path", async () => {
|
|
87
|
+
const fixture = await createFixture({healthPath: "/never-ready", healthTimeoutMs: 60000})
|
|
88
|
+
const started = waitForFile(fixture.startedPath)
|
|
89
|
+
const child = spawnDaemon(fixture, {releaseId: "slow-release", revision: "slow123"})
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
const managedPid = Number(await started)
|
|
93
|
+
child.kill("SIGTERM")
|
|
94
|
+
|
|
95
|
+
const [code, signal] = await once(child, "exit")
|
|
96
|
+
|
|
97
|
+
assert.equal(code, 0)
|
|
98
|
+
assert.equal(signal, null)
|
|
99
|
+
assert.equal(await fs.readFile(fixture.stoppedPath, "utf8"), String(managedPid))
|
|
100
|
+
await assert.rejects(() => fs.stat(fixture.socketPath), {code: "ENOENT"})
|
|
101
|
+
} finally {
|
|
102
|
+
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL")
|
|
103
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
104
|
+
}
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test("SIGTERM during multi-process bootstrap owns every process started after shutdown begins", async () => {
|
|
108
|
+
const fixture = await createFixture({multiProcessSignal: true})
|
|
109
|
+
const shutdownStarted = waitForLifecycleEvent(fixture.lifecyclePath, (event) => event.event === "shutdown")
|
|
110
|
+
const child = spawnDaemon(fixture, {releaseId: "multi-release", revision: "multi123"})
|
|
111
|
+
let output = ""
|
|
112
|
+
|
|
113
|
+
child.stdout.setEncoding("utf8").on("data", (chunk) => { output += chunk })
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
await shutdownStarted
|
|
117
|
+
await fs.writeFile(fixture.gatePath, "continue\n")
|
|
118
|
+
const [code, signal] = await once(child, "exit")
|
|
119
|
+
const events = (await fs.readFile(fixture.lifecyclePath, "utf8")).trim().split("\n").map((line) => JSON.parse(line))
|
|
120
|
+
const shutdownIndex = events.findIndex((event) => event.event === "shutdown")
|
|
121
|
+
const startedAfterShutdown = events.slice(shutdownIndex + 1).filter((event) => event.event === "started")
|
|
122
|
+
const records = output.trim().split("\n").filter(Boolean).map((line) => JSON.parse(line))
|
|
123
|
+
const recordedPids = new Set(records.filter((record) => record.message === "process started").map((record) => record.data?.pid))
|
|
124
|
+
|
|
125
|
+
assert.equal(code, 0)
|
|
126
|
+
assert.equal(signal, null)
|
|
127
|
+
assert.notEqual(shutdownIndex, -1)
|
|
128
|
+
assert.ok(startedAfterShutdown.length > 0, `fixture must start a later bootstrap process after triggering shutdown: ${JSON.stringify(events)}`)
|
|
129
|
+
assert.deepEqual(startedAfterShutdown.filter((event) => !recordedPids.has(event.pid)), [])
|
|
130
|
+
for (const event of startedAfterShutdown) assert.equal(isProcessAlive(event.pid), false, `expected process ${event.pid} to be stopped before daemon exit`)
|
|
131
|
+
} finally {
|
|
132
|
+
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL")
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
const events = (await fs.readFile(fixture.lifecyclePath, "utf8")).trim().split("\n").filter(Boolean).map((line) => JSON.parse(line))
|
|
136
|
+
|
|
137
|
+
for (const event of events) {
|
|
138
|
+
if (event.event === "started" && isProcessAlive(event.pid)) process.kill(-event.pid, "SIGKILL")
|
|
139
|
+
}
|
|
140
|
+
} catch {
|
|
141
|
+
// The fixture may exit before creating its lifecycle log.
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
145
|
+
}
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test("failed daemon bootstrap reports a structured failure, cleans its processes, and exits non-zero", async () => {
|
|
149
|
+
const fixture = await createFixture({healthPath: "/never-ready", healthTimeoutMs: 100})
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
const result = await runDaemon([
|
|
153
|
+
"--config", fixture.configPath,
|
|
154
|
+
"--release-path", fixture.root,
|
|
155
|
+
"--release-id", "bad-release",
|
|
156
|
+
"--revision", "bad123"
|
|
157
|
+
])
|
|
158
|
+
const records = result.output.split("\n").filter(Boolean).map((line) => JSON.parse(line))
|
|
159
|
+
const failure = records.find((record) => record.message === "bootstrap activation failed")
|
|
160
|
+
|
|
161
|
+
assert.notEqual(result.code, 0)
|
|
162
|
+
assert.deepEqual(failure?.data, {releaseId: "bad-release", status: "error"})
|
|
163
|
+
assert.ok(records.some((record) => record.message === "release startup process status" && record.data?.phase === "after cleanup" && record.data?.state === "stopped"))
|
|
164
|
+
await assert.rejects(() => fs.stat(fixture.socketPath), {code: "ENOENT"})
|
|
165
|
+
} finally {
|
|
166
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
167
|
+
}
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
test("daemon bootstrap reports but does not kill a live process from statePath", async () => {
|
|
171
|
+
const fixture = await createFixture({persistState: true})
|
|
172
|
+
const leftover = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], {stdio: "ignore"})
|
|
173
|
+
|
|
174
|
+
await once(leftover, "spawn")
|
|
175
|
+
await writeState(fixture.statePath, {
|
|
176
|
+
activeReleaseId: "previous",
|
|
177
|
+
releases: [{processes: [{id: "worker", pid: leftover.pid}], releaseId: "previous"}],
|
|
178
|
+
services: [],
|
|
179
|
+
singletons: []
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
const child = spawnDaemon(fixture, {releaseId: "recovered", revision: "def456"})
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
await waitForLog(child, "traffic switched")
|
|
186
|
+
const status = await sendControlCommand({command: {command: "status"}, path: fixture.socketPath})
|
|
187
|
+
|
|
188
|
+
assert.ok(leftover.pid !== undefined && isProcessAlive(leftover.pid))
|
|
189
|
+
assert.deepEqual(status.orphans, [{id: "worker", pid: leftover.pid, releaseId: "previous"}])
|
|
190
|
+
|
|
191
|
+
child.kill("SIGTERM")
|
|
192
|
+
await once(child, "exit")
|
|
193
|
+
|
|
194
|
+
assert.deepEqual(liveProcesses(await readState(fixture.statePath)), [{id: "worker", pid: leftover.pid, releaseId: "previous"}])
|
|
195
|
+
} finally {
|
|
196
|
+
if (child.exitCode === null) child.kill("SIGKILL")
|
|
197
|
+
leftover.kill("SIGKILL")
|
|
198
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
199
|
+
}
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
test("failed daemon bootstrap preserves prior live process records in statePath", async () => {
|
|
203
|
+
const fixture = await createFixture({healthPath: "/never-ready", healthTimeoutMs: 100, persistState: true})
|
|
204
|
+
const leftover = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], {stdio: "ignore"})
|
|
205
|
+
|
|
206
|
+
await once(leftover, "spawn")
|
|
207
|
+
await writeState(fixture.statePath, {
|
|
208
|
+
activeReleaseId: "previous",
|
|
209
|
+
releases: [{processes: [{id: "worker", pid: leftover.pid}], releaseId: "previous"}],
|
|
210
|
+
services: [],
|
|
211
|
+
singletons: []
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
try {
|
|
215
|
+
const result = await runDaemon([
|
|
216
|
+
"--config", fixture.configPath,
|
|
217
|
+
"--release-path", fixture.root,
|
|
218
|
+
"--release-id", "bad-release",
|
|
219
|
+
"--revision", "bad123"
|
|
220
|
+
])
|
|
221
|
+
|
|
222
|
+
assert.notEqual(result.code, 0)
|
|
223
|
+
assert.deepEqual(liveProcesses(await readState(fixture.statePath)), [{id: "worker", pid: leftover.pid, releaseId: "previous"}])
|
|
224
|
+
} finally {
|
|
225
|
+
leftover.kill("SIGKILL")
|
|
226
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
227
|
+
}
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* @param {{healthPath?: string, healthTimeoutMs?: number, multiProcessSignal?: boolean, persistState?: boolean}} [options] - Fixture options.
|
|
232
|
+
* @returns {Promise<{configPath: string, gatePath: string, lifecyclePath: string, root: string, socketPath: string, startedPath: string, statePath: string, stoppedPath: string}>} Fixture paths.
|
|
233
|
+
*/
|
|
234
|
+
async function createFixture({healthPath = "/ping", healthTimeoutMs = 1000, multiProcessSignal = false, persistState = false} = {}) {
|
|
235
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-bootstrap-"))
|
|
236
|
+
const socketPath = path.join(root, "control.sock")
|
|
237
|
+
const statePath = path.join(root, "state.json")
|
|
238
|
+
const startedPath = path.join(root, "started.pid")
|
|
239
|
+
const stoppedPath = path.join(root, "stopped.pid")
|
|
240
|
+
const lifecyclePath = path.join(root, "lifecycle.jsonl")
|
|
241
|
+
const gatePath = path.join(root, "continue.fifo")
|
|
242
|
+
const configPath = path.join(root, "rollbridge.js")
|
|
243
|
+
const command = `${JSON.stringify(process.execPath)} ${JSON.stringify(dummyAppPath)}`
|
|
244
|
+
const lifecycleEnv = {ROLLBRIDGE_TEST_LIFECYCLE_PATH: lifecyclePath}
|
|
245
|
+
const config = {
|
|
246
|
+
application: "bootstrap-test",
|
|
247
|
+
control: {path: socketPath},
|
|
248
|
+
processes: multiProcessSignal ? [
|
|
249
|
+
{command: `trap '' TERM; printf '%s\\n' '{"event":"shutdown"}' >> ${JSON.stringify(lifecyclePath)}; kill -TERM "$ROLLBRIDGE_TEST_DAEMON_PID"; printf '{"event":"started","pid":%s,"processId":"database","replicaIndex":"0"}\\n' "$$" >> ${JSON.stringify(lifecyclePath)}; read ignored < ${JSON.stringify(gatePath)}`, env: lifecycleEnv, id: "database", policy: "service"},
|
|
250
|
+
{command: `exec ${command}`, env: lifecycleEnv, id: "worker", policy: "companion", replicas: 2},
|
|
251
|
+
{command: `exec ${command}`, env: lifecycleEnv, health: {path: healthPath, timeoutMs: healthTimeoutMs}, id: "web", policy: "proxied", port: {from: 0, to: 0}}
|
|
252
|
+
] : [{command, env: {ROLLBRIDGE_TEST_STARTED_PATH: startedPath, ROLLBRIDGE_TEST_STOPPED_PATH: stoppedPath}, health: {path: healthPath, timeoutMs: healthTimeoutMs}, id: "web", policy: "proxied", port: {from: 0, to: 0}}],
|
|
253
|
+
proxy: {host: "127.0.0.1", port: 0},
|
|
254
|
+
...(persistState ? {statePath} : {})
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const setup = multiProcessSignal ? "process.env.ROLLBRIDGE_TEST_DAEMON_PID = String(process.pid)\n" : ""
|
|
258
|
+
|
|
259
|
+
if (multiProcessSignal) {
|
|
260
|
+
const fifo = spawn("mkfifo", [gatePath])
|
|
261
|
+
const [code] = await once(fifo, "exit")
|
|
262
|
+
|
|
263
|
+
assert.equal(code, 0)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
await fs.writeFile(configPath, `${setup}module.exports = ${JSON.stringify(config, null, 2)}\n`)
|
|
267
|
+
return {configPath, gatePath, lifecyclePath, root, socketPath, startedPath, statePath, stoppedPath}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* @param {string} filePath - JSON-lines event path.
|
|
272
|
+
* @param {(event: Record<string, import("../src/json.js").JsonValue>) => boolean} matches - Event predicate.
|
|
273
|
+
* @returns {Promise<Record<string, import("../src/json.js").JsonValue>>} First matching event.
|
|
274
|
+
*/
|
|
275
|
+
async function waitForLifecycleEvent(filePath, matches) {
|
|
276
|
+
const watcher = fs.watch(path.dirname(filePath))
|
|
277
|
+
|
|
278
|
+
try {
|
|
279
|
+
for await (const event of watcher) {
|
|
280
|
+
if (event.filename !== path.basename(filePath)) continue
|
|
281
|
+
|
|
282
|
+
const records = (await fs.readFile(filePath, "utf8")).trim().split("\n").filter(Boolean).map((line) => JSON.parse(line))
|
|
283
|
+
const match = records.find(matches)
|
|
284
|
+
|
|
285
|
+
if (match) return match
|
|
286
|
+
}
|
|
287
|
+
} finally {
|
|
288
|
+
await watcher.return?.()
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
throw new Error(`File watcher ended before a matching event was written to ${filePath}`)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* @param {string} filePath - File whose creation is the synchronization point.
|
|
296
|
+
* @returns {Promise<string>} File contents once created.
|
|
297
|
+
*/
|
|
298
|
+
async function waitForFile(filePath) {
|
|
299
|
+
const watcher = fs.watch(path.dirname(filePath))
|
|
300
|
+
|
|
301
|
+
try {
|
|
302
|
+
for await (const event of watcher) {
|
|
303
|
+
if (event.filename === path.basename(filePath)) return await fs.readFile(filePath, "utf8")
|
|
304
|
+
}
|
|
305
|
+
} finally {
|
|
306
|
+
await watcher.return?.()
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
throw new Error(`File watcher ended before ${filePath} was created`)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* @param {{configPath: string, root: string}} fixture - Fixture paths.
|
|
314
|
+
* @param {{releaseId: string, revision: string}} release - Bootstrap metadata.
|
|
315
|
+
* @returns {import("node:child_process").ChildProcessWithoutNullStreams} Spawned daemon.
|
|
316
|
+
*/
|
|
317
|
+
function spawnDaemon(fixture, release) {
|
|
318
|
+
return spawn(process.execPath, [binPath, "daemon", "--config", fixture.configPath, "--release-path", fixture.root, "--release-id", release.releaseId, "--revision", release.revision], {stdio: ["pipe", "pipe", "pipe"]})
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* @param {string[]} args - Daemon arguments.
|
|
323
|
+
* @returns {Promise<{code: number | null, output: string, stderr: string}>} Completed process result.
|
|
324
|
+
*/
|
|
325
|
+
async function runDaemon(args) {
|
|
326
|
+
const child = spawn(process.execPath, [binPath, "daemon", ...args], {stdio: ["ignore", "pipe", "pipe"]})
|
|
327
|
+
let output = ""
|
|
328
|
+
let stderr = ""
|
|
329
|
+
|
|
330
|
+
child.stdout.setEncoding("utf8").on("data", (chunk) => { output += chunk })
|
|
331
|
+
child.stderr.setEncoding("utf8").on("data", (chunk) => { stderr += chunk })
|
|
332
|
+
const [code] = await once(child, "exit")
|
|
333
|
+
|
|
334
|
+
return {code, output, stderr}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* @param {import("node:child_process").ChildProcessWithoutNullStreams} child - Spawned daemon.
|
|
339
|
+
* @param {string} message - Structured log message to await.
|
|
340
|
+
* @returns {Promise<void>} Resolves after the message is observed.
|
|
341
|
+
*/
|
|
342
|
+
async function waitForLog(child, message) {
|
|
343
|
+
child.stdout.setEncoding("utf8")
|
|
344
|
+
|
|
345
|
+
for await (const chunk of child.stdout) {
|
|
346
|
+
if (String(chunk).includes(`"message":"${message}"`)) return
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
throw new Error(`Daemon exited before logging ${message}`)
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* @param {Record<string, import("../src/json.js").JsonValue>} status - Daemon status.
|
|
354
|
+
* @param {string} releaseId - Expected release id.
|
|
355
|
+
* @returns {Record<string, import("../src/json.js").JsonValue>} Matching release status.
|
|
356
|
+
*/
|
|
357
|
+
function assertRelease(status, releaseId) {
|
|
358
|
+
assert.ok(Array.isArray(status.releases))
|
|
359
|
+
const release = status.releases.find((candidate) => candidate && typeof candidate === "object" && "releaseId" in candidate && candidate.releaseId === releaseId)
|
|
360
|
+
|
|
361
|
+
assert.ok(release && typeof release === "object" && !Array.isArray(release))
|
|
362
|
+
return release
|
|
363
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
3
|
import crypto from "node:crypto"
|
|
4
|
+
import fs from "node:fs"
|
|
4
5
|
import http from "node:http"
|
|
5
6
|
|
|
6
7
|
const port = Number(process.env.ROLLBRIDGE_PORT)
|
|
@@ -8,6 +9,10 @@ const releaseId = process.env.ROLLBRIDGE_RELEASE_ID || "unknown"
|
|
|
8
9
|
const healthFails = releaseId.includes("bad")
|
|
9
10
|
const sockets = new Set()
|
|
10
11
|
|
|
12
|
+
if (process.env.ROLLBRIDGE_TEST_STARTED_PATH) {
|
|
13
|
+
fs.writeFileSync(process.env.ROLLBRIDGE_TEST_STARTED_PATH, String(process.pid))
|
|
14
|
+
}
|
|
15
|
+
|
|
11
16
|
const server = http.createServer((request, response) => {
|
|
12
17
|
if (request.url === "/ping") {
|
|
13
18
|
if (healthFails) {
|
|
@@ -59,6 +64,14 @@ server.on("upgrade", (request, socket) => {
|
|
|
59
64
|
})
|
|
60
65
|
|
|
61
66
|
process.on("SIGTERM", () => {
|
|
67
|
+
if (process.env.ROLLBRIDGE_TEST_LIFECYCLE_PATH) {
|
|
68
|
+
fs.appendFileSync(process.env.ROLLBRIDGE_TEST_LIFECYCLE_PATH, `${JSON.stringify({event: "stopped", pid: process.pid, processId: process.env.ROLLBRIDGE_PROCESS_ID, replicaIndex: process.env.ROLLBRIDGE_REPLICA_INDEX})}\n`)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (process.env.ROLLBRIDGE_TEST_STOPPED_PATH) {
|
|
72
|
+
fs.writeFileSync(process.env.ROLLBRIDGE_TEST_STOPPED_PATH, String(process.pid))
|
|
73
|
+
}
|
|
74
|
+
|
|
62
75
|
server.close(() => process.exit(0))
|
|
63
76
|
|
|
64
77
|
if (sockets.size === 0) {
|
|
@@ -66,4 +79,14 @@ process.on("SIGTERM", () => {
|
|
|
66
79
|
}
|
|
67
80
|
})
|
|
68
81
|
|
|
69
|
-
|
|
82
|
+
const recordStarted = () => {
|
|
83
|
+
if (process.env.ROLLBRIDGE_TEST_LIFECYCLE_PATH) {
|
|
84
|
+
fs.appendFileSync(process.env.ROLLBRIDGE_TEST_LIFECYCLE_PATH, `${JSON.stringify({event: "started", pid: process.pid, processId: process.env.ROLLBRIDGE_PROCESS_ID, replicaIndex: process.env.ROLLBRIDGE_REPLICA_INDEX})}\n`)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (process.env.ROLLBRIDGE_PORT) {
|
|
89
|
+
server.listen(port, "127.0.0.1", recordStarted)
|
|
90
|
+
} else {
|
|
91
|
+
recordStarted()
|
|
92
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{"type":"controller-started","pid":23,"at":1786618711548}
|
|
2
|
+
{"type":"provider-started","provider":"codex","pid":34,"at":1786618711553}
|
|
3
|
+
{"type":"session-available","provider":"codex","sessionId":"019ffac6-1176-7023-8f14-82bae745817f","at":1786618712506}
|
|
4
|
+
{"type":"activity","provider":"codex","kind":"lifecycle","at":1786618712506}
|
|
5
|
+
{"type":"activity","provider":"codex","kind":"delta","at":1786618717893}
|
|
6
|
+
{"type":"activity","provider":"codex","kind":"tool","at":1786618723182}
|
|
7
|
+
{"type":"activity","provider":"codex","kind":"tool","at":1786618730240}
|
|
8
|
+
{"type":"activity","provider":"codex","kind":"tool","at":1786618740515}
|
|
9
|
+
{"type":"activity","provider":"codex","kind":"tool","at":1786618746269}
|
|
10
|
+
{"type":"activity","provider":"codex","kind":"delta","at":1786618770657}
|
|
11
|
+
{"type":"activity","provider":"codex","kind":"tool","at":1786618811295}
|
|
12
|
+
{"type":"activity","provider":"codex","kind":"tool","at":1786618816349}
|
|
13
|
+
{"type":"activity","provider":"codex","kind":"tool","at":1786618859240}
|
|
14
|
+
{"type":"activity","provider":"codex","kind":"tool","at":1786618872926}
|
|
15
|
+
{"type":"activity","provider":"codex","kind":"delta","at":1786618889245}
|
|
16
|
+
{"type":"activity","provider":"codex","kind":"tool","at":1786618908545}
|
|
17
|
+
{"type":"activity","provider":"codex","kind":"tool","at":1786618913878}
|
|
18
|
+
{"type":"activity","provider":"codex","kind":"delta","at":1786618934507}
|
|
19
|
+
{"type":"activity","provider":"codex","kind":"tool","at":1786618940248}
|
|
20
|
+
{"type":"activity","provider":"codex","kind":"tool","at":1786618945887}
|
|
21
|
+
{"type":"activity","provider":"codex","kind":"tool","at":1786618960106}
|
|
22
|
+
{"type":"activity","provider":"codex","kind":"tool","at":1786618969417}
|
|
23
|
+
{"type":"activity","provider":"codex","kind":"delta","at":1786618976649}
|
|
24
|
+
{"type":"activity","provider":"codex","kind":"tool","at":1786618996642}
|
|
25
|
+
{"type":"activity","provider":"codex","kind":"tool","at":1786619006003}
|
|
26
|
+
{"type":"activity","provider":"codex","kind":"tool","at":1786619014397}
|
|
27
|
+
{"type":"activity","provider":"codex","kind":"delta","at":1786619021770}
|
|
28
|
+
{"type":"terminal","provider":"codex","state":"completed","exitCode":0,"at":1786619026150}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{"type":"controller-started","pid":23,"at":1786619870796}
|
|
2
|
+
{"type":"provider-started","provider":"codex","pid":34,"at":1786619870799}
|
|
3
|
+
{"type":"session-available","provider":"codex","sessionId":"019ffad7-bed8-7861-911f-83cd5de11373","at":1786619871013}
|
|
4
|
+
{"type":"activity","provider":"codex","kind":"lifecycle","at":1786619871014}
|
|
5
|
+
{"type":"activity","provider":"codex","kind":"tool","at":1786619877574}
|
|
6
|
+
{"type":"activity","provider":"codex","kind":"tool","at":1786619886232}
|
|
7
|
+
{"type":"activity","provider":"codex","kind":"tool","at":1786619894201}
|
|
8
|
+
{"type":"activity","provider":"codex","kind":"delta","at":1786619899492}
|
|
9
|
+
{"type":"activity","provider":"codex","kind":"tool","at":1786619905754}
|
|
10
|
+
{"type":"activity","provider":"codex","kind":"tool","at":1786619921741}
|
|
11
|
+
{"type":"activity","provider":"codex","kind":"tool","at":1786619931957}
|
|
12
|
+
{"type":"activity","provider":"codex","kind":"delta","at":1786619937323}
|
|
13
|
+
{"type":"terminal","provider":"codex","state":"completed","exitCode":0,"at":1786620000478}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":1,"type":"delegated_result","state":"completed","exitCode":0,"conclusion":"I’ll perform a read-only review of the full uncommitted diff, then trace the daemon bootstrap lifecycle through its existing implementation and focused tests. I’ll avoid edits and the full test suite, and report only material, reproducible issues—or `PASS`.The repaired state preservation path is present, and the focused tests cover the two accepted lifecycle fixes. I’m now checking concurrency boundaries beneath `deploy()`—especially whether `ReleaseGroup.start()` and process/service startup remain safely stoppable when shutdown overlaps activation—and validating persistence shape compatibility.- High — [src/daemon.js:747](/home/dev/rollbridge/src/daemon.js:747): shutdown snapshots `services`, `singletons`, and `startingReleases` only once, while the concurrent bootstrap deployment can continue creating processes. `ReleaseGroup.start()` has no cancellation check inside its process/replica loop ([src/release-group.js:73](/home/dev/rollbridge/src/release-group.js:73)), and singleton replacement similarly continues after each await ([src/daemon.js:539](/home/dev/rollbridge/src/daemon.js:539)). If SIGTERM arrives while an early service, companion replica, or singleton is starting/stopping, shutdown may finish its snapshot while deployment starts a later process. The signal handler then calls `process.exit(0)` ([src/cli.js:44](/home/dev/rollbridge/src/cli.js:44)), potentially leaving that detached child running and unrecorded. The new signal test only uses one release process and signals during its health wait, so it does not cover this realistic multi-process race.","continuationHandle":"019ffad7-bed8-7861-911f-83cd5de11373"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
probe\n
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Rollbridge daemon bootstrap plan
|
|
2
|
+
|
|
3
|
+
1. Add focused CLI/daemon bootstrap tests first, covering all-or-nothing and safe absolute input validation before binding, exact activation through the normal deploy path, structured failure with attempt-owned cleanup and non-zero exit, unchanged plain-daemon startup, and preservation/reporting of live `statePath` orphans.
|
|
4
|
+
2. Run the new focused test file alone and record the expected RED result before production changes.
|
|
5
|
+
3. Add the minimal foreground daemon bootstrap options and validation, then start listeners and call the existing `RollbridgeDaemon.deploy()` path; on activation failure, log a safe structured failure, shut down only objects owned by this attempt, and propagate failure.
|
|
6
|
+
4. Update the CLI reference and README daemon usage, then run the changed focused test files individually, `npm run lint`, and `npm run typecheck`.
|
|
7
|
+
5. Inspect the final diff/status and leave every change uncommitted for independent verification.
|
|
8
|
+
|
|
9
|
+
Scope note: bootstrap requires explicit `--release-path`, `--release-id`, and `--revision` together with an explicit `--config`; values must be non-empty safe tokens where applicable and paths must be absolute, normalized, and usable. No orphan recovery or process killing is part of bootstrap.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{"type":"controller-started","pid":23,"at":1786619544825}
|
|
2
|
+
{"type":"provider-started","provider":"codex","pid":34,"at":1786619544829}
|
|
3
|
+
{"type":"session-available","provider":"codex","sessionId":"019ffad2-c71a-7522-9645-c45214c2ba90","at":1786619545505}
|
|
4
|
+
{"type":"activity","provider":"codex","kind":"lifecycle","at":1786619545506}
|
|
5
|
+
{"type":"activity","provider":"codex","kind":"tool","at":1786619552059}
|
|
6
|
+
{"type":"activity","provider":"codex","kind":"tool","at":1786619560395}
|
|
7
|
+
{"type":"activity","provider":"codex","kind":"delta","at":1786619567749}
|
|
8
|
+
{"type":"activity","provider":"codex","kind":"tool","at":1786619582034}
|
|
9
|
+
{"type":"activity","provider":"codex","kind":"delta","at":1786619609721}
|
|
10
|
+
{"type":"activity","provider":"codex","kind":"delta","at":1786619632612}
|
|
11
|
+
{"type":"activity","provider":"codex","kind":"tool","at":1786619657491}
|
|
12
|
+
{"type":"activity","provider":"codex","kind":"delta","at":1786619692967}
|
|
13
|
+
{"type":"activity","provider":"codex","kind":"tool","at":1786619698926}
|
|
14
|
+
{"type":"activity","provider":"codex","kind":"tool","at":1786619706811}
|
|
15
|
+
{"type":"activity","provider":"codex","kind":"tool","at":1786619724983}
|
|
16
|
+
{"type":"activity","provider":"codex","kind":"delta","at":1786619730097}
|
|
17
|
+
{"type":"activity","provider":"codex","kind":"tool","at":1786619736678}
|
|
18
|
+
{"type":"activity","provider":"codex","kind":"delta","at":1786619742798}
|
|
19
|
+
{"type":"activity","provider":"codex","kind":"tool","at":1786619748670}
|
|
20
|
+
{"type":"activity","provider":"codex","kind":"tool","at":1786619754672}
|
|
21
|
+
{"type":"activity","provider":"codex","kind":"tool","at":1786619764194}
|
|
22
|
+
{"type":"activity","provider":"codex","kind":"delta","at":1786619775649}
|
|
23
|
+
{"type":"activity","provider":"codex","kind":"tool","at":1786619780713}
|
|
24
|
+
{"type":"activity","provider":"codex","kind":"delta","at":1786619786176}
|
|
25
|
+
{"type":"terminal","provider":"codex","state":"completed","exitCode":0,"at":1786619829038}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":1,"type":"delegated_result","state":"completed","exitCode":0,"conclusion":"I’ll resume in place and preserve the current worktree. I’ll first inspect the branch, repository guidance, lifecycle code, and existing tests; then I’ll add only focused deterministic regressions and capture their expected failures before touching production code.The root conflict is confirmed: startup reads prior live PIDs into an advisory in-memory list, but the first persistence snapshot deliberately drops that list, and shutdown then unconditionally removes the file. I’m going to make the regressions assert two boundaries directly: SIGTERM while bootstrap activation is provably in-flight, and recoverable prior PID records after bootstrap persistence plus shutdown/failure.The focused regressions are now in place without timing sleeps: the signal test gates on the daemon’s own “control socket listening” milestone, and persistence checks decode the actual state store through `liveProcesses`. I’m running only the bootstrap test file now to record the RED failures before production edits.RED is confirmed with exactly the three expected failures: bootstrap SIGTERM exits by signal instead of code 0, and both successful-shutdown and failed-activation paths lose the prior PID record. I’m now making the minimal ownership changes: register signal handling before activation, track the in-progress release as daemon-owned for shutdown, and carry advisory orphan records through snapshots and clean shutdown.The focused file is GREEN. I’m tightening the signal regression before final checks so it proves the managed child was actually started and then stopped—not just that the CLI intercepted SIGTERM early. The test will use a filesystem watch/marker handshake from the existing fixture, still with no sleeps or polling.The strengthened regression is GREEN and confirms the exact started PID received the daemon’s graceful stop before the CLI exited. The focused persistence regressions also remain GREEN. I’m moving to the requested static checks now, without running the full suite.`npm run lint` reached its bundled typecheck and found one narrow test typing issue: the async iterator’s optional `return` method. I’ve corrected that optional call and will re-run the focused test before repeating the requested checks.All requested test and static checks are now passing. I made one small documentation clarification because the durability behavior is operator-visible: live advisory orphan records remain in `statePath` for explicit recovery. I’m doing the final whitespace, diff, branch, and worktree inspection now; I will leave the existing `tmp/` and all unrelated candidate changes untouched.Implemented both HIGH lifecycle fixes on `feature/daemon-bootstrap-release`:\n\n- SIGINT/SIGTERM ownership is installed before bootstrap activation.\n- In-progress releases are daemon-owned and stopped during shutdown.\n- Prior-daemon orphan PID records remain persisted across snapshots, successful shutdown, and failed bootstrap activation.\n- Added deterministic regressions using explicit process/file handshakes—no sleeps or retries.\n- Updated documentation to describe recoverable orphan records.\n\nRED was confirmed with three expected failures before production edits. Final checks:\n\n- `node --test test/daemon-bootstrap.test.js` — 12 passed\n- `npm run lint` — passed\n- `npm run typecheck` — passed\n- `git diff --check` — passed\n\nFinal branch/status inspected. No full suite, commit, push, or deploy performed. Existing unrelated changes and `tmp/` remain untouched.","continuationHandle":"019ffad2-c71a-7522-9645-c45214c2ba90"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":1,"type":"delegated_result","state":"failed","exitCode":2,"continuationHandle":"019ffac6-1176-7023-8f14-82bae745817f"}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{"type":"controller-started","pid":23,"at":1786619089604}
|
|
2
|
+
{"type":"provider-started","provider":"codex","pid":34,"at":1786619089609}
|
|
3
|
+
{"type":"session-available","provider":"codex","sessionId":"019ffacb-d3c0-7f93-affa-c472258a1b8b","at":1786619089955}
|
|
4
|
+
{"type":"activity","provider":"codex","kind":"lifecycle","at":1786619089955}
|
|
5
|
+
{"type":"activity","provider":"codex","kind":"tool","at":1786619096716}
|
|
6
|
+
{"type":"activity","provider":"codex","kind":"tool","at":1786619104649}
|
|
7
|
+
{"type":"activity","provider":"codex","kind":"tool","at":1786619110897}
|
|
8
|
+
{"type":"activity","provider":"codex","kind":"tool","at":1786619130819}
|
|
9
|
+
{"type":"activity","provider":"codex","kind":"delta","at":1786619140752}
|
|
10
|
+
{"type":"activity","provider":"codex","kind":"tool","at":1786619146978}
|
|
11
|
+
{"type":"activity","provider":"codex","kind":"tool","at":1786619159062}
|
|
12
|
+
{"type":"activity","provider":"codex","kind":"tool","at":1786619164311}
|
|
13
|
+
{"type":"activity","provider":"codex","kind":"delta","at":1786619179382}
|
|
14
|
+
{"type":"terminal","provider":"codex","state":"completed","exitCode":0,"at":1786619221045}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":1,"type":"delegated_result","state":"completed","exitCode":0,"conclusion":"I’ll review the exact working-tree candidate read-only, first inventorying repository instructions and the complete diff, then tracing the affected daemon/deploy/state/recovery paths and targeted tests. I won’t modify anything or run the full suite.The candidate is small and localized, but its safety claims depend heavily on existing daemon startup and deploy cleanup behavior. I’m tracing those internals now, including persisted-state loading and signal/exit handling, and will run only focused tests relevant to this interface.Two lifecycle interactions warrant direct verification: interruption during a slow bootstrap, and what remains recoverable in `statePath` when live prior-daemon processes were only reported. I’m checking those against focused existing/new tests and source behavior; no repository files will be changed.- High — [src/cli.js](/home/dev/rollbridge/src/cli.js:42): SIGINT/SIGTERM handlers are installed only after `daemon.deploy()` completes at line 46. A service-manager stop or restart during process startup or a long health check therefore performs Node’s default termination instead of `daemon.shutdown()`. Managed children are detached ([src/managed-process.js](/home/dev/rollbridge/src/managed-process.js:86)), so they can survive while the proxy/control socket and state are left unclean. This contradicts the documented “normal signal behavior” and is not covered by the bootstrap tests.\n\n- High — [src/daemon.js](/home/dev/rollbridge/src/daemon.js:677): After prior-daemon processes are read into the in-memory orphan list, startup immediately persists a new snapshot that deliberately excludes those orphans (lines 691–700), overwriting their only durable PID records. A later clean shutdown also deletes `statePath` (lines 750–755). Thus a live prior-daemon process is not killed, but becomes unrecoverable through `rollbridge recover` once bootstrap starts—or immediately when failed activation invokes shutdown. The test at [test/daemon-bootstrap.test.js](/home/dev/rollbridge/test/daemon-bootstrap.test.js:108) checks only process liveness and in-memory status, never preservation of the persisted orphan records. This conflicts with the documentation that previous `statePath` entries remain advisory orphans.","continuationHandle":"019ffacb-d3c0-7f93-affa-c472258a1b8b"}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{"type":"controller-started","pid":23,"at":1786620086602}
|
|
2
|
+
{"type":"provider-started","provider":"codex","pid":34,"at":1786620086605}
|
|
3
|
+
{"type":"session-available","provider":"codex","sessionId":"019ffadb-09f5-7460-bcd7-0db1e74e9935","at":1786620086862}
|
|
4
|
+
{"type":"activity","provider":"codex","kind":"lifecycle","at":1786620086862}
|
|
5
|
+
{"type":"activity","provider":"codex","kind":"delta","at":1786620091884}
|
|
6
|
+
{"type":"activity","provider":"codex","kind":"tool","at":1786620098162}
|
|
7
|
+
{"type":"activity","provider":"codex","kind":"tool","at":1786620108101}
|
|
8
|
+
{"type":"activity","provider":"codex","kind":"tool","at":1786620130450}
|
|
9
|
+
{"type":"activity","provider":"codex","kind":"delta","at":1786620181139}
|
|
10
|
+
{"type":"activity","provider":"codex","kind":"tool","at":1786620214551}
|
|
11
|
+
{"type":"activity","provider":"codex","kind":"tool","at":1786620233438}
|
|
12
|
+
{"type":"activity","provider":"codex","kind":"tool","at":1786620296843}
|
|
13
|
+
{"type":"activity","provider":"codex","kind":"delta","at":1786620316811}
|
|
14
|
+
{"type":"activity","provider":"codex","kind":"tool","at":1786620326537}
|
|
15
|
+
{"type":"activity","provider":"codex","kind":"tool","at":1786620349522}
|
|
16
|
+
{"type":"activity","provider":"codex","kind":"delta","at":1786620369610}
|
|
17
|
+
{"type":"activity","provider":"codex","kind":"tool","at":1786620386448}
|
|
18
|
+
{"type":"activity","provider":"codex","kind":"tool","at":1786620401793}
|
|
19
|
+
{"type":"activity","provider":"codex","kind":"tool","at":1786620419491}
|
|
20
|
+
{"type":"activity","provider":"codex","kind":"tool","at":1786620428258}
|
|
21
|
+
{"type":"activity","provider":"codex","kind":"tool","at":1786620467781}
|
|
22
|
+
{"type":"activity","provider":"codex","kind":"delta","at":1786620478524}
|
|
23
|
+
{"type":"activity","provider":"codex","kind":"tool","at":1786620514191}
|
|
24
|
+
{"type":"activity","provider":"codex","kind":"tool","at":1786620519855}
|
|
25
|
+
{"type":"activity","provider":"codex","kind":"tool","at":1786620542628}
|
|
26
|
+
{"type":"activity","provider":"codex","kind":"tool","at":1786620597146}
|
|
27
|
+
{"type":"activity","provider":"codex","kind":"tool","at":1786620603744}
|
|
28
|
+
{"type":"activity","provider":"codex","kind":"tool","at":1786620610390}
|
|
29
|
+
{"type":"activity","provider":"codex","kind":"tool","at":1786620615895}
|
|
30
|
+
{"type":"activity","provider":"codex","kind":"tool","at":1786620629789}
|
|
31
|
+
{"type":"activity","provider":"codex","kind":"tool","at":1786620635487}
|
|
32
|
+
{"type":"activity","provider":"codex","kind":"delta","at":1786620645508}
|
|
33
|
+
{"type":"activity","provider":"codex","kind":"tool","at":1786620651900}
|
|
34
|
+
{"type":"activity","provider":"codex","kind":"tool","at":1786620657807}
|
|
35
|
+
{"type":"activity","provider":"codex","kind":"tool","at":1786620663562}
|
|
36
|
+
{"type":"activity","provider":"codex","kind":"tool","at":1786620670250}
|
|
37
|
+
{"type":"activity","provider":"codex","kind":"delta","at":1786620682665}
|
|
38
|
+
{"type":"terminal","provider":"codex","state":"completed","exitCode":0,"at":1786620740752}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":1,"type":"delegated_result","state":"completed","exitCode":0,"conclusion":"I’ll focus only on the SIGTERM/bootstrap lifecycle race, preserve the current candidate changes, and avoid commits, pushes, deploys, the full suite, and any independent review. I’ll first inspect the existing daemon lifecycle/tests, add a handshake-driven regression, and run it alone to capture the required RED before touching production code.The race boundary is in three sequential creation loops: daemon-wide services, release-owned process/replica startup, and singleton replacement. I’m adding the regression at the CLI boundary with the first service command explicitly recording a “shutdown trigger” event and sending SIGTERM to the daemon itself; later service/replica commands record their PIDs, and every managed child records graceful stop. This avoids health-wait timing and gives an event-ordered proof without sleeps or polling.The focused regression is now a genuine RED for the accepted race: after the shutdown event, both worker replicas start, the daemon exits with code 0, and neither replica records a graceful stop. I’m implementing the narrow coordination boundary now: shutdown will wait for already-admitted deploy ownership to settle, then perform a final ownership sweep; failed/interrupted deploy cleanup will also stop its release explicitly. Prior-daemon advisory orphans remain outside these owned collections.The first fix run exposed a test-handshake issue rather than a lifecycle result: the fixture’s “started” record was emitted before its SIGTERM handler was installed, so a correctly stopped child could die without writing the stop acknowledgment. I moved that readiness event to the server-listening callback. The rerun is still active, so I’m checking whether the FIFO release handshake itself is now blocked before changing any production logic further.The repaired regression is GREEN and now checks the exact two required properties for every post-shutdown-start PID: it appears in the daemon’s managed-process records, and it is no longer alive before the signal handler exits. The fix does not adopt or sweep persisted orphan PIDs; it waits only for deploy promises admitted by this daemon and re-sweeps only its service/singleton/release ownership maps. I’m running the focused bootstrap file, then the directly affected daemon/release lifecycle files only.The full focused bootstrap file revealed an important compatibility constraint: waiting for the entire deploy promise also waits out an existing 60-second health check, delaying SIGTERM shutdown. I’m narrowing the fix to cancellation at every creation boundary instead. Because JavaScript cannot interleave between the cancellation check and registering a new owned process, shutdown’s snapshot will either own the current start or the loop will abort before creating the next one; health waits may remain in flight, but cannot create anything afterward.All relevant focused files are passing: the bootstrap integration file, release-group unit file, and daemon lifecycle integration file. Normal deploy, replicas, services, singletons, failed startup cleanup, shutdown persistence, and prior-orphan reporting remain green. I’m running the requested static checks now, then I’ll inspect the exact diff and worktree status without any further review pass.`npm run lint` found one narrow JSDoc-only issue in the new handshake helper: this repository disallows `unknown` there and requires its `JsonValue` type. I’m correcting that annotation, then I’ll rerun the focused bootstrap file before repeating lint and the remaining requested checks.Implemented the terminal SIGTERM/bootstrap race repair without commit, push, deploy, full suite, or independent review.\n\nChanges:\n\n- Added cancellation checks before each service, release process/replica, and singleton creation boundary.\n- Registered in-progress releases as daemon-owned during bootstrap.\n- Interrupted deploy cleanup explicitly stops only its release and newly started services.\n- Prior-daemon advisory orphans remain untouched and recoverable.\n- Added a deterministic FIFO/event-handshake regression proving post-shutdown-start processes are recorded and dead before daemon exit.\n\nValidation:\n\n- Required RED confirmed: two worker replicas started after shutdown and survived unrecorded.\n- `node --test test/daemon-bootstrap.test.js` — 13 passed\n- `node --test test/release-group.test.js` — 3 passed\n- `node --test test/rollbridge.test.js` — 39 passed\n- `npm run lint` — passed\n- `npm run typecheck` — passed\n- `git diff --check` — passed\n\nThe first lint run found one test JSDoc type error; corrected and rerun successfully. Final status/diff inspected. Existing candidate changes and `tmp/` remain preserved and uncommitted.","continuationHandle":"019ffadb-09f5-7460-bcd7-0db1e74e9935"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":1,"type":"delegated_result","state":"failed","exitCode":2,"continuationHandle":"019ffad2-c71a-7522-9645-c45214c2ba90"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":1,"type":"delegated_result","state":"failed","exitCode":2}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":1,"type":"delegated_result","state":"completed","exitCode":0,"conclusion":"I’ll inspect the repository guidance, daemon/CLI/control activation flow, and focused tests first. I’ll then create the feature branch and operational plan before making production edits, with the required RED test run recorded.The core path is clear: control-socket `deploy` already funnels through `RollbridgeDaemon.deploy()`, which owns service startup, release health, traffic switching, template refresh, singleton replacement, and failed-attempt cleanup. I’m narrowing the bootstrap work to CLI validation/orchestration around that same method, plus tests for early rejection, successful foreground activation, failure cleanup, and `statePath` orphan safety.I’ve created `feature/daemon-bootstrap-release` and written the scoped operational plan. The interface is intentionally all-or-nothing: bootstrap mode uses explicit `--config`, `--release-path`, `--release-id`, and `--revision`; it does not invoke orphan recovery or kill anything from persisted state. Next I’m adding the focused integration tests before touching production code.The first RED run failed before exercising the feature because dependencies are not installed (`ERR_MODULE_NOT_FOUND: commander`). I’m installing the locked dependencies, then I’ll rerun the same focused file to obtain a genuine missing-behavior RED before production edits.The focused bootstrap tests are now green after the minimal CLI orchestration change, including exact metadata activation, failed health-check cleanup/non-zero exit, and live orphan preservation. I’m adding the explicit plain-daemon regression case and documenting the new foreground interface; no changelog exists in this repository, so I’m following its README/CLI-reference convention.The focused file passes all eight assertions. The first lint run found only test-file style issues (four `no-useless-escape` errors plus JSDoc warnings); I’ve corrected those without changing behavior and am rerunning the focused test and required static checks.All required checks now pass. I’m doing the final diff/status audit for accidental generated or unrelated changes, confirming the branch and uncommitted state, and reviewing the bootstrap failure/shutdown path one last time against attempt ownership and orphan safety.Implemented on new branch `feature/daemon-bootstrap-release`; all changes remain uncommitted.\n\nKey changes:\n\n- Added all-or-nothing daemon bootstrap flags: `--config`, `--release-path`, `--release-id`, and `--revision`.\n- Validates inputs before listeners/processes start.\n- Uses the existing `RollbridgeDaemon.deploy()` activation path.\n- Emits structured bootstrap failure, cleans attempt-owned processes, and exits non-zero.\n- Preserves plain daemon behavior and does not kill `statePath` orphans.\n- Added focused integration coverage and updated README/CLI documentation.\n- Added the requested untracked [plan.md](/home/dev/rollbridge/tmp/worker-control/rollbridge-bootstrap/plan.md).\n\nValidation:\n\n- RED: `node --test test/daemon-bootstrap.test.js` — failed because daemon bootstrap options were unknown.\n- Final: `node --test test/daemon-bootstrap.test.js` — 10 passed, 0 failed.\n- `npm run lint` — passed.\n- `npm run typecheck` — passed.\n- `git diff --check` — passed.\n- Full test suite was not run, as requested.\n\nModified/untracked implementation files:\n\n- [src/cli.js](/home/dev/rollbridge/src/cli.js)\n- [test/daemon-bootstrap.test.js](/home/dev/rollbridge/test/daemon-bootstrap.test.js)\n- [README.md](/home/dev/rollbridge/README.md)\n- [docs/cli.md](/home/dev/rollbridge/docs/cli.md)\n\nNo commit, push, publish, deploy, or live-service modification was performed.","continuationHandle":"019ffac6-1176-7023-8f14-82bae745817f"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":1,"type":"delegated_result","state":"failed","exitCode":2}
|