block / block/buzz

Provider protocol: add an `undeploy` op — the protocol has a constructor and no destructor

Open
#5,570 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
Rust
Stars
32.7k
Forks
4.3k
Avg merge
1d 13h
Merged PRs (30d)
253

Description

**Motivation**

The provider protocol has a constructor and no destructor. `deploy` creates a
substrate object; nothing the desktop can ever send destroys one. Every
teardown path that exists today is either *cooperative* — it needs the thing it
is tearing down to be alive and willing — or it is an *orphan* by construction.

The absence is deliberate and marked as such in three places, so this is a
request to close a known deferral rather than a bug report:

- `desktop/src-tauri/src/commands/agents.rs:455` — *"the protocol does not
include an explicit `undeploy` operation (deferred to v2)."*
- `desktop/src-tauri/src/managed_agents/runtime.rs:162` — *"A future provider
`undeploy` operation (v2) will handle teardown."*
- `docs/remote-agents.md:461` — *"**There is no `undeploy` op in v1.** Deletion
of a remote agent from `D` orphans the substrate objects."*

Verified against `main` at `be48ce98bd163899197b79a82ad5b2bcf0bc9b54`. The wire
surface is `{"op":"info"}` and `{"op":"deploy"}` only
(`managed_agents/backend.rs:516`, `:523`); the reference provider rejects
everything else (`crates/buzz-backend-kubernetes/src/wire.rs:171`).

**What I am *not* asking for**

Not that Stop become a provider operation. `docs/remote-agents.md:886` is
explicit — *"**Stop** is not a provider operation"* — and the rationale holds:
routing stop through the relay keeps the provider out of the identity business
and gives the harness its graceful path (drain, publish presence `offline`,
close). `undeploy` is not a replacement for that. It is the destructor for the
substrate object *after* the agent is gone, or when it cannot be reached at all.

**Why the cooperative path is not sufficient on its own**

For a provider-backed agent, the desktop's local lifecycle commands refuse
outright — `stop_managed_agent` and `start_managed_agent` both return
`"agent {pubkey} is not a local agent"` (`commands/agents.rs:283`, `:377`,
`:408`). What actually happens when the user presses Shutdown is
`stopManagedAgentWithRules` posting the literal text `!shutdown` into a channel
(`desktop/src/features/agents/lib/managedAgentControlActions.ts:132`, `:167`).

That requires **three** things to be simultaneously true:

1. the harness process is alive and connected to the relay;
2. a channel resolves for the agent;
3. the harness resolves an owner identity and accepts the sender as owner —
`BUZZ_ACP_AGENT_OWNER` from `launch.owner_pubkey`, or `auth_tag`.

Any one false and there is no lever at all — not a degraded one, none. (3) is
not hypothetical: a provider that does not map `launch.owner_pubkey` produces a
harness that answers `!shutdown` conversationally, and the desktop reports the
stop as sent. We shipped exactly that and wrote it up as Defect 1 in our own
binding — `docs/ORCA-BINDING.md` §4.5.

**The consequences are structural, and the current fixes route around the gap**

Three separate open threads are each working around the same missing operation.
This is the strongest argument for adding it, so it is worth being specific:

| Symptom | Issue | In-flight fix | What it does about the missing destructor |
|---|---|---|---|
| Remote agent renders "online" with a live Shutdown button forever; Deploy unreachable | #4730 | PR #5138 (open) | Derives liveness from presence instead of `backend_agent_id`. Its own body: *"That closes the 'cannot bring it back' half **without needing the v2 `undeploy`**."* |
| Deleting a persona with provider-deployed instances always fails | #3771 | PR #3808 (open) | Force-deletes each instance first, every one through the orphan-warning confirm — i.e. the fix for the persona symptom is to orphan more reliably |
| Management is pinned to the desktop that created the agent | #4605 | — | A stranded desktop cannot even orphan it; there is no operation to send |
| A reconcile deploy is byte-identical to an owner Start, so a stopped agent revives | #5283 | proposed `reason` discriminator | The complement of this request: "stopped" has no state a provider can observe |

The refusal that blocks the persona cascade is correct as written
(`commands/personas/mod.rs:81`, message at `:180`) — deleting the local record
*would* orphan the deployment. It is only unfixable because there is no third
option between "refuse" and "orphan".

And the one exit that exists, `delete_managed_agent(force_remote_delete: true)`
(`commands/agents.rs:1276`), is honest about what it does rather than fixing it:
the spec's requirement at `:925` is that a *buggy IPC caller* cannot silently
orphan substrate objects. A confirmation dialog makes the orphan deliberate. It
does not make it not an orphan.

**Why the Kubernetes GC argument does not generalize**

`docs/remote-agents.md:1390-1394` argues the residue is bounded:

