Files
multica/server/internal/handler/runtime_blocking_agents.go
Bohan Jiang b425073cae MUL-7409 fix(runtime): stop steering stale-instance cleanup at the shared profile (#8483)
Deleting a profile-backed runtime instance is refused while its profile exists,
and the refusal said "delete its runtime profile instead". For the case that
produces it most often — a retired machine's leftover row in a profile other,
healthy machines still use — that points at a workspace-wide delete which takes
those machines' runtimes too, and which a bound agent refuses anyway with a
second message naming no agent and no machine.

Retention GC already deletes these rows after 7 days offline, ignoring
profile_id, so the refusals now describe that instead of recommending the
destructive route:

- Instance refusal is status-aware, and only promises automatic cleanup when it
  matches the gate gcRuntime actually applies: the runtime's own undrained
  tasks plus those owned by every user agent bound to it, archived included.
- Profile refusal names the blocking agents and the machine each sits on, with
  a per-class remedy — ordinary agents can be reassigned or archived; Mika
  cannot be archived but can be rebound; a Builder session is reachable only by
  its creator. Those clauses come from counts over the whole blocker set, so a
  class outside the 20-row sample is still reported.
- Response bounded at 20 entries with an exact total, capping rows read inside
  the locked delete transaction.
- CLI surfaces the server's sentence instead of dumping the raw JSON body.

When a delete is allowed or refused is unchanged; the guard predicates and the
instance-refusal error code are untouched. The remaining capability gap —
retiring an offline instance on demand — is tracked separately.

Fixes MUL-7409
2026-09-17 17:11:49 +08:00

176 lines
7.6 KiB
Go

package handler
import (
"fmt"
"strings"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/service"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// agentBuilderSystemKeyPrefix marks the hidden execution carrier behind an
// unfinished AI agent-creation flow. The suffix is the flow id.
const agentBuilderSystemKeyPrefix = "agent_builder:"
// A runtime or profile delete is refused while agents are still bound. Telling
// the user to "reassign or archive them" is only true for agents they can
// actually reach: the archive endpoint rejects anything carrying a system_key
// outright, and a builder carrier is not in the agent list at all. Naming one
// remedy for every blocker therefore hands some users an instruction that
// cannot be carried out — the same class of defect this whole change set exists
// to remove, so the refusals classify their blockers instead.
//
// system_key, not kind, is the discriminator. Mika is deliberately kind='user'
// (it must stay visible and assignable) while still being product-owned and
// unarchivable; builder carriers are kind='system'.
type blockingAgentClass int
const (
// blockingAgentUser is an ordinary workspace agent: rebind or archive it.
blockingAgentUser blockingAgentClass = iota
// blockingAgentMika is the workspace's built-in Mika. It cannot be archived
// (agent.go rejects any system_key there) but it CAN be rebound: UpdateAgent
// takes runtime_id for it like any other manageable agent. Those two halves
// have to be stated separately or the remedy is wrong in one direction.
blockingAgentMika
// blockingAgentBuilderCarrier is the hidden carrier behind an unfinished
// Agent Builder flow. It is released through that session, not the agent list.
blockingAgentBuilderCarrier
// blockingAgentOtherSystem is any future product-owned agent. Unarchivable
// like the rest, with no remedy this code can name specifically.
blockingAgentOtherSystem
)
// Class keys shared with the SQL CASE in ListActiveAgentsByProfile. That query
// has to classify server-side so its per-class counts can cover rows the LIMIT
// excludes, which means the mapping exists in two places; these constants give
// both the same vocabulary, and TestBlockingAgentClassMatchesSQLClassification
// pins them to each other.
const (
blockingAgentClassKeyUser = "user"
blockingAgentClassKeyMika = "mika"
blockingAgentClassKeyBuilder = "agent_builder"
blockingAgentClassKeyOtherSystem = "other_system"
)
// Unknown keys fall to other_system, not user. Only the exact "user" key may
// produce the one class whose remedy claims an action ("reassign or archive"):
// if a future class is added to the SQL CASE and not here, the refusal should
// say it cannot name a remedy rather than confidently hand out one that does
// not apply.
func blockingAgentClassFromKey(key string) blockingAgentClass {
switch key {
case blockingAgentClassKeyUser:
return blockingAgentUser
case blockingAgentClassKeyMika:
return blockingAgentMika
case blockingAgentClassKeyBuilder:
return blockingAgentBuilderCarrier
default:
return blockingAgentOtherSystem
}
}
func classifyBlockingAgent(systemKey pgtype.Text) blockingAgentClass {
key := strings.TrimSpace(systemKey.String)
if !systemKey.Valid || key == "" {
return blockingAgentUser
}
switch {
case key == service.MikaSystemKey:
return blockingAgentMika
case strings.HasPrefix(key, agentBuilderSystemKeyPrefix):
return blockingAgentBuilderCarrier
default:
return blockingAgentOtherSystem
}
}
// blockingAgentLabel renders one blocker for a refusal sentence. Product-owned
// agents are marked so the reader can tell at a glance why the plain remedy
// does not apply to them.
func blockingAgentLabel(name, runtimeName, runtimeStatus string, class blockingAgentClass) string {
switch class {
case blockingAgentBuilderCarrier:
return fmt.Sprintf("an unfinished Agent Builder session on %q (%s)", runtimeName, runtimeStatus)
case blockingAgentMika, blockingAgentOtherSystem:
return fmt.Sprintf("%q on %q (%s, built into Multica)", name, runtimeName, runtimeStatus)
default:
return fmt.Sprintf("%q on %q (%s)", name, runtimeName, runtimeStatus)
}
}
// blockingAgentScope distinguishes the two refusals, because one remedy differs
// between them: moving Mika off this runtime clears an instance refusal, but a
// profile refusal only clears if the destination is not another runtime of the
// same profile.
type blockingAgentScope int
const (
blockingAgentScopeInstance blockingAgentScope = iota
blockingAgentScopeProfile
)
// blockingAgentRemedies returns one clause per distinct recovery path present,
// in a fixed order so the sentence is stable. An empty result means every
// blocker was product-owned and the caller should say so rather than suggest
// an action.
func blockingAgentRemedies(classes map[blockingAgentClass]bool, scope blockingAgentScope) []string {
// "Reassign or archive them" must not appear to cover the product-owned
// blockers listed beside them, which is exactly the instruction they cannot
// follow. When both are present the clause names which ones it applies to.
mixed := classes[blockingAgentMika] ||
classes[blockingAgentBuilderCarrier] ||
classes[blockingAgentOtherSystem]
var out []string
if classes[blockingAgentUser] && mixed {
out = append(out, "The agents above that are not marked as built into Multica can be reassigned or archived.")
} else if classes[blockingAgentUser] {
out = append(out, "Reassign or archive them first.")
}
if classes[blockingAgentBuilderCarrier] {
// Addressed to the creator, not to whoever hit this error. Builder
// sessions are creator-scoped: ListAgentBuilderSessions returns 200 but
// omits other members' sessions, and switch/discard go through
// loadChatSessionForUser and return 403. So an admin who is not the
// creator cannot even see the session to act on it, and telling them to
// reopen it would be another instruction that cannot be carried out.
out = append(out, "The unfinished Agent Builder session(s) here are hidden from the agent list, and only their creator can open them — ask the member who started the session to switch its runtime or discard it; another admin cannot do that for them.")
}
if classes[blockingAgentMika] {
// Mika is unarchivable but NOT immovable: UpdateAgent takes runtime_id
// for it like any other agent an admin can manage (the only system_key
// guard in agent.go is on archive), and the agent detail page shows it
// an editable runtime picker. An earlier version of this message said
// there was no supported way to move it, which told an owner who could
// have fixed this in one edit to give up instead — the exact failure
// this whole change set is about. TestMikaRemedyMatchesWhatMikaCanDo
// exercises both halves against the real endpoints so the claim cannot
// drift from the product again.
target := "another runtime"
if scope == blockingAgentScopeProfile {
target = "a runtime that this profile does not provide"
}
out = append(out, fmt.Sprintf(
"Mika is built into Multica, so it cannot be archived — but it can be moved: open Mika's agent page and bind it to %s.",
target,
))
}
if classes[blockingAgentOtherSystem] {
out = append(out, "Some blockers are agents built into Multica and cannot be archived.")
}
return out
}
// blockingAgentClassesFromAgents collects the classes present on a plain agent
// set, for the callers that already hold db.Agent rows.
func blockingAgentClassesFromAgents(agents []db.Agent) map[blockingAgentClass]bool {
classes := make(map[blockingAgentClass]bool, 2)
for _, a := range agents {
classes[classifyBlockingAgent(a.SystemKey)] = true
}
return classes
}