Skip to content

fix(desktop): preserve agent timer state across workspace switches#1679

Merged
wpfleger96 merged 3 commits into
mainfrom
wpfleger/timer-workspace-switch
Jul 9, 2026
Merged

fix(desktop): preserve agent timer state across workspace switches#1679
wpfleger96 merged 3 commits into
mainfrom
wpfleger/timer-workspace-switch

Conversation

@wpfleger96

@wpfleger96 wpfleger96 commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator

Problem

Agent activity timer badges in the sidebar reset to zero when switching workspaces and switching back. resetWorkspaceState() in useWorkspaceInit.ts clears the activeAgentTurnsStore on every workspace switch, including the startedAt timestamps that anchor elapsed-time display. On switching back, turns are re-created by resurrectTurn() using the liveness event's timestamp as the new startedAt, so the timer counts from the moment of reconnection rather than the original turn start.

Fix

Add saveActiveAgentTurnsForWorkspace / restoreActiveAgentTurnsForWorkspace / clearSavedWorkspaceSnapshot to activeAgentTurnsStore.ts that snapshot and recover the four module-level maps:

  • activeTurnsByAgent — turn records with original startedAt timestamps
  • clockOffsetByAgent — calibrated per-agent clock-skew estimates
  • lastProcessed — per-agent observer event watermarks
  • terminalAtByAgent — terminal tombstones for completed turns

useWorkspaceInit.ts saves the outgoing workspace state before resetWorkspaceState() and restores the incoming workspace state after applyWorkspace() resolves. useWorkspaces.tsx calls clearSavedWorkspaceSnapshot in removeWorkspace alongside the existing relay-specific GC.

Edge cases

Scenario Behaviour
Turn completes while on another workspace turn_completed event arrives on reconnect → endTurn() removes the restored turn
New turn starts while away turn_started arrives → startTurn() creates it normally
Agent crashes while away No liveness → lastActivityAt refresh gives a 25 s grace window, then prune clears it
Stale events replayed after restore Watermark preserved → already-processed events filtered before reaching state
Completed turn + stale liveness after restore Tombstones preserved → stale liveness cannot resurrect a finished turn
Turn completes before save (turns map empty) Tombstone map non-empty → snapshot still saved so tombstones guard the round-trip
Rapid A→B→C switch (B's apply not yet resolved) prevWorkspaceIdRef nulled immediately after save → C's effect can't re-save the empty store and delete A's snapshot
Workspace deleted clearSavedWorkspaceSnapshot called in removeWorkspace → no memory leak for deleted workspaces
restore called with non-empty maps Maps cleared at top of restore → self-contained, no merge with stale state

Tests

9 new tests in the workspace-switch save / restore suite (2266 total, 0 failing):

  • Round-trip preserves original anchorAt (the core bug regression)
  • No-op restore with no snapshot (includes no-notification assertion)
  • Empty save discards prior snapshot
  • Snapshot consumed on first restore
  • Watermark preserved across save/restore
  • Terminal tombstones preserved across save/restore
  • Listeners notified after restore
  • Multiple independent workspace snapshots
  • clearSavedWorkspaceSnapshot makes restore a no-op

pnpm test: 2266 pass, 0 fail. just desktop-check and just desktop-build clean.

resetWorkspaceState() wiped the activeAgentTurnsStore on every workspace
switch, losing the startedAt timestamps that anchor the elapsed-time
badges. Switching A→B→A re-anchored timers to "just now" via resurrectTurn
instead of restoring the original start.

Add saveActiveAgentTurnsForWorkspace / restoreActiveAgentTurnsForWorkspace
to snapshot and recover all four module maps (activeTurnsByAgent,
clockOffsetByAgent, lastProcessed, terminalAtByAgent) around the reset.
useWorkspaceInit saves the outgoing workspace before resetWorkspaceState()
and restores the incoming workspace after applyWorkspace() resolves.

lastActivityAt is refreshed on restore so turns saved 25+ s ago are not
immediately pruned before new liveness events arrive. Tombstones are
preserved so completed turns cannot be resurrected by a stale liveness
frame arriving after the round-trip. The watermark is preserved so
already-processed events are not replayed. The snapshot is consumed on
first use; empty saves (no turns, no tombstones) discard prior snapshots.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
@wpfleger96 wpfleger96 requested a review from a team as a code owner July 9, 2026 16:37
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 and others added 2 commits July 9, 2026 12:46
Two fixes from Thufir review pass 1:

- useWorkspaceInit: null out prevWorkspaceIdRef.current immediately
  after saving the outgoing workspace's snapshot. Previously the ref
  stayed set to the outgoing workspace ID across a cancelled/failed
  incoming switch; on the next re-fire the save path would see the
  now-empty store and call the empty-store branch, deleting the saved
  snapshot. Nulling after save prevents the stale-ref overwrite.

- activeAgentTurnsStore: collapse restoreActiveAgentTurnsForWorkspace
  signature onto one line to satisfy Biome formatter (fixes Desktop Core
  CI failure).

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Address five code-review findings:

- Add clearSavedWorkspaceSnapshot() and wire it into removeWorkspace()
  alongside the existing relay-specific GC calls so snapshots for
  permanently-deleted workspaces don't sit in memory indefinitely.

- Make restoreActiveAgentTurnsForWorkspace self-contained by clearing all
  four module maps before writing from the snapshot. Callers no longer
  need to pre-clear as a precondition; the function is now safe against
  the narrow async window between resetWorkspaceState() and the restore.

- Add doc comment to resetActiveAgentTurnsStore clarifying that
  savedByWorkspace is intentionally excluded — snapshots must survive
  the reset that runs between save and restore.

- Add no-notification assertion to the no-op restore test so a future
  unconditional notifyListeners() regression is caught immediately.

- Refine the prevWorkspaceIdRef null-out comment to describe the actual
  rapid-switch (A→B→C before B resolves) scenario rather than the
  less-precise cancelled/failed framing.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
@wpfleger96 wpfleger96 merged commit 46613dc into main Jul 9, 2026
25 checks passed
@wpfleger96 wpfleger96 deleted the wpfleger/timer-workspace-switch branch July 9, 2026 17:38
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