Skip to content

fix(heartbeat): deliver every heartbeat and count only the user's activity - #18

Merged
AnnatarHe merged 12 commits into
mainfrom
claude/loving-cerf-xohla2
Oct 24, 2026
Merged

AnnatarHe merged 12 commits into
mainfrom
claude/loving-cerf-xohla2

Conversation

@AnnatarHe

@AnnatarHe AnnatarHe commented Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

An end-to-end check of the editor plugins (plugin → CLI daemon → server) found heartbeats that were silently dropped and activity that wasn't the user's. This PR fixes the JetBrains plugin; the companion PRs are listed below.

Delivery

  • {"status":"disabled"} counted as delivered. Heartbeats are now kept, the status bar shows ShellTime (tracking off) and a notification explains how to turn code tracking on.
  • The queue went out as one message, which servers failed to store past about 2,800 rows. Messages carry at most 500, the queue keeps the newest 5,000, a flush cut short keeps what it didn't send, and what is left when a project closes goes to the daemon's resend file (OfflineStore).
  • A connection closed without a reply counts as delivered only when the daemon still takes connections: daemons 0.1.42–0.1.47 handle heartbeats but never reply, and a current daemon skips the reply only while stopping, when its listener is already closed.
  • Closing a project waits for a periodic flush that was cancelled mid-send to put its batch back (stopAndJoin) before the final flush, and a flush still sending after the queue was drained (a Flush action, or tracking turned off) leaves what it didn't deliver in the resend file.
  • The 5-second socket timeout couldn't interrupt blocking socket I/O (withTimeoutOrNull around blocking calls), so a daemon that accepted and never answered hung the flush. It now uses runInterruptible.

