Skip to content

feat(staged): per-agent environment variables in Doctor settings - #1049

Merged
matt2e merged 9 commits into
mainfrom
agent-envars
Nov 9, 2026
Merged

matt2e merged 9 commits into
mainfrom
agent-envars

Conversation

@matt2e

@matt2e matt2e commented Nov 9, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a per-agent Environment variables section to Settings → Doctor. Each agent gets an ordered list of { name, value, enabled } rows persisted in preferences.json (agent-env-vars). Local agent launches apply the enabled rows on top of the inherited environment, and Doctor's auth/version probes for that agent see the same overlay, so Doctor's verdict matches what a session launch would get.

Frontend (apps/staged/src/lib/features/settings)

  • AgentEnvVarsSetting.svelte + agentEnvVars.ts: per-agent row editor with masked values, enable/disable toggles, duplicate and invalid-name validation, and a warning (not a refusal) for launcher-reserved names such as PATH and GOOSE_SEARCH_PATHS.
  • preferences.svelte.ts: new agentEnvVars preference; malformed entries are skipped individually.
  • Doctor rows surface skipped (disabled/invalid) and shadowed (duplicate) variables.

Backend (apps/staged/src-tauri)

  • New agent::env_vars module reads the preference straight from preferences.json (same pattern as branch-prefix) and yields KEY=VALUE pairs. One read per Doctor pass, so every agent's overlay comes from the same file version.
  • session_runner and the pikchr MCP sub-agent pass the pairs as extra_env.
  • doctor wires per-check overlays into DoctorEnv.

crates/acp-client driver

  • extra_env is applied last in every local spawn path: over the shell snapshot on direct spawns, and on the exec line via /usr/bin/env on the login-shell fallback, so an export in rc files can't shadow a Doctor value.
  • Values never appear in command text or shell history: they travel in mangled __STAGED_EXTRA_ENV_<i> seed variables that the exec line unsets before the agent starts.
  • Login-shell dispatch by $SHELL basename: POSIX shells and unknown names keep the existing -i -l -s stdin path; a known non-POSIX list (fish, xonsh, tcsh, csh) gets $SHELL -l -c 'exec /bin/sh -c "$__STAGED_EXEC_LINE"' so the user's rc files still run while the handoff happens in POSIX sh. nu and elvish are deliberately excluded since they don't expand $VAR in double quotes.
  • Live tests run the wrapper through every installed shell, confirm the override lands, and confirm no value reaches any file under HOME. The fish test skips when fish is absent unless STAGED_REQUIRE_FISH_TEST is set.

crates/doctor

  • DoctorEnv::with_check_overlay / for_check: per-check variables appended after the snapshot with a documented last-wins invariant shared by get, apply_doctor_env and build_shell_command.

Notes

  • Values are stored in plaintext in preferences.json by design (file is already user-private and served to the web UI); the UI says so in the field description.
  • Applies to local launches only; a remote session's extra env reaches the sq blox acp proxy, not the workstation agent.
  • Adds new preference and DoctorEnv fields, so this wants a data-model review per AGENTS.md.

Test plan

  • cargo test -p acp-client (390 passed), cargo test -p doctor (156 passed), Tauri doctor:: and agent::env_vars tests
  • cargo fmt --check and cargo clippy on both crates
  • agentEnvVars.test.ts and preferences.test.ts (vitest)
  • Pre-push hook ran just ci clean on the final push (one earlier run hit a known-flaky attach_cancel_is_unconfirmed_past_its_deadline SIGINT; passed on rerun)
  • Manual: add a var for an agent in Doctor, confirm the agent sees it and Doctor's auth probe reflects it

🤖 Generated with Claude Code

matt2e and others added 7 commits November 9, 2026 19:56
Let the user configure a list of environment variables for each agent
under its row in Settings → Doctor, and pass the enabled ones to the
agent process when it launches.

Model: `agent-env-vars` in preferences.json, a `Record<agentId, {name,
value, enabled}[]>` (frontend `agentEnvVars.ts`, backend
`agent::env_vars`). A missing key reads as no variables, so existing
settings need no migration; an entry without `enabled` reads as enabled.

