mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-14 21:59:30 +02:00
Generalize SyncRunFromLinkedIssueTask beyond Codex no-progress: any terminal create-issue task failure with no retry still in flight now fails the linked autopilot run, so it can no longer hang in issue_created (invisible to the failure-rate auto-pause monitor). - fail the linked run for any terminal task failure, gated by the existing HasActiveTaskForIssue wait-for-retry guard - remove the isNoProgressTaskFailure classifier (subsumed; drops duplicated pkg/agent marker literals) - drop the redundant GetIssue/origin lookup; GetAutopilotRunByIssue leads and short-circuits ordinary failures in one query - tests: keep no-progress regression, add agent_error (non-retryable) and retry-pending cases Follow-up to #3927. VEN-661 / VEN-662 / MUL-3164
1154 lines
42 KiB
Go
1154 lines
42 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"regexp"
|
|
"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/issueposition"
|
|
obsmetrics "github.com/multica-ai/multica/server/internal/metrics"
|
|
"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
|
|
}
|
|
|
|
// DefaultAutopilotTriggerTimezone is the timezone used to render Autopilot
|
|
// trigger output when a trigger has no configured timezone or the configured
|
|
// timezone fails to load. Exported so the scheduler can use the same default
|
|
// when computing next run times.
|
|
const DefaultAutopilotTriggerTimezone = "UTC"
|
|
|
|
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.
|
|
//
|
|
// When assignee_type='squad' the gate runs against the squad leader (Path A
|
|
// from MUL-2429: Autopilot-on-squad ≈ Autopilot-on-leader), so an offline or
|
|
// archived leader produces the same skip behaviour as an offline solo agent.
|
|
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,
|
|
SquadID: autopilotSquadAttribution(autopilot),
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create run: %w", err)
|
|
}
|
|
s.captureAutopilotRunStarted(autopilot, run, source)
|
|
|
|
switch autopilot.ExecutionMode {
|
|
case "create_issue":
|
|
triggerTimezone := s.resolveAutopilotTriggerTimezone(ctx, triggerID)
|
|
if err := s.dispatchCreateIssue(ctx, autopilot, &run, triggerTimezone); err != nil {
|
|
if skipped := s.handleDispatchSkip(ctx, autopilot, &run, err); skipped != nil {
|
|
return skipped, 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 {
|
|
if skipped := s.handleDispatchSkip(ctx, autopilot, &run, err); skipped != nil {
|
|
return skipped, 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.
|
|
//
|
|
// When the autopilot is assigned to a squad (Path A from MUL-2429), the
|
|
// created issue inherits assignee_type='squad' + assignee_id=squad. The
|
|
// existing issue listener chain (shouldEnqueueSquadLeaderOnAssign →
|
|
// enqueueSquadLeaderTask) then routes the work to the squad leader, exactly
|
|
// as a human manually assigning the issue to that squad would.
|
|
//
|
|
// Creator on the issue is always the agent that will actually do the work
|
|
// (the resolved leader for a squad autopilot, otherwise the assignee agent
|
|
// itself), so activity / mentions render with the right author identity.
|
|
func (s *AutopilotService) dispatchCreateIssue(ctx context.Context, ap db.Autopilot, run *db.AutopilotRun, triggerTimezone string) error {
|
|
leader, _, err := s.resolveAutopilotLeader(ctx, ap)
|
|
if err != nil {
|
|
return fmt.Errorf("resolve leader: %w", err)
|
|
}
|
|
|
|
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)
|
|
|
|
title := s.interpolateTemplate(ap, *run, triggerTimezone)
|
|
description := s.buildIssueDescription(ap, *run, triggerTimezone)
|
|
|
|
issueNumber, err := qtx.IncrementIssueCounter(ctx, ap.WorkspaceID)
|
|
if err != nil {
|
|
return fmt.Errorf("increment issue counter: %w", err)
|
|
}
|
|
|
|
newPosition, err := issueposition.NextTopPosition(ctx, tx, ap.WorkspaceID, "todo")
|
|
if err != nil {
|
|
return fmt.Errorf("get next issue position: %w", err)
|
|
}
|
|
|
|
issue, err := qtx.CreateIssueWithOrigin(ctx, db.CreateIssueWithOriginParams{
|
|
WorkspaceID: ap.WorkspaceID,
|
|
Title: title,
|
|
Description: description,
|
|
Status: "todo",
|
|
Priority: "none",
|
|
AssigneeType: pgtype.Text{String: ap.AssigneeType, Valid: true},
|
|
AssigneeID: ap.AssigneeID,
|
|
// The agent that the autopilot dispatches to is the issue's creator,
|
|
// not the human who originally configured the autopilot. The latter
|
|
// is captured separately via origin_type=autopilot + origin_id. For
|
|
// squad-assigned autopilots, the creator is the resolved leader —
|
|
// the same agent the issue listener will end up enqueueing.
|
|
CreatorType: "agent",
|
|
CreatorID: leader.ID,
|
|
ParentIssueID: pgtype.UUID{},
|
|
Position: newPosition,
|
|
StartDate: pgtype.Date{},
|
|
DueDate: pgtype.Date{},
|
|
Number: issueNumber,
|
|
ProjectID: ap.ProjectID,
|
|
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). For
|
|
// squad autopilots, this is what triggers shouldEnqueueSquadLeaderOnAssign
|
|
// → enqueueSquadLeaderTask — no separate squad-routing code needed here.
|
|
prefix := s.getIssuePrefix(ap.WorkspaceID)
|
|
s.Bus.Publish(events.Event{
|
|
Type: protocol.EventIssueCreated,
|
|
WorkspaceID: util.UUIDToString(ap.WorkspaceID),
|
|
ActorType: "agent",
|
|
ActorID: util.UUIDToString(leader.ID),
|
|
Payload: map[string]any{
|
|
"issue": issueToMap(issue, prefix),
|
|
},
|
|
})
|
|
s.captureIssueCreatedFromAutopilot(ap, run, issue, leader.ID)
|
|
|
|
// Enqueue agent task via the existing flow. Squad-assigned autopilots
|
|
// route to the resolved leader as the executing agent (Path A from
|
|
// MUL-2429); agent-assigned autopilots go through the standard issue
|
|
// path. Both code paths land in agent_task_queue with agent_id = leader.
|
|
if ap.AssigneeType == "squad" {
|
|
// Fail-closed private-leader gate: if the leader is private, verify
|
|
// the autopilot creator still has access. This catches illegitimate
|
|
// configs that were saved before the save-time gate was added.
|
|
if leader.Visibility == "private" && !s.canCreatorAccessPrivateLeader(ctx, ap, leader) {
|
|
return fmt.Errorf("autopilot creator cannot access private squad leader")
|
|
}
|
|
if _, err := s.TaskSvc.EnqueueTaskForSquadLeader(ctx, issue, leader.ID, pgtype.UUID{}); err != nil {
|
|
return fmt.Errorf("enqueue squad leader task: %w", err)
|
|
}
|
|
} else {
|
|
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),
|
|
"assignee_type", ap.AssigneeType,
|
|
"issue_id", util.UUIDToString(issue.ID),
|
|
"leader_id", util.UUIDToString(leader.ID),
|
|
"run_id", util.UUIDToString(run.ID),
|
|
)
|
|
return nil
|
|
}
|
|
|
|
// errDispatchSkipped wraps a readiness failure encountered after the
|
|
// admission gate has already passed. dispatchRunOnly returns this when a
|
|
// resolved leader has gone offline / been archived between admission and
|
|
// task creation; DispatchAutopilot recognises it and records a `skipped`
|
|
// run (with the wrapped reason) instead of a `failed` run.
|
|
//
|
|
// Without the sentinel, the existing failRun path would mark these races as
|
|
// failures and bubble a 500 out of the manual-trigger handler — both wrong
|
|
// (the work was never attempted, no one is at fault) and noisy (the failure
|
|
// monitor would auto-pause autopilots whose only crime was a flaky runtime).
|
|
type errDispatchSkipped struct {
|
|
reason string
|
|
}
|
|
|
|
func (e *errDispatchSkipped) Error() string { return e.reason }
|
|
|
|
// dispatchRunOnly enqueues a direct agent task without creating an issue.
|
|
//
|
|
// For squad autopilots, the executing agent is the squad leader resolved at
|
|
// trigger time (Path A from MUL-2429). The same archived / runtime-bound /
|
|
// runtime-online gates that the upstream admission check (shouldSkipDispatch)
|
|
// applies also run here as belt-and-braces: if the leader changed between
|
|
// admission and dispatch, or the runtime went offline in the gap, we still
|
|
// fail closed instead of enqueueing a doomed task.
|
|
func (s *AutopilotService) dispatchRunOnly(ctx context.Context, ap db.Autopilot, run *db.AutopilotRun) error {
|
|
agent, _, err := s.resolveAutopilotLeader(ctx, ap)
|
|
if err != nil {
|
|
// Same admission-vs-failure classification as shouldSkipDispatch:
|
|
// if the row disappeared or the squad was archived between
|
|
// admission and dispatch, that is a skip, not a failure.
|
|
if errors.Is(err, pgx.ErrNoRows) || errors.Is(err, errSquadArchived) {
|
|
return &errDispatchSkipped{reason: formatAdmissionReason(ap, "assignee no longer resolvable")}
|
|
}
|
|
return fmt.Errorf("resolve leader: %w", err)
|
|
}
|
|
ready, reason, err := AgentReadiness(ctx, s.Queries, agent)
|
|
if err != nil {
|
|
return fmt.Errorf("check agent readiness: %w", err)
|
|
}
|
|
if !ready {
|
|
return &errDispatchSkipped{reason: formatAdmissionReason(ap, reason)}
|
|
}
|
|
|
|
// Fail-closed private-leader gate for squad autopilots.
|
|
if ap.AssigneeType == "squad" && agent.Visibility == "private" && !s.canCreatorAccessPrivateLeader(ctx, ap, agent) {
|
|
return &errDispatchSkipped{reason: formatAdmissionReason(ap, "creator cannot access private squad leader")}
|
|
}
|
|
|
|
task, err := s.Queries.CreateAutopilotTask(ctx, db.CreateAutopilotTaskParams{
|
|
AgentID: agent.ID,
|
|
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")
|
|
}
|
|
}
|
|
|
|
// SyncRunFromLinkedIssueTask fails a create_issue autopilot run when its
|
|
// linked issue task fails terminally before the issue itself reaches a
|
|
// terminal status. create_issue tasks are linked through issue_id rather than
|
|
// autopilot_run_id, so SyncRunFromTask cannot see them directly. Without this
|
|
// the run would hang in `issue_created` forever — and because the failure-rate
|
|
// auto-pause monitor excludes issue_created/running runs, a consistently
|
|
// failing autopilot would never trip the auto-pause either.
|
|
//
|
|
// "Terminal" means no task is still active for the issue. FailTask enqueues an
|
|
// auto-retry for infra-shaped failures (timeout, runtime offline/recovery,
|
|
// codex no-progress) BEFORE it broadcasts the failure event, so an active task
|
|
// here means another attempt is already in flight — we wait for it instead of
|
|
// failing the run prematurely. Once retries are exhausted (or the failure was
|
|
// never retryable in the first place), the run fails carrying the task's reason.
|
|
func (s *AutopilotService) SyncRunFromLinkedIssueTask(ctx context.Context, task db.AgentTaskQueue) {
|
|
if task.AutopilotRunID.Valid || !task.IssueID.Valid || task.Status != "failed" {
|
|
return
|
|
}
|
|
// Only create_issue runs link through issue_id (and their linked issue is
|
|
// always origin_type=autopilot by construction), so a hit here both
|
|
// identifies an in-flight create_issue run and bails the common case of
|
|
// ordinary issue/chat task failures after a single query.
|
|
run, err := s.Queries.GetAutopilotRunByIssue(ctx, task.IssueID)
|
|
if err != nil {
|
|
return // no active run linked to this issue
|
|
}
|
|
// A still-active task — typically the auto-retry FailTask just enqueued —
|
|
// means the dispatch isn't terminal yet; wait for the final attempt.
|
|
hasActive, err := s.Queries.HasActiveTaskForIssue(ctx, task.IssueID)
|
|
if err != nil {
|
|
slog.Warn("failed to check active tasks for autopilot issue failure",
|
|
"issue_id", util.UUIDToString(task.IssueID),
|
|
"task_id", util.UUIDToString(task.ID),
|
|
"error", err,
|
|
)
|
|
return
|
|
}
|
|
if hasActive {
|
|
return
|
|
}
|
|
autopilot, err := s.Queries.GetAutopilot(ctx, run.AutopilotID)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
reason := taskFailureReasonForAutopilotRun(task)
|
|
updatedRun, err := s.Queries.UpdateAutopilotRunFailed(ctx, db.UpdateAutopilotRunFailedParams{
|
|
ID: run.ID,
|
|
FailureReason: pgtype.Text{String: reason, Valid: reason != ""},
|
|
})
|
|
if err != nil {
|
|
slog.Warn("failed to fail autopilot run from linked issue task",
|
|
"run_id", util.UUIDToString(run.ID),
|
|
"issue_id", util.UUIDToString(task.IssueID),
|
|
"task_id", util.UUIDToString(task.ID),
|
|
"error", err,
|
|
)
|
|
return
|
|
}
|
|
s.captureAutopilotRunFailed(autopilot, updatedRun, updatedRun.Source, reason)
|
|
s.publishRunDone(util.UUIDToString(autopilot.WorkspaceID), updatedRun, "failed")
|
|
}
|
|
|
|
func taskFailureReasonForAutopilotRun(task db.AgentTaskQueue) string {
|
|
if task.Error.Valid && strings.TrimSpace(task.Error.String) != "" {
|
|
return task.Error.String
|
|
}
|
|
if task.FailureReason.Valid && strings.TrimSpace(task.FailureReason.String) != "" {
|
|
return task.FailureReason.String
|
|
}
|
|
return "task failed"
|
|
}
|
|
|
|
// handleDispatchSkip recognises an errDispatchSkipped returned from a
|
|
// dispatch function and rewrites the in-flight run to `skipped` (instead of
|
|
// `failed`). Returns the updated run on a real skip, nil otherwise — callers
|
|
// fall through to the failure path on nil.
|
|
//
|
|
// Lives here, not inside dispatchRunOnly, because the run row was created by
|
|
// DispatchAutopilot up the stack and the failure-vs-skip distinction is
|
|
// owned by the dispatcher entry point. Keeps dispatchRunOnly free of
|
|
// state-mutation helpers.
|
|
func (s *AutopilotService) handleDispatchSkip(ctx context.Context, ap db.Autopilot, run *db.AutopilotRun, err error) *db.AutopilotRun {
|
|
var skipErr *errDispatchSkipped
|
|
if !errors.As(err, &skipErr) {
|
|
return nil
|
|
}
|
|
updated, uerr := s.Queries.UpdateAutopilotRunSkipped(ctx, db.UpdateAutopilotRunSkippedParams{
|
|
ID: run.ID,
|
|
FailureReason: pgtype.Text{String: skipErr.reason, Valid: true},
|
|
})
|
|
if uerr != nil {
|
|
slog.Warn("failed to mark dispatch as skipped",
|
|
"run_id", util.UUIDToString(run.ID), "error", uerr)
|
|
// Leave the run in its current (running/issue_created) state if
|
|
// the update failed; the failure monitor will eventually fail it
|
|
// out, but at least we didn't pretend it succeeded.
|
|
return nil
|
|
}
|
|
*run = updated
|
|
slog.Info("autopilot dispatch skipped post-admission",
|
|
"autopilot_id", util.UUIDToString(ap.ID),
|
|
"run_id", util.UUIDToString(run.ID),
|
|
"reason", skipErr.reason,
|
|
)
|
|
// Bump last_run_at on parity with recordSkippedRun (pre-flight skip) and
|
|
// the success path: from the scheduler's / UI's point of view we did
|
|
// evaluate the trigger this tick, even though the post-admission gate
|
|
// caught a late readiness regression.
|
|
s.Queries.UpdateAutopilotLastRunAt(ctx, ap.ID)
|
|
s.publishRunDone(util.UUIDToString(ap.WorkspaceID), updated, "skipped")
|
|
return run
|
|
}
|
|
|
|
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 (or, for squad autopilots, the squad leader) is
|
|
// gone, archived, has no runtime bound, or its runtime is not currently
|
|
// online. Returns ("", false) on the happy path.
|
|
//
|
|
// Errors are split into two classes:
|
|
// - pgx.ErrNoRows / errSquadArchived (the row truly doesn't exist or is
|
|
// archived) → hard skip. Retrying won't change anything; piling failed
|
|
// runs would pollute the failure-rate auto-pause monitor.
|
|
// - Anything else (connection drop, statement timeout, etc.) → fail-open:
|
|
// log + do not skip, so a transient DB hiccup never silently swallows a
|
|
// scheduled run. Migration 096 removed the agent FK on autopilot, so an
|
|
// agent assignee being missing is now a real condition the gate must
|
|
// handle (previously cascade-deleted).
|
|
func (s *AutopilotService) shouldSkipDispatch(ctx context.Context, ap db.Autopilot) (string, bool) {
|
|
if !ap.AssigneeID.Valid {
|
|
return "autopilot has no assignee", true
|
|
}
|
|
agent, squadResolved, err := s.resolveAutopilotLeader(ctx, ap)
|
|
if err != nil {
|
|
// Hard-skip the cases where another retry will produce the same
|
|
// outcome. Logging is unconditional so ops can still spot a run of
|
|
// dangling rows pointing at a deleted agent / archived squad.
|
|
missing := errors.Is(err, pgx.ErrNoRows)
|
|
archived := errors.Is(err, errSquadArchived)
|
|
slog.Warn("autopilot admission: failed to resolve leader",
|
|
"autopilot_id", util.UUIDToString(ap.ID),
|
|
"assignee_type", ap.AssigneeType,
|
|
"assignee_id", util.UUIDToString(ap.AssigneeID),
|
|
"missing", missing,
|
|
"archived", archived,
|
|
"error", err,
|
|
)
|
|
switch {
|
|
case archived:
|
|
// Squad row exists but is archived — DeleteSquad's transfer
|
|
// should have rewritten this autopilot's assignee to the leader
|
|
// already; surfacing the case explicitly keeps the failure
|
|
// reason useful when something slipped past the transfer.
|
|
return "assignee squad is archived", true
|
|
case missing && squadResolved:
|
|
return "assignee squad cannot be resolved", true
|
|
case missing && !squadResolved:
|
|
// Agent row gone. With migration 096 the FK is gone too, so
|
|
// this is the new "agent was hard-deleted under us" case. Skip
|
|
// rather than fail-open: we know retrying will not help.
|
|
return "assignee agent no longer exists", true
|
|
}
|
|
// Transient DB error — fail-open so the next scheduler tick gets a
|
|
// chance to succeed.
|
|
return "", false
|
|
}
|
|
ready, reason, err := AgentReadiness(ctx, s.Queries, agent)
|
|
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 !ready {
|
|
return formatAdmissionReason(ap, reason), 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.
|
|
//
|
|
// For squad autopilots the gate runs against the resolved leader.
|
|
// Leader visibility is the right thing to check — if the human creator
|
|
// can no longer reach the leader, the autopilot would silently fail
|
|
// even though the squad itself looks intact.
|
|
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
|
|
}
|
|
|
|
// formatAdmissionReason rewrites the generic AgentReadiness reason into the
|
|
// admission-gate phrasing the failure monitor and existing alerting are tuned
|
|
// for. Keeping the prefix stable matters: dashboards group skip reasons by
|
|
// substring ("offline at dispatch time" is how the MUL-1899 alert fires).
|
|
//
|
|
// For squad autopilots the message names the squad so an operator looking at
|
|
// the failure_reason field knows which squad's leader is down without
|
|
// joining back to autopilot_run.squad_id.
|
|
func formatAdmissionReason(ap db.Autopilot, raw string) string {
|
|
prefix := "assignee "
|
|
if ap.AssigneeType == "squad" {
|
|
prefix = "squad leader "
|
|
}
|
|
switch raw {
|
|
case "agent is archived":
|
|
return prefix + "agent is archived"
|
|
case "agent has no runtime bound":
|
|
return prefix + "agent has no runtime bound"
|
|
default:
|
|
// raw is "agent runtime is X" — surface the runtime status while
|
|
// preserving the legacy "at dispatch time" suffix from MUL-1899
|
|
// so alert queries do not need to change.
|
|
return raw + " at dispatch time"
|
|
}
|
|
}
|
|
|
|
// errSquadArchived signals that an autopilot's squad assignee has been
|
|
// archived. Distinct from a missing/loadable-but-failed squad so the
|
|
// admission gate can phrase the skip reason precisely and the failure
|
|
// monitor does not see "cannot be resolved" wear noise for what is a
|
|
// known, expected post-archive condition.
|
|
var errSquadArchived = errors.New("squad is archived")
|
|
|
|
// resolveAutopilotLeader returns the agent that will actually execute the
|
|
// autopilot's work. For assignee_type='agent' the agent is the assignee
|
|
// itself; for assignee_type='squad' it is the squad's leader_id. The second
|
|
// return is true when the resolver took the squad branch — callers use this
|
|
// to distinguish "failed loading an agent" from "failed loading a squad", so
|
|
// the admission gate can choose between fail-open (transient DB error on a
|
|
// known-good agent) and fail-closed (squad row gone, no point retrying).
|
|
//
|
|
// Archived squads are rejected here too: TransferSquadAutopilotsToLeader
|
|
// flips surviving autopilots to assignee_type='agent' on DeleteSquad, but
|
|
// the gate still has to fail closed for any row that slips through that
|
|
// transfer (e.g. squad archived through a code path that bypasses the
|
|
// handler) so an archived squad never produces work.
|
|
//
|
|
// Unknown assignee_type values return an error. assignee_type is gated by a
|
|
// CHECK constraint at the DB layer, so this only fires if a future code path
|
|
// inserts a row that bypasses the check.
|
|
func (s *AutopilotService) resolveAutopilotLeader(ctx context.Context, ap db.Autopilot) (agent db.Agent, squadResolved bool, err error) {
|
|
switch ap.AssigneeType {
|
|
case "", "agent":
|
|
agent, err = s.Queries.GetAgent(ctx, ap.AssigneeID)
|
|
return agent, false, err
|
|
case "squad":
|
|
squad, err := s.Queries.GetSquad(ctx, ap.AssigneeID)
|
|
if err != nil {
|
|
return db.Agent{}, true, fmt.Errorf("load squad: %w", err)
|
|
}
|
|
if squad.ArchivedAt.Valid {
|
|
return db.Agent{}, true, errSquadArchived
|
|
}
|
|
agent, err = s.Queries.GetAgent(ctx, squad.LeaderID)
|
|
if err != nil {
|
|
return db.Agent{}, true, fmt.Errorf("load squad leader: %w", err)
|
|
}
|
|
return agent, true, nil
|
|
default:
|
|
return db.Agent{}, false, fmt.Errorf("unknown assignee_type %q", ap.AssigneeType)
|
|
}
|
|
}
|
|
|
|
// autopilotSquadAttribution returns the squad_id attribution hook for an
|
|
// autopilot_run row. Only populated when assignee_type='squad'. First-version
|
|
// reports do not consume this; it exists so a future squad-cost view does not
|
|
// need to backfill — see RFC §4.e (MUL-2429).
|
|
func autopilotSquadAttribution(ap db.Autopilot) pgtype.UUID {
|
|
if ap.AssigneeType == "squad" && ap.AssigneeID.Valid {
|
|
return ap.AssigneeID
|
|
}
|
|
return pgtype.UUID{}
|
|
}
|
|
|
|
// 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,
|
|
SquadID: autopilotSquadAttribution(autopilot),
|
|
})
|
|
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, leaderID pgtype.UUID) {
|
|
if s.TaskSvc == nil || s.TaskSvc.Analytics == nil {
|
|
return
|
|
}
|
|
// For PostHog the agent_id should be the agent that will actually run
|
|
// the work (the resolved leader for squad autopilots) so per-agent task
|
|
// counts line up with what daemons report.
|
|
obsmetrics.RecordEvent(s.TaskSvc.Analytics, s.TaskSvc.Metrics, analytics.IssueCreated(
|
|
autopilotActorID(ap),
|
|
util.UUIDToString(ap.WorkspaceID),
|
|
util.UUIDToString(issue.ID),
|
|
util.UUIDToString(leaderID),
|
|
"",
|
|
util.UUIDToString(run.ID),
|
|
analytics.SourceAutopilot,
|
|
analytics.PlatformServer,
|
|
))
|
|
}
|
|
|
|
func (s *AutopilotService) captureAutopilotRunStarted(ap db.Autopilot, run db.AutopilotRun, triggerSource string) {
|
|
if s.TaskSvc == nil || s.TaskSvc.Analytics == nil {
|
|
return
|
|
}
|
|
obsmetrics.RecordEvent(s.TaskSvc.Analytics, s.TaskSvc.Metrics, analytics.AutopilotRunStarted(
|
|
autopilotActorID(ap),
|
|
util.UUIDToString(ap.WorkspaceID),
|
|
util.UUIDToString(ap.ID),
|
|
util.UUIDToString(run.ID),
|
|
triggerSource, // cadence proxy: see autopilot cadence note in metrics/labels_pr3.go
|
|
s.autopilotAssigneeAnalytics(ap),
|
|
triggerSource,
|
|
))
|
|
}
|
|
|
|
func (s *AutopilotService) captureAutopilotRunCompleted(ap db.Autopilot, run db.AutopilotRun) {
|
|
if s.TaskSvc == nil || s.TaskSvc.Analytics == nil {
|
|
return
|
|
}
|
|
obsmetrics.RecordEvent(s.TaskSvc.Analytics, s.TaskSvc.Metrics, analytics.AutopilotRunCompleted(
|
|
autopilotActorID(ap),
|
|
util.UUIDToString(ap.WorkspaceID),
|
|
util.UUIDToString(ap.ID),
|
|
util.UUIDToString(run.ID),
|
|
run.Source,
|
|
s.autopilotAssigneeAnalytics(ap),
|
|
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"
|
|
}
|
|
obsmetrics.RecordEvent(s.TaskSvc.Analytics, s.TaskSvc.Metrics, analytics.AutopilotRunFailed(
|
|
autopilotActorID(ap),
|
|
util.UUIDToString(ap.WorkspaceID),
|
|
util.UUIDToString(ap.ID),
|
|
util.UUIDToString(run.ID),
|
|
triggerSource,
|
|
s.autopilotAssigneeAnalytics(ap),
|
|
triggerSource,
|
|
reason,
|
|
autopilotErrorType(reason),
|
|
false,
|
|
autopilotRunDurationMS(run),
|
|
))
|
|
}
|
|
|
|
// autopilotAssigneeAnalytics builds the PostHog assignee descriptor for an
|
|
// autopilot. For squad autopilots agent_id is best-effort the resolved
|
|
// leader (so per-agent funnels stay consistent); a resolve error degrades
|
|
// to the raw assignee_id rather than dropping the event — incomplete data
|
|
// in the dashboard is preferable to silent attribution gaps.
|
|
func (s *AutopilotService) autopilotAssigneeAnalytics(ap db.Autopilot) analytics.AutopilotAssignee {
|
|
assignee := analytics.AutopilotAssignee{
|
|
AssigneeType: ap.AssigneeType,
|
|
}
|
|
if ap.AssigneeType == "squad" {
|
|
assignee.SquadID = util.UUIDToString(ap.AssigneeID)
|
|
if leader, _, err := s.resolveAutopilotLeader(context.Background(), ap); err == nil {
|
|
assignee.AgentID = util.UUIDToString(leader.ID)
|
|
} else {
|
|
assignee.AgentID = util.UUIDToString(ap.AssigneeID)
|
|
}
|
|
} else {
|
|
assignee.AgentID = util.UUIDToString(ap.AssigneeID)
|
|
}
|
|
return assignee
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
func (s *AutopilotService) resolveAutopilotTriggerTimezone(ctx context.Context, triggerID pgtype.UUID) string {
|
|
if !triggerID.Valid || s == nil || s.Queries == nil {
|
|
return DefaultAutopilotTriggerTimezone
|
|
}
|
|
|
|
trigger, err := s.Queries.GetAutopilotTrigger(ctx, triggerID)
|
|
if err != nil {
|
|
slog.Warn("failed to load autopilot trigger timezone; falling back to UTC",
|
|
"trigger_id", util.UUIDToString(triggerID),
|
|
"error", err,
|
|
)
|
|
return DefaultAutopilotTriggerTimezone
|
|
}
|
|
|
|
timezone := strings.TrimSpace(trigger.Timezone.String)
|
|
if !trigger.Timezone.Valid || timezone == "" {
|
|
return DefaultAutopilotTriggerTimezone
|
|
}
|
|
if _, err := time.LoadLocation(timezone); err != nil {
|
|
slog.Warn("invalid autopilot trigger timezone; falling back to UTC",
|
|
"trigger_id", util.UUIDToString(triggerID),
|
|
"timezone", timezone,
|
|
"error", err,
|
|
)
|
|
return DefaultAutopilotTriggerTimezone
|
|
}
|
|
return timezone
|
|
}
|
|
|
|
func formatAutopilotRunTimestamp(run db.AutopilotRun, timezone string) string {
|
|
triggeredAt := autopilotRunTriggeredAt(run)
|
|
loc, label := autopilotTriggerLocation(timezone)
|
|
return triggeredAt.In(loc).Format("2006-01-02 15:04") + " " + label
|
|
}
|
|
|
|
func formatAutopilotRunDate(run db.AutopilotRun, timezone string) string {
|
|
triggeredAt := autopilotRunTriggeredAt(run)
|
|
loc, _ := autopilotTriggerLocation(timezone)
|
|
return triggeredAt.In(loc).Format("2006-01-02")
|
|
}
|
|
|
|
func autopilotRunTriggeredAt(run db.AutopilotRun) time.Time {
|
|
if run.TriggeredAt.Valid {
|
|
return run.TriggeredAt.Time
|
|
}
|
|
if run.CreatedAt.Valid {
|
|
return run.CreatedAt.Time
|
|
}
|
|
return time.Now().UTC()
|
|
}
|
|
|
|
func autopilotTriggerLocation(timezone string) (*time.Location, string) {
|
|
label := strings.TrimSpace(timezone)
|
|
if label == "" {
|
|
label = DefaultAutopilotTriggerTimezone
|
|
}
|
|
loc, err := time.LoadLocation(label)
|
|
if err != nil {
|
|
return time.UTC, DefaultAutopilotTriggerTimezone
|
|
}
|
|
return loc, label
|
|
}
|
|
|
|
// buildIssueDescription appends an autopilot system instruction to the
|
|
// user-provided description, asking the agent to rename the issue after
|
|
// it understands the actual work. For webhook-sourced runs, also appends
|
|
// a payload section so the agent has the event context inline (otherwise
|
|
// the agent only sees the issue body, never the run's trigger_payload).
|
|
func (s *AutopilotService) buildIssueDescription(ap db.Autopilot, run db.AutopilotRun, triggerTimezone string) pgtype.Text {
|
|
triggeredAt := formatAutopilotRunTimestamp(run, triggerTimezone)
|
|
var b strings.Builder
|
|
b.WriteString(ap.Description.String)
|
|
b.WriteString("\n\n---\n*Autopilot run triggered at ")
|
|
b.WriteString(triggeredAt)
|
|
b.WriteString(". After starting work, rename this issue to accurately reflect what you are doing.*")
|
|
|
|
if run.Source == "webhook" && len(run.TriggerPayload) > 0 {
|
|
event := "webhook.received"
|
|
var payloadJSON []byte
|
|
var env struct {
|
|
Event string `json:"event"`
|
|
EventPayload json.RawMessage `json:"eventPayload"`
|
|
}
|
|
if err := json.Unmarshal(run.TriggerPayload, &env); err == nil {
|
|
if env.Event != "" {
|
|
event = env.Event
|
|
}
|
|
if len(env.EventPayload) > 0 {
|
|
if pretty, err := prettifyJSON(env.EventPayload); err == nil {
|
|
payloadJSON = pretty
|
|
}
|
|
}
|
|
}
|
|
if len(payloadJSON) == 0 {
|
|
if pretty, err := prettifyJSON(run.TriggerPayload); err == nil {
|
|
payloadJSON = pretty
|
|
} else {
|
|
payloadJSON = run.TriggerPayload
|
|
}
|
|
}
|
|
b.WriteString("\n\nWebhook event: ")
|
|
b.WriteString(event)
|
|
b.WriteString("\n\nWebhook payload:\n```json\n")
|
|
b.Write(payloadJSON)
|
|
b.WriteString("\n```")
|
|
}
|
|
|
|
return pgtype.Text{String: b.String(), Valid: true}
|
|
}
|
|
|
|
func prettifyJSON(raw []byte) ([]byte, error) {
|
|
var v any
|
|
if err := json.Unmarshal(raw, &v); err != nil {
|
|
return nil, err
|
|
}
|
|
return json.MarshalIndent(v, "", " ")
|
|
}
|
|
|
|
// issueTitleTemplateTokenRE matches any {{...}} token in an issue-title
|
|
// template. We deliberately permit whitespace inside the braces ({{ date }})
|
|
// so users can format templates either way; the canonical token is still
|
|
// {{date}}.
|
|
var issueTitleTemplateTokenRE = regexp.MustCompile(`\{\{\s*([^{}]*?)\s*\}\}`)
|
|
|
|
// interpolateTemplate substitutes supported {{name}} placeholders in the
|
|
// issue title template. Whitespace inside the braces ({{ date }}) is
|
|
// tolerated so the render layer accepts every form that
|
|
// ValidateIssueTitleTemplate accepts — otherwise users would save templates
|
|
// that pass validation but still emit a literal token at trigger time.
|
|
func (s *AutopilotService) interpolateTemplate(ap db.Autopilot, run db.AutopilotRun, triggerTimezone string) string {
|
|
tmpl := ap.Title
|
|
if ap.IssueTitleTemplate.Valid && ap.IssueTitleTemplate.String != "" {
|
|
tmpl = ap.IssueTitleTemplate.String
|
|
}
|
|
triggerDate := formatAutopilotRunDate(run, triggerTimezone)
|
|
return issueTitleTemplateTokenRE.ReplaceAllStringFunc(tmpl, func(match string) string {
|
|
name := strings.TrimSpace(match[2 : len(match)-2])
|
|
switch name {
|
|
case "date":
|
|
return triggerDate
|
|
default:
|
|
return match
|
|
}
|
|
})
|
|
}
|
|
|
|
// SupportedIssueTitleTemplateVariables enumerates the placeholders that
|
|
// interpolateTemplate will substitute. Keep this in sync with the
|
|
// substitution logic above and with the docs in autopilots.mdx /
|
|
// autopilots.zh.mdx.
|
|
var SupportedIssueTitleTemplateVariables = []string{"date"}
|
|
|
|
// ValidateIssueTitleTemplate rejects templates that contain any {{...}} token
|
|
// other than the supported set. An empty template is valid (the autopilot
|
|
// falls back to its own Title). The error message names the first offending
|
|
// token to keep CLI feedback actionable.
|
|
func ValidateIssueTitleTemplate(tmpl string) error {
|
|
if tmpl == "" {
|
|
return nil
|
|
}
|
|
for _, m := range issueTitleTemplateTokenRE.FindAllStringSubmatch(tmpl, -1) {
|
|
name := m[1]
|
|
if !isSupportedIssueTitleVariable(name) {
|
|
return fmt.Errorf(
|
|
"unknown template variable %q; supported: {{%s}}",
|
|
name,
|
|
strings.Join(SupportedIssueTitleTemplateVariables, "}}, {{"),
|
|
)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func isSupportedIssueTitleVariable(name string) bool {
|
|
for _, v := range SupportedIssueTitleTemplateVariables {
|
|
if name == v {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (s *AutopilotService) getIssuePrefix(workspaceID pgtype.UUID) string {
|
|
ws, err := s.Queries.GetWorkspace(context.Background(), workspaceID)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return ws.IssuePrefix
|
|
}
|
|
|
|
// canCreatorAccessPrivateLeader checks whether the autopilot's creator still
|
|
// has access to a private leader agent. Mirrors handler.canAccessPrivateAgent
|
|
// logic: agent creators always pass; member creators must be the agent owner
|
|
// or a workspace owner/admin. Returns false (fail-closed) on any lookup error.
|
|
func (s *AutopilotService) canCreatorAccessPrivateLeader(ctx context.Context, ap db.Autopilot, leader db.Agent) bool {
|
|
if ap.CreatedByType == "agent" {
|
|
return true
|
|
}
|
|
creatorID := util.UUIDToString(ap.CreatedByID)
|
|
if util.UUIDToString(leader.OwnerID) == creatorID {
|
|
return true
|
|
}
|
|
member, err := s.Queries.GetMemberByUserAndWorkspace(ctx, db.GetMemberByUserAndWorkspaceParams{
|
|
UserID: ap.CreatedByID,
|
|
WorkspaceID: ap.WorkspaceID,
|
|
})
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return member.Role == "owner" || member.Role == "admin"
|
|
}
|