Skip to content

fix(coding-agent): delete empty draft sessions on shutdown and sweep ghosts at startup - #1079

Closed
ruttybob wants to merge 2 commits into
PrimeIntellect-ai:mainfrom
ruttybob:fix/ghost-session-cleanup
Closed

ruttybob wants to merge 2 commits into
PrimeIntellect-ai:mainfrom
ruttybob:fix/ghost-session-cleanup

Conversation

@ruttybob

@ruttybob ruttybob commented Aug 9, 2026

Copy link
Copy Markdown

Closes #1078

Problem

Empty session files (ghost sessions) accumulate in ~/.prime/agent/sessions/ after every daemon shutdown or update restart. These files contain zero messages — only the 4 bootstrap entries plus a daemon-written session_state:active (~768 bytes). Orphaned session-lease directories also accumulate when a daemon exits without releasing its lease. Neither is ever cleaned up.

Full diagnosis and evidence in #1078.

Root cause

Two layers:

1. Ghost creation (now fixed)

SessionManager._persist() allowed session_state entries to bypass the "no assistant message → don't write to disk" guard:

// Before:
const shouldPersistWithoutAssistant = entry.type === "session_state" || entry.type === "session_info";

When addRuntime() calls appendSessionState({ status: "active" }), _persist wrote the file immediately — before any user message. This is the ghost's birth.

// After:
const shouldPersistWithoutAssistant = entry.type === "session_info";

session_info bypass is kept so explicit /name renames persist immediately. The file is now created only when the first assistant message triggers _rewriteFile, which writes all accumulated entries (including the deferred session_state) at once.