UI: `AgentEnvVarsSetting.svelte` renders one row per variable — enabled
checkbox, name, value, remove — with an "Add variable" button. Unchecked
rows stay but render muted. Names that can't be set (empty, `=`,
whitespace) show an inline error and are kept rather than dropped.

Launch: the session runner reads the agent's enabled variables and
merges them under the session's own extra env before `with_extra_env`,
which the driver applies after the inherited shell snapshot so the
user's values override it. Disabled and invalid rows are skipped; a
later duplicate name wins. Local sessions only — a remote session's
extra env reaches the `sq blox acp` proxy, not the workstation agent.
The pikchr diagram sub-agents get the same treatment.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
Address the review of the per-agent environment variables feature
(2b1168a): make the plaintext storage decision visible, warn about
duplicate names, and show an error for every row the backend skips.

Storage: the field description now says values are stored unencrypted
in the local preferences file and apply to local sessions only (remote
Blox sessions don't receive them). The Rust `agent::env_vars` module
docs record why plaintext was chosen — the file is private to the local
user, the agent sees the value in its environment anyway, and the web
UI already serves preferences — and when to revisit it.

Duplicates: the backend still lets the last enabled row win, but every
enabled row that shares a name (compared exactly, as the backend does)
now shows "Duplicate name: the last row wins". Disabled rows and rows
the backend would skip don't take part.

Value validation: `validateEnvVarValue` rejects a NUL byte, matching the
backend filter, and `validateEnvVar` combines name and value checks as
the one predicate behind the inline errors. Row errors move out of the
component into `agentEnvVarRowErrors` in `agentEnvVars.ts`, since
duplicate detection needs the whole list and the helper is unit-tested
without the Svelte runtime. The value input is also described by the
row's error for assistive tech.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…names, mask values

Address the second review of the per-agent environment variables feature
(e9b80f3). Four items.

Login-shell spawn path: the driver's `$SHELL -ils` fallback (pikchr
sub-agents, and a session whose env snapshot capture failed) seeded
`extra_env` with `Command::env` before the shell ran its rc files, so an
`export ANTHROPIC_API_KEY=…` in `.zshrc` shadowed the Doctor value there
while the direct-spawn path overrode it. `shell_exec_line` now takes the
extra env and exec's through `/usr/bin/env -- NAME='value' … <binary>`,
which sets the variables after every rc file and before the agent starts;
`env` accepts any name the OS does (shell `export` rejects `1FOO`), the
`--` keeps a leading `-` from reading as an option, and values are
single-quoted. The seed is kept so rc files still see the values. Tests
cover the plain, explicit-interpreter and PATH-guard variants, plus an
end-to-end run through `sh -s` with an rc-style export before the exec
line. `with_extra_env` and the `agent::env_vars` module docs now state
the precedence for every spawn path.

Launcher-reserved names: a row named `PATH` or `GOOSE_SEARCH_PATHS` is
applied as written and so replaces what `apply_managed_tools_env` set
for the agent. Not refused — the user may mean it — but the row now shows
a non-blocking warning, separate from the errors that mark a row the
backend skips. `LAUNCHER_RESERVED_NAMES` lives in `agentEnvVars.ts` with
a comment pointing at the Rust function to keep them in sync.

Normalisation: one malformed entry used to drop the whole agent's list,
and `setAgentEnvVars` then wrote the record back without it, erasing it
from disk on the next edit of any agent. `normalizeAgentEnvVars` now
skips only the entries that aren't objects with a string name.

On-screen masking: the value input is `type="password"` with a per-row
Show/Hide value toggle (eye icon, aria-label, aria-pressed, keyboard
focusable). Storage is unchanged. The inputs also drop their explicit
`text-xs`, which the repo's Input font-size test rejects: the default
`text-sm`/`mobile:text-base` keeps iOS Safari from zooming the field.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
… entries per row

Address the third review of the per-agent environment variables feature
(f8f78e7). Three items; the first is a security regression from that
commit.

Shell history: the fallback `$SHELL -ils` spawn wrote the values onto the
exec line as `exec /usr/bin/env -- 'NAME=value' …`, and an interactive
zsh or bash records every line it reads from stdin in the user's history
file — immediately under INC_APPEND_HISTORY/SHARE_HISTORY, and bash's
`exec` builtin saves history before replacing the process — so an API
key configured in Doctor landed in ~/.zsh_history on every pikchr
sub-agent launch. The line now carries names only. The values are seeded
into the shell's starting environment under mangled names
(`__STAGED_EXTRA_ENV_<i>`, from `shell_extra_env_seeds`; the prefix grows
an underscore until no user variable starts with it) alongside the real
names, which rc files still see, and the line copies them across by
expansion:

  set -- 'NAME='"$__STAGED_EXTRA_ENV_0" …; unset __STAGED_EXTRA_ENV_0 …;
  exec /usr/bin/env -- "$@" <binary> <args>

Going through the positional parameters lets the seeds be `unset` before
the exec so they don't leak into the agent's environment, in plain POSIX
sh with no `env -u`. Names are single-quoted (any byte but NUL passes;
bash history expansion can't fire), the expansion is double-quoted so
empty values stay one word, and `--` still guards a leading `-`. The
driver seeds the mangled copies only on the shell-spawn path. A new test
runs the real exec line through `zsh -ils` and `bash -ils` (skipping a
shell that isn't installed) with an isolated HOME/ZDOTDIR, an rc file
that re-exports the same name and HISTFILE pointed into that HOME, and
asserts the agent sees the override, no seed reaches its environment,
the shell did record the line, and the value appears in no file under
HOME. The `/bin/sh -s` precedence test stays and now checks the seeds
are unset too.

Malformed entries: the backend deserialised each agent's list as one
`Vec<AgentEnvVar>`, so one entry with a non-string name dropped the whole
list while the frontend's `normalizeAgentEnvVars` kept the good rows and
showed them as valid — every variable the user saw as enabled was
silently not applied until the UI next wrote the list back. Entries are
now read one at a time (`AgentEnvVar::from_json`): an entry that isn't an
object with a string name is skipped with a warning (index only, never
the contents), and a non-string value or non-boolean enabled read as
empty and enabled, exactly as the frontend reads them. Unit test added;
docs updated.

Reveal state: the per-row Show/Hide flags are positional and were only
shifted by the component's own remove, so a row removed from another
window through the synced store left a flag on its index, unmasking a
different row's value. The component now tracks the list length it last
produced or saw and clears every reveal when the length changes from
outside; its own add and remove update the expectation first, so local
edits don't re-mask anything.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
… agent env rows

Address the two findings of the fourth review of the per-agent
environment variables feature (507c7e6).

Non-POSIX login shell: the exec line the driver hands the `$SHELL -ils`
fallback is POSIX sh (`set --`, `unset`, `"$@"`), which fish rejects at
parse time. Checked against fish 4.9: the fallback never worked there at
all — fish has no `-s` and refuses `-ils` outright, and given commands on
stdin it reads them all to EOF before running any, so with the driver
never closing stdin the agent would not start and the shell would have
swallowed its first requests besides. The driver now decides by the
shell's basename (`LoginShellExec`): sh, bash, zsh, dash, ksh, ksh93,
mksh and ash keep `-i -l -s` with the line on stdin; anything else is
spawned as `$SHELL -l -c "exec /bin/sh -c '<line>'"`, so the user's own
rc files (config.fish) still run in their shell and the env handoff
happens in POSIX sh, which then execs `/usr/bin/env` and the agent as
before. The whole line goes inside the wrapper, PATH guard included.
The inner line is quoted by fish's single-quote rules (`\` and `'`
escaped); other non-POSIX shells get the same as a best effort. Values
still never appear in command text: the line carries names and the
values ride in the mangled seeds, which `/bin/sh` reads from its
environment. The spawn now logs the shell path, which path it took and
the names-only exec line, and a spawn failure names the shell, so a
maintainer can tell which shell was given what. Tests: a unit test on
the generated argument text (POSIX set, fish quoting with `'` and `\`,
other shells), and a live run through `fish -l -c` with an isolated HOME
whose config.fish re-exports the name — skipped with a message when fish
isn't installed (system dirs, Homebrew prefixes, PATH, `brew --prefix`
are checked) — asserting the agent sees the override, no seed reaches
it, a line written to stdin right after the spawn reaches the agent, and
the value lands in no file under HOME.

