diff --git a/CLI_AND_DAEMON.md b/CLI_AND_DAEMON.md index f2a0df8f..5d59c10b 100644 --- a/CLI_AND_DAEMON.md +++ b/CLI_AND_DAEMON.md @@ -289,6 +289,23 @@ multica issue comment add --parent --content "Thanks!" multica issue comment delete ``` +### Execution History + +```bash +# List all execution runs for an issue +multica issue runs +multica issue runs --output json + +# View messages for a specific execution run +multica issue run-messages +multica issue run-messages --output json + +# Incremental fetch (only messages after a given sequence number) +multica issue run-messages --since 42 --output json +``` + +The `runs` command shows all past and current executions for an issue, including running tasks. The `run-messages` command shows the detailed message log (tool calls, thinking, text, errors) for a single run. Use `--since` for efficient polling of in-progress runs. + ## Configuration ### View Config diff --git a/server/cmd/multica/cmd_issue.go b/server/cmd/multica/cmd_issue.go index 9f341347..3d842015 100644 --- a/server/cmd/multica/cmd_issue.go +++ b/server/cmd/multica/cmd_issue.go @@ -87,6 +87,22 @@ var issueCommentDeleteCmd = &cobra.Command{ RunE: runIssueCommentDelete, } +// Execution history subcommands. + +var issueRunsCmd = &cobra.Command{ + Use: "runs ", + Short: "List execution history for an issue", + Args: cobra.ExactArgs(1), + RunE: runIssueRuns, +} + +var issueRunMessagesCmd = &cobra.Command{ + Use: "run-messages ", + Short: "List messages for an execution", + Args: cobra.ExactArgs(1), + RunE: runIssueRunMessages, +} + var validIssueStatuses = []string{ "backlog", "todo", "in_progress", "in_review", "done", "blocked", "cancelled", } @@ -99,6 +115,8 @@ func init() { issueCmd.AddCommand(issueAssignCmd) issueCmd.AddCommand(issueStatusCmd) issueCmd.AddCommand(issueCommentCmd) + issueCmd.AddCommand(issueRunsCmd) + issueCmd.AddCommand(issueRunMessagesCmd) issueCommentCmd.AddCommand(issueCommentListCmd) issueCommentCmd.AddCommand(issueCommentAddCmd) @@ -145,6 +163,13 @@ func init() { // issue comment list issueCommentListCmd.Flags().String("output", "table", "Output format: table or json") + // issue runs + issueRunsCmd.Flags().String("output", "table", "Output format: table or json") + + // issue run-messages + issueRunMessagesCmd.Flags().String("output", "json", "Output format: table or json") + issueRunMessagesCmd.Flags().Int("since", 0, "Only return messages after this sequence number") + // issue comment add issueCommentAddCmd.Flags().String("content", "", "Comment content (required)") issueCommentAddCmd.Flags().String("parent", "", "Parent comment ID (reply to a specific comment)") @@ -625,6 +650,108 @@ func runIssueCommentDelete(cmd *cobra.Command, args []string) error { return nil } +// --------------------------------------------------------------------------- +// Execution history commands +// --------------------------------------------------------------------------- + +func runIssueRuns(cmd *cobra.Command, args []string) error { + client, err := newAPIClient(cmd) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + var runs []map[string]any + if err := client.GetJSON(ctx, "/api/issues/"+args[0]+"/task-runs", &runs); err != nil { + return fmt.Errorf("list runs: %w", err) + } + + output, _ := cmd.Flags().GetString("output") + if output == "json" { + return cli.PrintJSON(os.Stdout, runs) + } + + headers := []string{"ID", "AGENT", "STATUS", "STARTED", "COMPLETED", "ERROR"} + rows := make([][]string, 0, len(runs)) + for _, r := range runs { + started := strVal(r, "started_at") + if len(started) >= 16 { + started = started[:16] + } + completed := strVal(r, "completed_at") + if len(completed) >= 16 { + completed = completed[:16] + } + errMsg := strVal(r, "error") + if utf8.RuneCountInString(errMsg) > 50 { + runes := []rune(errMsg) + errMsg = string(runes[:47]) + "..." + } + rows = append(rows, []string{ + truncateID(strVal(r, "id")), + truncateID(strVal(r, "agent_id")), + strVal(r, "status"), + started, + completed, + errMsg, + }) + } + cli.PrintTable(os.Stdout, headers, rows) + return nil +} + +func runIssueRunMessages(cmd *cobra.Command, args []string) error { + client, err := newAPIClient(cmd) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + path := "/api/daemon/tasks/" + args[0] + "/messages" + if since, _ := cmd.Flags().GetInt("since"); since > 0 { + path += fmt.Sprintf("?since=%d", since) + } + + var messages []map[string]any + if err := client.GetJSON(ctx, path, &messages); err != nil { + return fmt.Errorf("list run messages: %w", err) + } + + output, _ := cmd.Flags().GetString("output") + if output == "json" { + return cli.PrintJSON(os.Stdout, messages) + } + + headers := []string{"SEQ", "TYPE", "TOOL", "CONTENT"} + rows := make([][]string, 0, len(messages)) + for _, m := range messages { + content := strVal(m, "content") + if content == "" { + content = strVal(m, "output") + } + if utf8.RuneCountInString(content) > 80 { + runes := []rune(content) + content = string(runes[:77]) + "..." + } + seq := "" + if v, ok := m["seq"]; ok { + seq = fmt.Sprintf("%v", v) + } + rows = append(rows, []string{ + seq, + strVal(m, "type"), + strVal(m, "tool"), + content, + }) + } + cli.PrintTable(os.Stdout, headers, rows) + return nil +} + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- diff --git a/server/internal/daemon/execenv/runtime_config.go b/server/internal/daemon/execenv/runtime_config.go index df7a51ef..e334a9c0 100644 --- a/server/internal/daemon/execenv/runtime_config.go +++ b/server/internal/daemon/execenv/runtime_config.go @@ -48,7 +48,9 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string { b.WriteString("- `multica issue list [--status X] [--priority X] [--assignee X] --output json` — List issues in workspace\n") b.WriteString("- `multica issue comment list --output json` — List all comments on an issue (includes id, parent_id for threading)\n") b.WriteString("- `multica workspace get --output json` — Get workspace details and context\n") - b.WriteString("- `multica agent list --output json` — List agents in workspace\n\n") + b.WriteString("- `multica agent list --output json` — List agents in workspace\n") + b.WriteString("- `multica issue runs --output json` — List all execution runs for an issue (status, timestamps, errors)\n") + b.WriteString("- `multica issue run-messages [--since ] --output json` — List messages for a specific execution run (supports incremental fetch)\n\n") b.WriteString("### Write\n") b.WriteString("- `multica issue comment add --content \"...\" [--parent ]` — Post a comment (use --parent to reply to a specific comment)\n") diff --git a/server/internal/handler/daemon.go b/server/internal/handler/daemon.go index d0051766..a7821e91 100644 --- a/server/internal/handler/daemon.go +++ b/server/internal/handler/daemon.go @@ -5,6 +5,7 @@ import ( "fmt" "log/slog" "net/http" + "strconv" "strings" "github.com/go-chi/chi/v5" @@ -483,7 +484,20 @@ func (h *Handler) ListTaskMessages(w http.ResponseWriter, r *http.Request) { return } - messages, err := h.Queries.ListTaskMessages(r.Context(), parseUUID(taskID)) + var messages []db.TaskMessage + if sinceStr := r.URL.Query().Get("since"); sinceStr != "" { + sinceSeq, parseErr := strconv.Atoi(sinceStr) + if parseErr != nil { + writeError(w, http.StatusBadRequest, "invalid since parameter") + return + } + messages, err = h.Queries.ListTaskMessagesSince(r.Context(), db.ListTaskMessagesSinceParams{ + TaskID: parseUUID(taskID), + Seq: int32(sinceSeq), + }) + } else { + messages, err = h.Queries.ListTaskMessages(r.Context(), parseUUID(taskID)) + } if err != nil { writeError(w, http.StatusInternalServerError, "failed to list task messages") return