> GC on next-deploy also self-heals the missing `undeploy`: delete-then-recreate
> converges, and a deleted-forever agent's residue is one Completed pod that
> never restarts (I5) plus one Secret, removable with `kubectl delete`.

Same-key reap on redeploy is what our provider does too, and it is genuinely
sufficient for redeploy. It cannot cover *deleted and never redeployed*, because
by definition no later deploy ever carries that key — the reap has no trigger.
So "self-heals" holds for one of the two cases and the argument for the other is
that the residue is small, not that it is reachable.

How small depends on the substrate, and that is the part a Kubernetes-shaped
intuition understates:

- On Kubernetes the residue is inert — a Completed pod and a Secret. Though the
Secret still holds a live `nsec` (§K8s Secrets) for an agent the user believes
they deleted.
- On a workstation substrate the residue is **live**. Deleting two agents on
this machine left two git worktrees, two branches, and a running `goose acp`
process still attached to a checkout for an agent that no longer existed.
Nothing in Buzz knew, and nothing would ever have cleaned it up. Details in
`docs/ORCA-BINDING.md` §6.3, which states the gap rather than claiming to
close it.

This is why it reads as a protocol-level gap rather than a per-binding one: a
binding cannot fix it, because the fact it needs — *the owner deleted this
agent* — is never transmitted. Every provider is currently required to guess it
from the absence of a future deploy.

**Proposed solution**

Add `undeploy` to the provider protocol.

```json
{ "op": "undeploy",
"request_id": "…",
"agent_id": "",
"provider_config": { … } }
```

Response `{"ok": true}` / `{"ok": false, "error": "…"}`, one-JSON-in /
one-JSON-out, exit codes carrying one bit — as [L2] item 1 already requires.

Four properties worth pinning in the spec text:

1. **Idempotent; delete-of-absent is success.** This already exists as a rule
for `deploy`'s reconciliation (`[L2].4`), so it is a restatement, not a new
concept.
2. **No `private_key_nsec` in the request.** A teardown needs no identity
material, and omitting it means `undeploy` is callable from a desktop that
never held the key — which is precisely #4605's stranded-desktop case. It
also keeps the operation outside the pre-secret negotiation gate.
3. **Called on delete, before the record is dropped** — and on success the
orphan-warning confirmation is not shown, because there is no orphan. The
confirmation stays for v1 providers and for `undeploy` failures.
4. **It is teardown, not stop.** The desktop should still publish `!shutdown`
first and let the harness take its graceful path; `undeploy` reclaims the
substrate object afterwards, and is the *only* path when the cooperative one
is unavailable.

**One migration detail that needs deciding first**

Version negotiation is currently exact-match, not minimum:
`validate_provider_info` rejects anything where
`protocol_version != PROVIDER_PROTOCOL_VERSION`
(`managed_agents/backend.rs:11`, `:18-25`). So a provider that advertises `2` is
refused by every shipped desktop, and a desktop that requires `2` refuses every
existing provider. Bumping the integer is therefore a hard cutover in both
directions.

Two ways out, either fine:

- have `info` advertise supported ops (`"ops": ["info","deploy","undeploy"]`)
and keep `protocol_version` at 1 — capability negotiation rather than
versioning, and it degrades cleanly to today's behaviour; or
- accept a version *range* (`min`/`max`, or a desktop-side accepted set) before
any version is bumped.

The first needs no version bump at all and is what I would suggest, but the
choice belongs to whoever owns the protocol. Happy to send a PR for the
`buzz-backend-kubernetes` side and the spec text once the shape is agreed.

**Alternatives considered**

- *Provider-side TTL / self-reaping.* A provider cannot distinguish "deleted" from
"idle" without the desktop telling it, so any TTL either kills live agents or
keeps dead ones.
- *Echo `backend_agent_id` back on deploy so the provider can reconcile.* Useful
independently (#4605 covers it) and we already derive a stable key from the
payload, but it still only fires on a deploy that never comes.
- *Leave it to `kubectl` / the substrate's own tooling.* Reasonable for a cluster
with an operator. Not reasonable when the substrate is the user's laptop and
the residue is a running process.

**Additional context**

Searched issues and PRs first. Nothing found requesting `undeploy` directly;
the closest are #4605, #4730 (+ PR #5138), #3771 (+ PR #3808), #5283 and #2798,
each a consequence rather than the operation itself. **PR #3449**
("feat: remote agents over SSH", open) is the most relevant precedent: it
extends the provider protocol to five ops — `info`, `check`,
`discover_harnesses`, `probe_models`, `deploy` — and `undeploy` is not among
them, so a second reference provider is about to inherit the same gap.

Found while building an out-of-tree `buzz-backend-*` provider that runs Buzz
agents in [Orca](https://stably.ai) worktrees. Buzz Desktop 0.5.8, macOS.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.