Doctor consistency: Doctor's agent checks and the login fix consult the
environment — `claude-agent-acp --cli auth status`, `codex-acp cli login
status` and the login CLIs read API keys from it — but ran with the
shared snapshot only, so a row that made sessions work could sit next to
a check reporting the agent unauthenticated. `DoctorEnv` gains per-check
overlays (`with_check_overlay`, `for_check`): variables appended to the
snapshot for one check id, so they win for that check's own probes
(`acp --help`, auth status, the version probe) and no other check sees
them. Binary resolution stays on the shared snapshot. Staged fills the
overlays from the agent env rows for every `ai-agent-*` check
(`agent_env_for_check`, keyed by the provider id after `ai-agent-`, as
the frontend derives it) and adds the same rows to the login fix's
environment. The field description now says the rows apply to Doctor's
checks and login too. One remaining edge is documented in
`agent::env_vars`: the probe runs through `zsh -l -c`, whose
`.zshenv`/`.zprofile` can still shadow a row for the probe alone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…, default unknown shells to stdin

Address the fifth review of the per-agent environment variables feature
(f728336). The previous commit's `$SHELL -l -c` wrapper single-quoted the
POSIX exec line by fish's rules, and every shell whose basename wasn't on a
POSIX allowlist got it — so a versioned or renamed bash/zsh (`bash5`,
`zsh-5.9`), yash, oksh, busybox and the like received a wrapper that is a
syntax error in POSIX sh (`\'` closes a single-quoted string there) and
never started the agent, where the old `-i -l -s` path had worked.

Quoting-free wrapper: the exec line now travels in another mangled seed
variable, `__STAGED_EXEC_LINE` (same collision-avoiding prefix scheme as
the value seeds; the two prefixes can't collide), and the wrapper is the
fixed text `exec /bin/sh -c "$__STAGED_EXEC_LINE"`, which expands the same
way in fish, bash, zsh, ksh, dash and csh. The seeded line starts with
`unset __STAGED_EXEC_LINE;` so the seed never reaches the agent. It still
carries names only; values ride in the `__STAGED_EXTRA_ENV_<i>` seeds as
before. `fish_quote` is gone. A test runs the identical wrapper through
every installed shell (sh, bash, zsh, dash, ksh, tcsh, csh here; fish
when present) with a `'` and `\` in the binary path and checks the
override, the absence of every seed, and a clean exit.

Dispatch inverted: an unknown basename now takes the pre-existing stdin
path (`-i -l -s`); the `-l -c` wrapper is reserved for an explicit
non-POSIX list — fish, nu, xonsh, elvish, tcsh, csh — matched on the
basename of `$SHELL` as given. tcsh and csh refuse `-l` next to `-c`
("Unknown option: `-l'"), so they get `-c` alone; fish is the one shell
verified end to end, the rest are best effort for shells that never
worked on the stdin path either. The doc records that dropping `-i` on
this path means Hermit's fish `--on-variable PWD` hook doesn't fire before
the exec, so the agent itself (absolute binary path) is unaffected and
only the tools it spawns from the project are. Unit tests cover the argv
and seed for `bash5`, `zsh-5.9`, an unknown name, an oddly cased one, a
`$SHELL` path that doesn't exist (stdin argv; the spawn then fails against
the logged shell path), fish, nu, xonsh, elvish, tcsh, csh, the no-extra-
env line, and a user variable that clashes with the seed's name.

