Files
multica/server/migrations/251_agent_runtime_unbind.up.sql
b06af2ae17 feat(runtime): unbind agents on runtime delete instead of destroying them (#6220)
* feat(runtime): unbind agents on runtime delete instead of destroying them

Deleting a runtime archived its agents and then hard-deleted the rows, so the
agents and every conversation with them disappeared — while the confirmation
dialog said "archive", which a user reasonably reads as recoverable. Retiring a
laptop is an ordinary action; losing the agents configured on it is not an
ordinary consequence.

An agent is now a persistent business object and a runtime is replaceable
execution capacity: deleting a runtime unbinds its agents. `runtime_id IS NULL`
means unbound — orthogonal to archived — and the agent keeps its instructions,
skills, chats, labels, channel installations, autopilots and task history.
service.AgentReadiness already refused an agent with no runtime, so the
scheduling safety gate needed no change.

Two columns become nullable, not one. Without `agent_task_queue.runtime_id`,
deleting the runtime still cascades the task history away (and task_message /
task_usage / task_token with it), so the agents would survive with no record of
anything they did — the same class of loss. A NOT VALID CHECK keeps NULL confined
to history: an active task must always have a runtime, so claim / dispatch /
delivery-CAS paths can never observe one without. It is written against
completed_at rather than a status list so a future non-terminal status fails
closed instead of slipping through.

Two prerequisites this depends on:

- 'deferred' (migration 128) was missing from CancelAgentTasksByRuntimeOrAgent.
  It went unnoticed because the delete used to cascade those rows away; with the
  new CHECK it would abort the delete and make the runtime undeletable.
- The channel-installation / label / chat-pin / invocation-target / draft-restore
  cleanups were scoped to "archived agents on this runtime". Archived user agents
  now survive, so that scope is narrowed to kind='system' — otherwise the fix
  would produce a subtler loss: agent alive, configuration wiped.

Also removes the squad guard that refused (409) when an active squad's leader was
an archived agent on the runtime, plus the archived-squad delete that existed
only to get past squad.leader_id's RESTRICT FK. The leader is no longer deleted,
so nothing needs to be given up to retire a machine. Autopilots are no longer
paused either: their assignee survives, and a rebind restores them without the
owner having to remember to re-enable.

Reason codes: an unbound agent reports agent_runtime_required, not
runtime_offline. The copy for runtime_offline tells users to reconnect a machine;
an unbound agent has no machine to reconnect, and the fix is to bind a runtime.
Chat's bare 409 string gains the same code so the composer can offer that action.

API: agents gain runtime_bound. runtime_id stays a string (empty when unbound) so
installed clients keep parsing and no gated two-release rollout is needed. The
confirmed-delete endpoint is /unbind-agents-and-delete; /archive-agents-and-delete
still routes to it, and the compared expected_active_agent_ids set is unchanged —
widening it would 409 every older client forever.

Co-authored-by: multica-agent <[email protected]>

* fix: make runtime unbinding recoverable

Co-authored-by: multica-agent <[email protected]>

* fix: address runtime unbind review nits

Co-authored-by: multica-agent <[email protected]>

* fix: resolve runtime unbind review blockers

Co-authored-by: multica-agent <[email protected]>

* fix(migrations): renumber runtime unbind after main merge

Co-authored-by: multica-agent <[email protected]>

* test(daemon): avoid late-request lease flake

Co-authored-by: multica-agent <[email protected]>

* test(autopilots): bind validation fixture runtime

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Eve <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-03 12:39:27 +08:00

69 lines
3.7 KiB
SQL

-- Migration 251 (MUL-5559): deleting a runtime unbinds its agents instead of
-- destroying them.
--
-- Before this migration an agent physically could not exist without a runtime
-- (agent.runtime_id NOT NULL), so the runtime-delete flow had no third option:
-- it archived the runtime's agents and then hard-deleted the rows, taking their
-- chat sessions with them. The confirmation dialog called that "archive", which
-- is not what the user got — the agents and every conversation with them were
-- gone. Retiring a laptop is an ordinary action; losing the agents configured
-- on it is not an ordinary consequence.
--
-- Two columns become nullable, not one:
--
-- 1. agent.runtime_id — NULL now means "unbound": the agent, its
-- instructions, skills, chats, labels, channel installations and
-- autopilot config all survive, it simply cannot run until it is bound to
-- a runtime again. Unbound is orthogonal to archived.
-- AgentReadiness already refuses an agent with no runtime, so the
-- scheduling safety gate needs no change.
--
-- 2. agent_task_queue.runtime_id — without this, unbinding the agent would
-- still lose its whole task history: that column is NOT NULL with an
-- ON DELETE CASCADE FK, which cascades further into task_message,
-- task_usage and task_token. ReassignTasksToRuntime already documents the
-- same trap for the legacy-daemon merge path ("Required before deleting
-- the old runtime row because agent_task_queue has an ON DELETE CASCADE
-- FK that would otherwise drop historical tasks"). The delete transaction
-- now nulls these rows explicitly, so the cascade never fires.
--
-- No new foreign keys or cascading actions are introduced (repository rule):
-- the unbind itself is an explicit application-layer UPDATE inside the same
-- transaction as the runtime delete. The pre-existing CASCADE on
-- agent_task_queue.runtime_id is left in place but is now inert on this path —
-- it stays as the last-resort guarantee that no row can reference a runtime
-- that no longer exists.
--
-- The CHECK is the invariant that keeps NULL confined to history: an ACTIVE
-- task must always have a runtime, so claim / dispatch / delivery-CAS paths can
-- never observe runtime_id IS NULL. It is expressed with completed_at rather
-- than a status list because every terminal transition stamps completed_at
-- (CompleteAgentTask / FailAgentTask / the five cancel queries), and because
-- naming enumerable status values in a constraint would force a migration for
-- each new status. A future non-terminal status therefore fails closed — it
-- must carry a runtime — instead of silently slipping through.
--
-- NOT VALID keeps this a metadata-only add on a hot table (no ACCESS EXCLUSIVE
-- full-table scan); PostgreSQL still enforces it on every INSERT and UPDATE
-- from this point on, which is exactly what the invariant needs. Existing rows
-- all satisfy it (runtime_id was NOT NULL until now), so there is nothing to
-- back-fill; a later migration can VALIDATE it during a quiet window.
ALTER TABLE agent
ALTER COLUMN runtime_id DROP NOT NULL;
ALTER TABLE agent_task_queue
ALTER COLUMN runtime_id DROP NOT NULL;
ALTER TABLE agent_task_queue
ADD CONSTRAINT agent_task_queue_active_requires_runtime
CHECK (runtime_id IS NOT NULL OR completed_at IS NOT NULL)
NOT VALID;
-- Runtime deletion pauses affected automations so a schedule cannot append one
-- identical skipped run on every tick forever. The reason is persisted
-- separately from status: clients can explain the recoverable condition, and a
-- user can rebind the agent then resume the unchanged automation.
ALTER TABLE autopilot
ADD COLUMN pause_reason TEXT;