Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/commands/switch.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 8 additions & 0 deletions src/cli/output.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
28 changes: 27 additions & 1 deletion src/workflows/codex_daemon.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion src/workflows/switch.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
8 changes: 4 additions & 4 deletions tests/cli_integration_test.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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"));
Expand Down Expand Up @@ -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"));
Expand Down Expand Up @@ -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);
}
}

Expand Down
3 changes: 3 additions & 0 deletions tests/support/codex_daemon.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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, .{});
Expand Down
50 changes: 50 additions & 0 deletions tests/workflows_daemon_test.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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(.{});
Expand Down