Skip to content

fix(scan): only invalidate and bump when the scan roots actually moved - #233

Merged
Nanako0129 merged 3 commits into
mainfrom
fix/redundant-root-generation
Aug 24, 2026
Merged

fix(scan): only invalidate and bump when the scan roots actually moved#233
Nanako0129 merged 3 commits into
mainfrom
fix/redundant-root-generation

Conversation

@Nanako0129

Copy link
Copy Markdown
Owner

ClaudeExtraRoots.apply() dropped every Swift-side scan-derived cache and advanced the generation views key their reloads on — both unconditionally, on every call.

apply() runs at launch (AppDelegate:90) and on every Settings save, not only when the list changes. So "an apply ran" has never implied "something changed", and every launch invalidated the reopen snapshot, the union scan, the hourly cache and the attributed series' rows, then moved the generation. A popover already open when that landed additionally took reloadForRootChange() — a forced, cache-bypassing full graph refresh — for a registry byte-identical to the one already installed.

The AppDelegate site at :387 was already value-gated on the persisted list. This puts the same test where it belongs: on the value the setter actually installed, so it covers the launch call and any future caller too.

Measured — and this is NOT the cold-start regression

Timed usage_graph::run, the computation the dashboard's "Loading usage…" actually waits on, at v1.13.3 and at 7323eda5. Same corpus (119 days), same machine, three runs each, through a throwaway #[ignore] probe rather than the app's own probes (those enter the provider credential path).

run 0 run 1 run 2
v1.13.3 3516 ms 3087 ms 3038 ms
main 4290 ms 2810 ms 2995 ms

The warm runs are the comparable ones: the scan did not get slower. run 0 differs by first-touch I/O and is n=1.

The engine is cleared by inspection as well. The vendored revision moved this release (731a2dccfc2941eb) but that diff is 92 insertions, 0 deletions, one new function — parser and scanner untouched, so the graph computation is the same code on both sides.

So this PR removes real waste on a path the user waits on, and it is not established as the cause of the slow cold start that prompted the investigation. Stated rather than implied, because a fix landing next to a complaint invites being credited with it.

Verification

make selftest: 1186 assertions, 0 failures (1182 before).

CE-MOVED asserts all four states, because the second alone is satisfied by a function that never reports a change:

  • a differing registry reports a change
  • the same registry reported again does not — that is the launch case, and the one that was paying
  • a genuinely different registry still does, so the gate is a comparison rather than a one-shot latch
  • a failed setter does not, because the registry still holds what it held

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7219dc0a2d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread Sources/TokenBar/ClaudeExtraRoots.swift
Nanako0129 added a commit that referenced this pull request Aug 23, 2026
The release grew from one merged PR plus the lens to four, so the notes now
cover #233, #234 and #235 alongside what was already written.

Three additions carry weight beyond describing a change.

**#234's downgrade consequence leads the "before you update" section**, ahead
of the cold-start note. Upgrading loses nothing — the migration exists so a
version bump does not reset everyone's history, and it preserves every sample.
Downgrading afterwards does: an older build cannot read schema 4, quarantines
the file, and starts from nothing. The distinction that matters to a reader is
that usage totals survive because they are recomputed from logs, while pace
readings are observations taken over time and cannot be. Weeks of them are what
lets a window say "Historically" instead of falling back to an average.

The laziness is stated for the same reason a silent behaviour always is: the
conversion happens on the next write the app had its own reason to make, so a
file still reading `"schemaVersion": 3` after updating is normal. Without that
sentence it reads as a migration that did not run, and someone reports it.

**#235's token-expiry limit is written as a deliberate trade-off**, not omitted
and not softened. An additional account whose token expires is not refreshed in
place; the card says so and asks the user to run `claude` under that config
directory. The refresh path reloads, validates and saves against the main
configuration directory throughout, so routing an additional account through it
would overwrite the main account's stored credential and log the user out of
it. A limit that is not named in the notes is reported as a bug, and the reader
cannot tell a chosen failure from an unnoticed one.

**#233 is described by what the user paid**, not by the mechanism: an ordinary
launch rebuilt caches it already had. The measurement that says this is not the
cold-start regression belongs in the PR, not in notes written for users.

Both files stay overrides, so `release_notes.sh` ships them verbatim and
DeepSeek is skipped on both surfaces — the two prompts are independent and
non-deterministic, and a release this size is the wrong one to let them
describe differently.
@Nanako0129

