Files
520359a2c9 MUL-6518 fix(daemon): reclaim orphaned per-task temp dirs in the GC (#7391)
* refactor(daemon): give the lock and dir-stat helpers neutral names

openEnvRootLockFile, releaseEnvRootLock and codexStoreStat are all
generic: they take a path, and nothing in them knows about env roots or
Codex stores. The next commit reuses the same execution lock for per-task
temp directories, where the old names would read as a bug at the call
site.

No behaviour change.

Co-authored-by: multica-agent <[email protected]>

* fix(daemon): reclaim orphaned per-task temp dirs in the GC

Every task gets a private temp dir (multica-task-*) under the system temp
base, exported to the agent as TMPDIR/TMP/TEMP. Its only exit was the
RemoveAll deferred at the end of runTask, which does not run when the
daemon is killed and does not succeed while a file inside is still open —
the Windows case reported in #7364, where one machine had accumulated 174
leftover directories. The GC never covered them: it scans WorkspacesRoot,
and the temp base is a different root, so multica-task- had exactly one
create site and zero reclaim sites.

Sweep the temp base as a fourth out-of-root pruner. Liveness is decided by
the directory's .task_lock — the same OS advisory lock an env root uses —
not by age: the sweep removes a directory only once it has itself acquired
the lock the owner would still be holding, which the kernel grants only
after that owner is gone.

Using the lock rather than a marker or a TTL is what makes this correct
rather than probabilistic. It is held by an open file description, so it
answers for a live task in this very process as well as one owned by a
different daemon sharing /tmp, which no in-memory active set could see.
And it needs no upper bound on task runtime — there is none to assume,
since DefaultAgentTimeout is 0.

Directories left by daemons predating the lock carry no lock file, so age
is the only signal available for them; they are reclaimed after
MULTICA_GC_TASK_TEMP_LEGACY_TTL (72h). That branch exists to clear the
existing leftovers and can be dropped in a release or two.

The end-of-run RemoveAll stays exactly as it was, and nothing was added to
the task-completion path: the run releases the lock before removing, so a
removal that fails there costs nothing and the next GC cycle finishes the
job.

Co-authored-by: multica-agent <[email protected]>

* fix(daemon): keep the temp dir marker when cleanup cannot finish

Two fixes from review.

1. A failed cleanup destroyed the marker the next sweep needs.

   os.RemoveAll keeps walking after a failure, so removing a directory
   whose payload is still open deleted the .task_lock sitting next to it
   and then returned the error. The directory came back as one with no
   marker — indistinguishable from a pre-lock leftover, so it was no
   longer reclaimable on liveness at all. That is exactly #7364's failure
   path, and it made the claim this change is built on ("a failed removal
   costs nothing, the next cycle retries") false.

   RemoveTaskTempDir removes contents first and stops at the first one it
   cannot remove, leaving the marker behind; the lock file goes last, once
   everything else is gone. Both the task-completion path and the GC use
   it. A cleanup that fails now leaves a directory that is still
   lock-bearing and still unlocked, so the next cycle takes the lock and
   retries — for as many cycles as the holder needs.

2. The legacy TTL could delete a live task's TMPDIR.

   Pre-lock directories were reclaimed on age after 72h by default, but
   age cannot establish liveness: DefaultAgentTimeout is 0, so a task may
   legitimately run far longer than any TTL; a daemon on another profile
   can still be running the old binary during an upgrade; and every daemon
   on the machine shares one temp base. The default is now 0 — leave them
   alone — and reclaiming them is an operator's explicit opt-in. Each
   cycle logs how many were left in place so the choice stays visible
   rather than silent.

   Classification also fails closed: only os.ErrNotExist means "no lock
   file". Any other stat error (another user's dir under a shared /tmp)
   now reads as in-use instead of legacy.

Regressions for both: a Windows test that makes a real sharing violation
with CreateFile minus FILE_SHARE_DELETE, asserting the marker survives the
failed cleanup and the directory goes once the handle closes (wired into
the Windows CI job, which runs targeted filters rather than whole
packages); and unix equivalents driving the same paths through an
unlinkable payload, plus fail-closed coverage for an unreadable directory.

Co-authored-by: multica-agent <[email protected]>

* fix(daemon): publish the temp dir marker atomically, restore it on rmdir failure

Two more windows in the marker's lifecycle, from review.

1. A directory could be swept between marker creation and lock acquisition.

   LockTaskTempDir created .task_lock and locked it as two steps, so there
   was an instant where the marker existed unlocked. A sweep landing there
   reads exactly what a dead owner leaves behind: it takes the lock,
   concludes the owner is gone, and deletes the directory — after which
   the starting task's own lock call succeeds and it runs with a TMPDIR
   that no longer exists.

   The marker is now locked under .task_lock.claiming and renamed into
   place once held. Rename within one directory is atomic and the lock
   rides on the open file description, not the name, so the marker becomes
   visible and held in the same step.

   Before that rename the directory carries no marker, which classifies as
   legacy — and legacy only reclaims what is OLDER than legacyTTL. A
   directory created microseconds ago is younger than any positive TTL,
   and a TTL of 0 disables the branch outright, so no configuration can
   sweep the pre-publish state either.

2. A failed rmdir left a directory with no marker.

   The marker has to be deleted before the directory can be removed, so a
   failure at that last step — a Windows process holding a handle to the
   directory, or content an orphaned child recreated — left a directory
   nothing could classify again. With the legacy branch disabled by
   default that is a permanent leak, and it was the one path where cleanup
   got furthest. The marker is now put back when that removal fails, so
   every failure mode leaves the retry available.

Regressions: the publication order and the mid-claim on-disk state (spared
under a disabled TTL and under positive ones), a sweeper running flat out
against 200 publish/use/cleanup cycles, and the rmdir-failure branch —
which fails without the restore, verified by removing it.

Co-authored-by: multica-agent <[email protected]>

* fix(daemon): decide the legacy sweep on content, not on age alone

The publication window was still open. The claim-then-rename ordering
stopped .task_lock from ever existing unlocked, but a directory mid-claim
carries no .task_lock at all, so the sweep still reaches it through the
legacy branch — and the argument that protected it there was wrong.

That argument was "a directory created microseconds ago is younger than
any positive TTL". MULTICA_GC_TASK_TEMP_LEGACY_TTL takes any duration,
down to a nanosecond, and an owner stalled between MkdirTemp and the
rename outlasts a large TTL just as easily. Reproduced at 1ns, 1ms, 1h
and 72h alike: the sweep deleted a directory whose claim was held.

Age cannot separate a pre-lock leftover from a directory being published,
so stop asking it to. Content can: a directory being published holds
nothing but its own lock files, because the task has not been handed the
path yet and cannot have written anything, while a leftover worth
reclaiming is by definition one with something in it. The legacy branch
now reclaims only directories that hold task content, which covers both
halves of the window — the empty directory before the claim exists, and
the claim-only directory after — at every TTL value, with no timing
assumption left in the argument.

The cost is that a pre-lock daemon which crashed before its task wrote
anything leaves an empty directory nothing reclaims. That is a few bytes
against deleting the temp dir of a task that is starting.

Tests: the mid-claim cases now run at 1ns/1ms/1h/72h with now pushed PAST
the TTL, so they fail if anything reintroduces a timing argument; the
pre-claim (empty directory) half is covered the same way; and the
invariant itself is pinned directly, so relaxing it breaks a test rather
than a race. The existing legacy TTL test now models a real leftover by
giving it content. Verified all of these fail with the content rule
removed.

Co-authored-by: multica-agent <[email protected]>

* fix(daemon): re-check the lock immediately before removing a legacy dir

The legacy branch's decision is several separate observations — classify,
then walk for content, then walk for mtime — and a directory can finish
publishing in the middle of them. Classified as legacy while its marker
was still unpublished, then published and written to by its task, and the
removal went ahead without ever looking at the marker that had appeared:
the sweep deletes a live task's TMPDIR. What was accidentally holding this
together was that the task's own write refreshes the mtime — a timing
crutch, in the branch whose whole point is not to rely on one.

Re-read the classification immediately before removing, and skip unless it
is still legacy.

That closes the window rather than narrowing it, because of what content
means: a task only writes into the directory after it has been handed the
path, which happens only after LockTaskTempDir returns and therefore only
after the marker is published and held. So content observed at the top of
the branch, plus no marker seen at the bottom, proves no live task owns
this directory — and none can arrive later either, since MkdirTemp only
ever hands out a fresh empty directory under a new name.

The regression drives that exact interleaving through a barrier: the sweep
commits to removing the directory, the owner then publishes and its task
writes, and the sweep must notice. `now` is far past the TTL so age cannot
mask it. Verified it fails without the re-check.

Also from review: drop a stale test comment that still claimed a positive
TTL can only be configured far above a directory's age, and record in the
config and operator docs that a directory holding no task content is never
reclaimed on age — including a shell left by a CURRENT daemon that died
between creating the directory and publishing its lock, not just an old
daemon's empty leftover.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-21 17:38:44 +08:00

388 lines
15 KiB
Go

package execenv
import (
"os"
"path/filepath"
"testing"
"time"
)
// makeTaskTempDir creates a temp dir the way ensureTaskTempDir does, optionally
// without the execution lock so a pre-lock daemon's leftovers can be modelled.
func makeTaskTempDir(t *testing.T, base, suffix string, withLock bool) string {
t.Helper()
dir := filepath.Join(base, TaskTempDirPrefix+suffix)
if err := os.Mkdir(dir, 0o700); err != nil {
t.Fatalf("create task temp dir: %v", err)
}
if withLock {
lock, err := LockTaskTempDir(dir)
if err != nil {
t.Fatalf("LockTaskTempDir(): %v", err)
}
ReleaseTaskTempLock(lock)
}
return dir
}
// TestPruneTaskTempDirsHoldsOffLiveDirThenReclaimsIt is the core contract: a
// directory whose owner still holds the lock is never removed, and the same
// sweep reclaims it as soon as that lock is gone.
//
// The lock here is held by THIS process, which is the case an in-memory active
// set would be needed for and the case the lock covers for free: flock and
// LockFileEx are held by an open file description, not by a process, so the
// sweep's own probe loses to it exactly as another daemon's would.
func TestPruneTaskTempDirsHoldsOffLiveDirThenReclaimsIt(t *testing.T) {
base := t.TempDir()
dir := filepath.Join(base, TaskTempDirPrefix+"live")
if err := os.Mkdir(dir, 0o700); err != nil {
t.Fatalf("create task temp dir: %v", err)
}
lock, err := LockTaskTempDir(dir)
if err != nil {
t.Fatalf("LockTaskTempDir(): %v", err)
}
// legacyTTL 0 so nothing here can be reclaimed on age: the lock is the only
// thing under test. `now` is far in the future for the same reason.
future := time.Now().Add(365 * 24 * time.Hour)
if removed, _ := PruneTaskTempDirs(base, 0, future, testLogger()); removed != 0 {
t.Fatalf("prune removed %d dirs while the lock was held, want 0", removed)
}
if _, err := os.Stat(dir); err != nil {
t.Fatalf("live task temp dir was removed: %v", err)
}
ReleaseTaskTempLock(lock)
removed, _ := PruneTaskTempDirs(base, 0, future, testLogger())
if removed != 1 {
t.Fatalf("prune removed %d dirs after the lock was released, want 1", removed)
}
if _, err := os.Stat(dir); !os.IsNotExist(err) {
t.Fatalf("task temp dir still present after prune: %v", err)
}
}
// TestPruneTaskTempDirsReclaimsUnlockedDirImmediately pins that age plays no
// part once liveness is answerable: a directory released seconds ago is gone on
// the next cycle, no TTL wait. This is what makes the end-of-task RemoveAll
// failing (the Windows sharing violation in #7364) cost nothing.
func TestPruneTaskTempDirsReclaimsUnlockedDirImmediately(t *testing.T) {
base := t.TempDir()
dir := makeTaskTempDir(t, base, "released", true)
payload := filepath.Join(dir, "node-compile-cache")
if err := os.WriteFile(payload, make([]byte, 4096), 0o600); err != nil {
t.Fatalf("write payload: %v", err)
}
removed, bytesFreed := PruneTaskTempDirs(base, 0, time.Now(), testLogger())
if removed != 1 {
t.Fatalf("prune removed %d dirs, want 1", removed)
}
if bytesFreed < 4096 {
t.Fatalf("prune reported %d bytes freed, want at least 4096", bytesFreed)
}
if _, err := os.Stat(dir); !os.IsNotExist(err) {
t.Fatalf("task temp dir still present after prune: %v", err)
}
}
// TestPruneTaskTempDirsLegacyDirsFallBackToTTL covers directories left by a
// daemon predating the lock: nothing recorded liveness for them, so age is the
// only signal available. They carry task content — that is both what makes them
// worth reclaiming and what distinguishes them from a directory mid-publication
// (see TestPruneTaskTempDirsLegacyBranchNeedsContent).
func TestPruneTaskTempDirsLegacyDirsFallBackToTTL(t *testing.T) {
const legacyTTL = 72 * time.Hour
legacyDir := func(t *testing.T, base string) string {
t.Helper()
dir := makeTaskTempDir(t, base, "legacy", false)
if err := os.WriteFile(filepath.Join(dir, "payload"), make([]byte, 1024), 0o600); err != nil {
t.Fatalf("write payload: %v", err)
}
return dir
}
t.Run("young legacy dir is kept", func(t *testing.T) {
base := t.TempDir()
dir := legacyDir(t, base)
if removed, _ := PruneTaskTempDirs(base, legacyTTL, time.Now(), testLogger()); removed != 0 {
t.Fatalf("prune removed %d young legacy dirs, want 0", removed)
}
if _, err := os.Stat(dir); err != nil {
t.Fatalf("young legacy dir was removed: %v", err)
}
})
t.Run("legacy dir past the TTL is reclaimed", func(t *testing.T) {
base := t.TempDir()
dir := legacyDir(t, base)
if removed, _ := PruneTaskTempDirs(base, legacyTTL, time.Now().Add(legacyTTL+time.Hour), testLogger()); removed != 1 {
t.Fatalf("prune removed %d expired legacy dirs, want 1", removed)
}
if _, err := os.Stat(dir); !os.IsNotExist(err) {
t.Fatalf("expired legacy dir still present: %v", err)
}
})
t.Run("zero TTL disables the legacy branch", func(t *testing.T) {
base := t.TempDir()
dir := legacyDir(t, base)
if removed, _ := PruneTaskTempDirs(base, 0, time.Now().Add(10*365*24*time.Hour), testLogger()); removed != 0 {
t.Fatalf("prune removed %d legacy dirs with the TTL disabled, want 0", removed)
}
if _, err := os.Stat(dir); err != nil {
t.Fatalf("legacy dir was removed with the TTL disabled: %v", err)
}
})
}
// TestPruneTaskTempDirsOnlyTouchesOwnDirs guards the blast radius: the temp base
// is usually a shared /tmp, so anything without our prefix — and the base
// itself — has to survive a sweep that is otherwise willing to delete on age.
func TestPruneTaskTempDirsOnlyTouchesOwnDirs(t *testing.T) {
base := t.TempDir()
foreignDir := filepath.Join(base, "someone-elses-dir")
if err := os.Mkdir(foreignDir, 0o700); err != nil {
t.Fatalf("create foreign dir: %v", err)
}
foreignFile := filepath.Join(base, "unrelated.sock")
if err := os.WriteFile(foreignFile, []byte("x"), 0o600); err != nil {
t.Fatalf("write foreign file: %v", err)
}
// A FILE carrying our prefix is not one of our directories either.
prefixedFile := filepath.Join(base, TaskTempDirPrefix+"not-a-dir")
if err := os.WriteFile(prefixedFile, []byte("x"), 0o600); err != nil {
t.Fatalf("write prefixed file: %v", err)
}
removed, _ := PruneTaskTempDirs(base, time.Hour, time.Now().Add(10*365*24*time.Hour), testLogger())
if removed != 0 {
t.Fatalf("prune removed %d entries, want 0", removed)
}
for _, path := range []string{base, foreignDir, foreignFile, prefixedFile} {
if _, err := os.Stat(path); err != nil {
t.Fatalf("prune touched %s: %v", path, err)
}
}
}
// TestPruneTaskTempDirsMissingBaseIsNotAnError: the base may not exist yet on a
// daemon that has never run a task, and a GC cycle must not care.
func TestPruneTaskTempDirsMissingBaseIsNotAnError(t *testing.T) {
removed, bytesFreed := PruneTaskTempDirs(filepath.Join(t.TempDir(), "nope"), time.Hour, time.Now(), testLogger())
if removed != 0 || bytesFreed != 0 {
t.Fatalf("prune over a missing base = (%d, %d), want (0, 0)", removed, bytesFreed)
}
}
// TestLockTaskTempDirPublishesAnAlreadyHeldMarker pins the publication order:
// when LockTaskTempDir returns, .task_lock exists AND is held, with no claim
// file left behind. If the marker were created first and locked second, a sweep
// landing in between would lock it, read the owner as dead, and delete the
// directory a task is about to start using.
func TestLockTaskTempDirPublishesAnAlreadyHeldMarker(t *testing.T) {
base := t.TempDir()
dir := filepath.Join(base, TaskTempDirPrefix+"publishing")
if err := os.Mkdir(dir, 0o700); err != nil {
t.Fatalf("create task temp dir: %v", err)
}
lock, err := LockTaskTempDir(dir)
if err != nil {
t.Fatalf("LockTaskTempDir(): %v", err)
}
t.Cleanup(func() { ReleaseTaskTempLock(lock) })
if _, err := os.Stat(filepath.Join(dir, envRootLockFile)); err != nil {
t.Fatalf("marker not published: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, taskTempLockClaimFile)); !os.IsNotExist(err) {
t.Fatalf("claim file left behind, stat err = %v", err)
}
if got := classifyTaskTempDir(dir, testLogger()); got != taskTempInUse {
t.Fatalf("classify = %v, want taskTempInUse", got)
}
}
// TestPruneTaskTempDirsSpareDirMidClaim covers the window itself: a directory
// whose owner has taken the claim but not yet published it must survive a sweep
// under every legacy setting, including ones whose TTL the directory has
// already outlived. Age must play no part in what protects it.
func TestPruneTaskTempDirsSpareDirMidClaim(t *testing.T) {
for _, legacyTTL := range []time.Duration{0, time.Nanosecond, time.Millisecond, time.Hour, 72 * time.Hour} {
t.Run(legacyTTL.String(), func(t *testing.T) {
base := t.TempDir()
dir := filepath.Join(base, TaskTempDirPrefix+"claiming")
if err := os.Mkdir(dir, 0o700); err != nil {
t.Fatalf("create task temp dir: %v", err)
}
// Exactly the on-disk state between claim and publish.
claim, err := openLockFile(filepath.Join(dir, taskTempLockClaimFile))
if err != nil {
t.Fatalf("open claim: %v", err)
}
if ok, err := lockFileExclusiveNonBlocking(claim); err != nil || !ok {
t.Fatalf("lock claim: ok=%v err=%v", ok, err)
}
t.Cleanup(func() { releaseLockFile(claim) })
// now is pushed past the TTL on purpose: what protects this state
// must not be that it is younger than the TTL, because legacyTTL
// accepts any duration and an owner can stall between MkdirTemp
// and the rename.
now := time.Now().Add(legacyTTL + time.Second)
if removed, _ := PruneTaskTempDirs(base, legacyTTL, now, testLogger()); removed != 0 {
t.Fatalf("prune removed %d dirs mid-claim, want 0", removed)
}
if _, err := os.Stat(dir); err != nil {
t.Fatalf("prune removed a directory whose owner was mid-claim: %v", err)
}
})
}
}
// TestLockTaskTempDirRacesPruneCleanly is the concurrency regression: a sweep
// running flat out against tasks publishing their temp dirs must never hand a
// task a directory that has been deleted underneath it.
func TestLockTaskTempDirRacesPruneCleanly(t *testing.T) {
base := t.TempDir()
stop := make(chan struct{})
swept := make(chan struct{})
go func() {
defer close(swept)
for {
select {
case <-stop:
return
default:
PruneTaskTempDirs(base, 0, time.Now(), testLogger())
}
}
}()
for i := 0; i < 200; i++ {
dir, err := os.MkdirTemp(base, TaskTempDirPrefix)
if err != nil {
t.Fatalf("MkdirTemp(): %v", err)
}
lock, err := LockTaskTempDir(dir)
if err != nil {
t.Fatalf("LockTaskTempDir() on iteration %d: %v", i, err)
}
// What the task does next: write into the TMPDIR it was handed.
if err := os.WriteFile(filepath.Join(dir, "payload"), []byte("x"), 0o600); err != nil {
t.Fatalf("task temp dir vanished under its owner on iteration %d: %v", i, err)
}
ReleaseTaskTempLock(lock)
_ = RemoveTaskTempDir(dir)
}
close(stop)
<-swept
}
// TestPruneTaskTempDirsSparesDirBeforeItsClaimExists covers the other half of
// the publication window — the instant between os.MkdirTemp and the claim file
// appearing, when the directory is simply empty. Nothing can be locked yet, so
// the only thing that can protect it is that it holds no task content.
func TestPruneTaskTempDirsSparesDirBeforeItsClaimExists(t *testing.T) {
for _, legacyTTL := range []time.Duration{time.Nanosecond, time.Millisecond, 72 * time.Hour} {
t.Run(legacyTTL.String(), func(t *testing.T) {
base := t.TempDir()
dir, err := os.MkdirTemp(base, TaskTempDirPrefix)
if err != nil {
t.Fatalf("MkdirTemp(): %v", err)
}
now := time.Now().Add(legacyTTL + time.Second)
if removed, _ := PruneTaskTempDirs(base, legacyTTL, now, testLogger()); removed != 0 {
t.Fatalf("prune removed %d dirs before their claim existed, want 0", removed)
}
if _, err := os.Stat(dir); err != nil {
t.Fatalf("prune removed a directory whose owner had not claimed it yet: %v", err)
}
})
}
}
// TestPruneTaskTempDirsLegacyBranchNeedsContent states the invariant the two
// tests above depend on, so that relaxing it fails here rather than in a race:
// the age branch reclaims a leftover holding task content, and nothing else.
func TestPruneTaskTempDirsLegacyBranchNeedsContent(t *testing.T) {
base := t.TempDir()
empty := makeTaskTempDir(t, base, "empty-legacy", false)
withContent := makeTaskTempDir(t, base, "used-legacy", false)
if err := os.WriteFile(filepath.Join(withContent, "payload"), make([]byte, 1024), 0o600); err != nil {
t.Fatalf("write payload: %v", err)
}
removed, _ := PruneTaskTempDirs(base, time.Hour, time.Now().Add(48*time.Hour), testLogger())
if removed != 1 {
t.Fatalf("prune removed %d dirs, want 1 (only the one holding content)", removed)
}
if _, err := os.Stat(withContent); !os.IsNotExist(err) {
t.Fatalf("expired legacy dir with content survived: %v", err)
}
if _, err := os.Stat(empty); err != nil {
t.Fatalf("empty legacy dir was reclaimed on age: %v", err)
}
}
// TestPruneTaskTempDirsRechecksLockBeforeRemoving is the TOCTOU regression: the
// sweep's legacy decision is several separate observations, and a directory can
// finish publishing in the middle of them. The barrier below reproduces exactly
// that interleaving — sweep classifies the directory as legacy, then the owner
// publishes its marker and the task writes — and the sweep must notice before
// it removes anything.
//
// Age deliberately cannot save this one: `now` is far past the TTL, and the
// write inside the barrier would normally refresh the directory's mtime, which
// is precisely the implicit timing crutch this pins against.
func TestPruneTaskTempDirsRechecksLockBeforeRemoving(t *testing.T) {
base := t.TempDir()
dir := makeTaskTempDir(t, base, "publishing-mid-sweep", false)
// Content at the top of the sweep, so the legacy branch's content and age
// gates both pass and it commits to removing this directory.
if err := os.WriteFile(filepath.Join(dir, "payload"), make([]byte, 1024), 0o600); err != nil {
t.Fatalf("write payload: %v", err)
}
var lock *os.File
barrierRuns := 0
taskTempSweepBeforeRecheck = func(swept string) {
if swept != dir {
return
}
barrierRuns++
// The owner wins the race: it publishes, and its task starts writing.
var err error
if lock, err = LockTaskTempDir(dir); err != nil {
t.Errorf("LockTaskTempDir() at the barrier: %v", err)
return
}
if err := os.WriteFile(filepath.Join(dir, "task-output"), []byte("live"), 0o600); err != nil {
t.Errorf("task write at the barrier: %v", err)
}
}
t.Cleanup(func() {
taskTempSweepBeforeRecheck = nil
ReleaseTaskTempLock(lock)
})
removed, _ := PruneTaskTempDirs(base, time.Hour, time.Now().Add(72*time.Hour), testLogger())
if barrierRuns != 1 {
t.Fatalf("barrier ran %d times, want 1 — the sweep never reached the removal decision", barrierRuns)
}
if removed != 0 {
t.Fatalf("sweep removed %d dirs after the owner published mid-decision, want 0", removed)
}
if _, err := os.Stat(filepath.Join(dir, "task-output")); err != nil {
t.Fatalf("sweep destroyed a live task's temp dir: %v", err)
}
if got := classifyTaskTempDir(dir, testLogger()); got != taskTempInUse {
t.Fatalf("classify = %v, want taskTempInUse", got)
}
}