diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..fd66fd32 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,16 @@ +# Repository Guidelines + +## Project Structure & Module Organization +`apps/web/` contains the Next.js 16 frontend: routes live in `app/`, reusable UI in `components/`, feature code in `features/`, test utilities in `test/`, and static assets in `public/`. `server/` contains the Go backend: entry points are in `cmd/{server,multica,migrate}`, application logic lives in `internal/`, migrations are in `migrations/`, and SQL lives under `pkg/db/queries/` with generated sqlc output in `pkg/db/generated/`. `e2e/` holds Playwright coverage. `scripts/` and the root `Makefile` drive local setup and verification. + +## Build, Test, and Development Commands +Use `make setup` for first-time setup: it installs dependencies, ensures PostgreSQL is running, and applies migrations. Use `make start` to run backend and frontend together with `.env` or `.env.worktree`. For single-surface work, use `pnpm dev:web` for the frontend and `make dev` for the Go server. Run `pnpm test` for Vitest, `make test` for Go tests, and `make check` for the full pipeline: typecheck, frontend unit tests, Go tests, then Playwright. After changing SQL in `server/pkg/db/queries/*.sql`, run `make sqlc`. + +## Coding Style & Naming Conventions +TypeScript in `apps/web` uses 2-space indentation, double quotes, and semicolons. Prefer PascalCase for React components, camelCase for hooks and helpers, and colocated test files such as `page.test.tsx`. Go code should stay `gofmt`-clean and use domain-oriented filenames like `issue.go` or `cmd_issue.go`. Do not hand-edit generated code in `server/pkg/db/generated/`. + +## Testing Guidelines +Frontend unit tests use Vitest with Testing Library and shared setup from `apps/web/test/`. End-to-end tests live in `e2e/*.spec.ts`; `make check` will start missing services automatically, while direct Playwright runs expect the app to already be running. Backend tests use Go’s standard `*_test.go` pattern. Add or update tests whenever you change handlers, CLI commands, daemon behavior, or SQL-backed flows. + +## Commit & Pull Request Guidelines +Recent history follows conventional commits with scopes, for example `feat(web): ...`, `fix(cli): ...`, `refactor(daemon): ...`, `test(cli): ...`, and `docs: ...`. Keep PRs focused and include a short description, linked issue or PR number when relevant, screenshots for UI work, and notes for migrations, env changes, or CLI surface changes. Before opening a PR, run `make check` or the relevant frontend/backend subset. diff --git a/apps/web/app/(dashboard)/inbox/page.tsx b/apps/web/app/(dashboard)/inbox/page.tsx index e64194c7..fc28a08f 100644 --- a/apps/web/app/(dashboard)/inbox/page.tsx +++ b/apps/web/app/(dashboard)/inbox/page.tsx @@ -44,6 +44,8 @@ const severityOrder: Record = { const typeLabels: Record = { issue_assigned: "Assigned", review_requested: "Review requested", + task_completed: "Task completed", + task_failed: "Task failed", agent_blocked: "Agent blocked", agent_completed: "Agent completed", mentioned: "Mentioned", diff --git a/apps/web/app/(dashboard)/settings/_components/workspace-tab.tsx b/apps/web/app/(dashboard)/settings/_components/workspace-tab.tsx index f9adfaa2..0f4fd06d 100644 --- a/apps/web/app/(dashboard)/settings/_components/workspace-tab.tsx +++ b/apps/web/app/(dashboard)/settings/_components/workspace-tab.tsx @@ -235,10 +235,20 @@ export function WorkspaceTab() { ))} {canManageWorkspace && ( - +
+ + +
)} diff --git a/apps/web/shared/types/inbox.ts b/apps/web/shared/types/inbox.ts index 19339f5e..e66e32e1 100644 --- a/apps/web/shared/types/inbox.ts +++ b/apps/web/shared/types/inbox.ts @@ -5,6 +5,8 @@ export type InboxSeverity = "action_required" | "attention" | "info"; export type InboxItemType = | "issue_assigned" | "review_requested" + | "task_completed" + | "task_failed" | "agent_blocked" | "agent_completed" | "mentioned" diff --git a/server/cmd/multica/cmd_status.go b/server/cmd/multica/cmd_status.go index 5f9bb8ab..656e851a 100644 --- a/server/cmd/multica/cmd_status.go +++ b/server/cmd/multica/cmd_status.go @@ -2,19 +2,29 @@ package main import ( "context" + "encoding/json" "fmt" + "net/http" "os" + "strings" "time" "github.com/spf13/cobra" + + "github.com/multica-ai/multica/server/internal/cli" + "github.com/multica-ai/multica/server/internal/daemon" ) var statusCmd = &cobra.Command{ Use: "status", - Short: "Check server health", + Short: "Check server and daemon status", RunE: runStatus, } +func init() { + statusCmd.Flags().String("output", "table", "Output format: table or json") +} + func runStatus(cmd *cobra.Command, _ []string) error { client, err := newAPIClient(cmd) if err != nil { @@ -24,13 +34,65 @@ func runStatus(cmd *cobra.Command, _ []string) error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() + // Check server health. + serverStatus := "unreachable" body, err := client.HealthCheck(ctx) - if err != nil { - fmt.Fprintf(os.Stderr, "Server unreachable: %v\n", err) - return err + if err == nil { + serverStatus = strings.TrimSpace(body) } - fmt.Fprintf(os.Stdout, "Server: %s\n", client.BaseURL) - fmt.Fprintf(os.Stdout, "Status: %s\n", body) + // Check local daemon via its health endpoint. + daemonHealth := checkDaemonHealth(ctx) + + output, _ := cmd.Flags().GetString("output") + if output == "json" { + result := map[string]any{ + "server": map[string]any{ + "url": client.BaseURL, + "status": serverStatus, + }, + "daemon": daemonHealth, + } + return cli.PrintJSON(os.Stdout, result) + } + + fmt.Fprintf(os.Stdout, "Server: %s (%s)\n", serverStatus, client.BaseURL) + if daemonHealth["status"] == "running" { + fmt.Fprintf(os.Stdout, "Daemon: running (pid %v, uptime %v)\n", daemonHealth["pid"], daemonHealth["uptime"]) + if agents, ok := daemonHealth["agents"].([]any); ok && len(agents) > 0 { + parts := make([]string, len(agents)) + for i, a := range agents { + parts[i] = fmt.Sprint(a) + } + fmt.Fprintf(os.Stdout, " Agents: %s\n", strings.Join(parts, ", ")) + } + if ws, ok := daemonHealth["workspaces"].([]any); ok { + fmt.Fprintf(os.Stdout, " Workspaces: %d\n", len(ws)) + } + } else { + fmt.Fprintf(os.Stdout, "Daemon: stopped\n") + } return nil } + +// checkDaemonHealth calls the daemon's local health endpoint. +func checkDaemonHealth(ctx context.Context) map[string]any { + addr := fmt.Sprintf("http://127.0.0.1:%d/health", daemon.DefaultHealthPort) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, addr, nil) + if err != nil { + return map[string]any{"status": "stopped"} + } + + httpClient := &http.Client{Timeout: 2 * time.Second} + resp, err := httpClient.Do(req) + if err != nil { + return map[string]any{"status": "stopped"} + } + defer resp.Body.Close() + + var result map[string]any + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return map[string]any{"status": "stopped"} + } + return result +} diff --git a/server/internal/daemon/config.go b/server/internal/daemon/config.go index 7084b57c..37d444b1 100644 --- a/server/internal/daemon/config.go +++ b/server/internal/daemon/config.go @@ -11,12 +11,13 @@ import ( ) const ( - DefaultServerURL = "ws://localhost:8080/ws" - DefaultPollInterval = 3 * time.Second - DefaultHeartbeatInterval = 15 * time.Second - DefaultAgentTimeout = 2 * time.Hour - DefaultRuntimeName = "Local Agent" + DefaultServerURL = "ws://localhost:8080/ws" + DefaultPollInterval = 3 * time.Second + DefaultHeartbeatInterval = 15 * time.Second + DefaultAgentTimeout = 2 * time.Hour + DefaultRuntimeName = "Local Agent" DefaultConfigReloadInterval = 5 * time.Second + DefaultHealthPort = 19514 ) // Config holds all daemon configuration. @@ -28,6 +29,7 @@ type Config struct { Agents map[string]AgentEntry // "claude" -> entry, "codex" -> entry WorkspacesRoot string // base path for execution envs (default: ~/multica_workspaces) KeepEnvAfterTask bool // preserve env after task for debugging + HealthPort int // local HTTP port for health checks (default: 19514) PollInterval time.Duration HeartbeatInterval time.Duration AgentTimeout time.Duration @@ -154,6 +156,7 @@ func LoadConfig(overrides Overrides) (Config, error) { Agents: agents, WorkspacesRoot: workspacesRoot, KeepEnvAfterTask: keepEnv, + HealthPort: DefaultHealthPort, PollInterval: pollInterval, HeartbeatInterval: heartbeatInterval, AgentTimeout: agentTimeout, diff --git a/server/internal/daemon/daemon.go b/server/internal/daemon/daemon.go index f7e2950f..a327a795 100644 --- a/server/internal/daemon/daemon.go +++ b/server/internal/daemon/daemon.go @@ -46,6 +46,12 @@ func New(cfg Config, logger *slog.Logger) *Daemon { // Run starts the daemon: resolves auth, registers runtimes, then polls for tasks. func (d *Daemon) Run(ctx context.Context) error { + // Bind health port early to detect another running daemon. + healthLn, err := d.listenHealth() + if err != nil { + return err + } + agentNames := make([]string, 0, len(d.cfg.Agents)) for name := range d.cfg.Agents { agentNames = append(agentNames, name) @@ -72,6 +78,7 @@ func (d *Daemon) Run(ctx context.Context) error { go d.heartbeatLoop(ctx) go d.usageScanLoop(ctx) + go d.serveHealth(ctx, healthLn, time.Now()) return d.pollLoop(ctx) } diff --git a/server/internal/daemon/daemon_test.go b/server/internal/daemon/daemon_test.go index 5783c58a..868970d6 100644 --- a/server/internal/daemon/daemon_test.go +++ b/server/internal/daemon/daemon_test.go @@ -32,11 +32,10 @@ func TestBuildPromptContainsIssueID(t *testing.T) { }, }) - // Prompt should contain the issue ID and CLI instructions. + // Prompt should contain the issue ID and CLI hint. for _, want := range []string{ issueID, "multica issue get", - "multica issue comment list", } { if !strings.Contains(prompt, want) { t.Fatalf("prompt missing %q", want) diff --git a/server/internal/daemon/execenv/execenv.go b/server/internal/daemon/execenv/execenv.go index 61e9d941..92208829 100644 --- a/server/internal/daemon/execenv/execenv.go +++ b/server/internal/daemon/execenv/execenv.go @@ -111,7 +111,7 @@ func Prepare(params PrepareParams, logger *slog.Logger) (*Environment, error) { env.gitRoot = gitRoot // Exclude injected directories from git tracking. - for _, pattern := range []string{".agent_context", ".claude", "AGENTS.md"} { + for _, pattern := range []string{".agent_context", "CLAUDE.md", "AGENTS.md"} { if err := excludeFromGit(workDir, pattern); err != nil { logger.Warn("execenv: failed to exclude from git", "pattern", pattern, "error", err) } diff --git a/server/internal/daemon/execenv/execenv_test.go b/server/internal/daemon/execenv/execenv_test.go index f1a19815..7512a5d0 100644 --- a/server/internal/daemon/execenv/execenv_test.go +++ b/server/internal/daemon/execenv/execenv_test.go @@ -369,9 +369,9 @@ func TestInjectRuntimeConfigClaude(t *testing.T) { t.Fatalf("InjectRuntimeConfig failed: %v", err) } - content, err := os.ReadFile(filepath.Join(dir, ".claude", "CLAUDE.md")) + content, err := os.ReadFile(filepath.Join(dir, "CLAUDE.md")) if err != nil { - t.Fatalf("failed to read .claude/CLAUDE.md: %v", err) + t.Fatalf("failed to read CLAUDE.md: %v", err) } s := string(content) @@ -428,9 +428,9 @@ func TestInjectRuntimeConfigNoSkills(t *testing.T) { t.Fatalf("InjectRuntimeConfig failed: %v", err) } - content, err := os.ReadFile(filepath.Join(dir, ".claude", "CLAUDE.md")) + content, err := os.ReadFile(filepath.Join(dir, "CLAUDE.md")) if err != nil { - t.Fatalf("failed to read .claude/CLAUDE.md: %v", err) + t.Fatalf("failed to read CLAUDE.md: %v", err) } s := string(content) diff --git a/server/internal/daemon/execenv/runtime_config.go b/server/internal/daemon/execenv/runtime_config.go index 9362b413..7ad0e7ec 100644 --- a/server/internal/daemon/execenv/runtime_config.go +++ b/server/internal/daemon/execenv/runtime_config.go @@ -10,18 +10,14 @@ import ( // InjectRuntimeConfig writes the meta skill content into the runtime-specific // config file so the agent discovers .agent_context/ through its native mechanism. // -// For Claude: writes {workDir}/.claude/CLAUDE.md +// For Claude: writes {workDir}/CLAUDE.md // For Codex: writes {workDir}/AGENTS.md func InjectRuntimeConfig(workDir, provider string, ctx TaskContextForEnv) error { content := buildMetaSkillContent(ctx) switch provider { case "claude": - dir := filepath.Join(workDir, ".claude") - if err := os.MkdirAll(dir, 0o755); err != nil { - return fmt.Errorf("create .claude dir: %w", err) - } - return os.WriteFile(filepath.Join(dir, "CLAUDE.md"), []byte(content), 0o644) + return os.WriteFile(filepath.Join(workDir, "CLAUDE.md"), []byte(content), 0o644) case "codex": return os.WriteFile(filepath.Join(workDir, "AGENTS.md"), []byte(content), 0o644) default: @@ -52,10 +48,19 @@ func buildMetaSkillContent(ctx TaskContextForEnv) string { b.WriteString("- `multica issue update [--title X] [--description X] [--priority X]` — Update issue fields\n\n") b.WriteString("### Workflow\n") + b.WriteString("You are responsible for managing the issue status throughout your work.\n\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") + fmt.Fprintf(&b, "2. Run `multica issue status %s in_progress`\n", ctx.IssueID) + b.WriteString("3. Read comments for additional context or human instructions\n") + b.WriteString("4. If the task requires code changes:\n") + b.WriteString(" a. Create a new branch\n") + b.WriteString(" b. Implement the changes and commit\n") + b.WriteString(" c. Push the branch to the remote\n") + b.WriteString(" d. Create a pull request (decide the target branch based on the repo's conventions)\n") + fmt.Fprintf(&b, " e. Post the PR link as a comment: `multica issue comment add %s --content \"PR: \"`\n", ctx.IssueID) + b.WriteString("5. If the task does not require code (e.g. research, documentation), post your findings as a comment\n") + fmt.Fprintf(&b, "6. Run `multica issue status %s in_review`\n", ctx.IssueID) + fmt.Fprintf(&b, "7. If blocked, run `multica issue status %s blocked` and post a comment explaining why\n\n", ctx.IssueID) if len(ctx.AgentSkills) > 0 { b.WriteString("## Skills\n\n") @@ -72,10 +77,9 @@ func buildMetaSkillContent(ctx TaskContextForEnv) string { } b.WriteString("## Output\n\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") + 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") return b.String() } diff --git a/server/internal/daemon/health.go b/server/internal/daemon/health.go new file mode 100644 index 00000000..62d7b14f --- /dev/null +++ b/server/internal/daemon/health.go @@ -0,0 +1,87 @@ +package daemon + +import ( + "context" + "encoding/json" + "fmt" + "net" + "net/http" + "os" + "time" +) + +// HealthResponse is returned by the daemon's local health endpoint. +type HealthResponse struct { + Status string `json:"status"` + PID int `json:"pid"` + Uptime string `json:"uptime"` + DaemonID string `json:"daemon_id"` + DeviceName string `json:"device_name"` + ServerURL string `json:"server_url"` + Agents []string `json:"agents"` + Workspaces []healthWorkspace `json:"workspaces"` +} + +type healthWorkspace struct { + ID string `json:"id"` + Runtimes []string `json:"runtimes"` +} + +// listenHealth binds the health port. Returns the listener or an error if +// another daemon is already running (port taken). +func (d *Daemon) listenHealth() (net.Listener, error) { + addr := fmt.Sprintf("127.0.0.1:%d", d.cfg.HealthPort) + ln, err := net.Listen("tcp", addr) + if err != nil { + return nil, fmt.Errorf("another daemon is already running on %s: %w", addr, err) + } + return ln, nil +} + +// serveHealth runs the health HTTP server on the given listener. +// Blocks until ctx is cancelled. +func (d *Daemon) serveHealth(ctx context.Context, ln net.Listener, startedAt time.Time) { + mux := http.NewServeMux() + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + d.mu.Lock() + var wsList []healthWorkspace + for id, ws := range d.workspaces { + wsList = append(wsList, healthWorkspace{ + ID: id, + Runtimes: ws.runtimeIDs, + }) + } + d.mu.Unlock() + + agents := make([]string, 0, len(d.cfg.Agents)) + for name := range d.cfg.Agents { + agents = append(agents, name) + } + + resp := HealthResponse{ + Status: "running", + PID: os.Getpid(), + Uptime: time.Since(startedAt).Truncate(time.Second).String(), + DaemonID: d.cfg.DaemonID, + DeviceName: d.cfg.DeviceName, + ServerURL: d.cfg.ServerBaseURL, + Agents: agents, + Workspaces: wsList, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + }) + + srv := &http.Server{Handler: mux} + + go func() { + <-ctx.Done() + srv.Close() + }() + + d.logger.Info("health server listening", "addr", ln.Addr().String()) + if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed { + d.logger.Warn("health server error", "error", err) + } +} diff --git a/server/internal/daemon/prompt.go b/server/internal/daemon/prompt.go index 47cb33ab..9c843818 100644 --- a/server/internal/daemon/prompt.go +++ b/server/internal/daemon/prompt.go @@ -6,21 +6,12 @@ import ( ) // BuildPrompt constructs the task prompt for an agent CLI. -// 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. +// Keep this minimal — detailed instructions live in CLAUDE.md / AGENTS.md +// injected 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\n") - fmt.Fprintf(&b, "Your assigned issue ID is: %s\n\n", task.IssueID) - - 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/handler/issue.go b/server/internal/handler/issue.go index 172fd8a9..8528f490 100644 --- a/server/internal/handler/issue.go +++ b/server/internal/handler/issue.go @@ -347,8 +347,9 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) { "creator_id": uuidToString(prevIssue.CreatorID), }) - // If assignee or readiness status changed, reconcile the task queue. - if assigneeChanged || statusChanged { + // Reconcile task queue when assignee changes (not on status changes — + // agents manage issue status themselves via the CLI). + if assigneeChanged { h.TaskService.CancelTasksForIssue(r.Context(), issue.ID) if h.shouldEnqueueAgentTask(r.Context(), issue) { diff --git a/server/internal/service/task.go b/server/internal/service/task.go index 09bbc5be..65091d42 100644 --- a/server/internal/service/task.go +++ b/server/internal/service/task.go @@ -130,7 +130,8 @@ func (s *TaskService) ClaimTaskForRuntime(ctx context.Context, runtimeID pgtype. return nil, nil } -// StartTask transitions a dispatched task to running and syncs issue status. +// StartTask transitions a dispatched task to running. +// Issue status is NOT changed here — the agent manages it via the CLI. func (s *TaskService) StartTask(ctx context.Context, taskID pgtype.UUID) (*db.AgentTaskQueue, error) { task, err := s.Queries.StartAgentTask(ctx, taskID) if err != nil { @@ -138,40 +139,36 @@ func (s *TaskService) StartTask(ctx context.Context, taskID pgtype.UUID) (*db.Ag } slog.Info("task started", "task_id", util.UUIDToString(task.ID), "issue_id", util.UUIDToString(task.IssueID)) - - // Sync issue → in_progress - issue, err := s.Queries.UpdateIssueStatus(ctx, db.UpdateIssueStatusParams{ - ID: task.IssueID, - Status: "in_progress", - }) - if err == nil { - s.broadcastIssueUpdated(issue) - } - return &task, nil } -// CompleteTask marks a task as completed and syncs issue/agent status. +// CompleteTask marks a task as completed. +// Issue status is NOT changed here — the agent manages it via the CLI. func (s *TaskService) CompleteTask(ctx context.Context, taskID pgtype.UUID, result []byte) (*db.AgentTaskQueue, error) { task, err := s.Queries.CompleteAgentTask(ctx, db.CompleteAgentTaskParams{ ID: taskID, Result: result, }) if err != nil { + // Log the current task state to help debug why the update matched no rows. + if existing, lookupErr := s.Queries.GetAgentTask(ctx, taskID); lookupErr == nil { + slog.Warn("complete task failed: task not in running state", + "task_id", util.UUIDToString(taskID), + "current_status", existing.Status, + "issue_id", util.UUIDToString(existing.IssueID), + "agent_id", util.UUIDToString(existing.AgentID), + ) + } else { + slog.Warn("complete task failed: task not found", + "task_id", util.UUIDToString(taskID), + "lookup_error", lookupErr, + ) + } return nil, fmt.Errorf("complete task: %w", err) } slog.Info("task completed", "task_id", util.UUIDToString(task.ID), "issue_id", util.UUIDToString(task.IssueID)) - // Sync issue → in_review - issue, issueErr := s.Queries.UpdateIssueStatus(ctx, db.UpdateIssueStatusParams{ - ID: task.IssueID, - Status: "in_review", - }) - if issueErr == nil { - s.broadcastIssueUpdated(issue) - } - var payload protocol.TaskCompletedPayload if err := json.Unmarshal(result, &payload); err == nil { if payload.Output != "" { @@ -179,8 +176,8 @@ func (s *TaskService) CompleteTask(ctx context.Context, taskID pgtype.UUID, resu } } - if issueErr == nil { - s.createInboxForIssueCreator(ctx, issue, task.AgentID, "review_requested", "attention", "Review requested: "+issue.Title, "") + if issue, err := s.Queries.GetIssue(ctx, task.IssueID); err == nil { + s.createInboxForIssueCreator(ctx, issue, task.AgentID, "task_completed", "attention", "Task completed: "+issue.Title, "") } // Reconcile agent status @@ -192,31 +189,37 @@ func (s *TaskService) CompleteTask(ctx context.Context, taskID pgtype.UUID, resu return &task, nil } -// FailTask marks a task as failed and syncs issue/agent status. +// FailTask marks a task as failed. +// Issue status is NOT changed here — the agent manages it via the CLI. func (s *TaskService) FailTask(ctx context.Context, taskID pgtype.UUID, errMsg string) (*db.AgentTaskQueue, error) { task, err := s.Queries.FailAgentTask(ctx, db.FailAgentTaskParams{ ID: taskID, Error: pgtype.Text{String: errMsg, Valid: true}, }) if err != nil { + if existing, lookupErr := s.Queries.GetAgentTask(ctx, taskID); lookupErr == nil { + slog.Warn("fail task failed: task not in running state", + "task_id", util.UUIDToString(taskID), + "current_status", existing.Status, + "issue_id", util.UUIDToString(existing.IssueID), + "agent_id", util.UUIDToString(existing.AgentID), + ) + } else { + slog.Warn("fail task failed: task not found", + "task_id", util.UUIDToString(taskID), + "lookup_error", lookupErr, + ) + } return nil, fmt.Errorf("fail task: %w", err) } slog.Warn("task failed", "task_id", util.UUIDToString(task.ID), "issue_id", util.UUIDToString(task.IssueID), "error", errMsg) - // Sync issue → blocked - issue, issueErr := s.Queries.UpdateIssueStatus(ctx, db.UpdateIssueStatusParams{ - ID: task.IssueID, - Status: "blocked", - }) - if issueErr == nil { - s.broadcastIssueUpdated(issue) - } if errMsg != "" { s.createAgentComment(ctx, task.IssueID, task.AgentID, errMsg, "system") } - if issueErr == nil { - s.createInboxForIssueCreator(ctx, issue, task.AgentID, "agent_blocked", "action_required", "Agent blocked: "+issue.Title, errMsg) + if issue, err := s.Queries.GetIssue(ctx, task.IssueID); err == nil { + s.createInboxForIssueCreator(ctx, issue, task.AgentID, "task_failed", "action_required", "Task failed: "+issue.Title, errMsg) } // Reconcile agent status