리뷰에서 확인된 Milestone 식별자와 컴파일 바이너리 상태 검증의 빈틈을 보완하고, 활성 agent-task에서는 dispatcher만 실행 경로로 사용하도록 고정한다.
1688 lines
53 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|