diff --git a/docs/orch-cli.md b/docs/orch-cli.md index 8d4ebb7..0c802ba 100644 --- a/docs/orch-cli.md +++ b/docs/orch-cli.md @@ -402,15 +402,6 @@ Behavior: - includes the latest thread message for each task when one exists - includes the latest blocked question for blocked tasks so the leader can inspect the current issue without a separate `blocked` call in the common case -### `orch show` - -Show one task with dependencies, attempts, and inbox mapping. - -Suggested flags: - -- `--run RUN_ID` -- `--task TASK_ID` - ### `orch council start` Start a three-reviewer council workflow for one target. diff --git a/packages/inbox-runtime/internal/cli/inbox/send_reply_contracts_extra_test.go b/packages/inbox-runtime/internal/cli/inbox/send_reply_contracts_extra_test.go new file mode 100644 index 0000000..10249e3 --- /dev/null +++ b/packages/inbox-runtime/internal/cli/inbox/send_reply_contracts_extra_test.go @@ -0,0 +1,103 @@ +package inbox + +import ( + "os" + "path/filepath" + "testing" +) + +// TestReplyReadsBodyFromBodyFile verifies reply loads its message body from a body file. +func TestReplyReadsBodyFromBodyFile(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + dbPath := filepath.Join(tempDir, "coord.db") + bodyPath := filepath.Join(tempDir, "reply.md") + body := "Decision note from body file." + if err := os.WriteFile(bodyPath, []byte(body), 0o644); err != nil { + t.Fatalf("write reply body file: %v", err) + } + + threadID := seedThreadForInboxTests(t, dbPath, "leader", "worker-a") + + replyOut := runInboxCommand( + t, + "--db", dbPath, + "--json", + "reply", + "--from", "leader", + "--to", "worker-a", + "--thread", threadID, + "--summary", "Use the retry policy", + "--body-file", bodyPath, + ) + + var replyResp map[string]any + mustDecodeJSON(t, replyOut, &replyResp) + if got := nestedString(t, replyResp, "data", "message", "body"); got != body { + t.Fatalf("expected reply body %q, got %q", body, got) + } +} + +// TestReplyPersistsPayloadJSON verifies reply preserves payload JSON on a successful reply. +func TestReplyPersistsPayloadJSON(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + threadID := seedThreadForInboxTests(t, dbPath, "leader", "worker-a") + + replyOut := runInboxCommand( + t, + "--db", dbPath, + "--json", + "reply", + "--from", "leader", + "--to", "worker-a", + "--thread", threadID, + "--summary", "Retry read timeouts", + "--payload-json", `{"decision":"retry","owner":"leader"}`, + ) + + var replyResp map[string]any + mustDecodeJSON(t, replyOut, &replyResp) + payload, ok := nestedValue(t, replyResp, "data", "message", "payload_json").(map[string]any) + if !ok { + t.Fatalf("expected payload_json object, got %#v", nestedValue(t, replyResp, "data", "message", "payload_json")) + } + if got := payload["decision"]; got != "retry" { + t.Fatalf("expected decision retry, got %#v", got) + } + if got := payload["owner"]; got != "leader" { + t.Fatalf("expected owner leader, got %#v", got) + } +} + +// TestReplyRejectsInvalidArtifactMetadataJSON verifies reply rejects malformed artifact metadata JSON. +func TestReplyRejectsInvalidArtifactMetadataJSON(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + dbPath := filepath.Join(tempDir, "coord.db") + artifactPath := filepath.Join(tempDir, "decision.md") + if err := os.WriteFile(artifactPath, []byte("Decision note."), 0o644); err != nil { + t.Fatalf("write artifact 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", "Retry read timeouts", + "--artifact", artifactPath, + "--artifact-metadata-json", "not-json", + ) + if exitCode != 30 { + t.Fatalf("expected exit code 30, got %d with output %s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "invalid_input") +} diff --git a/packages/inbox-runtime/internal/cli/inbox/send_reply_contracts_more_test.go b/packages/inbox-runtime/internal/cli/inbox/send_reply_contracts_more_test.go new file mode 100644 index 0000000..11356b5 --- /dev/null +++ b/packages/inbox-runtime/internal/cli/inbox/send_reply_contracts_more_test.go @@ -0,0 +1,102 @@ +package inbox + +import ( + "path/filepath" + "testing" +) + +// TestSendSupportsExplicitKindPriorityAndPayload verifies send honors explicit kind, priority, and payload JSON. +func TestSendSupportsExplicitKindPriorityAndPayload(t *testing.T) { + t.Parallel() + + dbPath := initCommandTestDB(t) + + sendOut := runInboxCommand( + t, + "--db", dbPath, + "--json", + "send", + "--from", "leader", + "--to", "worker-a", + "--subject", "Clarify auth behavior", + "--summary", "Need redirect policy", + "--kind", "question", + "--priority", "high", + "--payload-json", `{"topic":"auth","severity":"high"}`, + ) + + var sendResp map[string]any + mustDecodeJSON(t, sendOut, &sendResp) + + if got := nestedString(t, sendResp, "data", "message", "kind"); got != "question" { + t.Fatalf("expected message kind question, got %q", got) + } + if got := nestedString(t, sendResp, "data", "thread", "priority"); got != "high" { + t.Fatalf("expected thread priority high, got %q", got) + } + payload, ok := nestedValue(t, sendResp, "data", "message", "payload_json").(map[string]any) + if !ok { + t.Fatalf("expected payload_json object, got %#v", nestedValue(t, sendResp, "data", "message", "payload_json")) + } + if got, _ := payload["topic"].(string); got != "auth" { + t.Fatalf("expected payload topic auth, got %#v", payload["topic"]) + } + if got, _ := payload["severity"].(string); got != "high" { + t.Fatalf("expected payload severity high, got %#v", payload["severity"]) + } +} + +// TestReplySupportsQuestionAndProgressKinds verifies reply accepts the remaining documented non-default kinds. +func TestReplySupportsQuestionAndProgressKinds(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + kind string + summary string + body string + }{ + { + name: "question", + kind: "question", + summary: "Need product confirmation", + body: "Should the guest redirect happen before the paywall?", + }, + { + name: "progress", + kind: "progress", + summary: "Investigating", + body: "Checking the redirect and onboarding flows.", + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "coord.db") + threadID := seedThreadForInboxTests(t, dbPath, "leader", "worker-a") + + replyOut := runInboxCommand( + t, + "--db", dbPath, + "--json", + "reply", + "--from", "leader", + "--to", "worker-a", + "--thread", threadID, + "--kind", tc.kind, + "--summary", tc.summary, + "--body", tc.body, + ) + + var replyResp map[string]any + mustDecodeJSON(t, replyOut, &replyResp) + if got := nestedString(t, replyResp, "data", "message", "kind"); got != tc.kind { + t.Fatalf("expected reply kind %q, got %q", tc.kind, got) + } + if got := nestedString(t, replyResp, "data", "message", "summary"); got != tc.summary { + t.Fatalf("expected reply summary %q, got %q", tc.summary, got) + } + }) + } +} diff --git a/packages/inbox-runtime/internal/cli/inbox/terminal_contracts_more_test.go b/packages/inbox-runtime/internal/cli/inbox/terminal_contracts_more_test.go new file mode 100644 index 0000000..68043d8 --- /dev/null +++ b/packages/inbox-runtime/internal/cli/inbox/terminal_contracts_more_test.go @@ -0,0 +1,165 @@ +package inbox + +import ( + "path/filepath" + "testing" +) + +// TestDonePersistsPayloadJSONOnFinalMessage verifies done persists payload JSON on the final result message. +func TestDonePersistsPayloadJSONOnFinalMessage(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", "Retry policy implemented", + "--payload-json", `{"artifact":"report","lines":12}`, + ) + + showOut := runInboxCommand( + t, + "--db", dbPath, + "--json", + "show", + "--thread", threadID, + ) + + var showResp map[string]any + mustDecodeJSON(t, showOut, &showResp) + lastMessage := lastThreadMessageFromShow(t, showResp) + + payload, ok := lastMessage["payload_json"].(map[string]any) + if !ok { + t.Fatalf("expected payload_json object, got %#v", lastMessage["payload_json"]) + } + if got, _ := payload["artifact"].(string); got != "report" { + t.Fatalf("expected payload artifact report, got %#v", payload["artifact"]) + } + if got, _ := payload["lines"].(float64); got != 12 { + t.Fatalf("expected payload lines 12, got %#v", payload["lines"]) + } +} + +// TestFailPersistsPayloadJSONOnFailureMessage verifies fail persists payload JSON on the failure result message. +func TestFailPersistsPayloadJSONOnFailureMessage(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + threadID := seedClaimedThreadForInboxTests(t, dbPath, "leader", "worker-b", "worker-b") + + runInboxCommand( + t, + "--db", dbPath, + "--json", + "fail", + "--agent", "worker-b", + "--thread", threadID, + "--summary", "Migration failed", + "--payload-json", `{"code":"schema_mismatch","retryable":false}`, + ) + + showOut := runInboxCommand( + t, + "--db", dbPath, + "--json", + "show", + "--thread", threadID, + ) + + var showResp map[string]any + mustDecodeJSON(t, showOut, &showResp) + lastMessage := lastThreadMessageFromShow(t, showResp) + + payload, ok := lastMessage["payload_json"].(map[string]any) + if !ok { + t.Fatalf("expected payload_json object, got %#v", lastMessage["payload_json"]) + } + if got, _ := payload["code"].(string); got != "schema_mismatch" { + t.Fatalf("expected payload code schema_mismatch, got %#v", payload["code"]) + } + if got, _ := payload["retryable"].(bool); got { + t.Fatalf("expected retryable=false, got %#v", payload["retryable"]) + } +} + +// TestDoneRejectsInvalidArtifactMetadataJSONInput verifies done rejects malformed artifact metadata JSON. +func TestDoneRejectsInvalidArtifactMetadataJSONInput(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + dbPath := filepath.Join(tempDir, "coord.db") + threadID := seedClaimedThreadForInboxTests(t, dbPath, "leader", "worker-a", "worker-a") + artifactPath := filepath.Join(tempDir, "result.md") + + stdout, _, exitCode := executeInboxCommand( + "--db", dbPath, + "--json", + "done", + "--agent", "worker-a", + "--thread", threadID, + "--summary", "Retry policy implemented", + "--artifact", artifactPath, + "--artifact-metadata-json", "not-json", + ) + if exitCode != 30 { + t.Fatalf("expected exit code 30, got %d with output %s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "invalid_input") +} + +// TestFailRejectsInvalidArtifactMetadataJSONInput verifies fail rejects malformed artifact metadata JSON. +func TestFailRejectsInvalidArtifactMetadataJSONInput(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + dbPath := filepath.Join(tempDir, "coord.db") + threadID := seedClaimedThreadForInboxTests(t, dbPath, "leader", "worker-b", "worker-b") + artifactPath := filepath.Join(tempDir, "failure.md") + + stdout, _, exitCode := executeInboxCommand( + "--db", dbPath, + "--json", + "fail", + "--agent", "worker-b", + "--thread", threadID, + "--summary", "Migration failed", + "--artifact", artifactPath, + "--artifact-metadata-json", "not-json", + ) + if exitCode != 30 { + t.Fatalf("expected exit code 30, got %d with output %s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "invalid_input") +} + +// TestCancelRejectsInvalidArtifactMetadataJSONInput verifies cancel rejects malformed artifact metadata JSON. +func TestCancelRejectsInvalidArtifactMetadataJSONInput(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + dbPath := filepath.Join(tempDir, "coord.db") + threadID := seedThreadForInboxTests(t, dbPath, "leader", "worker-a") + artifactPath := filepath.Join(tempDir, "cancel.md") + + stdout, _, exitCode := executeInboxCommand( + "--db", dbPath, + "--json", + "cancel", + "--agent", "leader", + "--thread", threadID, + "--reason", "Task superseded by a larger refactor", + "--artifact", artifactPath, + "--artifact-metadata-json", "not-json", + ) + if exitCode != 30 { + t.Fatalf("expected exit code 30, got %d with output %s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "invalid_input") +} diff --git a/packages/inbox-runtime/internal/cli/inbox/update_terminal_contracts_extra_test.go b/packages/inbox-runtime/internal/cli/inbox/update_terminal_contracts_extra_test.go new file mode 100644 index 0000000..76c5ac7 --- /dev/null +++ b/packages/inbox-runtime/internal/cli/inbox/update_terminal_contracts_extra_test.go @@ -0,0 +1,72 @@ +package inbox + +import ( + "path/filepath" + "testing" +) + +// TestUpdateRejectsInvalidStatus verifies update rejects statuses outside the documented contract. +func TestUpdateRejectsInvalidStatus(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + threadID := seedClaimedThreadForInboxTests(t, dbPath, "leader", "worker-a", "worker-a") + + stdout, _, exitCode := executeInboxCommand( + "--db", dbPath, + "--json", + "update", + "--agent", "worker-a", + "--thread", threadID, + "--status", "pending", + "--summary", "Unexpected status", + ) + if exitCode != 30 { + t.Fatalf("expected exit code 30, got %d with output %s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "invalid_input") +} + +// TestDoneRejectsInvalidPayloadJSON verifies done rejects malformed payload JSON. +func TestDoneRejectsInvalidPayloadJSON(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + threadID := seedClaimedThreadForInboxTests(t, dbPath, "leader", "worker-a", "worker-a") + + stdout, _, exitCode := executeInboxCommand( + "--db", dbPath, + "--json", + "done", + "--agent", "worker-a", + "--thread", threadID, + "--summary", "Retry policy implemented", + "--payload-json", "not-json", + ) + if exitCode != 30 { + t.Fatalf("expected exit code 30, got %d with output %s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "invalid_input") +} + +// TestFailRejectsInvalidPayloadJSON verifies fail rejects malformed payload JSON. +func TestFailRejectsInvalidPayloadJSON(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + threadID := seedClaimedThreadForInboxTests(t, dbPath, "leader", "worker-b", "worker-b") + + stdout, _, exitCode := executeInboxCommand( + "--db", dbPath, + "--json", + "fail", + "--agent", "worker-b", + "--thread", threadID, + "--summary", "Migration failed", + "--payload-json", "not-json", + ) + if exitCode != 30 { + t.Fatalf("expected exit code 30, got %d with output %s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "invalid_input") +} diff --git a/packages/inbox-runtime/internal/cli/inbox/wait_watch_contracts_extra_test.go b/packages/inbox-runtime/internal/cli/inbox/wait_watch_contracts_extra_test.go new file mode 100644 index 0000000..68ea20e --- /dev/null +++ b/packages/inbox-runtime/internal/cli/inbox/wait_watch_contracts_extra_test.go @@ -0,0 +1,98 @@ +package inbox + +import ( + "path/filepath" + "testing" + "time" +) + +// TestWatchWakesOnTerminalStatuses verifies watch can wake on done and failed status filters. +func TestWatchWakesOnTerminalStatuses(t *testing.T) { + cases := []struct { + name string + status string + agent string + finish func(t *testing.T, dbPath, threadID, agent string) + }{ + { + name: "done", + status: "done", + agent: "worker-d", + finish: func(t *testing.T, dbPath, threadID, agent string) { + runInboxCommand( + t, + "--db", dbPath, + "--json", + "done", + "--agent", agent, + "--thread", threadID, + "--summary", "Task complete", + ) + }, + }, + { + name: "failed", + status: "failed", + agent: "worker-f", + finish: func(t *testing.T, dbPath, threadID, agent string) { + runInboxCommand( + t, + "--db", dbPath, + "--json", + "fail", + "--agent", agent, + "--thread", threadID, + "--summary", "Task failed", + ) + }, + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "coord.db") + runInboxCommand(t, "--db", dbPath, "--json", "init") + threadID := sendPendingThread(t, dbPath, "leader", tc.agent, "Implement feature", "Initial request") + + watchCh := make(chan watchCommandResult, 1) + go func() { + stdout, stderr, exitCode := executeInboxCommand( + "--db", dbPath, + "--json", + "watch", + "--agent", tc.agent, + "--status", tc.status, + "--timeout-seconds", "2", + ) + watchCh <- watchCommandResult{stdout: stdout, stderr: stderr, exit: exitCode} + }() + + time.Sleep(200 * time.Millisecond) + + runInboxCommand( + t, + "--db", dbPath, + "--json", + "claim", + "--agent", tc.agent, + "--thread", threadID, + ) + tc.finish(t, dbPath, threadID, tc.agent) + + 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) + } + var watchResp map[string]any + mustDecodeJSON(t, result.stdout, &watchResp) + if got := nestedString(t, watchResp, "data", "thread", "status"); got != tc.status { + t.Fatalf("expected watch status %q, got %q", tc.status, got) + } + case <-time.After(3 * time.Second): + t.Fatalf("watch command did not return for terminal status %s", tc.status) + } + }) + } +} diff --git a/packages/inbox-runtime/internal/cli/inbox/waits_and_updates_contracts_more_test.go b/packages/inbox-runtime/internal/cli/inbox/waits_and_updates_contracts_more_test.go new file mode 100644 index 0000000..d162f1a --- /dev/null +++ b/packages/inbox-runtime/internal/cli/inbox/waits_and_updates_contracts_more_test.go @@ -0,0 +1,159 @@ +package inbox + +import ( + "path/filepath" + "testing" + "time" +) + +// TestUpdateRejectsInvalidArtifactMetadataJSONOnProgressUpdate verifies update rejects malformed artifact metadata JSON on progress updates. +func TestUpdateRejectsInvalidArtifactMetadataJSONOnProgressUpdate(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + dbPath := filepath.Join(tempDir, "coord.db") + artifactPath := filepath.Join(tempDir, "progress.md") + threadID := seedClaimedThreadForInboxTests(t, dbPath, "leader", "worker-a", "worker-a") + + stdout, _, exitCode := executeInboxCommand( + "--db", dbPath, + "--json", + "update", + "--agent", "worker-a", + "--thread", threadID, + "--status", "in_progress", + "--summary", "Implementation started", + "--artifact", artifactPath, + "--artifact-metadata-json", "not-json", + ) + if exitCode != 30 { + t.Fatalf("expected exit code 30, got %d with output %s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "invalid_input") +} + +// TestWaitReplyWakesOnResultKindFilter verifies wait-reply wakes on a result message when --kinds result is used. +func TestWaitReplyWakesOnResultKindFilter(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", + "--json", + "wait-reply", + "--thread", threadID, + "--after-message", blockedMessageID, + "--kinds", "result", + "--timeout-seconds", "2", + ) + waitCh <- waitReplyCommandResult{stdout: stdout, stderr: stderr, exit: exitCode} + }() + + time.Sleep(200 * time.Millisecond) + + runInboxCommand( + t, + "--db", dbPath, + "--json", + "done", + "--agent", "worker-c", + "--thread", threadID, + "--summary", "Worker completed after unblock", + "--body", "Final result payload.", + ) + + var waitResult waitReplyCommandResult + select { + case waitResult = <-waitCh: + case <-time.After(3 * time.Second): + t.Fatal("wait-reply result filter command did not return") + } + if waitResult.exit != 0 { + t.Fatalf("wait-reply result filter failed with exit=%d\nstderr:\n%s\nstdout:\n%s", waitResult.exit, waitResult.stderr, waitResult.stdout) + } + + var waitResp map[string]any + mustDecodeJSON(t, waitResult.stdout, &waitResp) + if kind := nestedString(t, waitResp, "data", "message", "kind"); kind != "result" { + t.Fatalf("expected result wake message, got %q", kind) + } + if got := nestedString(t, waitResp, "data", "message", "summary"); got != "Worker completed after unblock" { + t.Fatalf("expected result summary, got %q", got) + } +} + +// TestWaitReplyKindsFilterSkipsAnswerUntilControl verifies wait-reply ignores non-matching answers until a matching control message arrives. +func TestWaitReplyKindsFilterSkipsAnswerUntilControl(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", + "--json", + "wait-reply", + "--thread", threadID, + "--after-message", blockedMessageID, + "--kinds", "control", + "--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", "Regular answer", + "--body", "This should not wake the control-only waiter.", + ) + + time.Sleep(200 * time.Millisecond) + + runInboxCommand( + t, + "--db", dbPath, + "--json", + "reply", + "--from", "leader", + "--to", "worker-c", + "--thread", threadID, + "--kind", "control", + "--summary", "Pause work", + "--body", "Pause work until product confirms the decision.", + ) + + var waitResult waitReplyCommandResult + select { + case waitResult = <-waitCh: + case <-time.After(3 * time.Second): + t.Fatal("wait-reply control filter command did not return") + } + if waitResult.exit != 0 { + t.Fatalf("wait-reply control filter failed with exit=%d\nstderr:\n%s\nstdout:\n%s", waitResult.exit, waitResult.stderr, waitResult.stdout) + } + + var waitResp map[string]any + mustDecodeJSON(t, waitResult.stdout, &waitResp) + if kind := nestedString(t, waitResp, "data", "message", "kind"); kind != "control" { + t.Fatalf("expected control wake message, got %q", kind) + } + if got := nestedString(t, waitResp, "data", "message", "summary"); got != "Pause work" { + t.Fatalf("expected control summary, got %q", got) + } +} diff --git a/packages/orch-runtime/internal/cli/orch/cleanup_council_contracts_more_test.go b/packages/orch-runtime/internal/cli/orch/cleanup_council_contracts_more_test.go new file mode 100644 index 0000000..98d7912 --- /dev/null +++ b/packages/orch-runtime/internal/cli/orch/cleanup_council_contracts_more_test.go @@ -0,0 +1,233 @@ +package orch + +import ( + "os" + "path/filepath" + "testing" +) + +// TestOrchCleanupAllCompletedRemovesMultipleWorktrees verifies cleanup removes every completed worktree when --all-completed is used. +func TestOrchCleanupAllCompletedRemovesMultipleWorktrees(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + repoPath := initGitRepo(t) + runID := "run_blog_cleanup_all_completed_001" + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "run", "init", + "--run", runID, + "--goal", "Validate cleanup across multiple completed worktrees", + ) + + worktreeOne := seedCompletedCodeTaskForCleanupCouncilTests(t, dbPath, repoPath, runID, "T1", "worker-a") + worktreeTwo := seedCompletedCodeTaskForCleanupCouncilTests(t, dbPath, repoPath, runID, "T2", "worker-b") + + cleanupOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "cleanup", + "--run", runID, + "--all-completed", + ) + + var cleanupResp map[string]any + mustDecodeJSON(t, cleanupOut, &cleanupResp) + cleaned := nestedArray(t, cleanupResp, "data", "cleaned") + if len(cleaned) != 2 { + t.Fatalf("expected two cleaned attempts, got %#v", cleaned) + } + + for _, worktreePath := range []string{worktreeOne, worktreeTwo} { + if _, err := os.Stat(worktreePath); !os.IsNotExist(err) { + t.Fatalf("expected cleaned worktree path %q to be removed, err=%v", worktreePath, err) + } + } +} + +// TestOrchCleanupForceRemovesCompletedWorktrees verifies cleanup accepts --force while removing completed worktrees selected by --all-completed. +func TestOrchCleanupForceRemovesCompletedWorktrees(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + repoPath := initGitRepo(t) + runID := "run_blog_cleanup_force_001" + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "run", "init", + "--run", runID, + "--goal", "Validate cleanup force flag acceptance", + ) + + worktreePath := seedCompletedCodeTaskForCleanupCouncilTests(t, dbPath, repoPath, runID, "T1", "worker-a") + + cleanupOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "cleanup", + "--run", runID, + "--all-completed", + "--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 attempt with --force, got %#v", cleaned) + } + if _, err := os.Stat(worktreePath); !os.IsNotExist(err) { + t.Fatalf("expected forced cleanup worktree path %q to be removed, err=%v", worktreePath, err) + } +} + +// TestOrchCouncilStartPersistsCustomInputs verifies council start stores explicit target-file, repo-path, task-id, review mode, and only-unanimous settings. +func TestOrchCouncilStartPersistsCustomInputs(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + dbPath := filepath.Join(tempDir, "coord.db") + repoPath := initGitRepo(t) + targetFile := filepath.Join(tempDir, "target.md") + if err := os.WriteFile(targetFile, []byte("# Review target\n\nInspect the API changes.\n"), 0o644); err != nil { + t.Fatalf("write council target file: %v", err) + } + + startOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "council", "start", + "--run", "council_blog_custom_inputs_001", + "--target-file", targetFile, + "--repo-path", repoPath, + "--task-id", "TASK-42", + "--target-type", "repo", + "--mode", "review", + "--output", "json", + "--only-unanimous", + ) + + var startResp map[string]any + mustDecodeJSON(t, startOut, &startResp) + if got := nestedString(t, startResp, "data", "run_id"); got != "council_blog_custom_inputs_001" { + t.Fatalf("expected council run id, got %q", got) + } + if got := nestedString(t, startResp, "data", "mode"); got != "review" { + t.Fatalf("expected council mode review, got %q", got) + } + reviewers := nestedArray(t, startResp, "data", "reviewers") + if len(reviewers) != 3 { + t.Fatalf("expected three reviewers, got %#v", reviewers) + } + + sqlDB, err := openOrchDB(t.Context(), dbPath) + if err != nil { + t.Fatalf("open orch db: %v", err) + } + defer sqlDB.Close() + + var ( + mode string + targetType string + outputMode string + onlyUnanimous int + ) + if err := sqlDB.QueryRowContext( + t.Context(), + `SELECT mode, target_type, output_mode, only_unanimous + FROM council_runs + WHERE run_id = ?`, + "council_blog_custom_inputs_001", + ).Scan(&mode, &targetType, &outputMode, &onlyUnanimous); err != nil { + t.Fatalf("query council_runs: %v", err) + } + if mode != "review" || targetType != "repo" || outputMode != "json" || onlyUnanimous != 1 { + t.Fatalf("unexpected council run metadata: mode=%q targetType=%q outputMode=%q onlyUnanimous=%d", mode, targetType, outputMode, onlyUnanimous) + } + + var ( + storedTargetFile string + storedRepoPath string + storedTaskID string + ) + if err := sqlDB.QueryRowContext( + t.Context(), + `SELECT target_file, repo_path, target_task_id + FROM council_inputs + WHERE run_id = ?`, + "council_blog_custom_inputs_001", + ).Scan(&storedTargetFile, &storedRepoPath, &storedTaskID); err != nil { + t.Fatalf("query council_inputs: %v", err) + } + if storedTargetFile != targetFile || storedRepoPath != repoPath || storedTaskID != "TASK-42" { + t.Fatalf("unexpected council inputs: targetFile=%q repoPath=%q taskID=%q", storedTargetFile, storedRepoPath, storedTaskID) + } +} + +func seedCompletedCodeTaskForCleanupCouncilTests(t *testing.T, dbPath, repoPath, runID, taskID, agent string) string { + t.Helper() + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "task", "add", + "--run", runID, + "--task", taskID, + "--title", "Complete cleanup target "+taskID, + "--default-to", agent, + ) + + dispatchOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "dispatch", + "--run", runID, + "--task", taskID, + "--execution-mode", "code", + "--repo-path", repoPath, + "--workspace-root", ".orch/worktrees", + ) + + var dispatchResp map[string]any + mustDecodeJSON(t, dispatchOut, &dispatchResp) + threadID := nestedString(t, dispatchResp, "data", "attempt", "thread_id") + worktreePath := nestedString(t, dispatchResp, "data", "attempt", "worktree_path") + + runInboxCommand( + t, + "--db", dbPath, + "--json", + "claim", + "--agent", agent, + "--thread", threadID, + ) + runInboxCommand( + t, + "--db", dbPath, + "--json", + "done", + "--agent", agent, + "--thread", threadID, + "--summary", "Completed cleanup target "+taskID, + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "reconcile", + "--run", runID, + ) + + return worktreePath +} diff --git a/packages/orch-runtime/internal/cli/orch/core_contracts_more_test.go b/packages/orch-runtime/internal/cli/orch/core_contracts_more_test.go new file mode 100644 index 0000000..ac153bb --- /dev/null +++ b/packages/orch-runtime/internal/cli/orch/core_contracts_more_test.go @@ -0,0 +1,330 @@ +package orch + +import ( + "os" + "path/filepath" + "testing" +) + +// TestOrchTaskAddRejectsInvalidMetadataJSON verifies orch task add rejects invalid metadata JSON. +func TestOrchTaskAddRejectsInvalidMetadataJSON(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "run", "init", + "--run", "run_blog_008", + "--goal", "Validate task metadata input", + ) + + stdout, _, exitCode := executeOrchCommand( + "--db", dbPath, + "--json", + "task", "add", + "--run", "run_blog_008", + "--task", "T1", + "--title", "Implement retry policy", + "--metadata-json", `{"repo":`, + ) + if exitCode != 30 { + t.Fatalf("expected invalid_input exit code 30, got %d\nstdout:\n%s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "invalid_input") +} + +// TestOrchDepAddCreatesDependencyAndAffectsReadyState verifies orch dep add returns the dependency and blocks the dependent task from ready output. +func TestOrchDepAddCreatesDependencyAndAffectsReadyState(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "run", "init", + "--run", "run_blog_dep_001", + "--goal", "Validate dependency contracts", + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "task", "add", + "--run", "run_blog_dep_001", + "--task", "T1", + "--title", "Prerequisite task", + "--default-to", "worker-a", + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "task", "add", + "--run", "run_blog_dep_001", + "--task", "T2", + "--title", "Dependent task", + "--default-to", "worker-b", + ) + + depOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "dep", "add", + "--run", "run_blog_dep_001", + "--task", "T2", + "--depends-on", "T1", + ) + + var depResp map[string]any + mustDecodeJSON(t, depOut, &depResp) + if got := nestedString(t, depResp, "data", "dependency", "run_id"); got != "run_blog_dep_001" { + t.Fatalf("expected dependency run_id run_blog_dep_001, got %q", got) + } + if got := nestedString(t, depResp, "data", "dependency", "task_id"); got != "T2" { + t.Fatalf("expected dependency task_id T2, got %q", got) + } + if got := nestedString(t, depResp, "data", "dependency", "depends_on_task_id"); got != "T1" { + t.Fatalf("expected dependency depends_on_task_id T1, got %q", got) + } + + readyOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "ready", + "--run", "run_blog_dep_001", + ) + + var readyResp map[string]any + mustDecodeJSON(t, readyOut, &readyResp) + readyTasks := nestedArray(t, readyResp, "data", "tasks") + if len(readyTasks) != 1 { + t.Fatalf("expected one ready task after dependency add, got %#v", readyTasks) + } + readyTask, ok := readyTasks[0].(map[string]any) + if !ok { + t.Fatalf("expected ready task object, got %#v", readyTasks[0]) + } + if got, _ := readyTask["task_id"].(string); got != "T1" { + t.Fatalf("expected only prerequisite task T1 to remain ready, got %#v", readyTask["task_id"]) + } +} + +// TestOrchDepAddRejectsMissingDependencyTask verifies orch dep add rejects a dependency on a missing task. +func TestOrchDepAddRejectsMissingDependencyTask(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "run", "init", + "--run", "run_blog_dep_002", + "--goal", "Validate dependency not found", + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "task", "add", + "--run", "run_blog_dep_002", + "--task", "T2", + "--title", "Dependent task", + "--default-to", "worker-b", + ) + + stdout, _, exitCode := executeOrchCommand( + "--db", dbPath, + "--json", + "dep", "add", + "--run", "run_blog_dep_002", + "--task", "T2", + "--depends-on", "T9", + ) + if exitCode != 40 { + t.Fatalf("expected not_found exit code 40, got %d\nstdout:\n%s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "not_found") +} + +// TestOrchDepAddRejectsMissingTask verifies orch dep add rejects a missing source task. +func TestOrchDepAddRejectsMissingTask(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "run", "init", + "--run", "run_blog_dep_003", + "--goal", "Validate source task not found", + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "task", "add", + "--run", "run_blog_dep_003", + "--task", "T1", + "--title", "Prerequisite task", + "--default-to", "worker-a", + ) + + stdout, _, exitCode := executeOrchCommand( + "--db", dbPath, + "--json", + "dep", "add", + "--run", "run_blog_dep_003", + "--task", "T9", + "--depends-on", "T1", + ) + if exitCode != 40 { + t.Fatalf("expected not_found exit code 40, got %d\nstdout:\n%s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "not_found") +} + +// TestOrchVerifyStatusReturnsTaskAttemptSpecAndGate verifies orch verify status returns task, attempt, spec, and gate details. +func TestOrchVerifyStatusReturnsTaskAttemptSpecAndGate(t *testing.T) { + t.Parallel() + + dbPath, specFile := seedVerifyStatusTaskForCoreContracts(t, "run_verify_core_001", true) + + verifyStatusOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "verify", "status", + "--run", "run_verify_core_001", + "--task", "T1", + ) + + var verifyStatusResp map[string]any + mustDecodeJSON(t, verifyStatusOut, &verifyStatusResp) + if got := nestedString(t, verifyStatusResp, "data", "task", "task_id"); got != "T1" { + t.Fatalf("expected task_id T1, got %q", got) + } + if got := nestedString(t, verifyStatusResp, "data", "task", "status"); got != "verifying" { + t.Fatalf("expected task status verifying, got %q", got) + } + if got := nestedString(t, verifyStatusResp, "data", "attempt", "assigned_to"); got != "worker-a" { + t.Fatalf("expected attempt assigned_to worker-a, got %q", got) + } + if got := nestedString(t, verifyStatusResp, "data", "spec", "spec_file"); got != specFile { + t.Fatalf("expected spec_file %q, got %q", specFile, got) + } + requiredChecks := nestedArray(t, verifyStatusResp, "data", "gate", "required_checks") + if len(requiredChecks) != 1 || requiredChecks[0] != "lint" { + t.Fatalf("expected gate required_checks [lint], got %#v", requiredChecks) + } + pendingChecks := nestedArray(t, verifyStatusResp, "data", "gate", "pending_checks") + if len(pendingChecks) != 1 || pendingChecks[0] != "lint" { + t.Fatalf("expected gate pending_checks [lint], got %#v", pendingChecks) + } +} + +// TestOrchVerifyStatusRejectsMissingExplicitAttempt verifies orch verify status rejects an explicit attempt that does not exist. +func TestOrchVerifyStatusRejectsMissingExplicitAttempt(t *testing.T) { + t.Parallel() + + dbPath, _ := seedVerifyStatusTaskForCoreContracts(t, "run_verify_core_002", false) + + stdout, _, exitCode := executeOrchCommand( + "--db", dbPath, + "--json", + "verify", "status", + "--run", "run_verify_core_002", + "--task", "T1", + "--attempt", "2", + ) + if exitCode != 30 { + t.Fatalf("expected invalid_state exit code 30, got %d\nstdout:\n%s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "invalid_state") +} + +func seedVerifyStatusTaskForCoreContracts(t *testing.T, runID string, moveToVerifying bool) (string, string) { + t.Helper() + + tempDir := t.TempDir() + dbPath := filepath.Join(tempDir, "coord.db") + specFile := filepath.Join(tempDir, "task.md") + if err := os.WriteFile(specFile, []byte("# Task\n\nShip the verifier-backed component change.\n"), 0o644); err != nil { + t.Fatalf("write spec file: %v", err) + } + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "run", "init", + "--run", runID, + "--goal", "Seed verify status contract task", + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "task", "add", + "--run", runID, + "--task", "T1", + "--title", "Implement verifier-backed task", + "--default-to", "worker-a", + "--spec-file", specFile, + "--required-check", "lint", + ) + + dispatchOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "dispatch", + "--run", runID, + "--task", "T1", + "--execution-mode", "analysis", + "--body", "Implement the gated task.", + ) + + var dispatchResp map[string]any + mustDecodeJSON(t, dispatchOut, &dispatchResp) + threadID := nestedString(t, dispatchResp, "data", "attempt", "thread_id") + + if moveToVerifying { + runInboxCommand( + t, + "--db", dbPath, + "--json", + "claim", + "--agent", "worker-a", + "--thread", threadID, + ) + runInboxCommand( + t, + "--db", dbPath, + "--json", + "done", + "--agent", "worker-a", + "--thread", threadID, + "--summary", "Implementation finished", + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "reconcile", + "--run", runID, + ) + } + + return dbPath, specFile +} diff --git a/packages/orch-runtime/internal/cli/orch/edge_contracts_more_test.go b/packages/orch-runtime/internal/cli/orch/edge_contracts_more_test.go new file mode 100644 index 0000000..d9d3423 --- /dev/null +++ b/packages/orch-runtime/internal/cli/orch/edge_contracts_more_test.go @@ -0,0 +1,136 @@ +package orch + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// TestOrchAnswerPropagatesBodyFileContent verifies orch answer reads the leader response body from a file. +func TestOrchAnswerPropagatesBodyFileContent(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + dbPath := filepath.Join(tempDir, "coord.db") + bodyPath := filepath.Join(tempDir, "answer.md") + body := "Use stdout for the MVP logs.\nKeep stderr for unexpected failures.\n" + if err := os.WriteFile(bodyPath, []byte(body), 0o644); err != nil { + t.Fatalf("write answer body file: %v", err) + } + + threadID := seedBlockedTaskForAnswerCleanupEdgeTests(t, dbPath, "run_blog_answer_file_001", "T2", "worker-b") + + answerOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "answer", + "--run", "run_blog_answer_file_001", + "--task", "T2", + "--body-file", bodyPath, + ) + + var answerResp map[string]any + mustDecodeJSON(t, answerOut, &answerResp) + if got := nestedString(t, answerResp, "data", "message", "kind"); got != "answer" { + t.Fatalf("expected answer message kind, got %q", got) + } + if got := nestedString(t, answerResp, "data", "message", "body"); got != body { + t.Fatalf("expected answer body %q, got %q", body, got) + } + + showOut := runInboxCommand( + t, + "--db", dbPath, + "--json", + "show", + "--thread", threadID, + ) + + var showResp map[string]any + mustDecodeJSON(t, showOut, &showResp) + messages := nestedArray(t, showResp, "data", "messages") + lastMessage, ok := messages[len(messages)-1].(map[string]any) + if !ok { + t.Fatalf("expected last message object, got %#v", messages[len(messages)-1]) + } + if got, _ := lastMessage["body"].(string); got != body { + t.Fatalf("expected latest inbox message body %q, got %#v", body, lastMessage["body"]) + } +} + +// TestOrchAnswerRejectsWithoutActiveBlockedQuestion verifies orch answer rejects tasks without an active blocked question. +func TestOrchAnswerRejectsWithoutActiveBlockedQuestion(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + seedDispatchedTaskForAnswerEdgeTests(t, dbPath, "run_blog_answer_ready_001", "T2", "worker-b") + + stdout, _, exitCode := executeOrchCommand( + "--db", dbPath, + "--json", + "answer", + "--run", "run_blog_answer_ready_001", + "--task", "T2", + "--body", "Use stdout for the MVP logs.", + ) + 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, "blocked") +} + +// TestOrchBlockedHelpMentionsCompactQueue verifies blocked help explains the compact queue leaders inspect before answering. +func TestOrchBlockedHelpMentionsCompactQueue(t *testing.T) { + t.Parallel() + + stdout, stderr, exitCode := executeOrchCommand("blocked", "--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, "latest question the leader needs to answer") { + t.Fatalf("expected blocked help to explain latest question role, got:\n%s", combined) + } + if !strings.Contains(combined, "Use blocked before answer") { + t.Fatalf("expected blocked help to explain inspect-before-answer flow, got:\n%s", combined) + } + if !strings.Contains(combined, "compact queue of unresolved worker questions") { + t.Fatalf("expected blocked help to explain compact queue role, got:\n%s", combined) + } +} + +func seedDispatchedTaskForAnswerEdgeTests(t *testing.T, dbPath, runID, taskID, agent string) { + t.Helper() + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "run", "init", + "--run", runID, + "--goal", "Prepare dispatched task for answer edge tests", + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "task", "add", + "--run", runID, + "--task", taskID, + "--title", "Build frontend", + "--default-to", agent, + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "dispatch", + "--run", runID, + "--task", taskID, + "--execution-mode", "analysis", + ) +} diff --git a/packages/orch-runtime/internal/cli/orch/verify_and_recovery_contracts_test.go b/packages/orch-runtime/internal/cli/orch/verify_and_recovery_contracts_test.go new file mode 100644 index 0000000..80cf45d --- /dev/null +++ b/packages/orch-runtime/internal/cli/orch/verify_and_recovery_contracts_test.go @@ -0,0 +1,547 @@ +package orch + +import ( + "os" + "path/filepath" + "testing" +) + +// TestOrchDispatchReadsBodyFromBodyFileAndCarriesSpecPayload verifies dispatch loads body-file input and includes spec and verification policy in the task payload. +func TestOrchDispatchReadsBodyFromBodyFileAndCarriesSpecPayload(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + dbPath := filepath.Join(tempDir, "coord.db") + specFile := filepath.Join(tempDir, "task.md") + bodyFile := filepath.Join(tempDir, "dispatch-body.txt") + specBody := "# Task\n\nImplement the spec-aware worker handoff.\n" + dispatchBody := "Worker brief loaded from file.\n" + if err := os.WriteFile(specFile, []byte(specBody), 0o644); err != nil { + t.Fatalf("write spec file: %v", err) + } + if err := os.WriteFile(bodyFile, []byte(dispatchBody), 0o644); err != nil { + t.Fatalf("write body file: %v", err) + } + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "run", "init", + "--run", "run_dispatch_contracts_001", + "--goal", "Validate dispatch payload contracts", + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "task", "add", + "--run", "run_dispatch_contracts_001", + "--task", "T1", + "--title", "Implement worker handoff", + "--summary", "Ship the spec-aware task payload", + "--default-to", "worker-a", + "--spec-file", specFile, + "--check-profile", "cadence_component", + "--required-check", "lint", + "--allowed-path", "packages/orch-runtime", + "--blocked-path", "scripts/release.sh", + "--metadata-json", `{"team":"core"}`, + ) + + dispatchOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "dispatch", + "--run", "run_dispatch_contracts_001", + "--task", "T1", + "--execution-mode", "analysis", + "--to", "worker-a", + "--body-file", bodyFile, + ) + + var dispatchResp map[string]any + mustDecodeJSON(t, dispatchOut, &dispatchResp) + if got := nestedString(t, dispatchResp, "data", "message", "body"); got != dispatchBody { + t.Fatalf("expected dispatch body %q, got %q", dispatchBody, got) + } + if got := nestedString(t, dispatchResp, "data", "task", "status"); got != "dispatched" { + t.Fatalf("expected task status dispatched, got %q", got) + } + + payload, ok := nestedValue(t, dispatchResp, "data", "message", "payload_json").(map[string]any) + if !ok { + t.Fatalf("expected payload_json object, got %#v", nestedValue(t, dispatchResp, "data", "message", "payload_json")) + } + if got, _ := payload["execution_mode"].(string); got != "analysis" { + t.Fatalf("expected execution_mode analysis, got %#v", payload["execution_mode"]) + } + + spec, ok := payload["spec"].(map[string]any) + if !ok { + t.Fatalf("expected payload spec object, got %#v", payload["spec"]) + } + if got, _ := spec["file"].(string); got != specFile { + t.Fatalf("expected spec file %q, got %#v", specFile, spec["file"]) + } + if got, _ := spec["body"].(string); got != specBody { + t.Fatalf("expected spec body %q, got %#v", specBody, spec["body"]) + } + if got, _ := spec["check_profile"].(string); got != "cadence_component" { + t.Fatalf("expected check_profile cadence_component, got %#v", spec["check_profile"]) + } + requiredChecks, ok := spec["required_checks"].([]any) + if !ok || len(requiredChecks) != 1 || requiredChecks[0] != "lint" { + t.Fatalf("expected required_checks [lint], got %#v", spec["required_checks"]) + } + allowedPaths, ok := spec["allowed_paths"].([]any) + if !ok || len(allowedPaths) != 1 || allowedPaths[0] != "packages/orch-runtime" { + t.Fatalf("expected allowed_paths [packages/orch-runtime], got %#v", spec["allowed_paths"]) + } + blockedPaths, ok := spec["blocked_paths"].([]any) + if !ok || len(blockedPaths) != 1 || blockedPaths[0] != "scripts/release.sh" { + t.Fatalf("expected blocked_paths [scripts/release.sh], got %#v", spec["blocked_paths"]) + } + metadata, ok := spec["metadata"].(map[string]any) + if !ok { + t.Fatalf("expected payload spec metadata object, got %#v", spec["metadata"]) + } + if got, _ := metadata["team"].(string); got != "core" { + t.Fatalf("expected payload spec metadata.team core, got %#v", metadata["team"]) + } +} + +// TestOrchVerifyRecordMovesTaskToFailedOnFailedCheck verifies verify record moves a verifying task to failed when a required check fails. +func TestOrchVerifyRecordMovesTaskToFailedOnFailedCheck(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + seedVerifyingTaskForVerifyAndRecoveryTests(t, dbPath, "run_verify_contracts_001", "T1", []string{"lint"}) + + verifyOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "verify", "record", + "--run", "run_verify_contracts_001", + "--task", "T1", + "--check", "lint", + "--status", "failed", + "--summary", "lint failed", + ) + + var verifyResp map[string]any + mustDecodeJSON(t, verifyOut, &verifyResp) + if got := nestedString(t, verifyResp, "data", "task", "status"); got != "failed" { + t.Fatalf("expected task status failed, got %q", got) + } + if got := nestedString(t, verifyResp, "data", "gate", "status"); got != "failed" { + t.Fatalf("expected gate status failed, got %q", got) + } + if got := nestedString(t, verifyResp, "data", "check", "status"); got != "failed" { + t.Fatalf("expected recorded check status failed, got %q", got) + } +} + +// TestOrchVerifyRecordUpdatesExistingNamedCheck verifies verify record updates an existing named check result and recomputes the gate. +func TestOrchVerifyRecordUpdatesExistingNamedCheck(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + seedVerifyingTaskForVerifyAndRecoveryTests(t, dbPath, "run_verify_contracts_002", "T1", []string{"lint", "test"}) + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "verify", "record", + "--run", "run_verify_contracts_002", + "--task", "T1", + "--check", "lint", + "--status", "failed", + "--summary", "first lint failure", + "--recorded-by", "qa-1", + ) + + verifyOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "verify", "record", + "--run", "run_verify_contracts_002", + "--task", "T1", + "--check", "lint", + "--status", "passed", + "--summary", "lint fixed", + "--recorded-by", "qa-2", + ) + + var verifyResp map[string]any + mustDecodeJSON(t, verifyOut, &verifyResp) + if got := nestedString(t, verifyResp, "data", "task", "status"); got != "verifying" { + t.Fatalf("expected task status verifying after updating lint result, got %q", got) + } + if got := nestedString(t, verifyResp, "data", "gate", "status"); got != "pending" { + t.Fatalf("expected gate status pending after updating lint result, got %q", got) + } + if got := nestedString(t, verifyResp, "data", "check", "status"); got != "passed" { + t.Fatalf("expected updated check status passed, got %q", got) + } + if got := nestedString(t, verifyResp, "data", "check", "summary"); got != "lint fixed" { + t.Fatalf("expected updated check summary lint fixed, got %q", got) + } + if got := nestedString(t, verifyResp, "data", "check", "recorded_by"); got != "qa-2" { + t.Fatalf("expected updated check recorded_by qa-2, got %q", got) + } + + verifyStatusOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "verify", "status", + "--run", "run_verify_contracts_002", + "--task", "T1", + ) + + var verifyStatusResp map[string]any + mustDecodeJSON(t, verifyStatusOut, &verifyStatusResp) + pendingChecks := nestedArray(t, verifyStatusResp, "data", "gate", "pending_checks") + if len(pendingChecks) != 1 || pendingChecks[0] != "test" { + t.Fatalf("expected only test to remain pending after lint update, got %#v", pendingChecks) + } +} + +// TestOrchVerifyRecordRoundTripsBodyMetadataAndRecorder verifies verify record preserves body-file content, metadata JSON, and recorder identity in the returned check result. +func TestOrchVerifyRecordRoundTripsBodyMetadataAndRecorder(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + dbPath := filepath.Join(tempDir, "coord.db") + bodyFile := filepath.Join(tempDir, "verify.txt") + body := "verification details from file\n" + if err := os.WriteFile(bodyFile, []byte(body), 0o644); err != nil { + t.Fatalf("write verify body file: %v", err) + } + + seedVerifyingTaskForVerifyAndRecoveryTests(t, dbPath, "run_verify_contracts_003", "T1", []string{"lint"}) + + verifyOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "verify", "record", + "--run", "run_verify_contracts_003", + "--task", "T1", + "--check", "lint", + "--status", "passed", + "--summary", "lint clean", + "--body-file", bodyFile, + "--metadata-json", `{"ticket":"QA-1"}`, + "--recorded-by", "qa-bot", + ) + + var verifyResp map[string]any + mustDecodeJSON(t, verifyOut, &verifyResp) + if got := nestedString(t, verifyResp, "data", "task", "status"); got != "done" { + t.Fatalf("expected task status done after the only required check passes, got %q", got) + } + if got := nestedString(t, verifyResp, "data", "check", "body"); got != body { + t.Fatalf("expected check body %q, got %q", body, got) + } + if got := nestedString(t, verifyResp, "data", "check", "recorded_by"); got != "qa-bot" { + t.Fatalf("expected check recorded_by qa-bot, got %q", got) + } + metadata, ok := nestedValue(t, verifyResp, "data", "check", "metadata_json").(map[string]any) + if !ok { + t.Fatalf("expected check metadata_json object, got %#v", nestedValue(t, verifyResp, "data", "check", "metadata_json")) + } + if got, _ := metadata["ticket"].(string); got != "QA-1" { + t.Fatalf("expected metadata_json.ticket QA-1, got %#v", metadata["ticket"]) + } +} + +// TestOrchRetryRejectsNonFailedTask verifies retry rejects tasks that are not currently failed. +func TestOrchRetryRejectsNonFailedTask(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "run", "init", + "--run", "run_retry_contracts_001", + "--goal", "Validate retry state guards", + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "task", "add", + "--run", "run_retry_contracts_001", + "--task", "T1", + "--title", "Implement retry guard", + "--default-to", "worker-a", + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "dispatch", + "--run", "run_retry_contracts_001", + "--task", "T1", + "--execution-mode", "analysis", + "--to", "worker-a", + ) + + stdout, _, exitCode := executeOrchCommand( + "--db", dbPath, + "--json", + "retry", + "--run", "run_retry_contracts_001", + "--task", "T1", + ) + if exitCode != 30 { + t.Fatalf("expected invalid_state exit code 30, got %d\nstdout:\n%s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "invalid_state") +} + +// TestOrchRetryUsesBodyFileForReplacementAttempt verifies retry loads the replacement worker brief from body-file. +func TestOrchRetryUsesBodyFileForReplacementAttempt(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + dbPath := filepath.Join(tempDir, "coord.db") + bodyFile := filepath.Join(tempDir, "retry-body.txt") + body := "Retry after the failure with the updated brief.\n" + if err := os.WriteFile(bodyFile, []byte(body), 0o644); err != nil { + t.Fatalf("write retry body file: %v", err) + } + + originalThreadID := seedFailedTaskForVerifyAndRecoveryTests(t, dbPath, "run_retry_contracts_002", "T1") + + retryOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "retry", + "--run", "run_retry_contracts_002", + "--task", "T1", + "--body-file", bodyFile, + ) + + var retryResp map[string]any + mustDecodeJSON(t, retryOut, &retryResp) + if got := nestedString(t, retryResp, "data", "task", "status"); got != "dispatched" { + t.Fatalf("expected task status dispatched after retry, got %q", got) + } + newThreadID := nestedString(t, retryResp, "data", "attempt", "thread_id") + if newThreadID == originalThreadID { + t.Fatalf("expected retry to create a new thread, got %q", newThreadID) + } + + showOut := runInboxCommand( + t, + "--db", dbPath, + "--json", + "show", + "--thread", newThreadID, + ) + + var showResp map[string]any + mustDecodeJSON(t, showOut, &showResp) + lastMessage := lastThreadMessageForVerifyAndRecoveryTests(t, showResp) + if got, _ := lastMessage["body"].(string); got != body { + t.Fatalf("expected retry message body %q, got %#v", body, lastMessage["body"]) + } +} + +// TestOrchReassignWorksFromFailedTask verifies reassign can move a failed task to a new worker by creating a replacement attempt. +func TestOrchReassignWorksFromFailedTask(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + originalThreadID := seedFailedTaskForVerifyAndRecoveryTests(t, dbPath, "run_reassign_contracts_001", "T1") + + reassignOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "reassign", + "--run", "run_reassign_contracts_001", + "--task", "T1", + "--to", "worker-b", + "--reason", "Route the failed task to a different worker.", + ) + + var reassignResp map[string]any + mustDecodeJSON(t, reassignOut, &reassignResp) + if got := nestedString(t, reassignResp, "data", "task", "status"); got != "dispatched" { + t.Fatalf("expected task status dispatched after reassign, got %q", got) + } + if got := nestedString(t, reassignResp, "data", "attempt", "assigned_to"); got != "worker-b" { + t.Fatalf("expected reassigned attempt to target worker-b, got %q", got) + } + if got := nestedValue(t, reassignResp, "data", "attempt", "attempt_no").(float64); got != 2 { + t.Fatalf("expected reassign attempt 2, got %#v", got) + } + newThreadID := nestedString(t, reassignResp, "data", "attempt", "thread_id") + if newThreadID == originalThreadID { + t.Fatalf("expected reassign to create a new thread, got %q", newThreadID) + } +} + +func seedVerifyingTaskForVerifyAndRecoveryTests(t *testing.T, dbPath, runID, taskID string, requiredChecks []string) string { + t.Helper() + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "run", "init", + "--run", runID, + "--goal", "Prepare verifying task for contract tests", + ) + + args := []string{ + "--db", dbPath, + "--json", + "task", "add", + "--run", runID, + "--task", taskID, + "--title", "Prepare verifying task", + "--default-to", "worker-a", + } + for _, check := range requiredChecks { + args = append(args, "--required-check", check) + } + runOrchCommand(t, args...) + + dispatchOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "dispatch", + "--run", runID, + "--task", taskID, + "--execution-mode", "analysis", + "--to", "worker-a", + "--body", "Prepare the task for verification.", + ) + + 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, + ) + runInboxCommand( + t, + "--db", dbPath, + "--json", + "done", + "--agent", "worker-a", + "--thread", threadID, + "--summary", "Implementation complete", + "--body", "Ready for verification.", + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "reconcile", + "--run", runID, + ) + + return threadID +} + +func seedFailedTaskForVerifyAndRecoveryTests(t *testing.T, dbPath, runID, taskID string) string { + t.Helper() + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "run", "init", + "--run", runID, + "--goal", "Prepare failed task for recovery tests", + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "task", "add", + "--run", runID, + "--task", taskID, + "--title", "Prepare failed task", + "--default-to", "worker-a", + ) + + dispatchOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "dispatch", + "--run", runID, + "--task", taskID, + "--execution-mode", "analysis", + "--to", "worker-a", + "--body", "Initial worker brief.", + ) + + 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, + ) + runInboxCommand( + t, + "--db", dbPath, + "--json", + "fail", + "--agent", "worker-a", + "--thread", threadID, + "--summary", "Worker reported a failure", + "--body", "The task failed before completion.", + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "reconcile", + "--run", runID, + ) + + return threadID +} + +func lastThreadMessageForVerifyAndRecoveryTests(t *testing.T, showResp map[string]any) map[string]any { + t.Helper() + + messages := nestedArray(t, showResp, "data", "messages") + if len(messages) == 0 { + t.Fatalf("expected at least one message, got %#v", messages) + } + lastMessage, ok := messages[len(messages)-1].(map[string]any) + if !ok { + t.Fatalf("expected message object, got %#v", messages[len(messages)-1]) + } + return lastMessage +} diff --git a/packages/orch-runtime/internal/cli/orch/verify_answer_contracts_extra_test.go b/packages/orch-runtime/internal/cli/orch/verify_answer_contracts_extra_test.go new file mode 100644 index 0000000..f9bf27b --- /dev/null +++ b/packages/orch-runtime/internal/cli/orch/verify_answer_contracts_extra_test.go @@ -0,0 +1,143 @@ +package orch + +import ( + "path/filepath" + "testing" +) + +// TestOrchAnswerRejectsInvalidPayloadJSON verifies answer rejects malformed payload JSON. +func TestOrchAnswerRejectsInvalidPayloadJSON(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + _ = seedBlockedTaskForAnswerCleanupEdgeTests(t, dbPath, "run_blog_answer_extra_002", "T2", "worker-b") + + stdout, _, exitCode := executeOrchCommand( + "--db", dbPath, + "--json", + "answer", + "--run", "run_blog_answer_extra_002", + "--task", "T2", + "--payload-json", "not-json", + ) + if exitCode != 30 { + t.Fatalf("expected exit code 30, got %d\nstdout:\n%s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "invalid_input") +} + +// TestOrchVerifyStatusSelectsExplicitAttempt verifies verify status can inspect a non-latest attempt by number. +func TestOrchVerifyStatusSelectsExplicitAttempt(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + runID := "run_blog_verify_extra_002" + seedVerifyingTaskForVerifyContractTests(t, dbPath, runID, "T1") + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "verify", "record", + "--run", runID, + "--task", "T1", + "--check", "lint", + "--status", "failed", + "--summary", "Lint failed", + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "retry", + "--run", runID, + "--task", "T1", + "--body", "Retry after fixing lint.", + ) + + verifyStatusOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "verify", "status", + "--run", runID, + "--task", "T1", + "--attempt", "1", + ) + + var verifyStatusResp map[string]any + mustDecodeJSON(t, verifyStatusOut, &verifyStatusResp) + if got := nestedValue(t, verifyStatusResp, "data", "attempt", "attempt_no").(float64); got != 1 { + t.Fatalf("expected verify status to select attempt 1, got %#v", got) + } + if got := nestedString(t, verifyStatusResp, "data", "attempt", "task_id"); got != "T1" { + t.Fatalf("expected verify status attempt task T1, got %q", got) + } + if got := nestedValue(t, verifyStatusResp, "data", "gate", "attempt_no").(float64); got != 1 { + t.Fatalf("expected verify gate attempt 1, got %#v", got) + } +} + +func seedVerifyingTaskForVerifyContractTests(t *testing.T, dbPath, runID, taskID string) { + t.Helper() + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "run", "init", + "--run", runID, + "--goal", "Prepare verify contract task", + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "task", "add", + "--run", runID, + "--task", taskID, + "--title", "Implement gated task", + "--default-to", "worker-a", + "--required-check", "lint", + ) + + dispatchOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "dispatch", + "--run", runID, + "--task", taskID, + "--execution-mode", "analysis", + "--body", "Implement the gated task.", + ) + + 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, + ) + runInboxCommand( + t, + "--db", dbPath, + "--json", + "done", + "--agent", "worker-a", + "--thread", threadID, + "--summary", "Task complete", + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "reconcile", + "--run", runID, + ) +}