Files
multica/server/internal/handler/admission.go
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

129 lines
5.7 KiB
Go

package handler
import (
"net/http"
"github.com/multica-ai/multica/server/internal/dispatch"
)
// Unified execution-admission contract (MUL-4525).
//
// Every synchronous enqueue entry point (comment mention, autopilot manual
// "run now", issue assign / promotion / batch, manual rerun, direct chat) needs
// to answer the SAME question in the SAME shape: given a user who explicitly
// named an execution target, did the run get `queued`, `coalesced` onto an
// existing task, `deferred`, or `blocked`? A silent no-op is never acceptable.
//
// Two invariants this contract exists to protect:
//
// 1. Whether the business object was written (comment saved, issue updated)
// and whether the agent was actually triggered are DIFFERENT facts. A
// comment can persist while one of its mentions is blocked — callers must
// be able to express partial success.
// 2. The reason a target was blocked is exposed ONLY as a stable, localizable,
// enumeration-safe code. It must never leak whether a private agent exists,
// its name, or its owner to a caller who cannot see the target. The precise
// cause goes to restricted server logs, not the wire.
// DispatchStatus is the domain-level outcome of one admission/enqueue attempt.
type DispatchStatus string
const (
// DispatchQueued: a new run was enqueued.
DispatchQueued DispatchStatus = "queued"
// DispatchCoalesced: the trigger merged into an already-pending task for
// the same target instead of creating a duplicate run.
DispatchCoalesced DispatchStatus = "coalesced"
// DispatchDeferred: admitted but intentionally not started yet (e.g. a
// backlog issue parked until promotion, or suppress_run).
DispatchDeferred DispatchStatus = "deferred"
// DispatchBlocked: the run was refused. ReasonCode carries why.
DispatchBlocked DispatchStatus = "blocked"
)
// DispatchReasonCode is the wire-facing admission reason. It aliases the
// canonical, cross-layer enum in the dispatch package so the service (which
// decides the reason at its source) and the handler (which serializes it) can
// never drift. New codes may be added; clients must treat an unknown code as a
// generic failure (they switch with a default branch). A code NEVER encodes the
// existence, name, or owner of a target the caller is not allowed to see.
type DispatchReasonCode = dispatch.ReasonCode
const (
ReasonQueued = dispatch.ReasonQueued
ReasonCoalesced = dispatch.ReasonCoalesced
ReasonDeferred = dispatch.ReasonDeferred
ReasonInvocationNotAllowed = dispatch.ReasonInvocationNotAllowed
ReasonTargetUnavailable = dispatch.ReasonTargetUnavailable
ReasonRuntimeOffline = dispatch.ReasonRuntimeOffline
ReasonAgentRuntimeRequired = dispatch.ReasonAgentRuntimeRequired
ReasonAttributionBlocked = dispatch.ReasonAttributionBlocked
ReasonAlreadyActive = dispatch.ReasonAlreadyActive
ReasonSelfTriggerSuppressed = dispatch.ReasonSelfTriggerSuppressed
ReasonInternalError = dispatch.ReasonInternalError
)
// DispatchTarget is the caller-visible reference to an execution target. Name
// is populated ONLY when the caller is allowed to see the target; a blocked
// private-agent invoke returns Type/ID (already known to the caller from their
// own request) but never a Name they were not otherwise entitled to.
type DispatchTarget struct {
Type string `json:"type"` // "agent" | "squad"
ID string `json:"id"`
Name string `json:"name,omitempty"`
}
// DispatchOutcome is the unified per-target result returned by every sync
// enqueue entry point. It is additive on the wire: old clients that ignore it
// keep working. TaskID / RunID are set when a run/task was produced.
type DispatchOutcome struct {
Status DispatchStatus `json:"status"`
ReasonCode DispatchReasonCode `json:"reason_code"`
Target *DispatchTarget `json:"target,omitempty"`
TaskID *string `json:"task_id,omitempty"`
RunID *string `json:"run_id,omitempty"`
}
// dispatchBlockedResponse is the structured body of a blocked synchronous
// enqueue (403/409). `error` is a generic, non-enumerating English fallback for
// old clients that only read the legacy field; `reason_code` is the stable
// machine-readable code new clients localize. Neither field leaks private
// target details.
type dispatchBlockedResponse struct {
Error string `json:"error"`
ReasonCode DispatchReasonCode `json:"reason_code"`
}
// writeDispatchBlocked writes a structured blocked-admission error. The HTTP
// status conveys the class (403 permission, 409 conflict); reason_code conveys
// the stable cause. Use this for any sync trigger the caller explicitly asked
// for that is refused before mutation.
func (h *Handler) writeDispatchBlocked(w http.ResponseWriter, status int, code DispatchReasonCode) {
writeJSON(w, status, dispatchBlockedResponse{
Error: dispatchBlockedFallbackMessage(code),
ReasonCode: code,
})
}
// dispatchBlockedFallbackMessage is the legacy `error` string paired with a
// reason code. It is intentionally generic and non-enumerating: it must be safe
// to show to a caller who is not allowed to know whether the target exists.
func dispatchBlockedFallbackMessage(code DispatchReasonCode) string {
switch code {
case ReasonInvocationNotAllowed:
return "you don't have permission to use this target"
case ReasonTargetUnavailable:
return "the target is unavailable"
case ReasonRuntimeOffline:
return "the target's runtime is offline"
case ReasonAgentRuntimeRequired:
return "the target needs a runtime"
case ReasonAttributionBlocked:
return "the run couldn't be attributed to a responsible member"
case ReasonAlreadyActive:
return "a run is already active for this target"
default:
return "the run was blocked"
}
}