rollbridge 0.1.11 → 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 +22 -2
- package/docs/cli.md +31 -0
- package/docs/config.md +22 -0
- package/package.json +1 -1
- package/src/cli.js +59 -1
- package/src/config.js +10 -1
- package/src/daemon.js +82 -28
- package/src/release-group.js +38 -2
- package/src/state-store.js +8 -0
- package/test/config-path.test.js +16 -1
- package/test/daemon-bootstrap.test.js +363 -0
- package/test/fixtures/dummy-app.js +24 -1
- package/test/rollbridge.test.js +92 -3
- 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
|
@@ -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
|
+
}
|
package/test/rollbridge.test.js
CHANGED
|
@@ -102,6 +102,96 @@ test("failed health check leaves the previous release active", async () => {
|
|
|
102
102
|
}
|
|
103
103
|
})
|
|
104
104
|
|
|
105
|
+
test("deploy reloads process config and retires the previous worker with the refreshed timeout", async () => {
|
|
106
|
+
const fixture = await createFixture({nonBlockingDrainWorker: true, workerStopDelayMs: 10000})
|
|
107
|
+
const initialConfig = normalizeConfig({
|
|
108
|
+
...fixture.config,
|
|
109
|
+
processes: fixture.config.processes.map((processConfig) => processConfig.id === "worker"
|
|
110
|
+
? {...processConfig, gracefulStopMs: "indefinite"}
|
|
111
|
+
: processConfig)
|
|
112
|
+
})
|
|
113
|
+
const configPath = await writeConfigFile(initialConfig, fixture.root)
|
|
114
|
+
const daemon = new RollbridgeDaemon({config: initialConfig, configPath, logger: () => {}})
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
await daemon.start()
|
|
118
|
+
await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
|
|
119
|
+
|
|
120
|
+
const refreshedConfig = normalizeConfig({
|
|
121
|
+
...initialConfig,
|
|
122
|
+
processes: initialConfig.processes.map((processConfig) => processConfig.id === "worker"
|
|
123
|
+
? {...processConfig, gracefulStopMs: 50}
|
|
124
|
+
: processConfig)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
await writeConfigFile(refreshedConfig, fixture.root)
|
|
128
|
+
await daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"})
|
|
129
|
+
await waitFor(() => statusRelease(daemon, "v1").processes.find((processStatus) => processStatus.id === "worker")?.state === "stopped", 1000)
|
|
130
|
+
|
|
131
|
+
assert.equal(daemon.config.processes.find((processConfig) => processConfig.id === "worker")?.gracefulStopMs, 50)
|
|
132
|
+
assert.equal(await fetchText(daemon, "/release"), "v2")
|
|
133
|
+
} finally {
|
|
134
|
+
await daemon.shutdown()
|
|
135
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
136
|
+
}
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
test("deploy rejects a reloaded config that changes the running proxy", async () => {
|
|
140
|
+
const fixture = await createFixture()
|
|
141
|
+
const configPath = await writeConfigFile(fixture.config, fixture.root)
|
|
142
|
+
const daemon = new RollbridgeDaemon({config: fixture.config, configPath, logger: () => {}})
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
await daemon.start()
|
|
146
|
+
await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
|
|
147
|
+
await writeConfigFile(normalizeConfig({
|
|
148
|
+
...fixture.config,
|
|
149
|
+
proxy: {...fixture.config.proxy, host: "0.0.0.0"}
|
|
150
|
+
}), fixture.root)
|
|
151
|
+
|
|
152
|
+
await assert.rejects(
|
|
153
|
+
() => daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"}),
|
|
154
|
+
/proxy\.host.*restart the Rollbridge daemon/
|
|
155
|
+
)
|
|
156
|
+
assert.equal(daemon.status().activeReleaseId, "v1")
|
|
157
|
+
assert.equal(await fetchText(daemon, "/release"), "v1")
|
|
158
|
+
} finally {
|
|
159
|
+
await daemon.shutdown()
|
|
160
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
161
|
+
}
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
test("a failed deploy does not adopt reloaded process config", async () => {
|
|
165
|
+
const fixture = await createFixture()
|
|
166
|
+
const configPath = await writeConfigFile(fixture.config, fixture.root)
|
|
167
|
+
const daemon = new RollbridgeDaemon({config: fixture.config, configPath, logger: () => {}})
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
await daemon.start()
|
|
171
|
+
await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
|
|
172
|
+
|
|
173
|
+
const failingConfig = normalizeConfig({
|
|
174
|
+
...fixture.config,
|
|
175
|
+
processes: fixture.config.processes.map((processConfig) => processConfig.id === "web"
|
|
176
|
+
? {...processConfig, health: {...processConfig.health, path: "/not-ready", timeoutMs: 100}}
|
|
177
|
+
: processConfig)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
await writeConfigFile(failingConfig, fixture.root)
|
|
181
|
+
await assert.rejects(
|
|
182
|
+
() => daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"}),
|
|
183
|
+
/Health check failed/
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
assert.equal(daemon.config.processes.find((processConfig) => processConfig.id === "web")?.health?.path, "/ping")
|
|
187
|
+
assert.equal(daemon.status().activeReleaseId, "v1")
|
|
188
|
+
assert.equal(await fetchText(daemon, "/release"), "v1")
|
|
189
|
+
} finally {
|
|
190
|
+
await daemon.shutdown()
|
|
191
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
192
|
+
}
|
|
193
|
+
})
|
|
194
|
+
|
|
105
195
|
test("wildcard proxy bind host targets release processes through loopback", async () => {
|
|
106
196
|
const fixture = await createFixture({proxyHost: "0.0.0.0"})
|
|
107
197
|
const daemon = await startDaemon(fixture.config)
|
|
@@ -1276,10 +1366,9 @@ async function processEvents(logPath) {
|
|
|
1276
1366
|
* @returns {Promise<string>} Written config path.
|
|
1277
1367
|
*/
|
|
1278
1368
|
async function writeConfigFile(config, root) {
|
|
1279
|
-
const configPath = path.join(root, "rollbridge.
|
|
1369
|
+
const configPath = path.join(root, "rollbridge.mjs")
|
|
1280
1370
|
|
|
1281
|
-
|
|
1282
|
-
await fs.writeFile(configPath, `module.exports = ${JSON.stringify(config, null, 2)}\n`)
|
|
1371
|
+
await fs.writeFile(configPath, `export default ${JSON.stringify(config, null, 2)}\n`)
|
|
1283
1372
|
|
|
1284
1373
|
return configPath
|
|
1285
1374
|
}
|
|
@@ -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"}
|