From 1bfe00b1a85ede4a8e7f720d65dd143a0ec532d2 Mon Sep 17 00:00:00 2001 From: kurihada Date: Tue, 24 Mar 2026 09:40:39 +0800 Subject: [PATCH] Add orch and inbox CLI gap-fill tests --- .../cli/inbox/gap_fill_additional_test.go | 208 ++++++ .../cli/inbox/lifecycle_additional_test.go | 629 +++++++++++++++++ .../cli/inbox/query_wait_additional_test.go | 597 ++++++++++++++++ .../core_additional_contracts_more_test.go | 342 ++++++++++ .../cli/orch/dispatch_wait_additional_test.go | 640 +++++++++++++++++ .../cli/orch/gap_fill_additional_test.go | 552 +++++++++++++++ .../orch/recovery_cleanup_additional_test.go | 646 ++++++++++++++++++ .../internal/cli/orch/test_helpers_test.go | 33 + .../orch/verify_council_additional_test.go | 632 +++++++++++++++++ 9 files changed, 4279 insertions(+) create mode 100644 packages/inbox-runtime/internal/cli/inbox/gap_fill_additional_test.go create mode 100644 packages/inbox-runtime/internal/cli/inbox/lifecycle_additional_test.go create mode 100644 packages/inbox-runtime/internal/cli/inbox/query_wait_additional_test.go create mode 100644 packages/orch-runtime/internal/cli/orch/core_additional_contracts_more_test.go create mode 100644 packages/orch-runtime/internal/cli/orch/dispatch_wait_additional_test.go create mode 100644 packages/orch-runtime/internal/cli/orch/gap_fill_additional_test.go create mode 100644 packages/orch-runtime/internal/cli/orch/recovery_cleanup_additional_test.go create mode 100644 packages/orch-runtime/internal/cli/orch/verify_council_additional_test.go diff --git a/packages/inbox-runtime/internal/cli/inbox/gap_fill_additional_test.go b/packages/inbox-runtime/internal/cli/inbox/gap_fill_additional_test.go new file mode 100644 index 0000000..3bf889a --- /dev/null +++ b/packages/inbox-runtime/internal/cli/inbox/gap_fill_additional_test.go @@ -0,0 +1,208 @@ +package inbox + +import ( + "strings" + "testing" +) + +// TestInboxLifecycleCommandsRejectMissingAgentWithoutRootFallback verifies lifecycle commands require an agent when root fallback is absent. +func TestInboxLifecycleCommandsRejectMissingAgentWithoutRootFallback(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + args []string + }{ + { + name: "claim", + args: []string{"claim", "--thread", "thr_missing"}, + }, + { + name: "renew", + args: []string{"renew", "--thread", "thr_missing"}, + }, + { + name: "update", + args: []string{"update", "--thread", "thr_missing", "--status", "blocked", "--summary", "Need leader input"}, + }, + { + name: "done", + args: []string{"done", "--thread", "thr_missing", "--summary", "Finished"}, + }, + { + name: "fail", + args: []string{"fail", "--thread", "thr_missing", "--summary", "Failed"}, + }, + { + name: "cancel", + args: []string{"cancel", "--thread", "thr_missing"}, + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + dbPath := initCommandTestDB(t) + args := append([]string{"--db", dbPath, "--json"}, tc.args...) + + stdout, _, exitCode := executeInboxCommand(args...) + if exitCode != 30 { + t.Fatalf("expected invalid_input exit code 30, got %d\nstdout:\n%s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "invalid_input") + assertInboxErrorMessageContains(t, stdout, "agent is required") + }) + } +} + +// TestInboxSendAndReplyRejectTerminalThreads verifies thread-append commands reject terminal threads. +func TestInboxSendAndReplyRejectTerminalThreads(t *testing.T) { + t.Parallel() + + t.Run("send", func(t *testing.T) { + dbPath := initCommandTestDB(t) + threadID := sendPendingThread(t, dbPath, "leader", "worker-a", "Terminal send", "Create terminal thread") + runInboxCommand(t, "--db", dbPath, "--json", "cancel", "--agent", "leader", "--thread", threadID, "--reason", "Stop work") + + stdout, _, exitCode := executeInboxCommand( + "--db", dbPath, + "--json", + "send", + "--from", "leader", + "--to", "worker-a", + "--thread", threadID, + "--summary", "Retry anyway", + ) + if exitCode != 30 { + t.Fatalf("expected invalid_state exit code 30, got %d\nstdout:\n%s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "invalid_state") + assertInboxErrorMessageContains(t, stdout, "already terminal") + }) + + t.Run("reply", func(t *testing.T) { + dbPath := initCommandTestDB(t) + threadID := sendPendingThread(t, dbPath, "leader", "worker-a", "Terminal reply", "Create terminal thread") + runInboxCommand(t, "--db", dbPath, "--json", "cancel", "--agent", "leader", "--thread", threadID, "--reason", "Stop work") + + stdout, _, exitCode := executeInboxCommand( + "--db", dbPath, + "--json", + "reply", + "--from", "leader", + "--to", "worker-a", + "--thread", threadID, + "--summary", "Reply anyway", + ) + if exitCode != 30 { + t.Fatalf("expected invalid_state exit code 30, got %d\nstdout:\n%s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "invalid_state") + assertInboxErrorMessageContains(t, stdout, "already terminal") + }) +} + +// TestCancelRejectsAdditionalInvalidArtifactCombinations verifies cancel covers the remaining artifact validation branches. +func TestCancelRejectsAdditionalInvalidArtifactCombinations(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + args []string + want string + }{ + { + name: "empty artifact path", + args: []string{"--artifact", " "}, + want: "artifact path cannot be empty", + }, + { + name: "artifact kind count mismatch", + args: []string{"--artifact", "decision.md", "--artifact-kind", "brief", "--artifact-kind", "log"}, + want: "artifact-kind must be specified once or once per artifact", + }, + { + name: "artifact metadata count mismatch", + args: []string{"--artifact", "decision.md", "--artifact-metadata-json", `{"label":"one"}`, "--artifact-metadata-json", `{"label":"two"}`}, + want: "artifact-metadata-json must be specified once or once per artifact", + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + dbPath := initCommandTestDB(t) + threadID := sendPendingThread(t, dbPath, "leader", "worker-a", "Cancel artifact validation", "Validate cancel artifacts") + + args := []string{ + "--db", dbPath, + "--json", + "cancel", + "--agent", "leader", + "--thread", threadID, + } + args = append(args, tc.args...) + + stdout, _, exitCode := executeInboxCommand(args...) + if exitCode != 30 { + t.Fatalf("expected invalid_input exit code 30, got %d\nstdout:\n%s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "invalid_input") + assertInboxErrorMessageContains(t, stdout, tc.want) + }) + } +} + +// TestInboxQueryCommandHelpContracts verifies the remaining query-style commands explain their operator contracts. +func TestInboxQueryCommandHelpContracts(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + args []string + want []string + }{ + { + name: "send", + args: []string{"send", "--help"}, + want: []string{"low-level thread creation primitive", "Constraints:", "body-file ./brief.md"}, + }, + { + name: "reply", + args: []string{"reply", "--help"}, + want: []string{"append a directed answer, control note, or follow-up message", "Constraints:", "--kind control"}, + }, + { + name: "fetch", + args: []string{"fetch", "--help"}, + want: []string{"fetch -> claim -> show -> update/done/fail", "Constraints:", "--status pending,blocked --unread --limit 5"}, + }, + { + name: "show", + args: []string{"show", "--help"}, + want: []string{"exact message sequence inside one thread", "Constraints:", "--mark-read"}, + }, + { + name: "watch", + args: []string{"watch", "--help"}, + want: []string{"watch is broader than wait-reply", "Constraints:", "--after-event 100 --timeout-seconds 300"}, + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + stdout, stderr, exitCode := executeInboxCommand(tc.args...) + if exitCode != 0 { + t.Fatalf("expected help exit 0, got %d\nstderr:\n%s\nstdout:\n%s", exitCode, stderr, stdout) + } + + combined := stdout + stderr + for _, want := range tc.want { + if !strings.Contains(combined, want) { + t.Fatalf("expected %s help to contain %q, got:\n%s", tc.name, want, combined) + } + } + }) + } +} diff --git a/packages/inbox-runtime/internal/cli/inbox/lifecycle_additional_test.go b/packages/inbox-runtime/internal/cli/inbox/lifecycle_additional_test.go new file mode 100644 index 0000000..0043750 --- /dev/null +++ b/packages/inbox-runtime/internal/cli/inbox/lifecycle_additional_test.go @@ -0,0 +1,629 @@ +package inbox + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func releaseLeaseForLifecycleTest(t *testing.T, dbPath, threadID string) { + t.Helper() + + sqlDB, err := openInboxDB(context.Background(), dbPath) + if err != nil { + t.Fatalf("open db: %v", err) + } + defer sqlDB.Close() + + if _, err := sqlDB.ExecContext(context.Background(), `UPDATE leases SET released_at = expires_at WHERE thread_id = ?`, threadID); err != nil { + t.Fatalf("release lease: %v", err) + } +} + +func assertInboxErrorMessageContains(t *testing.T, raw string, want string) { + t.Helper() + + var payload map[string]any + mustDecodeJSON(t, raw, &payload) + errorValue, ok := payload["error"].(map[string]any) + if !ok { + t.Fatalf("expected error object, got %#v", payload["error"]) + } + message, _ := errorValue["message"].(string) + if !strings.Contains(message, want) { + t.Fatalf("expected error message to contain %q, got %q", want, message) + } +} + +// TestClaimRejectsNonPendingThread verifies claim rejects threads that are no longer pending. +func TestClaimRejectsNonPendingThread(t *testing.T) { + t.Parallel() + + dbPath := initCommandTestDB(t) + threadID := sendPendingThread(t, dbPath, "leader", "worker-a", "Claimed already", "Trigger invalid state") + + runInboxCommand( + t, + "--db", dbPath, + "--json", + "claim", + "--agent", "worker-a", + "--thread", threadID, + ) + releaseLeaseForLifecycleTest(t, dbPath, threadID) + + stdout, _, exitCode := executeInboxCommand( + "--db", dbPath, + "--json", + "claim", + "--agent", "worker-a", + "--thread", threadID, + ) + if exitCode != 30 { + t.Fatalf("expected exit code 30, got %d with output %s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "invalid_state") + assertInboxErrorMessageContains(t, stdout, "is not pending") +} + +// TestInboxLifecycleCommandsRespectRootAgentAndPlainTextOutput verifies lifecycle commands accept the root agent flag and render text output. +func TestInboxLifecycleCommandsRespectRootAgentAndPlainTextOutput(t *testing.T) { + t.Parallel() + + t.Run("claim", func(t *testing.T) { + dbPath := initCommandTestDB(t) + threadID := sendPendingThread(t, dbPath, "leader", "worker-a", "Claim root agent", "Use root agent") + + stdout, stderr, exitCode := executeInboxCommand( + "--db", dbPath, + "--agent", "worker-a", + "claim", + "--thread", threadID, + ) + if exitCode != 0 { + t.Fatalf("expected exit 0, got %d\nstderr:\n%s\nstdout:\n%s", exitCode, stderr, stdout) + } + if !strings.Contains(stdout, "claimed thread "+threadID) { + t.Fatalf("expected claim output to mention thread %s, got %q", threadID, stdout) + } + }) + + t.Run("renew", func(t *testing.T) { + dbPath := initCommandTestDB(t) + threadID := sendPendingThread(t, dbPath, "leader", "worker-b", "Renew root agent", "Use root agent") + runInboxCommand(t, "--db", dbPath, "--json", "claim", "--agent", "worker-b", "--thread", threadID) + + stdout, stderr, exitCode := executeInboxCommand( + "--db", dbPath, + "--agent", "worker-b", + "renew", + "--thread", threadID, + ) + if exitCode != 0 { + t.Fatalf("expected exit 0, got %d\nstderr:\n%s\nstdout:\n%s", exitCode, stderr, stdout) + } + if !strings.Contains(stdout, "renewed lease on thread "+threadID) { + t.Fatalf("expected renew output to mention thread %s, got %q", threadID, stdout) + } + }) + + t.Run("update", func(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "coord.db") + threadID := seedClaimedThreadForInboxTests(t, dbPath, "leader", "worker-c", "worker-c") + + stdout, stderr, exitCode := executeInboxCommand( + "--db", dbPath, + "--agent", "worker-c", + "update", + "--thread", threadID, + "--status", "in_progress", + "--summary", "Started from root agent", + ) + if exitCode != 0 { + t.Fatalf("expected exit 0, got %d\nstderr:\n%s\nstdout:\n%s", exitCode, stderr, stdout) + } + if !strings.Contains(stdout, "updated thread "+threadID+" to in_progress") { + t.Fatalf("expected update output to mention thread %s, got %q", threadID, stdout) + } + }) + + t.Run("done", func(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "coord.db") + threadID := seedClaimedThreadForInboxTests(t, dbPath, "leader", "worker-d", "worker-d") + + stdout, stderr, exitCode := executeInboxCommand( + "--db", dbPath, + "--agent", "worker-d", + "done", + "--thread", threadID, + "--summary", "Finished via root agent", + ) + if exitCode != 0 { + t.Fatalf("expected exit 0, got %d\nstderr:\n%s\nstdout:\n%s", exitCode, stderr, stdout) + } + if !strings.Contains(stdout, "done thread "+threadID) { + t.Fatalf("expected done output to mention thread %s, got %q", threadID, stdout) + } + }) + + t.Run("fail", func(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "coord.db") + threadID := seedClaimedThreadForInboxTests(t, dbPath, "leader", "worker-e", "worker-e") + + stdout, stderr, exitCode := executeInboxCommand( + "--db", dbPath, + "--agent", "worker-e", + "fail", + "--thread", threadID, + "--summary", "Failed via root agent", + ) + if exitCode != 0 { + t.Fatalf("expected exit 0, got %d\nstderr:\n%s\nstdout:\n%s", exitCode, stderr, stdout) + } + if !strings.Contains(stdout, "fail thread "+threadID) { + t.Fatalf("expected fail output to mention thread %s, got %q", threadID, stdout) + } + }) + + t.Run("cancel", func(t *testing.T) { + dbPath := initCommandTestDB(t) + threadID := sendPendingThread(t, dbPath, "leader", "worker-f", "Cancel root agent", "Use root agent") + + stdout, stderr, exitCode := executeInboxCommand( + "--db", dbPath, + "--agent", "leader", + "cancel", + "--thread", threadID, + "--reason", "Task superseded", + ) + if exitCode != 0 { + t.Fatalf("expected exit 0, got %d\nstderr:\n%s\nstdout:\n%s", exitCode, stderr, stdout) + } + if !strings.Contains(stdout, "cancelled thread "+threadID) { + t.Fatalf("expected cancel output to mention thread %s, got %q", threadID, stdout) + } + }) +} + +// TestInboxLifecycleCommandHelpContracts verifies claim, renew, done, fail, and cancel help text explains their contracts. +func TestInboxLifecycleCommandHelpContracts(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + args []string + want []string + }{ + { + name: "claim", + args: []string{"claim", "--help"}, + want: []string{"Only one active lease may exist per thread at a time.", "Constraints:", "--lease-seconds 1800"}, + }, + { + name: "renew", + args: []string{"renew", "--help"}, + want: []string{"renew only applies when the caller already owns the active lease.", "Constraints:", "--lease-seconds 1800"}, + }, + { + name: "done", + args: []string{"done", "--help"}, + want: []string{"done is a terminal operation", "Constraints:", "--summary \"Implemented retry policy\""}, + }, + { + name: "fail", + args: []string{"fail", "--help"}, + want: []string{"fail is a terminal operation", "Constraints:", "retry, reassignment, or cancellation"}, + }, + { + name: "cancel", + args: []string{"cancel", "--help"}, + want: []string{"leader or operator action", "Constraints:", "--reason \"Task superseded by a new plan.\""}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + stdout, stderr, exitCode := executeInboxCommand(tc.args...) + if exitCode != 0 { + t.Fatalf("expected help exit 0, got %d\nstderr:\n%s\nstdout:\n%s", exitCode, stderr, stdout) + } + + combined := stdout + stderr + for _, want := range tc.want { + if !strings.Contains(combined, want) { + t.Fatalf("expected help for %s to contain %q, got:\n%s", tc.name, want, combined) + } + } + }) + } +} + +// TestRenewRejectsMissingAndTerminalThread verifies renew reports not found and terminal-thread errors. +func TestRenewRejectsMissingAndTerminalThread(t *testing.T) { + t.Parallel() + + t.Run("missing thread", func(t *testing.T) { + dbPath := initCommandTestDB(t) + + stdout, _, exitCode := executeInboxCommand( + "--db", dbPath, + "--json", + "renew", + "--agent", "worker-a", + "--thread", "thr_missing", + ) + if exitCode != 40 { + t.Fatalf("expected exit code 40, got %d with output %s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "not_found") + }) + + t.Run("terminal thread", func(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "coord.db") + threadID := seedClaimedThreadForInboxTests(t, dbPath, "leader", "worker-a", "worker-a") + runInboxCommand(t, "--db", dbPath, "--json", "done", "--agent", "worker-a", "--thread", threadID, "--summary", "Finished") + + stdout, _, exitCode := executeInboxCommand( + "--db", dbPath, + "--json", + "renew", + "--agent", "worker-a", + "--thread", threadID, + ) + if exitCode != 30 { + t.Fatalf("expected exit code 30, got %d with output %s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "invalid_state") + assertInboxErrorMessageContains(t, stdout, "already terminal") + }) +} + +// TestUpdateRejectsMissingAndTerminalThread verifies update rejects unknown or terminal threads. +func TestUpdateRejectsMissingAndTerminalThread(t *testing.T) { + t.Parallel() + + t.Run("missing thread", func(t *testing.T) { + dbPath := initCommandTestDB(t) + + stdout, _, exitCode := executeInboxCommand( + "--db", dbPath, + "--json", + "update", + "--agent", "worker-a", + "--thread", "thr_missing", + "--status", "in_progress", + "--summary", "Started", + ) + if exitCode != 40 { + t.Fatalf("expected exit code 40, got %d with output %s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "not_found") + }) + + t.Run("terminal thread", func(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "coord.db") + threadID := seedClaimedThreadForInboxTests(t, dbPath, "leader", "worker-a", "worker-a") + runInboxCommand(t, "--db", dbPath, "--json", "done", "--agent", "worker-a", "--thread", threadID, "--summary", "Finished") + + stdout, _, exitCode := executeInboxCommand( + "--db", dbPath, + "--json", + "update", + "--agent", "worker-a", + "--thread", threadID, + "--status", "blocked", + "--summary", "Need answer", + ) + if exitCode != 30 { + t.Fatalf("expected exit code 30, got %d with output %s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "invalid_state") + assertInboxErrorMessageContains(t, stdout, "already terminal") + }) +} + +// TestUpdateRejectsInvalidBodyFileInputs verifies update rejects mutually exclusive or unreadable body-file inputs. +func TestUpdateRejectsInvalidBodyFileInputs(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + threadID := seedClaimedThreadForInboxTests(t, dbPath, "leader", "worker-a", "worker-a") + + t.Run("body and body-file", func(t *testing.T) { + tempDir := t.TempDir() + bodyPath := filepath.Join(tempDir, "body.txt") + if err := os.WriteFile(bodyPath, []byte("from file"), 0o644); err != nil { + t.Fatalf("write body file: %v", err) + } + + stdout, _, exitCode := executeInboxCommand( + "--db", dbPath, + "--json", + "update", + "--agent", "worker-a", + "--thread", threadID, + "--status", "in_progress", + "--summary", "Started", + "--body", "inline", + "--body-file", bodyPath, + ) + if exitCode != 30 { + t.Fatalf("expected exit code 30, got %d with output %s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "invalid_input") + assertInboxErrorMessageContains(t, stdout, "mutually exclusive") + }) + + t.Run("unreadable body-file", func(t *testing.T) { + stdout, _, exitCode := executeInboxCommand( + "--db", dbPath, + "--json", + "update", + "--agent", "worker-a", + "--thread", threadID, + "--status", "in_progress", + "--summary", "Started", + "--body-file", filepath.Join(t.TempDir(), "missing.txt"), + ) + if exitCode != 30 { + t.Fatalf("expected exit code 30, got %d with output %s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "invalid_input") + assertInboxErrorMessageContains(t, stdout, "failed to read body-file") + }) +} + +// TestUpdateRejectsInvalidArtifactFlagCombinations verifies update validates artifact flag combinations before command execution. +func TestUpdateRejectsInvalidArtifactFlagCombinations(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + threadID := seedClaimedThreadForInboxTests(t, dbPath, "leader", "worker-a", "worker-a") + + cases := []struct { + name string + args []string + want string + }{ + { + name: "kind without artifact", + args: []string{"--artifact-kind", "log"}, + want: "require at least one artifact path", + }, + { + name: "empty artifact path", + args: []string{"--artifact", " "}, + want: "artifact path cannot be empty", + }, + { + name: "artifact-kind count mismatch", + args: []string{"--artifact", "a.log", "--artifact", "b.log", "--artifact-kind", "log", "--artifact-kind", "brief", "--artifact-kind", "extra"}, + want: "artifact-kind must be specified once or once per artifact", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + args := []string{ + "--db", dbPath, + "--json", + "update", + "--agent", "worker-a", + "--thread", threadID, + "--status", "in_progress", + "--summary", "Started", + } + args = append(args, tc.args...) + + stdout, _, exitCode := executeInboxCommand(args...) + if exitCode != 30 { + t.Fatalf("expected exit code 30, got %d with output %s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "invalid_input") + assertInboxErrorMessageContains(t, stdout, tc.want) + }) + } +} + +// TestDoneAndFailRejectMissingThread verifies terminal commands reject unknown threads. +func TestDoneAndFailRejectMissingThread(t *testing.T) { + t.Parallel() + + for _, command := range []string{"done", "fail"} { + t.Run(command, func(t *testing.T) { + dbPath := initCommandTestDB(t) + + stdout, _, exitCode := executeInboxCommand( + "--db", dbPath, + "--json", + command, + "--agent", "worker-a", + "--thread", "thr_missing", + "--summary", "Missing thread", + ) + if exitCode != 40 { + t.Fatalf("expected exit code 40, got %d with output %s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "not_found") + }) + } +} + +// TestDoneAndFailRejectInvalidBodyFileInputs verifies terminal commands reject bad body-file inputs. +func TestDoneAndFailRejectInvalidBodyFileInputs(t *testing.T) { + t.Parallel() + + cases := []struct { + command string + agent string + }{ + {command: "done", agent: "worker-a"}, + {command: "fail", agent: "worker-b"}, + } + + for _, tc := range cases { + t.Run(tc.command, func(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "coord.db") + threadID := seedClaimedThreadForInboxTests(t, dbPath, "leader", tc.agent, tc.agent) + bodyPath := filepath.Join(t.TempDir(), "body.txt") + if err := os.WriteFile(bodyPath, []byte("from file"), 0o644); err != nil { + t.Fatalf("write body file: %v", err) + } + + stdout, _, exitCode := executeInboxCommand( + "--db", dbPath, + "--json", + tc.command, + "--agent", tc.agent, + "--thread", threadID, + "--summary", "Finished", + "--body", "inline", + "--body-file", bodyPath, + ) + if exitCode != 30 { + t.Fatalf("expected exit code 30, got %d with output %s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "invalid_input") + assertInboxErrorMessageContains(t, stdout, "mutually exclusive") + + stdout, _, exitCode = executeInboxCommand( + "--db", dbPath, + "--json", + tc.command, + "--agent", tc.agent, + "--thread", threadID, + "--summary", "Finished", + "--body-file", filepath.Join(t.TempDir(), "missing.txt"), + ) + if exitCode != 30 { + t.Fatalf("expected exit code 30, got %d with output %s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "invalid_input") + assertInboxErrorMessageContains(t, stdout, "failed to read body-file") + }) + } +} + +// TestDoneAndFailRejectInvalidArtifactFlagCombinations verifies terminal commands validate artifact flag combinations. +func TestDoneAndFailRejectInvalidArtifactFlagCombinations(t *testing.T) { + t.Parallel() + + cases := []struct { + command string + agent string + args []string + want string + }{ + { + command: "done", + agent: "worker-a", + args: []string{"--artifact-kind", "brief"}, + want: "require at least one artifact path", + }, + { + command: "fail", + agent: "worker-b", + args: []string{"--artifact", "a.log", "--artifact", "b.log", "--artifact-metadata-json", `{}`, "--artifact-metadata-json", `{}`, "--artifact-metadata-json", `{}`}, + want: "artifact-metadata-json must be specified once or once per artifact", + }, + } + + for _, tc := range cases { + t.Run(tc.command, func(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "coord.db") + threadID := seedClaimedThreadForInboxTests(t, dbPath, "leader", tc.agent, tc.agent) + + args := []string{ + "--db", dbPath, + "--json", + tc.command, + "--agent", tc.agent, + "--thread", threadID, + "--summary", "Finished", + } + args = append(args, tc.args...) + + stdout, _, exitCode := executeInboxCommand(args...) + if exitCode != 30 { + t.Fatalf("expected exit code 30, got %d with output %s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "invalid_input") + assertInboxErrorMessageContains(t, stdout, tc.want) + }) + } +} + +// TestCancelRejectsTerminalThread verifies cancel rejects threads that are already terminal. +func TestCancelRejectsTerminalThread(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + threadID := seedClaimedThreadForInboxTests(t, dbPath, "leader", "worker-a", "worker-a") + runInboxCommand(t, "--db", dbPath, "--json", "done", "--agent", "worker-a", "--thread", threadID, "--summary", "Finished") + + stdout, _, exitCode := executeInboxCommand( + "--db", dbPath, + "--json", + "cancel", + "--agent", "leader", + "--thread", threadID, + ) + if exitCode != 30 { + t.Fatalf("expected exit code 30, got %d with output %s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "invalid_state") + assertInboxErrorMessageContains(t, stdout, "already terminal") +} + +// TestCancelUsesDefaultReasonWhenOmitted verifies cancel falls back to the default summary when no reason is supplied. +func TestCancelUsesDefaultReasonWhenOmitted(t *testing.T) { + t.Parallel() + + dbPath := initCommandTestDB(t) + threadID := sendPendingThread(t, dbPath, "leader", "worker-a", "Default cancel reason", "Check default summary") + + runInboxCommand( + t, + "--db", dbPath, + "--json", + "cancel", + "--agent", "leader", + "--thread", threadID, + ) + + showOut := runInboxCommand(t, "--db", dbPath, "--json", "show", "--thread", threadID) + var showResp map[string]any + mustDecodeJSON(t, showOut, &showResp) + + lastMessage := lastThreadMessageFromShow(t, showResp) + if got := lastMessage["summary"]; got != "thread cancelled" { + t.Fatalf("expected default cancel summary, got %#v", got) + } + if got := lastMessage["body"]; got != "" { + t.Fatalf("expected empty cancel body when reason omitted, got %#v", got) + } +} + +// TestCancelRejectsInvalidArtifactFlagCombinations verifies cancel validates artifact flag combinations. +func TestCancelRejectsInvalidArtifactFlagCombinations(t *testing.T) { + t.Parallel() + + dbPath := initCommandTestDB(t) + threadID := sendPendingThread(t, dbPath, "leader", "worker-a", "Cancel invalid artifact", "Check artifact validation") + + stdout, _, exitCode := executeInboxCommand( + "--db", dbPath, + "--json", + "cancel", + "--agent", "leader", + "--thread", threadID, + "--artifact-metadata-json", `{}`, + ) + if exitCode != 30 { + t.Fatalf("expected exit code 30, got %d with output %s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "invalid_input") + assertInboxErrorMessageContains(t, stdout, "require at least one artifact path") +} diff --git a/packages/inbox-runtime/internal/cli/inbox/query_wait_additional_test.go b/packages/inbox-runtime/internal/cli/inbox/query_wait_additional_test.go new file mode 100644 index 0000000..52639f6 --- /dev/null +++ b/packages/inbox-runtime/internal/cli/inbox/query_wait_additional_test.go @@ -0,0 +1,597 @@ +package inbox + +import ( + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" +) + +// TestSendRejectsMissingFromAgent verifies send requires a sender when no root agent is set. +func TestSendRejectsMissingFromAgent(t *testing.T) { + t.Parallel() + + dbPath := initCommandTestDB(t) + + stdout, _, exitCode := executeInboxCommand( + "--db", dbPath, + "--json", + "send", + "--to", "worker-a", + "--subject", "Investigate flaky test", + ) + if exitCode != 30 { + t.Fatalf("expected invalid_input exit code 30, got %d\nstdout:\n%s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "invalid_input") + assertQueryWaitErrorMessageContains(t, stdout, "from agent is required") +} + +// TestSendRejectsMissingSubjectWhenCreatingThread verifies send requires subject for a new thread. +func TestSendRejectsMissingSubjectWhenCreatingThread(t *testing.T) { + t.Parallel() + + dbPath := initCommandTestDB(t) + + stdout, _, exitCode := executeInboxCommand( + "--db", dbPath, + "--json", + "send", + "--from", "leader", + "--to", "worker-a", + "--summary", "Missing subject", + ) + if exitCode != 30 { + t.Fatalf("expected invalid_input exit code 30, got %d\nstdout:\n%s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "invalid_input") + assertQueryWaitErrorMessageContains(t, stdout, "subject is required when creating a new thread") +} + +// TestSendRejectsBodyAndBodyFileTogether verifies send rejects mutually exclusive body inputs. +func TestSendRejectsBodyAndBodyFileTogether(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + dbPath := filepath.Join(tempDir, "coord.db") + bodyPath := filepath.Join(tempDir, "brief.md") + if err := os.WriteFile(bodyPath, []byte("brief"), 0o644); err != nil { + t.Fatalf("write body file: %v", err) + } + runInboxCommand(t, "--db", dbPath, "--json", "init") + + stdout, _, exitCode := executeInboxCommand( + "--db", dbPath, + "--json", + "send", + "--from", "leader", + "--to", "worker-a", + "--subject", "Investigate flaky test", + "--body", "inline", + "--body-file", bodyPath, + ) + if exitCode != 30 { + t.Fatalf("expected invalid_input exit code 30, got %d\nstdout:\n%s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "invalid_input") + assertQueryWaitErrorMessageContains(t, stdout, "body and body-file are mutually exclusive") +} + +// TestSendRejectsUnreadableBodyFile verifies send reports unreadable body files. +func TestSendRejectsUnreadableBodyFile(t *testing.T) { + t.Parallel() + + dbPath := initCommandTestDB(t) + + stdout, _, exitCode := executeInboxCommand( + "--db", dbPath, + "--json", + "send", + "--from", "leader", + "--to", "worker-a", + "--subject", "Investigate flaky test", + "--body-file", filepath.Join(t.TempDir(), "missing.md"), + ) + if exitCode != 30 { + t.Fatalf("expected invalid_input exit code 30, got %d\nstdout:\n%s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "invalid_input") + assertQueryWaitErrorMessageContains(t, stdout, "failed to read body-file") +} + +// TestSendRejectsArtifactKindCountMismatch verifies send validates artifact flag cardinality. +func TestSendRejectsArtifactKindCountMismatch(t *testing.T) { + t.Parallel() + + dbPath := initCommandTestDB(t) + + stdout, _, exitCode := executeInboxCommand( + "--db", dbPath, + "--json", + "send", + "--from", "leader", + "--to", "worker-a", + "--subject", "Investigate flaky test", + "--artifact", "/tmp/task.md", + "--artifact-kind", "brief", + "--artifact-kind", "log", + ) + if exitCode != 30 { + t.Fatalf("expected invalid_input exit code 30, got %d\nstdout:\n%s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "invalid_input") + assertQueryWaitErrorMessageContains(t, stdout, "artifact-kind must be specified once or once per artifact") +} + +// TestSendUsesRootAgentAndPlainTextOutput verifies send falls back to the root agent and renders plain text output. +func TestSendUsesRootAgentAndPlainTextOutput(t *testing.T) { + t.Parallel() + + dbPath := initCommandTestDB(t) + + stdout := runInboxCommand( + t, + "--db", dbPath, + "--agent", "leader", + "send", + "--to", "worker-a", + "--subject", "Investigate flaky test", + "--summary", "Check latest failures", + ) + threadID := parsePlainThreadID(t, stdout, "created thread ") + + showOut := runInboxCommand( + t, + "--db", dbPath, + "--json", + "show", + "--thread", threadID, + ) + + var showResp map[string]any + mustDecodeJSON(t, showOut, &showResp) + if got := nestedString(t, showResp, "data", "thread", "created_by"); got != "leader" { + t.Fatalf("expected created_by leader, got %q", got) + } +} + +// TestReplyRejectsMissingFromAgent verifies reply requires a sender when no root agent is set. +func TestReplyRejectsMissingFromAgent(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + threadID := seedThreadForInboxTests(t, dbPath, "leader", "worker-a") + + stdout, _, exitCode := executeInboxCommand( + "--db", dbPath, + "--json", + "reply", + "--to", "worker-a", + "--thread", threadID, + "--summary", "Use stdout", + ) + if exitCode != 30 { + t.Fatalf("expected invalid_input exit code 30, got %d\nstdout:\n%s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "invalid_input") + assertQueryWaitErrorMessageContains(t, stdout, "from agent is required") +} + +// TestReplyRejectsBodyAndBodyFileTogether verifies reply rejects mutually exclusive body inputs. +func TestReplyRejectsBodyAndBodyFileTogether(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + dbPath := filepath.Join(tempDir, "coord.db") + bodyPath := filepath.Join(tempDir, "reply.md") + if err := os.WriteFile(bodyPath, []byte("reply"), 0o644); err != nil { + t.Fatalf("write body file: %v", err) + } + threadID := seedThreadForInboxTests(t, dbPath, "leader", "worker-a") + + stdout, _, exitCode := executeInboxCommand( + "--db", dbPath, + "--json", + "reply", + "--from", "leader", + "--to", "worker-a", + "--thread", threadID, + "--summary", "Use stdout", + "--body", "inline", + "--body-file", bodyPath, + ) + if exitCode != 30 { + t.Fatalf("expected invalid_input exit code 30, got %d\nstdout:\n%s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "invalid_input") + assertQueryWaitErrorMessageContains(t, stdout, "body and body-file are mutually exclusive") +} + +// TestReplyRejectsUnreadableBodyFile verifies reply reports unreadable body files. +func TestReplyRejectsUnreadableBodyFile(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + threadID := seedThreadForInboxTests(t, dbPath, "leader", "worker-a") + + stdout, _, exitCode := executeInboxCommand( + "--db", dbPath, + "--json", + "reply", + "--from", "leader", + "--to", "worker-a", + "--thread", threadID, + "--summary", "Use stdout", + "--body-file", filepath.Join(t.TempDir(), "missing.md"), + ) + if exitCode != 30 { + t.Fatalf("expected invalid_input exit code 30, got %d\nstdout:\n%s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "invalid_input") + assertQueryWaitErrorMessageContains(t, stdout, "failed to read body-file") +} + +// TestReplyRejectsArtifactMetadataCountMismatch verifies reply validates artifact metadata cardinality. +func TestReplyRejectsArtifactMetadataCountMismatch(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + threadID := seedThreadForInboxTests(t, dbPath, "leader", "worker-a") + + stdout, _, exitCode := executeInboxCommand( + "--db", dbPath, + "--json", + "reply", + "--from", "leader", + "--to", "worker-a", + "--thread", threadID, + "--summary", "Use stdout", + "--artifact", "/tmp/decision.md", + "--artifact-metadata-json", `{"label":"a"}`, + "--artifact-metadata-json", `{"label":"b"}`, + ) + if exitCode != 30 { + t.Fatalf("expected invalid_input exit code 30, got %d\nstdout:\n%s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "invalid_input") + assertQueryWaitErrorMessageContains(t, stdout, "artifact-metadata-json must be specified once or once per artifact") +} + +// TestReplyUsesRootAgentAndPlainTextOutput verifies reply falls back to the root agent and renders plain text output. +func TestReplyUsesRootAgentAndPlainTextOutput(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + threadID := seedThreadForInboxTests(t, dbPath, "leader", "worker-a") + + stdout := runInboxCommand( + t, + "--db", dbPath, + "--agent", "leader", + "reply", + "--to", "worker-a", + "--thread", threadID, + "--summary", "Use stdout", + ) + if got := strings.TrimSpace(stdout); got != "replied on thread "+threadID { + t.Fatalf("expected plain text reply output for %q, got %q", threadID, got) + } + + showOut := runInboxCommand(t, "--db", dbPath, "--json", "show", "--thread", threadID) + var showResp map[string]any + mustDecodeJSON(t, showOut, &showResp) + lastMessage := lastThreadMessageFromShow(t, showResp) + if got, _ := lastMessage["from_agent"].(string); got != "leader" { + t.Fatalf("expected last from_agent leader, got %#v", lastMessage["from_agent"]) + } +} + +// TestFetchDefaultsToPendingWithRootAgentAndPlainText verifies fetch uses pending by default and respects the root agent. +func TestFetchDefaultsToPendingWithRootAgentAndPlainText(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + runInboxCommand(t, "--db", dbPath, "--json", "init") + + pendingThreadID := sendPendingThread(t, dbPath, "leader", "worker-a", "Pending work", "Check latest failures") + blockedThreadID := sendPendingThread(t, dbPath, "leader", "worker-a", "Blocked work", "Need product decision") + runInboxCommand(t, "--db", dbPath, "--json", "claim", "--agent", "worker-a", "--thread", blockedThreadID) + runInboxCommand( + t, + "--db", dbPath, + "--json", + "update", + "--agent", "worker-a", + "--thread", blockedThreadID, + "--status", "blocked", + "--summary", "Need product decision", + ) + + stdout := runInboxCommand(t, "--db", dbPath, "--agent", "worker-a", "fetch") + lines := splitNonEmptyLines(stdout) + if len(lines) != 1 { + t.Fatalf("expected one plain text fetch row, got %#v", lines) + } + if !strings.Contains(lines[0], pendingThreadID+"\tpending\tPending work") { + t.Fatalf("expected pending thread %q in fetch output, got %q", pendingThreadID, lines[0]) + } + if strings.Contains(lines[0], blockedThreadID) { + t.Fatalf("did not expect blocked thread %q in default fetch output %q", blockedThreadID, lines[0]) + } +} + +// TestListReturnsNoMatchingWork verifies list returns no_matching_work when filters match nothing. +func TestListReturnsNoMatchingWork(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + runInboxCommand(t, "--db", dbPath, "--json", "init") + sendPendingThread(t, dbPath, "leader", "worker-a", "Pending work", "Check latest failures") + + stdout, _, exitCode := executeInboxCommand( + "--db", dbPath, + "--json", + "list", + "--assigned-to", "worker-z", + ) + if exitCode != 10 { + t.Fatalf("expected no_matching_work exit code 10, got %d\nstdout:\n%s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "no_matching_work") +} + +// TestListPlainTextOutput verifies list renders plain text rows for matching threads. +func TestListPlainTextOutput(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + runInboxCommand(t, "--db", dbPath, "--json", "init") + threadID := sendPendingThread(t, dbPath, "leader", "worker-a", "Pending work", "Check latest failures") + + stdout := runInboxCommand( + t, + "--db", dbPath, + "list", + "--assigned-to", "worker-a", + "--status", "pending", + ) + lines := splitNonEmptyLines(stdout) + if len(lines) != 1 { + t.Fatalf("expected one plain text list row, got %#v", lines) + } + if !strings.Contains(lines[0], threadID+"\tpending\tworker-a\tPending work") { + t.Fatalf("expected thread %q in list output, got %q", threadID, lines[0]) + } +} + +// TestShowRejectsMarkReadWithoutAgent verifies show requires an agent for --mark-read. +func TestShowRejectsMarkReadWithoutAgent(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + threadID := seedThreadForInboxTests(t, dbPath, "leader", "worker-a") + + stdout, _, exitCode := executeInboxCommand( + "--db", dbPath, + "--json", + "show", + "--thread", threadID, + "--mark-read", + ) + if exitCode != 30 { + t.Fatalf("expected invalid_input exit code 30, got %d\nstdout:\n%s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "invalid_input") + assertQueryWaitErrorMessageContains(t, stdout, "agent is required when using --mark-read") +} + +// TestShowPlainTextOutput verifies show renders plain text thread history including artifacts. +func TestShowPlainTextOutput(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + dbPath := filepath.Join(tempDir, "coord.db") + artifactPath := filepath.Join(tempDir, "task.md") + if err := os.WriteFile(artifactPath, []byte("task brief"), 0o644); err != nil { + t.Fatalf("write artifact file: %v", err) + } + runInboxCommand(t, "--db", dbPath, "--json", "init") + + sendOut := runInboxCommand( + t, + "--db", dbPath, + "--json", + "send", + "--from", "leader", + "--to", "worker-a", + "--subject", "Artifact task", + "--summary", "Attach brief", + "--artifact", artifactPath, + "--artifact-kind", "brief", + ) + var sendResp map[string]any + mustDecodeJSON(t, sendOut, &sendResp) + threadID := nestedString(t, sendResp, "data", "thread", "thread_id") + + stdout := runInboxCommand(t, "--db", dbPath, "show", "--thread", threadID) + if !strings.Contains(stdout, threadID+"\tpending\tArtifact task") { + t.Fatalf("expected thread header in show output, got %q", stdout) + } + if !strings.Contains(stdout, "- ") || !strings.Contains(stdout, "\ttask\tAttach brief") { + t.Fatalf("expected message row in show output, got %q", stdout) + } + if !strings.Contains(stdout, " artifact\tbrief\t"+artifactPath) { + t.Fatalf("expected artifact row in show output, got %q", stdout) + } +} + +// TestWatchUsesAfterEventResumeAndPlainTextOutput verifies watch resumes from after-event and renders plain text output. +func TestWatchUsesAfterEventResumeAndPlainTextOutput(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + runInboxCommand(t, "--db", dbPath, "--json", "init") + threadID := sendPendingThread(t, dbPath, "leader", "worker-c", "Pending work", "Check latest failures") + + firstWatchOut := runInboxCommand( + t, + "--db", dbPath, + "--json", + "watch", + "--agent", "worker-c", + "--status", "pending", + "--after-event", "0", + "--timeout-seconds", "1", + ) + var firstWatchResp map[string]any + mustDecodeJSON(t, firstWatchOut, &firstWatchResp) + firstEventID := int64(nestedValue(t, firstWatchResp, "data", "next_event_id").(float64)) + + watchCh := make(chan watchCommandResult, 1) + go func() { + stdout, stderr, exitCode := executeInboxCommand( + "--db", dbPath, + "--agent", "worker-c", + "watch", + "--status", "blocked", + "--after-event", strconv.FormatInt(firstEventID, 10), + "--timeout-seconds", "2", + ) + watchCh <- watchCommandResult{stdout: stdout, stderr: stderr, exit: exitCode} + }() + + time.Sleep(200 * time.Millisecond) + + runInboxCommand(t, "--db", dbPath, "--json", "claim", "--agent", "worker-c", "--thread", threadID) + runInboxCommand( + t, + "--db", dbPath, + "--json", + "update", + "--agent", "worker-c", + "--thread", threadID, + "--status", "blocked", + "--summary", "Need product decision", + ) + + select { + case result := <-watchCh: + if result.exit != 0 { + t.Fatalf("watch failed with exit=%d\nstderr:\n%s\nstdout:\n%s", result.exit, result.stderr, result.stdout) + } + if got := strings.TrimSpace(result.stdout); got != "watch woke on thread "+threadID+" at event "+strconv.FormatInt(firstEventID+2, 10) && !strings.Contains(got, "watch woke on thread "+threadID+" at event ") { + t.Fatalf("expected plain text wake output for %q, got %q", threadID, got) + } + case <-time.After(3 * time.Second): + t.Fatal("watch command did not return") + } +} + +// TestWaitReplyUsesRootAgentAndPlainTextOutput verifies wait-reply works with the root agent and plain text output. +func TestWaitReplyUsesRootAgentAndPlainTextOutput(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + threadID, blockedMessageID := seedBlockedThreadForWaitReply(t, dbPath) + + waitCh := make(chan waitReplyCommandResult, 1) + go func() { + stdout, stderr, exitCode := executeInboxCommand( + "--db", dbPath, + "--agent", "worker-c", + "wait-reply", + "--thread", threadID, + "--after-message", blockedMessageID, + "--timeout-seconds", "2", + ) + waitCh <- waitReplyCommandResult{stdout: stdout, stderr: stderr, exit: exitCode} + }() + + time.Sleep(200 * time.Millisecond) + + runInboxCommand( + t, + "--db", dbPath, + "--json", + "reply", + "--from", "leader", + "--to", "worker-c", + "--thread", threadID, + "--summary", "Use stdout", + ) + + select { + case result := <-waitCh: + if result.exit != 0 { + t.Fatalf("wait-reply failed with exit=%d\nstderr:\n%s\nstdout:\n%s", result.exit, result.stderr, result.stdout) + } + if got := strings.TrimSpace(result.stdout); !strings.Contains(got, "reply received on thread "+threadID+" at event ") { + t.Fatalf("expected plain text wait-reply output for %q, got %q", threadID, got) + } + case <-time.After(3 * time.Second): + t.Fatal("wait-reply command did not return") + } +} + +// TestWaitReplyRejectsUnknownMessageCursor verifies wait-reply reports not_found for an unknown message cursor. +func TestWaitReplyRejectsUnknownMessageCursor(t *testing.T) { + t.Parallel() + + dbPath := initCommandTestDB(t) + + stdout, _, exitCode := executeInboxCommand( + "--db", dbPath, + "--json", + "wait-reply", + "--thread", "thr_missing", + "--after-message", "msg_missing", + ) + if exitCode != 40 { + t.Fatalf("expected not_found exit code 40, got %d\nstdout:\n%s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "not_found") + assertQueryWaitErrorMessageContains(t, stdout, "message msg_missing not found in thread thr_missing") +} + +func assertQueryWaitErrorMessageContains(t *testing.T, raw string, want string) { + t.Helper() + + var payload map[string]any + mustDecodeJSON(t, raw, &payload) + errorValue, ok := payload["error"].(map[string]any) + if !ok { + t.Fatalf("expected error object, got %#v", payload["error"]) + } + message, _ := errorValue["message"].(string) + if !strings.Contains(message, want) { + t.Fatalf("expected error message to contain %q, got %q", want, message) + } +} + +func parsePlainThreadID(t *testing.T, stdout string, prefix string) string { + t.Helper() + + line := strings.TrimSpace(stdout) + if !strings.HasPrefix(line, prefix) { + t.Fatalf("expected prefix %q in output %q", prefix, stdout) + } + threadID := strings.TrimSpace(strings.TrimPrefix(line, prefix)) + if threadID == "" { + t.Fatalf("expected thread ID in output %q", stdout) + } + return threadID +} + +func splitNonEmptyLines(stdout string) []string { + lines := strings.Split(strings.TrimSpace(stdout), "\n") + out := make([]string, 0, len(lines)) + for _, line := range lines { + line = strings.TrimSpace(line) + if line != "" { + out = append(out, line) + } + } + return out +} diff --git a/packages/orch-runtime/internal/cli/orch/core_additional_contracts_more_test.go b/packages/orch-runtime/internal/cli/orch/core_additional_contracts_more_test.go new file mode 100644 index 0000000..8e8ada6 --- /dev/null +++ b/packages/orch-runtime/internal/cli/orch/core_additional_contracts_more_test.go @@ -0,0 +1,342 @@ +package orch + +import ( + "fmt" + "path/filepath" + "strings" + "testing" +) + +// TestOrchRunInitRejectsDuplicateRun verifies orch run init rejects a duplicate run ID. +func TestOrchRunInitRejectsDuplicateRun(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "run", "init", + "--run", "run_blog_dup_001", + "--goal", "Create the initial run", + ) + + stdout, _, exitCode := executeOrchCommand( + "--db", dbPath, + "--json", + "run", "init", + "--run", "run_blog_dup_001", + "--goal", "Try creating the same run again", + ) + if exitCode != 30 { + t.Fatalf("expected invalid_state exit code 30, got %d\nstdout:\n%s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "invalid_state") + assertErrorMessageContains(t, stdout, "already exists") +} + +// TestOrchRunShowPlainTextOutput verifies orch run show prints the human-readable summary. +func TestOrchRunShowPlainTextOutput(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "run", "init", + "--run", "run_blog_show_plain_001", + "--goal", "Inspect plain-text status output", + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "task", "add", + "--run", "run_blog_show_plain_001", + "--task", "T1", + "--title", "Make the run ready", + ) + + stdout, stderr, exitCode := executeOrchCommand( + "--db", dbPath, + "run", "show", + "--run", "run_blog_show_plain_001", + ) + if exitCode != 0 { + t.Fatalf("expected success exit code, got %d\nstderr:\n%s\nstdout:\n%s", exitCode, stderr, stdout) + } + if stderr != "" { + t.Fatalf("expected empty stderr, got:\n%s", stderr) + } + if got := strings.TrimSpace(stdout); got != "run run_blog_show_plain_001 status ready" { + t.Fatalf("expected plain-text run summary, got %q", got) + } +} + +// TestOrchTaskAddRejectsDuplicateTask verifies orch task add rejects a duplicate task ID in one run. +func TestOrchTaskAddRejectsDuplicateTask(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + seedCoreAdditionalRun(t, dbPath, "run_blog_task_dup_001", "Validate duplicate task rejection") + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "task", "add", + "--run", "run_blog_task_dup_001", + "--task", "T1", + "--title", "Create the original task", + ) + + stdout, _, exitCode := executeOrchCommand( + "--db", dbPath, + "--json", + "task", "add", + "--run", "run_blog_task_dup_001", + "--task", "T1", + "--title", "Create the duplicate task", + ) + if exitCode != 30 { + t.Fatalf("expected invalid_state exit code 30, got %d\nstdout:\n%s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "invalid_state") + assertErrorMessageContains(t, stdout, "already exists") +} + +// TestOrchTaskAddRejectsSpecSHAWithoutSpecFile verifies orch task add rejects spec-sha when spec-file is missing. +func TestOrchTaskAddRejectsSpecSHAWithoutSpecFile(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + seedCoreAdditionalRun(t, dbPath, "run_blog_task_sha_001", "Validate spec-sha guard") + + stdout, _, exitCode := executeOrchCommand( + "--db", dbPath, + "--json", + "task", "add", + "--run", "run_blog_task_sha_001", + "--task", "T1", + "--title", "Reject missing spec file", + "--spec-sha", "deadbeef", + ) + if exitCode != 30 { + t.Fatalf("expected invalid_input exit code 30, got %d\nstdout:\n%s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "invalid_input") + assertErrorMessageContains(t, stdout, "spec-sha requires spec-file") +} + +// TestOrchTaskAddRejectsUnreadableSpecFile verifies orch task add rejects an unreadable spec file. +func TestOrchTaskAddRejectsUnreadableSpecFile(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + dbPath := filepath.Join(tempDir, "coord.db") + seedCoreAdditionalRun(t, dbPath, "run_blog_task_spec_001", "Validate unreadable spec file") + + stdout, _, exitCode := executeOrchCommand( + "--db", dbPath, + "--json", + "task", "add", + "--run", "run_blog_task_spec_001", + "--task", "T1", + "--title", "Reject unreadable spec file", + "--spec-file", filepath.Join(tempDir, "missing-task.md"), + ) + if exitCode != 30 { + t.Fatalf("expected invalid_input exit code 30, got %d\nstdout:\n%s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "invalid_input") + assertErrorMessageContains(t, stdout, "failed to read spec-file") +} + +// TestOrchDepAddRejectsSelfDependency verifies orch dep add rejects a task depending on itself. +func TestOrchDepAddRejectsSelfDependency(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + seedCoreAdditionalRun(t, dbPath, "run_blog_dep_self_001", "Validate self-dependency") + seedCoreAdditionalTask(t, dbPath, "run_blog_dep_self_001", "T1", "One task") + + stdout, _, exitCode := executeOrchCommand( + "--db", dbPath, + "--json", + "dep", "add", + "--run", "run_blog_dep_self_001", + "--task", "T1", + "--depends-on", "T1", + ) + if exitCode != 30 { + t.Fatalf("expected invalid_input exit code 30, got %d\nstdout:\n%s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "invalid_input") + assertErrorMessageContains(t, stdout, "task cannot depend on itself") +} + +// TestOrchDepAddRejectsDuplicateDependency verifies orch dep add rejects a duplicate edge. +func TestOrchDepAddRejectsDuplicateDependency(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + seedCoreAdditionalRun(t, dbPath, "run_blog_dep_dup_001", "Validate duplicate dependency") + seedCoreAdditionalTask(t, dbPath, "run_blog_dep_dup_001", "T1", "Prerequisite task") + seedCoreAdditionalTask(t, dbPath, "run_blog_dep_dup_001", "T2", "Dependent task") + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "dep", "add", + "--run", "run_blog_dep_dup_001", + "--task", "T2", + "--depends-on", "T1", + ) + + stdout, _, exitCode := executeOrchCommand( + "--db", dbPath, + "--json", + "dep", "add", + "--run", "run_blog_dep_dup_001", + "--task", "T2", + "--depends-on", "T1", + ) + if exitCode != 30 { + t.Fatalf("expected invalid_state exit code 30, got %d\nstdout:\n%s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "invalid_state") + assertErrorMessageContains(t, stdout, "already exists") +} + +// TestOrchReadyRejectsMissingRun verifies orch ready rejects a missing run. +func TestOrchReadyRejectsMissingRun(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + + stdout, _, exitCode := executeOrchCommand( + "--db", dbPath, + "--json", + "ready", + "--run", "run_blog_ready_missing_001", + ) + if exitCode != 40 { + t.Fatalf("expected not_found exit code 40, got %d\nstdout:\n%s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "not_found") +} + +// TestOrchReadyPrintsNoReadyTasks verifies orch ready prints the no-work message for an empty run. +func TestOrchReadyPrintsNoReadyTasks(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + seedCoreAdditionalRun(t, dbPath, "run_blog_ready_empty_001", "Validate empty ready queue") + + stdout, stderr, exitCode := executeOrchCommand( + "--db", dbPath, + "ready", + "--run", "run_blog_ready_empty_001", + ) + if exitCode != 0 { + t.Fatalf("expected success exit code, got %d\nstderr:\n%s\nstdout:\n%s", exitCode, stderr, stdout) + } + if stderr != "" { + t.Fatalf("expected empty stderr, got:\n%s", stderr) + } + if got := strings.TrimSpace(stdout); got != "no ready tasks" { + t.Fatalf("expected no ready tasks message, got %q", got) + } +} + +// TestOrchReadyPrintsPlainTextTasks verifies orch ready prints tabular plain-text results. +func TestOrchReadyPrintsPlainTextTasks(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + seedCoreAdditionalRun(t, dbPath, "run_blog_ready_plain_001", "Validate plain-text ready output") + seedCoreAdditionalTask(t, dbPath, "run_blog_ready_plain_001", "T1", "First task") + seedCoreAdditionalTask(t, dbPath, "run_blog_ready_plain_001", "T2", "Second task") + + stdout, stderr, exitCode := executeOrchCommand( + "--db", dbPath, + "ready", + "--run", "run_blog_ready_plain_001", + ) + if exitCode != 0 { + t.Fatalf("expected success exit code, got %d\nstderr:\n%s\nstdout:\n%s", exitCode, stderr, stdout) + } + if stderr != "" { + t.Fatalf("expected empty stderr, got:\n%s", stderr) + } + if !strings.Contains(stdout, "T1\tnormal\tFirst task") { + t.Fatalf("expected plain-text output to contain T1 row, got:\n%s", stdout) + } + if !strings.Contains(stdout, "T2\tnormal\tSecond task") { + t.Fatalf("expected plain-text output to contain T2 row, got:\n%s", stdout) + } +} + +// TestOrchReadyDefaultsLimitWhenNonPositive verifies orch ready falls back to the default limit when limit is non-positive. +func TestOrchReadyDefaultsLimitWhenNonPositive(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + seedCoreAdditionalRun(t, dbPath, "run_blog_ready_limit_001", "Validate default ready limit") + for i := 1; i <= 25; i++ { + seedCoreAdditionalTask( + t, + dbPath, + "run_blog_ready_limit_001", + fmt.Sprintf("T%02d", i), + fmt.Sprintf("Task %02d", i), + ) + } + + readyOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "ready", + "--run", "run_blog_ready_limit_001", + "--limit", "0", + ) + + var readyResp map[string]any + mustDecodeJSON(t, readyOut, &readyResp) + readyTasks := nestedArray(t, readyResp, "data", "tasks") + if len(readyTasks) != 20 { + t.Fatalf("expected default ready limit of 20 tasks, got %#v", len(readyTasks)) + } +} + +func seedCoreAdditionalRun(t *testing.T, dbPath, runID, goal string) { + t.Helper() + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "run", "init", + "--run", runID, + "--goal", goal, + ) +} + +func seedCoreAdditionalTask(t *testing.T, dbPath, runID, taskID, title string) { + t.Helper() + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "task", "add", + "--run", runID, + "--task", taskID, + "--title", title, + ) +} diff --git a/packages/orch-runtime/internal/cli/orch/dispatch_wait_additional_test.go b/packages/orch-runtime/internal/cli/orch/dispatch_wait_additional_test.go new file mode 100644 index 0000000..962dcd7 --- /dev/null +++ b/packages/orch-runtime/internal/cli/orch/dispatch_wait_additional_test.go @@ -0,0 +1,640 @@ +package orch + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// TestOrchDispatchRejectsInvalidExecutionMode verifies dispatch rejects unknown execution modes. +func TestOrchDispatchRejectsInvalidExecutionMode(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "run", "init", + "--run", "run_dispatch_invalid_mode_001", + "--goal", "Validate dispatch mode guards", + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "task", "add", + "--run", "run_dispatch_invalid_mode_001", + "--task", "T1", + "--title", "Implement backend", + "--default-to", "worker-a", + ) + + stdout, _, exitCode := executeOrchCommand( + "--db", dbPath, + "--json", + "dispatch", + "--run", "run_dispatch_invalid_mode_001", + "--task", "T1", + "--execution-mode", "review", + ) + if exitCode != 30 { + t.Fatalf("expected invalid_input exit code 30, got %d\nstdout:\n%s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "invalid_input") + assertErrorMessageContains(t, stdout, "execution-mode must be one of: analysis, code") +} + +// TestOrchDispatchRejectsCodeOnlyFlagsInAnalysisMode verifies analysis mode rejects code-only flags. +func TestOrchDispatchRejectsCodeOnlyFlagsInAnalysisMode(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + args []string + message string + }{ + { + name: "repo-path", + args: []string{"--repo-path", "/tmp/repo"}, + message: "repo-path is only valid with --execution-mode code", + }, + { + name: "workspace-root", + args: []string{"--workspace-root", ".orch/worktrees"}, + message: "workspace-root is only valid with --execution-mode code", + }, + { + name: "base-ref", + args: []string{"--base-ref", "HEAD"}, + message: "base-ref is only valid with --execution-mode code", + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + args := append([]string{ + "--db", dbPath, + "--json", + "dispatch", + "--run", "run_unused", + "--task", "T1", + "--execution-mode", "analysis", + }, tc.args...) + + stdout, _, exitCode := executeOrchCommand(args...) + if exitCode != 30 { + t.Fatalf("expected invalid_input exit code 30, got %d\nstdout:\n%s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "invalid_input") + assertErrorMessageContains(t, stdout, tc.message) + }) + } +} + +// TestOrchDispatchRejectsMissingTargetAgent verifies dispatch requires either --to or task default_to. +func TestOrchDispatchRejectsMissingTargetAgent(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "run", "init", + "--run", "run_dispatch_target_001", + "--goal", "Validate dispatch target guards", + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "task", "add", + "--run", "run_dispatch_target_001", + "--task", "T1", + "--title", "Investigate logs", + ) + + stdout, _, exitCode := executeOrchCommand( + "--db", dbPath, + "--json", + "dispatch", + "--run", "run_dispatch_target_001", + "--task", "T1", + "--execution-mode", "analysis", + ) + if exitCode != 30 { + t.Fatalf("expected invalid_input exit code 30, got %d\nstdout:\n%s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "invalid_input") + assertErrorMessageContains(t, stdout, "dispatch target agent is required") +} + +// TestOrchDispatchCodeModeUsesCurrentWorkingDirectoryAndDefaultWorkspaceRoot verifies code-mode dispatch can infer repo and workspace root. +func TestOrchDispatchCodeModeUsesCurrentWorkingDirectoryAndDefaultWorkspaceRoot(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "coord.db") + repoPath := initGitRepo(t) + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "run", "init", + "--run", "run_dispatch_cwd_001", + "--goal", "Validate cwd repo inference", + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "task", "add", + "--run", "run_dispatch_cwd_001", + "--task", "T1", + "--title", "Implement backend", + "--default-to", "worker-a", + ) + + stdout, stderr, exitCode := executeOrchCommandInDir( + repoPath, + "--db", dbPath, + "--json", + "dispatch", + "--run", "run_dispatch_cwd_001", + "--task", "T1", + "--execution-mode", "code", + "--body", "Work from the current repository.", + ) + if exitCode != 0 { + t.Fatalf("expected successful dispatch from cwd, got %d\nstderr:\n%s\nstdout:\n%s", exitCode, stderr, stdout) + } + + var dispatchResp map[string]any + mustDecodeJSON(t, stdout, &dispatchResp) + worktreePath := nestedString(t, dispatchResp, "data", "attempt", "worktree_path") + expectedPath := buildAttemptWorktreePath(filepath.Join(repoPath, ".orch", "worktrees"), "run_dispatch_cwd_001", "T1", 1) + if worktreePath != expectedPath { + t.Fatalf("expected inferred worktree path %q, got %q", expectedPath, worktreePath) + } + if _, err := os.Stat(worktreePath); err != nil { + t.Fatalf("stat inferred worktree path %s: %v", worktreePath, err) + } +} + +// TestOrchDispatchCodeModeRejectsNonGitRepoPath verifies code-mode dispatch rejects non-Git paths. +func TestOrchDispatchCodeModeRejectsNonGitRepoPath(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + nonGitPath := filepath.Join(t.TempDir(), "not-a-repo") + if err := os.MkdirAll(nonGitPath, 0o755); err != nil { + t.Fatalf("mkdir non-git path: %v", err) + } + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "run", "init", + "--run", "run_dispatch_repo_guard_001", + "--goal", "Validate repo path guard", + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "task", "add", + "--run", "run_dispatch_repo_guard_001", + "--task", "T1", + "--title", "Implement backend", + "--default-to", "worker-a", + ) + + stdout, _, exitCode := executeOrchCommand( + "--db", dbPath, + "--json", + "dispatch", + "--run", "run_dispatch_repo_guard_001", + "--task", "T1", + "--execution-mode", "code", + "--repo-path", nonGitPath, + ) + if exitCode != 30 { + t.Fatalf("expected invalid_input exit code 30, got %d\nstdout:\n%s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "invalid_input") + assertErrorMessageContains(t, stdout, "repo-path must point to a Git worktree") +} + +// TestOrchDispatchCodeModeRejectsBadBaseRef verifies code-mode dispatch rejects unresolved base refs. +func TestOrchDispatchCodeModeRejectsBadBaseRef(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + repoPath := initGitRepo(t) + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "run", "init", + "--run", "run_dispatch_base_ref_001", + "--goal", "Validate base ref guard", + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "task", "add", + "--run", "run_dispatch_base_ref_001", + "--task", "T1", + "--title", "Implement backend", + "--default-to", "worker-a", + ) + + stdout, _, exitCode := executeOrchCommand( + "--db", dbPath, + "--json", + "dispatch", + "--run", "run_dispatch_base_ref_001", + "--task", "T1", + "--execution-mode", "code", + "--repo-path", repoPath, + "--base-ref", "does-not-exist", + ) + if exitCode != 30 { + t.Fatalf("expected invalid_input exit code 30, got %d\nstdout:\n%s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "invalid_input") + assertErrorMessageContains(t, stdout, "base-ref must resolve to a commit") +} + +// TestOrchReconcileMapsCancelledThreadsAndNoOpsWhenRepeated verifies reconcile maps cancelled threads once and then becomes a no-op. +func TestOrchReconcileMapsCancelledThreadsAndNoOpsWhenRepeated(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "run", "init", + "--run", "run_reconcile_cancelled_001", + "--goal", "Validate cancelled reconcile mapping", + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "task", "add", + "--run", "run_reconcile_cancelled_001", + "--task", "T1", + "--title", "Investigate logs", + "--default-to", "worker-a", + ) + + dispatchOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "dispatch", + "--run", "run_reconcile_cancelled_001", + "--task", "T1", + "--execution-mode", "analysis", + ) + + var dispatchResp map[string]any + mustDecodeJSON(t, dispatchOut, &dispatchResp) + threadID := nestedString(t, dispatchResp, "data", "attempt", "thread_id") + + runInboxCommand( + t, + "--db", dbPath, + "--json", + "cancel", + "--agent", "leader", + "--thread", threadID, + "--reason", "Task cancelled by operator.", + ) + + reconcileOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "reconcile", + "--run", "run_reconcile_cancelled_001", + ) + + var reconcileResp map[string]any + mustDecodeJSON(t, reconcileOut, &reconcileResp) + updatedTasks := nestedArray(t, reconcileResp, "data", "updated_tasks") + if len(updatedTasks) != 1 { + t.Fatalf("expected one updated task after cancelled reconcile, got %#v", updatedTasks) + } + task := updatedTasks[0].(map[string]any) + if got, _ := task["status"].(string); got != "cancelled" { + t.Fatalf("expected cancelled task status, got %#v", task["status"]) + } + taskCounts := nestedValue(t, reconcileResp, "data", "task_counts").(map[string]any) + if got, _ := taskCounts["cancelled"].(float64); got != 1 { + t.Fatalf("expected cancelled task count 1, got %#v", taskCounts["cancelled"]) + } + + secondReconcileOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "reconcile", + "--run", "run_reconcile_cancelled_001", + ) + + var secondResp map[string]any + mustDecodeJSON(t, secondReconcileOut, &secondResp) + if updated := nestedArray(t, secondResp, "data", "updated_tasks"); len(updated) != 0 { + t.Fatalf("expected repeated reconcile to be a no-op, got %#v", updated) + } +} + +// TestOrchStatusRejectsMissingRun verifies status reports not_found for unknown runs. +func TestOrchStatusRejectsMissingRun(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + + stdout, _, exitCode := executeOrchCommand( + "--db", dbPath, + "--json", + "status", + "--run", "run_status_missing_001", + ) + if exitCode != 40 { + t.Fatalf("expected not_found exit code 40, got %d\nstdout:\n%s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "not_found") +} + +// TestOrchStatusPlainTextOutput verifies status renders the human-readable dashboard view. +func TestOrchStatusPlainTextOutput(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "run", "init", + "--run", "run_status_text_001", + "--goal", "Validate status plain-text output", + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "task", "add", + "--run", "run_status_text_001", + "--task", "T1", + "--title", "Investigate flaky tests", + "--default-to", "worker-a", + ) + + stdout, stderr, exitCode := executeOrchCommand( + "--db", dbPath, + "status", + "--run", "run_status_text_001", + ) + if exitCode != 0 { + t.Fatalf("expected status exit 0, got %d\nstderr:\n%s\nstdout:\n%s", exitCode, stderr, stdout) + } + if !strings.Contains(stdout, "run run_status_text_001 status ready") { + t.Fatalf("expected run summary in plain-text status output, got:\n%s", stdout) + } + if !strings.Contains(stdout, "T1\tready\tInvestigate flaky tests") { + t.Fatalf("expected task row in plain-text status output, got:\n%s", stdout) + } +} + +// TestOrchWaitNormalizesForFlagAndReturnsExistingEventsImmediately verifies wait normalizes event filters and returns existing events. +func TestOrchWaitNormalizesForFlagAndReturnsExistingEventsImmediately(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "run", "init", + "--run", "run_wait_existing_001", + "--goal", "Validate wait filter normalization", + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "task", "add", + "--run", "run_wait_existing_001", + "--task", "T1", + "--title", "Implement backend", + "--default-to", "worker-a", + ) + + dispatchOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "dispatch", + "--run", "run_wait_existing_001", + "--task", "T1", + "--execution-mode", "analysis", + ) + + var dispatchResp map[string]any + mustDecodeJSON(t, dispatchOut, &dispatchResp) + threadID := nestedString(t, dispatchResp, "data", "attempt", "thread_id") + + runInboxCommand( + t, + "--db", dbPath, + "--json", + "claim", + "--agent", "worker-a", + "--thread", threadID, + ) + runInboxCommandEventually( + t, + "--db", dbPath, + "--json", + "update", + "--agent", "worker-a", + "--thread", threadID, + "--status", "blocked", + "--summary", "Need API decision", + "--payload-json", `{"question":"Use v1 or v2?"}`, + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "reconcile", + "--run", "run_wait_existing_001", + ) + + waitOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "wait", + "--run", "run_wait_existing_001", + "--for", " task_blocked , task_blocked , ", + "--after-event", "0", + ) + + var waitResp map[string]any + mustDecodeJSON(t, waitOut, &waitResp) + if woke, _ := nestedValue(t, waitResp, "data", "woke").(bool); !woke { + t.Fatalf("expected wait to wake on an existing blocked event, got %#v", waitResp) + } + events := nestedArray(t, waitResp, "data", "events") + if len(events) != 1 { + t.Fatalf("expected one matching blocked event, got %#v", events) + } + event := events[0].(map[string]any) + if got, _ := event["type"].(string); got != "task_blocked" { + t.Fatalf("expected task_blocked event, got %#v", event["type"]) + } + if got, _ := event["summary"].(string); got != "Need API decision" { + t.Fatalf("expected blocked summary in event, got %#v", event["summary"]) + } + eventID, ok := event["event_id"].(float64) + if !ok { + t.Fatalf("expected numeric event_id, got %#v", event["event_id"]) + } + if nextEventID, _ := nestedValue(t, waitResp, "data", "next_event_id").(float64); nextEventID != eventID { + t.Fatalf("expected next_event_id %.0f, got %#v", eventID, nestedValue(t, waitResp, "data", "next_event_id")) + } +} + +// TestOrchWaitRejectsMissingRun verifies wait reports not_found for unknown runs. +func TestOrchWaitRejectsMissingRun(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + + stdout, _, exitCode := executeOrchCommand( + "--db", dbPath, + "--json", + "wait", + "--run", "run_wait_missing_001", + ) + if exitCode != 40 { + t.Fatalf("expected not_found exit code 40, got %d\nstdout:\n%s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "not_found") +} + +// TestOrchWaitHelpExplainsBlockingPrimitive verifies wait help explains its blocking role and resume flag. +func TestOrchWaitHelpExplainsBlockingPrimitive(t *testing.T) { + t.Parallel() + + stdout, stderr, exitCode := executeOrchCommand("wait", "--help") + if exitCode != 0 { + t.Fatalf("expected help exit 0, got %d\nstderr:\n%s\nstdout:\n%s", exitCode, stderr, stdout) + } + + combined := stdout + stderr + if !strings.Contains(combined, "leader-side blocking primitive") { + t.Fatalf("expected wait help to explain its role, got:\n%s", combined) + } + if !strings.Contains(combined, "--after-event 0 --timeout-seconds 900") { + t.Fatalf("expected wait help to include resume example, got:\n%s", combined) + } +} + +// TestOrchWaitPlainTextOutput verifies wait renders matching events in plain-text mode. +func TestOrchWaitPlainTextOutput(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "run", "init", + "--run", "run_wait_text_001", + "--goal", "Validate wait plain-text output", + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "task", "add", + "--run", "run_wait_text_001", + "--task", "T1", + "--title", "Implement backend", + "--default-to", "worker-a", + ) + + dispatchOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "dispatch", + "--run", "run_wait_text_001", + "--task", "T1", + "--execution-mode", "analysis", + ) + + var dispatchResp map[string]any + mustDecodeJSON(t, dispatchOut, &dispatchResp) + threadID := nestedString(t, dispatchResp, "data", "attempt", "thread_id") + + runInboxCommand( + t, + "--db", dbPath, + "--json", + "claim", + "--agent", "worker-a", + "--thread", threadID, + ) + runInboxCommandEventually( + t, + "--db", dbPath, + "--json", + "update", + "--agent", "worker-a", + "--thread", threadID, + "--status", "blocked", + "--summary", "Need deployment window", + "--payload-json", `{"question":"Can we ship today?"}`, + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "reconcile", + "--run", "run_wait_text_001", + ) + + stdout, stderr, exitCode := executeOrchCommand( + "--db", dbPath, + "wait", + "--run", "run_wait_text_001", + "--for", "task_blocked", + "--after-event", "0", + ) + if exitCode != 0 { + t.Fatalf("expected wait exit 0, got %d\nstderr:\n%s\nstdout:\n%s", exitCode, stderr, stdout) + } + if !strings.Contains(stdout, "\ttask_blocked\tT1\tNeed deployment window") { + t.Fatalf("expected plain-text wait event row, got:\n%s", stdout) + } +} diff --git a/packages/orch-runtime/internal/cli/orch/gap_fill_additional_test.go b/packages/orch-runtime/internal/cli/orch/gap_fill_additional_test.go new file mode 100644 index 0000000..3a30d92 --- /dev/null +++ b/packages/orch-runtime/internal/cli/orch/gap_fill_additional_test.go @@ -0,0 +1,552 @@ +package orch + +import ( + "fmt" + "path/filepath" + "strings" + "testing" +) + +// TestOrchReconcileAdditionalContracts verifies reconcile missing-run, plain-text, and help behavior. +func TestOrchReconcileAdditionalContracts(t *testing.T) { + t.Parallel() + + t.Run("missing run", func(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "coord.db") + + stdout, _, exitCode := executeOrchCommand( + "--db", dbPath, + "--json", + "reconcile", + "--run", "run_reconcile_missing_001", + ) + if exitCode != 40 { + t.Fatalf("expected not_found exit code 40, got %d\nstdout:\n%s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "not_found") + }) + + t.Run("plain text", func(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "coord.db") + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "run", "init", + "--run", "run_reconcile_plain_001", + "--goal", "Validate reconcile plain-text output", + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "task", "add", + "--run", "run_reconcile_plain_001", + "--task", "T1", + "--title", "Investigate current state", + "--default-to", "worker-a", + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "dispatch", + "--run", "run_reconcile_plain_001", + "--task", "T1", + "--execution-mode", "analysis", + ) + + stdout, stderr, exitCode := executeOrchCommand( + "--db", dbPath, + "reconcile", + "--run", "run_reconcile_plain_001", + ) + if exitCode != 0 { + t.Fatalf("expected reconcile success, got %d\nstderr:\n%s\nstdout:\n%s", exitCode, stderr, stdout) + } + if got := strings.TrimSpace(stdout); got != "reconciled run run_reconcile_plain_001 (0 updated tasks)" { + t.Fatalf("expected reconcile plain-text summary, got %q", got) + } + }) + + t.Run("help", func(t *testing.T) { + stdout, stderr, exitCode := executeOrchCommand("reconcile", "--help") + if exitCode != 0 { + t.Fatalf("expected help exit 0, got %d\nstderr:\n%s\nstdout:\n%s", exitCode, stderr, stdout) + } + + combined := stdout + stderr + if !strings.Contains(combined, "bridge from worker-side inbox activity") { + t.Fatalf("expected reconcile help to explain the inbox bridge, got:\n%s", combined) + } + if !strings.Contains(combined, "fresh state") { + t.Fatalf("expected reconcile help to mention fresh state, got:\n%s", combined) + } + }) +} + +// TestOrchStatusShowsEmptyRunWithoutAttempts verifies status returns an empty task view for untouched runs. +func TestOrchStatusShowsEmptyRunWithoutAttempts(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "run", "init", + "--run", "run_status_empty_001", + "--goal", "Inspect untouched run status", + ) + + statusOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "status", + "--run", "run_status_empty_001", + ) + + var statusResp map[string]any + mustDecodeJSON(t, statusOut, &statusResp) + if got := nestedString(t, statusResp, "data", "run", "status"); got != "active" { + t.Fatalf("expected active run status, got %q", got) + } + tasks := nestedArray(t, statusResp, "data", "tasks") + if len(tasks) != 0 { + t.Fatalf("expected no tasks for untouched run, got %#v", tasks) + } +} + +// TestOrchWaitBlankFilterFallsBackToDefaultEvents verifies blank wait filters normalize back to the default event set. +func TestOrchWaitBlankFilterFallsBackToDefaultEvents(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "run", "init", + "--run", "run_wait_blank_001", + "--goal", "Validate wait filter fallback", + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "task", "add", + "--run", "run_wait_blank_001", + "--task", "T1", + "--title", "Ready task", + ) + + stdout := runOrchCommand( + t, + "--db", dbPath, + "--json", + "wait", + "--run", "run_wait_blank_001", + "--for", " , , ", + "--after-event", "0", + "--timeout-seconds", "1", + ) + + var waitResp map[string]any + mustDecodeJSON(t, stdout, &waitResp) + events := nestedArray(t, waitResp, "data", "events") + if len(events) != 1 { + t.Fatalf("expected one default wait event, got %#v", events) + } + event, ok := events[0].(map[string]any) + if !ok { + t.Fatalf("expected wait event object, got %#v", events[0]) + } + if got, _ := event["type"].(string); got != "task_ready" { + t.Fatalf("expected default task_ready event, got %#v", event["type"]) + } +} + +// TestOrchRecoveryCommandPlainTextOutputs verifies recovery and cleanup commands render human-readable success output. +func TestOrchRecoveryCommandPlainTextOutputs(t *testing.T) { + t.Run("answer", func(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "coord.db") + threadID := seedBlockedTaskForAnswerCleanupEdgeTests(t, dbPath, "run_answer_plain_001", "T2", "worker-b") + + stdout, stderr, exitCode := executeOrchCommand( + "--db", dbPath, + "answer", + "--run", "run_answer_plain_001", + "--task", "T2", + "--body", "Use stdout for MVP.", + ) + if exitCode != 0 { + t.Fatalf("expected answer success, got %d\nstderr:\n%s\nstdout:\n%s", exitCode, stderr, stdout) + } + if got := strings.TrimSpace(stdout); got != "answered task T2 on thread "+threadID { + t.Fatalf("expected answer plain-text output, got %q", got) + } + }) + + t.Run("retry", func(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "coord.db") + seedFailedTaskForVerifyAndRecoveryTests(t, dbPath, "run_retry_plain_001", "T1") + + stdout, stderr, exitCode := executeOrchCommand( + "--db", dbPath, + "retry", + "--run", "run_retry_plain_001", + "--task", "T1", + "--body", "Retry with the same execution contract.", + ) + if exitCode != 0 { + t.Fatalf("expected retry success, got %d\nstderr:\n%s\nstdout:\n%s", exitCode, stderr, stdout) + } + if got := strings.TrimSpace(stdout); got != "retried task T1 as attempt 2" { + t.Fatalf("expected retry plain-text output, got %q", got) + } + }) + + t.Run("reassign", func(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "coord.db") + seedFailedTaskForVerifyAndRecoveryTests(t, dbPath, "run_reassign_plain_001", "T1") + + stdout, stderr, exitCode := executeOrchCommand( + "--db", dbPath, + "reassign", + "--run", "run_reassign_plain_001", + "--task", "T1", + "--to", "worker-b", + "--reason", "Move the retry to another worker.", + ) + if exitCode != 0 { + t.Fatalf("expected reassign success, got %d\nstderr:\n%s\nstdout:\n%s", exitCode, stderr, stdout) + } + if got := strings.TrimSpace(stdout); got != "reassigned task T1 to worker-b as attempt 2" { + t.Fatalf("expected reassign plain-text output, got %q", got) + } + }) + + t.Run("cancel", func(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "coord.db") + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "run", "init", + "--run", "run_cancel_plain_001", + "--goal", "Validate cancel plain-text output", + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "task", "add", + "--run", "run_cancel_plain_001", + "--task", "T1", + "--title", "Superseded task", + ) + + stdout, stderr, exitCode := executeOrchCommand( + "--db", dbPath, + "cancel", + "--run", "run_cancel_plain_001", + "--task", "T1", + "--reason", "Superseded by a new plan.", + ) + if exitCode != 0 { + t.Fatalf("expected cancel success, got %d\nstderr:\n%s\nstdout:\n%s", exitCode, stderr, stdout) + } + if got := strings.TrimSpace(stdout); got != "cancelled task T1 in run run_cancel_plain_001" { + t.Fatalf("expected cancel plain-text output, got %q", got) + } + }) + + t.Run("cleanup", func(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "coord.db") + repoPath := initGitRepo(t) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "run", "init", + "--run", "run_cleanup_plain_001", + "--goal", "Validate cleanup plain-text output", + ) + seedCompletedCodeTaskForCleanupCouncilTests(t, dbPath, repoPath, "run_cleanup_plain_001", "T1", "worker-a") + + stdout, stderr, exitCode := executeOrchCommand( + "--db", dbPath, + "cleanup", + "--run", "run_cleanup_plain_001", + "--task", "T1", + ) + if exitCode != 0 { + t.Fatalf("expected cleanup success, got %d\nstderr:\n%s\nstdout:\n%s", exitCode, stderr, stdout) + } + if got := strings.TrimSpace(stdout); got != "cleaned 1 worktrees" { + t.Fatalf("expected cleanup plain-text output, got %q", got) + } + }) +} + +// TestOrchRecoveryCommandHelpContracts verifies recovery commands explain their intended operator contract. +func TestOrchRecoveryCommandHelpContracts(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + args []string + want []string + }{ + { + name: "answer", + args: []string{"answer", "--help"}, + want: []string{"active blocked attempt thread", "payload-json", "Constraints:"}, + }, + { + name: "retry", + args: []string{"retry", "--help"}, + want: []string{"fresh attempt and inbox thread", "analysis-only", "Constraints:"}, + }, + { + name: "reassign", + args: []string{"reassign", "--help"}, + want: []string{"create a new attempt for another worker", "fresh worktree", "Constraints:"}, + }, + { + name: "cancel", + args: []string{"cancel", "--help"}, + want: []string{"cancel one task inside the run", "entire request", "Constraints:"}, + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + stdout, stderr, exitCode := executeOrchCommand(tc.args...) + if exitCode != 0 { + t.Fatalf("expected help exit 0, got %d\nstderr:\n%s\nstdout:\n%s", exitCode, stderr, stdout) + } + combined := stdout + stderr + for _, want := range tc.want { + if !strings.Contains(combined, want) { + t.Fatalf("expected %s help to contain %q, got:\n%s", tc.name, want, combined) + } + } + }) + } +} + +// TestOrchVerifyPlainTextOutputs verifies verify record and verify status render human-readable success output. +func TestOrchVerifyPlainTextOutputs(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + runID := "run_verify_plain_001" + seedVerifyingTaskForVerifyAndRecoveryTests(t, dbPath, runID, "T1", []string{"lint"}) + + recordStdout, recordStderr, exitCode := executeOrchCommand( + "--db", dbPath, + "verify", "record", + "--run", runID, + "--task", "T1", + "--check", "lint", + "--status", "passed", + "--summary", "lint clean", + ) + if exitCode != 0 { + t.Fatalf("expected verify record success, got %d\nstderr:\n%s\nstdout:\n%s", exitCode, recordStderr, recordStdout) + } + if got := strings.TrimSpace(recordStdout); got != "recorded check lint=passed for "+runID+"/T1 attempt 1" { + t.Fatalf("expected verify record plain-text output, got %q", got) + } + + statusStdout, statusStderr, exitCode := executeOrchCommand( + "--db", dbPath, + "verify", "status", + "--run", runID, + "--task", "T1", + ) + if exitCode != 0 { + t.Fatalf("expected verify status success, got %d\nstderr:\n%s\nstdout:\n%s", exitCode, statusStderr, statusStdout) + } + if got := strings.TrimSpace(statusStdout); got != runID+"/T1 verification status passed" { + t.Fatalf("expected verify status plain-text output, got %q", got) + } +} + +// TestOrchCouncilAdditionalContracts verifies remaining council help, plain-text, and cancelled-reviewer failure paths. +func TestOrchCouncilAdditionalContracts(t *testing.T) { + t.Run("wait and tally help", func(t *testing.T) { + cases := []struct { + name string + args []string + want []string + }{ + { + name: "council wait", + args: []string{"council", "wait", "--help"}, + want: []string{"instead of polling reviewer task state manually", "--timeout-seconds 900", "Constraints:"}, + }, + { + name: "council tally", + args: []string{"council", "tally", "--help"}, + want: []string{"group similar proposals", "--similarity normal", "Constraints:"}, + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + stdout, stderr, exitCode := executeOrchCommand(tc.args...) + if exitCode != 0 { + t.Fatalf("expected help exit 0, got %d\nstderr:\n%s\nstdout:\n%s", exitCode, stderr, stdout) + } + combined := stdout + stderr + for _, want := range tc.want { + if !strings.Contains(combined, want) { + t.Fatalf("expected %s help to contain %q, got:\n%s", tc.name, want, combined) + } + } + }) + } + }) + + t.Run("wait plain text", func(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "coord.db") + runID := "council_wait_plain_001" + seedCouncilRunReadyForTally(t, dbPath, runID) + + stdout, stderr, exitCode := executeOrchCommand( + "--db", dbPath, + "council", "wait", + "--run", runID, + "--timeout-seconds", "1", + ) + if exitCode != 0 { + t.Fatalf("expected council wait success, got %d\nstderr:\n%s\nstdout:\n%s", exitCode, stderr, stdout) + } + if got := strings.TrimSpace(stdout); got != "all council reviewers completed for run "+runID { + t.Fatalf("expected council wait plain-text output, got %q", got) + } + }) + + t.Run("tally plain text", func(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "coord.db") + runID := "council_tally_plain_001" + seedCouncilRunReadyForTally(t, dbPath, runID) + + stdout, stderr, exitCode := executeOrchCommand( + "--db", dbPath, + "council", "tally", + "--run", runID, + "--similarity", "normal", + ) + if exitCode != 0 { + t.Fatalf("expected council tally success, got %d\nstderr:\n%s\nstdout:\n%s", exitCode, stderr, stdout) + } + if !strings.Contains(strings.TrimSpace(stdout), "tallied council run "+runID+" into ") { + t.Fatalf("expected council tally plain-text output, got %q", stdout) + } + }) + + t.Run("cancelled reviewer rejected by tally", func(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "coord.db") + runID := "council_tally_cancelled_001" + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "council", "start", + "--run", runID, + "--target", "Review cancelled reviewer handling.", + ) + completeCouncilReviewer(t, dbPath, runID, "architecture-reviewer", councilReviewerBody("architecture-reviewer", "Keep terminal states explicit", "Terminal states should stay visible.", "Preserve reviewer terminal states in council workflows.")) + completeCouncilReviewer(t, dbPath, runID, "implementation-reviewer", councilReviewerBody("implementation-reviewer", "Reject incomplete tally inputs", "Tally should require successful reviewers.", "Reject reviewer sets that are not all done.")) + cancelCouncilReviewerForAdditionalTests(t, dbPath, runID, "risk-reviewer", "Risk review superseded") + + stdout, _, exitCode := executeOrchCommand( + "--db", dbPath, + "--json", + "council", "tally", + "--run", runID, + ) + if exitCode != 30 { + t.Fatalf("expected invalid_state exit 30, got %d\nstdout:\n%s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "invalid_state") + assertErrorMessageContains(t, stdout, "did not finish successfully") + }) +} + +// TestOrchCouncilReportArtifactPathUsesExpectedBaseDir verifies council report artifacts derive from the intended base directory. +func TestOrchCouncilReportArtifactPathUsesExpectedBaseDir(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + dbPath string + runID string + want string + }{ + { + name: "current directory db", + dbPath: ".agents/coord.db", + runID: "council_blog_001", + want: filepath.Join(".orch", "reports", "council_blog_001.md"), + }, + { + name: "db outside .agents", + dbPath: filepath.Join("/tmp", "coord", "coord.db"), + runID: "council_blog_002", + want: filepath.Join("/tmp", "coord", ".orch", "reports", "council_blog_002.md"), + }, + { + name: "sanitize run id separators", + dbPath: filepath.Join("/tmp", "project", ".agents", "coord.db"), + runID: filepath.Join("council", "blog", "003"), + want: filepath.Join("/tmp", "project", ".orch", "reports", "council_blog_003.md"), + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + if got := councilReportArtifactPath(tc.dbPath, tc.runID); got != tc.want { + t.Fatalf("expected council report path %q, got %q", tc.want, got) + } + }) + } +} + +func seedCouncilRunReadyForTally(t *testing.T, dbPath, runID string) { + t.Helper() + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "council", "start", + "--run", runID, + "--target", "Review the council plain-text flow.", + ) + completeCouncilReviewer(t, dbPath, runID, "architecture-reviewer", councilReviewerBody("architecture-reviewer", "Split contracts", "Transport contracts are mixed into UI code.", "Move API contract definitions into a dedicated module.")) + completeCouncilReviewer(t, dbPath, runID, "implementation-reviewer", councilReviewerBody("implementation-reviewer", "Extract contracts", "Shared transport shapes are duplicated.", "Move API contract definitions into a dedicated module.")) + completeCouncilReviewer(t, dbPath, runID, "risk-reviewer", councilReviewerBody("risk-reviewer", "Cover regressions", "Contract drift becomes risky over time.", "Add integration tests for the council report flow.")) +} + +func councilReviewerBody(role, title, summary, proposal string) string { + return fmt.Sprintf( + `{"reviewer_role":%q,"findings":[{"title":%q,"summary":%q,"proposal":%q,"rationale":"Keep the council workflow deterministic.","confidence":"high","tags":["contracts"],"target_refs":{"repo_path":"."}}]}`, + role, + title, + summary, + proposal, + ) +} diff --git a/packages/orch-runtime/internal/cli/orch/recovery_cleanup_additional_test.go b/packages/orch-runtime/internal/cli/orch/recovery_cleanup_additional_test.go new file mode 100644 index 0000000..1d794ce --- /dev/null +++ b/packages/orch-runtime/internal/cli/orch/recovery_cleanup_additional_test.go @@ -0,0 +1,646 @@ +package orch + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +// TestOrchBlockedPlainTextOutput verifies blocked renders tabular output in plain-text mode. +func TestOrchBlockedPlainTextOutput(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + _ = seedBlockedTaskForAnswerCleanupEdgeTests(t, dbPath, "run_blog_blocked_plain_001", "T2", "worker-b") + + stdout, stderr, exitCode := executeOrchCommand( + "--db", dbPath, + "blocked", + "--run", "run_blog_blocked_plain_001", + ) + if exitCode != 0 { + t.Fatalf("expected plain blocked exit 0, got %d\nstderr:\n%s\nstdout:\n%s", exitCode, stderr, stdout) + } + if !strings.Contains(stdout, "T2\tNeed logging decision\n") { + t.Fatalf("expected blocked plain output to include task summary, got:\n%s", stdout) + } +} + +// TestOrchBlockedPlainTextNoBlockedTasks verifies blocked reports the empty queue in plain-text mode. +func TestOrchBlockedPlainTextNoBlockedTasks(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "run", "init", + "--run", "run_blog_blocked_plain_002", + "--goal", "Validate empty blocked queue", + ) + + stdout, stderr, exitCode := executeOrchCommand( + "--db", dbPath, + "blocked", + "--run", "run_blog_blocked_plain_002", + ) + if exitCode != 0 { + t.Fatalf("expected empty blocked exit 0, got %d\nstderr:\n%s\nstdout:\n%s", exitCode, stderr, stdout) + } + if stdout != "no blocked tasks\n" { + t.Fatalf("expected no blocked tasks line, got %#v", stdout) + } +} + +// TestOrchAnswerRejectsTerminalThread verifies answer rejects blocked tasks whose active thread is already terminal. +func TestOrchAnswerRejectsTerminalThread(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + threadID := seedBlockedTaskForAnswerCleanupEdgeTests(t, dbPath, "run_blog_answer_terminal_001", "T2", "worker-b") + setThreadStatusForRecoveryCleanupTests(t, dbPath, threadID, "cancelled") + + stdout, _, exitCode := executeOrchCommand( + "--db", dbPath, + "--json", + "answer", + "--run", "run_blog_answer_terminal_001", + "--task", "T2", + "--body", "Use stdout.", + ) + if exitCode != 30 { + t.Fatalf("expected invalid_state exit 30, got %d\nstdout:\n%s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "invalid_state") + assertErrorMessageContains(t, stdout, "already terminal") +} + +// TestOrchAnswerRejectsBodyAndBodyFileTogether verifies answer enforces body/body-file mutual exclusion. +func TestOrchAnswerRejectsBodyAndBodyFileTogether(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + dbPath := filepath.Join(tempDir, "coord.db") + bodyFile := filepath.Join(tempDir, "answer.txt") + if err := os.WriteFile(bodyFile, []byte("Use stdout.\n"), 0o644); err != nil { + t.Fatalf("write answer body file: %v", err) + } + _ = seedBlockedTaskForAnswerCleanupEdgeTests(t, dbPath, "run_blog_answer_body_guard_001", "T2", "worker-b") + + stdout, _, exitCode := executeOrchCommand( + "--db", dbPath, + "--json", + "answer", + "--run", "run_blog_answer_body_guard_001", + "--task", "T2", + "--body", "Use stdout.", + "--body-file", bodyFile, + ) + if exitCode != 30 { + t.Fatalf("expected invalid_input exit 30, got %d\nstdout:\n%s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "invalid_input") + assertErrorMessageContains(t, stdout, "mutually exclusive") +} + +// TestOrchAnswerRejectsUnreadableBodyFile verifies answer surfaces unreadable body-file errors. +func TestOrchAnswerRejectsUnreadableBodyFile(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + dbPath := filepath.Join(tempDir, "coord.db") + _ = seedBlockedTaskForAnswerCleanupEdgeTests(t, dbPath, "run_blog_answer_body_guard_002", "T2", "worker-b") + + stdout, _, exitCode := executeOrchCommand( + "--db", dbPath, + "--json", + "answer", + "--run", "run_blog_answer_body_guard_002", + "--task", "T2", + "--body-file", filepath.Join(tempDir, "missing-answer.txt"), + ) + if exitCode != 30 { + t.Fatalf("expected invalid_input exit 30, got %d\nstdout:\n%s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "invalid_input") + assertErrorMessageContains(t, stdout, "failed to read body-file") +} + +// TestOrchRetryOverridesAssignedWorkerAndAbandonsPreviousWorktree verifies retry can retarget a code attempt and mark the old worktree abandoned. +func TestOrchRetryOverridesAssignedWorkerAndAbandonsPreviousWorktree(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + _, originalWorktreePath := seedFailedCodeModeTaskForRecoveryCleanupTests(t, dbPath, "run_blog_retry_extra_001", "T1", "worker-a") + + retryOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "retry", + "--run", "run_blog_retry_extra_001", + "--task", "T1", + "--to", "worker-b", + "--body", "Retry with a different worker.", + ) + + var retryResp map[string]any + mustDecodeJSON(t, retryOut, &retryResp) + if got := nestedString(t, retryResp, "data", "attempt", "assigned_to"); got != "worker-b" { + t.Fatalf("expected retry target worker-b, got %q", got) + } + if got := nestedString(t, retryResp, "data", "previous_attempt", "worktree_path"); got != originalWorktreePath { + t.Fatalf("expected previous attempt worktree %q, got %q", originalWorktreePath, got) + } + newWorktreePath := nestedString(t, retryResp, "data", "attempt", "worktree_path") + if newWorktreePath == "" || newWorktreePath == originalWorktreePath { + t.Fatalf("expected retry to create a fresh worktree, got %q", newWorktreePath) + } + if _, err := os.Stat(newWorktreePath); err != nil { + t.Fatalf("stat retry worktree %s: %v", newWorktreePath, err) + } + + status, workspaceStatus, worktreePath := queryAttemptStateForRecoveryCleanupTests(t, dbPath, "run_blog_retry_extra_001", "T1", 1) + if status != "failed" { + t.Fatalf("expected previous attempt status failed, got %q", status) + } + if workspaceStatus != "abandoned" { + t.Fatalf("expected previous workspace_status abandoned, got %q", workspaceStatus) + } + if worktreePath != originalWorktreePath { + t.Fatalf("expected original worktree path %q, got %q", originalWorktreePath, worktreePath) + } +} + +// TestOrchRetryAnalysisModeRemainsWithoutWorktree verifies retry preserves analysis-mode attempts without allocating a worktree. +func TestOrchRetryAnalysisModeRemainsWithoutWorktree(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + originalThreadID := seedFailedTaskForVerifyAndRecoveryTests(t, dbPath, "run_blog_retry_extra_002", "T1") + + retryOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "retry", + "--run", "run_blog_retry_extra_002", + "--task", "T1", + "--body", "Retry analysis-only work.", + ) + + var retryResp map[string]any + mustDecodeJSON(t, retryOut, &retryResp) + attempt, ok := nestedValue(t, retryResp, "data", "attempt").(map[string]any) + if !ok { + t.Fatalf("expected retry attempt object, got %#v", nestedValue(t, retryResp, "data", "attempt")) + } + if got, _ := attempt["thread_id"].(string); got == originalThreadID { + t.Fatalf("expected retry to create a new thread, got %q", got) + } + if got, _ := attempt["worktree_path"].(string); got != "" { + t.Fatalf("expected no worktree_path for analysis retry, got %q", got) + } + if got, _ := attempt["workspace_status"].(string); got != "" { + t.Fatalf("expected no workspace_status for analysis retry, got %q", got) + } + + status, workspaceStatus, worktreePath := queryAttemptStateForRecoveryCleanupTests(t, dbPath, "run_blog_retry_extra_002", "T1", 2) + if status != "dispatched" { + t.Fatalf("expected retry attempt status dispatched, got %q", status) + } + if workspaceStatus != "" { + t.Fatalf("expected empty workspace_status for analysis retry, got %q", workspaceStatus) + } + if worktreePath != "" { + t.Fatalf("expected empty worktree_path for analysis retry, got %q", worktreePath) + } +} + +// TestOrchReassignCodeModeCreatesFreshWorktree verifies reassign creates a new code worktree and abandons the previous one. +func TestOrchReassignCodeModeCreatesFreshWorktree(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + _, originalWorktreePath := seedBlockedCodeModeTaskForRecoveryCleanupTests(t, dbPath, "run_blog_reassign_extra_001", "T1", "worker-a") + + reassignOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "reassign", + "--run", "run_blog_reassign_extra_001", + "--task", "T1", + "--to", "worker-b", + "--reason", "Move to another worker.", + ) + + var reassignResp map[string]any + mustDecodeJSON(t, reassignOut, &reassignResp) + if got := nestedString(t, reassignResp, "data", "previous_attempt", "worktree_path"); got != originalWorktreePath { + t.Fatalf("expected previous attempt worktree %q, got %q", originalWorktreePath, got) + } + newWorktreePath := nestedString(t, reassignResp, "data", "attempt", "worktree_path") + if newWorktreePath == "" || newWorktreePath == originalWorktreePath { + t.Fatalf("expected reassignment to create a fresh worktree, got %q", newWorktreePath) + } + if _, err := os.Stat(newWorktreePath); err != nil { + t.Fatalf("stat reassigned worktree %s: %v", newWorktreePath, err) + } + + status, workspaceStatus, _ := queryAttemptStateForRecoveryCleanupTests(t, dbPath, "run_blog_reassign_extra_001", "T1", 1) + if status != "cancelled" { + t.Fatalf("expected previous attempt status cancelled, got %q", status) + } + if workspaceStatus != "abandoned" { + t.Fatalf("expected previous workspace_status abandoned, got %q", workspaceStatus) + } +} + +// TestOrchCancelRejectsAlreadyCancelledTask verifies cancel rejects a task that was already cancelled explicitly. +func TestOrchCancelRejectsAlreadyCancelledTask(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "run", "init", + "--run", "run_blog_cancel_extra_001", + "--goal", "Validate repeated task cancellation", + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "task", "add", + "--run", "run_blog_cancel_extra_001", + "--task", "T1", + "--title", "Implement backend", + "--default-to", "worker-a", + ) + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "cancel", + "--run", "run_blog_cancel_extra_001", + "--task", "T1", + "--reason", "Task no longer needed.", + ) + + stdout, _, exitCode := executeOrchCommand( + "--db", dbPath, + "--json", + "cancel", + "--run", "run_blog_cancel_extra_001", + "--task", "T1", + ) + if exitCode != 30 { + t.Fatalf("expected invalid_state exit 30, got %d\nstdout:\n%s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "invalid_state") + assertErrorMessageContains(t, stdout, "already cancelled") +} + +// TestOrchCancelCodeModeMarksWorkspaceAbandoned verifies cancel leaves code-mode attempts as abandoned until cleanup removes them. +func TestOrchCancelCodeModeMarksWorkspaceAbandoned(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + threadID, worktreePath := seedCodeModeDispatchedTaskForRecoveryCleanupTests(t, dbPath, "run_blog_cancel_extra_002", "T1", "worker-a") + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "cancel", + "--run", "run_blog_cancel_extra_002", + "--task", "T1", + "--reason", "Stop this worktree attempt.", + ) + + status, workspaceStatus, gotWorktreePath := queryAttemptStateForRecoveryCleanupTests(t, dbPath, "run_blog_cancel_extra_002", "T1", 1) + if status != "cancelled" { + t.Fatalf("expected cancelled attempt status, got %q", status) + } + if workspaceStatus != "abandoned" { + t.Fatalf("expected abandoned workspace_status, got %q", workspaceStatus) + } + if gotWorktreePath != worktreePath { + t.Fatalf("expected worktree_path %q, got %q", worktreePath, gotWorktreePath) + } + if _, err := os.Stat(worktreePath); err != nil { + t.Fatalf("expected cancelled worktree to remain for later cleanup, err=%v", err) + } + + showOut := runInboxCommand( + t, + "--db", dbPath, + "--json", + "show", + "--thread", threadID, + ) + + var showResp map[string]any + mustDecodeJSON(t, showOut, &showResp) + if got := nestedString(t, showResp, "data", "thread", "status"); got != "cancelled" { + t.Fatalf("expected cancelled inbox thread, got %q", got) + } +} + +// TestOrchCleanupRejectsWithoutSelector verifies cleanup requires an explicit selector. +func TestOrchCleanupRejectsWithoutSelector(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "run", "init", + "--run", "run_blog_cleanup_extra_001", + "--goal", "Validate cleanup selector requirements", + ) + + stdout, _, exitCode := executeOrchCommand( + "--db", dbPath, + "--json", + "cleanup", + "--run", "run_blog_cleanup_extra_001", + ) + if exitCode != 30 { + t.Fatalf("expected invalid_input exit 30, got %d\nstdout:\n%s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "invalid_input") + assertErrorMessageContains(t, stdout, "specify --task, --attempt, or --all-completed") +} + +// TestOrchCleanupForceRemovesActiveWorktree verifies cleanup can force-remove a created worktree that is not yet terminal. +func TestOrchCleanupForceRemovesActiveWorktree(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + _, worktreePath := seedCodeModeDispatchedTaskForRecoveryCleanupTests(t, dbPath, "run_blog_cleanup_extra_002", "T1", "worker-a") + + stdout, _, exitCode := executeOrchCommand( + "--db", dbPath, + "--json", + "cleanup", + "--run", "run_blog_cleanup_extra_002", + "--task", "T1", + "--attempt", "1", + ) + if exitCode != 10 { + t.Fatalf("expected no_matching_work exit 10 without force, got %d\nstdout:\n%s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "no_matching_work") + + cleanupOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "cleanup", + "--run", "run_blog_cleanup_extra_002", + "--task", "T1", + "--attempt", "1", + "--force", + ) + + var cleanupResp map[string]any + mustDecodeJSON(t, cleanupOut, &cleanupResp) + cleaned := nestedArray(t, cleanupResp, "data", "cleaned") + if len(cleaned) != 1 { + t.Fatalf("expected one cleaned forced attempt, got %#v", cleaned) + } + if _, err := os.Stat(worktreePath); !os.IsNotExist(err) { + t.Fatalf("expected forced cleanup worktree path to be removed, err=%v", err) + } + + _, workspaceStatus, _ := queryAttemptStateForRecoveryCleanupTests(t, dbPath, "run_blog_cleanup_extra_002", "T1", 1) + if workspaceStatus != "cleaned" { + t.Fatalf("expected cleaned workspace_status after force cleanup, got %q", workspaceStatus) + } +} + +// TestOrchCleanupRemovesAbandonedWorktreeByExactAttempt verifies cleanup can target one abandoned attempt without touching the replacement worktree. +func TestOrchCleanupRemovesAbandonedWorktreeByExactAttempt(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + _, originalWorktreePath := seedFailedCodeModeTaskForRecoveryCleanupTests(t, dbPath, "run_blog_cleanup_extra_003", "T1", "worker-a") + + retryOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "retry", + "--run", "run_blog_cleanup_extra_003", + "--task", "T1", + "--body", "Retry in a fresh worktree.", + ) + + var retryResp map[string]any + mustDecodeJSON(t, retryOut, &retryResp) + replacementWorktreePath := nestedString(t, retryResp, "data", "attempt", "worktree_path") + if replacementWorktreePath == "" || replacementWorktreePath == originalWorktreePath { + t.Fatalf("expected retry to create a replacement worktree, got %q", replacementWorktreePath) + } + + cleanupOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "cleanup", + "--run", "run_blog_cleanup_extra_003", + "--task", "T1", + "--attempt", "1", + ) + + var cleanupResp map[string]any + mustDecodeJSON(t, cleanupOut, &cleanupResp) + cleaned := nestedArray(t, cleanupResp, "data", "cleaned") + if len(cleaned) != 1 { + t.Fatalf("expected one cleaned abandoned attempt, got %#v", cleaned) + } + cleanedAttempt, ok := cleaned[0].(map[string]any) + if !ok { + t.Fatalf("expected cleaned attempt object, got %#v", cleaned[0]) + } + if got, _ := cleanedAttempt["attempt_no"].(float64); got != 1 { + t.Fatalf("expected cleaned attempt 1, got %#v", cleanedAttempt["attempt_no"]) + } + if _, err := os.Stat(originalWorktreePath); !os.IsNotExist(err) { + t.Fatalf("expected original abandoned worktree to be removed, err=%v", err) + } + if _, err := os.Stat(replacementWorktreePath); err != nil { + t.Fatalf("expected replacement worktree to remain, err=%v", err) + } + + _, workspaceStatus, _ := queryAttemptStateForRecoveryCleanupTests(t, dbPath, "run_blog_cleanup_extra_003", "T1", 1) + if workspaceStatus != "cleaned" { + t.Fatalf("expected cleaned workspace_status for attempt 1, got %q", workspaceStatus) + } +} + +func seedCodeModeDispatchedTaskForRecoveryCleanupTests(t *testing.T, dbPath, runID, taskID, agent string) (string, string) { + t.Helper() + + repoPath := initGitRepo(t) + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "run", "init", + "--run", runID, + "--goal", "Prepare code-mode task for recovery and cleanup tests", + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "task", "add", + "--run", runID, + "--task", taskID, + "--title", "Prepare code-mode task", + "--default-to", agent, + ) + + dispatchOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "dispatch", + "--run", runID, + "--task", taskID, + "--execution-mode", "code", + "--repo-path", repoPath, + "--workspace-root", ".orch/worktrees", + "--to", agent, + "--body", "Prepare isolated worktree.", + ) + + var dispatchResp map[string]any + mustDecodeJSON(t, dispatchOut, &dispatchResp) + return nestedString(t, dispatchResp, "data", "attempt", "thread_id"), nestedString(t, dispatchResp, "data", "attempt", "worktree_path") +} + +func seedFailedCodeModeTaskForRecoveryCleanupTests(t *testing.T, dbPath, runID, taskID, agent string) (string, string) { + t.Helper() + + threadID, worktreePath := seedCodeModeDispatchedTaskForRecoveryCleanupTests(t, dbPath, runID, taskID, agent) + + runInboxCommand( + t, + "--db", dbPath, + "--json", + "claim", + "--agent", agent, + "--thread", threadID, + ) + runInboxCommand( + t, + "--db", dbPath, + "--json", + "fail", + "--agent", agent, + "--thread", threadID, + "--summary", "Code attempt failed", + "--body", "The code attempt failed before completion.", + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "reconcile", + "--run", runID, + ) + + return threadID, worktreePath +} + +func seedBlockedCodeModeTaskForRecoveryCleanupTests(t *testing.T, dbPath, runID, taskID, agent string) (string, string) { + t.Helper() + + threadID, worktreePath := seedCodeModeDispatchedTaskForRecoveryCleanupTests(t, dbPath, runID, taskID, agent) + + runInboxCommand( + t, + "--db", dbPath, + "--json", + "claim", + "--agent", agent, + "--thread", threadID, + ) + runInboxCommand( + t, + "--db", dbPath, + "--json", + "update", + "--agent", agent, + "--thread", threadID, + "--status", "blocked", + "--summary", "Need code review decision", + "--payload-json", `{"question":"Proceed with the current worktree?"}`, + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "reconcile", + "--run", runID, + ) + + return threadID, worktreePath +} + +func queryAttemptStateForRecoveryCleanupTests(t *testing.T, dbPath, runID, taskID string, attemptNo int) (string, string, string) { + t.Helper() + + sqlDB, err := openOrchDB(context.Background(), dbPath) + if err != nil { + t.Fatalf("open orch db: %v", err) + } + defer sqlDB.Close() + + row := sqlDB.QueryRowContext( + context.Background(), + `SELECT status, COALESCE(workspace_status, ''), COALESCE(worktree_path, '') + FROM task_attempts + WHERE run_id = ? AND task_id = ? AND attempt_no = ?`, + runID, + taskID, + attemptNo, + ) + + var status string + var workspaceStatus string + var worktreePath string + if err := row.Scan(&status, &workspaceStatus, &worktreePath); err != nil { + t.Fatalf("scan attempt state for %s/%s/%d: %v", runID, taskID, attemptNo, err) + } + return status, workspaceStatus, worktreePath +} + +func setThreadStatusForRecoveryCleanupTests(t *testing.T, dbPath, threadID, status string) { + t.Helper() + + sqlDB, err := openOrchDB(context.Background(), dbPath) + if err != nil { + t.Fatalf("open orch db: %v", err) + } + defer sqlDB.Close() + + if _, err := sqlDB.ExecContext(context.Background(), `UPDATE threads SET status = ? WHERE thread_id = ?`, status, threadID); err != nil { + t.Fatalf("update thread %s status to %s: %v", threadID, status, err) + } +} diff --git a/packages/orch-runtime/internal/cli/orch/test_helpers_test.go b/packages/orch-runtime/internal/cli/orch/test_helpers_test.go index 8f49d56..a41771e 100644 --- a/packages/orch-runtime/internal/cli/orch/test_helpers_test.go +++ b/packages/orch-runtime/internal/cli/orch/test_helpers_test.go @@ -3,12 +3,16 @@ package orch import ( "bytes" "encoding/json" + "os" "os/exec" "path/filepath" "runtime" + "sync" "testing" ) +var orchCommandDirMu sync.Mutex + func runOrchCommand(t *testing.T, args ...string) string { t.Helper() @@ -27,6 +31,35 @@ func executeOrchCommand(args ...string) (string, string, int) { return stdout.String(), stderr.String(), exitCode } +func executeOrchCommandInDir(dir string, args ...string) (string, string, int) { + orchCommandDirMu.Lock() + defer orchCommandDirMu.Unlock() + + cwd, err := os.Getwd() + if err != nil { + return "", err.Error(), 1 + } + if err := os.Chdir(dir); err != nil { + return "", err.Error(), 1 + } + defer func() { + if err := os.Chdir(cwd); err != nil { + panic(err) + } + }() + + return executeOrchCommand(args...) +} + +func orchCommandPath() string { + _, file, _, ok := runtime.Caller(0) + if !ok { + panic("unable to determine orch test helper path") + } + + return filepath.Join(filepath.Dir(file), "..", "..", "..", "cmd", "orch") +} + func runInboxCommand(t *testing.T, args ...string) string { t.Helper() diff --git a/packages/orch-runtime/internal/cli/orch/verify_council_additional_test.go b/packages/orch-runtime/internal/cli/orch/verify_council_additional_test.go new file mode 100644 index 0000000..a1fa053 --- /dev/null +++ b/packages/orch-runtime/internal/cli/orch/verify_council_additional_test.go @@ -0,0 +1,632 @@ +package orch + +import ( + "path/filepath" + "strings" + "testing" +) + +// TestOrchVerifyRecordRejectsInvalidStatus verifies verify record rejects unsupported check statuses. +func TestOrchVerifyRecordRejectsInvalidStatus(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + seedVerifyingTaskForVerifyAndRecoveryTests(t, dbPath, "run_verify_additional_001", "T1", []string{"lint"}) + + stdout, _, exitCode := executeOrchCommand( + "--db", dbPath, + "--json", + "verify", "record", + "--run", "run_verify_additional_001", + "--task", "T1", + "--check", "lint", + "--status", "maybe", + ) + if exitCode != 30 { + t.Fatalf("expected invalid_input exit code 30, got %d\nstdout:\n%s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "invalid_input") + assertErrorMessageContains(t, stdout, "check status must be one of passed, failed, skipped") +} + +// TestOrchVerifyRecordRejectsInvalidMetadataJSON verifies verify record rejects malformed metadata JSON. +func TestOrchVerifyRecordRejectsInvalidMetadataJSON(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + seedVerifyingTaskForVerifyAndRecoveryTests(t, dbPath, "run_verify_additional_002", "T1", []string{"lint"}) + + stdout, _, exitCode := executeOrchCommand( + "--db", dbPath, + "--json", + "verify", "record", + "--run", "run_verify_additional_002", + "--task", "T1", + "--check", "lint", + "--status", "passed", + "--metadata-json", `{"trace":`, + ) + if exitCode != 30 { + t.Fatalf("expected invalid_input exit code 30, got %d\nstdout:\n%s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "invalid_input") + assertErrorMessageContains(t, stdout, "metadata-json must be valid JSON") +} + +// TestOrchVerifyRecordRejectsNonLatestAttempt verifies verify record only accepts the latest attempt. +func TestOrchVerifyRecordRejectsNonLatestAttempt(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + runID := "run_verify_additional_003" + seedVerifyingTaskForVerifyContractTests(t, dbPath, runID, "T1") + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "verify", "record", + "--run", runID, + "--task", "T1", + "--check", "lint", + "--status", "failed", + "--summary", "lint failed on attempt 1", + ) + + retryOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "retry", + "--run", runID, + "--task", "T1", + "--body", "Retry after fixing lint failures.", + ) + + var retryResp map[string]any + mustDecodeJSON(t, retryOut, &retryResp) + threadID := nestedString(t, retryResp, "data", "attempt", "thread_id") + + runInboxCommand( + t, + "--db", dbPath, + "--json", + "claim", + "--agent", "worker-a", + "--thread", threadID, + ) + runInboxCommand( + t, + "--db", dbPath, + "--json", + "done", + "--agent", "worker-a", + "--thread", threadID, + "--summary", "Attempt 2 complete", + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "reconcile", + "--run", runID, + ) + + stdout, _, exitCode := executeOrchCommand( + "--db", dbPath, + "--json", + "verify", "record", + "--run", runID, + "--task", "T1", + "--attempt", "1", + "--check", "lint", + "--status", "passed", + ) + if exitCode != 30 { + t.Fatalf("expected invalid_state exit code 30, got %d\nstdout:\n%s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "invalid_state") + assertErrorMessageContains(t, stdout, "latest attempt") +} + +// TestOrchVerifyRecordRejectsTaskWithoutAttempt verifies verify record rejects tasks that were never dispatched. +func TestOrchVerifyRecordRejectsTaskWithoutAttempt(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "run", "init", + "--run", "run_verify_additional_004", + "--goal", "Seed task without attempts", + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "task", "add", + "--run", "run_verify_additional_004", + "--task", "T1", + "--title", "Undispatched task", + "--required-check", "lint", + ) + + stdout, _, exitCode := executeOrchCommand( + "--db", dbPath, + "--json", + "verify", "record", + "--run", "run_verify_additional_004", + "--task", "T1", + "--check", "lint", + "--status", "passed", + ) + if exitCode != 30 { + t.Fatalf("expected invalid_state exit code 30, got %d\nstdout:\n%s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "invalid_state") + assertErrorMessageContains(t, stdout, "has no attempt to verify") +} + +// TestOrchVerifyRecordSkippedCheckKeepsGatePending verifies skipped checks remain pending instead of completing the gate. +func TestOrchVerifyRecordSkippedCheckKeepsGatePending(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + seedVerifyingTaskForVerifyAndRecoveryTests(t, dbPath, "run_verify_additional_005", "T1", []string{"lint"}) + + recordOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "verify", "record", + "--run", "run_verify_additional_005", + "--task", "T1", + "--check", "lint", + "--status", "skipped", + "--summary", "lint intentionally skipped", + ) + + var recordResp map[string]any + mustDecodeJSON(t, recordOut, &recordResp) + if got := nestedString(t, recordResp, "data", "check", "status"); got != "skipped" { + t.Fatalf("expected skipped check status, got %q", got) + } + if got := nestedString(t, recordResp, "data", "task", "status"); got != "verifying" { + t.Fatalf("expected task to stay verifying, got %q", got) + } + if got := nestedString(t, recordResp, "data", "gate", "status"); got != "pending" { + t.Fatalf("expected pending gate after skipped check, got %q", got) + } + pendingChecks := nestedArray(t, recordResp, "data", "gate", "pending_checks") + if len(pendingChecks) != 1 || pendingChecks[0] != "lint" { + t.Fatalf("expected lint to remain pending, got %#v", pendingChecks) + } +} + +// TestOrchVerifyStatusReportsNoGateInPlainText verifies verify status prints a no-gate message for ungated tasks. +func TestOrchVerifyStatusReportsNoGateInPlainText(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "run", "init", + "--run", "run_verify_additional_006", + "--goal", "Seed ungated verify status task", + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "task", "add", + "--run", "run_verify_additional_006", + "--task", "T1", + "--title", "Ungated task", + ) + + stdout, stderr, exitCode := executeOrchCommand( + "--db", dbPath, + "verify", "status", + "--run", "run_verify_additional_006", + "--task", "T1", + ) + if exitCode != 0 { + t.Fatalf("expected verify status exit 0, got %d\nstderr:\n%s\nstdout:\n%s", exitCode, stderr, stdout) + } + if !strings.Contains(stdout, "run_verify_additional_006/T1 has no verification gate") { + t.Fatalf("expected no-gate plain-text output, got:\n%s", stdout) + } +} + +// TestOrchVerifyStatusHelpExplainsGateInspection verifies verify status help explains gate inspection scope. +func TestOrchVerifyStatusHelpExplainsGateInspection(t *testing.T) { + t.Parallel() + + stdout, stderr, exitCode := executeOrchCommand("verify", "status", "--help") + if exitCode != 0 { + t.Fatalf("expected help exit 0, got %d\nstderr:\n%s\nstdout:\n%s", exitCode, stderr, stdout) + } + + combined := stdout + stderr + if !strings.Contains(combined, "task spec snapshot") { + t.Fatalf("expected verify status help to mention the task spec snapshot, got:\n%s", combined) + } + if !strings.Contains(combined, "stuck in verifying or failed") { + t.Fatalf("expected verify status help to explain verifying/failed inspection, got:\n%s", combined) + } +} + +// TestOrchCouncilStartRejectsInvalidInputs verifies council start rejects missing targets and invalid enum values. +func TestOrchCouncilStartRejectsInvalidInputs(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + dbPath := filepath.Join(tempDir, "coord.db") + + cases := []struct { + name string + args []string + message string + }{ + { + name: "missing target", + args: []string{ + "--db", dbPath, + "--json", + "council", "start", + "--run", "council_invalid_missing_target_001", + }, + message: "at least one of target, target-file, repo-path, or task-id is required", + }, + { + name: "invalid mode", + args: []string{ + "--db", dbPath, + "--json", + "council", "start", + "--run", "council_invalid_mode_001", + "--target", "Review the current design.", + "--mode", "plan", + }, + message: "mode must be brainstorm or review", + }, + { + name: "invalid target type", + args: []string{ + "--db", dbPath, + "--json", + "council", "start", + "--run", "council_invalid_target_type_001", + "--target", "Review the current design.", + "--target-type", "doc", + }, + message: "target-type must be text, repo, or mixed", + }, + { + name: "invalid output", + args: []string{ + "--db", dbPath, + "--json", + "council", "start", + "--run", "council_invalid_output_001", + "--target", "Review the current design.", + "--output", "html", + }, + message: "output must be markdown, json, or both", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + stdout, _, exitCode := executeOrchCommand(tc.args...) + if exitCode != 30 { + t.Fatalf("expected invalid_input exit code 30, got %d\nstdout:\n%s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "invalid_input") + assertErrorMessageContains(t, stdout, tc.message) + }) + } +} + +// TestOrchCouncilStartRejectsDuplicateRun verifies council start refuses to reuse an existing run ID. +func TestOrchCouncilStartRejectsDuplicateRun(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + runOrchCommand( + t, + "--db", dbPath, + "--json", + "council", "start", + "--run", "council_duplicate_run_001", + "--target", "Review the current design.", + ) + + stdout, _, exitCode := executeOrchCommand( + "--db", dbPath, + "--json", + "council", "start", + "--run", "council_duplicate_run_001", + "--target", "Review the current design again.", + ) + if exitCode != 30 { + t.Fatalf("expected invalid_state exit code 30, got %d\nstdout:\n%s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "invalid_state") + assertErrorMessageContains(t, stdout, "already exists") +} + +// TestOrchCouncilWaitTreatsFailedAndCancelledReviewersAsComplete verifies council wait wakes once reviewers are terminal, even when some failed or were cancelled. +func TestOrchCouncilWaitTreatsFailedAndCancelledReviewersAsComplete(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + runID := "council_wait_terminals_001" + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "council", "start", + "--run", runID, + "--target", "Review the council workflow terminals.", + ) + + completeCouncilReviewer(t, dbPath, runID, "architecture-reviewer", `{"reviewer_role":"architecture-reviewer","findings":[{"title":"Keep queue small","summary":"One reviewer completed successfully.","proposal":"Keep the council wait contract explicit.","rationale":"This protects terminal-state handling.","confidence":"high","tags":["workflow"],"target_refs":{"repo_path":"."}}]}`) + failCouncilReviewerForAdditionalTests(t, dbPath, runID, "implementation-reviewer", "Implementation review failed") + cancelCouncilReviewerForAdditionalTests(t, dbPath, runID, "risk-reviewer", "Risk review superseded") + + waitOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "council", "wait", + "--run", runID, + "--timeout-seconds", "2", + ) + + var waitResp map[string]any + mustDecodeJSON(t, waitOut, &waitResp) + if woke, _ := nestedValue(t, waitResp, "data", "woke").(bool); !woke { + t.Fatalf("expected council wait to wake, got %#v", waitResp) + } + if allComplete, _ := nestedValue(t, waitResp, "data", "all_complete").(bool); !allComplete { + t.Fatalf("expected all_complete true, got %#v", waitResp) + } + statuses := reviewerStatusesByRole(t, waitResp) + if statuses["architecture-reviewer"] != "done" || statuses["implementation-reviewer"] != "failed" || statuses["risk-reviewer"] != "cancelled" { + t.Fatalf("unexpected reviewer statuses: %#v", statuses) + } +} + +// TestOrchCouncilTallyRejectsInvalidSimilarity verifies council tally rejects unsupported similarity modes. +func TestOrchCouncilTallyRejectsInvalidSimilarity(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + + stdout, _, exitCode := executeOrchCommand( + "--db", dbPath, + "--json", + "council", "tally", + "--run", "council_invalid_similarity_001", + "--similarity", "loose", + ) + if exitCode != 30 { + t.Fatalf("expected invalid_input exit code 30, got %d\nstdout:\n%s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "invalid_input") + assertErrorMessageContains(t, stdout, "similarity must be strict or normal") +} + +// TestOrchCouncilTallyRejectsUnfinishedReviewers verifies council tally refuses runs with incomplete reviewer work. +func TestOrchCouncilTallyRejectsUnfinishedReviewers(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + runOrchCommand( + t, + "--db", dbPath, + "--json", + "council", "start", + "--run", "council_tally_unfinished_001", + "--target", "Review unfinished council behavior.", + ) + + stdout, _, exitCode := executeOrchCommand( + "--db", dbPath, + "--json", + "council", "tally", + "--run", "council_tally_unfinished_001", + ) + if exitCode != 30 { + t.Fatalf("expected invalid_state exit code 30, got %d\nstdout:\n%s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "invalid_state") + assertErrorMessageContains(t, stdout, "not complete yet") +} + +// TestOrchCouncilTallyRejectsFailedReviewer verifies council tally refuses reviewer sets that did not all finish successfully. +func TestOrchCouncilTallyRejectsFailedReviewer(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + runID := "council_tally_failed_001" + runOrchCommand( + t, + "--db", dbPath, + "--json", + "council", "start", + "--run", runID, + "--target", "Review failed reviewer handling.", + ) + + completeCouncilReviewer(t, dbPath, runID, "architecture-reviewer", `{"reviewer_role":"architecture-reviewer","findings":[{"title":"Use explicit gates","summary":"Architecture reviewer completed.","proposal":"Keep council tally strict about success.","rationale":"This avoids mixed-quality reports.","confidence":"high","tags":["architecture"],"target_refs":{"repo_path":"."}}]}`) + completeCouncilReviewer(t, dbPath, runID, "risk-reviewer", `{"reviewer_role":"risk-reviewer","findings":[{"title":"Keep failure visible","summary":"Risk reviewer completed.","proposal":"Preserve terminal reviewer status.","rationale":"Operators need the final state.","confidence":"medium","tags":["risk"],"target_refs":{"repo_path":"."}}]}`) + failCouncilReviewerForAdditionalTests(t, dbPath, runID, "implementation-reviewer", "Implementation reviewer could not finish") + + stdout, _, exitCode := executeOrchCommand( + "--db", dbPath, + "--json", + "council", "tally", + "--run", runID, + ) + if exitCode != 30 { + t.Fatalf("expected invalid_state exit code 30, got %d\nstdout:\n%s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "invalid_state") + assertErrorMessageContains(t, stdout, "did not finish successfully") +} + +// TestOrchCouncilTallyRejectsInvalidReviewerJSON verifies council tally rejects reviewer result bodies that are not JSON. +func TestOrchCouncilTallyRejectsInvalidReviewerJSON(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + runID := "council_tally_invalid_json_001" + runOrchCommand( + t, + "--db", dbPath, + "--json", + "council", "start", + "--run", runID, + "--target", "Review invalid council reviewer JSON.", + ) + + completeCouncilReviewer(t, dbPath, runID, "architecture-reviewer", `{"reviewer_role":"architecture-reviewer","findings":[{"title":"Keep it valid","summary":"Architecture reviewer completed.","proposal":"Use valid JSON payloads.","rationale":"The tally step requires structured input.","confidence":"high","tags":["contracts"],"target_refs":{"repo_path":"."}}]}`) + completeCouncilReviewer(t, dbPath, runID, "risk-reviewer", `{"reviewer_role":"risk-reviewer","findings":[{"title":"Validate inputs","summary":"Risk reviewer completed.","proposal":"Reject malformed reviewer payloads.","rationale":"This keeps the council run deterministic.","confidence":"high","tags":["risk"],"target_refs":{"repo_path":"."}}]}`) + completeCouncilReviewer(t, dbPath, runID, "implementation-reviewer", "not-json") + + stdout, _, exitCode := executeOrchCommand( + "--db", dbPath, + "--json", + "council", "tally", + "--run", runID, + ) + if exitCode != 30 { + t.Fatalf("expected invalid_input exit code 30, got %d\nstdout:\n%s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "invalid_input") + assertErrorMessageContains(t, stdout, "reviewer output must be valid JSON") +} + +// TestOrchCouncilTallyRejectsReviewerRoleMismatch verifies council tally rejects reviewer outputs with mismatched reviewer_role values. +func TestOrchCouncilTallyRejectsReviewerRoleMismatch(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + runID := "council_tally_role_mismatch_001" + runOrchCommand( + t, + "--db", dbPath, + "--json", + "council", "start", + "--run", runID, + "--target", "Review council reviewer role mismatches.", + ) + + completeCouncilReviewer(t, dbPath, runID, "architecture-reviewer", `{"reviewer_role":"architecture-reviewer","findings":[{"title":"Match reviewer roles","summary":"Architecture reviewer completed.","proposal":"Keep reviewer metadata aligned.","rationale":"The tally step groups by reviewer role.","confidence":"medium","tags":["contracts"],"target_refs":{"repo_path":"."}}]}`) + completeCouncilReviewer(t, dbPath, runID, "risk-reviewer", `{"reviewer_role":"risk-reviewer","findings":[{"title":"Keep roles explicit","summary":"Risk reviewer completed.","proposal":"Validate reviewer_role during tally.","rationale":"This blocks malformed council output.","confidence":"high","tags":["risk"],"target_refs":{"repo_path":"."}}]}`) + completeCouncilReviewer(t, dbPath, runID, "implementation-reviewer", `{"reviewer_role":"risk-reviewer","findings":[{"title":"Wrong role","summary":"Role intentionally mismatched.","proposal":"Reject this output.","rationale":"The implementation reviewer should not impersonate another reviewer.","confidence":"high","tags":["contracts"],"target_refs":{"repo_path":"."}}]}`) + + stdout, _, exitCode := executeOrchCommand( + "--db", dbPath, + "--json", + "council", "tally", + "--run", runID, + ) + if exitCode != 30 { + t.Fatalf("expected invalid_input exit code 30, got %d\nstdout:\n%s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "invalid_input") + assertErrorMessageContains(t, stdout, "does not match expected implementation-reviewer") +} + +func failCouncilReviewerForAdditionalTests(t *testing.T, dbPath, runID, reviewerRole, summary string) { + t.Helper() + + threadID := councilReviewerThreadIDForAdditionalTests(t, dbPath, runID, reviewerRole) + runInboxCommand( + t, + "--db", dbPath, + "--json", + "claim", + "--agent", reviewerRole, + "--thread", threadID, + ) + runInboxCommand( + t, + "--db", dbPath, + "--json", + "fail", + "--agent", reviewerRole, + "--thread", threadID, + "--summary", summary, + ) +} + +func cancelCouncilReviewerForAdditionalTests(t *testing.T, dbPath, runID, reviewerRole, reason string) { + t.Helper() + + threadID := councilReviewerThreadIDForAdditionalTests(t, dbPath, runID, reviewerRole) + runInboxCommand( + t, + "--db", dbPath, + "--json", + "cancel", + "--agent", "leader", + "--thread", threadID, + "--reason", reason, + ) +} + +func councilReviewerThreadIDForAdditionalTests(t *testing.T, dbPath, runID, reviewerRole string) string { + t.Helper() + + sqlDB, err := openOrchDB(t.Context(), dbPath) + if err != nil { + t.Fatalf("open orch db: %v", err) + } + defer sqlDB.Close() + + var threadID string + if err := sqlDB.QueryRowContext( + t.Context(), + `SELECT a.thread_id + FROM council_reviewers cr + JOIN task_attempts a + ON a.run_id = cr.run_id + AND a.task_id = cr.task_id + AND a.attempt_no = 1 + WHERE cr.run_id = ? AND cr.reviewer_role = ?`, + runID, + reviewerRole, + ).Scan(&threadID); err != nil { + t.Fatalf("query council reviewer thread for %s: %v", reviewerRole, err) + } + + return threadID +} + +func reviewerStatusesByRole(t *testing.T, payload map[string]any) map[string]string { + t.Helper() + + reviewers := nestedArray(t, payload, "data", "reviewers") + statuses := make(map[string]string, len(reviewers)) + for _, item := range reviewers { + reviewer, ok := item.(map[string]any) + if !ok { + t.Fatalf("expected reviewer object, got %#v", item) + } + role, _ := reviewer["reviewer_role"].(string) + status, _ := reviewer["status"].(string) + statuses[role] = status + } + return statuses +}