Skip to content

Fix the paused watcher, the dead Dashboard button, and 37B phantom tokens - #1

Merged
vimoxshah merged 6 commits into
mainfrom
fix/watcher-lock-dashboard-hermes-inflation
Sep 3, 2026
Merged

Fix the paused watcher, the dead Dashboard button, and 37B phantom tokens#1
vimoxshah merged 6 commits into
mainfrom
fix/watcher-lock-dashboard-hermes-inflation

Conversation

@vimoxshah

Copy link
Copy Markdown
Owner

Three defects, each with a proven root cause and a regression test that fails without the fix.

1. The play button did nothing; the app sat paused

watch.pid held a bare PID. PID numbers restart and get reused at every boot, so a lock that outlived a reboot kept naming a live process — just not ours. On this machine the lock held 810, which after a restart belonged to /usr/libexec/mobilerepaird. kill(810, 0) kept succeeding, so acquireWatchLock() refused to start for days — from the KeepAlive launch agent and the menu bar's play button alike. 2.2 MB of watch.log is the same refusal over and over. Data went stale behind a phantom.

The lock now records the boot its PID was issued by (src/core/watch-lock.js), so a pidfile from an earlier boot is stale by construction. Legacy bare-number files fall back to asking the OS who owns the number. Both the CLI and the Swift app read that one lock — status.json is no longer treated as evidence of liveness, because a watcher block outlives the process that wrote it. A watcher that fails to start now reports why instead of failing silently into /dev/null.

2. Dashboard opened a closed port

openDashboard() opened http://127.0.0.1:<port> and hoped. With no server running the browser showed a connection error. It now probes /api/ping, starts the server when nothing is serving, shows Starting… while the bundle builds, and surfaces a failure. tokenflow dashboard binds the configured port instead of a hardcoded default the menu bar had never heard of, and a second invocation opens the window rather than dying on EADDRINUSE.

3. 2026-08-25 showed 39.9B tokens

session_model_usage is keyed on six columns. The hermes adapter's tail key used four, omitting billing_base_url and billing_mode. Two real rows — one session and model billed with mode "" and again with "chat_completions" — shared one tail, each computed its delta against the other's totals and then overwrote it. Every refresh cycle re-emitted the difference with a fresh emission index, so nothing deduped it:

input output cache_read
row A (mode "") 380,141 43,829 44,942,080
row B (mode chat_completions) 902,243 16,861 33,361,280

The two records that repeated ~3,100 times are exactly max(0, A-B) and max(0, B-A): 522,102 and 11,607,768 tokens. Five colliding sessions turned one day into 39.9B tokens at $302 — tokens growing with cycle count, cost with usage.

The key is now the whole primary key, and the tail is a high-water mark so a total that comes back lower can never manufacture usage.

Repairing a store that already holds bad data

tokenflow reset --source <id> --yes drops one source's records and clears its cursor so the next refresh re-reads it from scratch. Every other source is untouched. Re-ingested against the live database it reconciles exactly:

database store
rows / records 1,568 1,568
input 307,445,127 307,445,127
output 16,965,877 17,041,089 *
cache_read 6,274,625,316 6,274,625,316
cache_write 1,174,421 1,174,421

* the +75,212 is exactly SUM(reasoning_tokens) WHERE reasoning_tokens > output_tokens — the adapter's documented additive-reasoning fold.

Day totals after the repair (usage also returns to the days it happened on, which the phantom deltas had smeared forward):

day before after
2026-08-23 1,816,051,233 2,473,059,841
2026-08-24 2,570,114,997 1,876,914,129
2026-08-25 39,902,522,979 2,352,915,742
2026-08-26 856,447,137 721,061,184

Verification

  • 173 tests pass; each new test was confirmed to fail with its fix reverted.
  • The stale-lock failure was reproduced with a live foreign PID in watch.pid and the watcher started anyway.
  • The live/paused pill was verified in both states from rendered popover previews.
  • The dashboard was spawned exactly as the button spawns it, with nothing listening, and served HTTP 200.

