rollbridge 0.1.13 → 0.1.15

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.
Files changed (33) hide show
  1. package/README.md +6 -5
  2. package/compose.yml +17 -0
  3. package/docs/cli.md +28 -8
  4. package/docs/troubleshooting.md +18 -0
  5. package/package.json +1 -1
  6. package/src/cli.js +57 -18
  7. package/src/daemon-runtime.js +316 -0
  8. package/src/daemon.js +25 -4
  9. package/test/completion.test.js +1 -0
  10. package/test/daemon-bootstrap.test.js +40 -7
  11. package/test/daemon-runtime.test.js +90 -0
  12. package/test/fixtures/dummy-app.js +1 -1
  13. package/test/package-metadata.test.js +39 -0
  14. package/test/release-runtime-retention.test.js +357 -0
  15. package/test/rollbridge.test.js +3 -1
  16. package/tmp/worker-control/rollbridge-bootstrap/activity-3.jsonl +0 -28
  17. package/tmp/worker-control/rollbridge-bootstrap/current-head-review-activity.jsonl +0 -13
  18. package/tmp/worker-control/rollbridge-bootstrap/current-head-review-transcript.jsonl +0 -1
  19. package/tmp/worker-control/rollbridge-bootstrap/fs-probe-10.jsonl +0 -1
  20. package/tmp/worker-control/rollbridge-bootstrap/plan.md +0 -9
  21. package/tmp/worker-control/rollbridge-bootstrap/repair-activity.jsonl +0 -3
  22. package/tmp/worker-control/rollbridge-bootstrap/repair-fresh-activity.jsonl +0 -25
  23. package/tmp/worker-control/rollbridge-bootstrap/repair-fresh-transcript.jsonl +0 -1
  24. package/tmp/worker-control/rollbridge-bootstrap/repair-transcript.jsonl +0 -1
  25. package/tmp/worker-control/rollbridge-bootstrap/review-activity.jsonl +0 -14
  26. package/tmp/worker-control/rollbridge-bootstrap/review-transcript.jsonl +0 -1
  27. package/tmp/worker-control/rollbridge-bootstrap/terminal-repair-activity.jsonl +0 -3
  28. package/tmp/worker-control/rollbridge-bootstrap/terminal-repair-fresh-activity.jsonl +0 -38
  29. package/tmp/worker-control/rollbridge-bootstrap/terminal-repair-fresh-transcript.jsonl +0 -1
  30. package/tmp/worker-control/rollbridge-bootstrap/terminal-repair-transcript.jsonl +0 -1
  31. package/tmp/worker-control/rollbridge-bootstrap/transcript-2.jsonl +0 -1
  32. package/tmp/worker-control/rollbridge-bootstrap/transcript-3.jsonl +0 -1
  33. package/tmp/worker-control/rollbridge-bootstrap/transcript.jsonl +0 -1
