* fix(sync): preserve pending questions across session switch and directory eviction Closes #918, completes the gap left by #909. The 'agent question disappears after switching session / coming back later' bug had two root causes that #909 only partially addressed: 1. Directory-eviction TTL (20 min) silently dropped child stores that held pending questions/permissions. The discard wasn't gated on in-flight blocking-request state, so any 'question.asked' event that arrived during the eviction-then-rehydrate window was routed to a non-existent store and silently lost. 2. PR #909 re-fetches listPendingQuestions/Permissions only on SSE reconnect. Switching sessions within the same socket — including navigating back to a directory whose child store was rebuilt after eviction — left the UI relying on store state that may have missed events that fired while a different session was active. Three edits, in src/sync: - eviction.ts / types.ts / child-store.ts: add hasPendingBlockingRequests to EvictPlan + DisposeCheck and never evict a directory whose store carries a non-empty state.question or state.permission record. - sync-context.tsx: extract resyncBlockingRequestsForDirectory from the reconnect path and call it on currentSessionId changes (debounced 250ms), reusing PR #909's signature-based merge so concurrent SSE updates aren't clobbered. - __tests__/eviction.test.ts, __tests__/session-switch-resync.test.ts: new unit coverage for the eviction guard and resync semantics (deduped fetch per switch, in-flight SSE preservation, stale entry cleanup, unknown-session filtering). * fix(sync): refresh store before blocking request resync --------- Co-authored-by: Alexander Busse <alex@ableph.net> Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
54 lines
2.0 KiB
TypeScript
54 lines
2.0 KiB
TypeScript
import type { DisposeCheck, EvictPlan, State } from "./types"
|
|
|
|
/**
|
|
* Returns true when the directory's child store holds at least one pending
|
|
* blocking request — a question awaiting an answer or a permission awaiting
|
|
* approval. Such directories must never be evicted, otherwise the SSE-routed
|
|
* request data is lost and the user can never satisfy the agent.
|
|
*
|
|
* Tracks the same `state.question` / `state.permission` shape that
|
|
* {@link bootstrapDirectory} re-hydrates on a fresh `ensureChild` call —
|
|
* an empty record key (e.g. after a `question.replied` event clears the
|
|
* array) is treated as "no pending requests" so a fully resolved directory
|
|
* remains a normal eviction candidate.
|
|
*/
|
|
export function hasPendingBlockingRequests(state: State | undefined): boolean {
|
|
if (!state) return false
|
|
for (const list of Object.values(state.question ?? {})) {
|
|
if (list && list.length > 0) return true
|
|
}
|
|
for (const list of Object.values(state.permission ?? {})) {
|
|
if (list && list.length > 0) return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
export function pickDirectoriesToEvict(input: EvictPlan) {
|
|
const overflow = Math.max(0, input.stores.length - input.max)
|
|
let pendingOverflow = overflow
|
|
const sorted = input.stores
|
|
.filter((dir) => !input.pins.has(dir))
|
|
.filter((dir) => !input.hasPendingBlockingRequests?.(dir))
|
|
.slice()
|
|
.sort((a, b) => (input.state.get(a)?.lastAccessAt ?? 0) - (input.state.get(b)?.lastAccessAt ?? 0))
|
|
const output: string[] = []
|
|
for (const dir of sorted) {
|
|
const last = input.state.get(dir)?.lastAccessAt ?? 0
|
|
const idle = input.now - last >= input.ttl
|
|
if (!idle && pendingOverflow <= 0) continue
|
|
output.push(dir)
|
|
if (pendingOverflow > 0) pendingOverflow -= 1
|
|
}
|
|
return output
|
|
}
|
|
|
|
export function canDisposeDirectory(input: DisposeCheck) {
|
|
if (!input.directory) return false
|
|
if (!input.hasStore) return false
|
|
if (input.pinned) return false
|
|
if (input.booting) return false
|
|
if (input.loadingSessions) return false
|
|
if (input.hasPendingBlockingRequests) return false
|
|
return true
|
|
}
|