You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
hub: mergeSessionData transfers a fixed field list then cascade-deletes the old session row, silently dropping any FK-tied data not enumerated (blocks scratchlist v2 #893 / migrate-on-delete #894 data-preservation guarantees) #920
mergeSessionData (in hub/src/sync/sessionCache.ts) explicitly transfers a fixed enumerated list of session-scoped fields from the old session row to the new one, then calls deleteSession(oldSessionId) which fires SQLite ON DELETE CASCADE on any FK-tied table. Anything not in the explicit transfer list is silently dropped on every code path that triggers a merge - which today includes the agent-session-id dedup path from PR #448 and the reopen path from syncEngine.resumeSession.
This is a blocker for scratchlist v2 (#893) and the migrate-on-delete consent flow (#894). The session_scratchlist table planned in #893 will be silently cascade-dropped on every cursor session resume / hub-restart rotation unless mergeSessionData is updated to transfer scratchlist rows before the delete fires. And #894's consent flow protects only the operator-clicked-Delete path; the merge path bypasses consent entirely while doing the same destructive deleteSession().
This issue is analysis only - establishing the gap and the contract #893/#894 need from the merge codepath. No fix proposed.
Concrete data loss observed (2026-06-13 / 2026-06-15)
A HAPI operator observed the following session-id rotation events tied to hub bounces and resume-of-inactive-session flows:
event
cursor session id
HAPI before
HAPI after
bounce state
2026-06-13 16:52:55 hub bounce
a890acd1 (orchestrator)
55637443
decb7284
idle at bounce
2026-06-13 16:52:55 hub bounce
59ee41f5 (v2 peer)
b2efd739
58e5bcac
idle at bounce
2026-06-13 16:52:55 hub bounce
d5dd3aa0 (Fix B' peer)
7b422b92
7b422b92
active at bounce
2026-06-15 (another bounce)
a890acd1 (orchestrator)
decb7284
41e954af
idle at bounce
Pattern: inactive sessions get a fresh HAPI session row on the resume that follows the bounce; active sessions keep their HAPI id. The old HAPI row is deleted as part of the merge.
Runner log evidence (~/.hapi/logs/2026-06-13-16-57-52-pid-61435.log line 6):
[START] Reporting session 58e5bcac-415d-4342-b420-4426654e4cbb to runner
new UUID 58e5bcac, not the existing b2efd739.
Operator-observable consequence: localStorage scratchlist v1 entries (from #777) keyed under b2efd739, 55637443, decb7284 are stranded on every rotation. They never migrate because the web migration code only sees the new HAPI id; it has no signal that an old id was rotated into a new one for the same underlying cursor session.
Why the rotation itself is intentional - and the gap that isn't
The session id rotation is the deliberate behavior of PR #448 (fix(hub,web): deduplicate sessions by agent session ID, merged 2026-04-13). When the spawned CLI reports an agent session id (cursorSessionId, codexSessionId, etc.) that already exists on another HAPI row in the same namespace, the hub merges old into new via mergeSessions(oldId, newId). This is a correctness fix - it prevents duplicate HAPI rows for the same underlying agent thread.
The gap is not the rotation. It is the incomplete transfer policy of mergeSessionData.
What mergeSessionData actually does (upstream/main 93d00414, lines 682-813)
Transferred from old → new beforedeleteSession(oldId) fires:
messages (via mergeSessionMessages)
metadata (via mergeSessionMetadata)
model / modelReasoningEffort / effort (preserved if new is null)
todos (copied)
agentState (merged)
teamState (copied)
Then deleteSession(oldId) is called (line 794), which:
fires ON DELETE CASCADE on every FK-tied table in the schema
emits session-removed for the old id
evicts the old row from in-memory cache
Anything FK-tied that is not explicitly enumerated above is silently dropped. Anything out-of-DB keyed by the old HAPI session id (web localStorage, peer-ping handoff stores, custom dashboards) hears session-removed for the old id but has no signal that this is a rotation into a new id rather than a real terminal delete.
The v2 plan introduces session_scratchlist with FOREIGN KEY (sessionId) REFERENCES sessions(id) ON DELETE CASCADE. If #893 ships without first addressing this gap, scratchlist entries will be silently cascade-dropped on every hub-bounce resume / dedup rotation, defeating the durability goal of v2 ("survive reloads, second laptop, clear-site-data, etc."). The v2.0 acceptance criterion ("entries survive across devices") will be technically met but practically violated by the merge-delete path.
The v2.1 design says: when the operator deletes a session that has scratchlist entries, offer a migrate-and-summarize consent flow. This protection applies only to the operator-clicked-Delete code path. The merge path calls the same deleteSession() without operator consent and without invoking the migrate flow - so scratchlist entries are dropped without summarization, without the new-session-with-context spawn, and without the operator ever seeing the modal.
For #894 to deliver its actual promise (no scratchlist work is lost without operator consent), the contract has to apply to all deletion code paths - including the merge step.
What this gap needs from the maintainer (analysis only - no fix proposed)
Establishing what must be true, not how to implement it:
Every session-scoped table with a FK-cascade on sessions.id declares a merge policy: transfer to newId (default for operator-state tables like scratchlist), or drop with old session (only when the data is semantically tied to the old row, not the agent thread).
mergeSessionData enumerates and executes the declared transfer policy for each FK-tied table before calling deleteSession(oldId). New tables added to the schema in future must register their policy or fail-loud at startup so additions can't silently regress.
Out-of-DB consumers keyed by HAPI session id (web localStorage and similar) get a distinguishable signal for "rotation: oldId → newId, copy your state across" vs "terminal delete: drop everything." The current session-removed event is ambiguous - both cases use it.
tracking: scratchlist v2.1 - delete-session with summarize-and-migrate flow #894's migrate-on-delete consent UX is gated on whether the deletion is operator-initiated or merge-initiated. Operator-initiated retains the consent prompt; merge-initiated runs the transfer policy declared above without prompting (transfer is non-destructive and silent is correct for the user-invisible rotation case).
Reproducer
Start any cursor session, let it become inactive (no messages for the cache TTL window, or explicit archive + reopen).
Note its HAPI session id and cursorSessionId from hapi.db: sqlite3 ~/.hapi/hapi.db "SELECT id, json_extract(metadata, '\$.cursorSessionId') FROM sessions WHERE id='<sid>'".
Bounce the hub (systemctl restart hapi-hub.service) OR wait for the inactive timer to fire OR trigger a CLI-side rotation that emits a duplicate cursorSessionId.
Click Reopen / send a message that triggers resume.
Re-query hapi.db for the same cursorSessionId: the row id is different. Original row is gone.
HAPI upstream/main 93d00414 (codepath verified across mergeSessionData, deleteSession, FK schema)
Linux, multiple cursor flavor sessions; behavior is flavor-independent since the dedup is keyed by whatever agent-session-id field the flavor uses
Disclosure: this issue was drafted by Claude Sonnet 4.6 (model claude-sonnet-4-5) acting as the upstream-discovery peer agent in a multi-agent HAPI fork workflow. Operator orchestrator agent (HAPI session 41e954af-5fd8-466f-ae20-299fbddcdfef) observed the operator-visible symptoms and handed off for triage. Per the AI-assisted contributions policy in CONTRIBUTING.md.
Summary
mergeSessionData(inhub/src/sync/sessionCache.ts) explicitly transfers a fixed enumerated list of session-scoped fields from the old session row to the new one, then callsdeleteSession(oldSessionId)which fires SQLiteON DELETE CASCADEon any FK-tied table. Anything not in the explicit transfer list is silently dropped on every code path that triggers a merge - which today includes the agent-session-id dedup path from PR #448 and the reopen path fromsyncEngine.resumeSession.This is a blocker for scratchlist v2 (#893) and the migrate-on-delete consent flow (#894). The
session_scratchlisttable planned in #893 will be silently cascade-dropped on every cursor session resume / hub-restart rotation unlessmergeSessionDatais updated to transfer scratchlist rows before the delete fires. And #894's consent flow protects only the operator-clicked-Delete path; the merge path bypasses consent entirely while doing the same destructivedeleteSession().This issue is analysis only - establishing the gap and the contract #893/#894 need from the merge codepath. No fix proposed.
Concrete data loss observed (2026-06-13 / 2026-06-15)
A HAPI operator observed the following session-id rotation events tied to hub bounces and resume-of-inactive-session flows:
a890acd1(orchestrator)55637443decb728459ee41f5(v2 peer)b2efd73958e5bcacd5dd3aa0(Fix B' peer)7b422b927b422b92a890acd1(orchestrator)decb728441e954afPattern: inactive sessions get a fresh HAPI session row on the resume that follows the bounce; active sessions keep their HAPI id. The old HAPI row is deleted as part of the merge.
Runner log evidence (
~/.hapi/logs/2026-06-13-16-57-52-pid-61435.logline 6):58e5bcac, not the existingb2efd739.Operator-observable consequence: localStorage scratchlist v1 entries (from #777) keyed under
b2efd739,55637443,decb7284are stranded on every rotation. They never migrate because the web migration code only sees the new HAPI id; it has no signal that an old id was rotated into a new one for the same underlying cursor session.Why the rotation itself is intentional - and the gap that isn't
The session id rotation is the deliberate behavior of PR #448 (
fix(hub,web): deduplicate sessions by agent session ID, merged 2026-04-13). When the spawned CLI reports an agent session id (cursorSessionId,codexSessionId, etc.) that already exists on another HAPI row in the same namespace, the hub merges old into new viamergeSessions(oldId, newId). This is a correctness fix - it prevents duplicate HAPI rows for the same underlying agent thread.The gap is not the rotation. It is the incomplete transfer policy of
mergeSessionData.What
mergeSessionDataactually does (upstream/main93d00414, lines 682-813)Transferred from old → new before
deleteSession(oldId)fires:mergeSessionMessages)mergeSessionMetadata)Then
deleteSession(oldId)is called (line 794), which:ON DELETE CASCADEon every FK-tied table in the schemasession-removedfor the old idAnything FK-tied that is not explicitly enumerated above is silently dropped. Anything out-of-DB keyed by the old HAPI session id (web localStorage, peer-ping handoff stores, custom dashboards) hears
session-removedfor the old id but has no signal that this is a rotation into a new id rather than a real terminal delete.Why this blocks #893 / #894
#893 (scratchlist v2 - hub sync via typed table)
The v2 plan introduces
session_scratchlistwithFOREIGN KEY (sessionId) REFERENCES sessions(id) ON DELETE CASCADE. If #893 ships without first addressing this gap, scratchlist entries will be silently cascade-dropped on every hub-bounce resume / dedup rotation, defeating the durability goal of v2 ("survive reloads, second laptop, clear-site-data, etc."). The v2.0 acceptance criterion ("entries survive across devices") will be technically met but practically violated by the merge-delete path.#894 (scratchlist v2.1 migrate-on-delete)
The v2.1 design says: when the operator deletes a session that has scratchlist entries, offer a migrate-and-summarize consent flow. This protection applies only to the operator-clicked-Delete code path. The merge path calls the same
deleteSession()without operator consent and without invoking the migrate flow - so scratchlist entries are dropped without summarization, without the new-session-with-context spawn, and without the operator ever seeing the modal.For #894 to deliver its actual promise (no scratchlist work is lost without operator consent), the contract has to apply to all deletion code paths - including the merge step.
What this gap needs from the maintainer (analysis only - no fix proposed)
Establishing what must be true, not how to implement it:
sessions.iddeclares a merge policy: transfer to newId (default for operator-state tables like scratchlist), or drop with old session (only when the data is semantically tied to the old row, not the agent thread).mergeSessionDataenumerates and executes the declared transfer policy for each FK-tied table before callingdeleteSession(oldId). New tables added to the schema in future must register their policy or fail-loud at startup so additions can't silently regress.session-removedevent is ambiguous - both cases use it.Reproducer
cursorSessionIdfromhapi.db:sqlite3 ~/.hapi/hapi.db "SELECT id, json_extract(metadata, '\$.cursorSessionId') FROM sessions WHERE id='<sid>'".systemctl restart hapi-hub.service) OR wait for the inactive timer to fire OR trigger a CLI-side rotation that emits a duplicatecursorSessionId.hapi.dbfor the samecursorSessionId: the row id is different. Original row is gone.Related
Environment
93d00414(codepath verified acrossmergeSessionData,deleteSession, FK schema)Disclosure: this issue was drafted by Claude Sonnet 4.6 (model
claude-sonnet-4-5) acting as the upstream-discovery peer agent in a multi-agent HAPI fork workflow. Operator orchestrator agent (HAPI session41e954af-5fd8-466f-ae20-299fbddcdfef) observed the operator-visible symptoms and handed off for triage. Per the AI-assisted contributions policy in CONTRIBUTING.md.