mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-10 18:36:45 +08:00
* feat(metrics): attribute agent_runtime lookups by product source
MUL-6884 asked which product API is behind the hot GetAgentRuntime query.
It has no single answer: a dozen unrelated paths issue the same primary-key
read, so pg_stat_statements sums them into one row and can report that the
statement is busy without saying which behaviour is driving it. Deciding
between "raise the heartbeat interval" and "back off the 500ms browser polls"
needs that split, and today it is a guess.
Add multica_agent_runtime_lookup_total{source,result} and route every
production read through service.RuntimeLookup, which carries the source with
the connection and classifies pgx.ErrNoRows as not_found rather than as an
error — a deleted runtime is the daemon's self-heal signal, not an outage.
The labels are a closed enum (15 sources x 3 results, all prewarmed at zero)
because this fires on the hottest read in the system; an unclassified call
site degrades to "other" instead of minting a series. No query, no timing
histogram, and no extra SQL on /metrics — this PR only makes the existing
calls countable.
An AST lint test fails CI on any direct Queries.GetAgentRuntime call outside
the wrapper, since a bypassed call site would quietly undercount whichever
source it belonged to and send the follow-up work after the wrong caller.
Co-authored-by: multica-agent <[email protected]>
* refactor(metrics): drop the standalone handler lookup file
The two helpers it held are not an endpoint — no route, no HTTP handler, no
new file under cmd/ — but a new agent_runtime_lookup.go under internal/handler
reads like one, and a file name that has to be opened before it can be ruled
out is a bad file name.
Move both into runtime.go next to requireRuntimeReadAccess, where the rest of
the runtime read helpers already live, and name the metrics test after what it
tests. No behaviour change.
Co-authored-by: multica-agent <[email protected]>
---------
Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
219 lines
9.0 KiB
Go
219 lines
9.0 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/multica-ai/multica/server/internal/issuestatus"
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
)
|
|
|
|
// RunEnqueueSource identifies which kind of issue write would start an agent
|
|
// run. It is surfaced in preview responses so the UI can explain each trigger.
|
|
type RunEnqueueSource string
|
|
|
|
const (
|
|
// RunSourceAssign covers issue creation and assignee changes — the issue
|
|
// is being handed to an agent/squad. Parks silently on backlog.
|
|
RunSourceAssign RunEnqueueSource = "assign"
|
|
// RunSourceStatus covers promoting an already-assigned issue out of
|
|
// backlog into an active status.
|
|
RunSourceStatus RunEnqueueSource = "status"
|
|
)
|
|
|
|
// IssueTriggerProbe carries the request-scoped checks WillEnqueueRun cannot
|
|
// resolve from issue state alone.
|
|
//
|
|
// CanAccessAgent is the private-agent gate. The write paths enforce it at the
|
|
// HTTP boundary (validateAssigneePair on assign, canEnqueueSquadLeader inside
|
|
// the squad enqueue helper) and therefore pass an allow-all probe so the gate
|
|
// is never duplicated or sunk into the service layer. Preview passes the real
|
|
// gate so it never leaks a private agent's readiness to a member who cannot
|
|
// see it. A nil func is treated as allow-all.
|
|
//
|
|
// IsSelfLoop reports whether promoting this issue out of backlog would be the
|
|
// calling agent re-triggering its own running task. Only the status source
|
|
// consults it. A nil func means "not a self-loop".
|
|
//
|
|
// SuppressActiveSelfAssignment reports whether a direct agent assignment is a
|
|
// trusted agent claiming ownership for itself while the target (issue, agent)
|
|
// pair already has a non-terminal task. The ownership update still succeeds;
|
|
// only the duplicate enqueue is suppressed. Cross-issue handoffs to a fresh
|
|
// target remain runnable. A nil func means "do not suppress".
|
|
type IssueTriggerProbe struct {
|
|
CanAccessAgent func(agent db.Agent) bool
|
|
IsSelfLoop func() bool
|
|
SuppressActiveSelfAssignment func(agentID pgtype.UUID) bool
|
|
}
|
|
|
|
// IssueTriggerInput describes one prospective issue write in its post-write
|
|
// shape. AssigneeChanged / StatusChanged mark which fields the write touches;
|
|
// IsCreate marks a brand-new issue (no prior task to cancel, no self-loop).
|
|
type IssueTriggerInput struct {
|
|
Issue db.Issue
|
|
PrevStatus string
|
|
IsCreate bool
|
|
AssigneeChanged bool
|
|
StatusChanged bool
|
|
}
|
|
|
|
// IssueRunTrigger is the resolved decision shared by preview and the write
|
|
// paths. AgentID is the agent that will actually run — the assignee for an
|
|
// agent issue, the squad leader for a squad issue.
|
|
type IssueRunTrigger struct {
|
|
IssueID pgtype.UUID
|
|
AgentID pgtype.UUID
|
|
AssigneeType string
|
|
Source RunEnqueueSource
|
|
}
|
|
|
|
func allowAllAgents(db.Agent) bool { return true }
|
|
|
|
// WillEnqueueRun is the single predicate answering "will this issue write
|
|
// start an agent run, and for whom". It is the one source of truth shared by
|
|
// the issue update / batch-update write paths and the preview endpoint,
|
|
// replacing the per-site copies that drifted (squad omitted, self-loop
|
|
// omitted, four entry points inconsistent — see MUL-3375).
|
|
//
|
|
// It is intentionally a distinct predicate from the comment trigger
|
|
// (assignee fallback comment routing): issue writes park on backlog while comments fire
|
|
// in any status. The two only share leaf readiness checks (AgentReadiness,
|
|
// the pending-task dedup), not the top-level decision.
|
|
//
|
|
// The decision must equal the real enqueue conditions so preview never claims
|
|
// a net-new run that the write path then drops. The write enqueues through
|
|
// CreateAgentTask, guarded by the (issue_id, agent_id) partial unique index
|
|
// over pending (queued/dispatched) tasks; the pending check below mirrors that
|
|
// guard, and only the status source needs it:
|
|
// - status source (backlog → active) can re-fire against an assignee that
|
|
// already holds a pending task (e.g. one a @mention raised while the issue
|
|
// sat in backlog); the check keeps preview from promising a run the unique
|
|
// index would coalesce away.
|
|
// - assign source (create / assignee change) skips the check: a create
|
|
// targets a fresh issue with no prior task, and a reassignment no longer
|
|
// cancels existing tasks (#4963 / MUL-4113) — in the rare case the new
|
|
// assignee already holds a pending task the insert simply no-ops on the
|
|
// same unique index, so the assignee still ends up with one pending run.
|
|
func (s *IssueService) WillEnqueueRun(ctx context.Context, in IssueTriggerInput, probe IssueTriggerProbe) (IssueRunTrigger, bool) {
|
|
issue := in.Issue
|
|
if !issue.AssigneeType.Valid || !issue.AssigneeID.Valid {
|
|
return IssueRunTrigger{}, false
|
|
}
|
|
canAccess := probe.CanAccessAgent
|
|
if canAccess == nil {
|
|
canAccess = allowAllAgents
|
|
}
|
|
|
|
// The status source also requires LEAVING the backlog category, not merely
|
|
// changing the status key. Before custom statuses a key change out of
|
|
// `backlog` was always a category change, so the two were the same
|
|
// condition; now `backlog` → a custom status in the `backlog` category is a
|
|
// move within the parking lot, and starting a run on it would break the one
|
|
// promise backlog makes. (MUL-6463)
|
|
//
|
|
// Both sides of the transition are normalized to the canonical status they
|
|
// inherit, so a custom status in the `backlog` category parks exactly like
|
|
// Backlog and a custom status in the `todo` category starts a run exactly
|
|
// like Todo. Built-in keys resolve to themselves without a query, leaving
|
|
// this decision bit-identical for workspaces with no custom statuses —
|
|
// which is the whole set of them until an admin defines one. (MUL-6243)
|
|
currentStatus := issuestatus.Effective(ctx, s.Queries, issue.WorkspaceID, issue.Status)
|
|
prevStatus := issuestatus.Effective(ctx, s.Queries, issue.WorkspaceID, in.PrevStatus)
|
|
|
|
var source RunEnqueueSource
|
|
switch {
|
|
case in.IsCreate || in.AssigneeChanged:
|
|
// Backlog is the parking lot: assigning into backlog never starts a run.
|
|
if currentStatus == "backlog" {
|
|
return IssueRunTrigger{}, false
|
|
}
|
|
source = RunSourceAssign
|
|
case in.StatusChanged && prevStatus == "backlog" &&
|
|
currentStatus != "backlog" &&
|
|
currentStatus != "done" && currentStatus != "cancelled":
|
|
if probe.IsSelfLoop != nil && probe.IsSelfLoop() {
|
|
return IssueRunTrigger{}, false
|
|
}
|
|
source = RunSourceStatus
|
|
default:
|
|
return IssueRunTrigger{}, false
|
|
}
|
|
|
|
switch issue.AssigneeType.String {
|
|
case "agent":
|
|
agent, err := s.Queries.GetAgent(ctx, issue.AssigneeID)
|
|
if err != nil || !agent.RuntimeID.Valid || agent.ArchivedAt.Valid {
|
|
return IssueRunTrigger{}, false
|
|
}
|
|
if !canAccess(agent) {
|
|
return IssueRunTrigger{}, false
|
|
}
|
|
if source == RunSourceAssign && !in.IsCreate && probe.SuppressActiveSelfAssignment != nil &&
|
|
probe.SuppressActiveSelfAssignment(issue.AssigneeID) {
|
|
return IssueRunTrigger{}, false
|
|
}
|
|
if source == RunSourceStatus && s.hasPendingRun(ctx, issue.ID, issue.AssigneeID) {
|
|
return IssueRunTrigger{}, false
|
|
}
|
|
return IssueRunTrigger{
|
|
IssueID: issue.ID,
|
|
AgentID: issue.AssigneeID,
|
|
AssigneeType: "agent",
|
|
Source: source,
|
|
}, true
|
|
|
|
case "squad":
|
|
// Pair-scoped self-assignment suppression intentionally applies only to
|
|
// direct agent ownership. Assigning a squad changes execution context
|
|
// (leader briefing, roles, and member routing), so even when the acting
|
|
// agent is that squad's leader it is an intentional group handoff rather
|
|
// than a redundant direct self-claim. The status path below still uses
|
|
// the leader's pending-task guard.
|
|
squad, err := s.Queries.GetSquadInWorkspace(ctx, db.GetSquadInWorkspaceParams{
|
|
ID: issue.AssigneeID,
|
|
WorkspaceID: issue.WorkspaceID,
|
|
})
|
|
if err != nil {
|
|
return IssueRunTrigger{}, false
|
|
}
|
|
leader, err := s.Queries.GetAgent(ctx, squad.LeaderID)
|
|
if err != nil {
|
|
return IssueRunTrigger{}, false
|
|
}
|
|
verdict, err := AgentReadiness(ctx, s.runtimeLookup(s.Queries), leader)
|
|
if err != nil || !verdict.Ready() {
|
|
return IssueRunTrigger{}, false
|
|
}
|
|
if !canAccess(leader) {
|
|
return IssueRunTrigger{}, false
|
|
}
|
|
if source == RunSourceStatus && s.hasPendingRun(ctx, issue.ID, squad.LeaderID) {
|
|
return IssueRunTrigger{}, false
|
|
}
|
|
return IssueRunTrigger{
|
|
IssueID: issue.ID,
|
|
AgentID: squad.LeaderID,
|
|
AssigneeType: "squad",
|
|
Source: source,
|
|
}, true
|
|
}
|
|
return IssueRunTrigger{}, false
|
|
}
|
|
|
|
// hasPendingRun reports whether the agent already holds a queued or dispatched
|
|
// task for the issue (the (issue_id, agent_id) unique-index slot). Errors fail
|
|
// closed to "pending" so preview never over-promises a run.
|
|
func (s *IssueService) hasPendingRun(ctx context.Context, issueID, agentID pgtype.UUID) bool {
|
|
pending, err := s.Queries.HasPendingTaskForIssueAndAgent(ctx, db.HasPendingTaskForIssueAndAgentParams{
|
|
IssueID: issueID,
|
|
AgentID: agentID,
|
|
// Key dedup on the reviewed head so a pending run against an old HEAD
|
|
// does not shadow a request after HEAD advanced (TEN-356).
|
|
HeadSha: headShaText(s.TaskService.ResolveIssueReviewSHA(ctx, issueID)),
|
|
})
|
|
if err != nil {
|
|
return true
|
|
}
|
|
return pending
|
|
}
|