Not changed, both checked and correct: month-to-date reading lower than week-to-date (Sep 1–2 vs Mon Aug 31–Wed Sep 2), and the 2016 records (real git activity, not token usage).

🤖 Generated with Claude Code

vimoxshah and others added 6 commits September 3, 2026 08:22
… the numbers inflated

Watcher: an identity-bearing lock. The pidfile held only a number, and PID
numbers restart and get reused at every boot, so a lock that outlived a reboot
kept naming a live process — just not ours. A lock left at pid 810 was inherited
by /usr/libexec/mobilerepaird; kill(810,0) went on succeeding and every
`tokenflow watch` refused to start for days, from the launch agent and the menu
bar's play button alike. The data went stale behind a phantom while the UI said
"paused" and the button did nothing. The lock now records the boot its pid was
issued by (watch-lock.js), so a pidfile from an earlier boot is stale by
construction; legacy bare-number files fall back to asking the OS who owns the
number. status.json is no longer treated as evidence of liveness — a watcher
block outlives the process that wrote it — and a watcher that fails to start
now reports why instead of failing silently into /dev/null.

Dashboard: the button opened http://127.0.0.1:<port> and hoped. With no server
running the browser showed a connection error. It now probes /api/ping, starts
the server when nothing is serving, shows "Starting…" while the bundle builds,
and surfaces a failure. `tokenflow dashboard` binds the CONFIGURED port rather
than a hardcoded default the menu bar had never heard of, and a second
invocation opens the window instead of dying on EADDRINUSE.

Hermes: 37 billion phantom tokens in one day. session_model_usage is keyed on
six columns; the adapter's tail key used four, leaving out billing_base_url and
billing_mode. Two real rows — one session and model billed with mode "" and
again with "chat_completions" — shared one tail, each computed its delta
against the other's totals and overwrote it, so every refresh cycle re-emitted
the difference with a fresh emission index. Five colliding sessions turned
2026-08-25 into 39.9B tokens at $302, growing with cycle count rather than
usage. The key is now the whole primary key, and the tail is a high-water mark
so a total that comes back lower can never manufacture usage. `tokenflow reset
--source <id>` drops one source's records and clears its cursor for re-ingest,
which is the repair path for a store already holding bad data: re-read against
the live database it reconciles exactly (1,568 rows, 1,568 records), and
2026-08-25 falls to 2.35B with usage returning to the days it happened on.

Regression tests fail without each fix, verified in both directions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first commit captured the watcher's and dashboard's stderr in a Pipe and
read it only in the termination handler. That would have hung the very watcher
the play button starts. A refresh cycle shells out, and those children inherit
fd 2: the git provider alone accounts for 7,791 `fatal: your current branch
'main' does not have any commits yet` lines in this machine's watch.log, a few
per cycle. Nothing drains a Pipe nobody reads, so the buffer (16–64 KB on
macOS) fills within hours, the writing child blocks, and the cycle stops —
reproducing the exact "not running live" symptom this branch set out to fix,
and only on the path a click starts rather than launchd.

Output now appends to watch.log / dashboard.log, the same files the launch
agent writes, so a file that never blocks holds the whole story and there is
one place to look. On a failed start the reason is read back from the log
between the offset recorded at spawn and EOF — only what THIS launch wrote, so
a weeks-old line is never quoted as the cause of a failure that just happened —
preferring the CLI's own error marker over the hint line that follows it.

Also: the ANSI strip used `\u{1B}`, which is Swift's escape syntax, not ICU's.
NSRegularExpression could not compile it, the replace silently did nothing, and
escape codes would have shown up in the popover. It is `\u{001B}` in the
pattern string now, verified against a log carrying real colour codes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The corruption this branch fixes was detected the day it started. On
2026-08-25 the token-spike detector scored 39.90B against a 60-day median of
166.14M — 240×, a modified z-score of 170.9, severity high. Nobody saw it.

