Skip to content

feat(studio): emit sdk_absent_read_recovery around the stale-tree fallback - #4843

Open
vanceingalls wants to merge 3 commits into
mainfrom
feat/absent-read-recovery-telemetry
Open

vanceingalls wants to merge 3 commits into
mainfrom
feat/absent-read-recovery-telemetry

Conversation

@vanceingalls

@vanceingalls vanceingalls commented Oct 1, 2026 •

Copy link
Copy Markdown
Collaborator

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:

  • The fallback (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 current fileTree/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.
  • A second, callback-less useSdkSession instance (DesignPanelPromoteProvider) was double-firing triggered for the same absent path. The trigger now no-ops entirely without an onAbsentRead collaborator.

sdk_absent_read_recovery now 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 triggered with a same-path read succeeding, which the fallback's own target scenario never produces.

before

After

The corrected pairing: triggered alongside the fallback call, tree_corrected when the tree itself confirms the path is gone — the thing the fallback actually controls.

after

Test plan

  • 9 new/rewritten tests covering: triggered fires only with a collaborator (no double-count), tree_corrected fires once the path leaves the tree, no false-positive while still listed, no re-fire on an unrelated later tree update, resolves only the path that actually left (not any pending path), pending state is cleared on project switch, positive elapsed_ms
  • 2 manual mutation checks against this head (removing the onAbsentRead guard, removing the delete-after-emit) — both caught by the suite before reverting
  • 56/56 across useSdkSession.lifecycle.test.tsx, useExternalFileChangeCoordinator.test.tsx, CompositionMissingBanner.test.tsx
  • oxlint, oxfmt --check, tsc --noEmit clean, comment-ratchet clean

🤖 Generated with Claude Code

…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 somanshreddy left a comment

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.

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 fileTree out of its deps (useSdkSession.ts:548-556, deps [projectId, activeCompPath, reloadToken]). So a refresh never leads to the same-path re-read that reportAbsentRecoveryIfPending waits for.
  • In the case #4370 targets (an agent deletes a composition and the SSE is missed), success means the tree stops listing the file. CompositionMissingBanner tells 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, masterCompPath moves to the next composition (App.tsx:116), the hook's key changes, and the original trigger is orphaned.
  • The other way round, recovered fires whenever the file comes back and something else re-reads it: an SSE reload via reloadSdkSession, forceReload, or reselecting it. Those are the paths the fallback is a backup for, so a recovered doesn't show the fallback did anything.

B2. triggered also fires from the second, callback-less useSdkSession instance, so one incident counts twice.

  • DesignPanelPromoteProvider.tsx:54 opens its own session, useSdkSession(projectId, targetPath), with no onAbsentRead. Its targetPath defaults to activeCompPath, the same file as the primary session, and it's mounted by the normal property panel (StudioRightPanels.tsx:184).
  • handleReadFailure records the pending entry and emits triggered before the optional onAbsentRead?.() (useSdkSession.ts:414-416). So one absent file can emit two identical triggered events, 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 triggered only when onAbsentRead is present, or add source: "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, emit stage: "tree_corrected", with elapsed_ms from performance.now(). Keep the same-path-read-succeeded event only if it's useful, under a name that says what it is.

Should-fix

  1. The tests pass with broken pairing. I ran these mutants at head with NODE_ENV=test vitest on useSdkSession.lifecycle.test.tsx (27 tests):

    Mutant Result
    Remove the triggered emit 2 tests fail
    Remove the reportAbsentRecoveryIfPending call 1 test fails
    Recovery ignores the key (matches any pending entry) all pass
    Never delete the pending entry, so recovered re-fires on every later success all pass
    Hardcode elapsed_ms: 0 all 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_ms under fake timers. The recovery test also passes no onAbsentRead and recovers through a manual forceReload. That's the false-positive route from B1, pinned as the expected behaviour.

  2. The pending map outlives a project switch. refreshedAbsentPathsRef is cleared on projectId change (:383), but pendingAbsentRecoveryRef isn't.

    • A→B→A where the file reappears reports an elapsed_ms that 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.
  3. elapsed_ms uses wall-clock Date.now(). Tab suspension or clock adjustments skew it. The rest of this file uses performance.now() (e.g. fetchStarted).

Nits

  • recovered fires right after read.ok, before openComposition and the owner-current check. A file that comes back but is unparseable emits recovered and then sdk_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 existing refreshedAbsentPathsRef key has the same issue.

Verified

  • Destination: unchanged. It goes through trackStudioEvent to the existing phc_zjjb… US ingest, and the common props carry agent_runtime and tab_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 accuracy shards 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

@github-actions

github-actions Bot commented Oct 1, 2026

Copy link
Copy Markdown

Edit accuracy: 530 passing here, 530 on the base branch

The gate passes.
Smoothness is reported in the artifact, not gated. A case fails only if it fails 2 of 3 runs.

…-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>
@vanceingalls

Copy link
Copy Markdown
Collaborator Author

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 useFileTree.ts — refreshFileTree only updates the fetched file/composition lists, starts no read, confirming the fallback's own target scenario (file deleted, SSE missed) never produces a same-path read success. Replaced recovered with stage: "tree_corrected", fired from a new effect over fileTree/fileTreeLoaded that checks every pending path against the current tree — independent of which path the hook instance happens to be reading, so the master-view rotation case (masterCompPath moving to the next composition) resolves correctly too.

B2 (double-count): verified at DesignPanelPromoteProvider.tsx:55 — confirmed targetPath defaults to activeCompPath with no onAbsentRead. triggerOnce now no-ops entirely without a collaborator, so that secondary session never fires triggered.

Should-fix:

  1. New tests target the exact mutants in your table (onAbsentRead guard removed, delete-after-emit removed, wrong-key resolution, re-fire on unrelated update, hardcoded elapsed_ms) — manually re-ran two of them against this head (guard removal, delete removal) to confirm the current suite catches both before reverting.
  2. pendingTreeCorrectionRef's clear now runs in the same project-change effect as the dedup set — it didn't before.
  3. elapsed_ms now uses performance.now().

Nits: the premature-emit nit is moot now (tree_corrected doesn't depend on read/parse at all). Key collision fixed — dropped the ${projectId}: prefix entirely; path alone is safe given the per-project clear, so there's nothing left to collide.

Extracted the whole tracker into useAbsentReadRecoveryTelemetry.ts — the fix pushed useSdkSession.ts over the 600-line filesize gate.

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>

This branch has not been deployed

No deployments
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.

2 participants