@@ -0,0 +1,357 @@
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 net from "node:net"
8
+ import os from "node:os"
9
+ import path from "node:path"
10
+ import test from "node:test"
11
+ import {fileURLToPath} from "node:url"
12
+ import {sendControlCommand} from "../src/control-client.js"
13
+
14
+ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
15
+ const dummyAppPath = path.join(repoRoot, "test", "fixtures", "dummy-app.js")
16
+
17
+ test("detached daemon survives deletion of the release-local Rollbridge installation", async () => {
18
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-runtime-retention-"))
19
+ const releaseA = path.join(root, "releases", "A")
20
+ const releaseB = path.join(root, "releases", "B")
21
+ const socketPath = path.join(root, "control.sock")
22
+ const configPath = path.join(root, "rollbridge.js")
23
+ const logPath = path.join(root, "daemon.log")
24
+ const pidPath = path.join(root, "daemon.pid")
25
+ const runtimePath = path.join(root, "runtime")
26
+
27
+ try {
28
+ await Promise.all([prepareRelease(releaseA, true), prepareRelease(releaseB, true)])
29
+ await fs.writeFile(configPath, `export default ${JSON.stringify({
30
+ application: "runtime-retention-test",
31
+ control: {path: socketPath},
32
+ processes: [{
33
+ command: `${JSON.stringify(process.execPath)} ${JSON.stringify(dummyAppPath)}`,
34
+ health: {intervalMs: 25, path: "/ping", timeoutMs: 3000},
35
+ id: "web",
36
+ policy: "proxied",
37
+ port: {from: 0, to: 0}
38
+ }],
39
+ proxy: {drainTimeoutMs: 100, forceStopTimeoutMs: 100, host: "127.0.0.1", port: 0}
40
+ }, null, 2)}\n`)
41
+
42
+ await runReleaseCli(releaseA, [
43
+ "deploy", "--ensure-daemon", "--config", configPath,
44
+ "--release-path", releaseA, "--release-id", "A",
45
+ "--daemon-log-path", logPath, "--daemon-pid-path", pidPath,
46
+ "--daemon-runtime-path", runtimePath
47
+ ])
48
+ await runReleaseCli(releaseB, [
49
+ "deploy", "--ensure-daemon", "--config", configPath,
50
+ "--release-path", releaseB, "--release-id", "B",
51
+ "--daemon-log-path", logPath, "--daemon-pid-path", pidPath,
52
+ "--daemon-runtime-path", runtimePath
53
+ ])
54
+
55
+ await fs.rm(path.join(releaseA, "node_modules"), {recursive: true})
56
+
57
+ const status = await sendControlCommand({command: {command: "status"}, path: socketPath})
58
+ const proxyPort = /** @type {{port: number}} */ (status.proxy).port
59
+ const response = await fetch(`http://127.0.0.1:${proxyPort}/deferred-runtime`)
60
+ const runtime = /** @type {{digest: string, format: number, path: string, version: string}} */ (status.daemonRuntime)
61
+
62
+ assert.equal(status.activeReleaseId, "B")
63
+ assert.equal(runtime.format, 1)
64
+ assert.match(runtime.digest, /^[a-f0-9]{64}$/)
65
+ assert.equal(path.dirname(runtime.path), runtimePath)
66
+ assert.ok(!runtime.path.startsWith(releaseA), `runtime must be outside release A: ${runtime.path}`)
67
+ assert.equal(response.status, 200)
68
+ assert.equal(await response.text(), "deferred runtime loaded\n")
69
+ } finally {
70
+ try {
71
+ await sendControlCommand({command: {command: "shutdown"}, path: socketPath})
72
+ } catch {
73
+ const pid = Number.parseInt(await fs.readFile(pidPath, "utf8").catch(() => ""), 10)
74
+ if (Number.isInteger(pid)) process.kill(pid, "SIGKILL")
75
+ }
76
+
77
+ await fs.rm(root, {force: true, recursive: true})
78
+ }
79
+ })
80
+
81
+ test("runtime preparation failure prevents daemon startup and deploy handoff", async () => {
82
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-runtime-failure-"))
83
+ const release = path.join(root, "release")
84
+ const socketPath = path.join(root, "control.sock")
85
+ const configPath = path.join(root, "rollbridge.js")
86
+ const invalidRuntimePath = path.join(root, "not-a-directory")
87
+
88
+ try {
89
+ await prepareRelease(release, false)
90
+ await fs.writeFile(invalidRuntimePath, "occupied\n")
91
+ await fs.writeFile(configPath, `export default ${JSON.stringify(basicConfig(socketPath), null, 2)}\n`)
92
+
93
+ await assert.rejects(
94
+ () => runReleaseCli(release, [
95
+ "deploy", "--ensure-daemon", "--config", configPath,
96
+ "--release-path", release, "--release-id", "blocked",
97
+ "--daemon-runtime-path", invalidRuntimePath
98
+ ]),
99
+ /EEXIST|not a directory|ENOTDIR/
100
+ )
101
+ await assert.rejects(() => fs.stat(socketPath), {code: "ENOENT"})
102
+ } finally {
103
+ await fs.rm(root, {force: true, recursive: true})
104
+ }
105
+ })
106
+
107
+ test("ensure-daemon refuses a responsive legacy daemon before deploy", async () => {
108
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-runtime-legacy-"))
109
+ const release = path.join(root, "release")
110
+ const socketPath = path.join(root, "control.sock")
111
+ const configPath = path.join(root, "rollbridge.js")
112
+ let deployReceived = false
113
+ const legacyDaemon = net.createServer((socket) => {
114
+ socket.setEncoding("utf8")
115
+ socket.on("data", (contents) => {
116
+ const command = JSON.parse(String(contents).trim())
117
+
118
+ if (command.command === "deploy") deployReceived = true
119
+ socket.end(`${JSON.stringify({activeReleaseId: null, application: "runtime-retention-test", releases: []})}\n`)
120
+ })
121
+ })
122
+
123
+ try {
124
+ await prepareRelease(release, false)
125
+ await fs.writeFile(configPath, `export default ${JSON.stringify(basicConfig(socketPath), null, 2)}\n`)
126
+ await new Promise((resolve, reject) => {
127
+ legacyDaemon.once("error", reject)
128
+ legacyDaemon.listen(socketPath, () => resolve(undefined))
129
+ })
130
+
131
+ await assert.rejects(
132
+ () => runReleaseCli(release, [
133
+ "deploy", "--ensure-daemon", "--config", configPath,
134
+ "--release-path", release, "--release-id", "must-not-deploy",
135
+ "--daemon-runtime-path", path.join(root, "runtime")
136
+ ]),
137
+ /legacy or mismatched runtime.*deploy was not sent/s
138
+ )
139
+
140
+ assert.equal(deployReceived, false)
141
+ } finally {
142
+ await new Promise((resolve) => legacyDaemon.close(resolve))
143
+ await fs.rm(root, {force: true, recursive: true})
144
+ }
145
+ })
146
+
147
+ test("ensure-daemon refuses a mismatched runtime attestation before deploy", async () => {
148
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-runtime-mismatch-"))
149
+ const release = path.join(root, "release")
150
+ const socketPath = path.join(root, "control.sock")
151
+ const configPath = path.join(root, "rollbridge.js")
152
+ let deployReceived = false
153
+ const daemon = net.createServer((socket) => {
154
+ socket.setEncoding("utf8")
155
+ socket.on("data", (contents) => {
156
+ const command = JSON.parse(String(contents).trim())
157
+
158
+ if (command.command === "deploy") deployReceived = true
159
+ socket.end(`${JSON.stringify({
160
+ activeReleaseId: "current",
161
+ application: "runtime-retention-test",
162
+ daemonRuntime: {digest: "0".repeat(64), format: 1, path: "/durable/runtime", version: "0.1.14"},
163
+ releases: []
164
+ })}\n`)
165
+ })
166
+ })
167
+
168
+ try {
169
+ await prepareRelease(release, false)
170
+ await fs.writeFile(configPath, `export default ${JSON.stringify(basicConfig(socketPath), null, 2)}\n`)
171
+ await new Promise((resolve, reject) => {
172
+ daemon.once("error", reject)
173
+ daemon.listen(socketPath, () => resolve(undefined))
174
+ })
175
+
176
+ await assert.rejects(
177
+ () => runReleaseCli(release, [
178
+ "deploy", "--ensure-daemon", "--config", configPath,
179
+ "--release-path", release, "--release-id", "must-not-deploy",
180
+ "--daemon-runtime-path", path.join(root, "runtime")
181
+ ]),
182
+ /legacy or mismatched runtime.*deploy was not sent/s
183
+ )
184
+ assert.equal(deployReceived, false)
185
+ } finally {
186
+ await new Promise((resolve) => daemon.close(resolve))
187
+ await fs.rm(root, {force: true, recursive: true})
188
+ }
189
+ })
190
+
191
+ test("concurrent startup loser re-attests the winner before sending deploy", async () => {
192
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-runtime-start-race-"))
193
+ const winnerRelease = path.join(root, "releases", "winner")
194
+ const loserRelease = path.join(root, "releases", "loser")
195
+ const socketPath = path.join(root, "control.sock")
196
+ const configPath = path.join(root, "rollbridge.js")
197
+ const loserPausedPath = path.join(root, "loser-paused")
198
+ const winnerPidPath = path.join(root, "winner.pid")
199
+ const loserPidPath = path.join(root, "loser.pid")
200
+
201
+ try {
202
+ await Promise.all([prepareRelease(winnerRelease, false), prepareRelease(loserRelease, false)])
203
+ await installStartupPause(loserRelease, loserPausedPath)
204
+ await fs.writeFile(configPath, `export default ${JSON.stringify({
205
+ application: "runtime-start-race-test",
206
+ control: {path: socketPath},
207
+ processes: [{
208
+ command: `${JSON.stringify(process.execPath)} ${JSON.stringify(dummyAppPath)}`,
209
+ health: {intervalMs: 25, path: "/ping", timeoutMs: 3000},
210
+ id: "web",
211
+ policy: "proxied",
212
+ port: {from: 0, to: 0}
213
+ }],
214
+ proxy: {drainTimeoutMs: 100, forceStopTimeoutMs: 100, host: "127.0.0.1", port: 0}
215
+ }, null, 2)}\n`)
216
+
217
+ const loserDeploy = runReleaseCli(loserRelease, [
218
+ "deploy", "--ensure-daemon", "--config", configPath,
219
+ "--release-path", loserRelease, "--release-id", "loser",
220
+ "--daemon-log-path", path.join(root, "loser.log"), "--daemon-pid-path", loserPidPath,
221
+ "--daemon-runtime-path", path.join(root, "loser-runtime")
222
+ ])
223
+
224
+ await waitForFile(loserPausedPath)
225
+ await runReleaseCli(winnerRelease, [
226
+ "deploy", "--ensure-daemon", "--config", configPath,
227
+ "--release-path", winnerRelease, "--release-id", "winner",
228
+ "--daemon-log-path", path.join(root, "winner.log"), "--daemon-pid-path", winnerPidPath,
229
+ "--daemon-runtime-path", path.join(root, "winner-runtime")
230
+ ])
231
+
232
+ await assert.rejects(loserDeploy, /legacy or mismatched runtime.*deploy was not sent/s)
233
+ const status = await sendControlCommand({command: {command: "status"}, path: socketPath})
234
+
235
+ assert.equal(status.activeReleaseId, "winner")
236
+ } finally {
237
+ try {
238
+ await sendControlCommand({command: {command: "shutdown"}, path: socketPath})
239
+ } catch {
240
+ // The winning daemon may have failed before accepting commands.
241
+ }
242
+
243
+ for (const pidPath of [winnerPidPath, loserPidPath]) {
244
+ const pid = Number.parseInt(await fs.readFile(pidPath, "utf8").catch(() => ""), 10)
245
+
246
+ if (Number.isInteger(pid)) {
247
+ try { process.kill(pid, "SIGKILL") } catch { /* The process already exited. */ }
248
+ }
249
+ }
250
+
251
+ await fs.rm(root, {force: true, recursive: true})
252
+ }
253
+ })
254
+
255
+ /**
256
+ * Creates a release-local Rollbridge package with production dependencies and,
257
+ * for release A, a daemon module that performs one deliberately deferred import.
258
+ * @param {string} releasePath - Release directory.
259
+ * @param {boolean} deferredImport - Whether to add the deferred daemon route.
260
+ * @returns {Promise<void>} Resolves when prepared.
261
+ */
262
+ async function prepareRelease(releasePath, deferredImport) {
263
+ const packagePath = path.join(releasePath, "node_modules", "rollbridge")
264
+
265
+ await fs.mkdir(path.dirname(packagePath), {recursive: true})
266
+ await fs.cp(repoRoot, packagePath, {
267
+ filter: (source) => {
268
+ const relative = path.relative(repoRoot, source)
269
+
270
+ return relative !== ".git" && relative !== "node_modules" && relative !== "test" && relative !== "tmp"
271
+ },
272
+ recursive: true
273
+ })
274
+
275
+ for (const dependency of ["commander", "eventemitter3", "follow-redirects", "http-proxy", "requires-port"]) {
276
+ await fs.symlink(path.join(repoRoot, "node_modules", dependency), path.join(releasePath, "node_modules", dependency), "dir")
277
+ }
278
+
279
+ if (!deferredImport) return
280
+
281
+ const daemonPath = path.join(packagePath, "src", "daemon.js")
282
+ const source = await fs.readFile(daemonPath, "utf8")
283
+ const marker = " proxyHttp(request, response) {\n"
284
+ const deferredRoute = `${marker} if (request.url === "/deferred-runtime") {\n void import("./deferred-runtime.js")\n .then(({default: body}) => { response.writeHead(200); response.end(body) })\n .catch((error) => { response.writeHead(500); response.end(String(error)) })\n return\n }\n\n`
285
+
286
+ assert.ok(source.includes(marker))
287
+ await fs.writeFile(daemonPath, source.replace(marker, deferredRoute))
288
+ await fs.writeFile(path.join(packagePath, "src", "deferred-runtime.js"), "export default \"deferred runtime loaded\\n\"\n")
289
+ }
290
+
291
+ /**
292
+ * Pauses a release after its initial missing-daemon probe so another runtime can win startup.
293
+ * @param {string} releasePath - Release directory.
294
+ * @param {string} pausedPath - Synchronization file.
295
+ * @returns {Promise<void>} Resolves after installing the test hook.
296
+ */
297
+ async function installStartupPause(releasePath, pausedPath) {
298
+ const cliPath = path.join(releasePath, "node_modules", "rollbridge", "src", "cli.js")
299
+ const source = await fs.readFile(cliPath, "utf8")
300
+ const marker = " await startDaemonProcess({\n"
301
+ const pause = ` await fsPromises.writeFile(${JSON.stringify(pausedPath)}, "paused\\n")\n await new Promise((resolve) => setTimeout(resolve, 750))\n\n${marker}`
302
+
303
+ assert.ok(source.includes(marker))
304
+ await fs.writeFile(cliPath, source.replace(marker, pause))
305
+ }
306
+
307
+ /**
308
+ * @param {string} filePath - File to await.
309
+ * @returns {Promise<void>} Resolves when the file exists.
310
+ */
311
+ async function waitForFile(filePath) {
312
+ const deadline = Date.now() + 5000
313
+
314
+ while (Date.now() < deadline) {
315
+ try {
316
+ await fs.stat(filePath)
317
+ return
318
+ } catch (error) {
319
+ if (!error || typeof error !== "object" || !("code" in error) || error.code !== "ENOENT") throw error
320
+ }
321
+
322
+ await new Promise((resolve) => setTimeout(resolve, 20))
323
+ }
324
+
325
+ throw new Error(`Timed out waiting for ${filePath}`)
326
+ }
327
+
328
+ /**
329
+ * @param {string} socketPath - Control socket path.
330
+ * @returns {Record<string, import("../src/json.js").JsonValue>} Minimal config.
331
+ */
332
+ function basicConfig(socketPath) {
333
+ return {
334
+ application: "runtime-retention-test",
335
+ control: {path: socketPath},
336
+ processes: [{command: "true", id: "web", policy: "proxied", port: {from: 0, to: 0}}],
337
+ proxy: {host: "127.0.0.1", port: 0}
338
+ }
339
+ }
340
+
341
+ /**
342
+ * Runs a release-local Rollbridge CLI to completion.
343
+ * @param {string} releasePath - Release directory.
344
+ * @param {string[]} args - CLI arguments.
345
+ * @returns {Promise<void>} Resolves on success.
346
+ */
347
+ async function runReleaseCli(releasePath, args) {
348
+ const binPath = path.join(releasePath, "node_modules", "rollbridge", "bin", "rollbridge")
349
+ const child = spawn(process.execPath, [binPath, ...args], {stdio: ["ignore", "pipe", "pipe"]})
350
+ let output = ""
351
+
352
+ child.stdout.setEncoding("utf8").on("data", (chunk) => { output += chunk })
353
+ child.stderr.setEncoding("utf8").on("data", (chunk) => { output += chunk })
354
+ const [code] = await once(child, "exit")
355
+
356
+ if (code !== 0) throw new Error(output)
357
+ }
@@ -1117,7 +1117,9 @@ test("deploy can ensure the daemon before sending the release command", async ()
1117
1117
  "--daemon-log-path",
1118
1118
  logPath,
1119
1119
  "--daemon-pid-path",
1120
- pidPath
1120
+ pidPath,
1121
+ "--daemon-runtime-path",
1122
+ path.join(fixture.root, "daemon-runtime")
1121
1123
  ])
