From 06556eba693fb02d18316c9bbf387b7a3e4be35c Mon Sep 17 00:00:00 2001 From: toki Date: Fri, 31 Jul 2026 15:35:33 +0900 Subject: [PATCH] =?UTF-8?q?fix(agent):=20=EB=8B=A8=EC=9D=BC=C2=B7=EB=B6=84?= =?UTF-8?q?=ED=95=A0=20=EC=9E=91=EC=97=85=20=ED=83=90=EC=83=89=EC=9D=84=20?= =?UTF-8?q?=ED=95=A8=EA=BB=98=20=EC=A7=80=EC=9B=90=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/agent/internal/taskloop/workflow.go | 211 +++++++++++++++--- apps/agent/internal/taskloop/workflow_test.go | 164 ++++++++++++++ 2 files changed, 339 insertions(+), 36 deletions(-) diff --git a/apps/agent/internal/taskloop/workflow.go b/apps/agent/internal/taskloop/workflow.go index a45f4c1..cd547ad 100644 --- a/apps/agent/internal/taskloop/workflow.go +++ b/apps/agent/internal/taskloop/workflow.go @@ -125,14 +125,23 @@ func WorkspaceIdentity(root string) agenttask.WorkspaceID { func scanWorkflow(root, milestone string) ([]agenttask.WorkUnit, string, error) { activeRoot := filepath.Join(root, "agent-task", taskGroupPrefix+milestone) - entries, err := os.ReadDir(activeRoot) + entries, err := readOptionalTaskGroup(activeRoot) if err != nil { return nil, "", fmt.Errorf("taskloop: read active task group: %w", err) } - units := make([]agenttask.WorkUnit, 0, len(entries)) - evidence := make([]string, 0, len(entries)) + units := make([]agenttask.WorkUnit, 0, len(entries)+1) + evidence := make([]string, 0, len(entries)+1) seen := make(map[agenttask.WorkUnitID]string) + single, singleEvidence, singleActive, err := readSinglePlanUnit(root, activeRoot, milestone) + if err != nil { + return nil, "", err + } + if singleActive { + seen[single.ID] = taskGroupPrefix + milestone + units = append(units, single) + evidence = append(evidence, singleEvidence) + } for _, entry := range entries { if !entry.IsDir() { continue @@ -144,6 +153,13 @@ func scanWorkflow(root, milestone string) ([]agenttask.WorkUnit, string, error) if !include { continue } + if singleActive { + return nil, "", fmt.Errorf( + "taskloop: task group %q mixes a single-plan root pair with split-plan task directory %q", + taskGroupPrefix+milestone, + entry.Name(), + ) + } if prior, duplicate := seen[unit.ID]; duplicate { return nil, "", fmt.Errorf( "taskloop: work identity %q is ambiguous between %q and %q", @@ -164,7 +180,7 @@ func scanWorkflow(root, milestone string) ([]agenttask.WorkUnit, string, error) evidence = append(evidence, archivedEvidence...) if len(units) == 0 { return nil, "", fmt.Errorf( - "taskloop: selected milestone %q has no active PLAN artifacts", + "taskloop: selected milestone %q has no active or archived task artifacts", milestone, ) } @@ -173,6 +189,14 @@ func scanWorkflow(root, milestone string) ([]agenttask.WorkUnit, string, error) return units, strings.Join(evidence, "\n"), nil } +func readOptionalTaskGroup(root string) ([]os.DirEntry, error) { + entries, err := os.ReadDir(root) + if errors.Is(err, fs.ErrNotExist) { + return nil, nil + } + return entries, err +} + // InspectTaskGroup provides a configuration-free, read-only projection for an // operator dry-run. It deliberately uses the same workflow parser as Runtime // and never constructs a manager, provider, lease, or durable state store. @@ -192,6 +216,19 @@ func readArchivedUnits( root, milestone string, seen map[agenttask.WorkUnitID]string, ) ([]agenttask.WorkUnit, []string, error) { + directPattern := filepath.Join( + root, + "agent-task", + "archive", + "*", + "*", + taskGroupPrefix+milestone, + completeFileName, + ) + directCompletions, err := filepath.Glob(directPattern) + if err != nil { + return nil, nil, err + } pattern := filepath.Join( root, "agent-task", @@ -206,69 +243,171 @@ func readArchivedUnits( if err != nil { return nil, nil, err } + sort.Strings(directCompletions) sort.Strings(completions) var units []agenttask.WorkUnit var evidence []string + directID := singlePlanWorkUnitID(milestone) + for _, completion := range directCompletions { + unit, unitEvidence, include, err := readArchivedUnit( + root, + milestone, + completion, + directID, + []string{taskGroupPrefix + milestone, milestone}, + taskGroupPrefix+milestone, + seen, + ) + if err != nil { + return nil, nil, err + } + if include { + units = append(units, unit) + evidence = append(evidence, unitEvidence) + } + } for _, completion := range completions { taskDirectory := filepath.Base(filepath.Dir(completion)) id, _, aliases, err := parseTaskDirectory(taskDirectory) if err != nil { return nil, nil, fmt.Errorf("taskloop: archived task %q: %w", taskDirectory, err) } - workID := agenttask.WorkUnitID(id) - if _, active := seen[workID]; active { - continue - } - content, err := os.ReadFile(completion) + unit, unitEvidence, include, err := readArchivedUnit( + root, + milestone, + completion, + agenttask.WorkUnitID(id), + aliases, + taskDirectory, + seen, + ) if err != nil { return nil, nil, err } - seen[workID] = taskDirectory - units = append(units, agenttask.WorkUnit{ + if include { + units = append(units, unit) + evidence = append(evidence, unitEvidence) + } + } + return units, evidence, nil +} + +func readArchivedUnit( + root, milestone, completion string, + workID agenttask.WorkUnitID, + aliases []string, + taskDirectory string, + seen map[agenttask.WorkUnitID]string, +) (agenttask.WorkUnit, string, bool, error) { + if _, exists := seen[workID]; exists { + return agenttask.WorkUnit{}, "", false, nil + } + content, err := os.ReadFile(completion) + if err != nil { + return agenttask.WorkUnit{}, "", false, err + } + seen[workID] = taskDirectory + relative, err := filepath.Rel(root, completion) + if err != nil { + return agenttask.WorkUnit{}, "", false, err + } + return agenttask.WorkUnit{ ID: workID, MilestoneID: agenttask.MilestoneID(milestone), - Aliases: aliases, + Aliases: uniqueStrings(aliases), WriteSetKind: agenttask.WriteSetUnknown, IsolationMode: agentguard.IsolationModeOverlay, Completed: true, Metadata: map[string]string{"task_directory": taskDirectory}, - }) - relative, _ := filepath.Rel(root, completion) - evidence = append(evidence, digestBytes(filepath.ToSlash(relative), content)) + }, + digestBytes(filepath.ToSlash(relative), content), + true, + nil +} + +func readSinglePlanUnit( + root, activeRoot, milestone string, +) (agenttask.WorkUnit, string, bool, error) { + taskGroup := taskGroupPrefix + milestone + plan, review, include, err := matchingActivePair(activeRoot, taskGroup) + if err != nil || !include { + return agenttask.WorkUnit{}, "", include, err } - return units, evidence, nil + return normalizeActiveUnit( + root, + activeRoot, + milestone, + taskGroup, + plan, + review, + string(singlePlanWorkUnitID(milestone)), + nil, + []string{taskGroup, milestone}, + ) } func readActiveUnit( root, activeRoot, milestone, directory string, ) (agenttask.WorkUnit, string, bool, error) { taskRoot := filepath.Join(activeRoot, directory) - plans, err := matchingMarkdown(taskRoot, planPrefix) + plan, review, include, err := matchingActivePair(taskRoot, directory) + if err != nil || !include { + return agenttask.WorkUnit{}, "", include, err + } + id, predecessors, aliases, err := parseTaskDirectory(directory) if err != nil { return agenttask.WorkUnit{}, "", false, err } - if len(plans) == 0 { - return agenttask.WorkUnit{}, "", false, nil + return normalizeActiveUnit( + root, + taskRoot, + milestone, + directory, + plan, + review, + id, + predecessors, + aliases, + ) +} + +func matchingActivePair(root, task string) (string, string, bool, error) { + plans, err := matchingMarkdown(root, planPrefix) + if errors.Is(err, fs.ErrNotExist) { + return "", "", false, nil + } + if err != nil { + return "", "", false, err + } + reviews, err := matchingMarkdown(root, reviewPrefix) + if err != nil { + return "", "", false, err + } + if len(plans) == 0 && len(reviews) == 0 { + return "", "", false, nil } if len(plans) != 1 { - return agenttask.WorkUnit{}, "", false, fmt.Errorf( + return "", "", false, fmt.Errorf( "taskloop: task %q must contain exactly one active PLAN, found %d", - directory, + task, len(plans), ) } - reviews, err := matchingMarkdown(taskRoot, reviewPrefix) - if err != nil { - return agenttask.WorkUnit{}, "", false, err - } if len(reviews) != 1 { - return agenttask.WorkUnit{}, "", false, fmt.Errorf( + return "", "", false, fmt.Errorf( "taskloop: task %q must contain exactly one active CODE_REVIEW, found %d", - directory, + task, len(reviews), ) } - planBytes, err := os.ReadFile(plans[0]) + return plans[0], reviews[0], true, nil +} + +func normalizeActiveUnit( + root, taskRoot, milestone, taskDirectory, plan, review, id string, + predecessors, aliases []string, +) (agenttask.WorkUnit, string, bool, error) { + planBytes, err := os.ReadFile(plan) if err != nil { return agenttask.WorkUnit{}, "", false, err } @@ -276,19 +415,15 @@ func readActiveUnit( if err != nil { return agenttask.WorkUnit{}, "", false, fmt.Errorf( "taskloop: task %q PLAN: %w", - directory, + taskDirectory, err, ) } - id, predecessors, aliases, err := parseTaskDirectory(directory) + planRelative, err := filepath.Rel(root, plan) if err != nil { return agenttask.WorkUnit{}, "", false, err } - planRelative, err := filepath.Rel(root, plans[0]) - if err != nil { - return agenttask.WorkUnit{}, "", false, err - } - reviewRelative, err := filepath.Rel(root, reviews[0]) + reviewRelative, err := filepath.Rel(root, review) if err != nil { return agenttask.WorkUnit{}, "", false, err } @@ -316,7 +451,7 @@ func readActiveUnit( IsolationMode: agentguard.IsolationModeOverlay, Completed: completed, Metadata: map[string]string{ - "task_directory": directory, + "task_directory": taskDirectory, "plan_path": filepath.ToSlash(planRelative), "review_path": filepath.ToSlash(reviewRelative), }, @@ -324,6 +459,10 @@ func readActiveUnit( return unit, digestBytes(filepath.ToSlash(planRelative), planBytes), true, nil } +func singlePlanWorkUnitID(milestone string) agenttask.WorkUnitID { + return agenttask.WorkUnitID(taskGroupPrefix + milestone) +} + func matchingMarkdown(root, prefix string) ([]string, error) { entries, err := os.ReadDir(root) if err != nil { diff --git a/apps/agent/internal/taskloop/workflow_test.go b/apps/agent/internal/taskloop/workflow_test.go index 81e8ade..8cb58ca 100644 --- a/apps/agent/internal/taskloop/workflow_test.go +++ b/apps/agent/internal/taskloop/workflow_test.go @@ -47,6 +47,160 @@ func TestWorkflowNormalizesActiveCompletionAndDependencies(t *testing.T) { } } +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 @@ -145,6 +299,16 @@ func validPlan() string { ` } +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")