Copy link
Copy Markdown
Owner Author

Sync: #234 (quota-history schema 3→4) and #235 (multi-account quota cards) both merged into main since this branch was opened. git merge-tree --write-tree against current main auto-merges this branch cleanly — no textual conflict — but there is a semantic interaction worth knowing about before landing.

#235 restructured apply()/install() and, as part of fixing a separate finding (the quota poll was blocked behind the scan-root probe), moved the throttle invalidation and RegistryChange.signal() to run unconditionally, between the two setters — before this PR's moved check is even computed:

setConfigDirs(configDirsJSON)
Task { @MainActor in
    await AgentUsageThrottle.shared.invalidate()
    RegistryChange.signal()
}
let result = setScanPaths(json)
Task { @MainActor in
    let moved = recordAppliedAndReportChange(json, result: result)
    guard moved else { completion?(result); return }   // only gates the scan side now
    DashboardModel.invalidateScanDerivedCaches()
    ...
}

After an auto-merge, guard moved still gates invalidateScanDerivedCaches() and the generation bump correctly — the scan-cache waste this PR targets is fixed. But the quota-side throttle drop and poller wake are no longer inside that guard at all, so every apply() call — including the launch-time one with an unchanged registry, the exact case this PR's message describes — still invalidates the quota throttle and wakes every poller. Same waste, moved to the other registry.

Not fixing this here — it's your PR and your call whether the quota side should share the moved gate or stays unconditional on purpose (there's an argument either way; the throttle invalidation is comparatively cheap next to a scan-derived cache drop). Flagging the fact so it doesn't get discovered as a surprise after rebase.

`ClaudeExtraRoots.apply()` dropped every Swift-side scan-derived cache and
advanced the generation views key their reloads on — both unconditionally, on
every call.

`apply()` runs at launch (`AppDelegate:90`) and on every Settings save, not only
when the list changes, so "an apply ran" has never implied "something changed".
Every launch therefore invalidated the reopen snapshot, the union scan, the
hourly cache and the attributed series' rows, and moved the generation. A
popover already open when that landed also took `reloadForRootChange()` — a
forced, cache-bypassing full graph refresh — for a registry byte-identical to
the one already installed.

The AppDelegate site at `:387` was already value-gated on the persisted list;
this puts the same test where it belongs, on the value the setter actually
installed, so it also covers the launch call and any future caller.

`recordAppliedAndReportChange` is the seam: it records and reports whether the
installed registry moved, which is the fact both consequences were missing.
`recordApplied` keeps its signature for the callers that do not care.

Measured, and this is NOT the cold-start regression
---------------------------------------------------
Timed `usage_graph::run` — the computation the dashboard's "Loading usage…"
actually waits on — at `v1.13.3` and at `7323eda5`, same corpus (119 days),
same machine, three runs each, through a throwaway `#[ignore]` probe rather
than the app's own probes, which enter the provider credential path.

    v1.13.3   3516 / 3087 / 3038 ms
    main      4290 / 2810 / 2995 ms

The warm runs are the comparable ones and the scan did not get slower. The
engine is also cleared by inspection: the vendored revision moved this release
(`731a2dcc` → `fc2941eb`) but the diff is 92 insertions, 0 deletions, one new
function — the parser and scanner are untouched, so the graph computation is
the same code on both sides.

So this commit removes real waste on a path the user waits on, and it is not
established as the cause of what they reported. Said plainly rather than
implied, because a fix that lands next to a complaint invites being credited
with it.

Verification
------------
`make selftest`: 1186 assertions, 0 failures (1182 before).

CE-MOVED asserts all four states, because the second alone is satisfied by a
function that never reports a change: a differing registry reports one, the
SAME registry reported again does not — that is the launch case and the one
that was paying — a genuinely different registry still does, so the gate is a
comparison rather than a one-shot latch, and a failed setter does not, because
the registry still holds what it held.
#235 moved the quota throttle drop and the `RegistryChange.signal()` to run
between the two setters in `install()`, which is the correct POSITION — a
stalled scan probe must not delay the quota cards. But the wake was
unconditional, and `install()` runs at launch and on every Settings save, so
every launch dropped the quota throttle and woke every sleeping poller for an
account list identical to the one already installed.