Severity saturates at "high" around z=6, and the list then broke ties by date,
newest first. So a z=170.9 outlier ranked third behind request spikes of z=6.5
and z=11.2 from later that week, and the menu bar shows the top two alerts.
For a week the loudest signal the product had was the one thing it did not
show. Replayed against the real incident, the ordering is now:

  before   1. request_spike 08-28 z=6.5   2. request_spike 08-26 z=11.2
  after    1. token_spike   08-25 z=170.9

Ordinary alerts still read as a recency feed; only an outlier of a different
order (z >= 25, four times the "high" threshold) is floated above it. A z of
171 and a z of 6 are not the same news, whichever happened more recently.

Also: `tokenflow reset --source <id>` was added on this branch as the repair
path for a store holding bad data, and `tokenflow help` never mentioned it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…GELOG

"Live" needs a resident watcher and nothing installed one. The play button
could start one for the length of a session; that was all. A reboot left stale
numbers behind a paused menu bar, so anyone who wanted live data hand-rolled a
LaunchAgent — and a hand-rolled one is where two real defects came from.

`tokenflow watch --install-agent` installs it, `--uninstall-agent` removes it,
`watch --status` reports its state, and `tokenflow setup` installs it by
default on macOS (announced, with --no-agent to opt out; other platforms get
the systemd/cron hint instead).

KeepAlive is not a boolean here. `KeepAlive: true` restarts the job after ANY
exit, including the clean one a deliberate stop produces — measured on this
machine at about two seconds, which silently defeated the menu bar's stop
button. The agent uses `KeepAlive: { SuccessfulExit: false }`: a crash comes
back, a stop stays stopped until the next login. A watcher that cannot take the
lock exits 1, which under that rule is a restart, so `ThrottleInterval` is 60 —
without it a persistently blocked start is an infinite respawn loop writing to
the log every time, which is precisely what put 2.2 MB of one repeated refusal
in this machine's watch.log.

Installing removes any OTHER agent that runs a watcher, found by reading what
each plist actually runs rather than by matching a label, because a hand-rolled
one can be called anything. It deletes the plist rather than only unloading it:
an unloaded file comes back at the next login and the race with it.

The menu bar's play button now kickstarts the agent when one is installed,
instead of spawning an unsupervised child that no launchd would restart and
that would die at logout. `watch --status` also takes the PID from the lock
rather than the status file, so a restarted watcher is never described with its
predecessor's PID for a cycle.

Verified on a real machine in four states: install removes the conflicting
agent and leaves exactly one loaded; a stop stays stopped; a kickstart starts
it; a SIGKILL is restarted after the throttle, with the new watcher taking over
the lock the killed one never released.

CHANGELOG.md records 1.1.1 as Unreleased, including how to repair a store that
already holds the inflated Hermes records.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A launchd plist always carries POSIX paths, but renderPlist built the log path
with `path.join`, which follows the host separator. On Windows that produced
`\home\tf\watch.log` and the plist test failed there — the only CI job that
could catch it, since the agent itself never runs on Windows. Rendering now
uses `path.posix`, so it is identical on every platform and the test means the
same thing wherever it runs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e one

`tokenflow diagnostics` reported `process.env.npm_package_version || '1.1.0'`.
That env var is only set when the CLI runs through an npm script, so every
direct invocation — which is every real one — read the hardcoded literal. It
would have gone on naming 1.1.0 in support reports after this release, and
after every release after it. The version now comes from the shipped
package.json, which is the only copy that cannot drift.

The release workflow validates that the tag matches package.json, so the bump
has to land before the tag; it then builds the DMG, the npm tarball, the
checksums and the GitHub release from the tag alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@vimoxshah
vimoxshah merged commit e0e246a into main Sep 3, 2026
8 checks passed
@vimoxshah
vimoxshah deleted the fix/watcher-lock-dashboard-hermes-inflation branch September 3, 2026 06:04
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