refactor: decouple task lifecycle from issue status (#151)

* refactor: decouple task lifecycle from issue status, add daemon health server

- Remove automatic issue status changes from StartTask (in_progress),
  CompleteTask (in_review), and FailTask (blocked) in task service.
  Issue status is now fully managed by the agent via `multica issue status`.
- Update agent prompt and meta skill to instruct agents to manage issue
  status themselves (in_progress → done/in_review/blocked).
- Add daemon health HTTP server on 127.0.0.1:19514 with /health endpoint
  exposing pid, uptime, agents, and workspaces. Fail fast if port is taken
  (another daemon already running).
- Update `multica status` to check both server and daemon health.
- Add Save button to repos section in workspace settings UI.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor(daemon): simplify prompt, fix runtime config path, improve task error logging

- Slim down BuildPrompt to a minimal hint; detailed workflow now lives in CLAUDE.md/AGENTS.md
- Write CLAUDE.md to workDir root instead of .claude/CLAUDE.md
- Fix git-exclude pattern (.claude → CLAUDE.md)
- Decouple task queue reconciliation from issue status changes (agents manage status via CLI)
- Add diagnostic logging when CompleteTask/FailTask fail due to unexpected task state

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(task): use task_completed/task_failed inbox notification types

FailTask was sending "agent_blocked" which conflates agent crash with
issue-level blocked status. Align notification types with the new
decoupled model: task_completed and task_failed. Update frontend types
and labels accordingly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
LinYushen 2026-03-27 18:30:21 +08:00 committed by GitHub
parent 8bd476f47c
commit 6d2a0b45d2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 269 additions and 82 deletions

16
AGENTS.md Normal file
View file

@ -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 Gos 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.

View file

@ -44,6 +44,8 @@ const severityOrder: Record<InboxSeverity, number> = {
const typeLabels: Record<InboxItemType, string> = {
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",

View file

@ -235,10 +235,20 @@ export function WorkspaceTab() {
))}
{canManageWorkspace && (
<Button variant="outline" size="sm" onClick={handleAddRepo}>
<Plus className="h-3 w-3" />
Add repository
</Button>
<div className="flex items-center justify-between pt-1">
<Button variant="outline" size="sm" onClick={handleAddRepo}>
<Plus className="h-3 w-3" />
Add repository
</Button>
<Button
size="sm"
onClick={handleSave}
disabled={saving || !name.trim() || !canManageWorkspace}
>
<Save className="h-3 w-3" />
{saving ? "Saving..." : "Save"}
</Button>
</div>
)}
</CardContent>
</Card>

View file

@ -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"

View file

@ -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
}

View file

@ -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,

View file

@ -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)
}

View file

@ -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)

View file

@ -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)
}

View file

@ -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)

View file

@ -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 <id> [--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: <url>\"`\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()
}

View file

@ -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)
}
}

View file

@ -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()
}

View file

@ -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) {

View file

@ -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