Skip to content

feat: per-program status, opt-in reconcile tick, llmGateway.whenProxyUnavailable - #146

Merged
peterj merged 5 commits into
agentdesktop-dev:mainfrom
LutzLange:upstream/status-tick-proxy-policy
Oct 7, 2026
Merged

peterj merged 5 commits into
agentdesktop-dev:mainfrom
LutzLange:upstream/status-tick-proxy-policy

Conversation

@LutzLange

Copy link
Copy Markdown
Contributor

Part of #86. Replaces #136, #137 and #138 as one PR on main (they were stacked on #135, now merged). The review on #137 and #138 is addressed in the commits fix(agent): tick reports the revision ... and fix(agent): failClosed says whether anything is in place ...; the earlier threads are answered on those PRs.

Why

  • The controller sees only "applied" or "failed" per device, not which program was applied, which is in conflict, or which was skipped because the local proxy is down.
  • Between configuration pushes, a deleted or hand-edited managed file, or a loosened file mode, stays wrong until the next push or restart.
  • While the daemon runs without its proxy, the programs behind it removed their entries. For VS Code on GitHub's models that sends Copilot Chat straight to GitHub, past the gateway.

Change

Per-program status

  • Each apply reports every managed program's outcome with a detail: applied, unchanged, removed, conflict, failed, and two new ones: inactive (the program uses the gateway but the proxy isn't running) and blocked (not applied because another program conflicted or failed).
  • The daemon logs one line per program that wasn't applied and sends the list with its ConfigStatus; the controller stores the latest list per device, returns it in GET /api/v1/devices/{id} and shows a "Managed programs" card.

Opt-in reconcile tick

  • daemon.reconcileInterval (local startup setting, off when unset, at most 30 days) re-applies the current configuration. A push, the startup apply and a tick never overlap; a tick reports the revision it applied, and only when its outcome changed.
  • An apply no longer rewrites a file that already holds the planned bytes and grants no permission bit beyond the planned mode; a file with a looser mode is rewritten at the planned mode, for every program that writes a managed file.

llmGateway.whenProxyUnavailable for programs.copilot and programs.vscode

  • failClosed (default) leaves the managed entries in place, so the tools fail instead of bypassing the gateway; when nothing of the daemon's is in place yet, the status says so and the tool keeps its own settings.
  • failOpen removes the entries until the proxy is back; without a sidecar, entries carrying a pairing header are removed while the daemon has no pairing of its own.
  • The controller's configuration view has a "Fail open without the local proxy" toggle. examples/copilot/ adds Dex, agentgateway, a daemon config and a step-by-step guide, plus github-side.md (what the GitHub organisation owner sets).

Risk and compatibility

  • Upgrade the controller first: migration 0003 (device_program_status) is one-way, and older controllers and daemons reject whenProxyUnavailable. The proto fields programs and programs_reported are additive.
  • Behaviour change for every program: identical writes are skipped, and a looser mode on a managed file is tightened on the next apply.
  • A tick overrides local edits, which is why it's opt-in.

How to review

Five commits, one per part plus the review fixes:

  1. per-program status: reconcile/mod.rs, reconcile/plan.rs, controller/src/database.rs + migration, DeviceView.tsx;
  2. reconcile tick: tick.rs, remote.rs, the apply lock in reconcile/mod.rs, the identical-write skip in reconcile/plan.rs;
  3. review fixes for the tick (revision under the lock, mode repair in all planners, schema);
  4. whenProxyUnavailable: fail_closed_without_proxy in reconcile/mod.rs and the three planners, the toggle in ConfigurationView.tsx, the example;
  5. review fixes for the policy (in-place reasons, failOpen reason, cleanup without a sidecar).

Tests

Unit tests cover outcome precedence and reporting, controller storage, the tick (dedup, lock ordering, logout, revision), mode repair per program, and both policies in the three planners; Storybook stories cover the card and the toggle. Lab-tested on a Linux VM: the device page shows each program's outcome; with a 20 s interval a deleted file is back and a loosened mode is tightened within one interval, quiet ticks write nothing; both failClosed reasons, failOpen on all three files, and cleanup without sidecars. With a Copilot Business seat, VS Code on GitHub's models answers through the proxy, and with the proxy off under failClosed the chat fails with the requests refused at the loopback port, no fallback to GitHub. The guide's steps 1 to 4 and 6 were run from the guide alone.

Follow-ups

🤖 Generated with Claude Code

LutzLange and others added 5 commits October 7, 2026 20:03
Each apply reports every managed program's outcome with a detail: applied,
unchanged, removed, conflict, inactive (the program uses the gateway but the
local proxy is not running), blocked (not applied because another program
conflicted) or failed. The daemon logs one line per program that was not
applied and sends the list with its ConfigStatus (additive proto fields
`programs`, `programs_reported`).

The controller stores the latest list per device (migration 0003,
`device_program_status`), returns it in `GET /api/v1/devices/{id}`, and the
device page shows a "Managed programs" card. Upgrade the controller first
(migration 0003 is one-way).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`daemon.reconcileInterval` (local startup setting, off when unset; zero or
more than 30 days is rejected) re-applies the current configuration, which
repairs drift between pushes: a deleted or hand-edited managed file, a fixed
conflict, a loosened file mode.

