Skip to content

fix(sessions): detach child sessions on delete to avoid FK rollback#710

Open
laofoot wants to merge 1 commit into
fathah:mainfrom
laofoot:fix/session-delete-parent-fk
Open

fix(sessions): detach child sessions on delete to avoid FK rollback#710
laofoot wants to merge 1 commit into
fathah:mainfrom
laofoot:fix/session-delete-parent-fk

Conversation

@laofoot

@laofoot laofoot commented Jun 17, 2026

Copy link
Copy Markdown

Problem (fixes #276)

Deleting a session can silently fail: the row disappears optimistically, then reappears on refresh, with no error. Selecting all and deleting can delete nothing.

Root cause (verified on a real state.db)

The agent backend stores subagent runs / branches as child sessions linked via a self-referential FK:

CREATE TABLE sessions ( ... parent_session_id TEXT, ... FOREIGN KEY (parent_session_id) REFERENCES sessions(id) );

better-sqlite3 enables PRAGMA foreign_keys=ON by default. deleteSessionRows deletes messages then sessions, but never handles rows referencing the session via parent_session_id. So deleting a parent that still has a child throws FOREIGN KEY constraint failed. Because deleteSession / deleteSessions run inside a single transaction, the violation rolls back the whole transaction → 0 rows deleted. The renderer only console.errors the rejection, so the UI shows no error. A "select all → delete" almost always contains a parent→child pair, so the entire batch deletes nothing.

Reproduces only with FK enforcement ON (the app default). sqlite3 CLI defaults foreign_keys=OFF, which masks it — explaining the "CLI shows no sessions left, GUI still has them" reports in #276.

Fix

In deleteSessionRows, detach child sessions (UPDATE sessions SET parent_session_id = NULL WHERE parent_session_id = ?) before deleting, guarded by a column-existence check for older schemas. Children are preserved (not cascade-deleted), so a session the user didn't explicitly select is never removed.

Notes / open questions for maintainers

  • Cascade vs detach: the backend treats subagent runs as "cascade-delete targets" (ephemeral) while keeping branches. This PR takes the conservative, non-destructive route (detach all children) so no unselected session is deleted. Happy to switch ephemeral children to cascade if you prefer.
  • Silent failures: deleteSessions returns {requested, deleted} but the renderer ignores it. Surfacing deleted < requested would make future failures visible (left out of this PR to keep it focused).
  • Listing: separately, subagent child sessions currently appear in the session list (and feat(sessions): show sub-task indicator for agent-delegated sessions #685 adds a badge for them), whereas the backend's own list queries hide them. That's an orthogonal show-vs-hide design choice; this PR only fixes deletion.

Testing

Reproduced the FK rollback on a real state.db (parent with a subagent child); confirmed the detach-then-delete sequence deletes the parent successfully under foreign_keys=ON. tsc clean for the change.

deleteSessionRows deleted messages + the session row but never handled
rows referencing it via the self-referential FK parent_session_id ->
sessions.id (set by the agent backend for subagent runs / branches).

better-sqlite3 enables PRAGMA foreign_keys=ON by default, so deleting a
parent that still has a child threw 'FOREIGN KEY constraint failed'.
deleteSession/deleteSessions run inside one transaction, so the violation
rolled back the entire transaction -> 0 rows deleted, with no surfaced
error (the renderer only console.errors it). A select-all delete almost
always contains a parent->child pair, so the whole batch deleted nothing.

Detach children (UPDATE parent_session_id = NULL) before deleting, guarded
by a column-existence check for older schemas. Children are preserved
(not cascade-deleted) so a session the user didn't select is never removed.

Closes fathah#276
@greptile-apps

greptile-apps Bot commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a silent FK rollback bug where deleting a parent session (one with child sessions linked via parent_session_id) caused the entire delete transaction to roll back, leaving the row in place with no visible error. The fix adds a hasParentSessionColumn schema-introspection helper and a pre-delete UPDATE … SET parent_session_id = NULL step inside deleteSessionRows to detach children before removing the parent.

  • Root-cause fix is correct: the detach-before-delete sequence resolves the FK constraint violation under PRAGMA foreign_keys=ON for both single and batch deletes.
  • Module-level column cache (sessionsHasParentColumn) is process-global and ignores the db argument; if the column is absent at the time of the first call (e.g., before a migration runs, or in tests) the cache latches to false and the fix is never applied for the rest of the process lifetime.
  • Shallow detach only: the UPDATE disconnects direct children but leaves grandchildren (deeper subagent trees) with a dangling reference to the now-deleted intermediate parent, which may cause UI confusion in multi-level session trees.

Confidence Score: 4/5

Safe to merge for the primary fix; the detach-then-delete path is logically correct for single-level parent→child relationships, which is the common case.

The core delete logic is correct and directly resolves the described FK rollback. The two findings are both non-blocking: the module-level column cache could cause the fix to be silently skipped if a migration runs mid-process (unlikely in normal desktop operation), and the shallow-only detach leaves multi-level subagent trees in a partially orphaned state. Neither finding reverses the benefit of the fix for the common case, but both are worth a follow-up.

src/main/sessions.ts — specifically the sessionsHasParentColumn module-level cache and the single-level-only detach behavior in deleteSessionRows.

Important Files Changed

Filename Overview
src/main/sessions.ts Adds hasParentSessionColumn helper (with module-level cache) and a pre-delete UPDATE … SET parent_session_id = NULL in deleteSessionRows to prevent FK constraint failures when a parent session still has children referencing it

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant R as Renderer
    participant M as Main (sessions.ts)
    participant DB as SQLite (state.db)

    R->>M: deleteSession(parentId) / deleteSessions([ids])
    M->>DB: BEGIN TRANSACTION

    loop for each sessionId
        M->>DB: deletePromptImageAttachments(sessionId)
        M->>DB: deleteSessionContinuation(sessionId)
        alt parent_session_id column exists
            M->>DB: "UPDATE sessions SET parent_session_id = NULL WHERE parent_session_id = sessionId"
            DB-->>M: child rows detached
        end
        M->>DB: "DELETE FROM messages WHERE session_id = sessionId"
        M->>DB: "DELETE FROM sessions WHERE id = sessionId"
        DB-->>M: row deleted (no FK violation)
    end

    M->>DB: COMMIT
    DB-->>M: success
    M-->>R: "{requested, deleted}"
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant R as Renderer
    participant M as Main (sessions.ts)
    participant DB as SQLite (state.db)

    R->>M: deleteSession(parentId) / deleteSessions([ids])
    M->>DB: BEGIN TRANSACTION

    loop for each sessionId
        M->>DB: deletePromptImageAttachments(sessionId)
        M->>DB: deleteSessionContinuation(sessionId)
        alt parent_session_id column exists
            M->>DB: "UPDATE sessions SET parent_session_id = NULL WHERE parent_session_id = sessionId"
            DB-->>M: child rows detached
        end
        M->>DB: "DELETE FROM messages WHERE session_id = sessionId"
        M->>DB: "DELETE FROM sessions WHERE id = sessionId"
        DB-->>M: row deleted (no FK violation)
    end

    M->>DB: COMMIT
    DB-->>M: success
    M-->>R: "{requested, deleted}"
Loading

Comments Outside Diff (1)

  1. src/main/sessions.ts, line 793-797 (link)

    P2 Child-detach inside a shared transaction can silently orphan sessions the user chose to delete

    When deleteSessions is called with [parentId, childId] and the parent is processed first, the loop runs UPDATE … SET parent_session_id = NULL WHERE parent_session_id = parentId, which detaches every child — including childId. Then when deleteSessionRows is called for childId, the child is deleted normally, so no data is lost in this case.

    However, if the user selects only the parent and the child just happens to be a grandparent of other sessions, the detach-then-delete chain unlinks all direct children but those children may still hold their own children. Those grandchildren retain a now-NULL parent_session_id but their intermediate parent row is gone. This is an edge case in a multi-level tree, and the PR explicitly acknowledges the conservative detach approach; worth a comment or a note in a follow-up to handle recursive ancestry if subagent trees can be deeper than one level.

Reviews (1): Last reviewed commit: "fix(sessions): detach child sessions bef..." | Re-trigger Greptile

Comment thread src/main/sessions.ts
Comment on lines +729 to +743
let sessionsHasParentColumn: boolean | null = null;
function hasParentSessionColumn(db: Database.Database): boolean {
if (sessionsHasParentColumn !== null) return sessionsHasParentColumn;
try {
sessionsHasParentColumn =
db
.prepare(
"SELECT 1 FROM pragma_table_info('sessions') WHERE name = 'parent_session_id'",
)
.get() != null;
} catch {
sessionsHasParentColumn = false;
}
return sessionsHasParentColumn;
}

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.

P2 Module-level cache ignores the db argument

sessionsHasParentColumn is a process-singleton, but hasParentSessionColumn accepts a db argument. Two problems follow:

  1. Schema migration after first call: if the column doesn't exist at startup, the cache is set to false. A migration that later adds parent_session_id (during the same process lifetime) won't be detected, so the detach step is silently skipped and the FK error returns.
  2. Tests or multiple DB paths: any test suite that opens a second database without parent_session_id — or vice versa — will get the wrong cached answer for every subsequent call.

A simple fix is to key the cache on the database filename (db.name) or to clear sessionsHasParentColumn = null whenever the active database handle changes.

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.

can't delete session

1 participant