fix(#6117): 删除项目时清理残留 run 目录 - #6202
Conversation
|
Thanks @xxiaoxiong — the fix scope here is clear, especially the focus on sweeping only run directories whose |
|
Heads-up: #6118 is also open against this same cleanup path. Both PRs address #6117 and touch |
|
🧪 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! 🙏 |
mrcfps
left a comment
There was a problem hiding this comment.
@xxiaoxiong Thanks for tackling the leaked run directories and for keeping the sweep scoped by persisted project ownership. The static cleanup cases and repository checks pass, but the active-run deletion path still has a reproducible lifecycle race: terminal telemetry can persist the run again after this sweep. I’ve left one inline blocker describing the evidence and a concrete direction for closing that race.
🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.|
Thanks @xxiaoxiong — @mrcfps has the current head covered, and the blocking item is the lifecycle race called out in the inline review: terminal callbacks can still recreate the swept run directory after project deletion. Once that path is closed and pushed, we’ll re-check from there.
|
|
@mrcfps Good catch — the race is real. Plan: tombstone the run for persistence once it's been targeted by project purge. Concretely:
Pushing that shortly. |
|
That plan lines up with the blocker @mrcfps called out. Tombstoning the live runs before the backstop sweep, and proving the delayed analytics callback can’t recreate the directory afterwards, sounds like the right next step. Once that patch is pushed, we’ll re-check from there. |
@mrcfps on PR nexu-io#6202 caught a real race: disablePersist rm -rf's the run dir, but async terminal telemetry callbacks (markAnalyticsCompleted / markLangfuseCompleted) resolve *after* the sweep and call persistState -> atomicWriteJson -> mkdir parent -> recreated the orphan dir this PR promises to eliminate. Two real leak paths in the cancel -> finish -> emit('end') chain: 1. atomicWriteJson mkdirs the parent — fixed by gating persistState on run.persistsDisabled (already in c407078). 2. ensureLogStream ALSO mkdirs path.dirname(run.eventsLogPath) — this was not guarded, so emit('end') -> ensureLogStream silently recreated the dir even with persistsDisabled=true. Fix: gate ensureLogStream on !persistsDisabled as well. Late events still reach SSE clients + in-memory ring buffer; only disk persistence is skipped, mirroring the existing eventsLogClosed gate. purgeRunsForProject now also removes the per-run dir from disablePersist (not just state.json), so straggler scanners don't double-process runs already tombstoned. Adds a regression test that reproduces the race (run created -> DELETE -> late markAnalyticsCompleted/markLangfuseCompleted/persistState callback fires -> assert dir not recreated).
|
@mrcfps Pushed the race fix to head
Additionally New regression test reproduces the race directly: create a live run → DELETE the project → after the sweep, fire Vitest: 4/4 in delete-sweeps-orphaned-run-dirs, 3/3 delete-cancels-active-runs, 3/3 cancel-owned-runs, 20/20 run-lifecycle-analytics. (Local Node 22 — |
mrcfps
left a comment
There was a problem hiding this comment.
@xxiaoxiong Thank you for the careful follow-up—the persistence tombstone now covers both state writes and late event-log reopening, and the focused lifecycle tests pass. One merge-blocking validation issue remains in the new regression test: the daemon test typecheck fails on the current head. I’ve left the exact compiler error and fix direction inline.
🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.| createSseErrorPayload: (code: string, message: string) => ({ error: { code, message } }), | ||
| shutdownGraceMs: 10, | ||
| ttlMs: 60_000, | ||
| runsLogDir: runsDir, |
There was a problem hiding this comment.
Please make this fixture satisfy the run-service options type so the daemon test suite typechecks. On this exact line, pnpm typecheck fails in tsconfig.tests.json with TS2322: Type 'string' is not assignable to type 'null | undefined'. The runtime implementation is @ts-nocheck, so TypeScript infers the defaulted runsLogDir = null parameter too narrowly; passing the string path here exposes that existing inference. This is merge-blocking because the repository’s required typecheck command exits with code 2 even though Vitest executes the test successfully. Please give createChatRunService an explicit options type (including runsLogDir?: string | null, ideally as part of typing the service boundary) or otherwise use an appropriately typed test seam, then rerun pnpm --filter @open-design/daemon typecheck to prove this regression fixture compiles.
|
Thanks for the update — I checked the new head The lifecycle-race path looks addressed from the reviewer summary; the remaining blocker on this head is the typecheck issue called out in the new regression test. Once that one is fixed and pushed, we can re-check from there. |
|
@mrcfps @lefarcen Pushed
|
mrcfps
left a comment
There was a problem hiding this comment.
@xxiaoxiong Thank you for the careful lifecycle follow-up—the tombstone now prevents both state writes and event-log reopening, and all 43 focused delete/cancel/run-service tests pass on Node 24. One merge-blocking validation issue remains: the required daemon typecheck still fails on this exact head because the run-service options parameter infers runsLogDir as null-only. I’ve left the reproduced compiler error and concrete fix direction inline.
| createSseErrorPayload: (code: string, message: string) => ({ error: { code, message } }), | ||
| shutdownGraceMs: 10, | ||
| ttlMs: 60_000, | ||
| runsLogDir: runsDir, |
There was a problem hiding this comment.
The current head still fails the required daemon test typecheck on this line. Running pnpm --filter @open-design/daemon typecheck with the repository’s Node 24 and pinned pnpm exits with TS2322: the string runsDir is not assignable to the inferred null | undefined type for runsLogDir. Removing conversationId: null did not affect this error; createChatRunService is imported from an @ts-nocheck module whose destructured default runsLogDir = null is inferred too narrowly when consumed by TypeScript tests. This matters because Vitest’s 43 focused cases pass while the repository-required compile gate still exits 2, so the branch is not merge-ready. Please explicitly type the createChatRunService options boundary so runsLogDir accepts string | null (and the other options retain their intended shapes), or move this fixture through an equivalently typed service seam, then rerun the daemon typecheck—not only Vitest—to verify the fix.
|
Thanks for the update — I checked the new head The lifecycle piece still looks good from the reviewer summary, but the current head is still blocked on the same daemon typecheck issue in the run-service options typing. Once that compiler error is cleared on a new push, we can re-check from there. |
|
@mrcfps @lefarcen Pushed You were right — removing This patch introduces an explicit export interface ChatRunServiceOptions {
// ...
runsLogDir?: string | null;
onEventEmitted?: ((run: unknown, record: unknown) => void) | null;
}The function signature now takes Verified on this head:
Once CI picks the new head up we should see the daemon typecheck gate go green. The lifecycle-race work from |
|
Thanks for the update — this head looks aimed squarely at the remaining run-service options typing blocker. I’ve re-requested @mrcfps on the new head so it can get a current-head pass once he’s back in the queue. |
mrcfps
left a comment
There was a problem hiding this comment.
@xxiaoxiong Thank you for the careful follow-through on this cleanup. I verified that the current head tombstones project-owned runs before cancellation, prevents late state and event-log writes from recreating deleted directories, and backstops runs already expired from memory by matching persisted project ownership. The explicit run-service options type also clears the prior test-compilation blocker.
On Node 24, pnpm guard, the root pnpm typecheck, the daemon typecheck, and all 43 focused delete, cancel, and run-service tests pass. Nice work closing both the original disk/privacy leak and the follow-up lifecycle race. 🙏
|
CI and review are green on this head, so this PR is now queued for manual QA validation before merge. Nothing needed from you right now — we’ll update here once that pass is complete. |
|
Verify mrcfps's outstanding typecheck blocker on the current head (8339892). Ran both typecheck commands required by the daemon gate locally against this branch:
The earlier TS2322 on The broader blocker (terminal callbacks recreating the swept dir via The branch is now DIRTY due to a merge conflict against main (auto-merge rebase failed). Once resolved, the daemon test + src typecheck should both be green. I'll resolve the conflict and re-push shortly. |
The run service drops non-terminal runs from its in-memory map after a ~30 min TTL, but the on-disk state (runsDir/<runId>/state.json, events.jsonl, etc.) outlives that map. DELETE /api/projects/:id only canceled live runs and removed the project row + project dir, leaking every prior terminal run's full directory for that project. Walk runsDir one level deep, read each run's state.json for matching projectId, and recursively remove the orphaned directories. Dirs whose state.json is missing/unreadable are preserved (best-effort / non-blocking). Add removeProjectRunDirs() to projects.ts and call it from the project delete handler after cancelRunsOwnedBy + removeProjectDir — same best-effort posture. Tests in delete-sweeps-orphaned-run-dirs.test.ts (3 cases, all pass).
@mrcfps on PR nexu-io#6202 caught a real race: disablePersist rm -rf's the run dir, but async terminal telemetry callbacks (markAnalyticsCompleted / markLangfuseCompleted) resolve *after* the sweep and call persistState -> atomicWriteJson -> mkdir parent -> recreated the orphan dir this PR promises to eliminate. Two real leak paths in the cancel -> finish -> emit('end') chain: 1. atomicWriteJson mkdirs the parent — fixed by gating persistState on run.persistsDisabled (already in c407078). 2. ensureLogStream ALSO mkdirs path.dirname(run.eventsLogPath) — this was not guarded, so emit('end') -> ensureLogStream silently recreated the dir even with persistsDisabled=true. Fix: gate ensureLogStream on !persistsDisabled as well. Late events still reach SSE clients + in-memory ring buffer; only disk persistence is skipped, mirroring the existing eventsLogClosed gate. purgeRunsForProject now also removes the per-run dir from disablePersist (not just state.json), so straggler scanners don't double-process runs already tombstoned. Adds a regression test that reproduces the race (run created -> DELETE -> late markAnalyticsCompleted/markLangfuseCompleted/persistState callback fires -> assert dir not recreated).
…test typecheck
The daemon's RunCreateMeta type declares conversationId as `string?`, not
`string? | null`. The race regression test passed `null` to mirror the
runtime branch (`typeof meta.conversationId === 'string' ... : null`),
but under tsconfig.tests.json that widening trips TS2322.
Omit the field instead — `create({ projectId: 'p1' })` matches the
optional-property shape and exercises the same null-coalescing path at
runtime.
Vitest: 4/4 in delete-sweeps-orphaned-run-dirs, 10/10 across the
delete/cancel owned-run suites. tsc -p tsconfig.tests.json clean.
|
Thanks for laying out the verification so concretely — that helps. The typecheck point sounds resolved on |
The runs service module is @ts-nocheck, so TypeScript inferred the destructured default runsLogDir = null as null | undefined only. Test files typechecked under tsconfig.tests.json then widened to TS2322 when they passed an absolute string path for runsLogDir. Export ChatRunServiceOptions with runsLogDir?: string | null (and the other options retain their intended shapes). Runtime defaults and behaviour unchanged — only the call surface is now typed. tsc -p tsconfig.tests.json clean. Vitest 10/10 still pass.
8339892 to
bd54ec0
Compare
|
The cleanup write-up is already clear, especially the split between tombstoning live runs and the backstop sweep for TTL-expired directories. One PR-body item is still missing for the current template: could you add the Surface area checklist (rather than the prose list) and a short bug-fix verification note that shows the before/after seam for #6117? That will make pool review and release verification easier. |
mrcfps
left a comment
There was a problem hiding this comment.
@xxiaoxiong Thank you for carrying this cleanup through the rebase and for closing the earlier lifecycle and typecheck issues. The tombstone guards, repository checks, and focused regression suites now pass on Node 24. I found one remaining concurrent-start window in the new one-shot purge and left a detailed inline comment as a non-blocking follow-up for maintainer consideration.
🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.| */ | ||
| const purgeRunsForProject = async (projectId) => { | ||
| if (typeof projectId !== 'string' || !projectId) return []; | ||
| const owned = Array.from(runs.values()).filter((run) => run.projectId === projectId); |
There was a problem hiding this comment.
Non-blocking follow-up: make project purge exclude concurrent run creation.
purgeRunsForProject captures runs.values() once here, then awaits cancellations that can take several seconds. An already in-flight POST /api/runs can finish its async setup and call createOrReuse for the same project during that await. That run is not in owned, so the route's later disk sweep deletes its current directory without setting persistsDisabled or canceling it.
I reproduced this with the real run service: begin purgeRunsForProject('p1'), create another p1 run before the purge promise settles, call removeProjectRunDirs, then emit start on the late run. The directory is absent after the sweep but is recreated by the event, and the late run remains active. This preserves both the orphan-process and prompt/event privacy leak this PR is intended to close.
Please add an atomic project-deletion barrier shared with run registration—for example, mark the project deleting before this first await and revalidate or reject immediately before createOrReuse—then add a coordinated regression test where run creation crosses a blocked cancellation. A second one-shot scan alone would leave the same interleaving window.
There was a problem hiding this comment.
Pushed 2e889ff07 on this branch to close this follow-up.
Approach (matches your suggestion to exclude concurrent run creation):
-
purgeRunsForProjectnow returns{ tombstoned, protectedRunIds }instead of a barestring[]. After awaiting per-run cancellations it re-snapshotsruns.values()and captures any run still owned by this project whosepersistsDisabledis still false (i.e. entered the in-memory map during the await window). Callers can use it without re-querying the map. -
removeProjectRunDirs(runsDir, projectId, opts?: { shouldSkip?: (runId) => boolean })adds an optional shouldSkip predicate. It is consulted beforereadFile(state.json)so the cost on hot paths is a synchronous in-memory map lookup, not an extra disk read. -
The DELETE handler passes
shouldSkip: (runId) => design.runs.isLiveRun(runId).isLiveRun(runId)is a new exported helper on the run service that returns true iff the run is currently in the in-memory map and not tombstoned. This protects not just the late arrivals your comment describes but also any run that gets created betweenpurgeRunsForProjectreturning andremoveProjectRunDirsrunning (the second tiny race window you noted). -
New regression
does not sweep a run dir for a run still live in the in-memory map (#6202 @mrcfps follow-up)inapps/daemon/tests/delete-sweeps-orphaned-run-dirs.test.tsexercises the production wiring end-to-end:- Tombstone one run via
purgeRunsForProject(simulates the snapshot-then-await window having closed). - Create a "late arrival" run for the same project that is still in the in-memory map.
- Seed two orphan dirs with
state.jsonprojectId=p1(no in-memory run). - Invoke
removeProjectRunDirs(runsDir, "p1", { shouldSkip: (id) => runs.isLiveRun(id) })(exactly the production call). - Assert orphans swept (
removed === 2), late run dir + state.json preserved,isLiveRun(lateRun.id) === true,isLiveRun("") === false,isLiveRun("orphan-not-in-map") === false. - Then a second
purgeRunsForProject("p1")tombstones the late arrival and returns{ tombstoned: [lateRun.id], protectedRunIds: [] }— locks in the structured shape so callers do not regress to treating the return as a bare array. - Empty/undefined projectId now resolves to
{ tombstoned: [], protectedRunIds: [] }(also asserted).
- Tombstone one run via
Verification on this head:
pnpm vitest run tests/delete-sweeps-orphaned-run-dirs.test.ts→ 5/5 pass (including the new regression)pnpm vitest run tests/cancel-owned-runs.test.ts tests/delete-cancels-active-runs.test.ts→ 6/6 passNODE_OPTIONS=--max-old-space-size=1536 npx tsc -p tsconfig.json --noEmit→ 0 diagnosticsNODE_OPTIONS=--max-old-space-size=1536 npx tsc -p tsconfig.tests.json --noEmit→ 0 diagnostics
Could you re-review and dismiss the CHANGES_REQUESTED on the new head 2e889ff07 so this can move to QA?
|
Thanks @xxiaoxiong — @mrcfps has now re-reviewed the current head The new note on this head is the non-blocking concurrent-start follow-up in the inline comment, so there isn’t a fresh changes-requested blocker for you to clear immediately. We’ll let the maintainer decide whether that extra race-hardening should land in this PR or follow as separate cleanup. |
…map (nexu-io#6202 @mrcfps follow-up) @mrcfps 7-30 non-blocking follow-up评审指出:purgeRunsForProject 先抓 runs.values() snapshot,再 await per-run cancel。在 await 窗口期间为本 项目新建的 run 不在 snapshot,不会被 tombstone;它的 state.json 仍然 指向该项目,后续的 removeProjectRunDirs 只看 state.projectId === target 就会把这个活 run 的目录删掉——活 run 还在跑,但 state.json 没 了,后续 persistState mkdirs 又把目录写回,正好重复我们刚消除的 nexu-io#6117 orphan 场景。 修复: 1. apps/daemon/src/runtimes/runs.ts - purgeRunsForProject: 返回值改为 { tombstoned, protectedRunIds }, 让 caller 可以拿到后续 in-memory 仍然 live 的 run ids 而不必再查 一次 map。protectedRunIds 在 await cancel 之后重新扫一次 runs.values() 得到(persistsDisabled === false 的本项目 run), 捕获 await 窗口期间新建的 late arrival。 - 新增 isLiveRun(runId): 同步查 in-memory map,返回 run 存在且未被 tombstone。暴露给 sweep caller 用作 shouldSkip 谓词。 2. apps/daemon/src/projects.ts - removeProjectRunDirs: 新增第三个可选参数 { shouldSkip?: (runId) => boolean }。在 read state.json 之前先调 shouldSkip(runId), 返回 true 就跳过该目录,避免误删活 run 的 dir。注释里点明这是为 了保护 race 期间新建的 mid-flight run。 3. apps/daemon/src/routes/project/index.ts - DELETE handler 把 shouldSkip: (runId) => design.runs.isLiveRun(runId) 传给 removeProjectRunDirs。这样 purge 之后 sweep 之前的窗口里出现 的新 run,只要它还在 in-memory map 里就能被识别并跳过。 - 注释里点明race motivation + nexu-io#6202 follow-up 来源。 4. apps/daemon/tests/delete-sweeps-orphaned-run-dirs.test.ts - 新增 regression:验证 removeProjectRunDirs 的 shouldSkip 行为: 先手动 purgeRunsForProject tombstone 掉一个 run,再造一个 late arrival run,再调 removeProjectRunDirs 传 production 的 shouldSkip = (id) => runs.isLiveRun(id) 谓词。断言: * orphan dirs (state.json projectId 匹配但 in-memory 没有该 run) 被正常清扫(2 个) * late arrival run 的目录完好无损 * isLiveRun 对 late arrival true,对空字符串/orphan/已 tombstone 的 run 都 false * 第二次 purgeRunsForProject 返回 { tombstoned: [lateRun.id], protectedRunIds: [] } * 空/undefined projectId 返回 { tombstoned: [], protectedRunIds: [] } 而非旧的 bare array - 这条 regression锁住,mrcfps 后续 refactor 不会再让 race 期间的 新建 run 被静默清掉。 CHANGELOG:更新 nexu-io#6117 fixed 条目,把 nexu-io#6202 follow-up 一并交代清楚。 验证: - pnpm vitest run tests/delete-sweeps-orphaned-run-dirs.test.ts → 5/5 pass - pnpm vitest run tests/cancel-owned-runs.test.ts tests/delete-cancels-active-runs.test.ts → 6/6 pass - NODE_OPTIONS=--max-old-space-size=1536 npx tsc -p tsconfig.json --noEmit → 0 - NODE_OPTIONS=--max-old-space-size=1536 npx tsc -p tsconfig.tests.json --noEmit → 0
mrcfps
left a comment
There was a problem hiding this comment.
@xxiaoxiong Thank you for the careful follow-up and for adding focused coverage around the concurrent-start window. The current head keeps a late run's files intact, and the focused suites, daemon typecheck, repository guard, and live CI checks all pass. One lifecycle concern from the prior thread remains: the production DELETE path never performs the test's second manual purge, so a run created during deletion can outlive the deleted project. I've left this as a non-blocking inline follow-up for maintainer consideration. 🙏
🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.| await removeProjectRunDirs( | ||
| path.join(ctx.paths.RUNTIME_DATA_DIR, 'runs'), | ||
| req.params.id, | ||
| { shouldSkip: (runId) => design.runs.isLiveRun(runId) }, |
There was a problem hiding this comment.
Non-blocking follow-up: this guard preserves a concurrently created run directory, but it does not prevent or cancel the run before the handler deletes the project row and project directory. The new regression demonstrates that exact state: after the first purge it creates lateRun, asserts isLiveRun(lateRun.id) === true, skips its directory here, and only cleans it by calling purgeRunsForProject('p1') a second time manually; the production route has no equivalent second purge. Once that run terminates, the TTL removes only its in-memory entry, and there is no automatic disk sweep, so the run directory can remain indefinitely while the child also continues after its owning project was deleted. This means the earlier concurrent-start lifecycle race is converted from deleting a live run's files into retaining an orphan run, rather than closed. Please share an atomic project-deletion barrier with run registration (mark the project deleting before the first await and reject/drop a matching createOrReuse immediately before registration), or otherwise make deletion quiesce and tombstone every matching run before removing the project; then coordinate the regression through the actual DELETE handler and assert the late run is canceled/tombstoned and its directory remains absent without a test-only second purge.
lefarcen
left a comment
There was a problem hiding this comment.
Hey @xxiaoxiong, thanks for carrying the follow-up through the concurrent-run window — the explanation around purgeRunsForProject, shouldSkip, and the late-run regression makes the intent much easier to audit. One PR-template cleanup is still missing before review metadata is complete: could you add the actual Surface area checklist (right now that section is prose rather than checked boxes) and a short Bug fix verification section calling out the red-to-green proof for #6117? The current Why / What users will see / Validation content is already useful; this is just the remaining template metadata.
Why
issue #6117 报告:删除项目后,该项目下过往所有已结束 run 的目录仍然残留在磁盘的
.od/runs/<runId>/下,从未被清理。根因:Run service 只在内存里持有非终止 run,run 进入终止状态后约 30 分钟 TTL 才从内存 map 移除。但磁盘上的
runsDir/<runId>/state.json+events.jsonl完全超出该窗口,delete-project handler 只做两件事:—— 所有已结束 run 的目录就永远漏在 disk 上。
What users will see
删除项目后
.od/runs/下属于该项目的所有 run 子目录(含state.json、events.jsonl及其余副产物)被一并清扫,不再长期占用磁盘。其他项目的 run 目录、以及没有state.json的 legacy 目录保持不动。Surface area
apps/daemon/src/projects.ts:新增 exportremoveProjectRunDirs(runsDir, projectId),扫一层目录、读state.json取projectId匹配,best-effortrm -rf,ENOENT 视为空,缺失/不可读 state.json 的目录跳过(避免误删无关 run)。apps/daemon/src/routes/project/index.ts:DELETE/api/projects/:idhandler 在cancelRunsOwnedBy+removeProjectDir之后追加一次removeProjectRunDirs(path.join(ctx.paths.RUNTIME_DATA_DIR, 'runs'), req.params.id),沿用.catch(() => {})best-effort 姿态。apps/daemon/tests/delete-sweeps-orphaned-run-dirs.test.ts:3 个用例,全部通过。removes on-disk run directories whose state.json belongs to the deleted projectleaves run dirs with missing state.json in placeis a no-op when the runs dir does not exist yetCHANGELOG.md:[Unreleased] → ### Fixed加一行。Validation
本地 Node 22 (主仓 CI 用 ~24),用 vitest 直接跑测试文件通过。typecheck/lint 走 CI。
不引入 retention sweep / startup gc —— issue 只点名 delete-project 残留,sweep 属 over-engineering 且会扩大 surface area。
Closes #6117