Skip to content

fix(sessions): persist queued follow-ups across restart and offline#3267

Closed
gantoine wants to merge 1 commit into
mainfrom
posthog-code/persist-queued-followups
Closed

fix(sessions): persist queued follow-ups across restart and offline#3267
gantoine wants to merge 1 commit into
mainfrom
posthog-code/persist-queued-followups

Conversation

@gantoine

@gantoine gantoine commented Jul 8, 2026

Copy link
Copy Markdown
Member

Problem

A follow-up message queued while a cloud agent was running was lost on app restart, and an offline follow-up was thrown away entirely:

  • messageQueue lived only in the in-memory, non-persisted session store — a restart wiped it with no rehydration path.
  • sendPrompt threw on offline before enqueuing, so an offline follow-up surfaced only as a toast and was gone.

Changes

  • New durable queuedMessageStore (electronStorage-backed), mirrored from the core session store via a subscription so every queue mutation is captured — including the several UI call sites that mutate the store directly (dock remove, steer/queue toggle, cancel-into-editor) that a service-seam wrapper would miss. Reconciles-before-mirroring per task so a freshly-created empty session can't wipe not-yet-seeded messages; rehydrates persisted follow-ups when a session reappears.
  • Offline cloud follow-ups queue (and persist) instead of being thrown away; the composer stays enabled with a clarifying tooltip.
  • Non-destructive local drainsendQueuedMessages rolls back via prependQueuedMessages on send failure instead of silently dropping messages.
  • Durable queue + tracking wiped on logout / project switch (avoids leaking follow-ups across accounts).

Tests

Queued-message store cap/clear/empty-removal, offline cloud queueing. Typecheck and Biome pass.

Note

Independent of (and splittable from) the cloud-run resilience PR.


Created with PostHog Code

Queued follow-up messages no longer die with the in-memory session store
on restart, and an offline follow-up is queued instead of thrown away.

- New durable queuedMessageStore (electronStorage-backed), mirrored from
  the core session store via a subscription so every queue mutation is
  captured — including the several UI call sites that mutate the store
  directly (dock remove, steer/queue toggle, cancel-into-editor) that a
  service-seam wrapper would miss. Reconciles-before-mirroring per task
  so a freshly-created empty session can't wipe not-yet-seeded messages;
  rehydrates persisted follow-ups when a session reappears.
- Offline cloud follow-ups now queue (and persist) instead of being
  thrown away before enqueue; the composer stays enabled with a
  clarifying tooltip.
- Local queued-message drain rolls back via prependQueuedMessages on send
  failure instead of silently dropping messages.
- Durable queue and its tracking are wiped on logout / project switch.

Tests: queued-message store cap/clear, offline cloud queueing.

Generated-By: PostHog Code
Task-Id: 7e03ab1f-2d6d-4b85-b425-9f39906bd1b1
@trunk-io

trunk-io Bot commented Jul 8, 2026

Copy link
Copy Markdown

Merging to main in this repository is managed by Trunk.

  • To merge this pull request, check the box to the left or comment /trunk merge below.

After your PR is submitted to the merge queue, this comment will be automatically updated with its status. If the PR fails, failure details will also be posted here

@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown

React Doctor found no issues in the changed files. 🎉

Reviewed by React Doctor for commit 5e0c364.

@gantoine gantoine marked this pull request as draft July 8, 2026 13:20
@greptile-apps

greptile-apps Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Reviews (1): Last reviewed commit: "fix(sessions): persist queued follow-ups..." | Re-trigger Greptile

Comment on lines +104 to +110
onRehydrateStorage: () => (state, error) => {
if (error) {
useQueuedMessageStore.getState().setHasHydrated(true);
} else {
state?.setHasHydrated(true);
}
},

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.

P1 _hasHydrated never set on first run or cleared storage

When electronStorage has no data for this key (first run or post-clearAll), zustand-persist calls onRehydrateStorage with (undefined, undefined) — no error, but state is also undefined. The else branch evaluates state?.setHasHydrated(true) as a no-op, so _hasHydrated stays false forever. Every subsequent call to whenHydrated() in reconcileTask creates a store subscriber that never resolves and is never garbage-collected — one per session creation, accumulating for the process lifetime.

Suggested change
onRehydrateStorage: () => (state, error) => {
if (error) {
useQueuedMessageStore.getState().setHasHydrated(true);
} else {
state?.setHasHydrated(true);
}
},
onRehydrateStorage: () => (state, error) => {
if (error || !state) {
useQueuedMessageStore.getState().setHasHydrated(true);
} else {
state.setHasHydrated(true);
}
,

Comment on lines +42 to +52
it("clears a single task and clears all", () => {
queuedMessageStoreApi.set("task-1", [msg("a")]);
queuedMessageStoreApi.set("task-2", [msg("b")]);

queuedMessageStoreApi.clear("task-1");
expect(queuedMessageStoreApi.get("task-1")).toEqual([]);
expect(queuedMessageStoreApi.get("task-2").map((m) => m.id)).toEqual(["b"]);

queuedMessageStoreApi.clearAll();
expect(useQueuedMessageStore.getState().byTaskId).toEqual({});
});

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 Two independent behaviours in one it block

The "clears a single task and clears all" test verifies both clear(taskId) and clearAll() in sequence; a failure in the first assertion makes the second unreachable and the root cause ambiguous. Splitting these into two tests (and preferring parameterised form for the cap-boundary cases, per the project's convention) would make test failures self-describing.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@gantoine gantoine closed this Jul 13, 2026
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