- The current configuration is the startup configuration, replaced by each
  pushed configuration that parses, and cleared on logout (also between
  controller sessions and while waiting to retry).
- Applies are serialized: a push, the startup apply and a tick never overlap.
- A tick reports to the controller only when its outcome differs from what
  was last sent on the stream; a pushed configuration whose apply failed is
  saved once a tick applies it.

Behaviour change for every program: an apply no longer rewrites a file that
already holds the planned bytes with no mode bit beyond the planned mode, so
unchanged files and sidecars are no longer rewritten on each apply.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ired for every managed file

Review on agentdesktop-dev#137.

- Reconcile tick: the revision in a tick's status is the one read together
  with the configuration under the apply lock, not the one the tick woke up
  with. Before, a push between the two published the new revision's outcome
  under the old revision; the connection loop dropped it and delayed saving
  the pushed configuration by one interval. A tick that applies nothing
  (after a logout) publishes nothing.
- Mode repair: Codex, Grok, OpenCode, Claude Code and Claude Desktop now
  treat a managed file with identical bytes but a mode looser than planned
  as an update and rewrite it at the planned mode (before, they returned
  `unchanged` before planning a write, so the plan's mode check never saw
  them). For Claude Code and Claude Desktop such a file is rewritten even
  without the owner marker, as the unchanged path already claimed it. One
  predicate, `reconcile::grants_beyond`, decides "looser than planned" for
  the plan's skip check, json_merge and the VS Code settings file and
  sidecar; Codex, Grok, OpenCode and Claude Code name their file mode once.
  Only the permission bits are compared (setuid/setgid/sticky are not); a
  managed path that is a symlink is replaced by a regular file on such a
  repair, as on any rewrite.
- Schema: `daemon.reconcileInterval` states the 30-day maximum.

Tests: 16 new (2 tick, 4 plan, 10 provider; the 10 provider tests and 2 of
the plan tests are unix-only); the five existing tick tests take the
closure's new return type. The file-mode tests assume umask 022, as the
existing ones do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Proxy-unavailable policy
- While the daemon runs without its local LLM proxy, the programs behind it
  used to remove their managed entries; for VS Code on GitHub's models that
  removes the CAPI override, so Copilot Chat talks to GitHub directly, past
  the gateway.
- `llmGateway.whenProxyUnavailable` now decides for `programs.copilot` and
  `programs.vscode`: `failClosed` (default) leaves the managed files as they
  are, so the tools stay pointed at the loopback port and fail; `failOpen`
  removes them until the proxy is back. The program is `inactive` with a
  reason that names the policy.
- Program removal, a mode switch, `useLlmGateway: false` and a missing
  `proxyUrl` remove as before. The controller's configuration view offers the choice ("Fail open
  without the local proxy") when a Copilot program is selected. Older daemons and controllers reject the key
  (`deny_unknown_fields`); the default needs no key.

Example
- `examples/copilot/`: Dex, agentgateway, a daemon config and a step-by-step
  guide for the Copilot CLI, VS Code on own models and VS Code on GitHub's
  models, with what to check at each step (drift, conflicts, removal, the
  proxy gone), plus the settings the GitHub organisation owner configures
  (`github-side.md`).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…mes its policy and cleans up without a sidecar

Review on agentdesktop-dev#138.

- failClosed without the proxy: the program reports "no managed entries are
  in place, so the tool keeps its own settings" when nothing of the daemon's
  is in the file (a first apply without the proxy, or entries removed by
  hand), and keeps "the managed entries stay and the tool fails" when they
  are. In place = a managed provider or chat-model entry with a pairing
  header, or a VS Code override pointing at an agentdesktop loopback route;
  the file is read for this only under failClosed and never written. A file
  that cannot be read is logged and counts as not in place; one that does not
  parse counts as not in place (the merge reports it once the proxy is back).
  Before, the status claimed the tool fails while VS Code on GitHub's models
  went to GitHub directly.
- failOpen: all three planners report "the managed entries are removed until
  the proxy is back (whenProxyUnavailable: failOpen)" instead of the generic
  proxy-absent reason, which is removed.
- Cleanup without a sidecar while the daemon has no pairing (proxy off,
  failOpen or program removed): Copilot CLI providers and VS Code chat-model
  entries under the managed names that carry any pairing header are removed;
  user entries stay; with a known pairing only that value counts, as before.
  Before, such entries were left pointing at the unavailable port.
- README (when failClosed has nothing to keep, the status row, the
  any-pairing rule), schema (`llmGateway.whenProxyUnavailable`), the
  controller's toggle text and a Storybook fixture.

Tests: 13 new (Copilot CLI 5, VS Code chat models 5, VS Code settings 3;
2 of them guard existing behaviour). The two "keeps its mode" tests assume
umask 022, as the existing file-mode tests do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@peterj

peterj commented Oct 7, 2026

Copy link
Copy Markdown
Collaborator

@LutzLange there are some test/lint failures -- can you take a look?

@peterj
peterj merged commit ff46fc4 into agentdesktop-dev:main Oct 7, 2026
5 of 6 checks passed
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.

2 participants