1122
1124
 
1123
1125
  const status = await sendControlCommand({
@@ -1,28 +0,0 @@
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}
@@ -1,13 +0,0 @@
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}
@@ -1 +0,0 @@
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"}
@@ -1,9 +0,0 @@
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.
@@ -1,3 +0,0 @@
1
- {"type":"controller-started","pid":23,"at":1786619408071}
2
- {"type":"provider-started","provider":"codex","pid":34,"at":1786619408074}
3
- {"type":"terminal","provider":"codex","state":"failed","exitCode":2,"at":1786619520214}
@@ -1,25 +0,0 @@
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}
@@ -1 +0,0 @@
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"}
@@ -1 +0,0 @@
1
- {"version":1,"type":"delegated_result","state":"failed","exitCode":2,"continuationHandle":"019ffac6-1176-7023-8f14-82bae745817f"}
@@ -1,14 +0,0 @@
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}
@@ -1 +0,0 @@
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"}
@@ -1,3 +0,0 @@
1
- {"type":"controller-started","pid":23,"at":1786620029812}
2
- {"type":"provider-started","provider":"codex","pid":34,"at":1786620029815}
3
- {"type":"terminal","provider":"codex","state":"failed","exitCode":2,"at":1786620065194}
@@ -1,38 +0,0 @@
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}
@@ -1 +0,0 @@
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"}
@@ -1 +0,0 @@
1
- {"version":1,"type":"delegated_result","state":"failed","exitCode":2,"continuationHandle":"019ffad2-c71a-7522-9645-c45214c2ba90"}
@@ -1 +0,0 @@
1
- {"version":1,"type":"delegated_result","state":"failed","exitCode":2}
@@ -1 +0,0 @@
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"}
@@ -1 +0,0 @@
1
- {"version":1,"type":"delegated_result","state":"failed","exitCode":2}