From 13d9d7df1be8a55c73da8e69f1b0c95f38d75259 Mon Sep 17 00:00:00 2001 From: devv-eve Date: Fri, 24 Apr 2026 16:36:04 +0800 Subject: [PATCH] fix: pass autopilot run-only context to agents Fix run-only autopilot tasks so agents receive autopilot context instead of empty issue instructions. Add regression coverage for run-only terminal event sync. --- server/cmd/server/autopilot_listeners_test.go | 122 ++++++++++++++++++ server/internal/daemon/daemon.go | 28 ++-- server/internal/daemon/daemon_test.go | 29 +++++ server/internal/daemon/execenv/context.go | 45 +++++++ server/internal/daemon/execenv/execenv.go | 22 ++-- .../internal/daemon/execenv/execenv_test.go | 82 ++++++++++++ .../internal/daemon/execenv/runtime_config.go | 41 +++++- server/internal/daemon/prompt.go | 39 ++++++ server/internal/daemon/types.go | 36 +++--- server/internal/handler/agent.go | 61 +++++---- server/internal/handler/daemon.go | 29 ++++- 11 files changed, 463 insertions(+), 71 deletions(-) create mode 100644 server/cmd/server/autopilot_listeners_test.go diff --git a/server/cmd/server/autopilot_listeners_test.go b/server/cmd/server/autopilot_listeners_test.go new file mode 100644 index 0000000000..8fb81e93b9 --- /dev/null +++ b/server/cmd/server/autopilot_listeners_test.go @@ -0,0 +1,122 @@ +package main + +import ( + "context" + "strings" + "testing" + + "github.com/jackc/pgx/v5/pgtype" + "github.com/multica-ai/multica/server/internal/events" + "github.com/multica-ai/multica/server/internal/service" + db "github.com/multica-ai/multica/server/pkg/db/generated" +) + +func TestAutopilotRunOnlyTaskTerminalEventsUpdateRun(t *testing.T) { + ctx := context.Background() + queries := db.New(testPool) + bus := events.New() + taskSvc := service.NewTaskService(queries, testPool, nil, bus) + autopilotSvc := service.NewAutopilotService(queries, testPool, bus, taskSvc) + registerAutopilotListeners(bus, autopilotSvc) + + var agentID string + if err := testPool.QueryRow(ctx, + `SELECT id::text FROM agent WHERE workspace_id = $1 ORDER BY created_at ASC LIMIT 1`, + testWorkspaceID, + ).Scan(&agentID); err != nil { + t.Fatalf("load fixture agent: %v", err) + } + + tests := []struct { + name string + finalize func(task db.AgentTaskQueue) + wantStatus string + wantResult string + wantReason string + }{ + { + name: "completed", + finalize: func(task db.AgentTaskQueue) { + if _, err := taskSvc.CompleteTask(ctx, task.ID, []byte(`{"output":"done"}`), "", ""); err != nil { + t.Fatalf("CompleteTask: %v", err) + } + }, + wantStatus: "completed", + wantResult: "done", + }, + { + name: "failed", + finalize: func(task db.AgentTaskQueue) { + if _, err := taskSvc.FailTask(ctx, task.ID, "boom", "", "", "agent_error"); err != nil { + t.Fatalf("FailTask: %v", err) + } + }, + wantStatus: "failed", + wantReason: "boom", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ap, err := queries.CreateAutopilot(ctx, db.CreateAutopilotParams{ + WorkspaceID: parseUUID(testWorkspaceID), + Title: "Run-only listener " + tc.name, + Description: pgtype.Text{String: "Run listener regression test", Valid: true}, + AssigneeID: parseUUID(agentID), + Status: "active", + ExecutionMode: "run_only", + IssueTitleTemplate: pgtype.Text{}, + CreatedByType: "member", + CreatedByID: parseUUID(testUserID), + }) + if err != nil { + t.Fatalf("CreateAutopilot: %v", err) + } + t.Cleanup(func() { + if _, err := testPool.Exec(context.Background(), `DELETE FROM autopilot WHERE id = $1`, ap.ID); err != nil { + t.Logf("cleanup autopilot: %v", err) + } + }) + + run, err := autopilotSvc.DispatchAutopilot(ctx, ap, pgtype.UUID{}, "manual", nil) + if err != nil { + t.Fatalf("DispatchAutopilot: %v", err) + } + if !run.TaskID.Valid { + t.Fatal("run_only dispatch did not link a task") + } + + if _, err := testPool.Exec(ctx, + `UPDATE agent_task_queue SET status = 'dispatched', dispatched_at = now() WHERE id = $1`, + run.TaskID, + ); err != nil { + t.Fatalf("mark task dispatched: %v", err) + } + task, err := queries.StartAgentTask(ctx, run.TaskID) + if err != nil { + t.Fatalf("StartAgentTask: %v", err) + } + + tc.finalize(task) + + updatedRun, err := queries.GetAutopilotRun(ctx, run.ID) + if err != nil { + t.Fatalf("GetAutopilotRun: %v", err) + } + if updatedRun.Status != tc.wantStatus { + t.Fatalf("expected run status %q, got %q", tc.wantStatus, updatedRun.Status) + } + if tc.wantResult != "" && !strings.Contains(string(updatedRun.Result), tc.wantResult) { + t.Fatalf("expected run result to contain %q, got %s", tc.wantResult, string(updatedRun.Result)) + } + if tc.wantReason != "" { + if !updatedRun.FailureReason.Valid { + t.Fatalf("expected failure reason %q, got invalid", tc.wantReason) + } + if updatedRun.FailureReason.String != tc.wantReason { + t.Fatalf("expected failure reason %q, got %q", tc.wantReason, updatedRun.FailureReason.String) + } + } + }) + } +} diff --git a/server/internal/daemon/daemon.go b/server/internal/daemon/daemon.go index caea065b4e..656aa5fdb6 100644 --- a/server/internal/daemon/daemon.go +++ b/server/internal/daemon/daemon.go @@ -1037,14 +1037,20 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, taskLo // Repos are passed as metadata only — the agent checks them out on demand // via `multica repo checkout `. taskCtx := execenv.TaskContextForEnv{ - IssueID: task.IssueID, - TriggerCommentID: task.TriggerCommentID, - AgentID: agentID, - AgentName: agentName, - AgentInstructions: instructions, - AgentSkills: convertSkillsForEnv(skills), - Repos: convertReposForEnv(task.Repos), - ChatSessionID: task.ChatSessionID, + IssueID: task.IssueID, + TriggerCommentID: task.TriggerCommentID, + AgentID: agentID, + AgentName: agentName, + AgentInstructions: instructions, + AgentSkills: convertSkillsForEnv(skills), + Repos: convertReposForEnv(task.Repos), + ChatSessionID: task.ChatSessionID, + AutopilotRunID: task.AutopilotRunID, + AutopilotID: task.AutopilotID, + AutopilotTitle: task.AutopilotTitle, + AutopilotDescription: task.AutopilotDescription, + AutopilotSource: task.AutopilotSource, + AutopilotTriggerPayload: strings.TrimSpace(string(task.AutopilotTriggerPayload)), } // Try to reuse the workdir from a previous task on the same (agent, issue) pair. @@ -1090,6 +1096,12 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, taskLo "MULTICA_AGENT_ID": task.AgentID, "MULTICA_TASK_ID": task.ID, } + if task.AutopilotRunID != "" { + agentEnv["MULTICA_AUTOPILOT_RUN_ID"] = task.AutopilotRunID + } + if task.AutopilotID != "" { + agentEnv["MULTICA_AUTOPILOT_ID"] = task.AutopilotID + } // Ensure the multica CLI is on PATH inside the agent's environment. // Some runtimes (e.g. Codex) run in an isolated sandbox that may not // inherit the daemon's PATH. Prepend the directory of the running diff --git a/server/internal/daemon/daemon_test.go b/server/internal/daemon/daemon_test.go index 6069a41913..eb54f17e0e 100644 --- a/server/internal/daemon/daemon_test.go +++ b/server/internal/daemon/daemon_test.go @@ -98,6 +98,35 @@ func TestBuildPromptNoIssueDetails(t *testing.T) { } } +func TestBuildPromptAutopilotRunOnly(t *testing.T) { + t.Parallel() + + prompt := BuildPrompt(Task{ + AutopilotRunID: "run-1", + AutopilotID: "autopilot-1", + AutopilotTitle: "Daily dependency check", + AutopilotDescription: "Check dependencies and report outdated packages.", + AutopilotSource: "manual", + }) + + for _, want := range []string{ + "run-only mode", + "Autopilot run ID: run-1", + "Daily dependency check", + "Check dependencies and report outdated packages.", + "multica autopilot get autopilot-1 --output json", + "Do not run `multica issue get`", + } { + if !strings.Contains(prompt, want) { + t.Fatalf("autopilot prompt missing %q\n---\n%s", want, prompt) + } + } + + if strings.Contains(prompt, "Your assigned issue ID is:") { + t.Fatalf("autopilot prompt should not use issue assignment template\n---\n%s", prompt) + } +} + func TestBuildPromptCommentTriggered(t *testing.T) { t.Parallel() diff --git a/server/internal/daemon/execenv/context.go b/server/internal/daemon/execenv/context.go index 04a05a4dad..482bcbfa05 100644 --- a/server/internal/daemon/execenv/context.go +++ b/server/internal/daemon/execenv/context.go @@ -132,6 +132,10 @@ func writeSkillFiles(skillsDir string, skills []SkillContextForEnv) error { // renderIssueContext builds the markdown content for issue_context.md. func renderIssueContext(provider string, ctx TaskContextForEnv) string { + if ctx.AutopilotRunID != "" { + return renderAutopilotContext(ctx) + } + var b strings.Builder b.WriteString("# Task Assignment\n\n") @@ -158,3 +162,44 @@ func renderIssueContext(provider string, ctx TaskContextForEnv) string { return b.String() } + +func renderAutopilotContext(ctx TaskContextForEnv) string { + var b strings.Builder + + b.WriteString("# Autopilot Run\n\n") + fmt.Fprintf(&b, "**Autopilot run ID:** %s\n\n", ctx.AutopilotRunID) + if ctx.AutopilotID != "" { + fmt.Fprintf(&b, "**Autopilot ID:** %s\n\n", ctx.AutopilotID) + } + if ctx.AutopilotTitle != "" { + fmt.Fprintf(&b, "**Title:** %s\n\n", ctx.AutopilotTitle) + } + if ctx.AutopilotSource != "" { + fmt.Fprintf(&b, "**Trigger source:** %s\n\n", ctx.AutopilotSource) + } + if ctx.AutopilotTriggerPayload != "" { + fmt.Fprintf(&b, "## Trigger Payload\n\n```json\n%s\n```\n\n", ctx.AutopilotTriggerPayload) + } + + b.WriteString("## Quick Start\n\n") + b.WriteString("This is a run-only autopilot task with no assigned issue. Do not run `multica issue get` unless the autopilot instructions explicitly ask you to create or update an issue.\n\n") + if ctx.AutopilotID != "" { + fmt.Fprintf(&b, "Run `multica autopilot get %s --output json` if you need the full autopilot configuration.\n\n", ctx.AutopilotID) + } + if strings.TrimSpace(ctx.AutopilotDescription) != "" { + b.WriteString("## Autopilot Instructions\n\n") + b.WriteString(ctx.AutopilotDescription) + b.WriteString("\n\n") + } + + if len(ctx.AgentSkills) > 0 { + b.WriteString("## Agent Skills\n\n") + b.WriteString("The following skills are available to you:\n\n") + for _, skill := range ctx.AgentSkills { + fmt.Fprintf(&b, "- **%s**\n", skill.Name) + } + b.WriteString("\n") + } + + return b.String() +} diff --git a/server/internal/daemon/execenv/execenv.go b/server/internal/daemon/execenv/execenv.go index 5d0f037659..13eb177f7a 100644 --- a/server/internal/daemon/execenv/execenv.go +++ b/server/internal/daemon/execenv/execenv.go @@ -31,14 +31,20 @@ type PrepareParams struct { // TaskContextForEnv is the subset of task context used for writing context files. type TaskContextForEnv struct { - IssueID string - TriggerCommentID string // comment that triggered this task (empty for on_assign) - AgentID string // unique ID of the dispatched agent - AgentName string - AgentInstructions string // agent identity/persona instructions, injected into CLAUDE.md - AgentSkills []SkillContextForEnv - Repos []RepoContextForEnv // workspace repos available for checkout - ChatSessionID string // non-empty for chat tasks + IssueID string + TriggerCommentID string // comment that triggered this task (empty for on_assign) + AgentID string // unique ID of the dispatched agent + AgentName string + AgentInstructions string // agent identity/persona instructions, injected into CLAUDE.md + AgentSkills []SkillContextForEnv + Repos []RepoContextForEnv // workspace repos available for checkout + ChatSessionID string // non-empty for chat tasks + AutopilotRunID string // non-empty for autopilot run_only tasks + AutopilotID string + AutopilotTitle string + AutopilotDescription string + AutopilotSource string + AutopilotTriggerPayload string } // SkillContextForEnv represents a skill to be written into the execution environment. diff --git a/server/internal/daemon/execenv/execenv_test.go b/server/internal/daemon/execenv/execenv_test.go index 983751eee6..9810447903 100644 --- a/server/internal/daemon/execenv/execenv_test.go +++ b/server/internal/daemon/execenv/execenv_test.go @@ -267,6 +267,45 @@ func TestWriteContextFilesOmitsSkillsWhenEmpty(t *testing.T) { } } +func TestWriteContextFilesAutopilotRunOnly(t *testing.T) { + t.Parallel() + dir := t.TempDir() + + ctx := TaskContextForEnv{ + AutopilotRunID: "run-1", + AutopilotID: "autopilot-1", + AutopilotTitle: "Daily dependency check", + AutopilotDescription: "Check dependencies and report outdated packages.", + AutopilotSource: "manual", + } + + if err := writeContextFiles(dir, "", ctx); err != nil { + t.Fatalf("writeContextFiles failed: %v", err) + } + + content, err := os.ReadFile(filepath.Join(dir, ".agent_context", "issue_context.md")) + if err != nil { + t.Fatalf("failed to read: %v", err) + } + + s := string(content) + for _, want := range []string{ + "# Autopilot Run", + "run-1", + "autopilot-1", + "Check dependencies and report outdated packages.", + "multica autopilot get autopilot-1 --output json", + "no assigned issue", + } { + if !strings.Contains(s, want) { + t.Errorf("autopilot context missing %q\n---\n%s", want, s) + } + } + if strings.Contains(s, "Run `multica issue get") { + t.Errorf("autopilot context should not contain issue get workflow\n---\n%s", s) + } +} + func TestWriteContextFilesClaudeNativeSkills(t *testing.T) { t.Parallel() dir := t.TempDir() @@ -731,6 +770,49 @@ func TestInjectRuntimeConfigRequiresExplicitCommentPost(t *testing.T) { } } +func TestInjectRuntimeConfigAutopilotRunOnlyNoIssueWorkflow(t *testing.T) { + t.Parallel() + dir := t.TempDir() + + ctx := TaskContextForEnv{ + AutopilotRunID: "run-1", + AutopilotID: "autopilot-1", + AutopilotTitle: "Daily dependency check", + AutopilotDescription: "Check dependencies and report outdated packages.", + AutopilotSource: "manual", + } + + if err := InjectRuntimeConfig(dir, "codex", ctx); err != nil { + t.Fatalf("InjectRuntimeConfig failed: %v", err) + } + data, err := os.ReadFile(filepath.Join(dir, "AGENTS.md")) + if err != nil { + t.Fatalf("read AGENTS.md: %v", err) + } + s := string(data) + + for _, want := range []string{ + "Autopilot in run-only mode", + "Autopilot run ID: `run-1`", + "Check dependencies and report outdated packages.", + "multica autopilot get autopilot-1 --output json", + "Your final assistant output is captured automatically as the autopilot run result", + } { + if !strings.Contains(s, want) { + t.Errorf("autopilot runtime config missing %q\n---\n%s", want, s) + } + } + + for _, absent := range []string{ + "Run `multica issue get", + "Final results MUST be delivered via `multica issue comment add`", + } { + if strings.Contains(s, absent) { + t.Errorf("autopilot runtime config should not contain %q\n---\n%s", absent, s) + } + } +} + func TestInjectRuntimeConfigUnknownProvider(t *testing.T) { t.Parallel() dir := t.TempDir() diff --git a/server/internal/daemon/execenv/runtime_config.go b/server/internal/daemon/execenv/runtime_config.go index d5b4208c78..acfd6ab741 100644 --- a/server/internal/daemon/execenv/runtime_config.go +++ b/server/internal/daemon/execenv/runtime_config.go @@ -124,6 +124,33 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string { b.WriteString("- If asked to perform actions (create issues, update status, etc.), use the appropriate CLI commands\n") b.WriteString("- If the task requires code changes, use `multica repo checkout ` to get the code first\n") b.WriteString("- Keep responses concise and direct\n\n") + } else if ctx.AutopilotRunID != "" { + // Autopilot run_only task: no issue exists, so the agent must not + // follow the assignment/comment workflow. + b.WriteString("**This task was triggered by an Autopilot in run-only mode.** There is no assigned Multica issue for this run.\n\n") + fmt.Fprintf(&b, "- Autopilot run ID: `%s`\n", ctx.AutopilotRunID) + if ctx.AutopilotID != "" { + fmt.Fprintf(&b, "- Autopilot ID: `%s`\n", ctx.AutopilotID) + } + if ctx.AutopilotTitle != "" { + fmt.Fprintf(&b, "- Autopilot title: %s\n", ctx.AutopilotTitle) + } + if ctx.AutopilotSource != "" { + fmt.Fprintf(&b, "- Trigger source: %s\n", ctx.AutopilotSource) + } + if ctx.AutopilotTriggerPayload != "" { + fmt.Fprintf(&b, "- Trigger payload:\n\n```json\n%s\n```\n", ctx.AutopilotTriggerPayload) + } + if strings.TrimSpace(ctx.AutopilotDescription) != "" { + b.WriteString("\nAutopilot instructions:\n\n") + b.WriteString(ctx.AutopilotDescription) + b.WriteString("\n\n") + } + if ctx.AutopilotID != "" { + fmt.Fprintf(&b, "- Run `multica autopilot get %s --output json` if you need the full autopilot configuration\n", ctx.AutopilotID) + } + b.WriteString("- Complete the autopilot instructions directly\n") + b.WriteString("- Do not run `multica issue get`, `multica issue comment add`, or `multica issue status` for this run unless the autopilot instructions explicitly tell you to create or update an issue\n\n") } else if ctx.TriggerCommentID != "" { // Comment-triggered: focus on reading and replying b.WriteString("**This task was triggered by a NEW comment.** Your primary job is to respond to THIS specific comment, even if you have handled similar requests before in this session.\n\n") @@ -201,11 +228,15 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string { b.WriteString("do NOT attempt to work around it. Instead, post a comment mentioning the workspace owner to request the missing functionality.\n\n") b.WriteString("## Output\n\n") - b.WriteString("⚠️ **Final results MUST be delivered via `multica issue comment add`.** The user does NOT see your terminal output, assistant chat text, or run logs — only comments on the issue. A task that finishes without a result comment is invisible to the user, even if the work itself was correct.\n\n") - b.WriteString("Keep comments concise and natural — state the outcome, not the process.\n") - b.WriteString("Good: \"Fixed the login redirect. PR: https://...\"\n") - b.WriteString("Bad: \"1. Read the issue 2. Found the bug in auth.go 3. Created branch 4. ...\"\n") - b.WriteString("When referencing an issue in a comment, use the issue mention format `[MUL-123](mention://issue/)` so it renders as a clickable link. (Issue mentions have no side effect; only member/agent mentions do — see the Mentions section above.)\n") + if ctx.AutopilotRunID != "" { + b.WriteString("This is a run-only autopilot task, so there may be no issue comment to post. Your final assistant output is captured automatically as the autopilot run result. Keep it concise and state the outcome.\n") + } else { + b.WriteString("⚠️ **Final results MUST be delivered via `multica issue comment add`.** The user does NOT see your terminal output, assistant chat text, or run logs — only comments on the issue. A task that finishes without a result comment is invisible to the user, even if the work itself was correct.\n\n") + b.WriteString("Keep comments concise and natural — state the outcome, not the process.\n") + b.WriteString("Good: \"Fixed the login redirect. PR: https://...\"\n") + b.WriteString("Bad: \"1. Read the issue 2. Found the bug in auth.go 3. Created branch 4. ...\"\n") + b.WriteString("When referencing an issue in a comment, use the issue mention format `[MUL-123](mention://issue/)` so it renders as a clickable link. (Issue mentions have no side effect; only member/agent mentions do — see the Mentions section above.)\n") + } return b.String() } diff --git a/server/internal/daemon/prompt.go b/server/internal/daemon/prompt.go index f3fd537eec..e56a9db37b 100644 --- a/server/internal/daemon/prompt.go +++ b/server/internal/daemon/prompt.go @@ -17,6 +17,9 @@ func BuildPrompt(task Task) string { if task.TriggerCommentID != "" { return buildCommentPrompt(task) } + if task.AutopilotRunID != "" { + return buildAutopilotPrompt(task) + } var b strings.Builder b.WriteString("You are running as a local coding agent for a Multica workspace.\n\n") fmt.Fprintf(&b, "Your assigned issue ID is: %s\n\n", task.IssueID) @@ -62,3 +65,39 @@ func buildChatPrompt(task Task) string { fmt.Fprintf(&b, "User message:\n%s\n", task.ChatMessage) return b.String() } + +// buildAutopilotPrompt constructs a prompt for run_only autopilot tasks. +func buildAutopilotPrompt(task Task) string { + var b strings.Builder + b.WriteString("You are running as a local coding agent for a Multica workspace.\n\n") + b.WriteString("This task was triggered by an Autopilot in run-only mode. There is no assigned Multica issue for this run.\n\n") + fmt.Fprintf(&b, "Autopilot run ID: %s\n", task.AutopilotRunID) + if task.AutopilotID != "" { + fmt.Fprintf(&b, "Autopilot ID: %s\n", task.AutopilotID) + } + if task.AutopilotTitle != "" { + fmt.Fprintf(&b, "Autopilot title: %s\n", task.AutopilotTitle) + } + if task.AutopilotSource != "" { + fmt.Fprintf(&b, "Trigger source: %s\n", task.AutopilotSource) + } + if strings.TrimSpace(string(task.AutopilotTriggerPayload)) != "" { + fmt.Fprintf(&b, "Trigger payload:\n%s\n", strings.TrimSpace(string(task.AutopilotTriggerPayload))) + } + b.WriteString("\nAutopilot instructions:\n") + if strings.TrimSpace(task.AutopilotDescription) != "" { + b.WriteString(task.AutopilotDescription) + b.WriteString("\n\n") + } else if task.AutopilotTitle != "" { + fmt.Fprintf(&b, "%s\n\n", task.AutopilotTitle) + } else { + b.WriteString("No additional autopilot instructions were provided. Inspect the autopilot configuration before proceeding.\n\n") + } + if task.AutopilotID != "" { + fmt.Fprintf(&b, "Start by running `multica autopilot get %s --output json` if you need the full autopilot configuration, then complete the instructions above.\n", task.AutopilotID) + } else { + b.WriteString("Complete the instructions above.\n") + } + b.WriteString("Do not run `multica issue get`; this run does not have an issue ID.\n") + return b.String() +} diff --git a/server/internal/daemon/types.go b/server/internal/daemon/types.go index dca6336f02..dba4a9f0ec 100644 --- a/server/internal/daemon/types.go +++ b/server/internal/daemon/types.go @@ -25,21 +25,27 @@ type RepoData struct { // Task represents a claimed task from the server. // Agent data (name, skills) is populated by the claim endpoint. type Task struct { - ID string `json:"id"` - AgentID string `json:"agent_id"` - RuntimeID string `json:"runtime_id"` - IssueID string `json:"issue_id"` - WorkspaceID string `json:"workspace_id"` - Agent *AgentData `json:"agent,omitempty"` - Repos []RepoData `json:"repos,omitempty"` - PriorSessionID string `json:"prior_session_id,omitempty"` // Claude session ID from a previous task on this issue - PriorWorkDir string `json:"prior_work_dir,omitempty"` // work_dir from a previous task on this issue - TriggerCommentID string `json:"trigger_comment_id,omitempty"` // comment that triggered this task - TriggerCommentContent string `json:"trigger_comment_content,omitempty"` // content of the triggering comment - TriggerAuthorType string `json:"trigger_author_type,omitempty"` // "agent" or "member" — author kind for the triggering comment - TriggerAuthorName string `json:"trigger_author_name,omitempty"` // display name of the triggering comment author - ChatSessionID string `json:"chat_session_id,omitempty"` // non-empty for chat tasks - ChatMessage string `json:"chat_message,omitempty"` // user message content for chat tasks + ID string `json:"id"` + AgentID string `json:"agent_id"` + RuntimeID string `json:"runtime_id"` + IssueID string `json:"issue_id"` + WorkspaceID string `json:"workspace_id"` + Agent *AgentData `json:"agent,omitempty"` + Repos []RepoData `json:"repos,omitempty"` + PriorSessionID string `json:"prior_session_id,omitempty"` // Claude session ID from a previous task on this issue + PriorWorkDir string `json:"prior_work_dir,omitempty"` // work_dir from a previous task on this issue + TriggerCommentID string `json:"trigger_comment_id,omitempty"` // comment that triggered this task + TriggerCommentContent string `json:"trigger_comment_content,omitempty"` // content of the triggering comment + TriggerAuthorType string `json:"trigger_author_type,omitempty"` // "agent" or "member" — author kind for the triggering comment + TriggerAuthorName string `json:"trigger_author_name,omitempty"` // display name of the triggering comment author + ChatSessionID string `json:"chat_session_id,omitempty"` // non-empty for chat tasks + ChatMessage string `json:"chat_message,omitempty"` // user message content for chat tasks + AutopilotRunID string `json:"autopilot_run_id,omitempty"` // non-empty for autopilot run_only tasks + AutopilotID string `json:"autopilot_id,omitempty"` // autopilot that spawned this run + AutopilotTitle string `json:"autopilot_title,omitempty"` // autopilot title used as task context + AutopilotDescription string `json:"autopilot_description,omitempty"` // autopilot description used as task prompt + AutopilotSource string `json:"autopilot_source,omitempty"` // manual, schedule, webhook, or api + AutopilotTriggerPayload json.RawMessage `json:"autopilot_trigger_payload,omitempty"` // optional trigger payload for webhook/api runs } // AgentData holds agent details returned by the claim endpoint. diff --git a/server/internal/handler/agent.go b/server/internal/handler/agent.go index cc1c837d14..d96f737041 100644 --- a/server/internal/handler/agent.go +++ b/server/internal/handler/agent.go @@ -114,34 +114,39 @@ type RepoData struct { } type AgentTaskResponse struct { - ID string `json:"id"` - AgentID string `json:"agent_id"` - RuntimeID string `json:"runtime_id"` - IssueID string `json:"issue_id"` - WorkspaceID string `json:"workspace_id"` - Status string `json:"status"` - Priority int32 `json:"priority"` - DispatchedAt *string `json:"dispatched_at"` - StartedAt *string `json:"started_at"` - CompletedAt *string `json:"completed_at"` - Result any `json:"result"` - Error *string `json:"error"` - FailureReason string `json:"failure_reason,omitempty"` // see TaskService.MaybeRetryFailedTask - Attempt int32 `json:"attempt"` - MaxAttempts int32 `json:"max_attempts"` - ParentTaskID *string `json:"parent_task_id,omitempty"` - Agent *TaskAgentData `json:"agent,omitempty"` - Repos []RepoData `json:"repos,omitempty"` - CreatedAt string `json:"created_at"` - PriorSessionID string `json:"prior_session_id,omitempty"` // session ID from a previous task on same issue - PriorWorkDir string `json:"prior_work_dir,omitempty"` // work_dir from a previous task on same issue - TriggerCommentID *string `json:"trigger_comment_id,omitempty"` // comment that triggered this task - TriggerCommentContent string `json:"trigger_comment_content,omitempty"` // content of the triggering comment - TriggerAuthorType string `json:"trigger_author_type,omitempty"` // "agent" or "member" — author kind of the triggering comment - TriggerAuthorName string `json:"trigger_author_name,omitempty"` // display name of the triggering comment author - ChatSessionID string `json:"chat_session_id,omitempty"` // non-empty for chat tasks - ChatMessage string `json:"chat_message,omitempty"` // user message for chat tasks - AutopilotRunID string `json:"autopilot_run_id,omitempty"` // non-empty for autopilot-spawned tasks + ID string `json:"id"` + AgentID string `json:"agent_id"` + RuntimeID string `json:"runtime_id"` + IssueID string `json:"issue_id"` + WorkspaceID string `json:"workspace_id"` + Status string `json:"status"` + Priority int32 `json:"priority"` + DispatchedAt *string `json:"dispatched_at"` + StartedAt *string `json:"started_at"` + CompletedAt *string `json:"completed_at"` + Result any `json:"result"` + Error *string `json:"error"` + FailureReason string `json:"failure_reason,omitempty"` // see TaskService.MaybeRetryFailedTask + Attempt int32 `json:"attempt"` + MaxAttempts int32 `json:"max_attempts"` + ParentTaskID *string `json:"parent_task_id,omitempty"` + Agent *TaskAgentData `json:"agent,omitempty"` + Repos []RepoData `json:"repos,omitempty"` + CreatedAt string `json:"created_at"` + PriorSessionID string `json:"prior_session_id,omitempty"` // session ID from a previous task on same issue + PriorWorkDir string `json:"prior_work_dir,omitempty"` // work_dir from a previous task on same issue + TriggerCommentID *string `json:"trigger_comment_id,omitempty"` // comment that triggered this task + TriggerCommentContent string `json:"trigger_comment_content,omitempty"` // content of the triggering comment + TriggerAuthorType string `json:"trigger_author_type,omitempty"` // "agent" or "member" — author kind of the triggering comment + TriggerAuthorName string `json:"trigger_author_name,omitempty"` // display name of the triggering comment author + ChatSessionID string `json:"chat_session_id,omitempty"` // non-empty for chat tasks + ChatMessage string `json:"chat_message,omitempty"` // user message for chat tasks + AutopilotRunID string `json:"autopilot_run_id,omitempty"` // non-empty for autopilot-spawned tasks + AutopilotID string `json:"autopilot_id,omitempty"` // autopilot that spawned this task + AutopilotTitle string `json:"autopilot_title,omitempty"` // autopilot title used as task context + AutopilotDescription string `json:"autopilot_description,omitempty"` // autopilot description used as task prompt + AutopilotSource string `json:"autopilot_source,omitempty"` // manual, schedule, webhook, or api + AutopilotTriggerPayload json.RawMessage `json:"autopilot_trigger_payload,omitempty"` // optional trigger payload for webhook/api runs } // TaskAgentData holds agent info included in claim responses so the daemon diff --git a/server/internal/handler/daemon.go b/server/internal/handler/daemon.go index 3dab5c4bc1..88bcaee184 100644 --- a/server/internal/handler/daemon.go +++ b/server/internal/handler/daemon.go @@ -744,15 +744,30 @@ func (h *Handler) ClaimTaskByRuntime(w http.ResponseWriter, r *http.Request) { } } - // Autopilot run_only task: resolve workspace from autopilot_run → autopilot. - if task.AutopilotRunID.Valid && resp.WorkspaceID == "" { + // Autopilot run_only task: resolve workspace from autopilot_run → + // autopilot, and include the autopilot instructions because there is no + // issue for the agent to fetch. + if task.AutopilotRunID.Valid { if run, err := h.Queries.GetAutopilotRun(r.Context(), task.AutopilotRunID); err == nil { + resp.AutopilotID = uuidToString(run.AutopilotID) + resp.AutopilotSource = run.Source + if run.TriggerPayload != nil { + resp.AutopilotTriggerPayload = json.RawMessage(run.TriggerPayload) + } if ap, err := h.Queries.GetAutopilot(r.Context(), run.AutopilotID); err == nil { - resp.WorkspaceID = uuidToString(ap.WorkspaceID) - if ws, err := h.Queries.GetWorkspace(r.Context(), ap.WorkspaceID); err == nil && ws.Repos != nil { - var repos []RepoData - if json.Unmarshal(ws.Repos, &repos) == nil && len(repos) > 0 { - resp.Repos = repos + resp.AutopilotTitle = ap.Title + if ap.Description.Valid { + resp.AutopilotDescription = ap.Description.String + } + if resp.WorkspaceID == "" { + resp.WorkspaceID = uuidToString(ap.WorkspaceID) + } + if len(resp.Repos) == 0 { + if ws, err := h.Queries.GetWorkspace(r.Context(), ap.WorkspaceID); err == nil && ws.Repos != nil { + var repos []RepoData + if json.Unmarshal(ws.Repos, &repos) == nil && len(repos) > 0 { + resp.Repos = repos + } } } }