fix(agent): 단일·분할 작업 탐색을 함께 지원한다
This commit is contained in:
parent
fc361f363c
commit
06556eba69
2 changed files with 339 additions and 36 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Reference in a new issue