2. Ghost persistence (original PR #1079 fix)

closeSessionOnce() gated isEmptyDraftSession on closeKeepsResumeEntry(reason):

const keepsResumeEntry = this.closeKeepsResumeEntry(reason);          // true for "shutdown" | "update"
const isEmptyDraftSession = !keepsResumeEntry && this.isEmptyDraftContent(state);
//                     ↑ ALWAYS FALSE for shutdown/update

For shutdown/update, keepsResumeEntry is trueisEmptyDraftSession is always falsedeleteSessionFile() is never called. The empty draft stays on disk permanently.

Fix

Root cause prevention (commit df9959a)

Remove session_state from the _persist bypass so ghost files are never created in the first place.

Cleanup at close (commit 153b291)

Replace the keepsResumeEntry gate with isActiveSessionBusy — mirroring the existing isDiscardableDraft() in the detach-time discard path:

// Before:
const isEmptyDraftSession = !keepsResumeEntry && this.isEmptyDraftContent(state);

// After:
const isEmptyDraftSession = this.isEmptyDraftContent(state) && !isActiveSessionBusy(state);

Empty drafts are now deleted on all close reasons, but only when the session is truly idle (not streaming, not compacting, no unfinished actions, no running subagents). keepsResumeEntry still gates archiving and error propagation unchanged.

Safety nets

Change What
sweepStaleSessionLeases(agentDir) in session-lease.ts At daemon startup, remove lease directories whose owner PID is dead
sweepGhostSessionFiles(sessionDir) in session-file-actions.ts At daemon startup, remove session files containing only bootstrap entries + session_state
sweepStaleStartupState() in daemon-mode.ts start() Fires both sweeps — best-effort, fire-and-forget, never blocks startup

Consumer safety (verified — root cause fix is safe)

All 5 places that read state.status from disk were verified safe after removing session_state from the _persist bypass:

Consumer Reads Safe because
inactiveLifecycleForSession status === "archived" Only reads EXISTING files — which now always have an assistant message
isPersistedCronJobRunnable status !== "active" Cron jobs can only be created on sessions with user interaction → file already exists
restoreRlmHeartbeatSession parentInfo.state?.status Parent restored via createRuntime"active" re-written
archiveSession Writes "archived" Only called for non-empty sessions
deactivatePendingAgent Writes "archived" Checks existsSync(sessionFile) before opening

Tests (9 new, all green)

File Test
session-manager-flush.test.ts appendSessionState(active) does NOT create a file before assistant message
session-manager-flush.test.ts After first assistant message, file IS created and INCLUDES session_state entry
daemon-mode.test.ts Empty draft + shutdown → file deleted
daemon-mode.test.ts Session with user content + shutdown → file preserved
daemon-mode.test.ts 2 existing mock tests updated with hasRunningRlmChildren
session-lease.test.ts Stale lease swept, live lease preserved
session-lease.test.ts Missing directory → 0
session-artifacts-delete.test.ts Ghost file removed, real session preserved
session-artifacts-delete.test.ts Missing directory → 0

Checklist

  • Typecheck (tsgo --noEmit) passes
  • Biome lint/format passes
  • Pre-commit hooks pass (biome + typecheck + installer + browser smoke)
  • No new dependencies

…ghosts at startup

Empty session files (0 messages, only bootstrap entries + session_state:active)
accumulated in ~/.prime/agent/sessions/ because closeSessionOnce() short-circuited
isEmptyDraftSession via closeKeepsResumeEntry: for shutdown/update reasons the
condition was always false, so deleteSessionFile() was never called.

Core fix: replace the closeKeepsResumeEntry gate on isEmptyDraftSession with
isActiveSessionBusy, mirroring the detach-time discard path (isDiscardableDraft).
An empty draft is now deleted on ALL close reasons — but only when the session is
truly idle (not streaming, not compacting, no unfinished actions, no running
subagents).

Safety nets for existing ghosts and orphaned leases:
- sweepStaleSessionLeases(): at daemon startup, remove session-lease directories
  whose owner PID is no longer alive
- sweepGhostSessionFiles(): at daemon startup, remove session files that contain
  only bootstrap entries + session_state (no messages or user content)
- Both are best-effort, fire-and-forget, and never block startup
…files

Remove session_state from the _persist bypass so that
appendSessionState("active") in addRuntime() no longer writes a
session file to disk before any assistant message arrives. The file
is created only when the first assistant message triggers _rewriteFile,
which writes all accumulated entries (including the deferred
session_state) at once.

This addresses the root cause that PR PrimeIntellect-ai#1079's cleanup-only approach
missed: ghost sessions were still being born, then living for ~90 min
in active state with no way to delete them.

Keep session_info in the bypass so explicit /name renames persist
immediately.

Added two tests verifying:
- appendSessionState(active) does NOT create a file before an assistant
  message
- After the first assistant message, the file IS created and INCLUDES
  the session_state entry

Copy link
Copy Markdown
Member

Hi, thanks for taking the time to contribute to Prime Agent! Since open sourcing the project, we’ve received far more pull requests than we can responsibly review and validate. Prime Agent runs directly on users’ machines, so we need to be deliberate about which changes we accept and how they are reviewed. Rather than leave a large backlog that we cannot meaningfully work through, we’re closing the current PR queue and moving to a discussion-first contribution process.

We have established new contribution guidelines to help us continue iterating on Prime Agent and better manage contributions from the community. Going forward, we won’t review unsolicited pull requests. Instead, please start with a GitHub Discussion. We’ll identify recurring bugs and feature requests, create Issues for work we want to pursue, and invite pull requests from maintainers or vouched contributors when implementation is ready. Please read the full process documented in our contribution guidelines.

While we’re closing this backlog, we’re still reviewing it at a high level to identify recurring bugs, useful ideas, and important problems that we should address ourselves. Thanks again for the time you put into this!

stanleytejakusuma added a commit to stanleytejakusuma/prime-agent that referenced this pull request Aug 20, 2026
Root cause (two parts): a session-lease directory left behind by a
crashed or killed process is never reclaimed, so the daemon continues
to resolve the corresponding session as active. That blocks deletion
from the agents view: Ctrl+X on the resulting '(no messages)' row
either does nothing or reports 'Session became active; stop it before
deleting'. Separately, an empty draft session file (bootstrap entries
only, never sent a message) has no cleanup path once its owning
process is gone.