Live tests: the fish test still skips when fish is absent, but setting
`STAGED_REQUIRE_FISH_TEST` turns the skip into a failure with a message
naming where it looked, for a CI job that installs fish. The zsh/bash
history test now runs each shell twice, under its own name and through a
symlink named `zsh-5.9`/`bash5` in a temp dir, asserting both take the
stdin path and that the agent sees the override while the value lands in
no file under HOME.

Doctor: `DoctorEnv::vars` and `get` now document the append-order,
last-wins invariant that `get`, `apply_doctor_env` and
`build_shell_command` all rely on (a name may repeat after `for_check`
appends an overlay; the last pair wins because `Command::env` is applied
in order), and a test checks that overlaying a name the snapshot already
has yields the overlay value from `get`, for one and for two overlays.

Verified with `cargo test -p acp-client` (390 passed), `cargo test -p
doctor` (156 passed), `cargo fmt --check` and `cargo clippy --tests` on
both crates, all clean. Nothing was installed; fish is absent here, so
the fish live test skipped and the opt-in failure was exercised by hand.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…ad Doctor env overlays from one preferences read

Address the two remaining items of the sixth review of the per-agent
environment variables feature (f479dca).

Non-POSIX shell list: nu and elvish were listed in `NON_POSIX_LOGIN_SHELLS`
and so got the `$SHELL -l -c 'exec /bin/sh -c "$__STAGED_EXEC_LINE"'`
wrapper, but neither expands `$VAR` inside a double-quoted string (nushell
interpolates only `$"..."` and reads the environment as `$env.X`; elvish
uses `$E:X` and never expands inside quotes, and has no `-l`), so `/bin/sh`
received the literal text, the agent never started, and the spawn log said
the wrapper path was taken, pointing a maintainer at the wrong layer. Both
are removed; they now take the pre-existing stdin path and fail at `-s`
with the shell's own error, as they always did. The list keeps fish, xonsh,
tcsh and csh, and its doc now states the admission rule — accepts `-c` and
expands `$VAR` inside double quotes — with nu and elvish named as
deliberately excluded and why. The dispatch unit test asserts nu and elvish
take the stdin argv with no seed, and the wrapper loop covers xonsh and
fish. The `agent::env_vars` module doc no longer cites nu as an example.

Doctor overlays: `agent_env_overlays` called `read_agent_env` once per
entry in `AI_AGENT_CHECKS`, re-reading and re-parsing `preferences.json`
each time, so a UI write landing mid-loop could give two agents' overlays
from different versions of the file. `agent::env_vars` now splits the read
out: `read_preferences` reads and parses the file once (`None` on any
failure, read as "no variables" as before) and `agent_env_from_preferences`
derives one agent's enabled pairs from a parsed value; `read_agent_env`
composes the two and is unchanged for the session runner, pikchr
sub-agents and the login fix. `agent_env_overlays` reads once and derives
every check's overlay from that value through `agent_env_overlays_from`,
which a new unit test drives with one parsed document holding an enabled
row, a disabled-only agent and a key that isn't an agent. Behaviour is
otherwise identical: same keys, same order, same filtering.

Verified with `cargo test -p acp-client` (390 passed), the Tauri crate's
`doctor::` (41) and `agent::env_vars` (8) tests, `cargo fmt --check` on
both crates and `cargo clippy` (acp-client with `--tests`; the Tauri
crate as CI runs it), all clean. Nothing was installed or removed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 9, 2026 •

Copy link
Copy Markdown
Codex Review Summary: ✅ Completed

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-09T11:26:43.024439Z f372109 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f372109ed9

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread crates/doctor/src/lib.rs Resolved
matt2e and others added 2 commits November 9, 2026 22:52
…p, apply them to ACP config discovery

Address the three open Codex review threads on PR #1049 (per-agent
environment variables).

