From 1deae2a1e95fd32bb16c86319a03b361370bc993 Mon Sep 17 00:00:00 2001 From: yushen Date: Fri, 27 Mar 2026 15:31:22 +0800 Subject: [PATCH] refactor(daemon): remove context snapshot, let agent fetch data via CLI Replace the frozen context snapshot pattern with a CLI-driven approach: agents now use `multica` CLI commands to fetch issue details, comments, and workspace context on demand, always getting the latest data. - Remove buildContextSnapshot and snapshot generation from enqueue - Claim endpoint now returns fresh agent name + skills from DB - Daemon resolves provider from local runtimeIndex, not snapshot - Prompt instructs agent to use `multica issue get` / `comment list` - Meta skill (CLAUDE.md/AGENTS.md) documents all available CLI commands - Skills still injected as filesystem files (static agent config) - Simplify daemon types: remove TaskContext/IssueContext/RuntimeContext Co-Authored-By: Claude Opus 4.6 (1M context) --- server/internal/daemon/client.go | 5 + server/internal/daemon/daemon.go | 32 ++++--- server/internal/daemon/daemon_test.go | 47 ++++------ server/internal/daemon/execenv/context.go | 21 ++--- server/internal/daemon/execenv/execenv.go | 8 +- .../internal/daemon/execenv/execenv_test.go | 60 ++++++------ .../internal/daemon/execenv/runtime_config.go | 31 ++++--- server/internal/daemon/prompt.go | 25 +++-- server/internal/daemon/types.go | 40 ++------ server/internal/handler/agent.go | 40 ++++---- server/internal/handler/daemon.go | 14 ++- server/internal/service/task.go | 91 ++++--------------- 12 files changed, 176 insertions(+), 238 deletions(-) diff --git a/server/internal/daemon/client.go b/server/internal/daemon/client.go index ccf50f81..a62b65b2 100644 --- a/server/internal/daemon/client.go +++ b/server/internal/daemon/client.go @@ -56,6 +56,11 @@ func (c *Client) SetToken(token string) { c.token = token } +// Token returns the current auth token. +func (c *Client) Token() string { + return c.token +} + func (c *Client) ClaimTask(ctx context.Context, runtimeID string) (*Task, error) { var resp struct { Task *Task `json:"task"` diff --git a/server/internal/daemon/daemon.go b/server/internal/daemon/daemon.go index dbd2325e..e3130bde 100644 --- a/server/internal/daemon/daemon.go +++ b/server/internal/daemon/daemon.go @@ -482,7 +482,7 @@ func (d *Daemon) pollLoop(ctx context.Context) error { continue } if task != nil { - d.logger.Info("task received", "task_id", task.ID, "issue_id", task.IssueID, "title", task.Context.Issue.Title) + d.logger.Info("task received", "task_id", task.ID, "issue_id", task.IssueID) d.handleTask(ctx, *task) claimed = true pollOffset = (pollOffset + i + 1) % n @@ -506,8 +506,11 @@ func (d *Daemon) pollLoop(ctx context.Context) error { } func (d *Daemon) handleTask(ctx context.Context, task Task) { - provider := task.Context.Runtime.Provider - d.logger.Info("picked task", "task_id", task.ID, "issue_id", task.IssueID, "provider", provider, "title", task.Context.Issue.Title) + d.mu.Lock() + rt := d.runtimeIndex[task.RuntimeID] + d.mu.Unlock() + provider := rt.Provider + d.logger.Info("picked task", "task_id", task.ID, "issue_id", task.IssueID, "provider", provider) if err := d.client.StartTask(ctx, task.ID); err != nil { d.logger.Error("start task failed", "task_id", task.ID, "error", err) @@ -516,7 +519,7 @@ func (d *Daemon) handleTask(ctx context.Context, task Task) { _ = d.client.ReportProgress(ctx, task.ID, fmt.Sprintf("Launching %s", provider), 1, 2) - result, err := d.runTask(ctx, task) + result, err := d.runTask(ctx, task, provider) if err != nil { d.logger.Error("task failed", "task_id", task.ID, "error", err) if failErr := d.client.FailTask(ctx, task.ID, err.Error()); failErr != nil { @@ -540,26 +543,29 @@ func (d *Daemon) handleTask(ctx context.Context, task Task) { } } -func (d *Daemon) runTask(ctx context.Context, task Task) (TaskResult, error) { - provider := task.Context.Runtime.Provider +func (d *Daemon) runTask(ctx context.Context, task Task, provider string) (TaskResult, error) { entry, ok := d.cfg.Agents[provider] if !ok { return TaskResult{}, fmt.Errorf("no agent configured for provider %q", provider) } + agentName := "agent" + var skills []SkillData + if task.Agent != nil { + agentName = task.Agent.Name + skills = task.Agent.Skills + } + // Prepare isolated execution environment. taskCtx := execenv.TaskContextForEnv{ - IssueTitle: task.Context.Issue.Title, - IssueDescription: task.Context.Issue.Description, - WorkspaceContext: task.Context.WorkspaceContext, - AgentName: task.Context.Agent.Name, - AgentSkills: convertSkillsForEnv(task.Context.Agent.Skills), + IssueID: task.IssueID, + AgentName: agentName, + AgentSkills: convertSkillsForEnv(skills), } env, err := execenv.Prepare(execenv.PrepareParams{ WorkspacesRoot: d.cfg.WorkspacesRoot, - RepoPath: task.Context.RepoPath, TaskID: task.ID, - AgentName: task.Context.Agent.Name, + AgentName: agentName, Task: taskCtx, }, d.logger) if err != nil { diff --git a/server/internal/daemon/daemon_test.go b/server/internal/daemon/daemon_test.go index 0249a19d..5783c58a 100644 --- a/server/internal/daemon/daemon_test.go +++ b/server/internal/daemon/daemon_test.go @@ -18,28 +18,25 @@ func TestNormalizeServerBaseURL(t *testing.T) { } } -func TestBuildPromptIncludesIssueAndContext(t *testing.T) { +func TestBuildPromptContainsIssueID(t *testing.T) { t.Parallel() + issueID := "a1b2c3d4-e5f6-7890-abcd-ef1234567890" prompt := BuildPrompt(Task{ - Context: TaskContext{ - Issue: IssueContext{ - Title: "Fix failing test", - Description: "Investigate and fix the test failure.", - }, - Agent: AgentContext{ - Name: "Local Codex", - Skills: []SkillData{ - {Name: "Concise", Content: "Be concise."}, - }, + IssueID: issueID, + Agent: &AgentData{ + Name: "Local Codex", + Skills: []SkillData{ + {Name: "Concise", Content: "Be concise."}, }, }, }) - // Lean prompt: issue title + description only. No inlined skill content. + // Prompt should contain the issue ID and CLI instructions. for _, want := range []string{ - "Fix failing test", - "Investigate and fix the test failure.", + issueID, + "multica issue get", + "multica issue comment list", } { if !strings.Contains(prompt, want) { t.Fatalf("prompt missing %q", want) @@ -54,25 +51,19 @@ func TestBuildPromptIncludesIssueAndContext(t *testing.T) { } } -func TestBuildPromptTruncatesLongDescription(t *testing.T) { +func TestBuildPromptNoIssueDetails(t *testing.T) { t.Parallel() - longDesc := strings.Repeat("x", 300) prompt := BuildPrompt(Task{ - Context: TaskContext{ - Issue: IssueContext{ - Title: "Long desc", - Description: longDesc, - }, - Agent: AgentContext{Name: "Test"}, - }, + IssueID: "test-id", + Agent: &AgentData{Name: "Test"}, }) - if strings.Contains(prompt, longDesc) { - t.Fatal("expected long description to be truncated in prompt") - } - if !strings.Contains(prompt, "...") { - t.Fatal("expected truncation marker") + // Prompt should not contain issue title/description (agent fetches via CLI). + for _, absent := range []string{"**Issue:**", "**Summary:**"} { + if strings.Contains(prompt, absent) { + t.Fatalf("prompt should NOT contain %q — agent fetches details via CLI", absent) + } } } diff --git a/server/internal/daemon/execenv/context.go b/server/internal/daemon/execenv/context.go index e3f1f2a8..35e59669 100644 --- a/server/internal/daemon/execenv/context.go +++ b/server/internal/daemon/execenv/context.go @@ -77,25 +77,16 @@ func writeSkillFiles(contextDir string, skills []SkillContextForEnv) error { } // renderIssueContext builds the markdown content for issue_context.md. -// Sections with empty content are omitted. +// It contains only the issue ID and pointers to CLI commands for fetching +// dynamic data. Sections with empty content are omitted. func renderIssueContext(ctx TaskContextForEnv) string { var b strings.Builder - if ctx.IssueTitle != "" { - fmt.Fprintf(&b, "# Issue: %s\n\n", ctx.IssueTitle) - } + b.WriteString("# Task Assignment\n\n") + fmt.Fprintf(&b, "**Issue ID:** %s\n\n", ctx.IssueID) - if ctx.IssueDescription != "" { - b.WriteString("## Description\n\n") - b.WriteString(ctx.IssueDescription) - b.WriteString("\n\n") - } - - if ctx.WorkspaceContext != "" { - b.WriteString("## Workspace Context\n\n") - b.WriteString(ctx.WorkspaceContext) - b.WriteString("\n\n") - } + b.WriteString("Run `multica issue get " + ctx.IssueID + " --output json` for full issue details and description.\n") + b.WriteString("Run `multica issue comment list " + ctx.IssueID + "` for discussion history.\n\n") if len(ctx.AgentSkills) > 0 { b.WriteString("## Agent Skills\n\n") diff --git a/server/internal/daemon/execenv/execenv.go b/server/internal/daemon/execenv/execenv.go index ea8536ef..61e9d941 100644 --- a/server/internal/daemon/execenv/execenv.go +++ b/server/internal/daemon/execenv/execenv.go @@ -29,11 +29,9 @@ type PrepareParams struct { // TaskContextForEnv is the subset of task context used for writing context files. type TaskContextForEnv struct { - IssueTitle string - IssueDescription string - WorkspaceContext string - AgentName string - AgentSkills []SkillContextForEnv + IssueID string + AgentName string + AgentSkills []SkillContextForEnv } // 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 5a3f9ce0..f1a19815 100644 --- a/server/internal/daemon/execenv/execenv_test.go +++ b/server/internal/daemon/execenv/execenv_test.go @@ -100,8 +100,7 @@ func TestPrepareDirectoryMode(t *testing.T) { TaskID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", AgentName: "Test Agent", Task: TaskContextForEnv{ - IssueTitle: "Fix the bug", - IssueDescription: "There is a bug in the login flow.", + IssueID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", AgentSkills: []SkillContextForEnv{ {Name: "Code Review", Content: "Be concise."}, }, @@ -127,12 +126,12 @@ func TestPrepareDirectoryMode(t *testing.T) { } } - // Verify context file. + // Verify context file contains issue ID and CLI hints. content, err := os.ReadFile(filepath.Join(env.WorkDir, ".agent_context", "issue_context.md")) if err != nil { t.Fatalf("failed to read issue_context.md: %v", err) } - for _, want := range []string{"Fix the bug", "login flow", "Code Review"} { + for _, want := range []string{"a1b2c3d4-e5f6-7890-abcd-ef1234567890", "multica issue get", "Code Review"} { if !strings.Contains(string(content), want) { t.Fatalf("issue_context.md missing %q", want) } @@ -176,8 +175,7 @@ func TestPrepareGitWorktreeMode(t *testing.T) { TaskID: "b2c3d4e5-f6a7-8901-bcde-f12345678901", AgentName: "Code Reviewer", Task: TaskContextForEnv{ - IssueTitle: "Add feature", - IssueDescription: "Add a new feature.", + IssueID: "b2c3d4e5-f6a7-8901-bcde-f12345678901", }, }, testLogger()) if err != nil { @@ -216,9 +214,7 @@ func TestWriteContextFiles(t *testing.T) { dir := t.TempDir() ctx := TaskContextForEnv{ - IssueTitle: "Test Issue", - IssueDescription: "A detailed description.", - WorkspaceContext: "We use Go and TypeScript.", + IssueID: "test-issue-id-1234", AgentSkills: []SkillContextForEnv{ { Name: "Go Conventions", @@ -241,11 +237,8 @@ func TestWriteContextFiles(t *testing.T) { s := string(content) for _, want := range []string{ - "# Issue: Test Issue", - "## Description", - "A detailed description.", - "## Workspace Context", - "Go and TypeScript", + "test-issue-id-1234", + "multica issue get", "## Agent Skills", "Go Conventions", } { @@ -254,6 +247,13 @@ func TestWriteContextFiles(t *testing.T) { } } + // Issue details should NOT be in the context file (agent fetches via CLI). + for _, absent := range []string{"## Description", "## Workspace Context"} { + if strings.Contains(s, absent) { + t.Errorf("content should NOT contain %q — agent fetches details via CLI", absent) + } + } + // Verify skill directory and files. skillMd, err := os.ReadFile(filepath.Join(dir, ".agent_context", "skills", "go-conventions", "SKILL.md")) if err != nil { @@ -272,12 +272,12 @@ func TestWriteContextFiles(t *testing.T) { } } -func TestWriteContextFilesOmitsEmpty(t *testing.T) { +func TestWriteContextFilesOmitsSkillsWhenEmpty(t *testing.T) { t.Parallel() dir := t.TempDir() ctx := TaskContextForEnv{ - IssueTitle: "Minimal Issue", + IssueID: "minimal-issue-id", } if err := writeContextFiles(dir, ctx); err != nil { @@ -290,13 +290,11 @@ func TestWriteContextFilesOmitsEmpty(t *testing.T) { } s := string(content) - if !strings.Contains(s, "Minimal Issue") { - t.Error("expected title to be present") + if !strings.Contains(s, "minimal-issue-id") { + t.Error("expected issue ID to be present") } - for _, absent := range []string{"## Description", "## Workspace Context", "## Agent Skills"} { - if strings.Contains(s, absent) { - t.Errorf("expected %q to be omitted for empty content", absent) - } + if strings.Contains(s, "## Agent Skills") { + t.Error("expected skills section to be omitted when no skills") } } @@ -326,7 +324,7 @@ func TestCleanupGitWorktree(t *testing.T) { RepoPath: reposRoot, TaskID: "c3d4e5f6-a7b8-9012-cdef-123456789012", AgentName: "Cleanup Test", - Task: TaskContextForEnv{IssueTitle: "Cleanup test"}, + Task: TaskContextForEnv{IssueID: "cleanup-test-id"}, }, testLogger()) if err != nil { t.Fatalf("Prepare failed: %v", err) @@ -358,7 +356,7 @@ func TestInjectRuntimeConfigClaude(t *testing.T) { dir := t.TempDir() ctx := TaskContextForEnv{ - IssueTitle: "Test Issue", + IssueID: "test-issue-id", AgentSkills: []SkillContextForEnv{ {Name: "Go Conventions", Content: "Follow Go conventions.", Files: []SkillFileContextForEnv{ {Path: "example.go", Content: "package main"}, @@ -379,8 +377,8 @@ func TestInjectRuntimeConfigClaude(t *testing.T) { s := string(content) for _, want := range []string{ "Multica Agent Runtime", - ".agent_context/issue_context.md", - ".agent_context/skills/", + "multica issue get", + "multica issue comment list", "Go Conventions", "PR Review", "go-conventions/SKILL.md", @@ -398,7 +396,7 @@ func TestInjectRuntimeConfigCodex(t *testing.T) { dir := t.TempDir() ctx := TaskContextForEnv{ - IssueTitle: "Test Issue", + IssueID: "test-issue-id", AgentSkills: []SkillContextForEnv{{Name: "Coding", Content: "Write good code."}}, } @@ -424,7 +422,7 @@ func TestInjectRuntimeConfigNoSkills(t *testing.T) { t.Parallel() dir := t.TempDir() - ctx := TaskContextForEnv{IssueTitle: "Test Issue"} + ctx := TaskContextForEnv{IssueID: "test-issue-id"} if err := InjectRuntimeConfig(dir, "claude", ctx); err != nil { t.Fatalf("InjectRuntimeConfig failed: %v", err) @@ -436,8 +434,8 @@ func TestInjectRuntimeConfigNoSkills(t *testing.T) { } s := string(content) - if !strings.Contains(s, "issue_context.md") { - t.Error("should reference issue_context.md even without skills") + if !strings.Contains(s, "multica issue get") { + t.Error("should reference multica CLI even without skills") } if strings.Contains(s, "## Skills") { t.Error("should not have Skills section when there are no skills") @@ -469,7 +467,7 @@ func TestCleanupPreservesLogs(t *testing.T) { RepoPath: t.TempDir(), // not a git repo TaskID: "d4e5f6a7-b8c9-0123-defa-234567890123", AgentName: "Preserve Test", - Task: TaskContextForEnv{IssueTitle: "Preserve test"}, + Task: TaskContextForEnv{IssueID: "preserve-test-id"}, }, testLogger()) if err != nil { t.Fatalf("Prepare failed: %v", err) diff --git a/server/internal/daemon/execenv/runtime_config.go b/server/internal/daemon/execenv/runtime_config.go index ef942e4e..9362b413 100644 --- a/server/internal/daemon/execenv/runtime_config.go +++ b/server/internal/daemon/execenv/runtime_config.go @@ -31,26 +31,35 @@ func InjectRuntimeConfig(workDir, provider string, ctx TaskContextForEnv) error } // buildMetaSkillContent generates the meta skill markdown that teaches the agent -// about the Multica runtime environment and where to find task context/skills. +// about the Multica runtime environment and available CLI tools. func buildMetaSkillContent(ctx TaskContextForEnv) string { var b strings.Builder b.WriteString("# Multica Agent Runtime\n\n") - b.WriteString("You are running as a coding agent in the Multica platform.\n") - b.WriteString("Your task context and skill instructions are in the `.agent_context/` directory.\n\n") + b.WriteString("You are a coding agent in the Multica platform. Use the `multica` CLI to interact with the platform.\n\n") - b.WriteString("## Getting Started\n\n") - b.WriteString("1. Read `.agent_context/issue_context.md` for the full issue description, acceptance criteria, and context.\n") + b.WriteString("## Available Commands\n\n") + b.WriteString("### Read\n") + b.WriteString("- `multica issue get ` — Get full issue details (title, description, status, priority, assignee)\n") + b.WriteString("- `multica issue list [--status X] [--priority X] [--assignee X]` — List issues in workspace\n") + b.WriteString("- `multica issue comment list ` — List all comments on an issue\n") + b.WriteString("- `multica workspace get` — Get workspace details and context\n") + b.WriteString("- `multica agent list` — List agents in workspace\n\n") - if len(ctx.AgentSkills) > 0 { - b.WriteString("2. Read your skill files in `.agent_context/skills/` for detailed instructions on how to work.\n") - } + b.WriteString("### Write\n") + b.WriteString("- `multica issue comment add --content \"...\"` — Post a comment to an issue\n") + b.WriteString("- `multica issue status ` — Update issue status (todo, in_progress, in_review, done, blocked)\n") + b.WriteString("- `multica issue update [--title X] [--description X] [--priority X]` — Update issue fields\n\n") - b.WriteString("\n") + b.WriteString("### Workflow\n") + fmt.Fprintf(&b, "1. Run `multica issue get %s --output json` to understand your task\n", ctx.IssueID) + b.WriteString("2. Read comments for additional context or human instructions\n") + b.WriteString("3. Complete the work in the local codebase\n") + b.WriteString("4. Post a comment summarizing what you did\n\n") if len(ctx.AgentSkills) > 0 { b.WriteString("## Skills\n\n") - b.WriteString("Each skill directory contains a `SKILL.md` with instructions and optionally supporting files.\n\n") + b.WriteString("Detailed skill instructions are in `.agent_context/skills/`. Each subdirectory contains a `SKILL.md`.\n\n") for _, skill := range ctx.AgentSkills { dirName := sanitizeSkillName(skill.Name) fmt.Fprintf(&b, "- **%s** → `.agent_context/skills/%s/SKILL.md`", skill.Name, dirName) @@ -63,7 +72,7 @@ func buildMetaSkillContent(ctx TaskContextForEnv) string { } b.WriteString("## Output\n\n") - b.WriteString("When done, return a concise Markdown comment suitable for posting back to the issue.\n") + b.WriteString("When done, return a concise Markdown summary of your work.\n") b.WriteString("- Lead with the outcome.\n") b.WriteString("- Mention concrete files or commands if you changed anything.\n") b.WriteString("- If blocked, explain the blocker clearly.\n") diff --git a/server/internal/daemon/prompt.go b/server/internal/daemon/prompt.go index c151e6d7..47cb33ab 100644 --- a/server/internal/daemon/prompt.go +++ b/server/internal/daemon/prompt.go @@ -6,24 +6,21 @@ import ( ) // BuildPrompt constructs the task prompt for an agent CLI. -// This is kept lean — only the issue summary and acceptance criteria. -// Detailed skill instructions are injected via the runtime's native config -// mechanism (e.g., .claude/CLAUDE.md, AGENTS.md) by execenv.InjectRuntimeConfig. +// The prompt is intentionally minimal — it provides only the issue ID and +// instructs the agent to use the multica CLI to fetch details on demand. +// Skill instructions are injected via the runtime's native config mechanism +// (e.g., .claude/CLAUDE.md, AGENTS.md) by execenv.InjectRuntimeConfig. func BuildPrompt(task Task) string { var b strings.Builder - b.WriteString("You are running as a local coding agent for a Multica workspace.\n") - b.WriteString("Complete the assigned issue using the local environment.\n\n") + b.WriteString("You are running as a local coding agent for a Multica workspace.\n\n") - fmt.Fprintf(&b, "**Issue:** %s\n", task.Context.Issue.Title) - fmt.Fprintf(&b, "**Agent:** %s\n\n", task.Context.Agent.Name) + fmt.Fprintf(&b, "Your assigned issue ID is: %s\n\n", task.IssueID) - if task.Context.Issue.Description != "" { - desc := task.Context.Issue.Description - if len(desc) > 200 { - desc = desc[:200] + "..." - } - fmt.Fprintf(&b, "**Summary:** %s\n\n", desc) - } + b.WriteString("Use the `multica` CLI to fetch the issue details and any context you need:\n\n") + fmt.Fprintf(&b, " multica issue get %s --output json # Full issue details\n", task.IssueID) + fmt.Fprintf(&b, " multica issue comment list %s # Comments and discussion\n\n", task.IssueID) + + fmt.Fprintf(&b, "Start by running `multica issue get %s --output json` to understand your task, then complete it.\n", task.IssueID) return b.String() } diff --git a/server/internal/daemon/types.go b/server/internal/daemon/types.go index ae6fedf8..e8430846 100644 --- a/server/internal/daemon/types.go +++ b/server/internal/daemon/types.go @@ -15,37 +15,23 @@ type Runtime 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"` - IssueID string `json:"issue_id"` - Context TaskContext `json:"context"` + ID string `json:"id"` + AgentID string `json:"agent_id"` + RuntimeID string `json:"runtime_id"` + IssueID string `json:"issue_id"` + Agent *AgentData `json:"agent,omitempty"` } -// TaskContext contains the snapshot context for a task. -type TaskContext struct { - Issue IssueContext `json:"issue"` - Agent AgentContext `json:"agent"` - Runtime RuntimeContext `json:"runtime"` - WorkspaceContext string `json:"workspace_context,omitempty"` - RepoPath string `json:"repo_path,omitempty"` -} - -// IssueContext holds issue details for task execution. -type IssueContext struct { - ID string `json:"id"` - Title string `json:"title"` - Description string `json:"description"` -} - -// AgentContext holds agent details for task execution. -type AgentContext struct { +// AgentData holds agent details returned by the claim endpoint. +type AgentData struct { ID string `json:"id"` Name string `json:"name"` Skills []SkillData `json:"skills"` } -// SkillData represents a structured skill in the task context. +// SkillData represents a structured skill for task execution. type SkillData struct { Name string `json:"name"` Content string `json:"content"` @@ -58,14 +44,6 @@ type SkillFileData struct { Content string `json:"content"` } -// RuntimeContext holds runtime details for task execution. -type RuntimeContext struct { - ID string `json:"id"` - Name string `json:"name"` - Provider string `json:"provider"` - DeviceInfo string `json:"device_info"` -} - // TaskResult is the outcome of executing a task. type TaskResult struct { Status string `json:"status"` diff --git a/server/internal/handler/agent.go b/server/internal/handler/agent.go index c3ea1357..1a0373ac 100644 --- a/server/internal/handler/agent.go +++ b/server/internal/handler/agent.go @@ -10,6 +10,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5/pgtype" "github.com/multica-ai/multica/server/internal/logger" + "github.com/multica-ai/multica/server/internal/service" db "github.com/multica-ai/multica/server/pkg/db/generated" "github.com/multica-ai/multica/server/pkg/protocol" ) @@ -81,19 +82,27 @@ func agentToResponse(a db.Agent) AgentResponse { } type AgentTaskResponse struct { - ID string `json:"id"` - AgentID string `json:"agent_id"` - RuntimeID string `json:"runtime_id"` - IssueID string `json:"issue_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"` - Context any `json:"context,omitempty"` - CreatedAt string `json:"created_at"` + ID string `json:"id"` + AgentID string `json:"agent_id"` + RuntimeID string `json:"runtime_id"` + IssueID string `json:"issue_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"` + Agent *TaskAgentData `json:"agent,omitempty"` + CreatedAt string `json:"created_at"` +} + +// TaskAgentData holds agent info included in claim responses so the daemon +// can set up the execution environment (branch naming, skill files). +type TaskAgentData struct { + ID string `json:"id"` + Name string `json:"name"` + Skills []service.AgentSkillData `json:"skills,omitempty"` } func taskToResponse(t db.AgentTaskQueue) AgentTaskResponse { @@ -101,10 +110,6 @@ func taskToResponse(t db.AgentTaskQueue) AgentTaskResponse { if t.Result != nil { json.Unmarshal(t.Result, &result) } - var ctx any - if t.Context != nil { - json.Unmarshal(t.Context, &ctx) - } return AgentTaskResponse{ ID: uuidToString(t.ID), AgentID: uuidToString(t.AgentID), @@ -117,7 +122,6 @@ func taskToResponse(t db.AgentTaskQueue) AgentTaskResponse { CompletedAt: timestampToPtr(t.CompletedAt), Result: result, Error: textToPtr(t.Error), - Context: ctx, CreatedAt: timestampToString(t.CreatedAt), } } diff --git a/server/internal/handler/daemon.go b/server/internal/handler/daemon.go index 34c64fe6..fbe8b8f4 100644 --- a/server/internal/handler/daemon.go +++ b/server/internal/handler/daemon.go @@ -144,6 +144,7 @@ func (h *Handler) DaemonHeartbeat(w http.ResponseWriter, r *http.Request) { } // ClaimTaskByRuntime atomically claims the next queued task for a runtime. +// The response includes the agent's name and skills, fetched fresh from the DB. func (h *Handler) ClaimTaskByRuntime(w http.ResponseWriter, r *http.Request) { runtimeID := chi.URLParam(r, "runtimeId") @@ -159,8 +160,19 @@ func (h *Handler) ClaimTaskByRuntime(w http.ResponseWriter, r *http.Request) { return } + // Build response with fresh agent data (name + skills). + resp := taskToResponse(*task) + if agent, err := h.Queries.GetAgent(r.Context(), task.AgentID); err == nil { + skills := h.TaskService.LoadAgentSkills(r.Context(), task.AgentID) + resp.Agent = &TaskAgentData{ + ID: uuidToString(agent.ID), + Name: agent.Name, + Skills: skills, + } + } + slog.Info("task claimed by runtime", "task_id", uuidToString(task.ID), "runtime_id", runtimeID, "agent_id", uuidToString(task.AgentID)) - writeJSON(w, http.StatusOK, map[string]any{"task": taskToResponse(*task)}) + writeJSON(w, http.StatusOK, map[string]any{"task": resp}) } // ListPendingTasksByRuntime returns queued/dispatched tasks for a runtime. diff --git a/server/internal/service/task.go b/server/internal/service/task.go index e8489057..09bbc5be 100644 --- a/server/internal/service/task.go +++ b/server/internal/service/task.go @@ -26,7 +26,9 @@ func NewTaskService(q *db.Queries, hub *realtime.Hub, bus *events.Bus) *TaskServ return &TaskService{Queries: q, Hub: hub, Bus: bus} } -// EnqueueTaskForIssue creates a task with a context snapshot of the issue. +// EnqueueTaskForIssue creates a queued task for an agent-assigned issue. +// No context snapshot is stored — the agent fetches all data it needs at +// runtime via the multica CLI. func (s *TaskService) EnqueueTaskForIssue(ctx context.Context, issue db.Issue) (db.AgentTaskQueue, error) { if !issue.AssigneeID.Valid { slog.Error("task enqueue failed", "issue_id", util.UUIDToString(issue.ID), "error", "issue has no assignee") @@ -43,30 +45,11 @@ func (s *TaskService) EnqueueTaskForIssue(ctx context.Context, issue db.Issue) ( return db.AgentTaskQueue{}, fmt.Errorf("agent has no runtime") } - runtime, err := s.Queries.GetAgentRuntime(ctx, agent.RuntimeID) - if err != nil { - slog.Error("task enqueue failed", "issue_id", util.UUIDToString(issue.ID), "error", err) - return db.AgentTaskQueue{}, fmt.Errorf("load runtime: %w", err) - } - - // Include workspace context in the snapshot when available. - var workspaceContext string - if ws, err := s.Queries.GetWorkspace(ctx, issue.WorkspaceID); err == nil && ws.Context.Valid { - workspaceContext = ws.Context.String - } - - // Load agent's structured skills + files. - agentSkills := s.loadAgentSkillsForSnapshot(ctx, agent.ID) - - snapshot := buildContextSnapshot(issue, agent, runtime, workspaceContext, agentSkills) - contextJSON, _ := json.Marshal(snapshot) - - task, err := s.Queries.CreateAgentTaskWithContext(ctx, db.CreateAgentTaskWithContextParams{ + task, err := s.Queries.CreateAgentTask(ctx, db.CreateAgentTaskParams{ AgentID: issue.AssigneeID, RuntimeID: agent.RuntimeID, IssueID: issue.ID, Priority: priorityToInt(issue.Priority), - Context: contextJSON, }) if err != nil { slog.Error("task enqueue failed", "issue_id", util.UUIDToString(issue.ID), "error", err) @@ -292,70 +275,36 @@ func (s *TaskService) updateAgentStatus(ctx context.Context, agentID pgtype.UUID }) } -type skillSnapshot struct { - Name string `json:"name"` - Content string `json:"content"` - Files []skillFileSnapshot `json:"files,omitempty"` -} - -type skillFileSnapshot struct { - Path string `json:"path"` - Content string `json:"content"` -} - -func (s *TaskService) loadAgentSkillsForSnapshot(ctx context.Context, agentID pgtype.UUID) []skillSnapshot { +// LoadAgentSkills loads an agent's skills with their files for task execution. +func (s *TaskService) LoadAgentSkills(ctx context.Context, agentID pgtype.UUID) []AgentSkillData { skills, err := s.Queries.ListAgentSkills(ctx, agentID) if err != nil || len(skills) == 0 { return nil } - result := make([]skillSnapshot, 0, len(skills)) + result := make([]AgentSkillData, 0, len(skills)) for _, sk := range skills { - snap := skillSnapshot{Name: sk.Name, Content: sk.Content} + data := AgentSkillData{Name: sk.Name, Content: sk.Content} files, _ := s.Queries.ListSkillFiles(ctx, sk.ID) for _, f := range files { - snap.Files = append(snap.Files, skillFileSnapshot{Path: f.Path, Content: f.Content}) + data.Files = append(data.Files, AgentSkillFileData{Path: f.Path, Content: f.Content}) } - result = append(result, snap) + result = append(result, data) } return result } -func buildContextSnapshot(issue db.Issue, agent db.Agent, runtime db.AgentRuntime, workspaceContext string, skills []skillSnapshot) map[string]any { - var tools any - if agent.Tools != nil { - json.Unmarshal(agent.Tools, &tools) - } - var metadata any - if runtime.Metadata != nil { - json.Unmarshal(runtime.Metadata, &metadata) - } +// AgentSkillData represents a skill for task execution responses. +type AgentSkillData struct { + Name string `json:"name"` + Content string `json:"content"` + Files []AgentSkillFileData `json:"files,omitempty"` +} - m := map[string]any{ - "issue": map[string]any{ - "id": util.UUIDToString(issue.ID), - "title": issue.Title, - "description": issue.Description.String, - }, - "agent": map[string]any{ - "id": util.UUIDToString(agent.ID), - "name": agent.Name, - "skills": skills, - "tools": tools, - }, - "runtime": map[string]any{ - "id": util.UUIDToString(runtime.ID), - "name": runtime.Name, - "runtime_mode": runtime.RuntimeMode, - "provider": runtime.Provider, - "device_info": runtime.DeviceInfo, - "metadata": metadata, - }, - } - if workspaceContext != "" { - m["workspace_context"] = workspaceContext - } - return m +// AgentSkillFileData represents a supporting file within a skill. +type AgentSkillFileData struct { + Path string `json:"path"` + Content string `json:"content"` } func priorityToInt(p string) int32 {