Adds two swept startup passes, run once by the top-level daemon
supervisor (never a per-session worker), fire-and-forget so a sweep
failure never blocks socket binding:

- sweepStaleSessionLeases (session-lease.ts): removes every
  session-leases/*.lock directory whose recorded owner process is
  dead, reusing the existing isLeaseOwnerAlive/reclaimStaleLease
  machinery already in this file. Safe by construction -- a live
  process can always re-acquire a lease that gets swept from under it.

- sweepGhostSessionFiles + isEmptyDraftSessionFile
  (session-file-actions.ts): deletes a session .jsonl whose entries
  are all bootstrap/state types and which has no live lease, via the
  existing trash-first deleteSessionFile path.

This ports only the verified-safe half of a change bisected from an
earlier upstream PR (PrimeIntellect-ai#1079, never merged): a companion change there
also excluded the session_state entry type from persistence, which
broke legitimate passive RLM child discovery (a passive child shares
the exact same on-disk shape as a ghost draft -- bootstrap entries plus
session_state, no messages -- and is distinguished only by having a
live lease). That companion change is deliberately NOT replicated
here; hasLiveSessionLease is the explicit safety check that keeps
passive children untouched.

18 new regression tests across session-lease.test.ts (5 for the sweep,
3 for the new hasLiveSessionLease helper) and a new
session-file-actions-ghost-sweep.test.ts (10 tests, including the
exact passive-RLM-child protection case). Verified two tests fail when
the lease-liveness check is removed, reproducing precisely the
regression class the upstream companion change introduced -- proving
these tests catch that specific failure mode, not just a generic one.

Full daemon-mode.test.ts (198 tests, this branch predates the
cwd-resume fix's 2 additional tests) plus the two new files: 224
tests, zero regressions. tsgo --noEmit clean across all three touched
files. Build + boot gate pass.
stanleytejakusuma added a commit to stanleytejakusuma/prime-agent that referenced this pull request Aug 20, 2026
Root cause (two parts): a session-lease directory left behind by a
crashed or killed process is never reclaimed, so the daemon continues
to resolve the corresponding session as active. That blocks deletion
from the agents view: Ctrl+X on the resulting '(no messages)' row
either does nothing or reports 'Session became active; stop it before
deleting'. Separately, an empty draft session file (bootstrap entries
only, never sent a message) has no cleanup path once its owning
process is gone.

Adds two swept startup passes, run once by the top-level daemon
supervisor (never a per-session worker), fire-and-forget so a sweep
failure never blocks socket binding:

- sweepStaleSessionLeases (session-lease.ts): removes every
  session-leases/*.lock directory whose recorded owner process is
  dead, reusing the existing isLeaseOwnerAlive/reclaimStaleLease
  machinery already in this file. Safe by construction -- a live
  process can always re-acquire a lease that gets swept from under it.

- sweepGhostSessionFiles + isEmptyDraftSessionFile
  (session-file-actions.ts): deletes a session .jsonl whose entries
  are all bootstrap/state types and which has no live lease, via the
  existing trash-first deleteSessionFile path.

This ports only the verified-safe half of a change bisected from an
earlier upstream PR (PrimeIntellect-ai#1079, never merged): a companion change there
also excluded the session_state entry type from persistence, which
broke legitimate passive RLM child discovery (a passive child shares
the exact same on-disk shape as a ghost draft -- bootstrap entries plus
session_state, no messages -- and is distinguished only by having a
live lease). That companion change is deliberately NOT replicated
here; hasLiveSessionLease is the explicit safety check that keeps
passive children untouched.

18 new regression tests across session-lease.test.ts (5 for the sweep,
3 for the new hasLiveSessionLease helper) and a new
session-file-actions-ghost-sweep.test.ts (10 tests, including the
exact passive-RLM-child protection case). Verified two tests fail when
the lease-liveness check is removed, reproducing precisely the
regression class the upstream companion change introduced -- proving
these tests catch that specific failure mode, not just a generic one.

Full daemon-mode.test.ts (198 tests, this branch predates the
cwd-resume fix's 2 additional tests) plus the two new files: 224
tests, zero regressions. tsgo --noEmit clean across all three touched
files. Build + boot gate pass.
stanleytejakusuma added a commit to stanleytejakusuma/prime-agent that referenced this pull request Aug 23, 2026
Root cause (two parts): a session-lease directory left behind by a
crashed or killed process is never reclaimed, so the daemon continues
to resolve the corresponding session as active. That blocks deletion
from the agents view: Ctrl+X on the resulting '(no messages)' row
either does nothing or reports 'Session became active; stop it before
deleting'. Separately, an empty draft session file (bootstrap entries
only, never sent a message) has no cleanup path once its owning
process is gone.

Adds two swept startup passes, run once by the top-level daemon
supervisor (never a per-session worker), fire-and-forget so a sweep
failure never blocks socket binding:

- sweepStaleSessionLeases (session-lease.ts): removes every
  session-leases/*.lock directory whose recorded owner process is
  dead, reusing the existing isLeaseOwnerAlive/reclaimStaleLease
  machinery already in this file. Safe by construction -- a live
  process can always re-acquire a lease that gets swept from under it.

- sweepGhostSessionFiles + isEmptyDraftSessionFile
  (session-file-actions.ts): deletes a session .jsonl whose entries
  are all bootstrap/state types and which has no live lease, via the
  existing trash-first deleteSessionFile path.

This ports only the verified-safe half of a change bisected from an
earlier upstream PR (PrimeIntellect-ai#1079, never merged): a companion change there
also excluded the session_state entry type from persistence, which
broke legitimate passive RLM child discovery (a passive child shares
the exact same on-disk shape as a ghost draft -- bootstrap entries plus
session_state, no messages -- and is distinguished only by having a
live lease). That companion change is deliberately NOT replicated
here; hasLiveSessionLease is the explicit safety check that keeps
passive children untouched.

18 new regression tests across session-lease.test.ts (5 for the sweep,
3 for the new hasLiveSessionLease helper) and a new
session-file-actions-ghost-sweep.test.ts (10 tests, including the
exact passive-RLM-child protection case). Verified two tests fail when
the lease-liveness check is removed, reproducing precisely the
regression class the upstream companion change introduced -- proving
these tests catch that specific failure mode, not just a generic one.

Full daemon-mode.test.ts (198 tests, this branch predates the
cwd-resume fix's 2 additional tests) plus the two new files: 224
tests, zero regressions. tsgo --noEmit clean across all three touched
files. Build + boot gate pass.
stanleytejakusuma added a commit to stanleytejakusuma/prime-agent that referenced this pull request Aug 23, 2026
Root cause (two parts): a session-lease directory left behind by a
crashed or killed process is never reclaimed, so the daemon continues
to resolve the corresponding session as active. That blocks deletion
from the agents view: Ctrl+X on the resulting '(no messages)' row
either does nothing or reports 'Session became active; stop it before
deleting'. Separately, an empty draft session file (bootstrap entries
only, never sent a message) has no cleanup path once its owning
process is gone.

Adds two swept startup passes, run once by the top-level daemon
supervisor (never a per-session worker), fire-and-forget so a sweep
failure never blocks socket binding:

- sweepStaleSessionLeases (session-lease.ts): removes every
  session-leases/*.lock directory whose recorded owner process is
  dead, reusing the existing isLeaseOwnerAlive/reclaimStaleLease
  machinery already in this file. Safe by construction -- a live
  process can always re-acquire a lease that gets swept from under it.

- sweepGhostSessionFiles + isEmptyDraftSessionFile
  (session-file-actions.ts): deletes a session .jsonl whose entries
  are all bootstrap/state types and which has no live lease, via the
  existing trash-first deleteSessionFile path.

This ports only the verified-safe half of a change bisected from an
earlier upstream PR (PrimeIntellect-ai#1079, never merged): a companion change there
also excluded the session_state entry type from persistence, which
broke legitimate passive RLM child discovery (a passive child shares
the exact same on-disk shape as a ghost draft -- bootstrap entries plus
session_state, no messages -- and is distinguished only by having a
live lease). That companion change is deliberately NOT replicated
here; hasLiveSessionLease is the explicit safety check that keeps
passive children untouched.

18 new regression tests across session-lease.test.ts (5 for the sweep,
3 for the new hasLiveSessionLease helper) and a new
session-file-actions-ghost-sweep.test.ts (10 tests, including the
exact passive-RLM-child protection case). Verified two tests fail when
the lease-liveness check is removed, reproducing precisely the
regression class the upstream companion change introduced -- proving
these tests catch that specific failure mode, not just a generic one.

Full daemon-mode.test.ts (198 tests, this branch predates the
cwd-resume fix's 2 additional tests) plus the two new files: 224
tests, zero regressions. tsgo --noEmit clean across all three touched
files. Build + boot gate pass.
stanleytejakusuma added a commit to stanleytejakusuma/prime-agent that referenced this pull request Aug 23, 2026
Root cause (two parts): a session-lease directory left behind by a
crashed or killed process is never reclaimed, so the daemon continues
to resolve the corresponding session as active. That blocks deletion
from the agents view: Ctrl+X on the resulting '(no messages)' row
either does nothing or reports 'Session became active; stop it before
deleting'. Separately, an empty draft session file (bootstrap entries
only, never sent a message) has no cleanup path once its owning
process is gone.

Adds two swept startup passes, run once by the top-level daemon
supervisor (never a per-session worker), fire-and-forget so a sweep
failure never blocks socket binding:

- sweepStaleSessionLeases (session-lease.ts): removes every
  session-leases/*.lock directory whose recorded owner process is
  dead, reusing the existing isLeaseOwnerAlive/reclaimStaleLease
  machinery already in this file. Safe by construction -- a live
  process can always re-acquire a lease that gets swept from under it.

- sweepGhostSessionFiles + isEmptyDraftSessionFile
  (session-file-actions.ts): deletes a session .jsonl whose entries
  are all bootstrap/state types and which has no live lease, via the
  existing trash-first deleteSessionFile path.

This ports only the verified-safe half of a change bisected from an
earlier upstream PR (PrimeIntellect-ai#1079, never merged): a companion change there
also excluded the session_state entry type from persistence, which
broke legitimate passive RLM child discovery (a passive child shares
the exact same on-disk shape as a ghost draft -- bootstrap entries plus
session_state, no messages -- and is distinguished only by having a
live lease). That companion change is deliberately NOT replicated
here; hasLiveSessionLease is the explicit safety check that keeps
passive children untouched.

18 new regression tests across session-lease.test.ts (5 for the sweep,
3 for the new hasLiveSessionLease helper) and a new
session-file-actions-ghost-sweep.test.ts (10 tests, including the
exact passive-RLM-child protection case). Verified two tests fail when
the lease-liveness check is removed, reproducing precisely the
regression class the upstream companion change introduced -- proving
these tests catch that specific failure mode, not just a generic one.

Full daemon-mode.test.ts (198 tests, this branch predates the
cwd-resume fix's 2 additional tests) plus the two new files: 224
tests, zero regressions. tsgo --noEmit clean across all three touched
files. Build + boot gate pass.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Empty draft session files (ghost sessions) and orphaned leases accumulate on shutdown — never cleaned up

2 participants