fix(daemon): reclaim run directories when a project is deleted - #6118
fix(daemon): reclaim run directories when a project is deleted#6118kobbyDaUXer wants to merge 1 commit into
Conversation
Run event logs live at <dataDir>/runs/<runId>/{events.jsonl,state.json},
keyed by run id rather than nested under the project, so
removeProjectDir never reached them. Deleting a project left every run
it ever produced on disk permanently — with no project row left to list
them from, nothing could ever reclaim them. events.jsonl holds the
prompts and the agent's output, which a user deleting a project
reasonably expects to be gone.
Ownership is read from each run's own state.json rather than from
runs.list(): the run service is backed by an in-memory Map that evicts a
run the moment it reaches a terminal status, so a project's finished
runs are already absent from that view by the time it is deleted.
state.json records projectId and survives, making it the only durable
attribution for a historical run.
The sweep is deliberately conservative — a directory is removed only
when its state.json parses AND names this project. Missing, malformed,
or project-less state files are left alone, because stranding a few KB
beats deleting a run belonging to a project the user is keeping.
Per-entry failures are swallowed so one undeletable run cannot block the
delete the user asked for.
Refs nexu-io#6117
|
🧪 Queued for QA validation — this PR changes project-delete behavior in the daemon, so we want a manual QA pass before it merges. Nothing needed from you right now; we’ll update here once that validation is done. Thanks for the contribution! 🙏 |
nettee
left a comment
There was a problem hiding this comment.
@kobbyDaUXer I reviewed the changed ranges in the project delete path and the new daemon helper. The ownership check against durable state.json, the conservative skip behavior for unattributable entries, and the route wiring against RUNTIME_DATA_DIR/runs all look consistent with the existing run lifecycle and restart-reconciliation code. I also checked the surrounding delete/cancel flow to make sure active runs are canceled before the sweep and that unrelated run directories are not touched. Nice fix on a real privacy and disk-leak issue.
|
Heads-up: #6202 is also open against this same cleanup path. Both PRs address #6117 and touch |
|
Hi @kobbyDaUXer — I'm the author of #6202, the other PR touching this same #6117 path. I want to flag the gap I closed there so you (and reviewers) can decide whether to fold it into your PR or stick with the simpler scope here. Your #6118 does the disk sweep correctly — durable
So a project deleted while a run is still in its late-async telemetry-flush window can have its What #6202 adds:
I'd suggest, whichever direction reviewers prefer:
Either way, no work wasted — both PRs end up at the same fix. Happy to hand the tombstone patch over if Option A is cleaner. /cc @lefarcen |
|
Thanks @xxiaoxiong — this is very useful context. The disk sweep in #6118 and the lifecycle-race closure you described in #6202 are not equivalent fixes, so we should treat the tombstone + delayed-telemetry regression case as real review context rather than assuming the current head here already covers it. @kobbyDaUXer, if you want #6118 to stay the landing PR, folding that run-service-owned tombstone path and the late-callback regression into this branch would make the comparison straightforward; otherwise maintainers can choose #6202 as the fuller fix for #6117. |
Fixes #6117
Why
My use case: I created a throwaway project to smoke-test an agent adapter, deleted it afterwards, and noticed the run directory was still on disk.
The pain: run event logs live at
<dataDir>/runs/<runId>/{events.jsonl,state.json}— keyed by run id, not nested under the project — soremoveProjectDirnever reaches them. Deleting a project stranded every run it had ever produced, permanently: with the project row gone there is nothing left that can list or reclaim them. It is unbounded, and it accumulates fastest for exactly the people who use throwaway projects to try things out.There is a privacy dimension too.
events.jsonlcontains the prompts and the agent's output. Someone deleting a project reasonably expects that content gone; today it stays.What users will see
Deleting a project now also removes that project's run logs. Nothing new in the UI.
Implementation notes
Two things here are non-obvious enough to be worth calling out, because the naive version of this fix is wrong:
1. Ownership comes from
state.json, not fromruns.list(). The obvious approach is to reuse the existingcancelRunsOwnedBypath, which already enumerates runs byprojectId. That does not work for this:runsis an in-memoryMapand terminal runs are evicted from it (runtimes/runs.ts:229and:674), so by the time a user deletes a project its finished runs — the ones actually left on disk — are long gone from that view.runs.list({ projectId })would return only whatever happened to be in flight, i.e. approximately nothing, and the leak would look fixed while still leaking. Each run'sstate.jsonrecordsprojectIdand survives eviction, which makes it the only durable attribution for a historical run.2. The sweep is deliberately conservative. A directory is removed only when its
state.jsonparses and names this project. Missing, unparseable, orprojectId-less state files are left alone — stranding a few KB is strictly better than deleting a run belonging to a project the user is keeping. Per-entry failures are swallowed so one undeletable run cannot block the delete the user actually asked for, matching the existing.catch(() => {})treatment ofremoveProjectDirin the same handler.The runs root is derived from
ctx.paths.RUNTIME_DATA_DIRper the daemon data-directory contract inAGENTS.md, not recomputed.Scope
Deletes on project-delete only. Not included: a startup sweep for directories orphaned by earlier versions. That would be the natural companion, but it means deleting user data on upgrade based on a heuristic, and I would rather a maintainer decide that separately than smuggle it into a bug fix. Anyone upgrading keeps their existing orphans until they clear them by hand.
I also did not add a retention window. #6117 raised the question of whether run diagnostics should survive a project deletion briefly; immediate deletion is what the privacy argument points to, and it matches what the user asked for. Happy to change this if you would rather have a grace period.
Surface area
DELETE /api/projects/:idnow also removes the project's run directories. Previously they were retained indefinitely (unintentionally).Bug fix verification
apps/daemon/tests/routes/remove-owned-run-dirs.test.tsmain? The helper does not exist onmain, so the spec cannot compile there — the pre-fix behaviour is instead demonstrated by the reproduction in Deleting a project leaves its run directories orphaned under .od/runs/ #6117 (create project → run → delete project →ls .od/runs/<runId>still listsevents.jsonlandstate.json).Five cases covered: only-owned directories are removed; unattributable directories (no state file, corrupt JSON, no
projectId) survive; loose files in the runs root are ignored; a missing runs root is a no-op; empty arguments are a no-op.Validation
pnpm guard— passpnpm --filter @open-design/daemon typecheck— pass (exit 0, includestsconfig.tests.json)pnpm exec vitest run -c vitest.config.ts tests/routes/ tests/project-routes.test.ts— 11 files, 136 passed, 0 failedNote for anyone running the full
apps/daemonsuite locally: three tests fail onmainin my environment (langfuse-trace.test.ts×2,plugins-headless-run.test.ts×1). They look environment-sensitive rather than real, and they are unrelated to this change — flagging so the numbers are not a surprise.