Skip to content

feat(desktop): move an agent between local and remote, keeping its identity - #14

Merged
yjc801 merged 6 commits into
mainfrom
claude/agent-backend-migration
Aug 10, 2026
Merged

yjc801 merged 6 commits into
mainfrom
claude/agent-backend-migration

Conversation

@yjc801

@yjc801 yjc801 commented Aug 10, 2026

Copy link
Copy Markdown
Owner

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:9 states the rationale: "a provider-backed agent's
deployment holds its private key; there is no migrate operation."

But record.private_key_nsec lives in the desktop's managed-agents store, and
the 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 provider
exactly 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. Every
guard here exists for that, and it is enforced across two layers because
neither can do it alone.

Liveness is only observable for local agents:

signal authoritative?
Local sync_managed_agent_processes + live pid yes — checked in Rust
Provider deployed/not_deployed from backend_agent_id no — infrastructure existence

runtime.rs's own comment notes a sprite stays deployed after !shutdown, and
the real signal — relay presence — is polled in the frontend and never reaches
the Rust process. stop_managed_agent additionally 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_backend takes an
explicit remote_confirmed_stopped assertion and documents why. migrationGate
is 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 agent
whose 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:

  • 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.

Residual deployments, and why nothing ever reclaims them

Buzz has no undeploy, so leaving a provider strands infrastructure that
still holds a copy of the agent's key
. Naming it is the whole problem.

An earlier revision kept the raw backend_agent_id after the backend changed.
That preserves the id and destroys its attribution: delete_managed_agent
guards on backend != Local, so a migrated record deleted silently, taking the
key and the last pointer with it. The pointer is now retired into
residual_deployments{ provider_id, agent_id, config } — and
backend_agent_id cleared. deletion_orphans_infrastructure is the single
predicate 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.md I4), and for Kubernetes the scope is
context + namespace in provider_config. Deterministic instance naming
means the same (provider, agent_id) names a different pod, holding its own
copy 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 context is optional and an
omitted 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_id and
fresh_generation and 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 paper
over: 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

  • Migration is fenced against in-flight deploys. deploy_to_provider runs
    its external call outside the store lock, so begin_backend_transition is a
    per-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_pair re-checks
    backend == Local under the same lock it spawns in.
  • This is not a field on UpdateManagedAgentRequest. Keeping migration on a
    dedicated command means an ordinary edit-dialog save can never change where an
    agent runs. RunOnSummarySection's doc comment — which asserted no migrate
    operation 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 a
component, because a dialog mounted inside DropdownMenuContent unmounts the
moment 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 WhereToRunSection rather than a parallel
picker, 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_migration module as a pure function so
they are testable without an AppHandle — mirroring how #4 extracted
community_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 ci green end to end at this head, JUST_CI_EXIT=0:

  • Tauri cargo test2326 pass, 0 fail. The new backend_migration
    module carries 23 of them.
  • pnpm test4649 pass, 0 fail (migrationGate 14, whereToRunIntent
    14, managedAgentControlActions 24).
  • Two Playwright specs under --project=integration: one walks card → profile
    panel → settings menu → dialog, the other asserts a migrated agent's delete
    dialog names the deployment it left behind.
  • Workspace fmt + clippy, pnpm check, desktop + web builds, flutter test
    1261.

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, away blocking, and provider status not substituting for
presence.

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

  • Presence lags, so a sprite that just went quiet may still read online. The
    gate is conservative; the failure mode is waiting, not duplicating.
  • The provider's key copy outlives the migration. Moving back doesn't retract
    it; only destroying the sprite does, and there is no undeploy operation yet
    (runtime.rs calls it "a future provider undeploy operation (v2)"). This is
    what residual_deployments exists to keep nameable.
  • A returning agent shows a residual that may be its current deployment, and
    deletion warns about it. Deliberate — see above; the alternative loses a
    pointer permanently.
  • Workspace loss is silent unless the dialog is read. Warn-only was the
    chosen trade-off; the wording carries it.

🤖 Generated with Claude Code

yjc801 added 6 commits August 9, 2026 22:02
…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>
@yjc801
yjc801 merged commit 07dbaeb into main Aug 10, 2026
22 of 25 checks passed
@yjc801
yjc801 deleted the claude/agent-backend-migration branch August 10, 2026 18:19
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