Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion cmd/odek/subagent.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down Expand Up @@ -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...)
Expand Down
49 changes: 49 additions & 0 deletions cmd/odek/subagent_artifact_registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
101 changes: 101 additions & 0 deletions cmd/odek/subagent_staging_sweep_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading