diff --git a/cmd/odek/subagent.go b/cmd/odek/subagent.go index dfabea5..fa5aeb0 100644 --- a/cmd/odek/subagent.go +++ b/cmd/odek/subagent.go @@ -617,7 +617,12 @@ func subagentCmd(args []string) error { // is workspace-relative infrastructure (an ordinary local_write for // the child's file tools). Inside the fence it would be neutralized // for untrusted tasks, silently disabling artifacts exactly where - // they matter. + // they matter. The workspace is also self-healed here: stale + // sibling staging from crashed runs is swept at start. + if cwd, err := os.Getwd(); err == nil { + ensureStagingRoot(cwd) + sweepStagingOrphans(cwd, taskTaskID, stagingSweepMaxAge) + } prompt += childArtifactNote(".odek-artifacts/" + taskTaskID) } @@ -852,6 +857,9 @@ func subagentCmd(args []string) error { flags = append(flags, "[artifact] staging relocation failed: "+err.Error()) } } + // Re-create the staging root with its self-gitignore (relocation + // removed the whole subtree, root included). + ensureStagingRoot(cwd) refs, scanFlags := scanArtifacts(taskArtifactRoot, maxArtifactTaskBudget) result.Artifacts = refs flags = append(flags, scanFlags...) diff --git a/cmd/odek/subagent_artifact_registry.go b/cmd/odek/subagent_artifact_registry.go index c9ac159..4f2ccfd 100644 --- a/cmd/odek/subagent_artifact_registry.go +++ b/cmd/odek/subagent_artifact_registry.go @@ -261,3 +261,52 @@ func copyFileContents(src, dst string) error { } return out.Close() } + +const ( + // stagingSweepMaxAge matches the janitor backstop retention: orphaned + // staging subtrees (crash/kill before relocation) older than this are + // swept by the next artifact-bearing run in the same workspace. + stagingSweepMaxAge = 24 * time.Hour + // stagingGitignore keeps staged deliverables out of the user's + // repository — the staging root lives INSIDE the workspace. + stagingGitignore = "*\n!.gitignore\n" +) + +// ensureStagingRoot creates the staging root (0700) and drops a +// self-gitignore so staged deliverables never land in the user's +// repository. Idempotent; best-effort. +func ensureStagingRoot(cwd string) { + root := filepath.Join(cwd, stagingDirName) + if err := os.MkdirAll(root, 0o700); err != nil { + return + } + _ = os.WriteFile(filepath.Join(root, ".gitignore"), []byte(stagingGitignore), 0o600) +} + +// sweepStagingOrphans removes sibling staging task dirs older than maxAge — +// crash/kill orphans the runner could not clean (the janitor only knows +// ~/.odek, not user workspaces). The current task's dir is never touched, +// and fresh siblings may belong to in-flight parallel tasks in the same +// workspace. Returns the number of subtrees removed. +func sweepStagingOrphans(cwd, currentTaskID string, maxAge time.Duration) int { + root := filepath.Join(cwd, stagingDirName) + entries, err := os.ReadDir(root) + if err != nil { + return 0 + } + cutoff := time.Now().Add(-maxAge) + removed := 0 + for _, e := range entries { + if !e.IsDir() || e.Name() == currentTaskID { + continue + } + info, err := e.Info() + if err != nil || info.ModTime().After(cutoff) { + continue + } + if os.RemoveAll(filepath.Join(root, e.Name())) == nil { + removed++ + } + } + return removed +} diff --git a/cmd/odek/subagent_staging_sweep_test.go b/cmd/odek/subagent_staging_sweep_test.go new file mode 100644 index 0000000..fb1c432 --- /dev/null +++ b/cmd/odek/subagent_staging_sweep_test.go @@ -0,0 +1,101 @@ +package main + +// TDD RED phase — staging-dir hygiene (fix/staging-sweep). The workspace +// staging root (.odek-artifacts/) had three gaps: crash-orphans with +// content were never cleaned (the janitor only knows ~/.odek), the empty +// parent persisted, and staged deliverables were visible to the user's +// git. Every artifact-bearing child run now self-heals its workspace. + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestEnsureStagingRoot_Gitignore(t *testing.T) { + cwd := t.TempDir() + ensureStagingRoot(cwd) + + gitignore := filepath.Join(cwd, stagingDirName, ".gitignore") + b, err := os.ReadFile(gitignore) + if err != nil { + t.Fatalf("staging root must carry a self-gitignore: %v", err) + } + if string(b) != "*\n!.gitignore\n" { + t.Errorf("gitignore content = %q, want %q", b, "*\n!.gitignore\n") + } + // Idempotent: a second call must not fail or duplicate. + ensureStagingRoot(cwd) + b2, _ := os.ReadFile(gitignore) + if string(b2) != "*\n!.gitignore\n" { + t.Errorf("second ensure must keep content stable: %q", b2) + } +} + +func TestSweepStagingOrphans_AgedRemovedOthersKept(t *testing.T) { + cwd := t.TempDir() + root := filepath.Join(cwd, stagingDirName) + aged := filepath.Join(root, "task-dead") + fresh := filepath.Join(root, "task-live") + current := "task-current" + for _, d := range []string{filepath.Join(aged, "t"), fresh} { + if err := os.MkdirAll(d, 0o700); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(aged, "t", "big.bin"), []byte("orphan"), 0o600); err != nil { + t.Fatal(err) + } + old := time.Now().Add(-48 * time.Hour) + if err := os.Chtimes(aged, old, old); err != nil { + t.Fatal(err) + } + + removed := sweepStagingOrphans(cwd, current, 24*time.Hour) + if removed != 1 { + t.Fatalf("want 1 orphan removed, got %d", removed) + } + if _, err := os.Stat(aged); !os.IsNotExist(err) { + t.Error("aged orphan must be removed") + } + if _, err := os.Stat(fresh); err != nil { + t.Error("fresh sibling must be kept (may be an in-flight parallel task)") + } +} + +func TestSweepStagingOrphans_MissingRootNoop(t *testing.T) { + if n := sweepStagingOrphans(t.TempDir(), "task-x", 24*time.Hour); n != 0 { + t.Errorf("missing staging root must be a no-op, got %d", n) + } +} + +func TestStagingRoot_TidiedAfterRelocation(t *testing.T) { + // After relocation the staging root keeps only the self-gitignore — + // no empty task dirs, no deliverable residue. + root := t.TempDir() + staging := filepath.Join(root, stagingDirName, "task-1") + if err := os.MkdirAll(staging, 0o700); err != nil { + t.Fatal(err) + } + writeArtifactFile(t, staging, "report.md", "content") + canonical := filepath.Join(root, "canonical") + + if _, err := relocateStagingArtifacts(staging, canonical); err != nil { + t.Fatal(err) + } + ensureStagingRoot(root) + + entries, err := os.ReadDir(filepath.Join(root, stagingDirName)) + if err != nil { + t.Fatal(err) + } + for _, e := range entries { + if e.IsDir() { + t.Errorf("task dir %q must be gone after relocation", e.Name()) + } + } + if _, err := os.Stat(filepath.Join(canonical, "report.md")); err != nil { + t.Errorf("relocated artifact missing: %v", err) + } +}