feat(coding-agent): sweep disposable ghost sessions from the agents view - #2055
feat(coding-agent): sweep disposable ghost sessions from the agents view#2055snimu wants to merge 5 commits into
Conversation
…tinue on it One predicate owner (isDisposableGhost) decides when a session file holds no user intent at all: scan-provided user-content truth (shared creation-prefix rule with SessionManager.hasUserContent), no queued input, no schedule descriptor, no spawn-ledger children, not resident/attached/leased, and older than a 10-minute grace window. Every unknown fails toward keep. The saved-session scan now records that truth on SessionInfo (conservative: unscannable lines count as content), and --continue picks the newest non-ghost for the cwd instead of the newest mtime (RES-1274, closes the second #1921 residual).
…em through a fenced daemon deleter The agents view partitions saved catalog rows with the shared predicate: disposable ghosts are dropped from render immediately and their paths queued for a best-effort sweep_ghost_sessions command (capability-gated, revision 27). The supervisor re-verifies every path with fresh daemon-side truth (residency, attachment, read-only lease liveness, queued input, schedule descriptor, spawn- ledger children) under the eviction fence at delete time, deletes through the existing saved-session delete path, and retries Windows-transient EPERM/EBUSY once per delay step. Sweep failures never surface in the view (RES-1274).
…d protocol gate Partition pins: stale ghost hidden and queued; unknown scan truth, resident, queued-input, heartbeat-armed (active and paused), child-bearing, and young rows all stay visible. Deleter pins: only re-verified ghosts are deleted while every excluded candidate survives, transient EPERM deletes retry, and a persistent failure keeps the file without failing the command. Protocol pin: sweep_ghost_sessions is capability- and schema-gated, mutating, and absent from the default daemon capabilities (RES-1274).
…ment A just-created empty session stays visible-side and is never queued for the sweep (resident, attached, and grace each keep it) (RES-1274).
| } | ||
| try { | ||
| return withLeaseGuard(directory, () => { | ||
| const owner = readLeaseOwner(directory); |
There was a problem hiding this comment.
🟠 High core/session-lease.ts:277
When owner.json cannot be read or parsed, isSessionFileLeased returns false and the ghost sweep may delete a session still owned by a live process. readLeaseOwner converts those errors to undefined, so line 278 treats the lease as absent instead of reaching the fail-closed catch; preserve the read error (or distinguish it from a missing owner) so unreadable state returns true.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/coding-agent/src/core/session-lease.ts around line 277:
When `owner.json` cannot be read or parsed, `isSessionFileLeased` returns `false` and the ghost sweep may delete a session still owned by a live process. `readLeaseOwner` converts those errors to `undefined`, so line 278 treats the lease as absent instead of reaching the fail-closed `catch`; preserve the read error (or distinguish it from a missing owner) so unreadable state returns `true`.
Evidence trail:
Reviewed commit a07aef5: packages/coding-agent/src/core/session-lease.ts:86-101, 270-282; packages/coding-agent/src/modes/daemon/daemon-supervisor.ts:1322-1355; packages/coding-agent/src/core/session-ghost.ts:35-43
| let createdAtMs = Number.NaN; | ||
| try { | ||
| const entries = loadEntriesFromFile(path); | ||
| hasUserContent = sessionEntryTypesHaveUserContent(entries.map((entry) => entry.type)); |
There was a problem hiding this comment.
🟡 Medium core/session-manager.ts:827
--continue skips a newest session when its file contains only bootstrap entries followed by a malformed or truncated JSON line, potentially resuming an older session despite the newest session's persisted state being unknown. loadEntriesFromFile silently ignores that malformed line, so sessionEntryTypesHaveUserContent reports no user content; parse failures should instead make this pick path conservative and retain the session.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/coding-agent/src/core/session-manager.ts around line 827:
`--continue` skips a newest session when its file contains only bootstrap entries followed by a malformed or truncated JSON line, potentially resuming an older session despite the newest session's persisted state being unknown. `loadEntriesFromFile` silently ignores that malformed line, so `sessionEntryTypesHaveUserContent` reports no user content; parse failures should instead make this pick path conservative and retain the session.
Evidence trail:
Commit a07aef5
packages/coding-agent/src/core/session-manager.ts:581-587, 620-633, 787-809, 822-848
packages/coding-agent/src/core/session-ghost.ts:35-43
| createCompactionSummaryMessage, | ||
| createCustomMessage, | ||
| } from "./messages.js"; | ||
| import { isDisposableGhost } from "./session-ghost.js"; |
There was a problem hiding this comment.
🟡 Medium core/session-manager.ts:31
--continue can resume an older session instead of a newer session with queued input or a live lease. isDisposableGhost feeds isDisposableGhostSessionPick false for resident, attached, leased, hasQueuedInput, and hasScheduledJob despite those values being unknown on the daemon side, so the newer session is incorrectly classified as a disposable ghost. Preserve unknown state rather than representing it as false.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/coding-agent/src/core/session-manager.ts around line 31:
`--continue` can resume an older session instead of a newer session with queued input or a live lease. `isDisposableGhost` feeds `isDisposableGhostSessionPick` false for `resident`, `attached`, `leased`, `hasQueuedInput`, and `hasScheduledJob` despite those values being unknown on the daemon side, so the newer session is incorrectly classified as a disposable ghost. Preserve unknown state rather than representing it as `false`.
Evidence trail:
Reviewed commit a07aef5. packages/coding-agent/src/core/session-manager.ts:787-809, 815-848; packages/coding-agent/src/core/session-ghost.ts:1-43; packages/coding-agent/src/main.ts:497-499; packages/coding-agent/src/modes/daemon/daemon-mode.ts:1706-1712.
| modifiedAtMs: info.modified.getTime(), | ||
| resident: worker !== undefined || entry?.summary.activeSessionId !== undefined, | ||
| attached: (entry?.summary.attachedClients ?? 0) > 0, | ||
| leased: agentDir !== undefined && isSessionFileLeased(canonical, agentDir), |
There was a problem hiding this comment.
🟠 High daemon/daemon-supervisor.ts:1345
deleteVerifiedGhostSession can delete a session that becomes live after isSessionFileLeased returns false, causing an actively resumed session's transcript and artifacts to be removed. The lease check is released before catalog.delete, and the eviction fence does not block a foreign acquireSessionLease; acquire and hold the session lease through deletion (or revalidate under an equivalent exclusive guard).
Also found in 1 other location(s)
packages/coding-agent/src/core/session-lease.ts:276
isSessionFileLeasedreleaseswithLeaseGuardbefore returning its liveness result. The sweep subsequently acts on that stalefalseresult: an external process can acquire the lease and begin using the session after line 279 but before deletion, and the deleter will remove the file despite the newly live lease. The eviction fence does not cover foreign lease acquisition, so this can destroy an actively resumed session.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/coding-agent/src/modes/daemon/daemon-supervisor.ts around line 1345:
`deleteVerifiedGhostSession` can delete a session that becomes live after `isSessionFileLeased` returns `false`, causing an actively resumed session's transcript and artifacts to be removed. The lease check is released before `catalog.delete`, and the eviction fence does not block a foreign `acquireSessionLease`; acquire and hold the session lease through deletion (or revalidate under an equivalent exclusive guard).
Evidence trail:
Reviewed commit a07aef5:
- packages/coding-agent/src/modes/daemon/daemon-supervisor.ts:1305-1318, 1322-1367
- packages/coding-agent/src/core/session-lease.ts:203-249, 265-337
- packages/coding-agent/src/modes/daemon/daemon-mode.ts:1702-1717
- packages/coding-agent/src/core/session-file-actions.ts:58-73
Also found in 1 other location(s):
- packages/coding-agent/src/core/session-lease.ts:276 -- `isSessionFileLeased` releases `withLeaseGuard` before returning its liveness result. The sweep subsequently acts on that stale `false` result: an external process can acquire the lease and begin using the session after line 279 but before deletion, and the deleter will remove the file despite the newly live lease. The eviction fence does not cover foreign lease acquisition, so this can destroy an actively resumed session.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit a07aef5. Configure here.
| if (deleted.length > 0 && !this.stopped) this.refreshSavedSessionsIfLoaded(); | ||
| } catch { | ||
| // Deletion retries on a later agents-view entry. | ||
| } |
There was a problem hiding this comment.
Sweep never retries leftover ghosts
Medium Severity
maybeSweepGhostSessions records every candidate in attemptedGhostSweepPaths before the daemon accepts it. The command only deletes GHOST_SWEEP_MAX_PER_COMMAND (25) paths, and failed or unprocessed paths stay in that set, so later reconciles never resend them. The motivating case of 32 ghosts leaves leftovers on disk for the life of the view.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit a07aef5. Configure here.


What this does
Removes "(no messages)" ghost rows from the agents view and deletes the underlying stub files safely. Ghost session files (a header plus only the creation prefix) accumulate on disk — one reporter measured 32 of 53 files (#1990) — and today they render as inactive "(no messages)" rows and can shadow the real last conversation for
--continue(#1921, #1531).How it works
One predicate owner,
isDisposableGhost(core/session-ghost.ts), decides disposability. A session survives when ANY of these hold:sessionEntryTypesHaveUserContentrule, now also computed by the saved-session scan and carried on the wire ashasUserContent; unscannable lines count as content)Consumers:
sweep_ghost_sessionscommand (capabilityghost_session_sweep, schema revision 27; older daemons: rows hidden, no sweep).--continue: picks the newest non-ghost for the cwd instead of the newest mtime, closing [Bug] Undeletable "(no messages)" rows: the saved-session catalog skips the draft lifecycle filter #1921's second residual. Explicit--resume <selector>targets stay resolvable.Non-goals
Relation to prior work
Linear: RES-1274
Related: #1921, #1531, #1990, #1935, #1967
Wire compatibility
ghost_session_sweep(supervisor only). New client / old daemon: the view checks the capability and skips the sweep; rows are still hidden. Old client / new daemon:hasUserContentis an optional additive field.Size
Total src: +378/−25 (net +353); tests: +382/−2; changelog: +2.
Mechanism split (src): predicate owner 44; scan truth +
--continuegate 76/17 (session-manager); read-only lease check 20; wire 14 (protocol, saved-session-info, connection types, modes index); view partition + queue 104/1 (agents-view state+mode); fenced deleter + fence param 120/4 (supervisor).Tests
session-ghost.test.ts: one pin per exclusion (draft, stash, heartbeat-armed, child-bearing, resident/attached/leased, young session all survive; stale content-less stub is disposable).session-manager/file-operations.test.ts: scan truth pins (bare stub false, config-beyond-prefix true, unscannable-lines true) and--continuegating pins (stale ghost skipped fail-unfixed, fresh stub kept, all-ghost returns null).agents-view-state.test.ts: partition pins — stale ghost hidden and queued; unknown scan truth, resident, queued-input, heartbeat-armed (active and paused), child-bearing, and young rows stay visible; named first-run pin (just-created empty session is kept and never queued).daemon-supervisor-eviction.test.ts: deleter pins — only re-verified ghosts are deleted while every excluded candidate survives at delete time; transient EPERM retries; persistent failure keeps the file without failing the command.daemon-protocol.test.ts: schema-id digest pin plus capability/schema/mutating gate pin forsweep_ghost_sessions.Note
Sweep disposable ghost sessions from the agents view
sessionGhost.isDisposableGhostto identify empty session files older than 10 minutes with no user content, leases, or live children.sweep_ghost_sessionsdaemon command.EPERMorEBUSYerrors, and tombstones related state.findMostRecentSessionForCwdused by--continuenow skips stale ghost sessions, returning the newest real session or null.ghost_session_sweepcapability;--continuesession selection may now return different results when previous candidates were empty ghosts.Macroscope summarized f609ebc.
Note
Medium Risk
The change deletes on-disk session files, but only after conservative client filtering and daemon-side re-verification under the eviction fence; misclassification could still remove a dormant draft if lease/residency signals are stale.
Overview
Introduces a shared
isDisposableGhostpredicate (10-minute grace, fail-open on unknowns) so stale session files with only the creation prefix and no live claims can be hidden and removed safely.The agents view immediately drops matching saved rows and, when the daemon advertises
ghost_session_sweep(schema revision 27), queues paths for a backgroundsweep_ghost_sessionscommand. The supervisor re-checks each candidate under the idle-eviction fence (residency, attachments, read-only leases, queued input, schedules, spawn-ledger children) before deleting via the existing saved-session path, with bounded batches and transient unlink retries.Session scanning now computes
hasUserContentwith the shared prefix rule (unscannable lines count as content), and--continue/findMostRecentSessionForCwdskip disposable ghosts so empty stubs no longer shadow the newest real conversation.Reviewed by Cursor Bugbot for commit f609ebc. Bugbot is set up for automated code reviews on this repo. Configure here.