Skip to content

Commit 1a58c80

Browse files
heavygeecursoragent
andcommitted
fix(scratchlist): transfer rows during session merge so cascade-delete does not strand them (closes tiann#920 for v2.0)
`mergeSessionData` in `sessionCache.ts` ends every merge codepath with `deleteSession(oldSessionId)`, which fires `ON DELETE CASCADE` on every FK-tied table. `session_scratchlist.session_id` is FK'd with cascade, so without an explicit transfer step every dedup (tiann#448 agent-id collision) and every resume-of-inactive (`syncEngine.resumeSession` -> mergeSessions) silently destroys the operator's per-session notes. This is the gap upstream-discovery agent flagged on tiann#920 against PR tiann#896. With the 2026-06-15 hub-restart cascade incident as evidence (23 sessions auto-archived in a single bounce, 4 confirmed HAPI-id rotations across 2 bounces), unmitigated this would violate v2.0's "survives reloads / second laptop / clear-site-data" promise the first time the operator hits a hub bounce. Fix: - New `transferScratchlistEntries(db, fromSessionId, toSessionId)` in `hub/src/store/scratchlist.ts`. Atomic via BEGIN/COMMIT. Uses `UPDATE OR IGNORE` so rows that would collide on PRIMARY KEY (session_id, entry_id) simply do not move - the dedup target's copy wins, matching the operator's mental model that the consolidated session is authoritative. Cleans up any collision-loser rows so the no-delete codepath (`mergeSessionHistory`) is symmetric with the delete path. - Wired into `mergeSessionData` BEFORE the `deleteSession()` call, alongside the existing message-merge step. Both `mergeSessions` (deleteOld=true) and `mergeSessionHistory` (deleteOld=false) get coverage because both can rotate the visible session id. - Emits `session-updated{scratchlistUpdatedAt}` on the new session so any web client looking at the consolidated id invalidates and refetches; for the keep-old codepath the emit also fires on the old id since it stays alive but is now empty of scratchlist. Tests (`sessionCache-merge-scratchlist.test.ts`, 7 cases): - mergeSessions (deleteOld=true): rows move, old is gone, no stranded rows. - mergeSessions PK collision: dedup target wins, unique-to-old rows still come across. - mergeSessions SSE: exactly one scratchlist patch on the new id. - mergeSessions no-op: zero rows -> zero emits. - mergeSessionHistory (deleteOld=false): rows move, old session stays alive but empty of scratchlist. - mergeSessionHistory SSE: emits on BOTH old and new ids. - Cascade-delete safety smoke: post-merge, an explicit operator delete of the new session DOES cascade-delete its scratchlist (i.e. the FK cascade we want is intact; the bug was triggering it on the wrong id). Web layer note: v1 localStorage is keyed by HAPI session id; on rotation the old key is orphaned but no longer represents data loss because the hub now holds the canonical state and the offline-cache mirror re-populates `hapi.scratchlist.v1.<newId>` on first read of the consolidated session. Documented as a known limitation; not a blocker for v2.0 because the hub is the source of truth. tiann#894 (v2.1 migrate-on-delete) inherits a related concern about operator-Delete vs merge-Delete consent flow - flagged in the upstream-discovery handoff, separate scope. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 0fab1df commit 1a58c80

4 files changed

Lines changed: 288 additions & 0 deletions

File tree

hub/src/store/scratchlist.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,3 +179,62 @@ export function deleteScratchlistEntry(
179179
).run(sessionId, entryId)
180180
return result.changes > 0
181181
}
182+
183+
/**
184+
* Re-point all scratchlist rows from `fromSessionId` to `toSessionId`.
185+
*
186+
* Required by tiann/hapi#920: `mergeSessionData` in `sessionCache.ts`
187+
* deletes the old session row at the end of every merge codepath, and
188+
* `session_scratchlist.session_id` is FK'd with `ON DELETE CASCADE`.
189+
* Without an explicit transfer step, scratchlist entries are silently
190+
* destroyed every time the hub dedups two sessions or rotates the HAPI
191+
* id during resume - both of which fire on the upstream
192+
* hub-restart-cascade path documented in tiann/hapi#915.
193+
*
194+
* Strategy:
195+
* 1. `UPDATE OR IGNORE` the session_id column. Rows that would
196+
* collide with an existing (toSessionId, entry_id) PK simply do
197+
* not move - the dedup target's copy wins, which matches the
198+
* operator's mental model that the consolidated session is the
199+
* authoritative one.
200+
* 2. `DELETE` whatever didn't move. This is the collision-loser
201+
* cleanup; the cascade from `deleteSession` would do the same
202+
* thing, but doing it here makes the no-delete codepath
203+
* (`mergeSessionHistory`) symmetric and avoids leaving stale
204+
* duplicates on the old row when it stays alive.
205+
*
206+
* Wrapped in BEGIN/COMMIT so the move is atomic w.r.t. concurrent
207+
* writers. Returns counts so the caller can decide whether to fire
208+
* SSE patches.
209+
*/
210+
export function transferScratchlistEntries(
211+
db: Database,
212+
fromSessionId: string,
213+
toSessionId: string
214+
): { moved: number; collided: number } {
215+
if (fromSessionId === toSessionId) {
216+
return { moved: 0, collided: 0 }
217+
}
218+
219+
try {
220+
db.exec('BEGIN')
221+
const before = db.prepare(
222+
'SELECT COUNT(*) AS n FROM session_scratchlist WHERE session_id = ?'
223+
).get(fromSessionId) as { n: number } | undefined
224+
const total = before?.n ?? 0
225+
const moved = db.prepare(
226+
'UPDATE OR IGNORE session_scratchlist SET session_id = ? WHERE session_id = ?'
227+
).run(toSessionId, fromSessionId).changes
228+
const collided = total - moved
229+
if (collided > 0) {
230+
db.prepare(
231+
'DELETE FROM session_scratchlist WHERE session_id = ?'
232+
).run(fromSessionId)
233+
}
234+
db.exec('COMMIT')
235+
return { moved, collided }
236+
} catch (error) {
237+
db.exec('ROLLBACK')
238+
throw error
239+
}
240+
}

