multica/server/internal/daemon/execenv/execenv_test.go
LinYushen 6d2a0b45d2
refactor: decouple task lifecycle from issue status (#151)
* refactor: decouple task lifecycle from issue status, add daemon health server

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 18:30:21 +08:00

494 lines
13 KiB
Go

package execenv
import (
"log/slog"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func testLogger() *slog.Logger {
return slog.Default()
}
func TestShortID(t *testing.T) {
t.Parallel()
tests := []struct {
input, want string
}{
{"a1b2c3d4-e5f6-7890-abcd-ef1234567890", "a1b2c3d4"},
{"abcdef12", "abcdef12"},
{"ab", "ab"},
{"a1b2c3d4e5f67890", "a1b2c3d4"},
}
for _, tt := range tests {
if got := shortID(tt.input); got != tt.want {
t.Errorf("shortID(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestSanitizeName(t *testing.T) {
t.Parallel()
tests := []struct {
input, want string
}{
{"Code Reviewer", "code-reviewer"},
{"my_agent!@#v2", "my-agent-v2"},
{" spaces ", "spaces"},
{"UPPERCASE", "uppercase"},
{"a-very-long-name-that-exceeds-thirty-characters-total", "a-very-long-name-that-exceeds"},
{"", "agent"},
{"---", "agent"},
{"日本語テスト", "agent"},
}
for _, tt := range tests {
if got := sanitizeName(tt.input); got != tt.want {
t.Errorf("sanitizeName(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestDetectGitRepo(t *testing.T) {
t.Parallel()
dir := t.TempDir()
cmd := exec.Command("git", "init", dir)
if err := cmd.Run(); err != nil {
t.Skipf("git not available: %v", err)
}
root, ok := detectGitRepo(dir)
if !ok {
t.Fatal("expected git repo to be detected")
}
if root == "" {
t.Fatal("expected non-empty git root")
}
// Subdirectory should also detect.
subdir := filepath.Join(dir, "sub")
os.MkdirAll(subdir, 0o755)
root2, ok2 := detectGitRepo(subdir)
if !ok2 {
t.Fatal("expected subdirectory to detect git repo")
}
if root2 != root {
t.Fatalf("expected same root, got %q vs %q", root2, root)
}
}
func TestDetectGitRepoFalse(t *testing.T) {
t.Parallel()
dir := t.TempDir()
_, ok := detectGitRepo(dir)
if ok {
t.Fatal("expected non-git dir to return false")
}
}
func TestPrepareDirectoryMode(t *testing.T) {
t.Parallel()
workspacesRoot := t.TempDir()
reposRoot := t.TempDir() // not a git repo
env, err := Prepare(PrepareParams{
WorkspacesRoot: workspacesRoot,
RepoPath: reposRoot,
TaskID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
AgentName: "Test Agent",
Task: TaskContextForEnv{
IssueID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
AgentSkills: []SkillContextForEnv{
{Name: "Code Review", Content: "Be concise."},
},
},
}, testLogger())
if err != nil {
t.Fatalf("Prepare failed: %v", err)
}
defer env.Cleanup(true)
if env.Type != WorkspaceTypeDirectory {
t.Fatalf("expected directory type, got %s", env.Type)
}
if env.BranchName != "" {
t.Fatalf("expected empty branch name, got %s", env.BranchName)
}
// Verify directory structure.
for _, sub := range []string{"workdir", "output", "logs"} {
path := filepath.Join(env.RootDir, sub)
if _, err := os.Stat(path); os.IsNotExist(err) {
t.Fatalf("expected %s to exist", path)
}
}
// Verify context file contains issue ID and CLI hints.
content, err := os.ReadFile(filepath.Join(env.WorkDir, ".agent_context", "issue_context.md"))
if err != nil {
t.Fatalf("failed to read issue_context.md: %v", err)
}
for _, want := range []string{"a1b2c3d4-e5f6-7890-abcd-ef1234567890", "multica issue get", "Code Review"} {
if !strings.Contains(string(content), want) {
t.Fatalf("issue_context.md missing %q", want)
}
}
// Verify skill files.
skillContent, err := os.ReadFile(filepath.Join(env.WorkDir, ".agent_context", "skills", "code-review", "SKILL.md"))
if err != nil {
t.Fatalf("failed to read SKILL.md: %v", err)
}
if !strings.Contains(string(skillContent), "Be concise.") {
t.Fatal("SKILL.md missing content")
}
}
func TestPrepareGitWorktreeMode(t *testing.T) {
t.Parallel()
// Create a temporary git repo with an initial commit.
reposRoot := t.TempDir()
for _, args := range [][]string{
{"init", reposRoot},
{"-C", reposRoot, "commit", "--allow-empty", "-m", "initial"},
} {
cmd := exec.Command("git", args...)
cmd.Env = append(os.Environ(),
"GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@test.com",
"GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@test.com",
)
if out, err := cmd.CombinedOutput(); err != nil {
t.Skipf("git setup failed: %s: %v", out, err)
}
}
workspacesRoot := t.TempDir()
env, err := Prepare(PrepareParams{
WorkspacesRoot: workspacesRoot,
RepoPath: reposRoot,
TaskID: "b2c3d4e5-f6a7-8901-bcde-f12345678901",
AgentName: "Code Reviewer",
Task: TaskContextForEnv{
IssueID: "b2c3d4e5-f6a7-8901-bcde-f12345678901",
},
}, testLogger())
if err != nil {
t.Fatalf("Prepare failed: %v", err)
}
defer env.Cleanup(true)
if env.Type != WorkspaceTypeGitWorktree {
t.Fatalf("expected git_worktree type, got %s", env.Type)
}
if env.BranchName == "" {
t.Fatal("expected non-empty branch name")
}
if !strings.HasPrefix(env.BranchName, "agent/code-reviewer/") {
t.Fatalf("unexpected branch name: %s", env.BranchName)
}
// Verify worktree is listed.
cmd := exec.Command("git", "-C", reposRoot, "worktree", "list")
out, err := cmd.Output()
if err != nil {
t.Fatalf("git worktree list failed: %v", err)
}
if !strings.Contains(string(out), "workdir") {
t.Fatalf("worktree not listed: %s", out)
}
// Verify context file exists in workdir.
if _, err := os.Stat(filepath.Join(env.WorkDir, ".agent_context", "issue_context.md")); os.IsNotExist(err) {
t.Fatal("expected .agent_context/issue_context.md to exist in workdir")
}
}
func TestWriteContextFiles(t *testing.T) {
t.Parallel()
dir := t.TempDir()
ctx := TaskContextForEnv{
IssueID: "test-issue-id-1234",
AgentSkills: []SkillContextForEnv{
{
Name: "Go Conventions",
Content: "Follow Go conventions.",
Files: []SkillFileContextForEnv{
{Path: "templates/example.go", Content: "package main"},
},
},
},
}
if err := writeContextFiles(dir, ctx); err != nil {
t.Fatalf("writeContextFiles failed: %v", err)
}
content, err := os.ReadFile(filepath.Join(dir, ".agent_context", "issue_context.md"))
if err != nil {
t.Fatalf("failed to read: %v", err)
}
s := string(content)
for _, want := range []string{
"test-issue-id-1234",
"multica issue get",
"## Agent Skills",
"Go Conventions",
} {
if !strings.Contains(s, want) {
t.Errorf("content missing %q", want)
}
}
// Issue details should NOT be in the context file (agent fetches via CLI).
for _, absent := range []string{"## Description", "## Workspace Context"} {
if strings.Contains(s, absent) {
t.Errorf("content should NOT contain %q — agent fetches details via CLI", absent)
}
}
// Verify skill directory and files.
skillMd, err := os.ReadFile(filepath.Join(dir, ".agent_context", "skills", "go-conventions", "SKILL.md"))
if err != nil {
t.Fatalf("failed to read SKILL.md: %v", err)
}
if !strings.Contains(string(skillMd), "Follow Go conventions.") {
t.Error("SKILL.md missing content")
}
supportFile, err := os.ReadFile(filepath.Join(dir, ".agent_context", "skills", "go-conventions", "templates", "example.go"))
if err != nil {
t.Fatalf("failed to read supporting file: %v", err)
}
if string(supportFile) != "package main" {
t.Errorf("supporting file content = %q, want %q", string(supportFile), "package main")
}
}
func TestWriteContextFilesOmitsSkillsWhenEmpty(t *testing.T) {
t.Parallel()
dir := t.TempDir()
ctx := TaskContextForEnv{
IssueID: "minimal-issue-id",
}
if err := writeContextFiles(dir, ctx); err != nil {
t.Fatalf("writeContextFiles failed: %v", err)
}
content, err := os.ReadFile(filepath.Join(dir, ".agent_context", "issue_context.md"))
if err != nil {
t.Fatalf("failed to read: %v", err)
}
s := string(content)
if !strings.Contains(s, "minimal-issue-id") {
t.Error("expected issue ID to be present")
}
if strings.Contains(s, "## Agent Skills") {
t.Error("expected skills section to be omitted when no skills")
}
}
func TestCleanupGitWorktree(t *testing.T) {
t.Parallel()
// Create a temp git repo.
reposRoot := t.TempDir()
for _, args := range [][]string{
{"init", reposRoot},
{"-C", reposRoot, "commit", "--allow-empty", "-m", "initial"},
} {
cmd := exec.Command("git", args...)
cmd.Env = append(os.Environ(),
"GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@test.com",
"GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@test.com",
)
if out, err := cmd.CombinedOutput(); err != nil {
t.Skipf("git setup failed: %s: %v", out, err)
}
}
workspacesRoot := t.TempDir()
env, err := Prepare(PrepareParams{
WorkspacesRoot: workspacesRoot,
RepoPath: reposRoot,
TaskID: "c3d4e5f6-a7b8-9012-cdef-123456789012",
AgentName: "Cleanup Test",
Task: TaskContextForEnv{IssueID: "cleanup-test-id"},
}, testLogger())
if err != nil {
t.Fatalf("Prepare failed: %v", err)
}
branchName := env.BranchName
rootDir := env.RootDir
// Cleanup with removeAll=true.
if err := env.Cleanup(true); err != nil {
t.Fatalf("Cleanup failed: %v", err)
}
// Verify env root is removed.
if _, err := os.Stat(rootDir); !os.IsNotExist(err) {
t.Fatal("expected env root to be removed")
}
// Verify branch is deleted.
cmd := exec.Command("git", "-C", reposRoot, "branch", "--list", branchName)
out, _ := cmd.Output()
if strings.TrimSpace(string(out)) != "" {
t.Fatalf("expected branch %s to be deleted", branchName)
}
}
func TestInjectRuntimeConfigClaude(t *testing.T) {
t.Parallel()
dir := t.TempDir()
ctx := TaskContextForEnv{
IssueID: "test-issue-id",
AgentSkills: []SkillContextForEnv{
{Name: "Go Conventions", Content: "Follow Go conventions.", Files: []SkillFileContextForEnv{
{Path: "example.go", Content: "package main"},
}},
{Name: "PR Review", Content: "Review PRs carefully."},
},
}
if err := InjectRuntimeConfig(dir, "claude", ctx); err != nil {
t.Fatalf("InjectRuntimeConfig failed: %v", err)
}
content, err := os.ReadFile(filepath.Join(dir, "CLAUDE.md"))
if err != nil {
t.Fatalf("failed to read CLAUDE.md: %v", err)
}
s := string(content)
for _, want := range []string{
"Multica Agent Runtime",
"multica issue get",
"multica issue comment list",
"Go Conventions",
"PR Review",
"go-conventions/SKILL.md",
"pr-review/SKILL.md",
"1 supporting files",
} {
if !strings.Contains(s, want) {
t.Errorf("CLAUDE.md missing %q", want)
}
}
}
func TestInjectRuntimeConfigCodex(t *testing.T) {
t.Parallel()
dir := t.TempDir()
ctx := TaskContextForEnv{
IssueID: "test-issue-id",
AgentSkills: []SkillContextForEnv{{Name: "Coding", Content: "Write good code."}},
}
if err := InjectRuntimeConfig(dir, "codex", ctx); err != nil {
t.Fatalf("InjectRuntimeConfig failed: %v", err)
}
content, err := os.ReadFile(filepath.Join(dir, "AGENTS.md"))
if err != nil {
t.Fatalf("failed to read AGENTS.md: %v", err)
}
s := string(content)
if !strings.Contains(s, "Multica Agent Runtime") {
t.Error("AGENTS.md missing meta skill header")
}
if !strings.Contains(s, "Coding") {
t.Error("AGENTS.md missing skill name")
}
}
func TestInjectRuntimeConfigNoSkills(t *testing.T) {
t.Parallel()
dir := t.TempDir()
ctx := TaskContextForEnv{IssueID: "test-issue-id"}
if err := InjectRuntimeConfig(dir, "claude", ctx); err != nil {
t.Fatalf("InjectRuntimeConfig failed: %v", err)
}
content, err := os.ReadFile(filepath.Join(dir, "CLAUDE.md"))
if err != nil {
t.Fatalf("failed to read CLAUDE.md: %v", err)
}
s := string(content)
if !strings.Contains(s, "multica issue get") {
t.Error("should reference multica CLI even without skills")
}
if strings.Contains(s, "## Skills") {
t.Error("should not have Skills section when there are no skills")
}
}
func TestInjectRuntimeConfigUnknownProvider(t *testing.T) {
t.Parallel()
dir := t.TempDir()
// Unknown provider should be a no-op.
if err := InjectRuntimeConfig(dir, "unknown", TaskContextForEnv{}); err != nil {
t.Fatalf("expected no error for unknown provider, got: %v", err)
}
// No files should be created.
entries, _ := os.ReadDir(dir)
if len(entries) != 0 {
t.Fatalf("expected empty dir for unknown provider, got %d entries", len(entries))
}
}
func TestCleanupPreservesLogs(t *testing.T) {
t.Parallel()
workspacesRoot := t.TempDir()
env, err := Prepare(PrepareParams{
WorkspacesRoot: workspacesRoot,
RepoPath: t.TempDir(), // not a git repo
TaskID: "d4e5f6a7-b8c9-0123-defa-234567890123",
AgentName: "Preserve Test",
Task: TaskContextForEnv{IssueID: "preserve-test-id"},
}, testLogger())
if err != nil {
t.Fatalf("Prepare failed: %v", err)
}
// Write something to logs/.
os.WriteFile(filepath.Join(env.RootDir, "logs", "test.log"), []byte("log data"), 0o644)
// Cleanup with removeAll=false.
if err := env.Cleanup(false); err != nil {
t.Fatalf("Cleanup failed: %v", err)
}
// workdir should be gone.
if _, err := os.Stat(env.WorkDir); !os.IsNotExist(err) {
t.Fatal("expected workdir to be removed")
}
// logs should still exist.
logFile := filepath.Join(env.RootDir, "logs", "test.log")
if _, err := os.Stat(logFile); os.IsNotExist(err) {
t.Fatal("expected logs/test.log to be preserved")
}
}