Files
multica/server/internal/service/autopilot.go
Jiayuan Zhang 2d71872daa feat(autopilot): default subscriber template (MUL-2533) (#3060)
* feat(autopilot): default subscriber template (MUL-2533) — server

Add per-autopilot member subscriber template that fans out to every
issue the autopilot spawns. New autopilot_subscriber table; extend
issue_subscriber.reason with 'autopilot' so the dispatch-time fanout
is distinguishable from manual subscriptions.

API: POST/PATCH /api/autopilots accept a `subscribers` array (member
user_type only for the first version); PATCH semantics are full-replace.
GET returns subscribers on the detail endpoint; the list endpoint omits
them to avoid an N+1.

Dispatch: dispatchCreateIssue lists the template inside the same tx as
the issue insert and writes the rows with reason='autopilot' before
EventIssueCreated fires, so notification listeners see the full
subscriber set on the first event.

Co-authored-by: multica-agent <github@multica.ai>

* feat(autopilot): default subscriber template (MUL-2533) — frontend

New SubscriberMultiSelect picker (members-only search + chips) wired
into the create / edit AutopilotDialog. The detail page renders the
saved template as read-only chips; edits flow through the dialog.

TS types expose the new `subscribers` field on Autopilot, plus an
AutopilotSubscriberInput shape for the create/update wire payloads.

Co-authored-by: multica-agent <github@multica.ai>

* fix(autopilot): notify template subscribers on issue creation (MUL-2533)

The autopilot create-issue path fans out template subscribers into
issue_subscriber inside the same tx as the issue insert, but the
issue:created notification listener only matches handler.IssueResponse
payloads and only direct-notifies the assignee + @mentions. The autopilot
publishes a map[string]any payload, so the listener falls through and the
template subscribers never receive an inbox item for the creation event —
breaking OQ3 ("reason='autopilot' subscribers receive all subscription
events, consistent with reason='manual'").

Fix it where the divergence lives: in dispatchCreateIssue, right after
EventIssueCreated fires, write an inbox_item (type='issue_subscribed',
severity='info') for each member subscriber and publish EventInboxNew so
the recipient's inbox WS feed updates in real time. The write is after
the tx commit so an inbox hiccup can't roll back the issue; failures are
logged, not propagated. The manual path is unchanged — manual subscribers
don't exist at creation time, so there is nothing to notify there.

Adds a new InboxItemType 'issue_subscribed' (en/zh labels) and two
covering tests in autopilot_subscriber_test.go: one asserts the inbox
row lands for a template subscriber on dispatch, the other asserts the
no-subscriber autopilot stays silent.

Co-authored-by: multica-agent <github@multica.ai>

* fix(autopilot): align subscriber PR with current main

Co-authored-by: multica-agent <github@multica.ai>

* fix autopilot subscriber template transaction

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-17 15:18:06 +02:00

1263 lines
46 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)
}
// Fan out the default subscriber template inside the same tx as the
// issue insert, before EventIssueCreated fires — so notification
// listeners see the full subscriber set on the first event instead of
// racing the listener that would otherwise hydrate the template.
templateSubs, err := qtx.ListAutopilotSubscribers(ctx, ap.ID)
if err != nil {
return fmt.Errorf("list autopilot subscribers: %w", err)
}
for _, sub := range templateSubs {
if err := qtx.AddIssueSubscriber(ctx, db.AddIssueSubscriberParams{
IssueID: issue.ID,
UserType: sub.UserType,
UserID: sub.UserID,
Reason: "autopilot",
}); err != nil {
return fmt.Errorf("add autopilot subscriber to 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)
// The issue:created notification listener only handles handler.IssueResponse
// payloads and only direct-notifies the assignee + @mentions; subscribers
// don't get an inbox at creation time on the manual path because there are
// none yet. The autopilot path is different: the template subscribers were
// fanned out into issue_subscriber inside the tx above, so they exist at the
// moment of creation and OQ3 says they should receive the same subscription
// events as reason='manual'. Issue creation is one such event — so write
// the inbox rows directly here. Done after commit so a failure here doesn't
// roll back the issue itself.
s.notifyAutopilotSubscribersOnCreate(ctx, ap, issue, leader.ID, templateSubs)
// 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
}
// notifyAutopilotSubscribersOnCreate writes an inbox_item for each template
// subscriber of an autopilot-created issue and broadcasts an inbox:new event
// so the recipient's inbox updates in real time. Mirrors the inbox payload
// shape from notification_listeners.go so the WS consumer sees the same fields
// the listener-driven path produces. Failures are logged, not propagated:
// the issue and its subscriber rows are already committed, and an inbox-write
// hiccup must not bubble up as a dispatch failure.
func (s *AutopilotService) notifyAutopilotSubscribersOnCreate(
ctx context.Context,
ap db.Autopilot,
issue db.Issue,
leaderID pgtype.UUID,
subscribers []db.AutopilotSubscriber,
) {
if len(subscribers) == 0 {
return
}
details, _ := json.Marshal(map[string]string{
"autopilot_id": util.UUIDToString(ap.ID),
"reason": "autopilot",
})
for _, sub := range subscribers {
// Autopilot subscribers are restricted to user_type='member' at the
// handler boundary; defend in case that constraint is ever relaxed
// (agents don't have inbox).
if sub.UserType != "member" {
continue
}
item, err := s.Queries.CreateInboxItem(ctx, db.CreateInboxItemParams{
WorkspaceID: ap.WorkspaceID,
RecipientType: "member",
RecipientID: sub.UserID,
Type: "issue_subscribed",
Severity: "info",
IssueID: issue.ID,
Title: issue.Title,
Body: pgtype.Text{},
ActorType: pgtype.Text{String: "agent", Valid: true},
ActorID: leaderID,
Details: details,
})
if err != nil {
slog.Error("autopilot subscriber inbox write failed",
"autopilot_id", util.UUIDToString(ap.ID),
"issue_id", util.UUIDToString(issue.ID),
"recipient_id", util.UUIDToString(sub.UserID),
"error", err,
)
continue
}
s.Bus.Publish(events.Event{
Type: protocol.EventInboxNew,
WorkspaceID: util.UUIDToString(ap.WorkspaceID),
ActorType: "agent",
ActorID: util.UUIDToString(leaderID),
Payload: map[string]any{
"item": map[string]any{
"id": util.UUIDToString(item.ID),
"workspace_id": util.UUIDToString(item.WorkspaceID),
"recipient_type": item.RecipientType,
"recipient_id": util.UUIDToString(item.RecipientID),
"type": item.Type,
"severity": item.Severity,
"issue_id": util.UUIDToPtr(item.IssueID),
"issue_status": issue.Status,
"title": item.Title,
"body": util.TextToPtr(item.Body),
"read": item.Read,
"archived": item.Archived,
"created_at": util.TimestampToString(item.CreatedAt),
"actor_type": util.TextToPtr(item.ActorType),
"actor_id": util.UUIDToPtr(item.ActorID),
"details": json.RawMessage(item.Details),
},
},
})
}
}
// 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"
}