`lastApplied` cannot catch the launch case: it is in-memory state that starts
nil every process, and a comparison against nil always reports a change.
`appliedConfigDirsKey` persists the last-installed account list across launches,
which is what the comparison needs to be meaningful here.

Verified: two mutation-checked assertions.

* M3-o2: re-applying the SAME account list wakes no poller —
  removing the guard FAILs it.
* CE-CONFIG-MOVED: installing a different list reports a change,
  the same list again does not, and a different one after that still does —
  always-return-true FAILs the second.

M3-o still passes: a genuinely different list fires the wake before the scan
probe returns, which is the property that test was built to assert.

Co-authored-by: multi-account-quota-symmetry (peer session that identified the
interaction after its #235 merge)
@Nanako0129
Nanako0129 force-pushed the fix/redundant-root-generation branch from 7219dc0 to bc3106a Compare August 24, 2026 06:59

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: bc3106ad1b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread Sources/TokenBar/ClaudeExtraRoots.swift Outdated
// the registry that is now installed rather than the one it
// replaced — same reason the scan side records before its own
// invalidation, below.
guard recordAppliedConfigDirsAndReportChange(configDirsJSON) else { return }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Wake pollers after the first per-process account install

When configured Claude accounts persist across a relaunch, this guard compares against the previous process's marker even though the Rust account registry starts empty. AppDelegate launches the asynchronous apply before starting TrayAnimator, so its first quota fetch can race and use only the primary account; if the persisted JSON matches, the epoch never advances, that stale payload passes the guard in TrayAnimator.startQuotaPolling, and the missing account can remain absent until the five-minute poll (or the popover's one-minute poll). Track whether the account registry has been installed in this process and always invalidate/signal after that first nonempty install.

AGENTS.md reference: AGENTS.md:L25-L25

Useful? React with 👍 / 👎.

The persisted `appliedConfigDirsJSON` comparison answers "does this list differ
from what a PREVIOUS install recorded". On a process's FIRST install that is the
wrong question: the Rust account registry is process memory and starts empty
regardless of what an earlier process persisted, so a relaunch with an unchanged
account list installs into an empty registry while the marker already says
"unchanged".

The consequence is not cosmetic. `AppDelegate` starts `TrayAnimator` right after
launching the asynchronous `apply()`, and the tray's quota poll fetches
immediately — it can read only the primary account while the install is still in
flight. Nothing signalled `RegistryChange`, so the epoch guard in
`startQuotaPolling` accepts that partial payload, and the loop's own recovery is
`RegistryChange.sleep(upTo: 300, ...)` — which waits for exactly the signal that
never came. A configured account could be absent from the gauge for five minutes
despite the registry having held it since launch.

`didInstallConfigDirsThisProcess` tracks whether this process has installed the
account registry at all. The first non-empty install wakes pollers regardless of
the persisted marker; every install after that is gated on a real change as
before.

Restricted to non-empty. Waking for an empty registry that stays empty corrects
nothing and would reintroduce the per-launch cost this branch exists to remove,
one exemption down.

Verified: two mutation-checked assertions.

* M3-o3 (relaunch with a matching persisted marker, first install of the
  process, must wake) — removing the `firstNonEmptyInstall` term FAILs it.
* M3-o4 (first install with nothing configured must NOT wake) — dropping the
  non-empty qualifier FAILs it.

M3-o and M3-o2 still pass: a genuinely different list wakes before the scan
probe returns, and a repeat of an already-installed list within a process wakes
nothing.

Reported by Codex review on bc3106a.
@Nanako0129
Nanako0129 merged commit e89bd51 into main Aug 24, 2026
1 check passed
Nanako0129 added a commit that referenced this pull request Aug 24, 2026
…xception

#233 grew during review. The note described only its scan-cache half.

Registering the Claude directories also dropped the quota throttle and woke
every sleeping poller, unconditionally and on every launch — so the cost was
not only rebuilding a cache but spending provider requests against an endpoint
that rate-limits. Both halves are now gated on the registered set actually
differing, and the note says so.

The exception is stated rather than omitted. The first registration after a
launch still refreshes the quota cards even when the user's list is unchanged,
because the engine starts each run with nothing registered — a real change from
its point of view, and skipping it could leave the menu bar showing a reading
taken before the extra account was known. A note that claimed the work happens
"only when something changed" would be wrong about launch, which is the case
users will actually observe.

Also drops "scan-root list" for "your Claude directories": the internal name
described the payload, not the thing the reader configured.
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