package main import ( "bytes" "context" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "io" "os" "os/exec" "path/filepath" "strings" "sync" "syscall" "testing" "time" "iop/apps/agent/internal/command" "iop/apps/agent/internal/projectlog" "iop/apps/agent/internal/taskloop" "iop/packages/go/agenttask" "iop/packages/go/agentworkspace" ) // TestRunSuccessExitCode verifies that a successful command returns exit code 0 // and writes output only to stdout, never to stderr. func TestRunSuccessExitCode(t *testing.T) { tmpDir := t.TempDir() globalPath := filepath.Join(tmpDir, "runtime.yaml") localPath := filepath.Join(tmpDir, "local.yaml") catalogPath := filepath.Join(tmpDir, "providers.yaml") writeFile(t, globalPath, trackedGlobalConfig) writeFile(t, localPath, trackedLocalConfig) writeFile(t, catalogPath, trackedCatalogConfig) stdout := &bytes.Buffer{} stderr := &bytes.Buffer{} exit := run([]string{ "validate", "--repo-config", globalPath, "--local-config", localPath, "--provider-catalog", catalogPath, }, stdout, stderr) if exit != 0 { t.Fatalf("expected exit 0, got %d (stdout=%q stderr=%q)", exit, stdout.String(), stderr.String()) } if stderr.Len() != 0 { t.Errorf("expected empty stderr on success, got %q", stderr.String()) } if !strings.Contains(stdout.String(), "validate: ok") { t.Errorf("expected validate: ok in stdout, got %q", stdout.String()) } } // TestRunConfigErrorExitCode verifies that a missing config file returns exit code 1 // and writes the error to stderr, not stdout. func TestRunConfigErrorExitCode(t *testing.T) { stdout := &bytes.Buffer{} stderr := &bytes.Buffer{} exit := run([]string{ "validate", "--repo-config", "/nonexistent/runtime.yaml", "--local-config", "/nonexistent/local.yaml", "--provider-catalog", "/nonexistent/providers.yaml", }, stdout, stderr) if exit != 1 { t.Fatalf("expected exit 1, got %d (stdout=%q stderr=%q)", exit, stdout.String(), stderr.String()) } if stdout.Len() != 0 { t.Errorf("expected empty stdout on error, got %q", stdout.String()) } if !strings.Contains(stderr.String(), "read repo-global config") { t.Errorf("expected config error in stderr, got %q", stderr.String()) } } // TestRunUsageErrorExitCode verifies that an unknown subcommand shows help. // Cobra returns nil for unknown commands (shows help), so we verify help output. func TestRunUsageErrorExitCode(t *testing.T) { stdout := &bytes.Buffer{} stderr := &bytes.Buffer{} exit := run([]string{"unknown-command"}, stdout, stderr) // Cobra shows help for unknown commands and returns nil. if exit != 0 { t.Fatalf("expected exit 0 for unknown command (cobra shows help), got %d (stdout=%q stderr=%q)", exit, stdout.String(), stderr.String()) } if !strings.Contains(stdout.String(), "Available Commands") { t.Errorf("expected help output for unknown command, got stdout=%q", stdout.String()) } } // TestRunMissingRequiredFlags verifies that validate without required flags returns exit code 1. func TestRunMissingRequiredFlags(t *testing.T) { stdout := &bytes.Buffer{} stderr := &bytes.Buffer{} exit := run([]string{"validate"}, stdout, stderr) if exit != 1 { t.Fatalf("expected exit 1 for missing flags, got %d (stdout=%q stderr=%q)", exit, stdout.String(), stderr.String()) } if !strings.Contains(stderr.String(), "required") { t.Errorf("expected 'required' in error, got %q", stderr.String()) } } // TestRunProviderList verifies that provider list loads and displays providers. func TestRunProviderList(t *testing.T) { tmpDir := t.TempDir() catalogPath := filepath.Join(tmpDir, "providers.yaml") writeFile(t, catalogPath, trackedCatalogConfig) stdout := &bytes.Buffer{} stderr := &bytes.Buffer{} exit := run([]string{ "provider", "list", "--provider-catalog", catalogPath, }, stdout, stderr) if exit != 0 { t.Fatalf("expected exit 0, got %d (stdout=%q stderr=%q)", exit, stdout.String(), stderr.String()) } if !strings.Contains(stdout.String(), "claude") { t.Errorf("expected provider name in output, got %q", stdout.String()) } if stderr.Len() != 0 { t.Errorf("expected empty stderr, got %q", stderr.String()) } } // TestRunProjectList verifies that project list loads and displays projects. func TestRunProjectList(t *testing.T) { tmpDir := t.TempDir() globalPath := filepath.Join(tmpDir, "runtime.yaml") localPath := filepath.Join(tmpDir, "local.yaml") writeFile(t, globalPath, trackedGlobalConfig) writeFile(t, localPath, trackedLocalConfig) stdout := &bytes.Buffer{} stderr := &bytes.Buffer{} exit := run([]string{ "project", "list", "--repo-config", globalPath, "--local-config", localPath, }, stdout, stderr) if exit != 0 { t.Fatalf("expected exit 0, got %d (stdout=%q stderr=%q)", exit, stdout.String(), stderr.String()) } if !strings.Contains(stdout.String(), "iop-s0") { t.Errorf("expected project id in output, got %q", stdout.String()) } } // TestRunHelpShowsFullSurface verifies that --help lists all subcommands. func TestRunHelpShowsFullSurface(t *testing.T) { stdout := &bytes.Buffer{} stderr := &bytes.Buffer{} exit := run([]string{"--help"}, stdout, stderr) if exit != 0 { t.Fatalf("expected exit 0 for --help, got %d (stdout=%q stderr=%q)", exit, stdout.String(), stderr.String()) } surface := []string{"validate", "provider", "project", "milestone", "preview", "serve", "start", "stop", "resume", "status", "task-loop"} for _, cmd := range surface { if !strings.Contains(stdout.String(), cmd) { t.Errorf("help output missing subcommand %q", cmd) } } } func TestRunTaskLoopDryRunUsesNoProvider(t *testing.T) { globalPath, localPath, _ := createRuntimeFixture(t) stdout := &bytes.Buffer{} stderr := &bytes.Buffer{} exit := run([]string{ "task-loop", "--dry-run", "--task-group", "m-milestone-1", "--repo-config", globalPath, "--local-config", localPath, }, stdout, stderr) if exit != 0 { t.Fatalf("task-loop dry-run exit=%d stdout=%q stderr=%q", exit, stdout.String(), stderr.String()) } if !strings.Contains(stdout.String(), "task-loop dry_run=true") || !strings.Contains(stdout.String(), "project=iop-s0") { t.Fatalf("unexpected task-loop dry-run output %q", stdout.String()) } } func newArchiveOnlyRepositoryFixture(t *testing.T) string { t.Helper() root := t.TempDir() writeFile(t, filepath.Join(root, "go.mod"), "module test\n\ngo 1.24\n") completion := filepath.Join(root, "agent-task", "archive", "2026", "07", "m-completed", "complete.log") if err := os.MkdirAll(filepath.Dir(completion), 0700); err != nil { t.Fatal(err) } writeFile(t, completion, "complete\n") return root } func TestRunTaskLoopConfigFreeDryRun(t *testing.T) { root := newArchiveOnlyRepositoryFixture(t) t.Chdir(root) stdout := &bytes.Buffer{} stderr := &bytes.Buffer{} exit := run([]string{"task-loop", "--dry-run", "--task-group", "m-completed"}, stdout, stderr) if exit != 0 { t.Fatalf("config-free task-loop exit=%d stdout=%q stderr=%q", exit, stdout.String(), stderr.String()) } if stderr.Len() != 0 { t.Fatalf("stderr = %q, want empty", stderr.String()) } if !strings.Contains(stdout.String(), "project=workspace") || !strings.Contains(stdout.String(), "status=complete") { t.Fatalf("unexpected config-free task-loop output %q", stdout.String()) } } func TestRunValidatePlanUsesNoProvider(t *testing.T) { workspace := t.TempDir() writeFile(t, filepath.Join(workspace, "claimed.md"), "claimed\n") candidate := filepath.Join(t.TempDir(), "PLAN.md") writeFile(t, candidate, "# Plan\n\n## Modified Files Summary\n\n| File | Purpose |\n|---|---|\n| `claimed.md` | test |\n") stdout := &bytes.Buffer{} stderr := &bytes.Buffer{} if exit := run([]string{"task-loop", "validate-plan", "--workspace", workspace, candidate}, stdout, stderr); exit != 0 || stdout.Len() != 0 || stderr.Len() != 0 { t.Fatalf("exit=%d stdout=%q stderr=%q", exit, stdout.String(), stderr.String()) } writeFile(t, candidate, "# Plan\n") if exit := run([]string{"task-loop", "validate-plan", "--workspace", workspace, candidate}, stdout, stderr); exit != 1 || !strings.Contains(stderr.String(), "Modified Files Summary is missing") { t.Fatalf("invalid exit=%d stdout=%q stderr=%q", exit, stdout.String(), stderr.String()) } } func TestTaskLoopFinalStateDeterminesTerminalExit(t *testing.T) { globalPath, localPath, catalogPath := createRuntimeFixture(t) paths := command.RuntimeConfigPaths{RepoGlobalPath: globalPath, UserLocalPath: localPath, ProviderCatalog: catalogPath} cases := []struct { name string retry bool statuses []agenttask.ProjectStatus wantExit int wantResume int wantStart int }{ {"retry remains blocked", true, []agenttask.ProjectStatus{agenttask.ProjectStatusBlocked, agenttask.ProjectStatusBlocked}, 2, 1, 0}, {"completed only", false, []agenttask.ProjectStatus{agenttask.ProjectStatusCompleted, agenttask.ProjectStatusCompleted}, 0, 0, 1}, } for _, tt := range cases { t.Run(tt.name, func(t *testing.T) { stub := &taskLoopRuntimeStub{statuses: tt.statuses} adapter := newAdapter(io.Discard, io.Discard) adapter.operatorRuntimeFactory = func(command.RuntimeConfigPaths) (taskLoopOperatorRuntime, error) { return stub, nil } response, err := adapter.TaskLoop(context.Background(), command.TaskLoopRequest{Config: paths, RetryBlocked: tt.retry}) if err != nil { t.Fatal(err) } if response.ExitCode != tt.wantExit || stub.resumeCalls != tt.wantResume || stub.startCalls != tt.wantStart || stub.reconcileCalls != 1 || stub.statusCalls != 2 { t.Fatalf("response=%#v calls=%#v", response, stub) } }) } if taskLoopIsDrainedBlocked([]command.TaskLoopProject{{Status: string(agenttask.ProjectStatusBlocked)}, {Status: string(agenttask.ProjectStatusRunning)}}) { t.Fatal("a runnable final project must not receive terminal exit 2") } } type taskLoopRuntimeStub struct { statuses []agenttask.ProjectStatus statusCalls int startCalls int resumeCalls int reconcileCalls int } func (stub *taskLoopRuntimeStub) ProjectStatus(_ context.Context, _ string) (taskloop.ProjectView, error) { index := stub.statusCalls stub.statusCalls++ if index >= len(stub.statuses) { index = len(stub.statuses) - 1 } return taskloop.ProjectView{Status: stub.statuses[index]}, nil } func (stub *taskLoopRuntimeStub) StartProject(_ context.Context, _ string) (taskloop.ProjectView, error) { stub.startCalls++ return taskloop.ProjectView{Status: agenttask.ProjectStatusStarted}, nil } func (stub *taskLoopRuntimeStub) ResumeProject(_ context.Context, _ string) (taskloop.ProjectView, error) { stub.resumeCalls++ return taskloop.ProjectView{Status: agenttask.ProjectStatusStarted}, nil } func (stub *taskLoopRuntimeStub) Reconcile(context.Context) error { stub.reconcileCalls++ return nil } // TestRunVersionFlagShowsHelp verifies that --help lists all subcommands. // The agent CLI does not have a dedicated version subcommand; version is shown via --help. func TestRunVersionFlagShowsHelp(t *testing.T) { stdout := &bytes.Buffer{} stderr := &bytes.Buffer{} exit := run([]string{"--help"}, stdout, stderr) if exit != 0 { t.Fatalf("expected exit 0 for --help, got %d (stdout=%q stderr=%q)", exit, stdout.String(), stderr.String()) } if !strings.Contains(stdout.String(), "validate") { t.Errorf("expected validate in help output, got %q", stdout.String()) } } // TestRunNilStreamsFallsBackToOS verifies that nil stdout/stderr falls back to os.Stdout/os.Stderr. func TestRunNilStreamsFallsBackToOS(t *testing.T) { // This test just verifies it doesn't panic with nil streams. exit := run([]string{"--help"}, nil, nil) if exit != 0 { t.Fatalf("expected exit 0 with nil streams, got %d", exit) } } // TestRunOutputFormatValidation verifies that invalid --output format fails before dispatch. func TestRunOutputFormatValidation(t *testing.T) { stdout := &bytes.Buffer{} stderr := &bytes.Buffer{} exit := run([]string{"validate", "--output", "xml"}, stdout, stderr) if exit != 1 { t.Fatalf("expected exit 1 for invalid output format, got %d", exit) } if !strings.Contains(stderr.String(), "unsupported output format") { t.Errorf("expected output format error, got %q", stderr.String()) } } // TestRunStatusForUnknownProject verifies status returns unknown for unregistered project. func TestRunStatusForUnknownProject(t *testing.T) { tmpDir := t.TempDir() globalPath := filepath.Join(tmpDir, "runtime.yaml") localPath := filepath.Join(tmpDir, "local.yaml") writeFile(t, globalPath, trackedGlobalConfig) writeFile(t, localPath, trackedLocalConfig) stdout := &bytes.Buffer{} stderr := &bytes.Buffer{} exit := run([]string{ "status", "nonexistent-project", "--repo-config", globalPath, "--local-config", localPath, }, stdout, stderr) if exit != 0 { t.Fatalf("expected exit 0, got %d (stdout=%q stderr=%q)", exit, stdout.String(), stderr.String()) } if !strings.Contains(stdout.String(), "unknown") { t.Errorf("expected 'unknown' status, got %q", stdout.String()) } } // TestRunPreviewForUnknownProject verifies preview returns not_registered blocker. func TestRunPreviewForUnknownProject(t *testing.T) { tmpDir := t.TempDir() globalPath := filepath.Join(tmpDir, "runtime.yaml") localPath := filepath.Join(tmpDir, "local.yaml") writeFile(t, globalPath, trackedGlobalConfig) writeFile(t, localPath, trackedLocalConfig) stdout := &bytes.Buffer{} stderr := &bytes.Buffer{} exit := run([]string{ "preview", "nonexistent-project", "--repo-config", globalPath, "--local-config", localPath, }, stdout, stderr) if exit != 0 { t.Fatalf("expected exit 0, got %d (stdout=%q stderr=%q)", exit, stdout.String(), stderr.String()) } if !strings.Contains(stdout.String(), "not_registered") { t.Errorf("expected not_registered blocker, got %q", stdout.String()) } } // TestRunStartWithoutSelection verifies start rejects a registered project that // has no explicitly selected milestone, before reaching the runtime boundary. // The rejection is exact and writes only to stderr. func TestRunStartWithoutSelection(t *testing.T) { tmpDir := t.TempDir() globalPath := filepath.Join(tmpDir, "runtime.yaml") localPath := filepath.Join(tmpDir, "local.yaml") writeFile(t, globalPath, trackedGlobalConfig) writeFile(t, localPath, unselectedLocalConfig) stdout := &bytes.Buffer{} stderr := &bytes.Buffer{} exit := run([]string{ "start", "iop-s0", "--repo-config", globalPath, "--local-config", localPath, }, stdout, stderr) if exit != 1 { t.Fatalf("expected unselected start to exit 1, got %d (stdout=%q stderr=%q)", exit, stdout.String(), stderr.String()) } if stdout.Len() != 0 { t.Errorf("expected empty stdout on rejection, got %q", stdout.String()) } if got := stderr.String(); got != "start: project iop-s0 has no selected milestone\n" { t.Fatalf("unexpected stderr: %q", got) } } func TestRunMilestoneListAndSelectionShareCatalog(t *testing.T) { globalPath, localPath, _ := createRuntimeFixture(t) base := []string{"--repo-config", globalPath, "--local-config", localPath} stdout := &bytes.Buffer{} stderr := &bytes.Buffer{} if exit := run(append([]string{"milestone", "list", "iop-s0"}, base...), stdout, stderr); exit != 0 { t.Fatalf("milestone list exit = %d, stderr = %q", exit, stderr.String()) } wantCatalog := "milestones: 2\n - milestone-1 selected=yes work_units=2 completed=0\n - milestone-2 selected=no work_units=1 completed=0\n" if stdout.String() != wantCatalog || stderr.Len() != 0 { t.Fatalf("milestone list stdout = %q (stderr=%q), want %q", stdout.String(), stderr.String(), wantCatalog) } stdout.Reset() stderr.Reset() if exit := run(append([]string{"milestone", "select", "iop-s0", "milestone-2"}, base...), stdout, stderr); exit != 0 { t.Fatalf("milestone select exit = %d, stderr = %q", exit, stderr.String()) } wantSelect := "milestone select project=iop-s0 milestone=milestone-2\n" if stdout.String() != wantSelect || stderr.Len() != 0 { t.Fatalf("milestone select stdout = %q, want %q", stdout.String(), wantSelect) } stdout.Reset() stderr.Reset() if exit := run(append([]string{"status", "iop-s0"}, base...), stdout, stderr); exit != 0 { t.Fatalf("status exit = %d, stderr = %q", exit, stderr.String()) } if !strings.Contains(stdout.String(), "selected_milestone: milestone-2") || stderr.Len() != 0 { t.Fatalf("status output = %q (stderr=%q), want selected_milestone: milestone-2", stdout.String(), stderr.String()) } } func TestAdapterStatusPreservesAllWork(t *testing.T) { fixture := createCommandLifecycleFixture(t) provider := &commandLifecycleProvider{} reviewer := &commandLifecycleReviewer{} validator := &commandLifecycleValidator{} dependencies := taskRuntimeDependencies{ Provider: provider, ReviewExecutor: reviewer, Validator: validator.Validate, } adapter := newAdapterWithTaskRuntime(io.Discard, io.Discard, dependencies) paths := command.RuntimeConfigPaths{ RepoGlobalPath: fixture.globalPath, UserLocalPath: fixture.localPath, ProviderCatalog: fixture.catalogPath, } ctx := context.Background() started, err := adapter.Start(ctx, command.StartRequest{Config: paths, Project: "project"}) if err != nil || started.Status != string(agenttask.ProjectStatusStarted) { t.Fatalf("Start: err=%v started=%#v", err, started) } runtime, err := adapter.runtime(paths) if err != nil { t.Fatalf("runtime: %v", err) } if err := runtime.Reconcile(ctx); err != nil { t.Fatalf("Reconcile: %v", err) } assertCommandLifecycle(t, fixture, runtime, provider, reviewer, validator) status, err := adapter.Status(ctx, command.StatusRequest{Config: paths, Project: "project"}) if err != nil { t.Fatalf("Status error: %v", err) } if status.Project != "project" || status.SelectedMilestone != "m1" { t.Fatalf("status header = %#v", status) } if len(status.Works) != 2 { t.Fatalf("len(status.Works) = %d, want 2", len(status.Works)) } w1, w2 := status.Works[0], status.Works[1] if w1.ID != "1" || w1.State != string(agenttask.WorkStateTerminalDeferred) || w1.Integration != "terminal_deferred" || w1.Overlay != "overlay" || w1.DispatchOrdinal == 0 { t.Fatalf("work[0] = %#v", w1) } if w1.Blocker == nil || w1.Blocker.Code != "integration_failed" || !w1.Blocker.Retryable { t.Fatalf("work[0].Blocker = %#v", w1.Blocker) } if w2.ID != "2" || w2.State != string(agenttask.WorkStateCompleted) || w2.Integration != "integrated" || w2.DispatchOrdinal == 0 { t.Fatalf("work[1] = %#v", w2) } if w2.Blocker != nil { t.Fatalf("work[1].Blocker = %#v, want nil", w2.Blocker) } if len(status.Blockers) != 1 || status.Blockers[0].Code != w1.Blocker.Code || status.Blockers[0].Message != w1.Blocker.Message { t.Fatalf("status.Blockers = %#v, want aggregated blocker from work[0]", status.Blockers) } } // TestRunFullHeadlessS10Transcript proves that separate CLI invocations share // one persisted manager state without launching a provider process. func TestRunFullHeadlessS10Transcript(t *testing.T) { globalPath, localPath, catalogPath := createRuntimeFixture(t) readBase := []string{"--repo-config", globalPath, "--local-config", localPath} mutationBase := append(append([]string(nil), readBase...), "--provider-catalog", catalogPath) steps := []struct { name string args []string wantStdout string wantStderr string }{ { name: "milestone list", args: append([]string{"milestone", "list", "iop-s0"}, readBase...), wantStdout: "milestones: 2\n - milestone-1 selected=yes work_units=2 completed=0\n - milestone-2 selected=no work_units=1 completed=0\n", wantStderr: "", }, { name: "preview", args: append([]string{"preview", "iop-s0"}, readBase...), wantStdout: "preview project=iop-s0\n selected: milestone-1\n next: 1\n", wantStderr: "", }, { name: "observed status", args: append([]string{"status", "iop-s0"}, readBase...), wantStdout: "status project=iop-s0\n state: observed\n selected_milestone: milestone-1\n started_milestone: \n works: 0\n", wantStderr: "", }, { name: "manual start", args: append([]string{"start", "iop-s0"}, mutationBase...), wantStdout: "start project=iop-s0 status=started\n", wantStderr: "", }, { name: "new invocation sees start", args: append([]string{"status", "iop-s0"}, readBase...), wantStdout: "status project=iop-s0\n state: started\n selected_milestone: milestone-1\n started_milestone: milestone-1\n works: 0\n", wantStderr: "", }, { name: "manual stop", args: append([]string{"stop", "iop-s0"}, mutationBase...), wantStdout: "stop project=iop-s0 status=stopped\n", wantStderr: "", }, { name: "manual resume", args: append([]string{"resume", "iop-s0"}, mutationBase...), wantStdout: "resume project=iop-s0 status=started\n", wantStderr: "", }, { name: "select another milestone", args: append([]string{"milestone", "select", "iop-s0", "milestone-2"}, readBase...), wantStdout: "milestone select project=iop-s0 milestone=milestone-2\n", wantStderr: "", }, { name: "selection persists", args: append([]string{"status", "iop-s0"}, readBase...), wantStdout: "status project=iop-s0\n state: started\n selected_milestone: milestone-2\n started_milestone: milestone-1\n works: 0\n", wantStderr: "", }, } for _, step := range steps { t.Run(step.name, func(t *testing.T) { stdout := &bytes.Buffer{} stderr := &bytes.Buffer{} if exit := run(step.args, stdout, stderr); exit != 0 { t.Fatalf( "exit = %d, want 0 (stdout=%q stderr=%q)", exit, stdout.String(), stderr.String(), ) } if stdout.String() != step.wantStdout { t.Fatalf("stdout = %q, want %q", stdout.String(), step.wantStdout) } if stderr.String() != step.wantStderr { t.Fatalf("stderr = %q, want %q", stderr.String(), step.wantStderr) } }) } } func TestBuiltBinaryHeadlessS10Transcript(t *testing.T) { tmpDir := t.TempDir() binPath := filepath.Join(tmpDir, "iop-agent-test") buildCmd := exec.Command("go", "build", "-o", binPath, ".") buildCmd.Env = os.Environ() if out, err := buildCmd.CombinedOutput(); err != nil { t.Fatalf("go build failed: %v\noutput: %s", err, string(out)) } globalPath, localPath, catalogPath := createRuntimeFixture(t) readBase := []string{"--repo-config", globalPath, "--local-config", localPath} mutationBase := append(append([]string(nil), readBase...), "--provider-catalog", catalogPath) steps := []struct { name string args []string wantStdout string wantStderr string }{ { name: "validate", args: append([]string{"validate"}, mutationBase...), wantStdout: "", // substring validated inside test body wantStderr: "", }, { name: "milestone list", args: append([]string{"milestone", "list", "iop-s0"}, readBase...), wantStdout: "milestones: 2\n - milestone-1 selected=yes work_units=2 completed=0\n - milestone-2 selected=no work_units=1 completed=0\n", wantStderr: "", }, { name: "preview", args: append([]string{"preview", "iop-s0"}, readBase...), wantStdout: "preview project=iop-s0\n selected: milestone-1\n next: 1\n", wantStderr: "", }, { name: "status observed", args: append([]string{"status", "iop-s0"}, readBase...), wantStdout: "status project=iop-s0\n state: observed\n selected_milestone: milestone-1\n started_milestone: \n works: 0\n", wantStderr: "", }, { name: "manual start", args: append([]string{"start", "iop-s0"}, mutationBase...), wantStdout: "start project=iop-s0 status=started\n", wantStderr: "", }, { name: "status started", args: append([]string{"status", "iop-s0"}, readBase...), wantStdout: "status project=iop-s0\n state: started\n selected_milestone: milestone-1\n started_milestone: milestone-1\n works: 0\n", wantStderr: "", }, { name: "manual stop", args: append([]string{"stop", "iop-s0"}, mutationBase...), wantStdout: "stop project=iop-s0 status=stopped\n", wantStderr: "", }, { name: "manual resume", args: append([]string{"resume", "iop-s0"}, mutationBase...), wantStdout: "resume project=iop-s0 status=started\n", wantStderr: "", }, { name: "select another milestone", args: append([]string{"milestone", "select", "iop-s0", "milestone-2"}, readBase...), wantStdout: "milestone select project=iop-s0 milestone=milestone-2\n", wantStderr: "", }, { name: "status selected persisted", args: append([]string{"status", "iop-s0"}, readBase...), wantStdout: "status project=iop-s0\n state: started\n selected_milestone: milestone-2\n started_milestone: milestone-1\n works: 0\n", wantStderr: "", }, } for _, step := range steps { t.Run(step.name, func(t *testing.T) { cmd := exec.Command(binPath, step.args...) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr err := cmd.Run() if err != nil { t.Fatalf("command %v failed: %v (stdout=%q stderr=%q)", step.args, err, stdout.String(), stderr.String()) } if step.name == "validate" { if !strings.Contains(stdout.String(), "validate: ok") { t.Fatalf("validate output = %q, want validate: ok", stdout.String()) } } else if stdout.String() != step.wantStdout { t.Fatalf("stdout = %q, want %q", stdout.String(), step.wantStdout) } if stderr.String() != step.wantStderr { t.Fatalf("stderr = %q, want %q", stderr.String(), step.wantStderr) } }) } } func TestCommandAdapterFakeProviderPersistedLifecycleRollbackAndRestart(t *testing.T) { fixture := createCommandLifecycleFixture(t) provider := &commandLifecycleProvider{} reviewer := &commandLifecycleReviewer{} validator := &commandLifecycleValidator{} dependencies := taskRuntimeDependencies{ Provider: provider, ReviewExecutor: reviewer, Validator: validator.Validate, } adapter := newAdapterWithTaskRuntime(io.Discard, io.Discard, dependencies) paths := command.RuntimeConfigPaths{ RepoGlobalPath: fixture.globalPath, UserLocalPath: fixture.localPath, ProviderCatalog: fixture.catalogPath, } ctx := context.Background() started, err := adapter.Start(ctx, command.StartRequest{ Config: paths, Project: "project", }) if err != nil || started.Status != string(agenttask.ProjectStatusStarted) { t.Fatalf("Start = %#v, err = %v", started, err) } runtime, err := adapter.runtime(paths) if err != nil { t.Fatalf("runtime: %v", err) } if err := runtime.Reconcile(ctx); err != nil { t.Fatalf("Reconcile: %v", err) } assertCommandLifecycle(t, fixture, runtime, provider, reviewer, validator) restartedAdapter := newAdapterWithTaskRuntime(io.Discard, io.Discard, dependencies) restarted, err := restartedAdapter.runtime(paths) if err != nil { t.Fatalf("restart runtime: %v", err) } if err := restarted.Reconcile(ctx); err != nil { t.Fatalf("restart Reconcile: %v", err) } if provider.Count() != 2 || reviewer.Count() != 2 { t.Fatalf( "restart duplicated work: dispatches=%d reviews=%d", provider.Count(), reviewer.Count(), ) } status, err := restartedAdapter.Status(ctx, command.StatusRequest{ Config: paths, Project: "project", }) if err != nil || len(status.Blockers) != 1 { t.Fatalf("restart status = %#v, err = %v", status, err) } } func TestRunPersistedSelectionEnablesPreviouslyUnselectedStart(t *testing.T) { globalPath, localPath, catalogPath := createRuntimeFixture(t) content, err := os.ReadFile(localPath) if err != nil { t.Fatal(err) } unselected := strings.Replace( string(content), " selected_milestone: milestone-1\n", "", 1, ) writeFile(t, localPath, unselected) base := []string{"--repo-config", globalPath, "--local-config", localPath} stdout := &bytes.Buffer{} stderr := &bytes.Buffer{} if exit := run( append([]string{"milestone", "select", "iop-s0", "milestone-1"}, base...), stdout, stderr, ); exit != 0 { t.Fatalf("milestone select exit = %d, stdout=%q stderr=%q", exit, stdout.String(), stderr.String()) } stdout.Reset() stderr.Reset() startArgs := append( append([]string{"start", "iop-s0"}, base...), "--provider-catalog", catalogPath, ) if exit := run(startArgs, stdout, stderr); exit != 0 || !strings.Contains(stdout.String(), "status=started") { t.Fatalf("start exit = %d, stdout=%q stderr=%q", exit, stdout.String(), stderr.String()) } } // TestRunUnknownProjectRejected verifies that lifecycle mutations on a project // that is not registered are rejected with an exact not-registered error rather // than reporting fictitious success or the runtime-unavailable sentinel. func TestRunUnknownProjectRejected(t *testing.T) { tmpDir := t.TempDir() globalPath := filepath.Join(tmpDir, "runtime.yaml") localPath := filepath.Join(tmpDir, "local.yaml") writeFile(t, globalPath, trackedGlobalConfig) writeFile(t, localPath, trackedLocalConfig) base := []string{"--repo-config", globalPath, "--local-config", localPath} cases := []struct { name string args []string wantErr string }{ {"start", append([]string{"start", "ghost"}, base...), "start: project ghost is not registered\n"}, {"stop", append([]string{"stop", "ghost"}, base...), "stop: project ghost is not registered\n"}, {"resume", append([]string{"resume", "ghost"}, base...), "resume: project ghost is not registered\n"}, {"milestone list", append([]string{"milestone", "list", "ghost"}, base...), "milestone list: project ghost is not registered\n"}, {"milestone select", append([]string{"milestone", "select", "ghost", "m"}, base...), "milestone select: project ghost is not registered\n"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { stdout := &bytes.Buffer{} stderr := &bytes.Buffer{} exit := run(tc.args, stdout, stderr) if exit != 1 { t.Fatalf("expected exit 1, got %d (stdout=%q stderr=%q)", exit, stdout.String(), stderr.String()) } if stdout.Len() != 0 { t.Errorf("expected empty stdout, got %q", stdout.String()) } if got := stderr.String(); got != tc.wantErr { t.Errorf("unexpected stderr: got %q want %q", got, tc.wantErr) } }) } } // TestRunServeCancellationAndCleanup verifies that serve runs the production daemon // lifecycle until its context is cancelled, and performs clean component shutdown. func TestRunServeCancellationAndCleanup(t *testing.T) { tmpDir := t.TempDir() globalPath := filepath.Join(tmpDir, "runtime.yaml") localPath := filepath.Join(tmpDir, "local.yaml") catalogPath := filepath.Join(tmpDir, "providers.yaml") stateRoot := filepath.Join(tmpDir, "state") logRoot := filepath.Join(tmpDir, "logs") _ = os.MkdirAll(stateRoot, 0700) _ = os.Chmod(stateRoot, 0700) localConfig := strings.ReplaceAll(trackedLocalConfig, "/tmp/iop-agent-test/state", stateRoot) localConfig = strings.ReplaceAll(localConfig, "/tmp/iop-agent-test/logs", logRoot) writeFile(t, globalPath, trackedGlobalConfig) writeFile(t, localPath, localConfig) writeFile(t, catalogPath, trackedCatalogConfig) stdout := &bytes.Buffer{} stderr := &bytes.Buffer{} ctx, cancel := context.WithCancel(context.Background()) done := make(chan int, 1) go func() { done <- runWithContext(ctx, []string{ "serve", "--repo-config", globalPath, "--local-config", localPath, "--provider-catalog", catalogPath, }, stdout, stderr) }() socketPath := filepath.Join(stateRoot, "iop-agent.sock") for i := 0; i < 50; i++ { if _, err := os.Lstat(socketPath); err == nil { break } time.Sleep(50 * time.Millisecond) } if _, err := os.Lstat(socketPath); err != nil { cancel() t.Fatalf("expected Unix socket file at %s, got %v", socketPath, err) } cancel() select { case exit := <-done: if exit != 0 { t.Fatalf("expected serve to exit 0 on context cancel, got %d (stdout=%q stderr=%q)", exit, stdout.String(), stderr.String()) } case <-time.After(5 * time.Second): t.Fatal("serve did not exit within timeout after context cancel") } if _, err := os.Lstat(socketPath); !os.IsNotExist(err) { t.Errorf("expected Unix socket file to be removed after serve shutdown, got err = %v", err) } } // TestRunOfflineReads verifies that the offline configuration reads — validate, // provider list, and project list — succeed without any runtime, writing their // results only to stdout. func TestRunOfflineReads(t *testing.T) { tmpDir := t.TempDir() globalPath := filepath.Join(tmpDir, "runtime.yaml") localPath := filepath.Join(tmpDir, "local.yaml") catalogPath := filepath.Join(tmpDir, "providers.yaml") writeFile(t, globalPath, trackedGlobalConfig) writeFile(t, localPath, trackedLocalConfig) writeFile(t, catalogPath, trackedCatalogConfig) cases := []struct { name string args []string want string }{ { name: "validate", args: []string{"validate", "--repo-config", globalPath, "--local-config", localPath, "--provider-catalog", catalogPath}, want: "validate: ok", }, { name: "provider list", args: []string{"provider", "list", "--provider-catalog", catalogPath}, want: "claude", }, { name: "project list", args: []string{"project", "list", "--repo-config", globalPath, "--local-config", localPath}, want: "iop-s0", }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { stdout := &bytes.Buffer{} stderr := &bytes.Buffer{} exit := run(tc.args, stdout, stderr) if exit != 0 { t.Fatalf("expected exit 0, got %d (stdout=%q stderr=%q)", exit, stdout.String(), stderr.String()) } if stderr.Len() != 0 { t.Errorf("expected empty stderr, got %q", stderr.String()) } if !strings.Contains(stdout.String(), tc.want) { t.Errorf("expected %q in stdout, got %q", tc.want, stdout.String()) } }) } } // writeFile is a test helper that writes content to a file. func writeFile(t *testing.T, path, content string) { t.Helper() if err := os.WriteFile(path, []byte(content), 0644); err != nil { t.Fatalf("write %s: %v", path, err) } } func createRuntimeFixture(t *testing.T) (string, string, string) { t.Helper() root := t.TempDir() stateRoot := filepath.Join(root, "state") workspace := filepath.Join(root, "workspace") for _, directory := range []string{ stateRoot, filepath.Join(workspace, "agent-task", "m-milestone-1", "1_first"), filepath.Join(workspace, "agent-task", "m-milestone-1", "2_second"), filepath.Join(workspace, "agent-task", "m-milestone-2", "3_third"), } { if err := os.MkdirAll(directory, 0700); err != nil { t.Fatalf("create fixture directory %s: %v", directory, err) } } plan := `# Plan ## Modified Files Summary | File | Purpose | |------|---------| | ` + "`README.md`" + ` | Exercise the runtime fixture. | ` review := `# Code Review Reference ## Implementation Notes Pending implementation. ` for _, taskRoot := range []string{ filepath.Join(workspace, "agent-task", "m-milestone-1", "1_first"), filepath.Join(workspace, "agent-task", "m-milestone-1", "2_second"), filepath.Join(workspace, "agent-task", "m-milestone-2", "3_third"), } { writeFile(t, filepath.Join(taskRoot, "PLAN-test.md"), plan) writeFile(t, filepath.Join(taskRoot, "CODE_REVIEW-test.md"), review) } globalPath := filepath.Join(root, "runtime.yaml") localPath := filepath.Join(root, "local.yaml") catalogPath := filepath.Join(root, "providers.yaml") localConfig := fmt.Sprintf(`version: "1" device: state_root: %s overlay_root: %s log_root: %s projects: iop-s0: workspace: %s enabled: true selected_milestone: milestone-1 `, stateRoot, filepath.Join(root, "overlays"), filepath.Join(root, "logs"), workspace) writeFile(t, globalPath, trackedGlobalConfig) writeFile(t, localPath, localConfig) writeFile(t, catalogPath, trackedCatalogConfig) return globalPath, localPath, catalogPath } type commandLifecycleFixture struct { globalPath string localPath string catalogPath string workspace string logRoot string } func createCommandLifecycleFixture(t *testing.T) commandLifecycleFixture { t.Helper() root, err := filepath.EvalSymlinks(t.TempDir()) if err != nil { t.Fatal(err) } workspace := filepath.Join(root, "workspace") stateRoot := filepath.Join(root, "state") logRoot := filepath.Join(root, "logs") for _, directory := range []string{workspace, stateRoot, logRoot} { if err := os.MkdirAll(directory, 0o700); err != nil { t.Fatal(err) } } writeFile(t, filepath.Join(workspace, "rejected.txt"), "base rejected\n") writeFile(t, filepath.Join(workspace, "sibling.txt"), "base sibling\n") createCommandLifecycleTask(t, workspace, "1_rejected", "rejected.txt") createCommandLifecycleTask(t, workspace, "2_sibling", "sibling.txt") runCommandLifecycleGit(t, workspace, "init", "-q") runCommandLifecycleGit(t, workspace, "config", "user.email", "agent@example.invalid") runCommandLifecycleGit(t, workspace, "config", "user.name", "Agent Fixture") runCommandLifecycleGit(t, workspace, "add", "-A") runCommandLifecycleGit(t, workspace, "commit", "-q", "-m", "base") globalPath := filepath.Join(root, "runtime.yaml") localPath := filepath.Join(root, "local.yaml") catalogPath := filepath.Join(root, "providers.yaml") writeFile(t, globalPath, strings.Replace( trackedGlobalConfig, " - id: worker-high-grade\n", " - id: worker-high-grade\n", 1, )) writeFile(t, localPath, fmt.Sprintf(`version: "1" device: state_root: %s overlay_root: %s log_root: %s projects: project: workspace: %s enabled: true selected_milestone: m1 `, stateRoot, filepath.Join(root, "overlays"), logRoot, workspace)) writeFile(t, catalogPath, trackedCatalogConfig) return commandLifecycleFixture{ globalPath: globalPath, localPath: localPath, catalogPath: catalogPath, workspace: workspace, logRoot: logRoot, } } func createCommandLifecycleTask( t *testing.T, workspace, directory, target string, ) { t.Helper() taskRoot := filepath.Join(workspace, "agent-task", "m-m1", directory) if err := os.MkdirAll(taskRoot, 0o700); err != nil { t.Fatal(err) } reviewRelative := filepath.ToSlash( filepath.Join("agent-task", "m-m1", directory, "CODE_REVIEW-test.md"), ) writeFile(t, filepath.Join(taskRoot, "PLAN-test.md"), fmt.Sprintf(`# Plan ## Modified Files Summary | File | Purpose | |------|---------| | %s | Exercise provider output. | | %s | Record implementation and review. | `, "`"+target+"`", "`"+reviewRelative+"`")) writeFile(t, filepath.Join(taskRoot, "CODE_REVIEW-test.md"), `# Code Review Reference ## Implementation Notes Deterministic command-adapter evidence is complete. `) } func runCommandLifecycleGit(t *testing.T, root string, args ...string) { t.Helper() command := exec.Command("git", append([]string{"-C", root}, args...)...) if output, err := command.CombinedOutput(); err != nil { t.Fatalf("git %v: %v: %s", args, err, output) } } func assertCommandLifecycle( t *testing.T, fixture commandLifecycleFixture, runtime *taskloop.Runtime, provider *commandLifecycleProvider, reviewer *commandLifecycleReviewer, validator *commandLifecycleValidator, ) { t.Helper() if provider.Count() != 2 || reviewer.Count() != 2 || validator.Count() != 2 { t.Fatalf( "lifecycle counts dispatch=%d review=%d validation=%d", provider.Count(), reviewer.Count(), validator.Count(), ) } state, _, err := runtime.StateStore().Load(context.Background()) if err != nil { t.Fatal(err) } project := state.Projects["project"] rejected := project.Works["1"] sibling := project.Works["2"] if rejected.State != agenttask.WorkStateTerminalDeferred || rejected.Submission == nil || rejected.Review == nil || rejected.Review.Verdict != agenttask.ReviewVerdictPass || rejected.ChangeSet == nil || rejected.Blocker == nil || !strings.Contains(rejected.Blocker.Message, "validation") { t.Fatalf("rejected work = %#v", rejected) } if sibling.State != agenttask.WorkStateCompleted || sibling.Submission == nil || sibling.Review == nil || sibling.Review.Verdict != agenttask.ReviewVerdictPass || sibling.ChangeSet == nil || !sibling.CompletionVerified { t.Fatalf("sibling work = %#v", sibling) } assertCommandFile(t, filepath.Join(fixture.workspace, "rejected.txt"), "base rejected\n") assertCommandFile(t, filepath.Join(fixture.workspace, "sibling.txt"), "integrated 2\n") assertCommandTerminalArchives(t, fixture.logRoot) } func assertCommandFile(t *testing.T, path, want string) { t.Helper() content, err := os.ReadFile(path) if err != nil { t.Fatal(err) } if string(content) != want { t.Fatalf("%s = %q, want %q", path, content, want) } } func assertCommandTerminalArchives(t *testing.T, root string) { t.Helper() manifests := 0 timelines := make(map[agenttask.WorkUnitID]bool) if err := filepath.Walk(root, func(path string, info os.FileInfo, walkErr error) error { if walkErr != nil { return walkErr } if info.IsDir() { return nil } switch { case strings.HasSuffix(path, ".manifest.json"): manifests++ case strings.HasSuffix(path, ".timeline.jsonl"): content, err := os.ReadFile(path) if err != nil { return err } var last projectlog.ProjectLogRecord var prior uint64 for index, line := range strings.Split(strings.TrimSpace(string(content)), "\n") { var record projectlog.ProjectLogRecord if err := json.Unmarshal([]byte(line), &record); err != nil { return err } if index > 0 && record.Sequence != prior+1 { return errors.New("project log sequence is not monotonic") } prior = record.Sequence last = record } if !last.Terminal { return errors.New("project log timeline is not terminal") } timelines[last.WorkUnitID] = true } return nil }); err != nil { t.Fatal(err) } if manifests != 2 || !timelines["1"] || !timelines["2"] { t.Fatalf("archive evidence manifests=%d timelines=%#v", manifests, timelines) } } type commandLifecycleProvider struct { mu sync.Mutex requests []agenttask.DispatchRequest } func (provider *commandLifecycleProvider) Prepare( _ context.Context, request agenttask.DispatchRequest, ) (agenttask.ProviderLaunch, error) { if request.Confinement == nil || request.Permit == nil { return nil, errors.New("command fake requires admitted confinement") } binding := request.Confinement.Binding() if err := request.Confinement.Validate(binding); err != nil { return nil, err } provider.mu.Lock() provider.requests = append(provider.requests, request) provider.mu.Unlock() return &commandLifecycleLaunch{request: request, root: binding.WorkingDir}, nil } func (provider *commandLifecycleProvider) Count() int { provider.mu.Lock() defer provider.mu.Unlock() return len(provider.requests) } type commandLifecycleLaunch struct { request agenttask.DispatchRequest root string } func (launch *commandLifecycleLaunch) Command() agenttask.ConfinementCommand { return agenttask.ConfinementCommand{Name: "true"} } func (launch *commandLifecycleLaunch) BindStarted( started agenttask.StartedConfinement, ) (agenttask.ProviderInvocation, error) { if started == nil || started.Child() == nil || started.Child().Process == nil { return nil, errors.New("command fake child is incomplete") } opaque := fmt.Sprintf("command-fake-%d", started.Child().Process.Pid) return &commandLifecycleInvocation{ request: launch.request, root: launch.root, started: started, locator: agenttask.LocatorRecord{ Kind: agenttask.LocatorProcess, Opaque: opaque, Revision: commandLifecycleDigest("process", opaque), ProjectID: launch.request.Project.ProjectID, WorkspaceID: launch.request.Project.WorkspaceID, WorkUnitID: launch.request.Work.Unit.ID, AttemptID: launch.request.Work.AttemptID, }, }, nil } type commandLifecycleInvocation struct { request agenttask.DispatchRequest root string started agenttask.StartedConfinement locator agenttask.LocatorRecord } func (invocation *commandLifecycleInvocation) Locators() []agenttask.LocatorRecord { return []agenttask.LocatorRecord{invocation.locator} } func (invocation *commandLifecycleInvocation) Wait( context.Context, ) (agenttask.Submission, error) { if err := commandLifecycleWait(invocation.started); err != nil { return agenttask.Submission{}, err } target := commandLifecycleTarget(invocation.request.Work.Unit.DeclaredWriteSet) if target == "" { return agenttask.Submission{}, errors.New("command fake target is missing") } if err := os.WriteFile( filepath.Join(invocation.root, filepath.FromSlash(target)), []byte("integrated "+string(invocation.request.Work.Unit.ID)+"\n"), 0o600, ); err != nil { return agenttask.Submission{}, err } return agenttask.Submission{ ProjectID: invocation.request.Project.ProjectID, WorkUnitID: invocation.request.Work.Unit.ID, AttemptID: invocation.request.Work.AttemptID, ArtifactID: agenttask.ArtifactID(commandLifecycleDigest( "artifact", string(invocation.request.Work.Unit.ID), string(invocation.request.Work.AttemptID), )), Ready: true, Locators: invocation.Locators(), }, nil } func (invocation *commandLifecycleInvocation) Cancel(context.Context) error { return invocation.started.Abort() } func commandLifecycleWait(started agenttask.StartedConfinement) error { if stdin := started.Stdin(); stdin != nil { _ = stdin.Close() } if stdout := started.Stdout(); stdout != nil { _, _ = io.Copy(io.Discard, stdout) _ = stdout.Close() } if stderr := started.Stderr(); stderr != nil { _, _ = io.Copy(io.Discard, stderr) _ = stderr.Close() } return started.Child().Wait() } func commandLifecycleTarget(paths []string) string { for _, path := range paths { if !strings.HasPrefix(path, "agent-task/") { return path } } return "" } func commandLifecycleDigest(parts ...string) string { hash := sha256.New() for _, part := range parts { _, _ = fmt.Fprintf(hash, "%d:", len(part)) _, _ = hash.Write([]byte(part)) } return "sha256:" + hex.EncodeToString(hash.Sum(nil)) } type commandLifecycleReviewer struct { mu sync.Mutex count int } func (reviewer *commandLifecycleReviewer) ExecuteReview( _ context.Context, _ agenttask.ReviewRequest, root, _, reviewRelative string, ) error { reviewer.mu.Lock() reviewer.count++ reviewer.mu.Unlock() file, err := os.OpenFile( filepath.Join(root, filepath.FromSlash(reviewRelative)), os.O_APPEND|os.O_WRONLY, 0, ) if err != nil { return err } defer file.Close() _, err = io.WriteString(file, "\n## Code Review Result\n\nOverall Verdict: PASS\n") return err } func (reviewer *commandLifecycleReviewer) Count() int { reviewer.mu.Lock() defer reviewer.mu.Unlock() return reviewer.count } type commandLifecycleValidator struct { mu sync.Mutex count int } func (validator *commandLifecycleValidator) Validate( _ context.Context, request agentworkspace.ValidationRequest, ) error { validator.mu.Lock() validator.count++ validator.mu.Unlock() content, err := os.ReadFile(filepath.Join(request.ValidationRoot, "rejected.txt")) if err != nil { return err } if string(content) != "base rejected\n" { return errors.New("command fake validator rejected candidate") } if request.ValidationRoot == request.CanonicalRoot { return errors.New("validator did not receive an isolated candidate root") } return nil } func (validator *commandLifecycleValidator) Count() int { validator.mu.Lock() defer validator.mu.Unlock() return validator.count } // trackedGlobalConfig is the minimal repo-global runtime config for tests. const trackedGlobalConfig = `version: "1" defaults: default_profile: claude-headless auto_resume_interrupted: true profile_aliases: default: claude-headless selection: timezone: UTC default: provider: claude model: claude-opus-4-8 profile: claude-headless rules: - id: worker-high-grade match: stages: [worker] min_grade: 7 max_grade: 10 target: provider: claude model: claude-opus-4-8 profile: claude-headless isolation: default_mode: overlay fallback_modes: [worktree, clone] retention: completed_days: 14 blocked_days: 30 max_project_log_records: 500 ` // trackedLocalConfig is the minimal user-local config for tests. const trackedLocalConfig = `version: "1" device: state_root: /tmp/iop-agent-test/state overlay_root: /tmp/iop-agent-test/overlays log_root: /tmp/iop-agent-test/logs projects: iop-s0: workspace: /tmp/iop-agent-test/ws enabled: true selected_milestone: milestone-1 ` // unselectedLocalConfig registers iop-s0 without an explicit milestone // selection so start rejects it before reaching the runtime boundary. const unselectedLocalConfig = `version: "1" device: state_root: /tmp/iop-agent-test/state overlay_root: /tmp/iop-agent-test/overlays log_root: /tmp/iop-agent-test/logs projects: iop-s0: workspace: /tmp/iop-agent-test/ws enabled: true ` // trackedCatalogConfig is the minimal provider catalog for tests. const trackedCatalogConfig = `version: "1" providers: - id: claude command: claude version_probe: args: ["--version"] timeout_ms: 5000 authentication: args: ["auth", "status"] timeout_ms: 10000 capabilities: - approval_bypass - run - status - unattended - writable_root_confinement models: - id: claude-opus-4-8 provider: claude target: claude-opus-4-8 profiles: - id: claude-headless provider: claude model: claude-opus-4-8 args: - "--model" - "{{model}}" capabilities: - approval_bypass - run - status - unattended - writable_root_confinement ` // TestBuiltBinarySignalShutdownCleansDaemon verifies that sending SIGTERM to a running // iop-agent serve process causes it to shut down cleanly, terminate its configured client process, // and remove its socket. func TestBuiltBinarySignalShutdownCleansDaemon(t *testing.T) { tmpDir := t.TempDir() binPath := filepath.Join(tmpDir, "iop-agent-test") buildCmd := exec.Command("go", "build", "-o", binPath, ".") buildCmd.Env = os.Environ() if out, err := buildCmd.CombinedOutput(); err != nil { t.Fatalf("go build failed: %v\noutput: %s", err, string(out)) } globalPath := filepath.Join(tmpDir, "runtime.yaml") localPath := filepath.Join(tmpDir, "local.yaml") catalogPath := filepath.Join(tmpDir, "providers.yaml") stateRoot := filepath.Join(tmpDir, "state") logRoot := filepath.Join(tmpDir, "logs") pidPath := filepath.Join(tmpDir, "fixture.pid") _ = os.MkdirAll(stateRoot, 0700) localConfig := strings.ReplaceAll(trackedLocalConfig, "/tmp/iop-agent-test/state", stateRoot) localConfig = strings.ReplaceAll(localConfig, "/tmp/iop-agent-test/logs", logRoot) localConfig += fmt.Sprintf(` clients: flutter: executable: /bin/sh working_directory: %s args: ["-c", "echo $$ > %s; exec /bin/sleep 60"] launch_on_start: true `, tmpDir, pidPath) writeFile(t, globalPath, trackedGlobalConfig) writeFile(t, localPath, localConfig) writeFile(t, catalogPath, trackedCatalogConfig) cmd := exec.Command(binPath, "serve", "--repo-config", globalPath, "--local-config", localPath, "--provider-catalog", catalogPath, ) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr if err := cmd.Start(); err != nil { t.Fatalf("cmd.Start failed: %v", err) } t.Cleanup(func() { if cmd.Process != nil { _ = cmd.Process.Kill() } }) socketPath := filepath.Join(stateRoot, "iop-agent.sock") for i := 0; i < 100; i++ { if _, err := os.Lstat(socketPath); err == nil { if _, err := os.Lstat(pidPath); err == nil { break } } time.Sleep(50 * time.Millisecond) } if _, err := os.Lstat(socketPath); err != nil { t.Fatalf("expected Unix socket file at %s, got %v (stderr=%s)", socketPath, err, stderr.String()) } if _, err := os.Lstat(pidPath); err != nil { t.Fatalf("expected fixture pid file at %s, got %v (stderr=%s)", pidPath, err, stderr.String()) } pidBytes, err := os.ReadFile(pidPath) if err != nil { t.Fatalf("failed to read fixture pid file: %v", err) } var childPID int if _, err := fmt.Sscanf(strings.TrimSpace(string(pidBytes)), "%d", &childPID); err != nil || childPID <= 0 { t.Fatalf("invalid fixture pid in file %s: %q (err=%v)", pidPath, string(pidBytes), err) } t.Cleanup(func() { proc, err := os.FindProcess(childPID) if err == nil && proc != nil { _ = proc.Signal(syscall.SIGKILL) } }) if err := cmd.Process.Signal(syscall.SIGTERM); err != nil { t.Fatalf("failed to send SIGTERM: %v", err) } done := make(chan error, 1) go func() { done <- cmd.Wait() }() select { case err := <-done: if err != nil { t.Fatalf("process exited with error: %v (stdout=%s, stderr=%s)", err, stdout.String(), stderr.String()) } case <-time.After(5 * time.Second): t.Fatalf("process did not exit within 5s after SIGTERM") } if _, err := os.Lstat(socketPath); !os.IsNotExist(err) { t.Errorf("expected Unix socket file to be removed after SIGTERM shutdown, got err = %v", err) } childProc, err := os.FindProcess(childPID) if err == nil && childProc != nil { procExited := false for i := 0; i < 100; i++ { err := childProc.Signal(syscall.Signal(0)) if err != nil { if errors.Is(err, os.ErrProcessDone) || errors.Is(err, syscall.ESRCH) { procExited = true break } } time.Sleep(50 * time.Millisecond) } if !procExited { t.Errorf("expected fixture child process PID %d to be terminated after daemon shutdown", childPID) } } }