diff --git a/docs/commands/switch.md b/docs/commands/switch.md index b1a74116..678ff59d 100644 --- a/docs/commands/switch.md +++ b/docs/commands/switch.md @@ -79,7 +79,7 @@ codex-auth switch - --restart-daemon codex-auth switch personal --json --restart-daemon ``` -`--restart-daemon` requests a restart after account activation and registry persistence succeed. It also restarts when the selected credentials are already on disk, so it can repair stale daemon authentication or retry a failed restart. It can interrupt all attached sessions. Reconnect, verify the account, and resume work as needed. +`--restart-daemon` applies the selected credentials to the running daemon after account activation and registry persistence succeed. Codex daemons reload auth in place without interrupting attached sessions; upstream daemons without reload support are restarted instead, which can interrupt all attached sessions. It also covers stale daemon authentication and retries after a failed attempt. Reconnect, verify the account, and resume work as needed. - The selected `CODEX_HOME` is passed to both lifecycle commands. The home’s managed executable is preferred, with supported legacy layouts and a PATH fallback. - Responsiveness is checked with `codex app-server daemon version`. A missing control endpoint is treated as best-effort absence: no daemon is started. A stale endpoint or other probe error leaves the state unconfirmed; an explicit request then fails without invoking restart. diff --git a/src/cli/output.zig b/src/cli/output.zig index 2d0abce0..c2a4f999 100644 --- a/src/cli/output.zig +++ b/src/cli/output.zig @@ -29,6 +29,14 @@ pub fn writeCodexDaemonDiagnosticTo(out: *std.Io.Writer, home: []const u8, execu try out.writeAll("Account files switched successfully; Codex daemon restart completed. Reconnect and verify the selected account.\n"); return; }, + .reloaded => { + try out.writeAll("Account files switched successfully; the running daemon reloaded the new credentials without a restart.\n"); + return; + }, + .deferred => { + try out.writeAll("Account files switched successfully; the daemon deferred the reload until the running turn finishes. Rerun `switch --restart-daemon` afterwards.\n"); + return; + }, .running => try out.writeAll("A running Codex daemon may still use cached credentials. Use `switch --restart-daemon` to apply the selection or repair stale authentication. Restarting can interrupt attached sessions.\n"), .unknown => try out.writeAll(if (requested) "Account files switched successfully; daemon responsiveness could not be confirmed. The requested restart was not attempted.\n" diff --git a/src/workflows/codex_daemon.zig b/src/workflows/codex_daemon.zig index bacd15b0..9200fe84 100644 --- a/src/workflows/codex_daemon.zig +++ b/src/workflows/codex_daemon.zig @@ -3,10 +3,11 @@ const builtin = @import("builtin"); const runtime = @import("../core/runtime.zig"); pub const restart_timeout_ms = 120_000; +pub const reload_timeout_ms = 10_000; const probe_timeout_ms = 5_000; const output_limit = 16 * 1024; -pub const Outcome = enum { not_running, running, unknown, unsupported, restarted, restart_failed, timed_out }; +pub const Outcome = enum { not_running, running, unknown, unsupported, reloaded, deferred, restarted, restart_failed, timed_out }; /// Coordinates the supported Codex lifecycle commands for one selected home. pub const Client = struct { @@ -70,6 +71,31 @@ pub const Client = struct { return if (result.succeeded) .restarted else .restart_failed; } + /// Two-tier coordination: prefer the non-disruptive reload when the running + /// daemon supports it (codex daemons), and fall back to a full restart for + /// upstream daemons without reload support. Busy daemons defer instead of + /// interrupting a running turn. + pub fn ensure(self: *const Client, timeout_ms: u64) !Outcome { + const state = try self.probe(); + if (state != .running) return state; + if (try self.tryReload()) |outcome| return outcome; + return try self.restart(timeout_ms); + } + + /// Returns the reload outcome when the daemon accepted it (or deferred it); + /// null means reload is unavailable or failed and the caller should fall + /// back to a restart. + fn tryReload(self: *const Client) !?Outcome { + const result = self.runLifecycle("reload-auth", reload_timeout_ms) catch |err| switch (err) { + error.OutOfMemory => return err, + else => return null, + }; + defer self.allocator.free(result.stdout); + if (result.timed_out or !result.succeeded) return null; + if (std.mem.indexOf(u8, result.stdout, "skipped the auth reload") != null) return .deferred; + return .reloaded; + } + const Result = struct { stdout: []u8, succeeded: bool, timed_out: bool }; fn runLifecycle(self: *const Client, command: []const u8, timeout_ms: u64) !Result { diff --git a/src/workflows/switch.zig b/src/workflows/switch.zig index 798f4974..b2a4ae68 100644 --- a/src/workflows/switch.zig +++ b/src/workflows/switch.zig @@ -253,7 +253,7 @@ fn activateSelection( }; defer client.deinit(); const outcome = if (opts.restart_daemon) - try client.restart(daemon.restart_timeout_ms) + try client.ensure(daemon.restart_timeout_ms) else try client.probe(); try cli.output.printCodexDaemonDiagnostic(codex_home, client.executable, outcome, opts.restart_daemon); diff --git a/tests/cli_integration_test.zig b/tests/cli_integration_test.zig index e14fa34d..75e2b6f8 100644 --- a/tests/cli_integration_test.zig +++ b/tests/cli_integration_test.zig @@ -854,7 +854,7 @@ test "Scenario: Given daemon-aware switching then explicit recovery preserves se } const calls = try fake_daemon.read(home_dir, "lifecycle-calls"); defer gpa.free(calls); - const expected = if (!case.requested) "version\n" else if (case.attempts == 2) "version\nrestart\nversion\nrestart\n" else "version\nrestart\n"; + const expected = if (!case.requested) "version\n" else if (case.attempts == 2) "version\nreload-auth\nversion\nrestart\nversion\nreload-auth\nversion\nrestart\n" else "version\nreload-auth\nversion\nrestart\n"; try std.testing.expectEqualStrings(expected, calls); if (case.requested) { const forwarded = try fake_daemon.read(home_dir, "restarted-home"); @@ -898,7 +898,7 @@ test "Scenario: Given no managed executable then daemon restart resolves relativ try std.testing.expect(std.mem.indexOf(u8, result.stderr, "daemon restart completed") != null); const calls = try fake_daemon.read(dir, "lifecycle-calls"); defer gpa.free(calls); - try std.testing.expectEqualStrings("version\nrestart\n", calls); + try std.testing.expectEqualStrings("version\nreload-auth\nversion\nrestart\n", calls); const forwarded = try fake_daemon.read(dir, "restarted-home"); defer gpa.free(forwarded); try std.testing.expectEqualStrings(home, std.mem.trimEnd(u8, forwarded, "\n")); @@ -942,7 +942,7 @@ test "Scenario: Given a non-executable codex first in PATH then daemon restart u try std.testing.expect(std.mem.indexOf(u8, result.stderr, "daemon restart completed") != null); const calls = try fake_daemon.read(dir, "lifecycle-calls"); defer gpa.free(calls); - try std.testing.expectEqualStrings("version\nrestart\n", calls); + try std.testing.expectEqualStrings("version\nreload-auth\nversion\nrestart\n", calls); const forwarded = try fake_daemon.read(dir, "restarted-home"); defer gpa.free(forwarded); try std.testing.expectEqualStrings(home, std.mem.trimEnd(u8, forwarded, "\n")); @@ -2975,7 +2975,7 @@ test "Scenario: Given switch query with multiple matches when running switch the defer gpa.free(calls_path); const calls = try fixtures.readFileAlloc(gpa, calls_path); defer gpa.free(calls); - try std.testing.expectEqualStrings("version\nrestart\n", calls); + try std.testing.expectEqualStrings("version\nreload-auth\nversion\nrestart\n", calls); } } diff --git a/tests/support/codex_daemon.zig b/tests/support/codex_daemon.zig index c4495c8a..501b85d5 100644 --- a/tests/support/codex_daemon.zig +++ b/tests/support/codex_daemon.zig @@ -22,6 +22,9 @@ pub fn install(dir: fs.Dir, body: []const u8) !void { pub const running = "if [ \"$3\" = version ]; then printf '%s\\n' '{\"status\":\"running\",\"appServerVersion\":\"0.160.0\",\"backend\":\"pid\"}'; exit 0; fi\n"; pub const succeed = running ++ "[ \"$3\" = restart ] || exit 91\nprintf '%s\\n' \"$CODEX_HOME\" > \"$CODEX_HOME/restarted-home\"\n"; +pub const reload_changed = running ++ "[ \"$3\" = reload-auth ] || exit 91\nprintf '%s\\n' 'Daemon reloaded auth; the account changed.'\n"; +pub const reload_busy = running ++ "[ \"$3\" = reload-auth ] || exit 91\nprintf '%s\\n' 'Daemon skipped the auth reload because a turn is running.'\n"; +pub const reload_unsupported_succeed = running ++ "if [ \"$3\" = reload-auth ]; then exit 92; fi\n" ++ "[ \"$3\" = restart ] || exit 91\nprintf '%s\\n' \"$CODEX_HOME\" > \"$CODEX_HOME/restarted-home\"\n"; pub fn read(dir: fs.Dir, path: []const u8) ![]u8 { var file = try dir.openFile(path, .{}); diff --git a/tests/workflows_daemon_test.zig b/tests/workflows_daemon_test.zig index 044704b4..112fe6ec 100644 --- a/tests/workflows_daemon_test.zig +++ b/tests/workflows_daemon_test.zig @@ -51,6 +51,56 @@ test "Scenario: Given failed or malformed daemon probes then explicit restart re } } +test "Scenario: Given a codex daemon that reloads auth then ensure reloads without restarting" { + if (builtin.os.tag == .windows) return error.SkipZigTest; + var tmp = fs.tmpDir(.{}); + defer tmp.cleanup(); + try fake.install(tmp.dir, fake.reload_changed); + const home = try tmp.dir.realpathAlloc(std.testing.allocator, "."); + defer std.testing.allocator.free(home); + var client = try daemon.Client.init(std.testing.allocator, home); + defer client.deinit(); + try std.testing.expectEqual(daemon.Outcome.reloaded, try client.ensure(1_000)); + const calls = try fake.read(tmp.dir, "lifecycle-calls"); + defer std.testing.allocator.free(calls); + try std.testing.expectEqualStrings("version\nreload-auth\n", calls); + try std.testing.expectError(error.FileNotFound, tmp.dir.openFile("restarted-home", .{})); +} + +test "Scenario: Given an upstream daemon without reload support then ensure falls back to restart" { + if (builtin.os.tag == .windows) return error.SkipZigTest; + var tmp = fs.tmpDir(.{}); + defer tmp.cleanup(); + try fake.install(tmp.dir, fake.reload_unsupported_succeed); + const home = try tmp.dir.realpathAlloc(std.testing.allocator, "."); + defer std.testing.allocator.free(home); + var client = try daemon.Client.init(std.testing.allocator, home); + defer client.deinit(); + try std.testing.expectEqual(daemon.Outcome.restarted, try client.ensure(1_000)); + const calls = try fake.read(tmp.dir, "lifecycle-calls"); + defer std.testing.allocator.free(calls); + try std.testing.expectEqualStrings("version\nreload-auth\nversion\nrestart\n", calls); + const forwarded = try fake.read(tmp.dir, "restarted-home"); + defer std.testing.allocator.free(forwarded); + try std.testing.expect(forwarded.len > 0); +} + +test "Scenario: Given a busy daemon then the reload defers without restarting" { + if (builtin.os.tag == .windows) return error.SkipZigTest; + var tmp = fs.tmpDir(.{}); + defer tmp.cleanup(); + try fake.install(tmp.dir, fake.reload_busy); + const home = try tmp.dir.realpathAlloc(std.testing.allocator, "."); + defer std.testing.allocator.free(home); + var client = try daemon.Client.init(std.testing.allocator, home); + defer client.deinit(); + try std.testing.expectEqual(daemon.Outcome.deferred, try client.ensure(1_000)); + const calls = try fake.read(tmp.dir, "lifecycle-calls"); + defer std.testing.allocator.free(calls); + try std.testing.expectEqualStrings("version\nreload-auth\n", calls); + try std.testing.expectError(error.FileNotFound, tmp.dir.openFile("restarted-home", .{})); +} + test "Scenario: Given a restart failure then retry makes another explicit restart attempt" { if (builtin.os.tag == .windows) return error.SkipZigTest; var tmp = fs.tmpDir(.{});