From aeedb7c85de2c519dd902292096b9df8a76e6742 Mon Sep 17 00:00:00 2001 From: Rob Lipstreu Date: Tue, 10 Nov 2026 08:55:15 -0500 Subject: [PATCH 1/6] fix(server): stop orphaning the agent-device daemon after a missed health probe When a cached daemon missed a single 2 s health probe (for example while it was busy with another command), T3 deleted daemon.json and spawned a new one. The old daemon kept running and holding daemon.lock, so every later start failed with "Daemon registration busy" and device_open reported "Agent tools did not start within 30000 ms" until the process was killed by hand. `agent-device daemon stop` could not reach it either, because it locates the daemon through daemon.json. Retry the probe before giving up, and stop the old daemon through agent-device while daemon.json still identifies it, before removing the file. Co-Authored-By: Claude Opus 5.5 --- .../server/src/device/LocalDeviceHost.test.ts | 51 ++++++++++++++++--- apps/server/src/device/LocalDeviceHost.ts | 16 ++++-- 2 files changed, 57 insertions(+), 10 deletions(-) diff --git a/apps/server/src/device/LocalDeviceHost.test.ts b/apps/server/src/device/LocalDeviceHost.test.ts index 6b86a4d032610..ce2ea9cd3f48a 100644 --- a/apps/server/src/device/LocalDeviceHost.test.ts +++ b/apps/server/src/device/LocalDeviceHost.test.ts @@ -47,6 +47,8 @@ const agentFixture = Effect.fn("agentFixture")(function* ( const hub = { running: true, spawnFails: false }; const stateDir = agentDeviceStateDir(path, path.join(baseDir, "userdata")); const daemonHealthy = yield* Ref.make(true); + const failedProbes = yield* Ref.make(0); + const commands: Array = []; for (const [name, version, entry] of [ ["expo-device-hub", DEVICE_HUB_VERSION, "dist/server/cli.mjs"], ["agent-device", AGENT_DEVICE_VERSION, "bin/agent-device.mjs"], @@ -92,15 +94,22 @@ const agentFixture = Effect.fn("agentFixture")(function* ( ), ), Effect.provideService(ProcessRunner.ProcessRunner, { - run: (input) => - input.args.includes("devices") + run: (input) => { + if (input.args.includes("devices") || input.args.includes("daemon")) + commands.push(input.args.slice(1, 3).join(" ")); + return input.args.includes("devices") ? startup(stateDir) - : Effect.succeed(input.args.includes("-list-avds") ? avdOutput : processOutput(0)), + : Effect.succeed(input.args.includes("-list-avds") ? avdOutput : processOutput(0)); + }, }), Effect.provideService( HttpClient.HttpClient, HttpClient.make((request) => - Ref.get(daemonHealthy).pipe( + Effect.all([ + Ref.get(daemonHealthy), + Ref.getAndUpdate(failedProbes, (remaining) => Math.max(0, remaining - 1)), + ]).pipe( + Effect.map(([daemonUp, failing]) => daemonUp && failing === 0), Effect.map((healthy) => HttpClientResponse.fromWeb( request, @@ -114,15 +123,15 @@ const agentFixture = Effect.fn("agentFixture")(function* ( ), ); yield* host.ensureReady(() => Effect.void); - return { host, fs, stateDir, hub, daemonHealthy }; + return { host, fs, stateDir, hub, daemonHealthy, failedProbes, commands }; }); describe("AgentDevice startup", () => { - it.effect("restarts a cached daemon that no longer answers its health check", () => + it.live("restarts a cached daemon that no longer answers its health check", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; let attempts = 0; - const { host, daemonHealthy } = yield* agentFixture((stateDir) => + const { host, daemonHealthy, commands } = yield* agentFixture((stateDir) => Effect.gen(function* () { attempts++; yield* fs @@ -143,6 +152,8 @@ describe("AgentDevice startup", () => { yield* Ref.set(daemonHealthy, false); const restarted = yield* host.ensureAgentReady(() => Effect.void); expect(attempts).toBe(2); + // The old daemon is stopped while daemon.json still identifies it. + expect(commands).toEqual(["devices --json", "daemon stop", "devices --json"]); expect(restarted.agentDevice).toMatchObject({ baseUrl: "http://127.0.0.1:12347", token: "token-2", @@ -150,6 +161,32 @@ describe("AgentDevice startup", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + it.live("keeps a busy daemon that misses a health probe", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + let attempts = 0; + const { host, failedProbes, commands } = yield* agentFixture((stateDir) => + Effect.gen(function* () { + attempts++; + yield* fs + .writeFileString( + `${stateDir}/daemon.json`, + JSON.stringify({ httpPort: 12345 + attempts, token: `token-${attempts}` }), + ) + .pipe(Effect.orDie); + return processOutput(0); + }), + ); + const first = yield* host.ensureAgentReady(() => Effect.void); + + yield* Ref.set(failedProbes, 1); + const again = yield* host.ensureAgentReady(() => Effect.void); + expect(again.agentDevice).toEqual(first.agentDevice); + expect(attempts).toBe(1); + expect(commands).toEqual(["devices --json"]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + it.effect("reports an incompatible lock immediately and retains it for manual repair", () => Effect.gen(function* () { const result = processOutput( diff --git a/apps/server/src/device/LocalDeviceHost.ts b/apps/server/src/device/LocalDeviceHost.ts index afb408f5cc0fb..0445553f35bcb 100644 --- a/apps/server/src/device/LocalDeviceHost.ts +++ b/apps/server/src/device/LocalDeviceHost.ts @@ -53,6 +53,8 @@ import { const HUB_READY_TIMEOUT_MS = 30_000; const DAEMON_READY_TIMEOUT_MS = 30_000; const DAEMON_POLL_MS = 100; +const DAEMON_HEALTH_TIMEOUT_MS = 3_000; +const DAEMON_HEALTH_ATTEMPTS = 3; const HUB_RESTART_STABLE_UPTIME_MS = 60_000; const HUB_RESTART_MAX_DELAY_MS = 30_000; @@ -551,17 +553,25 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { entryPath: agentTool.entryPath, }); if (existing._tag === "Some") { - const alive = yield* HttpClient.withScope(httpClient) + const probe = HttpClient.withScope(httpClient) .get(`http://127.0.0.1:${existing.value.httpPort}/health`) .pipe( - Effect.timeout(Duration.seconds(2)), + Effect.timeout(Duration.millis(DAEMON_HEALTH_TIMEOUT_MS)), Effect.flatMap((response) => response.arrayBuffer.pipe(Effect.as(response.status === 200)), ), Effect.scoped, Effect.orElseSucceed(() => false), ); - if (alive) return toEndpoint(existing.value); + // A daemon busy with a long command can miss one probe; restarting it then + // would race its live lock and wedge every later start. + for (let attempt = 1; attempt <= DAEMON_HEALTH_ATTEMPTS; attempt++) { + if (yield* probe) return toEndpoint(existing.value); + if (attempt < DAEMON_HEALTH_ATTEMPTS) yield* Effect.sleep(Duration.millis(DAEMON_POLL_MS)); + } + // Stop through agent-device while daemon.json still names the process; once + // the file is gone it reports "not-running" and the daemon keeps daemon.lock. + yield* stopAgentDeviceDaemon({ entryPath: agentTool.entryPath, nodePath }); yield* fs.remove(daemonFilePath(), { force: true }).pipe(Effect.ignore); } // No `daemon start` exists; `devices` is the cheapest command that starts and awaits it. From 2d078844f4f6d4b72cfa2cab0607e9a513c03ead Mon Sep 17 00:00:00 2001 From: Rob Lipstreu Date: Tue, 10 Nov 2026 10:17:50 -0500 Subject: [PATCH 2/6] fix(server): keep the daemon record when its recovery stop is unconfirmed The recovery path ignored the result of `daemon stop` and removed daemon.json anyway, so a stop that failed or timed out still orphaned a live daemon. Remove the record only after a clean, non-timed-out exit, and otherwise fail with the record intact so a later attempt can retry the stop. The recovery stop allows 15 s, since agent-device waits up to 10 s after SIGTERM and 2 s after SIGKILL before it reports. Co-Authored-By: Claude Opus 5.5 --- .../server/src/device/LocalDeviceHost.test.ts | 57 ++++++++++++++++++- apps/server/src/device/LocalDeviceHost.ts | 50 ++++++++++------ 2 files changed, 89 insertions(+), 18 deletions(-) diff --git a/apps/server/src/device/LocalDeviceHost.test.ts b/apps/server/src/device/LocalDeviceHost.test.ts index ce2ea9cd3f48a..5919f6ad98c69 100644 --- a/apps/server/src/device/LocalDeviceHost.test.ts +++ b/apps/server/src/device/LocalDeviceHost.test.ts @@ -49,6 +49,8 @@ const agentFixture = Effect.fn("agentFixture")(function* ( const daemonHealthy = yield* Ref.make(true); const failedProbes = yield* Ref.make(0); const commands: Array = []; + const stopResult = yield* Ref.make(processOutput(0)); + const daemonFileAtStop: Array = []; for (const [name, version, entry] of [ ["expo-device-hub", DEVICE_HUB_VERSION, "dist/server/cli.mjs"], ["agent-device", AGENT_DEVICE_VERSION, "bin/agent-device.mjs"], @@ -97,6 +99,12 @@ const agentFixture = Effect.fn("agentFixture")(function* ( run: (input) => { if (input.args.includes("devices") || input.args.includes("daemon")) commands.push(input.args.slice(1, 3).join(" ")); + if (input.args.includes("daemon")) + return fs.exists(`${stateDir}/daemon.json`).pipe( + Effect.orDie, + Effect.tap((exists) => Effect.sync(() => daemonFileAtStop.push(exists))), + Effect.andThen(Ref.get(stopResult)), + ); return input.args.includes("devices") ? startup(stateDir) : Effect.succeed(input.args.includes("-list-avds") ? avdOutput : processOutput(0)); @@ -123,7 +131,17 @@ const agentFixture = Effect.fn("agentFixture")(function* ( ), ); yield* host.ensureReady(() => Effect.void); - return { host, fs, stateDir, hub, daemonHealthy, failedProbes, commands }; + return { + host, + fs, + stateDir, + hub, + daemonHealthy, + failedProbes, + commands, + stopResult, + daemonFileAtStop, + }; }); describe("AgentDevice startup", () => { @@ -131,7 +149,7 @@ describe("AgentDevice startup", () => { Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; let attempts = 0; - const { host, daemonHealthy, commands } = yield* agentFixture((stateDir) => + const { host, daemonHealthy, commands, daemonFileAtStop } = yield* agentFixture((stateDir) => Effect.gen(function* () { attempts++; yield* fs @@ -154,6 +172,7 @@ describe("AgentDevice startup", () => { expect(attempts).toBe(2); // The old daemon is stopped while daemon.json still identifies it. expect(commands).toEqual(["devices --json", "daemon stop", "devices --json"]); + expect(daemonFileAtStop).toEqual([true]); expect(restarted.agentDevice).toMatchObject({ baseUrl: "http://127.0.0.1:12347", token: "token-2", @@ -161,6 +180,40 @@ describe("AgentDevice startup", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + it.live.each([ + ["fails", processOutput(1)], + ["times out", processOutput(0, "", "", true)], + ] as const)("keeps an unresponsive daemon's record when its stop %s", ([, result]) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + let attempts = 0; + const { host, stateDir, daemonHealthy, commands, stopResult } = yield* agentFixture( + (stateDir) => + Effect.gen(function* () { + attempts++; + yield* fs + .writeFileString( + `${stateDir}/daemon.json`, + JSON.stringify({ httpPort: 12345 + attempts, token: `token-${attempts}` }), + ) + .pipe(Effect.orDie); + return processOutput(0); + }), + ); + yield* host.ensureAgentReady(() => Effect.void); + yield* Ref.set(daemonHealthy, false); + yield* Ref.set(stopResult, result); + + const error = yield* host.ensureAgentReady(() => Effect.void).pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: "DeviceHostError", + step: "stopping unresponsive agent tools", + }); + expect(commands).toEqual(["devices --json", "daemon stop"]); + expect(yield* fs.exists(`${stateDir}/daemon.json`)).toBe(true); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + it.live("keeps a busy daemon that misses a health probe", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/device/LocalDeviceHost.ts b/apps/server/src/device/LocalDeviceHost.ts index 0445553f35bcb..6cee59453f88f 100644 --- a/apps/server/src/device/LocalDeviceHost.ts +++ b/apps/server/src/device/LocalDeviceHost.ts @@ -55,6 +55,9 @@ const DAEMON_READY_TIMEOUT_MS = 30_000; const DAEMON_POLL_MS = 100; const DAEMON_HEALTH_TIMEOUT_MS = 3_000; const DAEMON_HEALTH_ATTEMPTS = 3; +// agent-device waits up to 10 s after SIGTERM and 2 s after SIGKILL before it +// reports whether the daemon exited. +const DAEMON_RECOVERY_STOP_TIMEOUT = Duration.seconds(15); const HUB_RESTART_STABLE_UPTIME_MS = 60_000; const HUB_RESTART_MAX_DELAY_MS = 30_000; @@ -571,7 +574,18 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { } // Stop through agent-device while daemon.json still names the process; once // the file is gone it reports "not-running" and the daemon keeps daemon.lock. - yield* stopAgentDeviceDaemon({ entryPath: agentTool.entryPath, nodePath }); + // Keep the file unless the stop is confirmed, so a later attempt can retry it. + const stopped = yield* runAgentDeviceDaemonStop( + { entryPath: agentTool.entryPath, nodePath }, + DAEMON_RECOVERY_STOP_TIMEOUT, + ).pipe(Effect.result); + if (stopped._tag === "Failure" || stopped.success.timedOut || stopped.success.code !== 0) { + return yield* new DeviceHost.DeviceHostError({ + hostId, + step: "stopping unresponsive agent tools", + cause: stopped._tag === "Failure" ? stopped.failure : stopped.success, + }); + } yield* fs.remove(daemonFilePath(), { force: true }).pipe(Effect.ignore); } // No `daemon start` exists; `devices` is the cheapest command that starts and awaits it. @@ -634,25 +648,29 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { } }); + const runAgentDeviceDaemonStop = ( + agentTool: { readonly entryPath: string; readonly nodePath: string }, + timeout: Duration.Duration, + ) => + runner.run({ + command: agentTool.nodePath, + args: [ + agentTool.entryPath, + "daemon", + "stop", + "--state-dir", + agentDeviceStateDir(path, config.stateDir), + ], + env: { ...hostEnvironment, AGENT_DEVICE_NO_UPDATE_NOTIFIER: "1" }, + timeout, + timeoutBehavior: "timedOutResult", + }); + const stopAgentDeviceDaemon = ( agentTool: { readonly entryPath: string; readonly nodePath: string } | null, ) => agentTool - ? runner - .run({ - command: agentTool.nodePath, - args: [ - agentTool.entryPath, - "daemon", - "stop", - "--state-dir", - agentDeviceStateDir(path, config.stateDir), - ], - env: { ...hostEnvironment, AGENT_DEVICE_NO_UPDATE_NOTIFIER: "1" }, - timeout: Duration.seconds(10), - timeoutBehavior: "timedOutResult", - }) - .pipe(Effect.ignore) + ? runAgentDeviceDaemonStop(agentTool, Duration.seconds(10)).pipe(Effect.ignore) : Effect.void; let agentToolRef: { readonly entryPath: string; readonly nodePath: string } | null = null; From d2bddf52fc3d03e142bbc5ff4f76605db562e07e Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Wed, 11 Nov 2026 11:27:28 +0000 Subject: [PATCH 3/6] fix(server): restart the agent-device daemon even when its recovery stop fails --- .../server/src/device/LocalDeviceHost.test.ts | 35 ++++++++----------- apps/server/src/device/LocalDeviceHost.ts | 7 ++-- 2 files changed, 17 insertions(+), 25 deletions(-) diff --git a/apps/server/src/device/LocalDeviceHost.test.ts b/apps/server/src/device/LocalDeviceHost.test.ts index 52e5f85c1858b..27089a45cdfe9 100644 --- a/apps/server/src/device/LocalDeviceHost.test.ts +++ b/apps/server/src/device/LocalDeviceHost.test.ts @@ -192,34 +192,29 @@ describe("AgentDevice startup", () => { it.live.each([ ["fails", processOutput(1)], ["times out", processOutput(0, "", "", true)], - ] as const)("keeps an unresponsive daemon's record when its stop %s", ([, result]) => + ] as const)("still restarts an unresponsive daemon when its stop %s", ([, result]) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; let attempts = 0; - const { host, stateDir, daemonHealthy, commands, stopResult } = yield* agentFixture( - (stateDir) => - Effect.gen(function* () { - attempts++; - yield* fs - .writeFileString( - `${stateDir}/daemon.json`, - JSON.stringify({ httpPort: 12345 + attempts, token: `token-${attempts}` }), - ) - .pipe(Effect.orDie); - return processOutput(0); - }), + const { host, daemonHealthy, commands, stopResult } = yield* agentFixture((stateDir) => + Effect.gen(function* () { + attempts++; + yield* fs + .writeFileString( + `${stateDir}/daemon.json`, + JSON.stringify({ httpPort: 12345 + attempts, token: `token-${attempts}` }), + ) + .pipe(Effect.orDie); + return processOutput(0); + }), ); yield* host.ensureAgentReady(() => Effect.void); yield* Ref.set(daemonHealthy, false); yield* Ref.set(stopResult, result); - const error = yield* host.ensureAgentReady(() => Effect.void).pipe(Effect.flip); - expect(error).toMatchObject({ - _tag: "DeviceHostError", - step: "stopping unresponsive agent tools", - }); - expect(commands).toEqual(["devices --json", "daemon stop"]); - expect(yield* fs.exists(`${stateDir}/daemon.json`)).toBe(true); + const restarted = yield* host.ensureAgentReady(() => Effect.void); + expect(commands).toEqual(["devices --json", "daemon stop", "devices --json"]); + expect(restarted.agentDevice).toMatchObject({ token: "token-2" }); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); diff --git a/apps/server/src/device/LocalDeviceHost.ts b/apps/server/src/device/LocalDeviceHost.ts index 04277330e12da..874febe193ea7 100644 --- a/apps/server/src/device/LocalDeviceHost.ts +++ b/apps/server/src/device/LocalDeviceHost.ts @@ -561,10 +561,10 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { const probe = HttpClient.withScope(httpClient) .get(`http://127.0.0.1:${existing.value.httpPort}/health`) .pipe( - Effect.timeout(Duration.millis(DAEMON_HEALTH_TIMEOUT_MS)), Effect.flatMap((response) => response.arrayBuffer.pipe(Effect.as(response.status === 200)), ), + Effect.timeout(Duration.millis(DAEMON_HEALTH_TIMEOUT_MS)), Effect.scoped, Effect.orElseSucceed(() => false), ); @@ -576,15 +576,12 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { } // Stop through agent-device while daemon.json still names the process; once // the file is gone it reports "not-running" and the daemon keeps daemon.lock. - // Keep the file unless the stop is confirmed, so a later attempt can retry it. const stopped = yield* runAgentDeviceDaemonStop( { entryPath: agentTool.entryPath, nodePath }, DAEMON_RECOVERY_STOP_TIMEOUT, ).pipe(Effect.result); if (stopped._tag === "Failure" || stopped.success.timedOut || stopped.success.code !== 0) { - return yield* new DeviceHost.DeviceHostError({ - hostId, - step: "stopping unresponsive agent tools", + yield* Effect.logWarning("Could not stop an unresponsive agent-device daemon", { cause: stopped._tag === "Failure" ? stopped.failure : stopped.success, }); } From 417101b73f7fb99cc15bdacd0d13247c7429877f Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Wed, 11 Nov 2026 11:45:09 +0000 Subject: [PATCH 4/6] fix(server): log only the exit status of a failed daemon stop --- apps/server/src/device/LocalDeviceHost.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/server/src/device/LocalDeviceHost.ts b/apps/server/src/device/LocalDeviceHost.ts index 874febe193ea7..c6b28eef8c7ba 100644 --- a/apps/server/src/device/LocalDeviceHost.ts +++ b/apps/server/src/device/LocalDeviceHost.ts @@ -581,9 +581,12 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { DAEMON_RECOVERY_STOP_TIMEOUT, ).pipe(Effect.result); if (stopped._tag === "Failure" || stopped.success.timedOut || stopped.success.code !== 0) { - yield* Effect.logWarning("Could not stop an unresponsive agent-device daemon", { - cause: stopped._tag === "Failure" ? stopped.failure : stopped.success, - }); + yield* Effect.logWarning( + "Could not stop an unresponsive agent-device daemon", + stopped._tag === "Failure" + ? { cause: stopped.failure } + : { code: stopped.success.code, timedOut: stopped.success.timedOut }, + ); } yield* fs.remove(daemonFilePath(), { force: true }).pipe(Effect.ignore); } From ad516d30ae163f8dec3f8c3267633fafafaf6969 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Wed, 11 Nov 2026 14:27:07 +0000 Subject: [PATCH 5/6] fix(server): retry daemon health probes without sleeping between them --- apps/server/src/device/LocalDeviceHost.test.ts | 6 +++--- apps/server/src/device/LocalDeviceHost.ts | 1 - 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/apps/server/src/device/LocalDeviceHost.test.ts b/apps/server/src/device/LocalDeviceHost.test.ts index 27089a45cdfe9..8198ab69948f8 100644 --- a/apps/server/src/device/LocalDeviceHost.test.ts +++ b/apps/server/src/device/LocalDeviceHost.test.ts @@ -154,7 +154,7 @@ const agentFixture = Effect.fn("agentFixture")(function* ( }); describe("AgentDevice startup", () => { - it.live("restarts a cached daemon that no longer answers its health check", () => + it.effect("restarts a cached daemon that no longer answers its health check", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; let attempts = 0; @@ -189,7 +189,7 @@ describe("AgentDevice startup", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); - it.live.each([ + it.effect.each([ ["fails", processOutput(1)], ["times out", processOutput(0, "", "", true)], ] as const)("still restarts an unresponsive daemon when its stop %s", ([, result]) => @@ -218,7 +218,7 @@ describe("AgentDevice startup", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); - it.live("keeps a busy daemon that misses a health probe", () => + it.effect("keeps a busy daemon that misses a health probe", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; let attempts = 0; diff --git a/apps/server/src/device/LocalDeviceHost.ts b/apps/server/src/device/LocalDeviceHost.ts index c6b28eef8c7ba..08316d522b1ff 100644 --- a/apps/server/src/device/LocalDeviceHost.ts +++ b/apps/server/src/device/LocalDeviceHost.ts @@ -572,7 +572,6 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { // would race its live lock and wedge every later start. for (let attempt = 1; attempt <= DAEMON_HEALTH_ATTEMPTS; attempt++) { if (yield* probe) return toEndpoint(existing.value); - if (attempt < DAEMON_HEALTH_ATTEMPTS) yield* Effect.sleep(Duration.millis(DAEMON_POLL_MS)); } // Stop through agent-device while daemon.json still names the process; once // the file is gone it reports "not-running" and the daemon keeps daemon.lock. From 8c187583a5750cb7d62e72ac849638a1c09dac3a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 20:06:20 +0000 Subject: [PATCH 6/6] test(server): keep one stop-failure case for daemon recovery AI-assisted; verified with the tests and tools listed above. --- apps/server/src/device/LocalDeviceHost.test.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/apps/server/src/device/LocalDeviceHost.test.ts b/apps/server/src/device/LocalDeviceHost.test.ts index 8198ab69948f8..d1099a8c266fc 100644 --- a/apps/server/src/device/LocalDeviceHost.test.ts +++ b/apps/server/src/device/LocalDeviceHost.test.ts @@ -189,10 +189,7 @@ describe("AgentDevice startup", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); - it.effect.each([ - ["fails", processOutput(1)], - ["times out", processOutput(0, "", "", true)], - ] as const)("still restarts an unresponsive daemon when its stop %s", ([, result]) => + it.effect("still restarts an unresponsive daemon when its stop times out", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; let attempts = 0; @@ -210,7 +207,7 @@ describe("AgentDevice startup", () => { ); yield* host.ensureAgentReady(() => Effect.void); yield* Ref.set(daemonHealthy, false); - yield* Ref.set(stopResult, result); + yield* Ref.set(stopResult, processOutput(0, "", "", true)); const restarted = yield* host.ensureAgentReady(() => Effect.void); expect(commands).toEqual(["devices --json", "daemon stop", "devices --json"]);