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

624 lines
20 KiB
Go

package taskloop
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"iop/packages/go/agenttask"
)
func TestWorkflowNormalizesActiveCompletionAndDependencies(t *testing.T) {
fixture := newRuntimeFixture(t)
workflow, err := NewWorkflow(fixture.snapshot, fixture.runtime.selections)
if err != nil {
t.Fatalf("NewWorkflow: %v", err)
}
snapshot, err := workflow.Snapshot(context.Background(), "project-a")
if err != nil {
t.Fatalf("Snapshot: %v", err)
}
if snapshot.WorkspaceID != WorkspaceIdentity(fixture.projectA) ||
len(snapshot.Units) != 2 {
t.Fatalf("workflow snapshot = %#v", snapshot)
}
if snapshot.Units[0].ID != "1" || snapshot.Units[0].Completed {
t.Fatalf("first unit = %#v", snapshot.Units[0])
}
if snapshot.Units[1].ID != "2" ||
len(snapshot.Units[1].ExplicitPredecessors) != 1 ||
snapshot.Units[1].ExplicitPredecessors[0].Ref != "1" {
t.Fatalf("dependent unit = %#v", snapshot.Units[1])
}
writeTaskFile(
t,
filepath.Join(fixture.projectA, "agent-task", "m-m1", "1_first", completeFileName),
"complete\n",
)
snapshot, err = workflow.Snapshot(context.Background(), "project-a")
if err != nil {
t.Fatalf("Snapshot after completion: %v", err)
}
if !snapshot.Units[0].Completed {
t.Fatalf("completion was not observed: %#v", snapshot.Units[0])
}
}
func TestWorkflowMilestonesListsSelectableTaskGroups(t *testing.T) {
fixture := newRuntimeFixture(t)
archivedComp := filepath.Join(fixture.projectA, "agent-task", "archive", "2026", "07", "m-m2", "01_done", completeFileName)
if err := os.MkdirAll(filepath.Dir(archivedComp), 0700); err != nil {
t.Fatal(err)
}
writeTaskFile(t, archivedComp, "complete\n")
milestones, err := fixture.runtime.Milestones(context.Background(), "project-a")
if err != nil {
t.Fatalf("Milestones: %v", err)
}
if len(milestones) != 2 {
t.Fatalf("milestones count = %d, want 2", len(milestones))
}
if milestones[0].ID != "m1" || !milestones[0].Selected || milestones[0].WorkUnits != 2 || milestones[0].CompletedWorkUnits != 0 {
t.Fatalf("milestones[0] = %#v", milestones[0])
}
if milestones[1].ID != "m2" || milestones[1].Selected || milestones[1].WorkUnits != 1 || milestones[1].CompletedWorkUnits != 1 {
t.Fatalf("milestones[1] = %#v", milestones[1])
}
for _, m := range milestones {
if err := fixture.runtime.SelectMilestone(context.Background(), "project-a", m.ID); err != nil {
t.Fatalf("SelectMilestone(%q): %v", m.ID, err)
}
snapshot, err := fixture.runtime.WorkflowSnapshot(context.Background(), "project-a")
if err != nil {
t.Fatalf("WorkflowSnapshot for %q: %v", m.ID, err)
}
if len(snapshot.Units) != m.WorkUnits {
t.Fatalf("snapshot.Units length = %d, want %d for milestone %q", len(snapshot.Units), m.WorkUnits, m.ID)
}
}
}
func TestWorkflowMilestonesFailClosedBoundaryMatrix(t *testing.T) {
t.Run("empty workspace returns error", func(t *testing.T) {
fixture := newRuntimeFixture(t)
if err := os.RemoveAll(filepath.Join(fixture.projectA, "agent-task")); err != nil {
t.Fatal(err)
}
_, err := fixture.runtime.Milestones(context.Background(), "project-a")
if err == nil || !strings.Contains(err.Error(), "no workflow-backed milestones") {
t.Fatalf("Milestones error = %v, want no workflow-backed milestones", err)
}
})
t.Run("malformed selected workflow fails closed", func(t *testing.T) {
fixture := newRuntimeFixture(t)
malformed := filepath.Join(fixture.projectA, "agent-task", "m-m1", "3_bad")
if err := os.MkdirAll(malformed, 0700); err != nil {
t.Fatal(err)
}
writeTaskFile(t, filepath.Join(malformed, "PLAN-test.md"), "# Bad Plan without summary\n")
writeTaskFile(t, filepath.Join(malformed, "CODE_REVIEW-test.md"), "# Review\n")
_, err := fixture.runtime.Milestones(context.Background(), "project-a")
if err == nil || !strings.Contains(err.Error(), "Modified Files Summary is missing") {
t.Fatalf("Milestones error = %v, want Modified Files Summary is missing", err)
}
})
t.Run("invalid active identity in agent-task fails closed", func(t *testing.T) {
fixture := newRuntimeFixture(t)
badDir := filepath.Join(fixture.projectA, "agent-task", "m-FOO")
if err := os.MkdirAll(badDir, 0700); err != nil {
t.Fatal(err)
}
_, err := fixture.runtime.Milestones(context.Background(), "project-a")
if err == nil {
t.Fatal("expected error for invalid active identity m-FOO")
}
})
t.Run("invalid archive identity fails closed", func(t *testing.T) {
fixture := newRuntimeFixture(t)
badDir := filepath.Join(fixture.projectA, "agent-task", "archive", "2026", "07", "m-*")
if err := os.MkdirAll(badDir, 0700); err != nil {
t.Fatal(err)
}
_, err := fixture.runtime.Milestones(context.Background(), "project-a")
if err == nil {
t.Fatal("expected error for invalid archive identity m-*")
}
})
}
func TestInspectTaskGroupRejectsInvalidIdentifiers(t *testing.T) {
workspace := canonicalTempDir(t)
invalid := []string{
"m-*",
"m-foo?",
"m-FOO",
"m-foo_bar",
"m-foo bar",
"m-foo/bar",
"m-",
"m-123+456",
"foo",
"",
}
for _, id := range invalid {
t.Run(id, func(t *testing.T) {
_, err := InspectTaskGroup(workspace, id)
if err == nil {
t.Fatalf("InspectTaskGroup(%q) expected error, got nil", id)
}
})
}
}
func TestWorkflowMilestonesNormalizesArchiveCollisionSuffixes(t *testing.T) {
workspace := canonicalTempDir(t)
createSinglePlanPair(t, workspace, "m1")
archive1 := filepath.Join(workspace, "agent-task", "archive", "2026", "06", "m-m1_1", "01_done", completeFileName)
archive2 := filepath.Join(workspace, "agent-task", "archive", "2026", "07", "m-m1_2", "02_done", completeFileName)
for _, p := range []string{archive1, archive2} {
if err := os.MkdirAll(filepath.Dir(p), 0700); err != nil {
t.Fatal(err)
}
writeTaskFile(t, p, "complete\n")
}
views, err := scanMilestones(workspace, "m1")
if err != nil {
t.Fatalf("scanMilestones: %v", err)
}
if len(views) != 1 {
t.Fatalf("views count = %d, want 1 (archive collision suffixes normalized)", len(views))
}
if views[0].ID != "m1" || views[0].WorkUnits != 3 || views[0].CompletedWorkUnits != 2 {
t.Fatalf("views[0] = %#v, want ID=m1 WorkUnits=3 CompletedWorkUnits=2", views[0])
}
}
func TestWorkflowArchiveOnlyMilestoneRemainsSelectable(t *testing.T) {
fixture := newRuntimeFixture(t)
if err := os.RemoveAll(filepath.Join(fixture.projectA, "agent-task", "m-m1")); err != nil {
t.Fatal(err)
}
archivedComp := filepath.Join(fixture.projectA, "agent-task", "archive", "2026", "07", "m-m1", completeFileName)
if err := os.MkdirAll(filepath.Dir(archivedComp), 0700); err != nil {
t.Fatal(err)
}
writeTaskFile(t, archivedComp, "complete\n")
snapshot, err := fixture.runtime.WorkflowSnapshot(context.Background(), "project-a")
if err != nil {
t.Fatalf("WorkflowSnapshot for archive-only milestone: %v", err)
}
if len(snapshot.Units) != 1 || !snapshot.Units[0].Completed {
t.Fatalf("snapshot units = %#v", snapshot.Units)
}
}
func TestInspectTaskGroupArchiveOnly(t *testing.T) {
workspace := canonicalTempDir(t)
completion := filepath.Join(workspace, "agent-task", "archive", "2026", "07", "m-m1", completeFileName)
if err := os.MkdirAll(filepath.Dir(completion), 0700); err != nil {
t.Fatal(err)
}
writeTaskFile(t, completion, "complete\n")
units, err := InspectTaskGroup(workspace, "m-m1")
if err != nil {
t.Fatalf("InspectTaskGroup: %v", err)
}
if len(units) != 1 || units[0].ID != "m-m1" || !units[0].Completed {
t.Fatalf("units = %#v", units)
}
}
func TestInspectTaskGroupSupportsSingleAndSplitLayouts(t *testing.T) {
tests := []struct {
name string
prepare func(*testing.T, string)
wantID agenttask.WorkUnitID
wantPlanPath string
}{
{
name: "single plan at task group root",
prepare: func(t *testing.T, workspace string) {
createSinglePlanPair(t, workspace, "m1")
},
wantID: "m-m1",
wantPlanPath: "agent-task/m-m1/PLAN-test.md",
},
{
name: "split plan in indexed child",
prepare: func(t *testing.T, workspace string) {
createTaskPair(t, workspace, "m1", "01_first", false)
},
wantID: "01",
wantPlanPath: "agent-task/m-m1/01_first/PLAN-test.md",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
workspace := canonicalTempDir(t)
test.prepare(t, workspace)
units, err := InspectTaskGroup(workspace, "m-m1")
if err != nil {
t.Fatalf("InspectTaskGroup: %v", err)
}
if len(units) != 1 || units[0].ID != test.wantID || units[0].Completed {
t.Fatalf("units = %#v", units)
}
if units[0].Metadata["plan_path"] != test.wantPlanPath {
t.Fatalf("plan path = %q, want %q", units[0].Metadata["plan_path"], test.wantPlanPath)
}
})
}
}
func TestInspectTaskGroupSupportsArchiveOnlySingleAndSplitLayouts(t *testing.T) {
tests := []struct {
name string
completion string
wantID agenttask.WorkUnitID
}{
{
name: "single plan archive",
completion: filepath.Join("agent-task", "archive", "2026", "07", "m-m1", completeFileName),
wantID: "m-m1",
},
{
name: "split plan archive",
completion: filepath.Join("agent-task", "archive", "2026", "07", "m-m1", "01_done", completeFileName),
wantID: "01",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
workspace := canonicalTempDir(t)
completion := filepath.Join(workspace, test.completion)
if err := os.MkdirAll(filepath.Dir(completion), 0700); err != nil {
t.Fatal(err)
}
writeTaskFile(t, completion, "complete\n")
units, err := InspectTaskGroup(workspace, "m-m1")
if err != nil {
t.Fatalf("InspectTaskGroup: %v", err)
}
if len(units) != 1 || units[0].ID != test.wantID || !units[0].Completed {
t.Fatalf("units = %#v", units)
}
})
}
}
func TestInspectTaskGroupCombinesSinglePlanWithArchivedSplitHistory(t *testing.T) {
workspace := canonicalTempDir(t)
createSinglePlanPair(t, workspace, "m1")
completionRoot := filepath.Join(
workspace,
"agent-task",
"archive",
"2026",
"07",
"m-m1",
"26_done",
)
if err := os.MkdirAll(completionRoot, 0700); err != nil {
t.Fatal(err)
}
writeTaskFile(
t,
filepath.Join(completionRoot, completeFileName),
"complete\n",
)
units, err := InspectTaskGroup(workspace, "m-m1")
if err != nil {
t.Fatalf("InspectTaskGroup: %v", err)
}
if len(units) != 2 ||
units[0].ID != "26" || !units[0].Completed ||
units[1].ID != "m-m1" || units[1].Completed {
t.Fatalf("units = %#v", units)
}
}
func TestWorkflowRejectsMixedActiveSingleAndSplitLayouts(t *testing.T) {
workspace := canonicalTempDir(t)
createSinglePlanPair(t, workspace, "m1")
createTaskPair(t, workspace, "m1", "01_split", false)
_, err := InspectTaskGroup(workspace, "m-m1")
if err == nil || !strings.Contains(err.Error(), "mixes a single-plan root pair with split-plan") {
t.Fatalf("InspectTaskGroup error = %v", err)
}
}
func TestWorkflowRejectsIncompleteSinglePlanPair(t *testing.T) {
tests := []struct {
name string
filename string
content string
wantErr string
}{
{"missing review", "PLAN-test.md", validPlan(), "exactly one active CODE_REVIEW"},
{"missing plan", "CODE_REVIEW-test.md", "# Review\n", "exactly one active PLAN"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
workspace := canonicalTempDir(t)
taskRoot := filepath.Join(workspace, "agent-task", "m-m1")
if err := os.MkdirAll(taskRoot, 0700); err != nil {
t.Fatal(err)
}
writeTaskFile(
t,
filepath.Join(taskRoot, test.filename),
test.content,
)
_, err := InspectTaskGroup(workspace, "m-m1")
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("InspectTaskGroup error = %v, want %q", err, test.wantErr)
}
})
}
}
func TestWorkflowRejectsMalformedMissingAndAmbiguousArtifacts(t *testing.T) {
tests := []struct {
name string
mutate func(*testing.T, runtimeFixture)
wantErr string
}{
{
name: "missing review",
mutate: func(t *testing.T, fixture runtimeFixture) {
root := filepath.Join(fixture.projectA, "agent-task", "m-m1", "3_missing")
if err := os.MkdirAll(root, 0700); err != nil {
t.Fatal(err)
}
writeTaskFile(t, filepath.Join(root, "PLAN-only.md"), validPlan())
},
wantErr: "exactly one active CODE_REVIEW",
},
{
name: "malformed plan",
mutate: func(t *testing.T, fixture runtimeFixture) {
writeTaskFile(
t,
filepath.Join(fixture.projectA, "agent-task", "m-m1", "1_first", "PLAN-test.md"),
"# Plan without a write set\n",
)
},
wantErr: "Modified Files Summary is missing",
},
{
name: "ambiguous identity",
mutate: func(t *testing.T, fixture runtimeFixture) {
createTaskPair(t, fixture.projectA, "m1", "1_duplicate", false)
},
wantErr: `work identity "1" is ambiguous`,
},
{
name: "missing explicit dependency",
mutate: func(t *testing.T, fixture runtimeFixture) {
createTaskPair(t, fixture.projectA, "m1", "3+missing_dependency", false)
},
wantErr: "explicit predecessor",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
fixture := newRuntimeFixture(t)
test.mutate(t, fixture)
if test.name == "missing explicit dependency" {
preview, err := fixture.runtime.Preview(context.Background(), "project-a")
if err != nil {
t.Fatalf("Preview: %v", err)
}
if preview.NextWork != "1" {
t.Fatalf("preview chose %q, want first independent work", preview.NextWork)
}
// Complete every earlier work so the missing predecessor is the
// only remaining scheduling explanation.
for _, task := range []string{"1_first", "2+1_second"} {
writeTaskFile(
t,
filepath.Join(fixture.projectA, "agent-task", "m-m1", task, completeFileName),
"complete\n",
)
}
preview, err = fixture.runtime.Preview(context.Background(), "project-a")
if err != nil {
t.Fatalf("Preview after completion: %v", err)
}
if len(preview.Blockers) != 1 ||
preview.Blockers[0].Code != agenttask.BlockerDependencyMissing ||
!strings.Contains(preview.Blockers[0].Message, test.wantErr) {
t.Fatalf("missing-dependency preview = %#v", preview)
}
return
}
workflow, err := NewWorkflow(fixture.snapshot, fixture.runtime.selections)
if err != nil {
t.Fatalf("NewWorkflow: %v", err)
}
_, err = workflow.Snapshot(context.Background(), "project-a")
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("Snapshot error = %v, want substring %q", err, test.wantErr)
}
})
}
}
func validPlan() string {
return `# Plan
## Modified Files Summary
| File | Purpose |
|------|---------|
| ` + "`README.md`" + ` | Exercise workflow parsing. |
`
}
func createSinglePlanPair(t *testing.T, workspace, milestone string) {
t.Helper()
taskRoot := filepath.Join(workspace, "agent-task", "m-"+milestone)
if err := os.MkdirAll(taskRoot, 0700); err != nil {
t.Fatalf("create single-plan task group: %v", err)
}
writeTaskFile(t, filepath.Join(taskRoot, "PLAN-test.md"), validPlan())
writeTaskFile(t, filepath.Join(taskRoot, "CODE_REVIEW-test.md"), "# Code Review Reference\n")
}
func TestValidatePlanWriteSetMatrix(t *testing.T) {
workspace := t.TempDir()
writeTaskFile(t, filepath.Join(workspace, "existing.md"), "existing\n")
if err := os.Mkdir(filepath.Join(workspace, "directory"), 0700); err != nil {
t.Fatal(err)
}
candidate := filepath.Join(t.TempDir(), "candidate.md")
plan := func(heading, rows string) string {
return fmt.Sprintf("# Plan\n\n## %s\n\n| File | Purpose |\n|---|---|\n%s\n", heading, rows)
}
cases := []struct {
name string
contents string
want []string
wantErr string
}{
{"relative", plan("Modified Files Summary", "| `new.md` | new |"), []string{"new.md"}, ""},
{"absolute with line suffix", plan("Modified Files Summary", fmt.Sprintf("| `%s:10:2` | existing |", filepath.Join(workspace, "existing.md"))), []string{"existing.md"}, ""},
{"legacy heading", plan("수정 파일 요약", "| `legacy.md` | legacy |"), []string{"legacy.md"}, ""},
{"missing section", "# Plan\n", nil, "Modified Files Summary is missing"},
{"duplicate section", plan("Modified Files Summary", "| `one.md` | one |") + plan("수정 파일 요약", "| `two.md` | two |"), nil, "appears more than once"},
{"empty claim", plan("Modified Files Summary", "| `` | empty |"), nil, "empty file path claim"},
{"glob", plan("Modified Files Summary", "| `*.md` | glob |"), nil, "contained literal"},
{"url", plan("Modified Files Summary", "| `https://example.com/plan.md` | url |"), nil, "URL claim"},
{"placeholder", plan("Modified Files Summary", "| `TODO.md` | placeholder |"), nil, "contained literal"},
{"backslash", plan("Modified Files Summary", "| `nested\\file.md` | malformed |"), nil, "contained literal"},
{"root", plan("Modified Files Summary", fmt.Sprintf("| `%s` | root |", workspace)), nil, "workspace root claim"},
{"outside", plan("Modified Files Summary", "| `../outside.md` | outside |"), nil, "outside the workspace"},
{"duplicate", plan("Modified Files Summary", "| `same.md` | one |\n| `same.md` | two |"), nil, "duplicated"},
{"directory", plan("Modified Files Summary", "| `directory` | directory |"), nil, "directory claim"},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
writeTaskFile(t, candidate, tt.contents)
got, err := ValidatePlanWriteSet(workspace, candidate)
if tt.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("ValidatePlanWriteSet error = %v, want %q", err, tt.wantErr)
}
return
}
if err != nil {
t.Fatal(err)
}
if strings.Join(got, ",") != strings.Join(tt.want, ",") {
t.Fatalf("write set = %#v, want %#v", got, tt.want)
}
})
}
}
func TestValidatePlanWriteSetSymlinkContainment(t *testing.T) {
workspace := t.TempDir()
if err := os.MkdirAll(filepath.Join(workspace, "contained-target"), 0700); err != nil {
t.Fatal(err)
}
external := t.TempDir()
cases := []struct {
name string
prepare func(t *testing.T)
claim string
want []string
wantErr string
}{
{
name: "contained broken leaf",
prepare: func(t *testing.T) {
if err := os.Symlink("contained-target/new.md", filepath.Join(workspace, "contained-link.md")); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
},
claim: "contained-link.md", want: []string{"contained-target/new.md"},
},
{
name: "external broken leaf",
prepare: func(t *testing.T) {
if err := os.Symlink(filepath.Join(external, "missing.md"), filepath.Join(workspace, "external-link.md")); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
},
claim: "external-link.md", wantErr: "outside the workspace",
},
{
name: "symlink loop",
prepare: func(t *testing.T) {
if err := os.Symlink("loop-b.md", filepath.Join(workspace, "loop-a.md")); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
if err := os.Symlink("loop-a.md", filepath.Join(workspace, "loop-b.md")); err != nil {
t.Fatal(err)
}
},
claim: "loop-a.md", wantErr: "cannot be canonicalized",
},
{name: "nested missing file", prepare: func(t *testing.T) {}, claim: "nested/new/file.md", want: []string{"nested/new/file.md"}},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
tt.prepare(t)
planPath := filepath.Join(t.TempDir(), "candidate.md")
contents := fmt.Sprintf("# Plan\n\n## Modified Files Summary\n\n| File | Purpose |\n|---|---|\n| `%s` | test |\n", tt.claim)
writeTaskFile(t, planPath, contents)
got, err := ValidatePlanWriteSet(workspace, planPath)
if tt.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("ValidatePlanWriteSet error = %v, want %q", err, tt.wantErr)
}
return
}
if err != nil {
t.Fatal(err)
}
if strings.Join(got, ",") != strings.Join(tt.want, ",") {
t.Fatalf("write set = %#v, want %#v", got, tt.want)
}
})
}
}
func TestValidatePlanWriteSetDeepNonexistentTarget(t *testing.T) {
workspace := t.TempDir()
parts := make([]string, maxCandidateSymlinkResolutions+2)
for index := range parts[:len(parts)-1] {
parts[index] = "missing"
}
parts[len(parts)-1] = "new.md"
claim := strings.Join(parts, "/")
planPath := filepath.Join(t.TempDir(), "candidate.md")
contents := fmt.Sprintf("# Plan\n\n## Modified Files Summary\n\n| File | Purpose |\n|---|---|\n| `%s` | test |\n", claim)
writeTaskFile(t, planPath, contents)
got, err := ValidatePlanWriteSet(workspace, planPath)
if err != nil {
t.Fatalf("ValidatePlanWriteSet: %v", err)
}
if strings.Join(got, ",") != claim {
t.Fatalf("write set = %#v, want %q", got, claim)
}
}