Files
multica/server/pkg/db/queries/issue_label.sql
Multica Eve 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 <github@multica.ai>

* fix: make runtime unbinding recoverable

Co-authored-by: multica-agent <github@multica.ai>

* fix: address runtime unbind review nits

Co-authored-by: multica-agent <github@multica.ai>

* fix: resolve runtime unbind review blockers

Co-authored-by: multica-agent <github@multica.ai>

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

Co-authored-by: multica-agent <github@multica.ai>

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

Co-authored-by: multica-agent <github@multica.ai>

* test(autopilots): bind validation fixture runtime

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-03 12:39:27 +08:00

213 lines
7.5 KiB
SQL

-- name: ListLabels :many
SELECT l.*,
CASE l.resource_type
WHEN 'issue' THEN (SELECT COUNT(*) FROM issue_to_label x WHERE x.label_id = l.id)
WHEN 'agent' THEN (SELECT COUNT(*) FROM agent_to_label x WHERE x.label_id = l.id)
WHEN 'skill' THEN (SELECT COUNT(*) FROM skill_to_label x WHERE x.label_id = l.id)
ELSE 0
END::bigint AS usage_count
FROM issue_label l
WHERE l.workspace_id = sqlc.arg('workspace_id')::uuid
AND l.resource_type = sqlc.arg('resource_type')::text
ORDER BY LOWER(name) ASC;
-- name: GetLabel :one
SELECT * FROM issue_label
WHERE id = $1 AND workspace_id = $2;
-- name: CreateLabel :one
INSERT INTO issue_label (workspace_id, resource_type, name, description, color)
VALUES ($1, $2, $3, $4, $5)
RETURNING *;
-- name: UpdateLabel :one
UPDATE issue_label SET
name = COALESCE(sqlc.narg('name'), name),
description = COALESCE(sqlc.narg('description'), description),
color = COALESCE(sqlc.narg('color'), color),
updated_at = now()
WHERE id = $1 AND workspace_id = $2
RETURNING *;
-- name: DeleteLabel :one
-- :one RETURNING id so the handler distinguishes pgx.ErrNoRows (→ 404) from
-- infrastructure errors (→ 500), and avoids a TOCTOU precheck.
DELETE FROM issue_label
WHERE id = $1 AND workspace_id = $2
RETURNING id;
-- The resource-label junctions deliberately have no foreign keys. Keeping
-- their cleanup in the same application transaction as the owner deletion
-- avoids database-level cascades with unreviewed locking and audit behavior.
-- name: DeleteIssueLabelAssignmentsByLabel :exec
DELETE FROM issue_to_label WHERE label_id = $1;
-- name: DeleteAgentLabelAssignmentsByLabel :exec
DELETE FROM agent_to_label WHERE label_id = $1;
-- name: DeleteSkillLabelAssignmentsByLabel :exec
DELETE FROM skill_to_label WHERE label_id = $1;
-- name: DeleteAgentLabelAssignmentsByAgent :exec
DELETE FROM agent_to_label WHERE agent_id = $1;
-- name: DeleteSkillLabelAssignmentsBySkill :exec
DELETE FROM skill_to_label WHERE skill_id = $1;
-- The single-entity cleanups above cover one agent/skill at a time. The runtime
-- variant below covers runtime and runtime-profile bulk hard deletes, where the
-- owning agents disappear without passing through a per-entity delete.
-- Workspace-wide cleanup lives in DeleteWorkspace so it is atomic with that
-- workspace's existing multi-table teardown.
-- name: DeleteAgentLabelAssignmentsBySystemRuntimeAgents :exec
-- Runtime teardown hard-deletes the system agents bound to the runtime (user
-- agents are unbound and kept since MUL-5559). Clear only those agents' label
-- links so none survive the agent hard-delete — a surviving unbound agent must
-- keep its labels.
DELETE FROM agent_to_label
WHERE agent_id IN (SELECT id FROM agent WHERE runtime_id = $1 AND kind = 'system');
-- name: AttachLabelToIssue :exec
-- Workspace-guarded INSERT: the WHERE EXISTS clauses ensure both the issue
-- and the label belong to the given workspace. A future caller that forgets
-- handler-level prechecks still cannot attach labels across workspaces.
INSERT INTO issue_to_label (issue_id, label_id)
SELECT sqlc.arg('issue_id')::uuid, sqlc.arg('label_id')::uuid
WHERE EXISTS (
SELECT 1 FROM issue i
WHERE i.id = sqlc.arg('issue_id')::uuid
AND i.workspace_id = sqlc.arg('workspace_id')::uuid
)
AND EXISTS (
SELECT 1 FROM issue_label l
WHERE l.id = sqlc.arg('label_id')::uuid
AND l.workspace_id = sqlc.arg('workspace_id')::uuid
AND l.resource_type = 'issue'
)
ON CONFLICT DO NOTHING;
-- name: DetachLabelFromIssue :exec
-- Workspace-guarded DELETE: only deletes if the issue is in the given
-- workspace. Mirror of the attach query.
DELETE FROM issue_to_label
WHERE issue_id = sqlc.arg('issue_id')::uuid
AND label_id = sqlc.arg('label_id')::uuid
AND EXISTS (
SELECT 1 FROM issue i
WHERE i.id = sqlc.arg('issue_id')::uuid
AND i.workspace_id = sqlc.arg('workspace_id')::uuid
);
-- name: ListLabelsByIssue :many
-- Workspace filter at the SQL layer (mirrors GetProjectInWorkspace). Any caller
-- that passes the wrong workspace gets an empty list rather than leaking labels.
SELECT l.*
FROM issue_label l
JOIN issue_to_label il ON il.label_id = l.id
WHERE il.issue_id = sqlc.arg('issue_id')::uuid
AND l.workspace_id = sqlc.arg('workspace_id')::uuid
AND l.resource_type = 'issue'
ORDER BY LOWER(l.name) ASC;
-- name: ListLabelsForIssues :many
-- Bulk variant: fetch labels for many issues in one round-trip so the issue
-- list endpoints can fold labels into each row without N+1 queries from the
-- client. Workspace-guarded the same way as ListLabelsByIssue.
SELECT il.issue_id, l.*
FROM issue_label l
JOIN issue_to_label il ON il.label_id = l.id
WHERE il.issue_id = ANY(sqlc.arg('issue_ids')::uuid[])
AND l.workspace_id = sqlc.arg('workspace_id')::uuid
AND l.resource_type = 'issue'
ORDER BY il.issue_id, LOWER(l.name) ASC;
-- name: ListLabelsByAgent :many
SELECT l.*
FROM issue_label l
JOIN agent_to_label atl ON atl.label_id = l.id
WHERE atl.agent_id = sqlc.arg('agent_id')::uuid
AND l.workspace_id = sqlc.arg('workspace_id')::uuid
AND l.resource_type = 'agent'
ORDER BY LOWER(l.name) ASC;
-- name: ListLabelsForAgents :many
SELECT atl.agent_id, l.*
FROM issue_label l
JOIN agent_to_label atl ON atl.label_id = l.id
WHERE atl.agent_id = ANY(sqlc.arg('agent_ids')::uuid[])
AND l.workspace_id = sqlc.arg('workspace_id')::uuid
AND l.resource_type = 'agent'
ORDER BY atl.agent_id, LOWER(l.name) ASC;
-- name: AttachLabelToAgent :exec
INSERT INTO agent_to_label (agent_id, label_id)
SELECT sqlc.arg('agent_id')::uuid, sqlc.arg('label_id')::uuid
WHERE EXISTS (
SELECT 1 FROM agent a
WHERE a.id = sqlc.arg('agent_id')::uuid
AND a.workspace_id = sqlc.arg('workspace_id')::uuid
)
AND EXISTS (
SELECT 1 FROM issue_label l
WHERE l.id = sqlc.arg('label_id')::uuid
AND l.workspace_id = sqlc.arg('workspace_id')::uuid
AND l.resource_type = 'agent'
)
ON CONFLICT DO NOTHING;
-- name: DetachLabelFromAgent :exec
DELETE FROM agent_to_label
WHERE agent_id = sqlc.arg('agent_id')::uuid
AND label_id = sqlc.arg('label_id')::uuid
AND EXISTS (
SELECT 1 FROM agent a
WHERE a.id = sqlc.arg('agent_id')::uuid
AND a.workspace_id = sqlc.arg('workspace_id')::uuid
);
-- name: ListLabelsBySkill :many
SELECT l.*
FROM issue_label l
JOIN skill_to_label stl ON stl.label_id = l.id
WHERE stl.skill_id = sqlc.arg('skill_id')::uuid
AND l.workspace_id = sqlc.arg('workspace_id')::uuid
AND l.resource_type = 'skill'
ORDER BY LOWER(l.name) ASC;
-- name: ListLabelsForSkills :many
SELECT stl.skill_id, l.*
FROM issue_label l
JOIN skill_to_label stl ON stl.label_id = l.id
WHERE stl.skill_id = ANY(sqlc.arg('skill_ids')::uuid[])
AND l.workspace_id = sqlc.arg('workspace_id')::uuid
AND l.resource_type = 'skill'
ORDER BY stl.skill_id, LOWER(l.name) ASC;
-- name: AttachLabelToSkill :exec
INSERT INTO skill_to_label (skill_id, label_id)
SELECT sqlc.arg('skill_id')::uuid, sqlc.arg('label_id')::uuid
WHERE EXISTS (
SELECT 1 FROM skill s
WHERE s.id = sqlc.arg('skill_id')::uuid
AND s.workspace_id = sqlc.arg('workspace_id')::uuid
)
AND EXISTS (
SELECT 1 FROM issue_label l
WHERE l.id = sqlc.arg('label_id')::uuid
AND l.workspace_id = sqlc.arg('workspace_id')::uuid
AND l.resource_type = 'skill'
)
ON CONFLICT DO NOTHING;
-- name: DetachLabelFromSkill :exec
DELETE FROM skill_to_label
WHERE skill_id = sqlc.arg('skill_id')::uuid
AND label_id = sqlc.arg('label_id')::uuid
AND EXISTS (
SELECT 1 FROM skill s
WHERE s.id = sqlc.arg('skill_id')::uuid
AND s.workspace_id = sqlc.arg('workspace_id')::uuid
);