Doctor overlay vs. shell startup (crates/doctor/src/lib.rs:245): the
per-check overlay was placed in the login shell's starting environment
only, so an `export` of the same name in `.zshenv`/`.zprofile` replaced
it before the auth or version probe ran, and the login fix appended the
rows to the snapshot with the same exposure. `DoctorEnv` now records an
overlay (`with_overlay`, `overlay()`; `for_check` routes a check's overlay
through it), and `build_shell_command` carries each overlaid value in a
seed variable (`__BUILDERBOT_DOCTOR_OVERLAY_<i>`, prefix grown past any
snapshot name that starts with it) and prepends one line per variable to
the `-c` payload that re-exports the real name from the seed and unsets
the seed, ahead of the existing `PATH` restore so a row named `PATH`
keeps the resolved-binary dirs merged into it. Values never enter the
command text. Only shell-identifier names get a line: anything else
can't be assigned by an rc file either, so it passes through untouched.
`ExecuteFixOptions::with_env` takes a `DoctorEnv`, and the login fix
builds its environment as `DoctorEnv::new(snapshot).with_overlay(rows)`.
Tests: the generated payload and seeds (including the prefix clash and a
non-identifier name), the identifier predicate, overlay bookkeeping
through `for_check`, and a live run through `/bin/zsh -l -c` with an
isolated HOME whose `.zshenv`/`.zprofile` re-export the name, asserting
the probe sees the overlay and no seed. The `agent::env_vars` doc no
longer lists the rc-file shadowing as an open edge.

ACP config discovery (session_runner.rs:1752): the model/effort picker's
discovery launch spawned the agent from the working-directory snapshot
alone, so an agent authenticated only through a Doctor row answered
`session/new` with `AuthRequired`, which discovery reads as "no options".
`apply_acp_config_discovery_env` now takes the agent's enabled rows and
applies them last, over the snapshot and the env-shebang `PATH` guard,
and the inherited-environment fallback applies them over the inherited
env, matching `with_extra_env` on a session launch. Unit test added.

Error colour (AgentEnvVarsSetting.svelte:274): the inline row error used
`var(--destructive, var(--color-danger, #f85149))`, a hardcoded fallback
behind two variables the theme doesn't define; it now uses `--ui-danger`,
the theme's danger colour, as the other settings panels do.

Verified with `cargo test -p doctor` (overlay and shell tests), the
Tauri crate's `doctor::`, `agent::env_vars` and new discovery test (50
passed), `cargo fmt --check` on both crates, `cargo clippy -p doctor
--tests` (clean), prettier on the Svelte file and the agentEnvVars vitest
file. Tauri-crate clippy was skipped under load; CI runs it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
The Crates CI "Check" job on 5fe430a failed one acp-client test,
`sessions_fail_as_remote_agent_lost_once_bloxlet_reports_a_quiet_prompts_agent_failed`,
with the error naming "no longer lists the agent process as running"
where the test expects "reports the agent process as failed". Nothing on
this branch touches that code; the race is in the test fake and exists
identically on main.

The fake `sq` serves `sq blox ws commands` from two files the tests
rewrite through `FakeSq::set_commands`, which wrote `active.json` and
then `history.json`. The proxy's liveness watcher polls every 200 ms in
test builds and `remote_liveness::check` runs two sequential `sq` calls:
the active list first, and history only when the command is missing from
it. A poll landing between the two writes saw the new empty active list,
then read history before it had the new FAILED record (the old contents,
or an empty file mid-truncation that `parse_commands` rejects), and both
read as "no record", so the check reported the agent as no longer listed.
The window only opens when the test thread is descheduled between the
writes, hence a loaded CI runner. Inserting a 300 ms sleep between the
writes reproduced the CI message on every run.

The helper now writes `history.json` first. While the active list still
says RUNNING no poll reads history, and by the time a poll sees the empty
active list the history record is complete. All 29 callers go through the
helper; no production code changes. With the 300 ms gap kept in place
during verification, all 31 `remote_process` tests passed; they pass
again with the gap removed (`cargo test -p acp-client remote_process`,
31 passed) and `cargo fmt --check` is clean.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
@matt2e
matt2e merged commit 0db0382 into main Nov 9, 2026
10 checks passed
@matt2e
matt2e deleted the agent-envars branch November 9, 2026 13:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant