Skip to content

feat(coding-agent): sweep disposable ghost sessions from the agents view - #2055

Closed
snimu wants to merge 5 commits into
mainfrom
feat/agents-view-ghost-sweep-v2
Closed

feat(coding-agent): sweep disposable ghost sessions from the agents view#2055
snimu wants to merge 5 commits into
mainfrom
feat/agents-view-ghost-sweep-v2

Conversation

@snimu

@snimu snimu commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

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:

  • user content beyond the creation prefix (drafts with model/thinking/tier changes, names, labels — the shared sessionEntryTypesHaveUserContent rule, now also computed by the saved-session scan and carried on the wire as hasUserContent; unscannable lines count as content)
  • queued or stashed prompt input recorded for the session
  • an armed heartbeat or registered cron descriptor (durable artifact truth)
  • live spawn-ledger children
  • resident, attached, or leased (read-only lease check; never reclaims)
  • younger than a 10-minute grace window
  • unknown truth of any kind (older daemon/scan: everything is kept)

Consumers:

  1. Agents view: saved rows matching the predicate are dropped from render immediately; their paths are queued to a background sweep_ghost_sessions command (capability ghost_session_sweep, schema revision 27; older daemons: rows hidden, no sweep).
  2. Fenced deleter (supervisor): bounded batch per command; every path is re-verified with fresh daemon-side truth under the eviction fence at delete time, so a create admitted between the client's scan and the sweep wins. Deletion goes through the existing saved-session delete path (spawn-ledger tombstone, roster cleanup, trash-first). Windows-transient EPERM/EBUSY unlinks are retried; per-path failures only log — the view never surfaces a sweep error.
  3. --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

  • No daemon-idle periodic sweep: the sweep runs only when the agents view queues candidates.
  • No tombstone/trash mechanism beyond the existing delete path: the predicate makes loss impossible by construction — every clause fails toward keep, and delete-time re-verification under the fence covers the scan-to-delete window.

Relation to prior work

  • Supersedes closed feat(coding-agent): sweep empty ghost sessions when opening the agents view #1561; its review findings are addressed structurally: config-changed drafts survive via the shared prefix rule, the lease check is read-only (no reclaim race), delete-time re-verification replaces scan-time trust, and the command is in the supervisor allowlist behind a negotiated capability.
  • Resurrects the fenced-passivation concept from ENG-5809: decisions are made only after admitted mutations drain under the eviction fence.

Linear: RES-1274
Related: #1921, #1531, #1990, #1935, #1967

Wire compatibility

Size

Total src: +378/−25 (net +353); tests: +382/−2; changelog: +2.

Mechanism split (src): predicate owner 44; scan truth + --continue gate 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 --continue gating 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 for sweep_ghost_sessions.

Note

Sweep disposable ghost sessions from the agents view

  • Adds sessionGhost.isDisposableGhost to identify empty session files older than 10 minutes with no user content, leases, or live children.
  • The agents view hides these ghost rows immediately and queues them for background deletion via the new sweep_ghost_sessions daemon command.
  • The daemon re-verifies all liveness conditions before deleting files, retrying transient EPERM or EBUSY errors, and tombstones related state.
  • findMostRecentSessionForCwd used by --continue now skips stale ghost sessions, returning the newest real session or null.
  • Behavioral Change: Bumps daemon schema to revision 27 with the new ghost_session_sweep capability; --continue session 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 isDisposableGhost predicate (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 background sweep_ghost_sessions command. 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 hasUserContent with the shared prefix rule (unscannable lines count as content), and --continue / findMostRecentSessionForCwd skip 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.

…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).
@snimu
snimu marked this pull request as ready for review September 5, 2026 09:50
}
try {
return withLeaseGuard(directory, () => {
const owner = readLeaseOwner(directory);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 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));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 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

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.

🚀 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.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ 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.
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a07aef5. Configure here.

@snimu
snimu marked this pull request as draft September 5, 2026 10:15
@snimu snimu closed this Sep 5, 2026
@snimu
snimu deleted the feat/agents-view-ghost-sweep-v2 branch September 5, 2026 10:29
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.

1 participant