Skip to content

fix(sessions): expiry goes through the one session teardown, and stops leaking worktrees - #125

Merged
Lexus2016 merged 2 commits into
mainfrom
fix/session-expiry-teardown
Oct 3, 2026
Merged

Lexus2016 merged 2 commits into
mainfrom
fix/session-expiry-teardown

Conversation

@Lexus2016

Copy link
Copy Markdown
Owner

SESSION_TTL_DAYS ran a bare DELETE FROM sessions. Every expired session's git
worktree and ccs/session-* branch stayed in data/worktrees with no row left to own
it, and its queued_messages (no FK) were restored at every boot. Reported by a
third-party Docker image that had to prune the trees in its own entrypoint.

The three doors that delete sessions (DELETE, bulk delete, expiry) each carried
their own copy, and they had drifted: the single delete never killed the terminal
pane either. teardownSessions(rows, { deleteTasks }) is now the only code that
deletes a session; sessionHasUnmergedWork() is the one gate the doors ask first.

Expiry is unattended, so it skips a running session, a live terminal pane and a
worktree holding unmerged or uncommitted work (an unreadable tree counts as work),
and keeps the chat's Kanban cards (deleteTasks: false; the FK unlinks them).

WM.pruneOrphanWorktrees() reconciles the trees earlier versions leaked: only trees
no sessions/tasks/task_chains row owns, older than an hour, clean and merged, and
removed without --force. It checks for data/worktrees before probing git, so an
install with no worktrees spawns nothing at boot.

test/session-expiry.test.js boots a real server twice against one APP_DIR and ages
sessions in SQLite between the boots; reverting expiry to the bare DELETE fails 6
of its 17 checks.

…s leaking worktrees

SESSION_TTL_DAYS ran a bare `DELETE FROM sessions`. Every expired session's git
worktree and ccs/session-* branch stayed in data/worktrees with no row left to own
it, and its queued_messages (no FK) were restored at every boot. Reported by a
third-party Docker image that had to prune the trees in its own entrypoint.

The three doors that delete sessions (DELETE, bulk delete, expiry) each carried
their own copy, and they had drifted: the single delete never killed the terminal
pane either. teardownSessions(rows, { deleteTasks }) is now the only code that
deletes a session; sessionHasUnmergedWork() is the one gate the doors ask first.

Expiry is unattended, so it skips a running session, a live terminal pane and a
worktree holding unmerged or uncommitted work (an unreadable tree counts as work),
and keeps the chat's Kanban cards (deleteTasks: false; the FK unlinks them).

WM.pruneOrphanWorktrees() reconciles the trees earlier versions leaked: only trees
no sessions/tasks/task_chains row owns, older than an hour, clean and merged, and
removed without --force. It checks for data/worktrees before probing git, so an
install with no worktrees spawns nothing at boot.

test/session-expiry.test.js boots a real server twice against one APP_DIR and ages
sessions in SQLite between the boots; reverting expiry to the bare DELETE fails 6
of its 17 checks.
…etter case

Found by an adversarial review of the expiry teardown: an expiring chain session
could take a live chain's tree (task_chains was not counted as a holder), and on a
case-insensitive disk a row stored under a differently-cased path read as an orphan.
Both are pinned in test/session-expiry.test.js and fail when reverted.
@Lexus2016
Lexus2016 merged commit 376bc61 into main Oct 3, 2026
2 checks passed
@Lexus2016
Lexus2016 deleted the fix/session-expiry-teardown branch October 3, 2026 11:19
Lexus2016 added a commit that referenced this pull request Oct 7, 2026
… unknown

hasUnmergedWork() (hardened in the previous commit to fail closed) also failed
closed on a unit whose directory AND branch were both gone. That is the normal end
state of an auto-merged task or chain: removeWorktree() deletes both, while the
session row keeps git_root/workdir/git_branch — nothing clears them. Such sessions
read as holding unmerged work, so expiry kept them forever (the #125 leak, now for
merged sessions) and a manual delete asked for a confirmation about nothing.

When the directory is gone, ask git whether the branch still exists. for-each-ref
answers 'absent' with an empty success, so a git FAILURE still throws and still
reads as unknown — an unreadable project keeps its protection. Real unmerged
commits on a branch whose directory vanished are still reported.
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