Accuracy

  • Typing counts only while the IDE is active and an editor of this project has focus: reloads from disk and the same file open in another project's window don't. Tabs restored when a project opens, tab switches and caret moves while the IDE is in the background, and files a refactoring saves without opening them no longer count. Folders the project excludes are skipped.
  • Languages are VS Code language ids (LanguageIds), so a language adds up across editors.
  • A file outside the project's base directory counts toward its Git repository; the branch lookup uses getRepositoryForFileQuick, which doesn't update the repository cache on the EDT.
  • The config comes from the files the CLI reads (config.yaml, .yml or .toml, plus config.local.*, with the CLI's TOML keys), and the settings page keeps the file's endpoints.

Build: Kotlin API version

The plugin runs on the IDE's Kotlin standard library (kotlin.stdlib.default.dependency=false): 1.9 on 2024.1, the oldest build pluginSinceBuild allows. Compiled at the Kotlin 2.3 API level (as on main), its coroutines carry debug metadata version 2, which older libraries can't read (the test runtime has 2.2.0). In the coroutines' debug mode (on with -ea), reading it threw Debug metadata version mismatch. Expected: 1, got 2, the dispatcher reported a fatal CoroutinesInternalError and the coroutine was never resumed. On CI that hung every test that resumed a coroutine on another dispatcher or after a cancellation; the project service's dispose waits for exactly such a cancelled flush. build.gradle.kts now sets apiVersion to 1.9, which writes version 1 metadata and keeps the code to the standard library 2024.1 has.

Testing

The IntelliJ Platform can't be downloaded in the environment this was written in, so this PR's CI is the only build and test run of the platform-bound code.

  • The platform-free classes (SocketClient, HeartbeatSender, OfflineStore, ConfigLoader, LanguageIds, VersionChecker, the data classes) were also compiled in a harness with stubbed platform types, where SocketClientTest (ok/disabled/old reply, no reply from a daemon still listening and from one stopping, no daemon, a daemon that never answers), HeartbeatSenderTest (chunking, requeue order, disabled, cap, cancelled flush, stopAndJoin, a flush failing after the drain, drain), VersionCheckerTest, OfflineStoreTest, ConfigDiscoveryTest, LanguageIdsTest and HeartbeatDataTest pass (49 tests). Breaking stopAndJoin or the save after the drain fails its test.
  • The CI hang reproduces in the harness with kotlin-stdlib 2.0.21 (2024.3's) and the kotlinx.coroutines debug agent on the test classpath: 19 tests hang at the 2.3 API level, none at 1.9.
  • VersionCheckerTest (unchanged on main) mocked URL's constructors, which routes every URL in the JVM through the mock, the class loader's resource reads included: on CI kotlin-reflect first initialized inside it (MockK asks whether a mocked type is a value class), read its built-ins from a relaxed mock stream and hung. It passed on main only while something else initialized kotlin-reflect first. It now runs against a local HttpServer, and the encoding case checks the exact query the server gets.
  • The IntelliJ test framework's thread leak tracker runs on these tests too, so SocketClientTest's fake daemon releases and joins its threads in tearDown.
  • A sender or socket test that times out fails with every thread's stack, its locks, the coroutine dump and the runtime's library versions (ThreadDumpOnTimeout, added with @ExtendWith); that is what named the metadata mismatch.
  • End to end: the real sender and socket client against a local daemon and server: 1,200 in 3 messages, kept while tracking was off and delivered after, and with the daemon stopped the rest went to the resend file and was sent on restart. All 1,903 heartbeats stored.

Related

The same end-to-end check, one PR per repository: shelltime/server#567, shelltime/cli#336 (daemon), shelltime/coding-extension-vscode#25, shelltime/coding-extension-jetbrains#18, shelltime/coding-extension-vim#20 and shelltime/web#727. The plugin keeps working with the current daemon and server.

🤖 Generated with Claude Code

https://claude.ai/code/session_019PvzR6GVwT4zShCyuHopyi

…ivity

Delivery:
- The daemon's {"status":"disabled"} reply counted as delivered, so
  heartbeats were dropped while the status bar said ShellTime. They are now
  kept, the status bar shows "tracking off", and a notification explains
  how to turn it on.
- The queue went out as one message, which servers failed to store past
  about 2,800 rows. Messages now carry at most 500, the queue keeps the
  newest 5,000, and a flush cut short (the project closing) keeps what it
  didn't send. What is left when a project closes goes to the daemon's
  resend file.
- The 5-second socket timeout couldn't interrupt blocking socket I/O, so a
  daemon that accepted and never answered hung the flush.

Accuracy:
- Typing counts only while the IDE is active and an editor of this project
  has focus: reloads from disk (git checkout, a formatter or agent in a
  terminal) and the same file open in another project's window don't.
- Tabs restored when a project opens, caret moves while the IDE is in the
  background, and files a refactoring saves without opening them no
  longer count. Folders the project excludes (build output) are skipped.
- Languages are reported as VS Code language ids, so a language adds up
  across editors.
- A file outside the project's base directory counts toward its Git
  repository. The branch lookup only reads the repository cache, which the
  platform requires on the EDT.
- The config is read from the files the CLI reads (config.yaml, .yml or
  .toml plus config.local.*, with the CLI's TOML keys), and the settings
  page keeps the file's endpoints.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019PvzR6GVwT4zShCyuHopyi
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ⚠️ Failed 2026-10-24T12:12:13.962633Z
We couldn’t complete this review. Comment @codex review to try again.
ea9233d PR opened
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@shelltime

shelltime Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

☁️ Claude Code cloud · Audit, fix, test, and merge editor activity pipeline

Audited all three editor plugins end to end, fixed activity accuracy and reliability issues across the plugins, CLI daemon, and server, expanded regression coverage, and merged the completed changes.

💰 Cost 🔢 Tokens ⏱️ Duration 🧠 Model 💬 Prompts ✏️ Lines
$230.80 637.9M 8h 1m (3h 36m active) claude-opus-5-5 3 +3101 / −298

637.9M tokens: 15.9K input · 2.4M output · 625.9M cache read · 9.4M cache write

📝 AI summary

The session exercised the VS Code, Vim/Neovim, and JetBrains integrations against the Shelltime CLI/service and server, reviewing the full activity path rather than only isolated plugin behavior. Fixes included VS Code heartbeat collection/offline handling and Git metadata logic (src/heartbeat/collector.ts, src/heartbeat/offline.ts, src/utils/git.ts), plus Vim socket lifecycle, initialization, TOML parsing, and Git utilities (lua/shelltime/socket.lua, init.lua, and utils/*). JetBrains received test-infrastructure improvements for diagnosing coroutine/test timeouts.

The CLI daemon’s heartbeat resynchronization and timing behavior were corrected and covered with additional tests in daemon/heartbeat_resync.go, heartbeat_resync_more_test.go, and cc_info_timer_test.go. Server-side activity handling and coding-activity timeseries/schema behavior were also updated and validated, notably in server/handler/activity.go and related tests. End-to-end and targeted regression checks were added or run across repositories, including Vim socket tests and temporary independent review/verifier suites. The resulting changes across the editor extensions, CLI, and server were then merged together.

View this session on ShellTime →

Updated 2026-10-24 15:03 UTC by ShellTime · totals for the whole session so far · the session page is visible to its owner

… daemon still answers status

A stopping daemon closes its listener, then drops a heartbeat message it can no longer queue without replying, so treating every blank reply as delivered lost up to 500 heartbeats. Daemons before 0.1.48 never replied to heartbeats, so a blank reply is still delivered when a status probe answers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019PvzR6GVwT4zShCyuHopyi
…flush on project close

stop() only cancelled the flush job, so a flush cancelled mid-send could put its heartbeats back after dispose had drained the queue to the resend file and cleared it. dispose now calls stopAndJoin() first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019PvzR6GVwT4zShCyuHopyi
handleFileSwitch lacked the isActive guard the document and caret handlers have, so the tab a project restores in the background, and tabs a debugger selects there, produced heartbeats.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019PvzR6GVwT4zShCyuHopyi
…ect closed

A Flush action or the flush that turning tracking off starts can still be
sending when the project closes. Once the queue has been drained for the
daemon's resend file, what such a flush doesn't deliver now goes to that
file too, instead of into a queue nothing reads again. The sender takes the
file as a parameter, so tests never write the daemon's real one.

A reply-less close now counts as delivered when the daemon still takes
connections, not when it answers status: daemon 0.1.42 took heartbeats
without replying and has no status handler.

The test task fails a test that hangs after 60 seconds with its stack trace
and logs each test's result, instead of stalling CI until its timeout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019PvzR6GVwT4zShCyuHopyi
MockK runs a coAnswers block in a runBlocking of its own, so a mocked
send that suspends blocks its thread outside the flush's coroutine, and
cancelling the flush never reaches it. On the IDE's test classpath the
two tests that suspend in a send hung, and the threads they left behind
timed out every later test that touched kotlin-reflect.

The sender now takes a HeartbeatSource (the collector) and a
HeartbeatTransport (the socket client), and the test uses plain fakes
whose suspend functions run in the sender's coroutine.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019PvzR6GVwT4zShCyuHopyi
On CI two HeartbeatSenderTest cases still time out after the move to
fakes, so MockK was not what hung them, and the timeout's trace only
shows the test thread parked in runBlocking. ThreadDumpOnTimeout,
registered for every test through META-INF/services, adds every thread's
stack with its locks, the coroutine dump when the debug agent runs, the
JVM arguments and where kotlinx-coroutines and kotlin-reflect come from.

CLAUDE.md and the fake's comment no longer blame MockK.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019PvzR6GVwT4zShCyuHopyi
Runs each coroutine primitive the hanging tests use, alone and before
any other test, so CI shows which one hangs there. To be removed once
the cause is known.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019PvzR6GVwT4zShCyuHopyi

Copy link
Copy Markdown
Collaborator Author

test is red on f596cfc for the same reason as before: under the IntelliJ Platform test runtime, the two HeartbeatSenderTest cases that suspend in a send time out at 10 s. After that, every SocketClientTest case times out at 60 s and VersionCheckerTest fails with kotlin-reflect's Recursion detected in a lazy value (it passes on main). Replacing MockK with plain fakes changed nothing, so MockK isn't the cause.

What blocks a fix: the same tests pass with plain kotlinx-coroutines 1.9 and kotlin-reflect, and the environment this was written in can't download the IDE, so CI is the only place the hang reproduces. JUnit's timeout trace only shows the test thread parked in runBlocking. 1c1d39c adds ThreadDumpOnTimeout, which attaches every thread's stack and the coroutine dump to a test that times out, and a temporary probe class that runs each coroutine primitive alone first. The fix follows from that run, and the probe class is removed with it.


Generated by Claude Code

The plugin runs on the IDE's Kotlin standard library
(kotlin.stdlib.default.dependency=false), which is 1.9 on 2024.1, the
oldest build pluginSinceBuild allows, and 2.0 on 2024.3. Compiled at the
Kotlin 2.3 API level, its coroutines carry debug metadata version 2,
which those libraries can't read. In the coroutines' debug mode (on with
-ea), reading it threw "Debug metadata version mismatch. Expected: 1,
got 2", the dispatcher reported a fatal CoroutinesInternalError and the
coroutine was never resumed: on CI every test that resumed a coroutine
on another dispatcher, or after a cancellation, hung, and the project
service's dispose waits for exactly such a cancelled flush.

apiVersion 1.9 writes version 1 metadata and keeps the code to the
standard library 2024.1 has. With kotlin-stdlib 2.0.21 and the
kotlinx.coroutines debug agent on the test classpath, the harness went
from 19 hanging tests to none. The temporary probe tests are removed;
ThreadDumpOnTimeout now also names the standard library version.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019PvzR6GVwT4zShCyuHopyi
JUnit extension autodetection also loaded the IntelliJ test framework's
own extensions. Its thread leak tracker failed the never-answering
daemon test (the fake daemon thread sleeps out its 30 s), and under its
interceptors VersionCheckerTest, unchanged and passing on main, timed out
and left kotlin-reflect failing every later case. ThreadDumpOnTimeout is
now added to the sender and socket tests with @ExtendWith.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019PvzR6GVwT4zShCyuHopyi
VersionCheckerTest mocked URL's constructors, which routes every URL in
the JVM through the mock, the class loader's resource reads included.
On CI kotlin-reflect first initialized inside that window (MockK asks it
whether a mocked type is a value class), read its built-ins through a
relaxed mock stream that never ends and hung; every later case then
failed with "Recursion detected in a lazy value". It passed on main only
while something else had initialized kotlin-reflect first. The test now
runs the checker against a local HttpServer, and the encoding case checks
the exact query the server receives.

The IntelliJ test framework's thread leak tracker runs on these plain
JUnit tests whether or not extension autodetection is on, so the
previous commit's reason was wrong: it failed the never-answering daemon
test because the fake daemon's thread slept out 30 s. The fake daemon
now releases and joins its threads in tearDown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019PvzR6GVwT4zShCyuHopyi
@codecov

codecov Bot commented Oct 24, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 66.47887% with 119 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...lin/xyz/shelltime/jetbrains/config/ConfigLoader.kt 50.00% 5 Missing and 17 partials ⚠️
...time/jetbrains/services/ShellTimeProjectService.kt 0.00% 19 Missing ⚠️
...lin/xyz/shelltime/jetbrains/actions/FlushAction.kt 0.00% 17 Missing ⚠️
...helltime/jetbrains/heartbeat/HeartbeatCollector.kt 0.00% 12 Missing ⚠️
...tlin/xyz/shelltime/jetbrains/utils/ProjectUtils.kt 7.69% 6 Missing and 6 partials ⚠️
...tlin/xyz/shelltime/jetbrains/ui/StatusBarWidget.kt 0.00% 10 Missing ⚠️
...yz/shelltime/jetbrains/actions/ShowStatusAction.kt 0.00% 9 Missing ⚠️
...z/shelltime/jetbrains/heartbeat/HeartbeatSender.kt 89.83% 4 Missing and 2 partials ⚠️
...n/kotlin/xyz/shelltime/jetbrains/utils/GitUtils.kt 14.28% 5 Missing and 1 partial ⚠️
...z/shelltime/jetbrains/services/ShellTimeService.kt 0.00% 3 Missing ⚠️
... and 2 more

❌ Your project check has failed because the head coverage (43.31%) is below the target coverage (60.00%). You can increase the head coverage or adjust the target coverage.

Impacted file tree graph

@@             Coverage Diff             @@
##             main      #18       +/-   ##
===========================================
+ Coverage   14.21%   43.31%   +29.09%     
===========================================
  Files          20       23        +3     
  Lines         802      972      +170     
  Branches      184      231       +47     
===========================================
+ Hits          114      421      +307     
+ Misses        669      487      -182     
- Partials       19       64       +45     
Files with missing lines Coverage Δ
...kotlin/xyz/shelltime/jetbrains/config/Constants.kt 100.00% <100.00%> (ø)
.../xyz/shelltime/jetbrains/config/ShellTimeConfig.kt 100.00% <100.00%> (+66.66%) ⬆️
...helltime/jetbrains/listeners/FileEditorListener.kt 0.00% <ø> (ø)
...lin/xyz/shelltime/jetbrains/socket/SocketClient.kt 87.14% <100.00%> (+87.14%) ⬆️
...otlin/xyz/shelltime/jetbrains/utils/LanguageIds.kt 100.00% <100.00%> (ø)
...xyz/shelltime/jetbrains/heartbeat/HeartbeatData.kt 72.72% <75.00%> (-0.45%) ⬇️
.../xyz/shelltime/jetbrains/heartbeat/OfflineStore.kt 81.81% <81.81%> (ø)
...z/shelltime/jetbrains/services/ShellTimeService.kt 0.00% <0.00%> (ø)
...z/shelltime/jetbrains/heartbeat/HeartbeatSender.kt 84.04% <89.83%> (+84.04%) ⬆️
...n/kotlin/xyz/shelltime/jetbrains/utils/GitUtils.kt 5.55% <14.28%> (+5.55%) ⬆️
... and 7 more

Continue to review full report in Codecov by Harness.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 853aca5...3c68c44. Read the comment docs.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copy link
Copy Markdown
Collaborator Author

test is green on 3c68c44 (71 tests). The hang was the Kotlin API version: coroutine debug metadata v2 that the IDE's standard library can't read, fixed in 8b73ddf. The last two failures were VersionCheckerTest's URL-constructor mock and the fake daemon's leftover thread, fixed in 3c68c44.

codecov/project (43.31% vs the 60% target) isn't this PR's to fix: main is at 14.21% and fails the same check, as #17 did. This PR raises project coverage by 29 points, and codecov/patch passes. What keeps it under 60% is code that only runs inside an IDE (services, actions, status bar widget, collector). Reaching the target needs IntelliJ platform tests or a lower target in codecov.yml. That's a separate decision, so no fix is in this PR.


Generated by Claude Code

@AnnatarHe
AnnatarHe merged commit ecc1bfc into main Oct 24, 2026
3 of 4 checks passed
@AnnatarHe
AnnatarHe deleted the claude/loving-cerf-xohla2 branch October 24, 2026 15:13
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