diff --git a/src/cli/daemon-cli/lifecycle-core.config-guard.test.ts b/src/cli/daemon-cli/lifecycle-core.config-guard.test.ts index fa1264544df..fdf1b7b5154 100644 --- a/src/cli/daemon-cli/lifecycle-core.config-guard.test.ts +++ b/src/cli/daemon-cli/lifecycle-core.config-guard.test.ts @@ -193,7 +193,8 @@ describe("config-guard gates repairNotLoaded (#43602 + #35862)", () => { }); expect(repairNotLoaded).toHaveBeenCalledTimes(1); - expect(service.restart).toHaveBeenCalledTimes(1); + // Repair already started the service; service.restart() is NOT called + expect(service.restart).not.toHaveBeenCalled(); }); it("restart: aborts before repairNotLoaded when config is invalid", async () => { diff --git a/src/cli/daemon-cli/lifecycle-core.test.ts b/src/cli/daemon-cli/lifecycle-core.test.ts index 9cbcc163193..561351d6ae4 100644 --- a/src/cli/daemon-cli/lifecycle-core.test.ts +++ b/src/cli/daemon-cli/lifecycle-core.test.ts @@ -205,8 +205,9 @@ describe("runServiceRestart token drift", () => { }); expect(repairNotLoaded).toHaveBeenCalledTimes(1); - // After successful repair, start should proceed to restart the service. - expect(service.restart).toHaveBeenCalledTimes(1); + // Repair already started the service (bootstrap → kickstart), so + // service.restart() should NOT be called — avoids double-kickstart. + expect(service.restart).not.toHaveBeenCalled(); const jsonLine = runtimeLogs.find((line) => line.trim().startsWith("{")); const payload = JSON.parse(jsonLine ?? "{}") as { result?: string }; expect(payload.result).toBe("started"); @@ -307,6 +308,44 @@ describe("runServiceRestart token drift", () => { expect(repairNotLoaded).not.toHaveBeenCalled(); }); + it("restart: does not double-log repair message in non-JSON mode", async () => { + service.isLoaded.mockResolvedValue(false); + const repairNotLoaded = vi.fn().mockResolvedValue({ ok: true }); + const serviceWithRepair = { ...service, repairNotLoaded }; + + const result = await runServiceRestart({ + serviceNoun: "Gateway", + service: serviceWithRepair, + renderStartHints: () => [], + opts: { json: false }, + onNotLoaded: async () => null, + }); + + expect(result).toBe(true); + expect(repairNotLoaded).toHaveBeenCalledTimes(1); + // The re-registered message should appear exactly once in non-JSON output + const reRegisterLogs = runtimeLogs.filter((line) => line.includes("re-registered")); + expect(reRegisterLogs).toHaveLength(1); + }); + + it("restart: does not call service.restart after successful repair (no double-kickstart)", async () => { + service.isLoaded.mockResolvedValue(false); + const repairNotLoaded = vi.fn().mockResolvedValue({ ok: true }); + const serviceWithRepair = { ...service, repairNotLoaded }; + + await runServiceRestart({ + serviceNoun: "Gateway", + service: serviceWithRepair, + renderStartHints: () => [], + opts: { json: true }, + onNotLoaded: async () => null, + }); + + // repairLaunchAgentBootstrap already kickstarted the service. + // service.restart() should NOT be called — avoids redundant kill+restart. + expect(service.restart).not.toHaveBeenCalled(); + }); + it("restart: falls through to hints when repair returns ok:false", async () => { service.isLoaded.mockResolvedValue(false); const repairNotLoaded = vi.fn().mockResolvedValue({ ok: false }); diff --git a/src/cli/daemon-cli/lifecycle-core.ts b/src/cli/daemon-cli/lifecycle-core.ts index 389d9609153..344ddfe84f3 100644 --- a/src/cli/daemon-cli/lifecycle-core.ts +++ b/src/cli/daemon-cli/lifecycle-core.ts @@ -219,12 +219,20 @@ export async function runServiceStart(params: { try { const repair = await params.service.repairNotLoaded({ env: process.env }); if (repair.ok) { - loaded = true; + // repairLaunchAgentBootstrap already started the service (enable → + // bootstrap → kickstart). Emit success and return — calling + // service.restart() here would be a redundant kill+restart cycle. + const message = `${params.serviceNoun} was not loaded — re-registered from existing service definition.`; + emit({ + ok: true, + result: "started", + message, + service: buildDaemonServiceSnapshot(params.service, true), + }); if (!json) { - defaultRuntime.log( - `${params.serviceNoun} was not loaded — re-registered from existing service definition.`, - ); + defaultRuntime.log(message); } + return; } } catch { // Best-effort repair; fall through to normal not-loaded handling. @@ -416,17 +424,20 @@ export async function runServiceRestart(params: { // No running process to signal, but the service definition may still // exist on disk (e.g. macOS LaunchAgent unloaded after sleep/idle). // Re-register it so `restart` can proceed normally. See #43602. + // + // repairLaunchAgentBootstrap already starts the service (enable → + // bootstrap → kickstart), so we do NOT set `loaded = true` here — + // that would cause the `if (loaded)` branch below to call + // `service.restart()`, issuing a redundant kill+restart cycle. + // Instead we record the result via `handledNotLoaded` and let the + // end-of-function emit path handle messaging. try { const repair = await params.service.repairNotLoaded({ env: process.env }); if (repair.ok) { - loaded = true; handledNotLoaded = { result: "restarted", message: `${params.serviceNoun} was not loaded — re-registered from existing service definition.`, }; - if (!json) { - defaultRuntime.log(handledNotLoaded.message); - } } } catch { // Best-effort repair; fall through to normal not-loaded handling.