mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-30 16:20:35 +02:00
* feat(agents): gate private-agent surfaces with allowed_principals predicate
Tighten chat/@-mention, history, edit, and delete entry points so private
agents are only reachable by their owner or workspace owner/admin. Agent-to-
agent traffic still bypasses the gate so A2A collaboration keeps working.
- New canAccessPrivateAgent predicate in handler/agent_access.go; used by
comment.enqueueMentionedAgentTasks (replacing the inline check), GetAgent,
ListAgents (filter), ListAgentTasks, GetWorkspaceAgentRunCounts /
Activity30d / TaskSnapshot (workspace-wide aggregations no longer leak
private-agent existence + counts), chat.CreateChatSession,
chat.SendChatMessage (re-checks on every send so role changes can't leave
a stale session as a back-door), and autopilot.shouldSkipDispatch
(caller = autopilot creator).
- allowed_principals is computed inline as {agent.owner_id} ∪ workspace
owner/admin members. No new table — manual config is intentionally not
exposed in v1; the predicate is the extension seam.
- Front-end agent detail page distinguishes 403 (private agent the caller
can't access) from 404 (deleted/missing) and renders a "no access"
placeholder with a back-to-agents button.
- Go tests cover the pure predicate matrix + the four protected surfaces;
vitest passes for the affected views.
Co-authored-by: multica-agent <github@multica.ai>
* feat(agents): gate issue assignment with the private-agent predicate
Refactor validateAssigneePair to call the shared canAccessPrivateAgent
helper. This closes the back door where a plain member could assign a
private agent to an issue and let normal task dispatch run it, side-
stepping the chat / @-mention gate. Agent callers (X-Agent-ID) bypass
so A2A delegation onto a private assignee still works.
Add an integration test covering all three callers (workspace owner,
agent owner, plain member).
Co-authored-by: multica-agent <github@multica.ai>
* fix(agents): close three private-agent gate bypasses found in PR review
1. X-Agent-ID forgery (resolveActor): require X-Task-ID alongside
X-Agent-ID before trusting the agent identity. Without this a plain
workspace member could set X-Agent-ID to any visible agent UUID and
short-circuit the gate to "actor=agent, allow". Daemons already
pair the two headers, so legitimate A2A traffic is unaffected.
2. Chat history read path (chat.go): GetChatSession / ListChatMessages /
GetPendingChatTask / MarkChatSessionRead now go through a new
gateChatSessionForUser helper that re-applies canAccessPrivateAgent
after the ownership check, so a session creator whose role was later
downgraded loses transcript access. ListChatSessions and
ListPendingChatTasks filter their result sets by the same predicate.
3. Cross-workspace @mention (comment.enqueueMentionedAgentTasks):
resolve the mentioned agent via GetAgentInWorkspace scoped to the
issue's workspace so a UUID belonging to a different workspace's
private agent can't slip past the gate (the gate was being applied
against the current workspace's role table, which is the wrong
one).
Regression tests cover each bypass, plus an update to the resolveActor
unit test to reflect the new "X-Agent-ID without X-Task-ID falls back
to member" contract.
Co-authored-by: multica-agent <github@multica.ai>
* test(handler): seed X-Task-ID alongside X-Agent-ID in existing agent-caller tests
After tightening resolveActor to require both headers (X-Agent-ID +
X-Task-ID) for the "agent" actor identity, three existing tests that
set only X-Agent-ID started failing because their requests now resolve
to "member" instead of "agent". Add createHandlerTestTaskForAgent
helper and seed a task per agent-caller assertion. Also patch
TestAgentExplicitMentionStillTriggers — it still passed only because
the @mention path doesn't care about author type for member callers,
but the test claims to exercise the agent path, so make it faithful.
Co-authored-by: multica-agent <github@multica.ai>
* test(handler): finish X-Task-ID seeding + fix cross-workspace mention test schema
The previous CI run still failed in two places:
1. server/cmd/server integration tests — postCommentAsAgent → authRequestWithAgent
only set X-Agent-ID, so resolveActor downgraded the request to "member"
and the on_comment chain produced the wrong task counts. Fix:
authRequestWithAgent now also sets X-Task-ID, fetched or seeded by a new
ensureAgentTask(agentID) helper.
2. TestMentionAgent_RejectsCrossWorkspaceAgentUUID's hand-crafted comment
INSERT was missing comment.workspace_id, which migration 025 made
NOT NULL. Pass testWorkspaceID into the seed row.
Build + vet clean locally; both packages compile.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
608 lines
19 KiB
Go
608 lines
19 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/multica-ai/multica/server/internal/analytics"
|
|
"github.com/multica-ai/multica/server/internal/events"
|
|
"github.com/multica-ai/multica/server/internal/util"
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
"github.com/multica-ai/multica/server/pkg/protocol"
|
|
)
|
|
|
|
// TxStarter abstracts transaction creation (satisfied by pgxpool.Pool).
|
|
type TxStarter interface {
|
|
Begin(ctx context.Context) (pgx.Tx, error)
|
|
}
|
|
|
|
type AutopilotService struct {
|
|
Queries *db.Queries
|
|
TxStarter TxStarter
|
|
Bus *events.Bus
|
|
TaskSvc *TaskService
|
|
}
|
|
|
|
func NewAutopilotService(q *db.Queries, tx TxStarter, bus *events.Bus, taskSvc *TaskService) *AutopilotService {
|
|
return &AutopilotService{Queries: q, TxStarter: tx, Bus: bus, TaskSvc: taskSvc}
|
|
}
|
|
|
|
// DispatchAutopilot is the core execution entry point.
|
|
// It creates a run and either creates an issue or enqueues a direct agent task
|
|
// depending on execution_mode.
|
|
//
|
|
// Before any work is queued we run an admission check against the assignee
|
|
// agent's runtime: if it is not online, we record a `skipped` run with a
|
|
// failure_reason and return without enqueueing. This is the "触发时准入" gate
|
|
// from MUL-1899 — without it a paused laptop / offline daemon causes scheduled
|
|
// autopilots to pile thousands of doomed tasks onto agent_task_queue.
|
|
func (s *AutopilotService) DispatchAutopilot(
|
|
ctx context.Context,
|
|
autopilot db.Autopilot,
|
|
triggerID pgtype.UUID,
|
|
source string,
|
|
payload []byte,
|
|
) (*db.AutopilotRun, error) {
|
|
if reason, skip := s.shouldSkipDispatch(ctx, autopilot); skip {
|
|
return s.recordSkippedRun(ctx, autopilot, triggerID, source, payload, reason)
|
|
}
|
|
|
|
// Determine initial status based on execution mode.
|
|
initialStatus := "issue_created"
|
|
if autopilot.ExecutionMode == "run_only" {
|
|
initialStatus = "running"
|
|
}
|
|
|
|
run, err := s.Queries.CreateAutopilotRun(ctx, db.CreateAutopilotRunParams{
|
|
AutopilotID: autopilot.ID,
|
|
TriggerID: triggerID,
|
|
Source: source,
|
|
Status: initialStatus,
|
|
TriggerPayload: payload,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create run: %w", err)
|
|
}
|
|
s.captureAutopilotRunStarted(autopilot, run, source)
|
|
|
|
switch autopilot.ExecutionMode {
|
|
case "create_issue":
|
|
if err := s.dispatchCreateIssue(ctx, autopilot, &run); err != nil {
|
|
s.failRun(ctx, run.ID, err.Error())
|
|
s.captureAutopilotRunFailed(autopilot, run, source, err.Error())
|
|
return &run, fmt.Errorf("dispatch create_issue: %w", err)
|
|
}
|
|
case "run_only":
|
|
if err := s.dispatchRunOnly(ctx, autopilot, &run); err != nil {
|
|
s.failRun(ctx, run.ID, err.Error())
|
|
s.captureAutopilotRunFailed(autopilot, run, source, err.Error())
|
|
return &run, fmt.Errorf("dispatch run_only: %w", err)
|
|
}
|
|
default:
|
|
s.failRun(ctx, run.ID, "unknown execution_mode: "+autopilot.ExecutionMode)
|
|
s.captureAutopilotRunFailed(autopilot, run, source, "unknown execution_mode: "+autopilot.ExecutionMode)
|
|
return &run, fmt.Errorf("unknown execution_mode: %s", autopilot.ExecutionMode)
|
|
}
|
|
|
|
// Update last_run_at on the autopilot.
|
|
s.Queries.UpdateAutopilotLastRunAt(ctx, autopilot.ID)
|
|
|
|
// Publish run start event.
|
|
s.Bus.Publish(events.Event{
|
|
Type: protocol.EventAutopilotRunStart,
|
|
WorkspaceID: util.UUIDToString(autopilot.WorkspaceID),
|
|
ActorType: "system",
|
|
Payload: map[string]any{
|
|
"run_id": util.UUIDToString(run.ID),
|
|
"autopilot_id": util.UUIDToString(autopilot.ID),
|
|
"source": source,
|
|
"status": run.Status,
|
|
},
|
|
})
|
|
|
|
return &run, nil
|
|
}
|
|
|
|
// dispatchCreateIssue creates an issue and enqueues a task for the agent.
|
|
func (s *AutopilotService) dispatchCreateIssue(ctx context.Context, ap db.Autopilot, run *db.AutopilotRun) error {
|
|
tx, err := s.TxStarter.Begin(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("begin tx: %w", err)
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
qtx := s.Queries.WithTx(tx)
|
|
|
|
// Get next issue number.
|
|
issueNumber, err := qtx.IncrementIssueCounter(ctx, ap.WorkspaceID)
|
|
if err != nil {
|
|
return fmt.Errorf("increment issue counter: %w", err)
|
|
}
|
|
|
|
title := s.interpolateTemplate(ap)
|
|
description := s.buildIssueDescription(ap)
|
|
|
|
issue, err := qtx.CreateIssueWithOrigin(ctx, db.CreateIssueWithOriginParams{
|
|
WorkspaceID: ap.WorkspaceID,
|
|
Title: title,
|
|
Description: description,
|
|
Status: "todo",
|
|
Priority: "none",
|
|
AssigneeType: pgtype.Text{String: "agent", Valid: true},
|
|
AssigneeID: ap.AssigneeID,
|
|
CreatorType: ap.CreatedByType,
|
|
CreatorID: ap.CreatedByID,
|
|
ParentIssueID: pgtype.UUID{},
|
|
Position: 0,
|
|
DueDate: pgtype.Timestamptz{},
|
|
Number: issueNumber,
|
|
ProjectID: pgtype.UUID{},
|
|
OriginType: pgtype.Text{String: "autopilot", Valid: true},
|
|
OriginID: ap.ID,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("create issue: %w", err)
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return fmt.Errorf("commit tx: %w", err)
|
|
}
|
|
|
|
// Update run with the linked issue.
|
|
updatedRun, err := s.Queries.UpdateAutopilotRunIssueCreated(ctx, db.UpdateAutopilotRunIssueCreatedParams{
|
|
ID: run.ID,
|
|
IssueID: issue.ID,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("link run to issue: %w", err)
|
|
}
|
|
*run = updatedRun
|
|
|
|
// Publish issue:created so the existing event chain fires
|
|
// (subscriber listeners, activity listeners, notification listeners).
|
|
prefix := s.getIssuePrefix(ap.WorkspaceID)
|
|
s.Bus.Publish(events.Event{
|
|
Type: protocol.EventIssueCreated,
|
|
WorkspaceID: util.UUIDToString(ap.WorkspaceID),
|
|
ActorType: ap.CreatedByType,
|
|
ActorID: util.UUIDToString(ap.CreatedByID),
|
|
Payload: map[string]any{
|
|
"issue": issueToMap(issue, prefix),
|
|
},
|
|
})
|
|
s.captureIssueCreatedFromAutopilot(ap, run, issue)
|
|
|
|
// Enqueue agent task via the existing flow.
|
|
if _, err := s.TaskSvc.EnqueueTaskForIssue(ctx, issue); err != nil {
|
|
return fmt.Errorf("enqueue task for issue: %w", err)
|
|
}
|
|
|
|
slog.Info("autopilot dispatched (create_issue)",
|
|
"autopilot_id", util.UUIDToString(ap.ID),
|
|
"issue_id", util.UUIDToString(issue.ID),
|
|
"run_id", util.UUIDToString(run.ID),
|
|
)
|
|
return nil
|
|
}
|
|
|
|
// dispatchRunOnly enqueues a direct agent task without creating an issue.
|
|
func (s *AutopilotService) dispatchRunOnly(ctx context.Context, ap db.Autopilot, run *db.AutopilotRun) error {
|
|
agent, err := s.Queries.GetAgent(ctx, ap.AssigneeID)
|
|
if err != nil {
|
|
return fmt.Errorf("load agent: %w", err)
|
|
}
|
|
if agent.ArchivedAt.Valid {
|
|
return fmt.Errorf("agent is archived")
|
|
}
|
|
if !agent.RuntimeID.Valid {
|
|
return fmt.Errorf("agent has no runtime")
|
|
}
|
|
|
|
task, err := s.Queries.CreateAutopilotTask(ctx, db.CreateAutopilotTaskParams{
|
|
AgentID: ap.AssigneeID,
|
|
RuntimeID: agent.RuntimeID,
|
|
Priority: 0,
|
|
AutopilotRunID: run.ID,
|
|
// Snapshot the autopilot title so task rows self-describe later
|
|
// without joining back to autopilot. Truncated for the same
|
|
// transmission-cost reason as comment-driven summaries.
|
|
TriggerSummary: pgtype.Text{
|
|
String: truncateForSummary(ap.Title, triggerSummaryMaxLen),
|
|
Valid: ap.Title != "",
|
|
},
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("create autopilot task: %w", err)
|
|
}
|
|
|
|
// Update run with task reference.
|
|
updatedRun, err := s.Queries.UpdateAutopilotRunRunning(ctx, db.UpdateAutopilotRunRunningParams{
|
|
ID: run.ID,
|
|
TaskID: task.ID,
|
|
})
|
|
if err != nil {
|
|
slog.Warn("failed to update run with task_id", "run_id", util.UUIDToString(run.ID), "error", err)
|
|
} else {
|
|
*run = updatedRun
|
|
}
|
|
|
|
// Drop the empty-claim cache and wake the daemon. dispatchRunOnly
|
|
// inserts the task row directly via Queries.CreateAutopilotTask
|
|
// (bypassing TaskService.Enqueue*), so without this the runtime
|
|
// would not get a wakeup and any cached "empty" verdict would
|
|
// stall the task until the TTL expired.
|
|
s.TaskSvc.NotifyTaskEnqueued(ctx, task)
|
|
|
|
slog.Info("autopilot dispatched (run_only)",
|
|
"autopilot_id", util.UUIDToString(ap.ID),
|
|
"task_id", util.UUIDToString(task.ID),
|
|
"run_id", util.UUIDToString(run.ID),
|
|
)
|
|
return nil
|
|
}
|
|
|
|
// SyncRunFromIssue updates the autopilot run when its linked issue reaches a terminal status.
|
|
func (s *AutopilotService) SyncRunFromIssue(ctx context.Context, issue db.Issue) {
|
|
if !issue.OriginType.Valid || issue.OriginType.String != "autopilot" {
|
|
return
|
|
}
|
|
|
|
run, err := s.Queries.GetAutopilotRunByIssue(ctx, issue.ID)
|
|
if err != nil {
|
|
return // no active run linked to this issue
|
|
}
|
|
autopilot, err := s.Queries.GetAutopilot(ctx, run.AutopilotID)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
wsID := util.UUIDToString(issue.WorkspaceID)
|
|
|
|
switch issue.Status {
|
|
case "done", "in_review":
|
|
updatedRun, err := s.Queries.UpdateAutopilotRunCompleted(ctx, db.UpdateAutopilotRunCompletedParams{
|
|
ID: run.ID,
|
|
})
|
|
if err != nil {
|
|
slog.Warn("failed to complete autopilot run", "run_id", util.UUIDToString(run.ID), "error", err)
|
|
return
|
|
}
|
|
s.captureAutopilotRunCompleted(autopilot, updatedRun)
|
|
s.publishRunDone(wsID, updatedRun, "completed")
|
|
case "cancelled", "blocked":
|
|
reason := "issue " + issue.Status
|
|
updatedRun, err := s.Queries.UpdateAutopilotRunFailed(ctx, db.UpdateAutopilotRunFailedParams{
|
|
ID: run.ID,
|
|
FailureReason: pgtype.Text{String: reason, Valid: true},
|
|
})
|
|
if err != nil {
|
|
slog.Warn("failed to fail autopilot run", "run_id", util.UUIDToString(run.ID), "error", err)
|
|
return
|
|
}
|
|
s.captureAutopilotRunFailed(autopilot, updatedRun, updatedRun.Source, reason)
|
|
s.publishRunDone(wsID, updatedRun, "failed")
|
|
}
|
|
}
|
|
|
|
// SyncRunFromTask updates the autopilot run when a run_only task completes or fails.
|
|
func (s *AutopilotService) SyncRunFromTask(ctx context.Context, task db.AgentTaskQueue) {
|
|
if !task.AutopilotRunID.Valid {
|
|
return
|
|
}
|
|
|
|
run, err := s.Queries.GetAutopilotRun(ctx, task.AutopilotRunID)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
autopilot, err := s.Queries.GetAutopilot(ctx, run.AutopilotID)
|
|
if err != nil {
|
|
return
|
|
}
|
|
wsID := util.UUIDToString(autopilot.WorkspaceID)
|
|
|
|
switch task.Status {
|
|
case "completed":
|
|
updatedRun, err := s.Queries.UpdateAutopilotRunCompleted(ctx, db.UpdateAutopilotRunCompletedParams{
|
|
ID: run.ID,
|
|
Result: task.Result,
|
|
})
|
|
if err != nil {
|
|
slog.Warn("failed to complete autopilot run from task", "run_id", util.UUIDToString(run.ID), "error", err)
|
|
return
|
|
}
|
|
s.captureAutopilotRunCompleted(autopilot, updatedRun)
|
|
s.publishRunDone(wsID, updatedRun, "completed")
|
|
case "failed", "cancelled":
|
|
reason := "task " + task.Status
|
|
if task.Error.Valid {
|
|
reason = task.Error.String
|
|
}
|
|
updatedRun, err := s.Queries.UpdateAutopilotRunFailed(ctx, db.UpdateAutopilotRunFailedParams{
|
|
ID: run.ID,
|
|
FailureReason: pgtype.Text{String: reason, Valid: true},
|
|
})
|
|
if err != nil {
|
|
slog.Warn("failed to fail autopilot run from task", "run_id", util.UUIDToString(run.ID), "error", err)
|
|
return
|
|
}
|
|
s.captureAutopilotRunFailed(autopilot, updatedRun, updatedRun.Source, reason)
|
|
s.publishRunDone(wsID, updatedRun, "failed")
|
|
}
|
|
}
|
|
|
|
func (s *AutopilotService) failRun(ctx context.Context, runID pgtype.UUID, reason string) {
|
|
if _, err := s.Queries.UpdateAutopilotRunFailed(ctx, db.UpdateAutopilotRunFailedParams{
|
|
ID: runID,
|
|
FailureReason: pgtype.Text{String: reason, Valid: true},
|
|
}); err != nil {
|
|
slog.Warn("failed to mark autopilot run as failed", "run_id", util.UUIDToString(runID), "error", err)
|
|
}
|
|
}
|
|
|
|
// shouldSkipDispatch is the pre-flight admission check from MUL-1899.
|
|
// Returns (reason, true) when dispatching now would only enqueue a doomed
|
|
// task — i.e. the assignee agent is gone, archived, has no runtime bound, or
|
|
// its runtime is not currently online. Returns ("", false) on the happy path.
|
|
//
|
|
// Errors loading the agent / runtime are logged but treated as "do not skip"
|
|
// so a transient DB hiccup never silently swallows a scheduled run.
|
|
func (s *AutopilotService) shouldSkipDispatch(ctx context.Context, ap db.Autopilot) (string, bool) {
|
|
if !ap.AssigneeID.Valid {
|
|
return "autopilot has no assignee", true
|
|
}
|
|
agent, err := s.Queries.GetAgent(ctx, ap.AssigneeID)
|
|
if err != nil {
|
|
slog.Warn("autopilot admission: failed to load assignee agent",
|
|
"autopilot_id", util.UUIDToString(ap.ID),
|
|
"agent_id", util.UUIDToString(ap.AssigneeID),
|
|
"error", err,
|
|
)
|
|
return "", false
|
|
}
|
|
if agent.ArchivedAt.Valid {
|
|
return "assignee agent is archived", true
|
|
}
|
|
if !agent.RuntimeID.Valid {
|
|
return "assignee agent has no runtime bound", true
|
|
}
|
|
rt, err := s.Queries.GetAgentRuntime(ctx, agent.RuntimeID)
|
|
if err != nil {
|
|
slog.Warn("autopilot admission: failed to load runtime",
|
|
"autopilot_id", util.UUIDToString(ap.ID),
|
|
"runtime_id", util.UUIDToString(agent.RuntimeID),
|
|
"error", err,
|
|
)
|
|
return "", false
|
|
}
|
|
if rt.Status != "online" {
|
|
return "agent runtime is " + rt.Status + " at dispatch time", true
|
|
}
|
|
// Private-agent gate at the autopilot layer. Caller identity = the
|
|
// autopilot's creator: if the creator no longer has access to the
|
|
// (now-private) target agent, the dispatch is recorded as `skipped`.
|
|
// Agent-created autopilots bypass the gate to preserve A2A
|
|
// collaboration. Errors loading the workspace member fail closed —
|
|
// without an authoritative role the gate cannot grant access.
|
|
if agent.Visibility == "private" && ap.CreatedByType == "member" {
|
|
creatorID := util.UUIDToString(ap.CreatedByID)
|
|
if util.UUIDToString(agent.OwnerID) != creatorID {
|
|
member, err := s.Queries.GetMemberByUserAndWorkspace(ctx, db.GetMemberByUserAndWorkspaceParams{
|
|
UserID: ap.CreatedByID,
|
|
WorkspaceID: ap.WorkspaceID,
|
|
})
|
|
if err != nil {
|
|
return "autopilot creator no longer in workspace", true
|
|
}
|
|
if member.Role != "owner" && member.Role != "admin" {
|
|
return "autopilot creator lacks access to private assignee agent", true
|
|
}
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// recordSkippedRun persists a `skipped` autopilot_run with the given reason
|
|
// and emits the same WS / analytics signals that a normal terminal transition
|
|
// would. Returns the run + nil error so callers (scheduler tick, manual
|
|
// trigger handler) treat this as a successful — but no-op — dispatch.
|
|
func (s *AutopilotService) recordSkippedRun(
|
|
ctx context.Context,
|
|
autopilot db.Autopilot,
|
|
triggerID pgtype.UUID,
|
|
source string,
|
|
payload []byte,
|
|
reason string,
|
|
) (*db.AutopilotRun, error) {
|
|
run, err := s.Queries.CreateAutopilotRun(ctx, db.CreateAutopilotRunParams{
|
|
AutopilotID: autopilot.ID,
|
|
TriggerID: triggerID,
|
|
Source: source,
|
|
Status: "skipped",
|
|
TriggerPayload: payload,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create skipped run: %w", err)
|
|
}
|
|
|
|
updated, err := s.Queries.UpdateAutopilotRunSkipped(ctx, db.UpdateAutopilotRunSkippedParams{
|
|
ID: run.ID,
|
|
FailureReason: pgtype.Text{String: reason, Valid: true},
|
|
})
|
|
if err == nil {
|
|
run = updated
|
|
} else {
|
|
slog.Warn("failed to set skip reason on autopilot run",
|
|
"run_id", util.UUIDToString(run.ID), "error", err)
|
|
}
|
|
|
|
slog.Info("autopilot dispatch skipped",
|
|
"autopilot_id", util.UUIDToString(autopilot.ID),
|
|
"run_id", util.UUIDToString(run.ID),
|
|
"source", source,
|
|
"reason", reason,
|
|
)
|
|
|
|
// Bump last_run_at so scheduler advancement and "last seen" UI both
|
|
// reflect that we did evaluate the trigger this tick.
|
|
s.Queries.UpdateAutopilotLastRunAt(ctx, autopilot.ID)
|
|
|
|
s.publishRunDone(util.UUIDToString(autopilot.WorkspaceID), run, "skipped")
|
|
return &run, nil
|
|
}
|
|
|
|
func (s *AutopilotService) publishRunDone(workspaceID string, run db.AutopilotRun, status string) {
|
|
s.Bus.Publish(events.Event{
|
|
Type: protocol.EventAutopilotRunDone,
|
|
WorkspaceID: workspaceID,
|
|
ActorType: "system",
|
|
Payload: map[string]any{
|
|
"run_id": util.UUIDToString(run.ID),
|
|
"autopilot_id": util.UUIDToString(run.AutopilotID),
|
|
"status": status,
|
|
},
|
|
})
|
|
}
|
|
|
|
func (s *AutopilotService) captureIssueCreatedFromAutopilot(ap db.Autopilot, run *db.AutopilotRun, issue db.Issue) {
|
|
if s.TaskSvc == nil || s.TaskSvc.Analytics == nil {
|
|
return
|
|
}
|
|
s.TaskSvc.Analytics.Capture(analytics.IssueCreated(
|
|
autopilotActorID(ap),
|
|
util.UUIDToString(ap.WorkspaceID),
|
|
util.UUIDToString(issue.ID),
|
|
util.UUIDToString(ap.AssigneeID),
|
|
"",
|
|
util.UUIDToString(run.ID),
|
|
analytics.SourceAutopilot,
|
|
))
|
|
}
|
|
|
|
func (s *AutopilotService) captureAutopilotRunStarted(ap db.Autopilot, run db.AutopilotRun, triggerSource string) {
|
|
if s.TaskSvc == nil || s.TaskSvc.Analytics == nil {
|
|
return
|
|
}
|
|
s.TaskSvc.Analytics.Capture(analytics.AutopilotRunStarted(
|
|
autopilotActorID(ap),
|
|
util.UUIDToString(ap.WorkspaceID),
|
|
util.UUIDToString(ap.ID),
|
|
util.UUIDToString(run.ID),
|
|
util.UUIDToString(ap.AssigneeID),
|
|
triggerSource,
|
|
))
|
|
}
|
|
|
|
func (s *AutopilotService) captureAutopilotRunCompleted(ap db.Autopilot, run db.AutopilotRun) {
|
|
if s.TaskSvc == nil || s.TaskSvc.Analytics == nil {
|
|
return
|
|
}
|
|
s.TaskSvc.Analytics.Capture(analytics.AutopilotRunCompleted(
|
|
autopilotActorID(ap),
|
|
util.UUIDToString(ap.WorkspaceID),
|
|
util.UUIDToString(ap.ID),
|
|
util.UUIDToString(run.ID),
|
|
util.UUIDToString(ap.AssigneeID),
|
|
run.Source,
|
|
autopilotRunDurationMS(run),
|
|
))
|
|
}
|
|
|
|
func (s *AutopilotService) captureAutopilotRunFailed(ap db.Autopilot, run db.AutopilotRun, triggerSource, reason string) {
|
|
if s.TaskSvc == nil || s.TaskSvc.Analytics == nil {
|
|
return
|
|
}
|
|
if reason == "" {
|
|
reason = "unknown"
|
|
}
|
|
s.TaskSvc.Analytics.Capture(analytics.AutopilotRunFailed(
|
|
autopilotActorID(ap),
|
|
util.UUIDToString(ap.WorkspaceID),
|
|
util.UUIDToString(ap.ID),
|
|
util.UUIDToString(run.ID),
|
|
util.UUIDToString(ap.AssigneeID),
|
|
triggerSource,
|
|
reason,
|
|
autopilotErrorType(reason),
|
|
false,
|
|
autopilotRunDurationMS(run),
|
|
))
|
|
}
|
|
|
|
func autopilotErrorType(reason string) string {
|
|
switch {
|
|
case strings.Contains(reason, "unknown execution_mode"):
|
|
return "configuration"
|
|
case strings.HasPrefix(reason, "issue "):
|
|
return "issue_terminal"
|
|
case strings.Contains(reason, "create issue"), strings.Contains(reason, "enqueue task"), strings.Contains(reason, "dispatch"):
|
|
return "dispatch_error"
|
|
case strings.HasPrefix(reason, "task "):
|
|
return "task_error"
|
|
default:
|
|
return "autopilot_error"
|
|
}
|
|
}
|
|
|
|
func autopilotActorID(ap db.Autopilot) string {
|
|
id := util.UUIDToString(ap.CreatedByID)
|
|
if ap.CreatedByType == "agent" && id != "" {
|
|
return "agent:" + id
|
|
}
|
|
if id != "" {
|
|
return id
|
|
}
|
|
return "system"
|
|
}
|
|
|
|
func autopilotRunDurationMS(run db.AutopilotRun) int64 {
|
|
if !run.CompletedAt.Valid {
|
|
return 0
|
|
}
|
|
start := run.TriggeredAt
|
|
if !start.Valid {
|
|
start = run.CreatedAt
|
|
}
|
|
if !start.Valid {
|
|
return 0
|
|
}
|
|
ms := run.CompletedAt.Time.Sub(start.Time).Milliseconds()
|
|
if ms < 0 {
|
|
return 0
|
|
}
|
|
return ms
|
|
}
|
|
|
|
// buildIssueDescription appends an autopilot system instruction to the
|
|
// user-provided description, asking the agent to rename the issue after
|
|
// it understands the actual work.
|
|
func (s *AutopilotService) buildIssueDescription(ap db.Autopilot) pgtype.Text {
|
|
now := time.Now().UTC().Format("2006-01-02 15:04 UTC")
|
|
note := fmt.Sprintf("\n\n---\n*Autopilot run triggered at %s. After starting work, rename this issue to accurately reflect what you are doing.*", now)
|
|
base := ap.Description.String
|
|
return pgtype.Text{String: base + note, Valid: true}
|
|
}
|
|
|
|
// interpolateTemplate replaces {{date}} in the issue title template.
|
|
func (s *AutopilotService) interpolateTemplate(ap db.Autopilot) string {
|
|
tmpl := ap.Title
|
|
if ap.IssueTitleTemplate.Valid && ap.IssueTitleTemplate.String != "" {
|
|
tmpl = ap.IssueTitleTemplate.String
|
|
}
|
|
now := time.Now().UTC().Format("2006-01-02")
|
|
return strings.ReplaceAll(tmpl, "{{date}}", now)
|
|
}
|
|
|
|
func (s *AutopilotService) getIssuePrefix(workspaceID pgtype.UUID) string {
|
|
ws, err := s.Queries.GetWorkspace(context.Background(), workspaceID)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return ws.IssuePrefix
|
|
}
|