mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-05 17:40:11 +02:00
* 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>
58 lines
3.2 KiB
Go
58 lines
3.2 KiB
Go
// Package dispatch holds the canonical, cross-layer vocabulary for execution
|
|
// admission outcomes (MUL-4525). It is a leaf package (no internal deps) so both
|
|
// the service layer — which MAKES the admission/skip decision and therefore owns
|
|
// the reason at its source — and the handler layer — which serializes it to the
|
|
// wire — share one enum and can never drift.
|
|
//
|
|
// A ReasonCode is decided at the branch that blocks/skips a run and carried
|
|
// through to the response verbatim; it is never reverse-engineered from a
|
|
// human-readable failure string. Codes are stable, localizable by clients, and
|
|
// enumeration-safe: a code never reveals whether a private agent exists, its
|
|
// name, or its owner.
|
|
package dispatch
|
|
|
|
// ReasonCode is a stable, client-localizable admission/dispatch reason.
|
|
type ReasonCode string
|
|
|
|
const (
|
|
// ReasonQueued / ReasonCoalesced / ReasonDeferred are the success-path codes.
|
|
ReasonQueued ReasonCode = "queued"
|
|
ReasonCoalesced ReasonCode = "coalesced"
|
|
ReasonDeferred ReasonCode = "deferred"
|
|
|
|
// ReasonInvocationNotAllowed: the acting principal may not trigger this
|
|
// target under the invocation-permission model. Deliberately generic — it
|
|
// does not distinguish "target is private" from "target does not exist".
|
|
ReasonInvocationNotAllowed ReasonCode = "invocation_not_allowed"
|
|
// ReasonTargetUnavailable: the target cannot run (archived agent, deleted /
|
|
// archived squad, unresolvable leader, or no assignee).
|
|
ReasonTargetUnavailable ReasonCode = "target_unavailable"
|
|
// ReasonRuntimeOffline: the target is permitted and bound to a runtime, but
|
|
// that runtime is not online at dispatch time. The task is not lost — the
|
|
// user's fix is to bring the machine back, and queued work waits for it.
|
|
ReasonRuntimeOffline ReasonCode = "runtime_offline"
|
|
// ReasonAgentRuntimeRequired: the target is permitted but bound to no
|
|
// runtime at all (agent.runtime_id IS NULL), which is where an agent lands
|
|
// when its runtime is deleted (MUL-5559). Distinct from runtime_offline on
|
|
// purpose: there is no machine to bring back, nothing will ever claim work
|
|
// for this agent, and the only fix is binding it to a runtime. Clients that
|
|
// collapse the two send the user looking for an offline computer that does
|
|
// not exist.
|
|
ReasonAgentRuntimeRequired ReasonCode = "agent_runtime_required"
|
|
// ReasonAttributionBlocked: a fail-closed workspace could not resolve a
|
|
// responsible human for the run, so it was refused.
|
|
ReasonAttributionBlocked ReasonCode = "attribution_blocked"
|
|
// ReasonAlreadyActive: a run is already active/pending for this target and
|
|
// this trigger did not coalesce.
|
|
ReasonAlreadyActive ReasonCode = "already_active"
|
|
// ReasonSelfTriggerSuppressed: the target was intentionally not (re-)triggered
|
|
// because doing so would be a self-trigger the guard suppresses, and no active
|
|
// run remains to cover it — e.g. a squad leader's own @mention of its squad
|
|
// whose latest task is already terminal. Not a permission block, but NOT
|
|
// success: nothing new runs. (Named to avoid implying the NEW comment was
|
|
// already processed.)
|
|
ReasonSelfTriggerSuppressed ReasonCode = "self_trigger_suppressed"
|
|
// ReasonInternalError: an unexpected server error prevented a clean decision.
|
|
ReasonInternalError ReasonCode = "internal_error"
|
|
)
|