hub/src/store/scratchlistStore.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
deleteScratchlistEntry,
88
getScratchlistEntry,
99
listScratchlistEntries,
10+
transferScratchlistEntries,
1011
updateScratchlistEntry,
1112
type CreateScratchlistResult
1213
} from './scratchlist'
@@ -49,4 +50,15 @@ export class ScratchlistStore {
4950
delete(sessionId: string, entryId: string): boolean {
5051
return deleteScratchlistEntry(this.db, sessionId, entryId)
5152
}
53+
54+
/**
55+
* Re-point rows during a session merge. See
56+
* `transferScratchlistEntries` for the contract; the wrapper just
57+
* forwards through. Must be called BEFORE `deleteSession` so
58+
* `ON DELETE CASCADE` on `session_scratchlist.session_id` doesn't
59+
* race the migration. Required by tiann/hapi#920.
60+
*/
61+
transfer(fromSessionId: string, toSessionId: string): { moved: number; collided: number } {
62+
return transferScratchlistEntries(this.db, fromSessionId, toSessionId)
63+
}
5264
}
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
import { describe, expect, it } from 'bun:test'
2+
import type { SyncEvent } from '@hapi/protocol/types'
3+
import { Store } from '../store'
4+
import type { EventPublisher } from './eventPublisher'
5+
import { SessionCache } from './sessionCache'
6+
7+
/**
8+
* Regression tests for tiann/hapi#920: scratchlist rows must survive
9+
* the `mergeSessionData` codepath in `SessionCache`.
10+
*
11+
* Background: `mergeSessionData` ends with `deleteSession(oldSessionId)`
12+
* which fires `ON DELETE CASCADE` on every FK-tied table. The
13+
* `session_scratchlist` table joins on `sessions(id)` with cascade
14+
* delete, so without an explicit transfer step every dedup
15+
* (#448 agent-id collision) and every resume-of-inactive path
16+
* (`syncEngine.resumeSession`) silently destroys the operator's notes.
17+
*
18+
* Two codepaths:
19+
* - `mergeSessions(old, new, ns)` -> `mergeSessionData(deleteOld=true)`
20+
* - `mergeSessionHistory(old, new, ns, opts)` -> `mergeSessionData(deleteOld=false)`
21+
*
22+
* Both must transfer scratchlist rows. We pin both with their own
23+
* happy-path test plus a PK-collision test (same `entryId` on both
24+
* sides; the dedup target wins).
25+
*/
26+
27+
function createCapturingPublisher(events: SyncEvent[]): EventPublisher {
28+
return {
29+
emit: (event: SyncEvent) => {
30+
events.push(event)
31+
}
32+
} as unknown as EventPublisher
33+
}
34+
35+
function setup() {
36+
const store = new Store(':memory:')
37+
const events: SyncEvent[] = []
38+
const cache = new SessionCache(store, createCapturingPublisher(events))
39+
return { store, events, cache }
40+
}
41+
42+
function makeSessions(cache: SessionCache, ns: string = 'default') {
43+
const oldSession = cache.getOrCreateSession(
44+
'agent-merge-old-' + Math.random().toString(36).slice(2, 8),
45+
{ path: '/tmp/project', host: 'localhost', flavor: 'codex' },
46+
null,
47+
ns
48+
)
49+
const newSession = cache.getOrCreateSession(
50+
'agent-merge-new-' + Math.random().toString(36).slice(2, 8),
51+
{ path: '/tmp/project', host: 'localhost', flavor: 'codex' },
52+
null,
53+
ns
54+
)
55+
return { oldSession, newSession }
56+
}
57+
58+
describe('mergeSessions (deleteOldSession=true) - scratchlist transfer', () => {
59+
it('moves scratchlist rows from old to new before the cascade-delete fires', async () => {
60+
const { store, cache } = setup()
61+
const { oldSession, newSession } = makeSessions(cache)
62+
63+
store.scratchlist.create(oldSession.id, 'note one', { entryId: 'e-1', createdAt: 100 })
64+
store.scratchlist.create(oldSession.id, 'note two', { entryId: 'e-2', createdAt: 200 })
65+
66+
await cache.mergeSessions(oldSession.id, newSession.id, 'default')
67+
68+
// New session now owns the rows.
69+
const onNew = store.scratchlist.list(newSession.id).map((e) => e.entryId).sort()
70+
expect(onNew).toEqual(['e-1', 'e-2'])
71+
72+
// Old session is gone (deleteOldSession=true) AND its rows
73+
// are not stranded on a phantom session id.
74+
expect(store.scratchlist.list(oldSession.id)).toEqual([])
75+
})
76+
77+
it('handles entryId PK collision by keeping the dedup target row (operator-visible session wins)', async () => {
78+
const { store, cache } = setup()
79+
const { oldSession, newSession } = makeSessions(cache)
80+
81+
// Both sessions have an entry at the same id - the new wins.
82+
store.scratchlist.create(oldSession.id, 'OLD copy', { entryId: 'shared-id', createdAt: 100 })
83+
store.scratchlist.create(newSession.id, 'NEW copy', { entryId: 'shared-id', createdAt: 200 })
84+
store.scratchlist.create(oldSession.id, 'unique to old', { entryId: 'old-only', createdAt: 50 })
85+
86+
await cache.mergeSessions(oldSession.id, newSession.id, 'default')
87+
88+
const final = store.scratchlist.list(newSession.id)
89+
const byId = new Map(final.map((e) => [e.entryId, e.text]))
90+
expect(byId.get('shared-id')).toBe('NEW copy')
91+
expect(byId.get('old-only')).toBe('unique to old')
92+
expect(final).toHaveLength(2)
93+
})
94+
95+
it('emits scratchlistUpdatedAt on the new session (and not on the old one - it is about to be removed)', async () => {
96+
const { store, events, cache } = setup()
97+
const { oldSession, newSession } = makeSessions(cache)
98+
store.scratchlist.create(oldSession.id, 'note', { entryId: 'e-1', createdAt: 100 })
99+
events.length = 0
100+
101+
await cache.mergeSessions(oldSession.id, newSession.id, 'default')
102+
103+
const scratchPatches = events.filter((e) => {
104+
return e.type === 'session-updated'
105+
&& typeof e.data === 'object' && e.data !== null
106+
&& 'scratchlistUpdatedAt' in (e.data as Record<string, unknown>)
107+
})
108+
// Exactly one - on the new session id.
109+
expect(scratchPatches).toHaveLength(1)
110+
expect(scratchPatches[0]!.type === 'session-updated' && scratchPatches[0]!.sessionId).toBe(newSession.id)
111+
})
112+
113+
it('is a no-op (no extra emit) when the old session has no scratchlist rows', async () => {
114+
const { events, cache } = setup()
115+
const { oldSession, newSession } = makeSessions(cache)
116+
events.length = 0
117+
118+
await cache.mergeSessions(oldSession.id, newSession.id, 'default')
119+
120+
const scratchPatches = events.filter((e) => {
121+
return e.type === 'session-updated'
122+
&& typeof e.data === 'object' && e.data !== null
123+
&& 'scratchlistUpdatedAt' in (e.data as Record<string, unknown>)
124+
})
125+
expect(scratchPatches).toHaveLength(0)
126+
})
127+
})
128+
129+
describe('mergeSessionHistory (deleteOldSession=false) - scratchlist transfer', () => {
130+
it('moves scratchlist rows even when the old session row stays alive', async () => {
131+
const { store, cache } = setup()
132+
const { oldSession, newSession } = makeSessions(cache)
133+
134+
store.scratchlist.create(oldSession.id, 'still-need-this', { entryId: 'e-1', createdAt: 100 })
135+
136+
// Active-duplicate codepath: keeps the live socket but moves
137+
// the persisted history into the dedup target. Scratchlist
138+
// is "persisted history" for this purpose.
139+
await cache.mergeSessionHistory(oldSession.id, newSession.id, 'default', { mergeAgentState: false })
140+
141+
expect(store.scratchlist.list(newSession.id).map((e) => e.entryId)).toEqual(['e-1'])
142+
// Old row is still alive but its scratchlist is empty - the
143+
// operator-facing dedup target is now the source of truth.
144+
expect(store.scratchlist.list(oldSession.id)).toEqual([])
145+
})
146+
147+
it('emits scratchlistUpdatedAt on BOTH the new and the still-alive old session id', async () => {
148+
const { store, events, cache } = setup()
149+
const { oldSession, newSession } = makeSessions(cache)
150+
store.scratchlist.create(oldSession.id, 'note', { entryId: 'e-1', createdAt: 100 })
151+
events.length = 0
152+
153+
await cache.mergeSessionHistory(oldSession.id, newSession.id, 'default', { mergeAgentState: false })
154+
155+
const scratchPatches = events.filter((e) => {
156+
return e.type === 'session-updated'
157+
&& typeof e.data === 'object' && e.data !== null
158+
&& 'scratchlistUpdatedAt' in (e.data as Record<string, unknown>)
159+
})
160+
// Two emits: one per session id, so any client looking at
161+
// either side invalidates and refetches.
162+
const ids = scratchPatches
163+
.map((e) => e.type === 'session-updated' ? e.sessionId : '')
164+
.sort()
165+
expect(ids).toEqual([oldSession.id, newSession.id].sort())
166+
})
167+
})
168+
169+
describe('cascade-delete safety (regression)', () => {
170+
it('without the transfer, the cascade would have nuked them - confirm by deleting the new session at the end', async () => {
171+
// This is a smoke test for the ON DELETE CASCADE on
172+
// `session_scratchlist.session_id` itself: after the merge
173+
// moves rows to the new session and the new session is
174+
// later deleted (e.g. operator clicks Delete), the rows
175+
// disappear too. This is the cascade we DO want; the bug
176+
// is that the merge codepath was triggering it on the OLD
177+
// id while the operator expected the data to follow the
178+
// new id.
179+
const { store, cache } = setup()
180+
const { oldSession, newSession } = makeSessions(cache)
181+
store.scratchlist.create(oldSession.id, 'note', { entryId: 'e-1', createdAt: 100 })
182+
183+
await cache.mergeSessions(oldSession.id, newSession.id, 'default')
184+
expect(store.scratchlist.list(newSession.id)).toHaveLength(1)
185+
186+
// Now an explicit operator-driven delete of the new session.
187+
// Mark it inactive first because deleteSession refuses to
188+
// delete an active session.
189+
const cached = cache.getSession(newSession.id)
190+
if (cached) cached.active = false
191+
await cache.deleteSession(newSession.id)
192+
expect(store.scratchlist.list(newSession.id)).toEqual([])
193+
})
194+
})

