feat(desktop): move an agent between local and remote, keeping its identity - #14
Merged
Merged
Conversation
…entity Where an agent runs is currently fixed at creation. The only way to change it is delete-and-recreate, which permanently destroys the keypair, channel grants, git ACL, auth tag and NIP-AE engrams — Buzz has no key import. This adds the backend half of a real migration: pubkey and everything keyed to it survive the move. `RunOnSummarySection.tsx:9` justifies the current immutability with "a provider-backed agent's deployment holds its private key; there is no migrate operation". That does not hold up — `record.private_key_nsec` lives in the desktop store and the sprites backend already COPIES it to the sprite on every provider deploy (`env.rs:266`). Migration copies a key to a provider exactly as creating a remote agent does, and the desktop keeps its copy, which is why the reverse direction is symmetric and nearly free. The invariant this protects is one identity, one live harness. Two harnesses signing as one pubkey means doubled replies, flapping presence, and concurrent engram writes against the same (agent, owner) pair. The two directions are not symmetric, and that shapes the design. Liveness is only observable for local agents: `sync_managed_agent_processes` reconciles real processes, so a surviving pid is authoritative. Provider agents report `deployed`/`not_deployed` from `backend_agent_id`, which is INFRASTRUCTURE EXISTENCE, not liveness — runtime.rs's own comment notes a sprite stays "deployed" after !shutdown — and relay presence, the real signal, is polled by the frontend and never reaches this process. `stop_managed_agent` also rejects non-local backends outright, so no backend call could establish stoppedness. Rather than check something it cannot see, the command takes an explicit `remote_confirmed_stopped` assertion from the caller and documents why. Honest beats reassuring for a guard whose failure mode is a duplicate agent. Guards live in a new `backend_migration` module as a pure function so they are testable without an AppHandle, mirroring how PR #4 extracted `community_scope`. Preconditions are a struct rather than three positional bools — that argument list is exactly how a guard silently inverts. Two deliberate choices worth not undoing: - `backend_agent_id` is PRESERVED when leaving a provider. It is the only pointer back to infrastructure that still exists and still holds a copy of this agent's key; clearing it would strand the deployment unnameable. It is ignored for display while the backend is Local. - This is not a field on `UpdateManagedAgentRequest`. An ordinary edit-dialog save must never be able to change where an agent runs. Backend only — there is no UI surface yet, so the command is unreachable from the app. The dialog and the Agents-page action follow. Signed-off-by: Junchao Yan <yjc801@gmail.com>
Completes the feature: the command from the previous commit was registered but unreachable. Adds the API wrapper, the mutation hook, a migrate dialog, and a "Move" control on each agent row. The load-bearing piece is `migrationGate` — the UI half of a guard the backend cannot finish alone. `set_managed_agent_backend` verifies local liveness (a surviving pid after process sync is authoritative) but has no signal for a remote harness: provider status reports deployed/not_deployed, which is infrastructure existence, and relay presence is polled in the frontend and never reaches that process. So the command accepts `remote_confirmed_stopped` as an assertion, and this gate is what makes the assertion true — it forwards `true` only when presence says the agent is offline. It fails closed in three distinct ways, which is the whole point: presence not yet loaded, presence loaded but absent for this agent, and any status other than offline all block the move. Absence is not evidence of offline — an agent whose heartbeat never arrived looks identical to one that stopped. The cost of guessing wrong toward "offline" is two harnesses on one key; the cost of guessing wrong the other way is waiting a few seconds. Also of note: - `away` counts as running. Only `offline` releases the gate. - `agent.status` never substitutes for presence on a provider agent. Even `not_deployed` blocks while presence says online, because it only means no infrastructure is recorded. - A running LOCAL agent is blocked regardless of presence — a local process can be alive while the relay shows it offline. Different signals, different questions. The dialog reuses the create flow's `WhereToRunSection` rather than a parallel picker, so provider discovery, schema probing and config validation stay in one place; a second implementation would drift, and "where this agent runs" is exactly where a divergence would go unnoticed. It reseeds from the agent's current location on every open, so a half-finished edit never becomes a silent default, and it states the two things a user cannot recover by guessing: working files stay behind, and (local→remote only) the private key is copied to the provider and stays there until that deployment is destroyed. `RunOnSummarySection`'s doc comment asserted that no migrate operation exists — it was the codebase's own justification for the gap, and the first thing a future reader would trust. Rewritten to point at the new command and say why migration is a separate operation rather than an edit-dialog field. `presenceLoaded` is threaded into `AgentSummary` because the gate must distinguish "offline" from "not loaded yet"; `presenceStatus` alone cannot. Signed-off-by: Junchao Yan <yjc801@gmail.com>
Review round 1 on PR #14. Four of the five findings were real defects in the guard I wrote; the fifth was the Tauri formatter. All verified in source before fixing, none disputed. **Local liveness was structurally dead.** The check read `record.runtime_pid`, but `sync_managed_agent_processes` — which this very command runs three lines earlier — unconditionally `take()`s that field on every record as legacy bookkeeping (runtime/lifecycle.rs:117). So the guard evaluated false for a normally running local agent, allowed Local → Provider with the child still alive, and left it alive: `stop_managed_agent` then rejects the record because it is no longer Local, and the next provider start produces exactly the two-live-harness state this command exists to prevent. Liveness now comes from `local_harness_alive`: every tracked runtime key for the pubkey in *any* community, plus this instance's on-disk pair receipts (the untracked-pair case). Not workspace-scoped, unlike `build_managed_agent_summary` — a pair alive in another community is just as fatal to changing where this identity runs. **A provider deploy could outlive the record it was deploying.** Callers resolve the provider under the store lock, release it, then spend up to the deploy timeout in an external process; `deploy_to_provider` reacquires the lock afterwards and writes `backend_agent_id` without rechecking. A move to Local landing in that window is durable before the deploy finishes, so the deploy still starts a remote harness while the record says Local — and Local permits a second, local one. A post-result check cannot help: the external effect has already happened. Added a per-agent fence (`begin_backend_transition`) taken by both `set_managed_agent_backend` and `deploy_to_provider`, spanning the external call, plus a backend re-read under it. Always acquired *before* the store lock, so the two locks have a fixed order. Fencing inside `deploy_to_provider` rather than `start_managed_agent` covers the create-time and owner-access-reconcile deploys too. The mirrored direction needs no fence: `start_pair` re-checks `backend == Local` under the same store lock it spawns and registers within, so a migration either loses that race or is refused by the liveness check. **A preserved `backend_agent_id` was unattributed, and that lost both consumers.** `delete_managed_agent` guards on `backend != Local`, so after Provider → Local the delete proceeded without `force_remote_delete` and erased the key plus the last pointer to a deployment that still holds a copy of that key. Provider A → Provider B was worse: A's id made B read as `deployed`, and B's first deploy overwrote it. The pointer is now *retired* rather than left behind — moved into a new `residual_deployments` list with the provider that issued it, and cleared from `backend_agent_id`. Same provider with a new config is not a retirement; that deployment is still live. The deletion guard, the frontend force flag, its confirmation copy, and the e2e bridge mock all read the residual list. **The shared-compute guard read a field that is documented as not authoritative.** `record.relay_mesh` is a back-compat marker: a linked instance's definition can switch away from mesh while the record bytes still say mesh, and a blank definition can inherit global `relay-mesh` with no marker at all. The guard therefore blocked legal migrations and accepted impossible ones — the latter saving Provider and only failing later in `build_deploy_payload`. Now resolved through `resolve_effective_relay_mesh_model_id`, matching local start and deploy preflight. Also mocks `set_managed_agent_backend` in the e2e bridge: the Agents-page Move action landed on this branch after the review, and without a mock it was unreachable in mock mode. `just ci` green end to end, including the Tauri format check that was failing at the reviewed head. Signed-off-by: Junchao Yan <yjc801@gmail.com> Co-authored-by: Junchao Yan <yjc801@gmail.com>
Round 2 review fixes. Persona deletion re-derived the orphan predicate rather than sharing it, so it only knew the first of the two ways an agent can hold provider infrastructure. An agent that had moved Provider -> Local reads `Local` with a cleared `backend_agent_id`, so the cascade was admitted and the record — and its key — destroyed while the retired deployment kept its copy. It now calls `orphans_infrastructure`, the same predicate single-agent deletion guards on. The Move affordance was mounted only on `ManagedAgentRow`, which nothing renders: its only caller is `AgentGroupRows`, and that has no caller at all. `AgentsView` renders `UnifiedAgentsSection`. The entry point moves to the profile panel's settings menu, beside the other whole-agent operations, as `useAgentRunLocationMove` — two nodes rather than a component, because a dialog mounted inside `DropdownMenuContent` unmounts with the menu that closes when the dialog takes focus. An e2e spec walks card -> panel -> menu -> dialog so reachability is pinned by a browser rather than by argument. `WhereToRunSection` hid itself whenever provider discovery came back empty, which is right when creating an agent and traps an existing one: a remote agent whose provider binary was removed lost its only way home, even though moving to Local needs no provider binary. `runOnOptions` now keeps the current provider listed whether or not discovery found it, and the section hides only when there is genuinely nothing to choose between. `unchanged` compared provider ids alone, so every config-only edit left "Move agent" disabled — unreachable despite the backend accepting same-provider, new-config as a real transition. It now compares the config by value. Finally, a provider that derives its id from the agent hands back the same id after A -> Local -> A, leaving one deployment recorded as both current and abandoned; `reclaim_residual_deployment` drops the exact match on a successful deploy. The edit dialog no longer tells users to recreate the agent. Signed-off-by: Junchao Yan <yjc801@gmail.com>
…m on delete Round 3 review fixes. `(provider_id, agent_id)` is not a complete deployment identity. `docs/remote-agents.md` I4 guarantees at most one live instance per key per **deployment scope**, and states outright that the protocol cannot prevent one key living in two scopes; for the Kubernetes provider that scope is the `context` and `namespace` in `provider_config`, and instance names are deterministic. The same `(provider, agent_id)` therefore names a different pod, holding its own copy of the key, in every namespace. So residuals carry the config they were deployed with, and both halves match on it. Redeploying into namespace B no longer discards the residual naming the pod in namespace A. `retire_deployment_pointer` loses its same-provider early return for the same reason: a namespace edit strands the old pod exactly as switching providers does, and that path is reachable now that config-only edits can be submitted. The desktop cannot tell a scope key from a tuning key — the deploy response carries `agent_id` and `fresh_generation`, never a scope handle — so every config difference counts as possibly-different infrastructure. That keeps a residual which is sometimes really the live deployment, over-warning on deletion; the opposite error orphans a pod and Secret holding the private key, silently. Deletion also has to say so. The profile path passes `skipRemoteDeleteConfirm`, which suppresses the residual `window.confirm` while still sending `forceRemoteDelete`, so the dialog is the only disclosure a user gets — and keyed on the backend alone it told an agent with orphanable infrastructure only that a local process would stop. Proving that in a browser surfaced two mock-fidelity bugs behind it: `cloneManagedAgent` is a field-by-field clone that omitted `residual_deployments`, so the bridge's retirement was invisible to the app whatever it wrote, and the mock kept the same-provider early return this commit removes from the real rule. Signed-off-by: Junchao Yan <yjc801@gmail.com>
Raw provider config cannot prove effective deployment scope, so no equality test here can decide that a redeploy landed on the deployment a previous move retired. The Kubernetes provider's `context` is optional, and an omitted one resolves from the machine's *current* kubeconfig at deploy time (`config.rs` `context: Option<String>` — "`None` uses the current context"; `client.rs` `connect` hands it to `KubeConfigOptions`). Deploy in cluster A without an explicit context, move to Local, change current-context to cluster B, move back: the saved config is identical, the deterministic pod name is identical, and the cluster is not. Both previous keyings — `(provider_id, agent_id)`, then that plus the config — would drop the entry naming the pod and Secret in A that still hold the private key. This is not specific to Kubernetes. Any provider config key that is optional and environment-resolved has the same shape, and the desktop observes none of that resolution: the deploy response carries `agent_id` and `fresh_generation`, never a scope handle. So `reclaim_residual_deployment` is removed rather than re-keyed, and residuals are retained unconditionally. That reinstates the duplicate warning after A -> Local -> A as a deliberate cost: retention is wrong about a *message*, reclaiming is wrong about a *pointer*, and only the second is unrecoverable. The delete disclosures now state the ambiguity rather than asserting the deployment was abandoned. Deciding this properly needs a provider-returned stable scope identity, which is a protocol addition. Signed-off-by: Junchao Yan <yjc801@gmail.com>
This was referenced Aug 10, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Where an agent runs is fixed at creation today. The only way to change it is
delete-and-recreate — which permanently destroys the keypair, channel grants,
git ACL, auth tag and NIP-AE engrams, because Buzz has no key import. This adds
a real migration, both directions: the agent's pubkey and everything keyed to it
survive the move.
Why the current immutability doesn't hold up
RunOnSummarySection.tsx:9states the rationale: "a provider-backed agent'sdeployment holds its private key; there is no migrate operation."
But
record.private_key_nseclives in the desktop's managed-agents store, andthe sprites backend already copies it to the sprite on every provider deploy
(
buzz-backend-sprites/src/env.rs:266). Migration copies a key to a providerexactly as creating a remote agent does, and the desktop keeps its copy — which
is also why the reverse direction is symmetric and nearly free.
The invariant: one identity, one live harness
Two harnesses signing as the same pubkey means doubled replies, flapping
presence, and concurrent NIP-AE writes against one
(agent, owner)pair. Everyguard here exists for that, and it is enforced across two layers because
neither can do it alone.
Liveness is only observable for local agents:
sync_managed_agent_processes+ live piddeployed/not_deployedfrombackend_agent_idruntime.rs's own comment notes a sprite staysdeployedafter!shutdown, andthe real signal — relay presence — is polled in the frontend and never reaches
the Rust process.
stop_managed_agentadditionally rejects non-local backends("remote agents are stopped via !shutdown message"), so no backend call could
establish stoppedness.
Rather than check something it cannot see,
set_managed_agent_backendtakes anexplicit
remote_confirmed_stoppedassertion and documents why.migrationGateis what makes that assertion true, and it fails closed in three distinct
ways — presence not yet loaded, presence loaded but absent for this agent,
and any status other than
offline. Absence is not evidence of offline: an agentwhose heartbeat never arrived looks identical to one that stopped. Guessing wrong
toward "offline" costs a duplicate agent; guessing wrong the other way costs a
few seconds of waiting.
Three consequences worth reviewing directly:
awaycounts as running. Onlyofflinereleases the gate.agent.statusnever substitutes for presence on a provider agent — evennot_deployedblocks while presence says online, because it only means noinfrastructure is recorded.
can be alive while the relay shows it offline. Different signals, different
questions.
Residual deployments, and why nothing ever reclaims them
Buzz has no
undeploy, so leaving a provider strands infrastructure thatstill holds a copy of the agent's key. Naming it is the whole problem.
An earlier revision kept the raw
backend_agent_idafter the backend changed.That preserves the id and destroys its attribution:
delete_managed_agentguards on
backend != Local, so a migrated record deleted silently, taking thekey and the last pointer with it. The pointer is now retired into
residual_deployments—{ provider_id, agent_id, config }— andbackend_agent_idcleared.deletion_orphans_infrastructureis the singlepredicate every delete path shares, including the persona cascade.
The config is part of the identity because at-most-one is a per-scope
guarantee (
docs/remote-agents.mdI4), and for Kubernetes the scope iscontext+namespaceinprovider_config. Deterministic instance namingmeans the same
(provider, agent_id)names a different pod, holding its owncopy of the key, in every namespace.
Nothing removes a residual automatically, and that is the load-bearing
decision here. Two revisions tried — first keyed on
(provider_id, agent_id),then on the config too — and both were wrong for the same reason: raw config
cannot prove effective scope. The Kubernetes
contextis optional and anomitted one resolves from the machine's current kubeconfig at deploy time, so
identical saved config plus an identical deterministic pod name can still be a
different cluster. That is not a Kubernetes quirk; it is any optional
environment-resolved config key, and the deploy response carries
agent_idandfresh_generationand no scope handle at all.So residuals are retained unconditionally. The cost is a duplicate warning after
A → Local → A, which the delete surfaces disclose honestly rather than paperover: retention is wrong about a message, reclaiming is wrong about a pointer,
and only the pointer is unrecoverable. Deciding it properly needs the provider
to return a stable resolved scope identity — a protocol addition, not this PR.
Two choices worth not undoing
deploy_to_providerrunsits external call outside the store lock, so
begin_backend_transitionis aper-agent claim spanning the whole deploy, taken by both the migration command
and every deploy site. Always acquired before the store lock, never while
holding it. The mirrored direction needs no fence:
start_pairre-checksbackend == Localunder the same lock it spawns in.UpdateManagedAgentRequest. Keeping migration on adedicated command means an ordinary edit-dialog save can never change where an
agent runs.
RunOnSummarySection's doc comment — which asserted no migrateoperation exists, and was the codebase's own justification for the gap — is
rewritten to say that, rather than deleted.
The Move action lives in the agent profile panel's settings menu, beside
auto-start, archive and delete — the surface reached by opening an agent from
UnifiedAgentsSection. It is a hook returning two nodes rather than acomponent, because a dialog mounted inside
DropdownMenuContentunmounts themoment it takes focus and closes the menu; the archive and delete confirmations
in that file are hoisted for the same reason.
The dialog reuses the create flow's
WhereToRunSectionrather than a parallelpicker, so provider discovery, schema probing and config validation stay in one
place. It reseeds from the agent's current location on every open, and states the
two things a user cannot recover by guessing: working files stay behind, and
(local→remote only) the private key is copied to the provider and stays there
until that deployment is destroyed.
Backend guards live in a new
backend_migrationmodule as a pure function sothey are testable without an
AppHandle— mirroring how #4 extractedcommunity_scope. Preconditions are a struct rather than three positional bools;that argument list is exactly how a guard silently inverts.
Related issue
None found — no existing issue or PR covers agent backend migration.
Testing
just cigreen end to end at this head,JUST_CI_EXIT=0:cargo test— 2326 pass, 0 fail. The newbackend_migrationmodule carries 23 of them.
pnpm test— 4649 pass, 0 fail (migrationGate14,whereToRunIntent14,
managedAgentControlActions24).--project=integration: one walks card → profilepanel → settings menu → dialog, the other asserts a migrated agent's delete
dialog names the deployment it left behind.
pnpm check, desktop + web builds,flutter test1261.
The tests pin the parts most likely to regress — on the Rust side: a
running local process blocks both directions; leaving a provider requires the
assertion but entering one must not (or the common direction becomes
unusable); relay-mesh agents can't move to a provider but can move back;
a no-op is rejected before every other check; same provider id with a different
config is a real re-deploy, not a no-op. On the UI side: each of the three
fail-closed paths,
awayblocking, and provider status not substituting forpresence.
Important
Not verified live yet. End-to-end verification is: migrate a throwaway
agent (not a fleet member) local→remote, Start it, confirm same pubkey, channel
membership and engrams survive and it answers a mention; then migrate back.
Plus the negative case — with a remote agent live, confirm the control is
disabled and the command refuses. I'd want that run before merge.
Risks
gate is conservative; the failure mode is waiting, not duplicating.
it; only destroying the sprite does, and there is no undeploy operation yet
(
runtime.rscalls it "a future providerundeployoperation (v2)"). This iswhat
residual_deploymentsexists to keep nameable.deletion warns about it. Deliberate — see above; the alternative loses a
pointer permanently.
chosen trade-off; the wording carries it.
🤖 Generated with Claude Code