fix(agent): 단일·분할 작업 탐색을 함께 지원한다

This commit is contained in:
toki 2026-07-31 15:35:33 +09:00
parent fc361f363c
commit 06556eba69
2 changed files with 339 additions and 36 deletions

View file

@ -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 {

View file

@ -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")