hub/src/sync/sessionCache.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -732,6 +732,29 @@ export class SessionCache {
732732
this.publisher.emit({ type: 'messages-invalidated', sessionId: newSessionId, namespace })
733733
}
734734

735+
// tiann/hapi#920: transfer scratchlist rows BEFORE the
736+
// deleteSession() call below fires `ON DELETE CASCADE` on
737+
// `session_scratchlist.session_id`. Without this step every
738+
// dedup (#448 agent-id collision) and every resume-of-inactive
739+
// path (`syncEngine.resumeSession` -> here) silently destroys
740+
// the operator's per-session notes, contradicting the v2.0
741+
// promise that scratchlist survives reloads.
742+
const movedScratchlist = this.store.scratchlist.transfer(oldSessionId, newSessionId)
743+
if (movedScratchlist.moved > 0) {
744+
// Fire on the new id so any remote-control web client
745+
// looking at the consolidated session re-fetches and shows
746+
// the migrated rows. For the keep-old codepath
747+
// (`mergeSessionHistory`) the old session also stays
748+
// visible but is now empty of scratchlist - emit so its
749+
// viewer's cache invalidates too. For the delete codepath
750+
// we skip the old emit because `session-removed` is about
751+
// to fire and the new id is where the operator lands.
752+
this.emitScratchlistChanged(newSessionId)
753+
if (!options.deleteOldSession) {
754+
this.emitScratchlistChanged(oldSessionId)
755+
}
756+
}
757+
735758
const mergedMetadata = this.mergeSessionMetadata(oldStored.metadata, newStored.metadata)
736759
if (mergedMetadata !== null && mergedMetadata !== newStored.metadata) {
737760
for (let attempt = 0; attempt < 2; attempt += 1) {

0 commit comments

Comments
 (0)