iop/apps/agent/cmd/agent/main_test.go
toki e5794fda2d fix(agent): task-loop 검증 경계와 상태 검사를 보강한다
리뷰에서 확인된 Milestone 식별자와 컴파일 바이너리 상태 검증의 빈틈을 보완하고, 활성 agent-task에서는 dispatcher만 실행 경로로 사용하도록 고정한다.
2026-07-31 18:40:26 +09:00

1688 lines
53 KiB
Go

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)
}
}
}