feat(studio): emit sdk_absent_read_recovery around the stale-tree fallback - #4843
vanceingalls wants to merge 3 commits into
Conversation
…lback The acceptance metric chosen for #4370 (absent-reads-per-tab, p50/max) never moved — re-checked 4 times over a week against 25+ versions and ~1200 tabs, p50 sat at 1.0 on every single version, fix included. That metric can't prove or disprove the fix: it's dominated by agent-driven sessions (100% of absent-read events in the last 7 days carry PostHog's bot flag — expected, this product is built for agents) where more than one absent read per tab was already the norm before the fix existed, so there was never a "loop" baseline to show improvement against. This adds the metric that can answer it directly. `useSdkSession` already has the one state transition that matters: `onAbsentRead` fires once per (projectId, path) when a read hits `reason: "absent"` (the trigger), and the open effect's next successful read for that identity is the only possible "it healed" signal (the resolution). Both sides were silent. `sdk_absent_read_recovery` now fires twice around that window: - `{ stage: "triggered" }` from `handleReadFailure`, alongside the existing `onAbsentRead` call. - `{ stage: "recovered", elapsed_ms }` from the open effect's success branch, via `reportAbsentRecoveryIfPending`, keyed off a `Map<string, number>` recording when the trigger fired. A path that never recovers just never emits the second event — visible directly as a gap between triggered and recovered counts, no inference required. 3 new tests: triggered fires alongside onAbsentRead, recovered fires with a numeric elapsed_ms on the next successful read, and no recovery event for a read that never went absent. 51/51 across the three touched test files, oxlint/oxfmt/tsc clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
somanshreddy
left a comment
There was a problem hiding this comment.
Verdict at e33e194d: Request changes. This is telemetry-only, so it can't hurt users. The blocker is the data. The PR exists to finally answer "does #4370's fallback fix the stale tree?", and as built the triggered-vs-recovered count can't answer that. It would most likely report a working fix as "rarely recovers". Two independent passes found this: a Codex review of the raw PR, and mine. I checked both at source.
Blocking
B1. recovered measures "this path became readable again", not "the fallback fixed the stale tree".
- The fallback is
onAbsentRead→refreshFileTree(useStudioSdkSessions.ts:29). It only updates the file list and the composition list (useFileTree.ts:76-105), and it starts no SDK read. - The open effect deliberately leaves
fileTreeout of its deps (useSdkSession.ts:548-556, deps[projectId, activeCompPath, reloadToken]). So a refresh never leads to the same-path re-read thatreportAbsentRecoveryIfPendingwaits for. - In the case #4370 targets (an agent deletes a composition and the SSE is missed), success means the tree stops listing the file.
CompositionMissingBannertells the user to "Select another composition". No same-path read ever succeeds, so the working fix is counted as unrecovered. - On the master view the miscount is automatic. After the refresh,
masterCompPathmoves to the next composition (App.tsx:116), the hook's key changes, and the original trigger is orphaned. - The other way round,
recoveredfires whenever the file comes back and something else re-reads it: an SSE reload viareloadSdkSession,forceReload, or reselecting it. Those are the paths the fallback is a backup for, so arecovereddoesn't show the fallback did anything.
B2. triggered also fires from the second, callback-less useSdkSession instance, so one incident counts twice.
DesignPanelPromoteProvider.tsx:54opens its own session,useSdkSession(projectId, targetPath), with noonAbsentRead. ItstargetPathdefaults toactiveCompPath, the same file as the primary session, and it's mounted by the normal property panel (StudioRightPanels.tsx:184).handleReadFailurerecords the pending entry and emitstriggeredbefore the optionalonAbsentRead?.()(useSdkSession.ts:414-416). So one absent file can emit two identicaltriggeredevents, and only one of them had a fallback.- The events carry no source or attempt id, so PostHog can't separate the two. The denominator ends up counting hook reads, not fallback attempts.
Suggested shape (one option):
- Emit
triggeredonly whenonAbsentReadis present, or addsource: "primary" | "secondary". - Measure the outcome the fallback controls. The hook already receives
fileTree/fileTreeLoaded, so an effect on those props can check each pending key. When a refreshed tree no longer lists the path, emitstage: "tree_corrected", withelapsed_msfromperformance.now(). Keep the same-path-read-succeeded event only if it's useful, under a name that says what it is.
Should-fix
-
The tests pass with broken pairing. I ran these mutants at head with
NODE_ENV=testvitest onuseSdkSession.lifecycle.test.tsx(27 tests):Mutant Result Remove the triggeredemit2 tests fail Remove the reportAbsentRecoveryIfPendingcall1 test fails Recovery ignores the key (matches any pending entry) all pass Never delete the pending entry, so recoveredre-fires on every later successall pass Hardcode elapsed_ms: 0all pass The double-count mutant matters most because it breaks exactly the count comparison this PR depends on. Assert exact call counts and order, a second success that does not re-emit, a different path or project not recovering, and
elapsed_msunder fake timers. The recovery test also passes noonAbsentReadand recovers through a manualforceReload. That's the false-positive route from B1, pinned as the expected behaviour. -
The pending map outlives a project switch.
refreshedAbsentPathsRefis cleared onprojectIdchange (:383), butpendingAbsentRecoveryRefisn't.- A→B→A where the file reappears reports an
elapsed_msthat includes the whole time spent in B. - A re-trigger after returning overwrites the first timestamp, giving 2 triggers for at most 1 recovery.
- Permanently absent keys pile up for the hook's lifetime. That's small, but unbounded.
- A→B→A where the file reappears reports an
-
elapsed_msuses wall-clockDate.now(). Tab suspension or clock adjustments skew it. The rest of this file usesperformance.now()(e.g.fetchStarted).
Nits
recoveredfires right afterread.ok, beforeopenCompositionand the owner-current check. A file that comes back but is unparseable emitsrecoveredand thensdk_session_unavailable. Rename it to "read succeeded", or emit after install.- The
${projectId}:${path}key is ambiguous once either part contains:. #4829 is making colon project names openable, so("a:b","c")and("a","b:c")collide. The existingrefreshedAbsentPathsRefkey has the same issue.
Verified
- Destination: unchanged. It goes through
trackStudioEventto the existingphc_zjjb…US ingest, and the common props carryagent_runtimeandtab_id, so per-tab and bot splits work. - Tests: 27/27 in the touched file pass locally. Mutation results are in the table above.
- CI: required checks are green at this head.
Studio: edit accuracyshards 1–8 were still pending when I posted. - Not done: no live PostHog query, and I didn't run the whole studio suite locally.
— Somu
Edit accuracy: 530 passing here, 530 on the base branchThe gate passes. |
…-path read Review on #4843 (Somu, two independent passes including Codex) found the original design would most likely report a WORKING fix as "rarely recovers" — both findings verified against source before this fix: B1: `onAbsentRead` → `refreshFileTree` starts no read (confirmed in useFileTree.ts — it only updates the fetched file/composition lists), and the open effect deliberately excludes `fileTree` from its deps. So the fallback's actual target scenario (an agent deletes the file, the SSE is missed) never produces a same-path read success: the tree just stops listing the path and CompositionMissingBanner tells the user to pick another composition. The old `recovered` stage would have scored that as unrecovered. On the master view it's worse — a refresh rotates `masterCompPath` to the next composition (App.tsx), orphaning the original trigger's key entirely. B2: DesignPanelPromoteProvider opens a second, callback-less `useSdkSession` targeting the same path as the primary session whenever nothing is selected (confirmed at its call site) — `triggered` fired from both, with no field to tell PostHog's two identical events apart. Both fixed, and extracted into `useAbsentReadRecoveryTelemetry` (also keeps useSdkSession.ts under the 600-line filesize gate): - `triggerOnce` now only does the once-per-path bookkeeping and emits `triggered` when `onAbsentRead` is actually provided — the callback-less secondary session no longer fires at all. - The success half is now `stage: "tree_corrected"`, fired from an effect over `fileTree`/`fileTreeLoaded` that checks every still-pending path against the CURRENT tree, independent of which path `useSdkSession` happens to be reading right now. That's the one thing the fallback actually controls. - `elapsed_ms` now comes from `performance.now()` (monotonic), not `Date.now()` (wall-clock, skews under tab suspension or clock changes). - Pending keys are path-only, not `${projectId}:${path}` — the composite key could stringify two different (projectId, path) pairs identically once either contains `:` (a real near-term risk: #4829 opens colon- containing project names). Safe without the prefix because the map is fully cleared on every `projectId` change, so only one project's entries are ever live. That clear now covers both the dedup set and the pending map — only the former was cleared before, so a pending entry could survive a project switch and either inflate its own `elapsed_ms` with idle time spent on the other project, or collide with an unrelated path reused there. 9 new/rewritten tests, 2 manually mutation-checked against this head (removing the `onAbsentRead` guard, removing the delete-after-emit) — both caught by the existing assertions before being reverted. 56/56 across the three touched test files. oxlint/oxfmt/tsc clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Both blocking findings confirmed against source and fixed, plus the should-fix items and the key-collision nit: B1 (measuring the wrong thing): verified in B2 (double-count): verified at Should-fix:
Nits: the premature-emit nit is moot now (tree_corrected doesn't depend on read/parse at all). Key collision fixed — dropped the Extracted the whole tracker into 56/56 across the three touched test files, oxlint/oxfmt/tsc clean. |
…e's comment share CI's comment-ratchet gate flagged it at 18.0% against the package's 10.8% — cut the per-function rationale down to one line each; the invariant is in the commit history and the PR thread, not owed to every reader of the file. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Summary
Follow-up to #4370 (the stale-file-tree fix). Spent a week checking PostHog for proof the fix was working and couldn't get one — not because the fix doesn't work, but because the metric I picked (absent-reads-per-tab, p50/max) can't tell the difference. Re-checked 4 times across 25+ versions and ~1200 tabs over 7+ days; p50 sat at exactly 1.0 on every single version, fix included, no cliff anywhere. Turned out this product's traffic on that event is 100% bot-flagged (agent-driven Studio sessions — expected, "built for agents"), where more than one absent read per tab was already the norm before #4370 ever existed. There was never a "stuck loop" baseline in that metric to show improvement against, so no amount of waiting was going to produce a verdict.
This adds the metric that can actually answer it.
Review (Somu, two independent passes) found the first version of this would have most likely reported a working fix as "rarely recovers". Both findings verified against source, then fixed:
onAbsentRead→refreshFileTree) starts no read — it only updates the file tree. So "a later read of the same path succeeded" is the wrong signal; in the fallback's own target scenario (file deleted, SSE missed), no same-path read ever happens again. Recovery is now measured on the TREE: a new effect checks every pending path against the currentfileTree/fileTreeLoaded, independent of which path the hook is currently reading — so it also handles the master-view case where a refresh rotates to the next composition.useSdkSessioninstance (DesignPanelPromoteProvider) was double-firingtriggeredfor the same absent path. The trigger now no-ops entirely without anonAbsentReadcollaborator.sdk_absent_read_recoverynow fires twice around the fallback:{ stage: "triggered" }when an absent read fires the once-per-path tree-refresh fallback.{ stage: "tree_corrected", elapsed_ms }when that path actually drops out of the file tree.A path that never recovers just never emits the second event — a direct count comparison instead of an inference from a noisy aggregate.
Before
Telemetry-only change — no UI to screenshot. These cards show the event-shape bug the review caught and the fix: the old design paired
triggeredwith a same-path read succeeding, which the fallback's own target scenario never produces.After
The corrected pairing:
triggeredalongside the fallback call,tree_correctedwhen the tree itself confirms the path is gone — the thing the fallback actually controls.Test plan
onAbsentReadguard, removing the delete-after-emit) — both caught by the suite before revertinguseSdkSession.lifecycle.test.tsx,useExternalFileChangeCoordinator.test.tsx,CompositionMissingBanner.test.tsxoxlint,oxfmt --check,tsc --noEmitclean, comment-ratchet clean🤖 Generated with Claude Code