From e13eb6c21637109ad8c81ccecf43a84777986fcd Mon Sep 17 00:00:00 2001 From: leroy-chen <8088746+leroy-chen@users.noreply.github.com> Date: Fri, 17 Jul 2026 15:43:29 +0800 Subject: [PATCH] feat(cli): persist daemon flags in config.json (#3824) Merge approved PR #5161. --- .../content/docs/environment-variables.ja.mdx | 32 +++ .../content/docs/environment-variables.ko.mdx | 32 +++ .../content/docs/environment-variables.mdx | 33 ++- .../content/docs/environment-variables.zh.mdx | 31 +++ server/cmd/multica/cmd_config.go | 237 +++++++++++++++-- server/cmd/multica/cmd_config_test.go | 245 +++++++++++++++++- server/cmd/multica/cmd_daemon.go | 234 +++++++++++++++-- .../cmd/multica/cmd_daemon_precedence_test.go | 243 +++++++++++++++++ server/internal/cli/config.go | 94 +++++++ server/internal/cli/config_test.go | 39 +++ 10 files changed, 1179 insertions(+), 41 deletions(-) create mode 100644 server/cmd/multica/cmd_daemon_precedence_test.go diff --git a/apps/docs/content/docs/environment-variables.ja.mdx b/apps/docs/content/docs/environment-variables.ja.mdx index 7fe230b3c..8568e878d 100644 --- a/apps/docs/content/docs/environment-variables.ja.mdx +++ b/apps/docs/content/docs/environment-variables.ja.mdx @@ -178,13 +178,45 @@ S3 の前段に CloudFront を置く場合、3 つの変数が適用されます | 変数 | デフォルト | 説明 | |---|---|---| | `MULTICA_SERVER_URL` | `ws://localhost:8080/ws` | サーバーアドレス(セルフホスト: 自身のドメインに置き換えてください) | +| `MULTICA_DAEMON_DEVICE_NAME` | ホスト名 | Runtimes 画面に表示されるデバイス名 | +| `MULTICA_AGENT_RUNTIME_NAME` | 組み込みデフォルト | ランタイムの表示名 | | `MULTICA_DAEMON_HEARTBEAT_INTERVAL` | `15s` | ハートビート間隔 | | `MULTICA_DAEMON_POLL_INTERVAL` | `3s` | タスクのポーリング間隔 | | `MULTICA_DAEMON_MAX_CONCURRENT_TASKS` | `20` | 最大同時タスク数 | +| `MULTICA_AGENT_TIMEOUT` | `0` | エージェント実行あたりの絶対壁時計上限。`0` は上限なしを意味し、実行時間は inactivity watchdog のみで抑えられます。ハードなリソース/コスト上限が欲しい場合は正の Go 期間を設定してください | +| `MULTICA_CODEX_SEMANTIC_INACTIVITY_TIMEOUT` | `10m` | Codex 固有の inactivity watchdog の待ち時間 | +| `MULTICA_CODEX_HANDSHAKE_TIMEOUT` | `30s` | Codex `app-server` 起動 RPC のタイムアウト | +| `MULTICA_DAEMON_AUTO_UPDATE` | cloud: `true`, self-host: `false` | `false`/`0`/`no`/`off` でデーモンの CLI 自動アップデートを無効化。`true`/`1`/`yes`/`on` はセルフホストのデーモンにアップストリーム更新を購読させます | +| `MULTICA_DAEMON_AUTO_UPDATE_INTERVAL` | `6h` | デーモンが CLI の新リリースを GitHub でチェックする間隔 | | `MULTICA__PATH` | CLI 名に一致 | 各 AI コーディングツールの実行ファイルへのパス(例: `MULTICA_CLAUDE_PATH`) | | `MULTICA__MODEL` | 空 | 各 AI コーディングツールのデフォルトモデル | | `MULTICA__ARGS` | 空 | バックエンドごとのデーモン全体のデフォルト CLI 引数。各タスクに対し、各エージェント自身の `custom_args` より前に適用される。`MULTICA_CLAUDE_ARGS`、`MULTICA_CODEX_ARGS`、`MULTICA_CODEBUDDY_ARGS` をサポート | +### デーモン設定を `config.json` に永続化する + +上表の各デーモンキー — `device_name`、`runtime_name`、`max_concurrent_tasks`、`poll_interval`、`heartbeat_interval`、`agent_timeout`、`codex_semantic_inactivity_timeout`、`codex_handshake_timeout`、`disable_auto_update`、`auto_update_check_interval` — は、プロファイルの `~/.multica/config.json`(もしくは `~/.multica/profiles//config.json`)に永続化することもできます。これにより、`daemon start` のたびにフラグを再指定したり環境変数を export したりする必要がなくなります。 + +```bash +multica config set device_name my-vm-custom-name +multica config set runtime_name worker-a +multica config set max_concurrent_tasks 8 +multica config set poll_interval 10s # Go の期間、必ず > 0 +multica config set heartbeat_interval 30s +multica config set agent_timeout 1h # 0s を渡すと壁時計上限を明示的に無効化 +multica config set codex_semantic_inactivity_timeout 15m +multica config set codex_handshake_timeout 45s +multica config set disable_auto_update true # 単方向: 自動アップデートを無効化する用途のみ +multica config set auto_update_check_interval 12h +``` + +期間系キーは正の Go 期間(`500ms`、`10s`、`1m30s` など)のみを受け付けます。`0s`、負値、パース不能な文字列は `config set` の時点で拒否されます。**ただし `agent_timeout` は例外で、ここでは `0s` は「壁時計上限を明示的に無効化する」意味を持つ有効値**です(負値はやはり拒否されます)。永続化した値をクリアして環境変数 / 組み込みデフォルトに戻すには空文字を渡してください: `multica config set poll_interval ""`。 + +`disable_auto_update` は単方向のブール値です。`true` にするとデーモンの CLI 自動アップデートを無効化し、`false`(もしくは `""` でクリア)にすると判定を `MULTICA_DAEMON_AUTO_UPDATE` / cloud-vs-self-host のデフォルトに委ねます。config ファイルから自動アップデートを *強制的に有効化* することはできません。 + +優先順位は上から順に: `daemon start` の `--flag` > `MULTICA_…` 環境変数 > `config.json` > 組み込みデフォルト。systemd unit などで環境変数を設定するとファイルより優先され、明示的な CLI フラグは両者よりさらに優先されます。 + +`--profile ` で同一ホスト上に複数のデーモンを走らせるときに特に有用です。プロファイルごとに `device_name` を独立して設定すれば、Runtimes 画面でも共有ホスト名で潰れることなく別々の行として区別できます。 + 各パラメータがデーモンの動作にどう影響するかの完全な説明は、[デーモンとランタイム](/daemon-runtimes)を参照してください。 ### デフォルトのエージェント引数(`MULTICA__ARGS`) diff --git a/apps/docs/content/docs/environment-variables.ko.mdx b/apps/docs/content/docs/environment-variables.ko.mdx index c06222177..ca0a98f39 100644 --- a/apps/docs/content/docs/environment-variables.ko.mdx +++ b/apps/docs/content/docs/environment-variables.ko.mdx @@ -178,13 +178,45 @@ S3 앞에 CloudFront를 두는 경우 세 가지 변수가 적용됩니다: `CLO | 변수 | 기본값 | 설명 | |---|---|---| | `MULTICA_SERVER_URL` | `ws://localhost:8080/ws` | 서버 주소 (자체 호스팅: 자신의 도메인으로 교체) | +| `MULTICA_DAEMON_DEVICE_NAME` | 호스트명 | Runtimes UI에 표시되는 장치 이름 | +| `MULTICA_AGENT_RUNTIME_NAME` | 기본값 | 런타임 표시 이름 | | `MULTICA_DAEMON_HEARTBEAT_INTERVAL` | `15s` | 하트비트 간격 | | `MULTICA_DAEMON_POLL_INTERVAL` | `3s` | 작업 폴링 간격 | | `MULTICA_DAEMON_MAX_CONCURRENT_TASKS` | `20` | 최대 동시 작업 수 | +| `MULTICA_AGENT_TIMEOUT` | `0` | 에이전트 실행당 절대 벽시계 상한. `0`은 상한 없음 — inactivity watchdog만이 긴 실행을 제한합니다. 하드 리소스/비용 상한이 필요하면 양의 Go 기간을 지정하세요 | +| `MULTICA_CODEX_SEMANTIC_INACTIVITY_TIMEOUT` | `10m` | Codex 전용 inactivity watchdog 시간 | +| `MULTICA_CODEX_HANDSHAKE_TIMEOUT` | `30s` | Codex `app-server` 시작 RPC 타임아웃 | +| `MULTICA_DAEMON_AUTO_UPDATE` | 클라우드: `true`, 자체 호스팅: `false` | `false`/`0`/`no`/`off`로 설정하면 데몬의 CLI 자동 업데이트 폴링이 꺼집니다. `true`/`1`/`yes`/`on`은 자체 호스팅 데몬이 업스트림 업데이트를 구독하도록 합니다 | +| `MULTICA_DAEMON_AUTO_UPDATE_INTERVAL` | `6h` | 데몬이 새 CLI 릴리스를 GitHub에서 확인하는 주기 | | `MULTICA__PATH` | CLI 이름과 일치 | 각 AI 코딩 도구 실행 파일의 경로 (예: `MULTICA_CLAUDE_PATH`) | | `MULTICA__MODEL` | 비어 있음 | 각 AI 코딩 도구의 기본 모델 | | `MULTICA__ARGS` | 비어 있음 | 백엔드별 데몬 전역 기본 CLI 인자. 각 작업에 대해 각 에이전트 자체의 `custom_args`보다 먼저 적용됩니다. `MULTICA_CLAUDE_ARGS`, `MULTICA_CODEX_ARGS`, `MULTICA_CODEBUDDY_ARGS`를 지원 | +### `config.json`에 데몬 설정 유지하기 + +위 표의 모든 데몬 키 — `device_name`, `runtime_name`, `max_concurrent_tasks`, `poll_interval`, `heartbeat_interval`, `agent_timeout`, `codex_semantic_inactivity_timeout`, `codex_handshake_timeout`, `disable_auto_update`, `auto_update_check_interval` — 는 프로파일의 `~/.multica/config.json`(또는 `~/.multica/profiles//config.json`)에 유지할 수도 있어, `daemon start` 마다 플래그를 다시 전달하거나 환경 변수를 export 할 필요가 없습니다. + +```bash +multica config set device_name my-vm-custom-name +multica config set runtime_name worker-a +multica config set max_concurrent_tasks 8 +multica config set poll_interval 10s # Go 기간, 반드시 > 0 +multica config set heartbeat_interval 30s +multica config set agent_timeout 1h # 또는 0s로 벽시계 상한을 명시적으로 비활성화 +multica config set codex_semantic_inactivity_timeout 15m +multica config set codex_handshake_timeout 45s +multica config set disable_auto_update true # 단방향: 자동 업데이트를 끄는 용도로만 사용 +multica config set auto_update_check_interval 12h +``` + +기간 키는 양수인 Go 기간(`500ms`, `10s`, `1m30s` 등)만 허용합니다. `0s`, 음수, 파싱 불가능한 문자열은 `config set` 시점에 거부됩니다 — **단 `agent_timeout`은 예외로, 여기서 `0s`는 "벽시계 상한을 명시적으로 비활성화" 하는 의미 있는 값**입니다(음수는 여전히 거부됨). 이미 저장된 값을 지우고 환경 변수 / 내장 기본값으로 되돌리려면 빈 문자열을 전달하세요: `multica config set poll_interval ""`. + +`disable_auto_update`는 단방향 불리언입니다. `true`로 설정하면 데몬의 CLI 자동 업데이트 폴링이 꺼지고, `false`(또는 `""`로 클리어)로 설정하면 결정을 `MULTICA_DAEMON_AUTO_UPDATE` / 클라우드-vs-자체호스팅 기본값에 다시 넘깁니다. config 파일에서 자동 업데이트를 *강제로 켜는* 데는 사용할 수 없습니다. + +우선순위(위쪽이 우선): `daemon start`의 `--flag` > `MULTICA_…` 환경 변수 > `config.json` > 내장 기본값. systemd unit 등에서 설정한 라이브 환경 변수는 파일을 이깁니다. 명시적인 CLI 플래그는 둘 다 이깁니다. + +같은 호스트에서 `--profile `으로 여러 데몬을 실행할 때 특히 유용합니다: 프로파일마다 별도의 `device_name`을 지정하면 Runtimes UI에서 공유된 호스트명 아래로 뭉치지 않고 서로 다른 행으로 구분됩니다. + 각 파라미터가 데몬 동작에 어떻게 영향을 미치는지에 대한 전체 설명은 [데몬과 런타임](/daemon-runtimes)을 참고하세요. ### 기본 에이전트 인자 (`MULTICA__ARGS`) diff --git a/apps/docs/content/docs/environment-variables.mdx b/apps/docs/content/docs/environment-variables.mdx index 7b386e9a7..51d4fcf8b 100644 --- a/apps/docs/content/docs/environment-variables.mdx +++ b/apps/docs/content/docs/environment-variables.mdx @@ -178,10 +178,16 @@ The daemon runs on the user's local machine, and its config is read from local e | Variable | Default | Description | |---|---|---| | `MULTICA_SERVER_URL` | `ws://localhost:8080/ws` | Server address (self-host: replace with your domain) | +| `MULTICA_DAEMON_DEVICE_NAME` | hostname | Human-readable device label shown in the Runtimes UI | +| `MULTICA_AGENT_RUNTIME_NAME` | built-in default | Runtime display name | | `MULTICA_DAEMON_HEARTBEAT_INTERVAL` | `15s` | Heartbeat interval | | `MULTICA_DAEMON_POLL_INTERVAL` | `3s` | Task polling interval | | `MULTICA_DAEMON_MAX_CONCURRENT_TASKS` | `20` | Max concurrent tasks | -| `MULTICA_AGENT_TIMEOUT` | `0` | Absolute wall-clock limit for one task. `0` leaves long active runs uncapped and relies on the inactivity watchdogs | +| `MULTICA_AGENT_TIMEOUT` | `0` | Absolute wall-clock cap per agent run. `0` disables the cap and lets the inactivity watchdogs alone bound long runs. Set a positive Go duration when you want a hard resource/cost ceiling | +| `MULTICA_CODEX_SEMANTIC_INACTIVITY_TIMEOUT` | `10m` | Codex-specific inactivity watchdog window | +| `MULTICA_CODEX_HANDSHAKE_TIMEOUT` | `30s` | Codex `app-server` startup RPC timeout | +| `MULTICA_DAEMON_AUTO_UPDATE` | cloud: `true`, self-host: `false` | Set to `false`/`0`/`no`/`off` to turn off the daemon's periodic CLI self-update; `true`/`1`/`yes`/`on` opts a self-host daemon into upstream updates | +| `MULTICA_DAEMON_AUTO_UPDATE_INTERVAL` | `6h` | How often the daemon polls GitHub for a newer CLI release | | `MULTICA_AGENT_IDLE_WATCHDOG` | `30m` | Global no-message watchdog for runs with no tool in flight. `0` disables the whole watchdog, including provider-specific overrides | | `MULTICA_OPENCODE_IDLE_WATCHDOG` | `10m` | OpenCode-specific no-message window for missing first tokens or stalled streams. `0` falls back to `MULTICA_AGENT_IDLE_WATCHDOG`; larger values cannot extend that global bound | | `MULTICA_AGENT_TOOL_WATCHDOG` | `2h` | Silence budget while a tool call is in flight. `0` disables the stuck-tool backstop | @@ -191,6 +197,31 @@ The daemon runs on the user's local machine, and its config is read from local e For a full explanation of how each parameter affects daemon behavior, see [Daemon and runtimes](/daemon-runtimes). +### Persisting daemon settings in `config.json` + +Every daemon knob in the table above — `device_name`, `runtime_name`, `max_concurrent_tasks`, `poll_interval`, `heartbeat_interval`, `agent_timeout`, `codex_semantic_inactivity_timeout`, `codex_handshake_timeout`, `disable_auto_update`, `auto_update_check_interval` — can also be persisted in the profile's `~/.multica/config.json` (or `~/.multica/profiles//config.json`) so you don't have to re-pass flags or export env vars on every `daemon start`. Set them with: + +```bash +multica config set device_name my-vm-custom-name +multica config set runtime_name worker-a +multica config set max_concurrent_tasks 8 +multica config set poll_interval 10s # Go duration, must be > 0 +multica config set heartbeat_interval 30s +multica config set agent_timeout 1h # or 0s to explicitly disable the cap +multica config set codex_semantic_inactivity_timeout 15m +multica config set codex_handshake_timeout 45s +multica config set disable_auto_update true # single-direction: only turns auto-update off +multica config set auto_update_check_interval 12h +``` + +Duration keys accept any positive Go duration (`500ms`, `10s`, `1m30s`, ...). Values ≤ 0 (`0s`, `-5s`) and un-parseable strings are rejected at `config set` time — **except `agent_timeout`, where `0s` is a meaningful "disable the wall-clock cap" sentinel** (negative values are still rejected). To clear a persisted value and fall back to the env / built-in default, pass an empty string: `multica config set poll_interval ""`. + +`disable_auto_update` is a single-direction bool: setting it to `true` turns off the daemon's periodic CLI self-update, and setting it to `false` (or clearing with `""`) hands the decision back to `MULTICA_DAEMON_AUTO_UPDATE` / the cloud-vs-self-host default. It cannot be used to *force* auto-update on from the config file. + +Precedence, highest first: `--flag` on `daemon start` > `MULTICA_…` env var > `config.json` value > built-in default. A live env override (typical for systemd units) always wins over the file, and an explicit CLI flag wins over both. + +This is especially useful when running multiple daemons on one host via `--profile `: each profile can pin its own `device_name` so the Runtimes UI shows distinct rows instead of collapsing them under the shared hostname. + ### Default agent arguments (`MULTICA__ARGS`) These set a **fleet-wide default** layer of CLI flags for a backend — a convenient way to apply a default cost or resource baseline (for example `--max-turns`) across every agent on a daemon without editing each agent's `custom_args` individually. This is a default layer, not a hard ceiling: per-agent `custom_args` are appended afterward and can override it (see **Precedence** below). diff --git a/apps/docs/content/docs/environment-variables.zh.mdx b/apps/docs/content/docs/environment-variables.zh.mdx index 6484d0df5..0a709deb3 100644 --- a/apps/docs/content/docs/environment-variables.zh.mdx +++ b/apps/docs/content/docs/environment-variables.zh.mdx @@ -178,6 +178,8 @@ API 返回的 `download_url` 在未配置 CloudFront 签名时会指向 `GET /ap | 环境变量 | 默认值 | 说明 | |---|---|---| | `MULTICA_SERVER_URL` | `ws://localhost:8080/ws` | server 地址(self-host 换成你的域名)| +| `MULTICA_DAEMON_DEVICE_NAME` | 主机名 | 运行时页面显示的设备名 | +| `MULTICA_AGENT_RUNTIME_NAME` | 内置默认 | 运行时显示名 | | `MULTICA_DAEMON_HEARTBEAT_INTERVAL` | `15s` | 心跳频率 | | `MULTICA_DAEMON_POLL_INTERVAL` | `3s` | 任务轮询频率 | | `MULTICA_DAEMON_MAX_CONCURRENT_TASKS` | `20` | 并发任务上限 | @@ -185,12 +187,41 @@ API 返回的 `download_url` 在未配置 CloudFront 签名时会指向 `GET /ap | `MULTICA_AGENT_IDLE_WATCHDOG` | `30m` | 空闲看门狗:backend 持续静默(无消息、消息队列为空、且没有工具在途)这么久就 force-stop。`0` = 关闭整套看门狗,包括 provider 专用阈值 | | `MULTICA_OPENCODE_IDLE_WATCHDOG` | `10m` | OpenCode 首 token 未到或流式回复中断时的专用无消息阈值;`0` = 回退到 `MULTICA_AGENT_IDLE_WATCHDOG`,更大的值不能放宽全局阈值 | | `MULTICA_AGENT_TOOL_WATCHDOG` | `2h` | 工具在途时的静默上限:某个工具调用发出后长时间无任何输出(疑似卡死的子进程)这么久就 force-stop。`0` = 关闭该兜底(在途工具永不被停)| +| `MULTICA_CODEX_SEMANTIC_INACTIVITY_TIMEOUT` | `10m` | Codex 专用的静默看门狗窗口 | +| `MULTICA_CODEX_HANDSHAKE_TIMEOUT` | `30s` | Codex `app-server` 启动 RPC 超时 | +| `MULTICA_DAEMON_AUTO_UPDATE` | cloud:`true`,self-host:`false` | 设为 `false`/`0`/`no`/`off` 关闭守护进程的 CLI 自动更新轮询;`true`/`1`/`yes`/`on` 让 self-host 守护进程订阅上游更新 | +| `MULTICA_DAEMON_AUTO_UPDATE_INTERVAL` | `6h` | 守护进程轮询 GitHub 检查 CLI 新版本的间隔 | | `MULTICA__PATH` | 对应 CLI 名 | 各 AI 编程工具的可执行文件路径(如 `MULTICA_CLAUDE_PATH`)| | `MULTICA__MODEL` | 空 | 各 AI 编程工具的默认模型 | | `MULTICA__ARGS` | 空 | 守护进程级的默认 CLI 参数,作用于该后端的每个任务,并排在各智能体自身的 `custom_args` 之前。支持 `MULTICA_CLAUDE_ARGS`、`MULTICA_CODEX_ARGS`、`MULTICA_CODEBUDDY_ARGS` | 完整解释每个参数对守护进程行为的影响,见 [守护进程与运行时](/daemon-runtimes)。 +### 把守护进程参数持久化到 `config.json` + +上面表格里的每个守护进程键 —— `device_name`、`runtime_name`、`max_concurrent_tasks`、`poll_interval`、`heartbeat_interval`、`agent_timeout`、`codex_semantic_inactivity_timeout`、`codex_handshake_timeout`、`disable_auto_update`、`auto_update_check_interval` —— 都可以持久化到 profile 对应的 `~/.multica/config.json`(或 `~/.multica/profiles//config.json`),这样就不用每次 `daemon start` 都重传 flag 或 export 环境变量。写入方式: + +```bash +multica config set device_name my-vm-custom-name +multica config set runtime_name worker-a +multica config set max_concurrent_tasks 8 +multica config set poll_interval 10s # Go 时长格式,必须 > 0 +multica config set heartbeat_interval 30s +multica config set agent_timeout 1h # 或 0s 显式关闭墙钟上限 +multica config set codex_semantic_inactivity_timeout 15m +multica config set codex_handshake_timeout 45s +multica config set disable_auto_update true # 单向:只能关闭自动更新 +multica config set auto_update_check_interval 12h +``` + +时长类键只接受**正的** Go 时长(`500ms`、`10s`、`1m30s` 等)。`0s`、负值以及无法解析的字符串在 `config set` 阶段就会被拒绝——**唯一例外是 `agent_timeout`:这里 `0s` 是有意义的"显式关闭墙钟上限"哨兵**(负值仍然被拒)。若要清除已持久化的值、退回到环境变量 / 内置默认,传空串:`multica config set poll_interval ""`。 + +`disable_auto_update` 是单向布尔:设为 `true` 会关闭守护进程的 CLI 自动更新轮询;设为 `false`(或用 `""` 清除)会把决策交回 `MULTICA_DAEMON_AUTO_UPDATE` / cloud-vs-self-host 默认。它不能从 config 文件强制开启自动更新。 + +优先级从高到低:`--flag`(`daemon start` 命令行)> `MULTICA_…` 环境变量 > `config.json` > 内置默认。运行时环境变量(如 systemd unit 里设的)优先级高于 config 文件;显式命令行 flag 又高于两者。 + +当你在同一台机器上用 `--profile ` 起多个 daemon 时,尤为好用:给每个 profile 独立设置 `device_name`,运行时页面就能看到独立的机器,而不是全部挤在同一个主机名下。 + ### 默认智能体参数(`MULTICA__ARGS`) 为某个后端设置一层**全机队默认**的 CLI 参数——可以方便地给一台守护进程上的所有智能体应用一个默认的成本或资源基线(例如 `--max-turns`),而不必逐个修改每个智能体的 `custom_args`。这是一层默认值,而不是不可突破的硬上限:每个智能体自己的 `custom_args` 会追加在后面,并可以覆盖它(见下方**优先级**)。 diff --git a/server/cmd/multica/cmd_config.go b/server/cmd/multica/cmd_config.go index 532fd5116..dd38ca80d 100644 --- a/server/cmd/multica/cmd_config.go +++ b/server/cmd/multica/cmd_config.go @@ -3,6 +3,9 @@ package main import ( "fmt" "os" + "strconv" + "strings" + "time" "github.com/spf13/cobra" @@ -21,12 +24,50 @@ var configShowCmd = &cobra.Command{ RunE: runConfigShow, } +// configSetSupportedKeys is the whitelist consumed by both `config set`'s +// switch and its --help output, so a new key gets validation, error text, +// and documentation in one place. Order matches configShow output. +var configSetSupportedKeys = []string{ + "server_url", + "app_url", + "workspace_id", + "device_name", + "runtime_name", + "max_concurrent_tasks", + "poll_interval", + "heartbeat_interval", + "agent_timeout", + "codex_semantic_inactivity_timeout", + "codex_handshake_timeout", + "disable_auto_update", + "auto_update_check_interval", +} + var configSetCmd = &cobra.Command{ Use: "set ", Short: "Set a CLI configuration value", - Long: "Supported keys: server_url, app_url, workspace_id", - Args: exactArgs(2), - RunE: runConfigSet, + Long: "Supported keys: " + + "server_url, app_url, workspace_id, " + + "device_name, runtime_name, max_concurrent_tasks, poll_interval, " + + "heartbeat_interval, agent_timeout, " + + "codex_semantic_inactivity_timeout, codex_handshake_timeout, " + + "disable_auto_update, auto_update_check_interval.\n\n" + + "The daemon keys (device_name, runtime_name, max_concurrent_tasks, " + + "poll_interval, heartbeat_interval, agent_timeout, " + + "codex_semantic_inactivity_timeout, codex_handshake_timeout, " + + "disable_auto_update, auto_update_check_interval) mirror their " + + "--flag / env counterparts and are read by `daemon start` when " + + "neither the flag nor the env var is set. " + + "Precedence: --flag > MULTICA_… env > config.json > built-in default. " + + "Duration keys take a positive Go duration (e.g. '10s', '500ms', '1m30s'); " + + "'0s' and negative values are rejected — except agent_timeout, where " + + "'0s' is meaningful and explicitly disables the wall-clock cap. " + + "disable_auto_update takes 'true' or 'false' (single-direction: setting " + + "it to 'true' turns auto-update off, 'false' clears the override so " + + "env/default decides). Pass an empty string to clear a persisted " + + "value (e.g. `config set poll_interval \"\"`).", + Args: exactArgs(2), + RunE: runConfigSet, } func init() { @@ -46,9 +87,19 @@ func runConfigShow(cmd *cobra.Command, _ []string) error { if profile != "" { fmt.Fprintf(os.Stdout, "Profile: %s\n", profile) } - fmt.Fprintf(os.Stdout, "server_url: %s\n", valueOrDefault(cfg.ServerURL, "(not set)")) - fmt.Fprintf(os.Stdout, "app_url: %s\n", valueOrDefault(cfg.AppURL, "(not set)")) - fmt.Fprintf(os.Stdout, "workspace_id: %s\n", valueOrDefault(cfg.WorkspaceID, "(not set)")) + fmt.Fprintf(os.Stdout, "%-34s %s\n", "server_url:", valueOrDefault(cfg.ServerURL, "(not set)")) + fmt.Fprintf(os.Stdout, "%-34s %s\n", "app_url:", valueOrDefault(cfg.AppURL, "(not set)")) + fmt.Fprintf(os.Stdout, "%-34s %s\n", "workspace_id:", valueOrDefault(cfg.WorkspaceID, "(not set)")) + fmt.Fprintf(os.Stdout, "%-34s %s\n", "device_name:", valueOrDefault(cfg.DeviceName, "(not set)")) + fmt.Fprintf(os.Stdout, "%-34s %s\n", "runtime_name:", valueOrDefault(cfg.RuntimeName, "(not set)")) + fmt.Fprintf(os.Stdout, "%-34s %s\n", "max_concurrent_tasks:", intOrDefault(cfg.MaxConcurrentTasks, "(not set)")) + fmt.Fprintf(os.Stdout, "%-34s %s\n", "poll_interval:", valueOrDefault(cfg.PollInterval, "(not set)")) + fmt.Fprintf(os.Stdout, "%-34s %s\n", "heartbeat_interval:", valueOrDefault(cfg.HeartbeatInterval, "(not set)")) + fmt.Fprintf(os.Stdout, "%-34s %s\n", "agent_timeout:", agentTimeoutDisplay(cfg.AgentTimeout)) + fmt.Fprintf(os.Stdout, "%-34s %s\n", "codex_semantic_inactivity_timeout:", valueOrDefault(cfg.CodexSemanticInactivityTimeout, "(not set)")) + fmt.Fprintf(os.Stdout, "%-34s %s\n", "codex_handshake_timeout:", valueOrDefault(cfg.CodexHandshakeTimeout, "(not set)")) + fmt.Fprintf(os.Stdout, "%-34s %t\n", "disable_auto_update:", cfg.DisableAutoUpdate) + fmt.Fprintf(os.Stdout, "%-34s %s\n", "auto_update_check_interval:", valueOrDefault(cfg.AutoUpdateCheckInterval, "(not set)")) return nil } @@ -61,15 +112,8 @@ func runConfigSet(cmd *cobra.Command, args []string) error { return err } - switch key { - case "server_url": - cfg.ServerURL = value - case "app_url": - cfg.AppURL = value - case "workspace_id": - cfg.WorkspaceID = value - default: - return fmt.Errorf("unknown config key %q (supported: server_url, app_url, workspace_id)", key) + if err := applyConfigSet(&cfg, key, value); err != nil { + return err } if err := cli.SaveCLIConfigForProfile(cfg, profile); err != nil { @@ -80,9 +124,172 @@ func runConfigSet(cmd *cobra.Command, args []string) error { return nil } +// applyConfigSet mutates cfg in place per (key, value). Split out from +// runConfigSet so tests can exercise the validation branches without +// touching disk. Unknown keys and bad values return errors; the caller +// only saves when this returns nil. +// +// Validation rules keep the on-disk config sane at write time so the +// daemon doesn't have to re-check on every start: an empty duration +// string is treated as "clear the field" (parity with `config set +// server_url ""` clearing a URL), a non-parseable duration is rejected +// up front rather than being persisted and re-erroring later. +func applyConfigSet(cfg *cli.CLIConfig, key, value string) error { + switch key { + case "server_url": + cfg.ServerURL = value + case "app_url": + cfg.AppURL = value + case "workspace_id": + cfg.WorkspaceID = value + case "device_name": + cfg.DeviceName = value + case "runtime_name": + cfg.RuntimeName = value + case "max_concurrent_tasks": + if value == "" { + cfg.MaxConcurrentTasks = 0 + return nil + } + n, err := strconv.Atoi(value) + if err != nil { + return fmt.Errorf("max_concurrent_tasks must be an integer: %w", err) + } + if n < 0 { + return fmt.Errorf("max_concurrent_tasks must be >= 0 (got %d)", n) + } + cfg.MaxConcurrentTasks = n + case "poll_interval": + if value == "" { + cfg.PollInterval = "" + return nil + } + d, err := time.ParseDuration(value) + if err != nil { + return fmt.Errorf("poll_interval must be a Go duration (e.g. 10s, 500ms): %w", err) + } + // Reject zero and negative durations. Persisting "0s" would look + // configured in `config show` but be silently ignored at daemon + // start (the resolver only substitutes strictly positive values), + // which is exactly the trap reported in #3824's review. Empty + // string is the one and only way to clear a previously persisted + // value. + if d <= 0 { + return fmt.Errorf("poll_interval must be positive (got %s); use `config set poll_interval \"\"` to clear it", d) + } + cfg.PollInterval = value + case "heartbeat_interval": + if err := assignPositiveDuration(&cfg.HeartbeatInterval, key, value); err != nil { + return err + } + case "agent_timeout": + // agent_timeout is the one duration knob where "0s" is a + // meaningful persisted value (it explicitly disables the + // wall-clock cap; see cli.CLIConfig.AgentTimeout). Store the raw + // string via a pointer so we can distinguish "not set" (nil) + // from "disabled" (non-nil, "0s") and any positive value. + if value == "" { + cfg.AgentTimeout = nil + return nil + } + d, err := time.ParseDuration(value) + if err != nil { + return fmt.Errorf("agent_timeout must be a Go duration (e.g. 10m, 0s to disable): %w", err) + } + if d < 0 { + return fmt.Errorf("agent_timeout must be >= 0 (got %s); use 0s to disable the cap or \"\" to clear the persisted value", d) + } + s := value + cfg.AgentTimeout = &s + case "codex_semantic_inactivity_timeout": + if err := assignPositiveDuration(&cfg.CodexSemanticInactivityTimeout, key, value); err != nil { + return err + } + case "codex_handshake_timeout": + if err := assignPositiveDuration(&cfg.CodexHandshakeTimeout, key, value); err != nil { + return err + } + case "disable_auto_update": + if value == "" { + cfg.DisableAutoUpdate = false + return nil + } + b, err := strconv.ParseBool(value) + if err != nil { + return fmt.Errorf("disable_auto_update must be 'true' or 'false' (got %q)", value) + } + cfg.DisableAutoUpdate = b + case "auto_update_check_interval": + if err := assignPositiveDuration(&cfg.AutoUpdateCheckInterval, key, value); err != nil { + return err + } + default: + return fmt.Errorf("unknown config key %q (supported: %s)", key, joinKeys(configSetSupportedKeys)) + } + return nil +} + +// assignPositiveDuration parses value as a strictly-positive Go duration +// and writes the raw string into dst. Shared by every persisted daemon +// duration knob except agent_timeout, whose zero value is meaningful. +// Empty string clears the field. +func assignPositiveDuration(dst *string, key, value string) error { + if value == "" { + *dst = "" + return nil + } + normalized := strings.TrimSpace(value) + d, err := time.ParseDuration(normalized) + if err != nil { + return fmt.Errorf("%s must be a Go duration (e.g. 10s, 500ms): %w", key, err) + } + if d <= 0 { + return fmt.Errorf("%s must be positive (got %s); use `config set %s \"\"` to clear it", key, d, key) + } + *dst = normalized + return nil +} + +// agentTimeoutDisplay renders the tri-state agent_timeout value for +// `config show`. nil = not persisted (fall through to env/default); +// non-nil "0s" = explicitly disabled; any other non-nil = the persisted +// duration string. +func agentTimeoutDisplay(v *string) string { + if v == nil { + return "(not set)" + } + if *v == "" { + return "(not set)" + } + if d, err := time.ParseDuration(*v); err == nil && d == 0 { + return *v + " (disabled)" + } + return *v +} + func valueOrDefault(v, fallback string) string { if v == "" { return fallback } return v } + +func intOrDefault(v int, fallback string) string { + if v == 0 { + return fallback + } + return strconv.Itoa(v) +} + +// joinKeys renders the supported-keys list for the error message. Cheap +// to keep tiny rather than pulling in strings.Join through Sprintf tricks. +func joinKeys(keys []string) string { + out := "" + for i, k := range keys { + if i > 0 { + out += ", " + } + out += k + } + return out +} diff --git a/server/cmd/multica/cmd_config_test.go b/server/cmd/multica/cmd_config_test.go index 19ab0feff..2879e295e 100644 --- a/server/cmd/multica/cmd_config_test.go +++ b/server/cmd/multica/cmd_config_test.go @@ -3,6 +3,7 @@ package main import ( "strings" "testing" + "time" "github.com/spf13/cobra" @@ -53,16 +54,36 @@ func TestRunConfigShowIncludesProfileAndDefaults(t *testing.T) { if err != nil { t.Fatalf("runConfigShow: %v", err) } - for _, want := range []string{ - "Profile: empty", - "server_url: (not set)", - "app_url: (not set)", - "workspace_id: (not set)", + // Match on "key:" + eventual "(not set)" — the column width is a + // formatting detail, not something worth pinning byte-for-byte. + for _, key := range []string{ + "server_url:", + "app_url:", + "workspace_id:", + "device_name:", + "runtime_name:", + "max_concurrent_tasks:", + "poll_interval:", + "heartbeat_interval:", + "agent_timeout:", + "codex_semantic_inactivity_timeout:", + "codex_handshake_timeout:", + "disable_auto_update:", + "auto_update_check_interval:", } { - if !strings.Contains(out, want) { - t.Fatalf("runConfigShow output missing %q:\n%s", want, out) + if !strings.Contains(out, key) { + t.Fatalf("runConfigShow output missing %q:\n%s", key, out) } } + if !strings.Contains(out, "(not set)") { + t.Fatalf("runConfigShow output missing (not set) placeholder:\n%s", out) + } + if !strings.Contains(out, "disable_auto_update:") || !strings.Contains(out, "false") { + t.Fatalf("runConfigShow disable_auto_update default should print false:\n%s", out) + } + if !strings.Contains(out, "Profile: empty") { + t.Fatalf("runConfigShow missing profile header:\n%s", out) + } } func TestRunConfigSetRejectsUnknownKey(t *testing.T) { @@ -74,3 +95,213 @@ func TestRunConfigSetRejectsUnknownKey(t *testing.T) { t.Fatalf("runConfigSet error = %v, want unknown key", err) } } + +// TestApplyConfigSetSupportsDaemonKeys locks in the daemon keys added +// for issue #3824 (device_name, runtime_name, max_concurrent_tasks, +// poll_interval) plus the follow-up knobs that use the same shape +// (heartbeat_interval, codex_*, disable_auto_update, +// auto_update_check_interval). applyConfigSet is the split-out validator +// so tests don't have to touch disk on every case. +func TestApplyConfigSetSupportsDaemonKeys(t *testing.T) { + t.Parallel() + + cfg := cli.CLIConfig{} + pairs := []struct{ key, val string }{ + {"device_name", "vm-1-custom-name"}, + {"runtime_name", "worker-a"}, + {"max_concurrent_tasks", "4"}, + {"poll_interval", "10s"}, + {"heartbeat_interval", "5s"}, + {"codex_semantic_inactivity_timeout", "15m"}, + {"codex_handshake_timeout", "45s"}, + {"disable_auto_update", "true"}, + {"auto_update_check_interval", "12h"}, + } + for _, p := range pairs { + if err := applyConfigSet(&cfg, p.key, p.val); err != nil { + t.Fatalf("applyConfigSet(%s=%s): %v", p.key, p.val, err) + } + } + if cfg.DeviceName != "vm-1-custom-name" || + cfg.RuntimeName != "worker-a" || + cfg.MaxConcurrentTasks != 4 || + cfg.PollInterval != "10s" || + cfg.HeartbeatInterval != "5s" || + cfg.CodexSemanticInactivityTimeout != "15m" || + cfg.CodexHandshakeTimeout != "45s" || + cfg.DisableAutoUpdate != true || + cfg.AutoUpdateCheckInterval != "12h" { + t.Fatalf("cfg after set = %+v", cfg) + } +} + +func TestApplyConfigSetPositiveDurationRoundTripsToDaemonResolver(t *testing.T) { + const envName = "TEST_MULTICA_PERSISTED_DURATION" + t.Setenv(envName, "") + + cases := []struct { + key string + read func(cli.CLIConfig) string + }{ + {"heartbeat_interval", func(cfg cli.CLIConfig) string { return cfg.HeartbeatInterval }}, + {"codex_semantic_inactivity_timeout", func(cfg cli.CLIConfig) string { return cfg.CodexSemanticInactivityTimeout }}, + {"codex_handshake_timeout", func(cfg cli.CLIConfig) string { return cfg.CodexHandshakeTimeout }}, + {"auto_update_check_interval", func(cfg cli.CLIConfig) string { return cfg.AutoUpdateCheckInterval }}, + } + for _, tc := range cases { + t.Run(tc.key, func(t *testing.T) { + cfg := cli.CLIConfig{} + if err := applyConfigSet(&cfg, tc.key, " 5s "); err != nil { + t.Fatalf("applyConfigSet: %v", err) + } + + stored := tc.read(cfg) + if stored != "5s" { + t.Fatalf("stored value = %q, want canonical %q", stored, "5s") + } + got, err := resolveDaemonDurationOverride(0, envName, stored) + if err != nil { + t.Fatalf("resolve persisted value: %v", err) + } + if got != 5*time.Second { + t.Fatalf("resolved duration = %v, want %v", got, 5*time.Second) + } + }) + } +} + +// TestApplyConfigSetAgentTimeoutTriState pins the agent_timeout +// pointer-based semantics: "" clears, "0s" persists as an explicit +// "disabled" sentinel, positive durations persist as-is. Negative +// values are rejected up front so the daemon doesn't fall back to the +// default and quietly lose the operator's intent. +func TestApplyConfigSetAgentTimeoutTriState(t *testing.T) { + t.Parallel() + + cfg := cli.CLIConfig{} + if err := applyConfigSet(&cfg, "agent_timeout", "30m"); err != nil { + t.Fatalf("set 30m: %v", err) + } + if cfg.AgentTimeout == nil || *cfg.AgentTimeout != "30m" { + t.Fatalf("AgentTimeout = %v, want &\"30m\"", cfg.AgentTimeout) + } + if err := applyConfigSet(&cfg, "agent_timeout", "0s"); err != nil { + t.Fatalf("set 0s: %v", err) + } + if cfg.AgentTimeout == nil || *cfg.AgentTimeout != "0s" { + t.Fatalf("AgentTimeout = %v, want explicit &\"0s\"", cfg.AgentTimeout) + } + if err := applyConfigSet(&cfg, "agent_timeout", ""); err != nil { + t.Fatalf("clear: %v", err) + } + if cfg.AgentTimeout != nil { + t.Fatalf("AgentTimeout = %v, want nil after clear", cfg.AgentTimeout) + } + if err := applyConfigSet(&cfg, "agent_timeout", "-1s"); err == nil { + t.Fatalf("expected error for negative agent_timeout") + } +} + +// TestApplyConfigSetRejectsBadValues covers the typed keys — ints must +// be non-negative, most durations must be strictly positive, booleans +// must parse. Catching bad values at write time keeps the error next to +// the user's typo instead of surfacing later at daemon start. +// +// The "poll zero" case is the regression from #3824's review: `config +// set poll_interval 0s` used to be accepted and persisted, then +// silently ignored at daemon start because the resolver only +// substitutes strictly positive durations. Reject it up front so +// `config show` and daemon behavior agree. +func TestApplyConfigSetRejectsBadValues(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + key string + value string + want string + }{ + {"max non-int", "max_concurrent_tasks", "many", "integer"}, + {"max negative", "max_concurrent_tasks", "-1", ">= 0"}, + {"poll bad duration", "poll_interval", "10", "duration"}, + {"poll zero", "poll_interval", "0s", "positive"}, + {"poll negative", "poll_interval", "-5s", "positive"}, + {"heartbeat bad duration", "heartbeat_interval", "abc", "duration"}, + {"heartbeat zero", "heartbeat_interval", "0s", "positive"}, + {"codex semantic zero", "codex_semantic_inactivity_timeout", "0s", "positive"}, + {"codex handshake bad", "codex_handshake_timeout", "10", "duration"}, + {"agent_timeout bad duration", "agent_timeout", "abc", "duration"}, + {"agent_timeout negative", "agent_timeout", "-1s", ">= 0"}, + {"disable_auto_update bad bool", "disable_auto_update", "maybe", "true"}, + {"auto_update_check_interval zero", "auto_update_check_interval", "0s", "positive"}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + cfg := cli.CLIConfig{} + err := applyConfigSet(&cfg, tc.key, tc.value) + if err == nil { + t.Fatalf("expected error containing %q, got nil", tc.want) + } + if !strings.Contains(err.Error(), tc.want) { + t.Fatalf("error = %q; want to contain %q", err.Error(), tc.want) + } + }) + } +} + +// TestApplyConfigSetPollIntervalZeroDoesNotOverwrite ensures a rejected +// "0s" write leaves any previously persisted value intact — the caller +// only saves when applyConfigSet returns nil, but pin the invariant so +// a future refactor can't quietly drop it. +func TestApplyConfigSetPollIntervalZeroDoesNotOverwrite(t *testing.T) { + t.Parallel() + + cfg := cli.CLIConfig{PollInterval: "10s"} + if err := applyConfigSet(&cfg, "poll_interval", "0s"); err == nil { + t.Fatalf("expected error for poll_interval=0s, got nil") + } + if cfg.PollInterval != "10s" { + t.Fatalf("PollInterval mutated on rejected write: got %q, want %q", cfg.PollInterval, "10s") + } +} + +// TestApplyConfigSetEmptyStringClearsTypedKeys — parity with the existing +// "set server_url ''" clearing behavior. For int and duration keys, "" +// resets to the zero value rather than surfacing an Atoi/ParseDuration +// error the user didn't ask for. +func TestApplyConfigSetEmptyStringClearsTypedKeys(t *testing.T) { + t.Parallel() + + cfg := cli.CLIConfig{MaxConcurrentTasks: 8, PollInterval: "10s"} + if err := applyConfigSet(&cfg, "max_concurrent_tasks", ""); err != nil { + t.Fatalf("clear max_concurrent_tasks: %v", err) + } + if err := applyConfigSet(&cfg, "poll_interval", ""); err != nil { + t.Fatalf("clear poll_interval: %v", err) + } + if cfg.MaxConcurrentTasks != 0 || cfg.PollInterval != "" { + t.Fatalf("cfg after clear = %+v", cfg) + } +} + +// TestPollIntervalRoundTripThroughDuration ensures the string persisted +// by applyConfigSet parses back to the same Go duration the daemon will +// consume at start-up. Cheap sanity check — the daemon calls +// time.ParseDuration on the same string. +func TestPollIntervalRoundTripThroughDuration(t *testing.T) { + t.Parallel() + + cfg := cli.CLIConfig{} + if err := applyConfigSet(&cfg, "poll_interval", "1m30s"); err != nil { + t.Fatalf("set: %v", err) + } + got, err := time.ParseDuration(cfg.PollInterval) + if err != nil { + t.Fatalf("re-parse %q: %v", cfg.PollInterval, err) + } + if want := time.Minute + 30*time.Second; got != want { + t.Fatalf("parsed = %v, want %v", got, want) + } +} diff --git a/server/cmd/multica/cmd_daemon.go b/server/cmd/multica/cmd_daemon.go index ba7886db6..168d52d77 100644 --- a/server/cmd/multica/cmd_daemon.go +++ b/server/cmd/multica/cmd_daemon.go @@ -621,6 +621,12 @@ func runDaemonForeground(cmd *cobra.Command) error { profile := resolveProfile(cmd) + // Load the profile config once — several daemon knobs fall back to + // values persisted here when both the CLI flag and the env var are + // unset. Errors reading the config are non-fatal for anything other + // than server URL: an unreadable / missing config only means "no + // persisted overrides", not "cannot start". + fileCfg, _ := cli.LoadCLIConfigForProfile(profile) // Pick the log sink. A user who runs `daemon start --foreground` in a shell // keeps live, colored logging on their terminal (a documented debugging // path — see docs troubleshooting). A detached/background child, whose @@ -650,40 +656,97 @@ func runDaemonForeground(cmd *cobra.Command) error { } serverURL := resolveDaemonServerURL(cmd, profile) + // Each persistable daemon knob follows the same three-tier precedence: + // + // --flag > MULTICA_… env > config.json > built-in default + // + // The flag/env pair is resolved inside daemon.LoadConfig (via + // Overrides + envOrDefault). Here we only substitute the config-file + // value when BOTH the flag AND the env are unset, so a live env + // override (typical for systemd units) always wins over the file. This + // matches server_url's precedence above and resolves #3824. + deviceNameFlag := resolveDaemonStringOverride( + flagString(cmd, "device-name"), + "MULTICA_DAEMON_DEVICE_NAME", + fileCfg.DeviceName, + ) + runtimeNameFlag := resolveDaemonStringOverride( + flagString(cmd, "runtime-name"), + "MULTICA_AGENT_RUNTIME_NAME", + fileCfg.RuntimeName, + ) + overrides := daemon.Overrides{ ServerURL: serverURL, DaemonID: flagString(cmd, "daemon-id"), - DeviceName: flagString(cmd, "device-name"), - RuntimeName: flagString(cmd, "runtime-name"), + DeviceName: deviceNameFlag, + RuntimeName: runtimeNameFlag, Profile: profile, HealthPort: healthPortForProfile(profile), } - if d, _ := cmd.Flags().GetDuration("poll-interval"); d > 0 { - overrides.PollInterval = d + pollFlag, _ := cmd.Flags().GetDuration("poll-interval") + pollOverride, err := resolveDaemonDurationOverride(pollFlag, "MULTICA_DAEMON_POLL_INTERVAL", fileCfg.PollInterval) + if err != nil { + return err } - if d, _ := cmd.Flags().GetDuration("heartbeat-interval"); d > 0 { - overrides.HeartbeatInterval = d + if pollOverride > 0 { + overrides.PollInterval = pollOverride + } + heartbeatFlag, _ := cmd.Flags().GetDuration("heartbeat-interval") + heartbeatOverride, err := resolveDaemonDurationOverride(heartbeatFlag, "MULTICA_DAEMON_HEARTBEAT_INTERVAL", fileCfg.HeartbeatInterval) + if err != nil { + return err + } + if heartbeatOverride > 0 { + overrides.HeartbeatInterval = heartbeatOverride } // Distinguish "flag not passed" from an explicit `--agent-timeout 0` so a - // user can turn off an env-configured cap from the CLI. - if cmd.Flags().Changed("agent-timeout") { - d, _ := cmd.Flags().GetDuration("agent-timeout") - overrides.AgentTimeout = &d + // user can turn off an env-configured cap from the CLI. The persisted + // config.json value uses the same tri-state (see cli.CLIConfig.AgentTimeout) + // so `config set agent_timeout 0s` can survive daemon restarts. + agentTimeoutOverride, err := resolveDaemonAgentTimeoutOverride(cmd, "MULTICA_AGENT_TIMEOUT", fileCfg.AgentTimeout) + if err != nil { + return err } - if d, _ := cmd.Flags().GetDuration("codex-semantic-inactivity-timeout"); d > 0 { - overrides.CodexSemanticInactivityTimeout = d + if agentTimeoutOverride != nil { + overrides.AgentTimeout = agentTimeoutOverride } - if d, _ := cmd.Flags().GetDuration("codex-handshake-timeout"); d > 0 { - overrides.CodexHandshakeTimeout = d + semanticFlag, _ := cmd.Flags().GetDuration("codex-semantic-inactivity-timeout") + semanticOverride, err := resolveDaemonDurationOverride(semanticFlag, "MULTICA_CODEX_SEMANTIC_INACTIVITY_TIMEOUT", fileCfg.CodexSemanticInactivityTimeout) + if err != nil { + return err } - if n, _ := cmd.Flags().GetInt("max-concurrent-tasks"); n > 0 { + if semanticOverride > 0 { + overrides.CodexSemanticInactivityTimeout = semanticOverride + } + handshakeFlag, _ := cmd.Flags().GetDuration("codex-handshake-timeout") + handshakeOverride, err := resolveDaemonDurationOverride(handshakeFlag, "MULTICA_CODEX_HANDSHAKE_TIMEOUT", fileCfg.CodexHandshakeTimeout) + if err != nil { + return err + } + if handshakeOverride > 0 { + overrides.CodexHandshakeTimeout = handshakeOverride + } + maxFlag, _ := cmd.Flags().GetInt("max-concurrent-tasks") + if n := resolveDaemonIntOverride(maxFlag, "MULTICA_DAEMON_MAX_CONCURRENT_TASKS", fileCfg.MaxConcurrentTasks); n > 0 { overrides.MaxConcurrentTasks = n } - if b, _ := cmd.Flags().GetBool("no-auto-update"); b { + // --no-auto-update / MULTICA_DAEMON_AUTO_UPDATE=false / config.json + // disable_auto_update are all single-direction: none of them can force + // auto-update *on*, they can only turn it off. Env "true" is not + // treated as an override signal here — LoadConfig honors the raw env + // itself for the affirmative case. + noAutoUpdateFlag, _ := cmd.Flags().GetBool("no-auto-update") + if resolveDaemonDisableAutoUpdate(noAutoUpdateFlag, "MULTICA_DAEMON_AUTO_UPDATE", fileCfg.DisableAutoUpdate) { overrides.DisableAutoUpdate = true } - if d, _ := cmd.Flags().GetDuration("auto-update-interval"); d > 0 { - overrides.AutoUpdateCheckInterval = d + autoUpdateFlag, _ := cmd.Flags().GetDuration("auto-update-interval") + autoUpdateOverride, err := resolveDaemonDurationOverride(autoUpdateFlag, "MULTICA_DAEMON_AUTO_UPDATE_INTERVAL", fileCfg.AutoUpdateCheckInterval) + if err != nil { + return err + } + if autoUpdateOverride > 0 { + overrides.AutoUpdateCheckInterval = autoUpdateOverride } cfg, err := daemon.LoadConfig(overrides) @@ -1076,6 +1139,141 @@ func flagString(cmd *cobra.Command, name string) string { return val } +// envUnset reports whether the given env var is empty or missing entirely. +// The three-tier precedence for daemon knobs (flag > env > config.json) +// hinges on this check: config-file substitution only fires when both the +// flag AND the env are unset. Trimming whitespace matches the behavior of +// daemon.envOrDefault, so an env exported to " " reads as "not set" for +// fallback purposes — same as the runtime. +func envUnset(name string) bool { + return strings.TrimSpace(os.Getenv(name)) == "" +} + +// resolveDaemonStringOverride picks the value that goes into +// daemon.Overrides for a string knob (device_name, runtime_name). +// Precedence, highest first: +// +// 1. flag (already resolved to string; empty means "not passed") +// 2. env (if set, we leave overrides empty — daemon.LoadConfig reads env) +// 3. cfg (persisted config.json value) +// 4. "" (leave overrides empty — daemon.LoadConfig applies its default) +// +// Split out so the precedence is testable without spinning up cobra + the +// full daemon.LoadConfig pipeline. Returns the override value the caller +// should assign; an empty string means "let daemon.LoadConfig decide". +func resolveDaemonStringOverride(flagValue, envName, cfgValue string) string { + if flagValue != "" { + return flagValue + } + if !envUnset(envName) { + // Env is set — return empty so daemon.LoadConfig's envOrDefault + // resolves it. If we returned the env value here we'd double-read + // it and quietly diverge if the daemon's own trimming ever changes. + return "" + } + return cfgValue +} + +// resolveDaemonDurationOverride is the numeric counterpart for +// poll_interval. flagValue > 0 wins; otherwise, if the env var is unset +// we parse cfgValue. Parse errors and non-positive values are surfaced +// so a bad config.json value doesn't silently fall through to the +// default — `config set` already rejects the same shapes at write +// time, and this guard catches legacy configs that predate that check. +func resolveDaemonDurationOverride(flagValue time.Duration, envName, cfgValue string) (time.Duration, error) { + if flagValue > 0 { + return flagValue, nil + } + if !envUnset(envName) || cfgValue == "" { + return 0, nil + } + parsed, err := time.ParseDuration(cfgValue) + if err != nil { + return 0, fmt.Errorf("config value %q for %s is not a valid duration: %w", cfgValue, envName, err) + } + if parsed <= 0 { + return 0, fmt.Errorf("config value %q for %s must be positive", cfgValue, envName) + } + return parsed, nil +} + +// resolveDaemonIntOverride is the max_concurrent_tasks counterpart to +// resolveDaemonStringOverride. flagValue > 0 wins; otherwise the config +// value applies only when the env var is unset. +func resolveDaemonIntOverride(flagValue int, envName string, cfgValue int) int { + if flagValue > 0 { + return flagValue + } + if !envUnset(envName) { + return 0 + } + if cfgValue > 0 { + return cfgValue + } + return 0 +} + +// resolveDaemonAgentTimeoutOverride resolves --agent-timeout across the +// same three-tier precedence used by the other daemon knobs, but preserves +// the tri-state agent_timeout semantics: nil result = "not overridden", +// non-nil = "use this exact value" (which may legitimately be zero to +// disable the wall-clock cap). +// +// 1. --agent-timeout was explicitly passed (even as `--agent-timeout 0`) +// -> use that value; daemon.LoadConfig then bypasses env/default. +// 2. MULTICA_AGENT_TIMEOUT is set -> return nil so LoadConfig reads the +// env itself (matches the other duration helpers). +// 3. cfgValue non-nil -> parse the persisted string and use it. "0s" is +// valid here (it's the "disabled" sentinel). +// 4. Otherwise -> nil, LoadConfig applies DefaultAgentTimeout. +func resolveDaemonAgentTimeoutOverride(cmd *cobra.Command, envName string, cfgValue *string) (*time.Duration, error) { + if cmd.Flags().Changed("agent-timeout") { + d, _ := cmd.Flags().GetDuration("agent-timeout") + return &d, nil + } + if !envUnset(envName) { + return nil, nil + } + if cfgValue == nil || *cfgValue == "" { + return nil, nil + } + parsed, err := time.ParseDuration(strings.TrimSpace(*cfgValue)) + if err != nil { + return nil, fmt.Errorf("config value %q for %s is not a valid duration: %w", *cfgValue, envName, err) + } + if parsed < 0 { + return nil, fmt.Errorf("config value %q for %s must be >= 0", *cfgValue, envName) + } + return &parsed, nil +} + +// resolveDaemonDisableAutoUpdate resolves the single-direction disable +// signal for auto-update. Precedence: +// +// 1. --no-auto-update flag passed -> disable. +// 2. MULTICA_DAEMON_AUTO_UPDATE explicitly set to a falsy value -> +// disable. Env values other than false/0/no/off are handled inside +// daemon.LoadConfig (they may enable auto-update on self-host, which +// is not something we can express via CLI overrides today). +// 3. config.json disable_auto_update=true (only when both flag and env +// are silent) -> disable. +// 4. Otherwise -> leave the override off; LoadConfig picks the default. +func resolveDaemonDisableAutoUpdate(flagValue bool, envName string, cfgValue bool) bool { + if flagValue { + return true + } + if v := strings.TrimSpace(os.Getenv(envName)); v != "" { + switch strings.ToLower(v) { + case "false", "0", "no", "off": + return true + } + // Env is set to something other than a falsy value — leave the + // override off and let LoadConfig read the raw env itself. + return false + } + return cfgValue +} + // --- daemon disk-usage --- func runDaemonDiskUsage(cmd *cobra.Command, _ []string) error { diff --git a/server/cmd/multica/cmd_daemon_precedence_test.go b/server/cmd/multica/cmd_daemon_precedence_test.go new file mode 100644 index 000000000..4a06c3617 --- /dev/null +++ b/server/cmd/multica/cmd_daemon_precedence_test.go @@ -0,0 +1,243 @@ +package main + +import ( + "strings" + "testing" + "time" + + "github.com/spf13/cobra" +) + +// TestResolveDaemonStringOverridePrecedence pins the three-tier order: +// flag > env > config.json. The daemon.LoadConfig layer applies the env +// value itself via envOrDefault, so when only env is set we return "" — +// the "don't touch, let the runtime read it" signal. +func TestResolveDaemonStringOverridePrecedence(t *testing.T) { + const envName = "TEST_MULTICA_STR_OVERRIDE" + + cases := []struct { + name string + flag string + env string // "" means unset + cfg string + want string + }{ + {"flag wins over env and cfg", "flag-val", "env-val", "cfg-val", "flag-val"}, + {"env suppresses cfg", "", "env-val", "cfg-val", ""}, + {"cfg used when flag and env unset", "", "", "cfg-val", "cfg-val"}, + {"all unset returns empty", "", "", "", ""}, + {"whitespace env counts as unset", "", " ", "cfg-val", "cfg-val"}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + if tc.env != "" { + t.Setenv(envName, tc.env) + } else { + t.Setenv(envName, "") + } + got := resolveDaemonStringOverride(tc.flag, envName, tc.cfg) + if got != tc.want { + t.Fatalf("got %q, want %q", got, tc.want) + } + }) + } +} + +// TestResolveDaemonDurationOverridePrecedence covers the numeric path: +// flag>0 wins, env suppresses cfg, cfg parsed on demand, invalid cfg +// surfaces as an error so the daemon doesn't silently fall back. +func TestResolveDaemonDurationOverridePrecedence(t *testing.T) { + const envName = "TEST_MULTICA_DUR_OVERRIDE" + + cases := []struct { + name string + flag time.Duration + env string + cfg string + want time.Duration + errSub string // substring of expected error, "" = no error + }{ + {"flag wins", 5 * time.Second, "10s", "20s", 5 * time.Second, ""}, + {"env suppresses cfg", 0, "10s", "20s", 0, ""}, + {"cfg parsed when flag and env unset", 0, "", "500ms", 500 * time.Millisecond, ""}, + {"empty cfg returns zero", 0, "", "", 0, ""}, + {"invalid cfg errors", 0, "", "not-a-duration", 0, "not a valid duration"}, + {"zero cfg errors", 0, "", "0s", 0, "must be positive"}, + {"negative cfg errors", 0, "", "-1s", 0, "must be positive"}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + if tc.env != "" { + t.Setenv(envName, tc.env) + } else { + t.Setenv(envName, "") + } + got, err := resolveDaemonDurationOverride(tc.flag, envName, tc.cfg) + if tc.errSub != "" { + if err == nil { + t.Fatalf("expected error containing %q, got nil (value=%v)", tc.errSub, got) + } + if !strings.Contains(err.Error(), tc.errSub) { + t.Fatalf("error = %q; want to contain %q", err.Error(), tc.errSub) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tc.want { + t.Fatalf("got %v, want %v", got, tc.want) + } + }) + } +} + +// TestResolveDaemonIntOverridePrecedence — same shape as the string case +// but with the int knob (max_concurrent_tasks). flag>0 wins; env +// non-empty suppresses cfg; cfg>0 wins only when both are absent. +func TestResolveDaemonIntOverridePrecedence(t *testing.T) { + const envName = "TEST_MULTICA_INT_OVERRIDE" + + cases := []struct { + name string + flag int + env string + cfg int + want int + }{ + {"flag wins", 8, "16", 32, 8}, + {"env suppresses cfg", 0, "16", 32, 0}, + {"cfg used when flag and env unset", 0, "", 32, 32}, + {"zero everywhere returns zero", 0, "", 0, 0}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + if tc.env != "" { + t.Setenv(envName, tc.env) + } else { + t.Setenv(envName, "") + } + got := resolveDaemonIntOverride(tc.flag, envName, tc.cfg) + if got != tc.want { + t.Fatalf("got %d, want %d", got, tc.want) + } + }) + } +} + +// TestResolveDaemonAgentTimeoutOverridePrecedence pins the pointer-based +// tri-state for --agent-timeout: an explicit flag (even `--agent-timeout 0`) +// wins over env and cfg; env set means "let LoadConfig read the raw env" +// (nil); cfg pointer is only consulted when both flag and env are silent, +// and "0s" there is a legitimate persisted "disable the cap" sentinel. +func TestResolveDaemonAgentTimeoutOverridePrecedence(t *testing.T) { + const envName = "TEST_MULTICA_AGENT_TIMEOUT" + strPtr := func(s string) *string { return &s } + + newCmd := func(changed bool, flagVal time.Duration) *cobra.Command { + cmd := &cobra.Command{Use: "test"} + cmd.Flags().Duration("agent-timeout", 0, "") + if changed { + _ = cmd.Flags().Set("agent-timeout", flagVal.String()) + } + return cmd + } + + cases := []struct { + name string + flagChgd bool + flagVal time.Duration + env string + cfg *string + wantPtr bool + wantVal time.Duration + wantErrSub string + }{ + {"explicit zero flag wins", true, 0, "10m", strPtr("20m"), true, 0, ""}, + {"positive flag wins", true, 5 * time.Minute, "10m", strPtr("20m"), true, 5 * time.Minute, ""}, + {"env suppresses cfg", false, 0, "10m", strPtr("20m"), false, 0, ""}, + {"cfg positive applied when flag+env silent", false, 0, "", strPtr("30m"), true, 30 * time.Minute, ""}, + {"cfg zero string persists as explicit disable", false, 0, "", strPtr("0s"), true, 0, ""}, + {"cfg nil -> no override", false, 0, "", nil, false, 0, ""}, + {"cfg empty string -> no override", false, 0, "", strPtr(""), false, 0, ""}, + {"cfg unparseable errors", false, 0, "", strPtr("not-a-dur"), false, 0, "not a valid duration"}, + {"cfg negative errors", false, 0, "", strPtr("-1s"), false, 0, "must be >= 0"}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + if tc.env != "" { + t.Setenv(envName, tc.env) + } else { + t.Setenv(envName, "") + } + cmd := newCmd(tc.flagChgd, tc.flagVal) + got, err := resolveDaemonAgentTimeoutOverride(cmd, envName, tc.cfg) + if tc.wantErrSub != "" { + if err == nil { + t.Fatalf("expected error containing %q, got nil", tc.wantErrSub) + } + if !strings.Contains(err.Error(), tc.wantErrSub) { + t.Fatalf("error = %q; want %q", err.Error(), tc.wantErrSub) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tc.wantPtr { + if got == nil { + t.Fatalf("got nil, want ptr to %v", tc.wantVal) + } + if *got != tc.wantVal { + t.Fatalf("got %v, want %v", *got, tc.wantVal) + } + return + } + if got != nil { + t.Fatalf("got %v, want nil (no override)", *got) + } + }) + } +} + +// TestResolveDaemonDisableAutoUpdatePrecedence pins the single-direction +// disable signal: flag OR falsy env OR persisted cfg=true all disable +// auto-update; a truthy env leaves the override off so LoadConfig honors +// the raw env; missing signals return false so the default wins. +func TestResolveDaemonDisableAutoUpdatePrecedence(t *testing.T) { + const envName = "TEST_MULTICA_DAEMON_AUTO_UPDATE" + + cases := []struct { + name string + flag bool + env string + cfg bool + want bool + }{ + {"flag true wins", true, "true", false, true}, + {"env=false disables", false, "false", false, true}, + {"env=0 disables", false, "0", false, true}, + {"env=off disables", false, "off", false, true}, + {"env=true leaves override off", false, "true", true, false}, + {"cfg=true when flag+env silent", false, "", true, true}, + {"cfg=false when flag+env silent", false, "", false, false}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + if tc.env != "" { + t.Setenv(envName, tc.env) + } else { + t.Setenv(envName, "") + } + got := resolveDaemonDisableAutoUpdate(tc.flag, envName, tc.cfg) + if got != tc.want { + t.Fatalf("got %v, want %v", got, tc.want) + } + }) + } +} diff --git a/server/internal/cli/config.go b/server/internal/cli/config.go index cb7fa7e9a..c59e9a6fb 100644 --- a/server/internal/cli/config.go +++ b/server/internal/cli/config.go @@ -17,6 +17,100 @@ type CLIConfig struct { WorkspaceID string `json:"workspace_id,omitempty"` Token string `json:"token,omitempty"` + // DeviceName is the human-readable label shown in the server's Runtimes + // UI for the daemon started with this profile. When multiple daemons run + // on the same host under different profiles (typical for shared servers + // where one Linux user hosts one profile), os.Hostname() collapses them + // all onto one indistinguishable "VM-…" row. Setting device_name per + // profile — usually to "-" or the operator's real name — + // makes the runtimes list navigable. + // + // Resolution precedence (highest wins): --device-name flag, + // MULTICA_DAEMON_DEVICE_NAME env, this field, os.Hostname(). + DeviceName string `json:"device_name,omitempty"` + + // RuntimeName is the daemon's own runtime label ("Runtime display name" + // in the UI). Same shape as DeviceName but scopes to the runtime row + // rather than the host: users who run several distinct daemons per + // profile (rare, but supported) can pin different runtime names for + // each. Resolution precedence (highest wins): --runtime-name flag, + // MULTICA_AGENT_RUNTIME_NAME env, this field, the built-in + // DefaultRuntimeName. + RuntimeName string `json:"runtime_name,omitempty"` + + // MaxConcurrentTasks caps the number of task executions the daemon + // processes in parallel. Persist here to avoid re-passing + // --max-concurrent-tasks on every daemon start / auto-restart. 0 means + // "not set — use env / built-in default". Resolution precedence + // (highest wins): --max-concurrent-tasks flag, + // MULTICA_DAEMON_MAX_CONCURRENT_TASKS env, this field, default. + MaxConcurrentTasks int `json:"max_concurrent_tasks,omitempty"` + + // PollInterval is how often the daemon polls the server for new tasks + // (Go duration string, e.g. "10s", "500ms"). Same persist-once + // motivation as MaxConcurrentTasks. Empty ("") means "not set — use + // env / built-in default". Only strictly positive durations are + // meaningful here: `config set poll_interval` rejects non-positive + // values (including "0s") and un-parseable durations at write time, + // so a value that reaches this field is always well-formed. Use + // `config set poll_interval ""` to clear a previously persisted + // value. Resolution precedence (highest wins): --poll-interval flag, + // MULTICA_DAEMON_POLL_INTERVAL env, this field, DefaultPollInterval. + PollInterval string `json:"poll_interval,omitempty"` + + // HeartbeatInterval is how often the daemon sends heartbeat pings to + // the server (Go duration string). Same persist-once motivation as + // PollInterval. Empty ("") means "not set — use env / built-in + // default"; `config set heartbeat_interval` rejects zero and + // negative durations. Resolution precedence: --heartbeat-interval + // flag, MULTICA_DAEMON_HEARTBEAT_INTERVAL env, this field, + // DefaultHeartbeatInterval. + HeartbeatInterval string `json:"heartbeat_interval,omitempty"` + + // AgentTimeout is the absolute wall-clock cap per agent run (Go + // duration string). Unlike the other duration knobs, "0s" is a + // meaningful, non-default value here: it explicitly disables the cap + // so a run is bounded only by the inactivity watchdogs (see + // DefaultAgentTimeout). To distinguish "not persisted" from + // "persisted as disabled", we use a pointer: nil = not set, non-nil + // = use this string (which may be "0s"). `config set agent_timeout` + // accepts any non-negative Go duration; "" clears the persisted + // value. Resolution precedence: --agent-timeout flag (including + // explicit 0), MULTICA_AGENT_TIMEOUT env, this field, + // DefaultAgentTimeout. + AgentTimeout *string `json:"agent_timeout,omitempty"` + + // CodexSemanticInactivityTimeout is the Codex-specific inactivity + // watchdog window (Go duration string). Persist-once semantics match + // PollInterval: empty = not set, positive = use this value. + // Resolution precedence: --codex-semantic-inactivity-timeout flag, + // MULTICA_CODEX_SEMANTIC_INACTIVITY_TIMEOUT env, this field, + // DefaultCodexSemanticInactivityTimeout. + CodexSemanticInactivityTimeout string `json:"codex_semantic_inactivity_timeout,omitempty"` + + // CodexHandshakeTimeout caps the Codex app-server startup RPCs (Go + // duration string). Persist-once semantics match PollInterval. + // Resolution precedence: --codex-handshake-timeout flag, + // MULTICA_CODEX_HANDSHAKE_TIMEOUT env, this field, + // DefaultCodexHandshakeTimeout. + CodexHandshakeTimeout string `json:"codex_handshake_timeout,omitempty"` + + // DisableAutoUpdate, when true, turns off the daemon's periodic CLI + // self-update poll. Only a single direction is persistable — the + // --no-auto-update flag is likewise one-way — because the env/default + // already resolves to enabled on Multica Cloud. Absent / false means + // "let env/default decide". Resolution precedence: + // --no-auto-update flag, MULTICA_DAEMON_AUTO_UPDATE=false env, this + // field, cloud/self-host default. + DisableAutoUpdate bool `json:"disable_auto_update,omitempty"` + + // AutoUpdateCheckInterval is how often the daemon polls GitHub for a + // newer CLI release (Go duration string). Persist-once semantics + // match PollInterval. Resolution precedence: + // --auto-update-interval flag, MULTICA_DAEMON_AUTO_UPDATE_INTERVAL + // env, this field, DefaultAutoUpdateCheckInterval. + AutoUpdateCheckInterval string `json:"auto_update_check_interval,omitempty"` + // Backends contains per-backend overrides for users who want to point // the daemon at non-default tool installations (e.g. an OpenClaw bundled // inside another desktop app, or multiple isolated profiles on the same diff --git a/server/internal/cli/config_test.go b/server/internal/cli/config_test.go index 6caf3e582..8b4a35dd8 100644 --- a/server/internal/cli/config_test.go +++ b/server/internal/cli/config_test.go @@ -297,3 +297,42 @@ func TestCLIConfig_UnknownFieldsArePreserved(t *testing.T) { t.Error("unknown field future_backend_xyz was dropped on round-trip") } } + +// TestCLIConfig_DaemonKnobs_RoundTrip verifies that every persisted +// daemon knob added on top of #3824 survives a Save -> Load cycle, +// including the tri-state AgentTimeout pointer (an explicit "0s" is +// distinguishable from "not set"). If a future refactor accidentally +// drops one of these fields from the schema, this test fails at write +// time instead of silently losing the operator's config on restart. +func TestCLIConfig_DaemonKnobs_RoundTrip(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HOME", tmp) + + zero := "0s" + original := CLIConfig{ + HeartbeatInterval: "5s", + AgentTimeout: &zero, + CodexSemanticInactivityTimeout: "15m", + CodexHandshakeTimeout: "45s", + DisableAutoUpdate: true, + AutoUpdateCheckInterval: "12h", + } + if err := SaveCLIConfig(original); err != nil { + t.Fatal(err) + } + + loaded, err := LoadCLIConfig() + if err != nil { + t.Fatal(err) + } + if loaded.HeartbeatInterval != "5s" || + loaded.CodexSemanticInactivityTimeout != "15m" || + loaded.CodexHandshakeTimeout != "45s" || + !loaded.DisableAutoUpdate || + loaded.AutoUpdateCheckInterval != "12h" { + t.Errorf("scalar knobs lost on round-trip: %+v", loaded) + } + if loaded.AgentTimeout == nil || *loaded.AgentTimeout != "0s" { + t.Errorf("AgentTimeout tri-state lost: got %v, want &\"0s\"", loaded.AgentTimeout) + } +}