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:
parent
8bd476f47c
commit
6d2a0b45d2
15 changed files with 269 additions and 82 deletions
16
AGENTS.md
Normal file
16
AGENTS.md
Normal 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 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.
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
87
server/internal/daemon/health.go
Normal file
87
server/internal/daemon/health.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue