Skip to content

fix(daemon): stop losing editor heartbeats - #336

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

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

Conversation

@AnnatarHe

@AnnatarHe AnnatarHe commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Summary

The editor plugins drop a batch of heartbeats once the daemon acknowledges it, so anything the daemon loses after {"status":"ok"} is gone for good. This PR closes every path where the daemon acknowledged editor heartbeats and then lost them, and makes code tracking settings apply without a restart. Companion PRs are listed below.

What was wrong

  • Failed stores logged as sent. Servers before shelltime/server's chunked inserts answer 200 {"success": false} when storing a large batch fails; SendHeartbeatsToServer only checked the status code.
  • Acknowledged messages dropped on stop. Publish hands each message to a goroutine and the socket acks right away; on SIGTERM pubsub.Close() discarded every message still waiting. Reproduced end to end: 1,200 heartbeats sent in three messages, the daemon stopped 100 ms later, 700 of them (plus a later message of 3) never stored.
  • One big saved batch blocked every resync. The resync read coding-heartbeat.data.log with bufio.Scanner, whose 64 KB line limit a saved batch of a few hundred heartbeats exceeds; lines saved while a resync was sending were overwritten.
  • Settings needed a restart. The socket gate, the handler and the resync service used the config the daemon started with; the resync service didn't run at all when code tracking was off at start.
  • config.local.* replaced codeTracking as a whole, so a local file that only set its token turned tracking off.

Changes

  • daemon/handlers.heartbeat.go: save for resync when errors exceed the rows without an id or entity; read the config per message (falling back to the last good one); while the daemon stops, save instead of send, and cut a send in flight short and save it.
  • daemon/chan.go, daemon/shutdown.go, SocketHandler.Shutdown: count messages not yet handled and drain them (up to ShutdownDrainTimeout, 5 s) after the socket stops accepting; heartbeats still queued when the drain runs out (behind a slow sync, say) are written to the resync file before the channel closes.
  • daemon/socket.go: read codeTracking from disk per message; no acknowledgment when publishing fails, so the editor keeps the batch.
  • daemon/heartbeat_resync.go: unbounded line reads, a mutex shared with the handler, and a merge that keeps lines appended during a pass; each saved line is resent in chunks of 500 under the same rule as the handler (kept when errors exceed the invalid rows), and only the chunks not stored are kept; runs whenever the daemon isn't AI cloud only, and keeps the file while tracking is off.
  • model/config.go: merge codeTracking field by field, and set the global config under the config lock (the daemon now reads the file from several goroutines).
  • CLAUDE.md: the daemon's editor heartbeat path.

Testing

  • go vet ./... && go test ./...
  • go test -race ./daemon/ ./model/
  • New: daemon/shutdown_test.go (drain waits for handled messages; a stopping daemon saves instead of sending; a send cut short is saved; a full socket → processor → Shutdown run keeps all three acknowledged batches; a heartbeat queued behind a slow sync is saved), resync tests for chunked resends, an old server's 200 with errors, lines over 64 KB, lines appended during a pass, a replaced file and tracking off, the gate reading the config from disk, and the field-by-field merge.
  • End to end against a local server: the same 1,200 + 3 + 700 run now stores every heartbeat; on SIGTERM the daemon wrote the four queued messages to the resync file and sent them after the restart.

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. They can be merged in any order: every plugin keeps working with the current daemon and server, and the daemon with the current server.

🤖 Generated with Claude Code

https://claude.ai/code/session_019PvzR6GVwT4zShCyuHopyi

Editors drop a batch once the daemon acknowledges it, so every path that
acknowledged heartbeats and then lost them is closed:

- A 200 whose errors exceed the rows without an id or entity (servers
  before chunked inserts answer that when storing the batch failed) is
  saved for resync instead of logged as sent.
- The heartbeat log is read without bufio.Scanner's 64 KB line limit, so a
  large saved batch no longer stops every later resync, and lines saved
  while a resync runs are kept.
- On SIGTERM the socket stops accepting, heartbeats still queued are saved
  to the resync file instead of sent (a send in flight is cut short and
  saved too), and the channel drains for up to five seconds. Before, the
  messages waiting in the channel were dropped.
- A heartbeat the channel refuses gets no acknowledgment, so the editor
  keeps it.

The socket, the handler and the resync service read the config on disk,
so turning code tracking on or a new token from shelltime auth applies
without a restart; the resync service now also runs when code tracking was
off at start, and keeps the file while it is off. config.local.* merges
codeTracking field by field, so a local file that only sets the token
keeps tracking on.

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:11:48.651749Z
We couldn’t complete this review. Comment @codex review to try again.
d45490c 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.

@github-actions

github-actions Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

⏱️ shelltime track performance

✅ No significant change in shelltime track latency

Comparing main @ c75ec6a → #336 @ e6a939d. Each scenario execs the binary the way the shell hooks do; values are the median of 10 interleaved rounds. Lower is better.

Scenario base head Δ p
Startup/floor 167 µs ±12% 175 µs ±25% ~ 0.912 A/A
Startup/version 2.20 ms ±20% 2.14 ms ±24% ~ 0.971 ✅
Track/daemon/pre 2.52 ms ±13% 2.53 ms ±19% ~ 0.579 ✅
Track/daemon/post 2.37 ms ±23% 2.35 ms ±19% ~ 0.853 ✅
Track/direct/pre 2.21 ms ±18% 2.67 ms ±22% ~ 0.393 ✅
Track/direct/post 4.79 ms ±13% 5.00 ms ±19% ~ 0.579 ✅
Track/direct/post-sync 5.88 ms ±29% 6.04 ms ±14% ~ 0.912 ✅

Binary size: 19.2 MiB → 19.2 MiB (+0.0%).

CPU, tail latency and memory

Per exec, base → head (Δ when significant).

Scenario p95 wall user CPU sys CPU peak RSS
Startup/floor 195 µs → 227 µs 144 µs → 149 µs 5 µs → 5 µs 9.2 MiB → 9.2 MiB
Startup/version 2.87 ms → 3.01 ms 1.63 ms → 1.56 ms 592 µs → 591 µs 15.8 MiB → 15.8 MiB (-0.07%)
Track/daemon/pre 3.60 ms → 3.44 ms 1.71 ms → 1.67 ms 846 µs → 861 µs 16.6 MiB → 16.6 MiB
Track/daemon/post 3.28 ms → 3.59 ms 1.67 ms → 1.61 ms 711 µs → 832 µs 16.4 MiB → 16.4 MiB
Track/direct/pre 2.98 ms → 3.37 ms 1.60 ms → 1.82 ms 709 µs → 814 µs 16.5 MiB → 16.5 MiB
Track/direct/post 6.53 ms → 6.88 ms 3.31 ms → 3.44 ms 1.68 ms → 1.65 ms 17.7 MiB → 17.6 MiB (-0.40%)
Track/direct/post-sync 7.99 ms → 8.49 ms 4.41 ms → 4.41 ms 1.98 ms → 2.05 ms 19.5 MiB → 19.4 MiB (-0.31%)
benchstat
goos: linux
goarch: amd64
pkg: github.com/malamtime/cli/perf
cpu: AMD EPYC
                         │     base     │                head                 │
                         │    sec/op    │    sec/op     vs base               │
Startup/floor-2            166.6µ ± 12%   174.8µ ± 25%       ~ (p=0.912 n=10)
Startup/version-2          2.204m ± 20%   2.136m ± 24%       ~ (p=0.971 n=10)
Track/daemon/pre-2         2.520m ± 13%   2.533m ± 19%       ~ (p=0.579 n=10)
Track/daemon/post-2        2.370m ± 23%   2.353m ± 19%       ~ (p=0.853 n=10)
Track/direct/pre-2         2.215m ± 18%   2.667m ± 22%       ~ (p=0.393 n=10)
Track/direct/post-2        4.793m ± 13%   5.002m ± 19%       ~ (p=0.579 n=10)
Track/direct/post-sync-2   5.884m ± 29%   6.042m ± 14%       ~ (p=0.912 n=10)
geomean                    2.019m         2.099m        +3.93%

                         │     base     │                head                 │
                         │  p50-sec/op  │  p50-sec/op   vs base               │
Startup/floor-2            158.7µ ±  9%   161.5µ ± 32%       ~ (p=0.912 n=10)
Startup/version-2          2.026m ± 28%   1.981m ± 30%       ~ (p=0.912 n=10)
Track/daemon/pre-2         2.405m ± 12%   2.202m ± 21%       ~ (p=0.393 n=10)
Track/daemon/post-2        2.277m ± 30%   2.178m ± 27%       ~ (p=0.481 n=10)
Track/direct/pre-2         2.090m ± 20%   2.451m ± 19%       ~ (p=0.436 n=10)
Track/direct/post-2        4.606m ± 13%   4.662m ± 29%       ~ (p=0.579 n=10)
Track/direct/post-sync-2   5.673m ± 34%   5.730m ± 12%       ~ (p=0.971 n=10)
geomean                    1.920m         1.932m        +0.63%

                         │     base     │                head                 │
                         │  p95-sec/op  │  p95-sec/op   vs base               │
Startup/floor-2            195.5µ ± 18%   226.8µ ± 22%       ~ (p=0.353 n=10)
Startup/version-2          2.870m ± 27%   3.009m ± 18%       ~ (p=0.796 n=10)
Track/daemon/pre-2         3.602m ± 19%   3.436m ± 32%       ~ (p=0.684 n=10)
Track/daemon/post-2        3.282m ± 22%   3.591m ± 27%       ~ (p=0.796 n=10)
Track/direct/pre-2         2.982m ± 23%   3.369m ± 37%       ~ (p=0.315 n=10)
Track/direct/post-2        6.533m ± 20%   6.880m ± 25%       ~ (p=0.796 n=10)
Track/direct/post-sync-2   7.993m ± 28%   8.485m ± 25%       ~ (p=1.000 n=10)
geomean                    2.695m         2.883m        +6.99%

                         │     base     │                head                 │
                         │  peak-rss-B  │  peak-rss-B   vs base               │
Startup/floor-2            9.153Mi ± 1%   9.182Mi ± 1%       ~ (p=0.305 n=10)
Startup/version-2          15.77Mi ± 0%   15.76Mi ± 0%  -0.07% (p=0.021 n=10)
Track/daemon/pre-2         16.64Mi ± 0%   16.64Mi ± 0%       ~ (p=0.401 n=10)
Track/daemon/post-2        16.40Mi ± 0%   16.40Mi ± 0%       ~ (p=0.697 n=10)
Track/direct/pre-2         16.52Mi ± 0%   16.52Mi ± 0%       ~ (p=0.957 n=10)
Track/direct/post-2        17.65Mi ± 0%   17.58Mi ± 0%  -0.40% (p=0.000 n=10)
Track/direct/post-sync-2   19.50Mi ± 0%   19.44Mi ± 0%  -0.31% (p=0.034 n=10)
geomean                    15.59Mi        15.58Mi       -0.07%

                         │     base     │                head                 │
                         │  sys-sec/op  │  sys-sec/op   vs base               │
Startup/floor-2            5.065µ ± 65%   4.965µ ± 64%       ~ (p=1.000 n=10)
Startup/version-2          592.5µ ± 50%   591.2µ ± 43%       ~ (p=0.631 n=10)
Track/daemon/pre-2         846.4µ ± 24%   860.7µ ± 41%       ~ (p=0.853 n=10)
Track/daemon/post-2        710.5µ ± 52%   831.9µ ± 28%       ~ (p=0.796 n=10)
Track/direct/pre-2         708.9µ ± 36%   813.9µ ± 35%       ~ (p=0.529 n=10)
Track/direct/post-2        1.684m ± 35%   1.645m ± 23%       ~ (p=0.739 n=10)
Track/direct/post-sync-2   1.984m ± 43%   2.047m ± 22%       ~ (p=0.796 n=10)
geomean                    458.7µ         478.7µ        +4.35%

                         │     base     │                head                 │
                         │ user-sec/op  │ user-sec/op   vs base               │
Startup/floor-2            144.3µ ± 11%   148.5µ ± 24%       ~ (p=0.971 n=10)
Startup/version-2          1.628m ± 10%   1.555m ± 11%       ~ (p=0.853 n=10)
Track/daemon/pre-2         1.708m ±  9%   1.665m ± 12%       ~ (p=0.353 n=10)
Track/daemon/post-2        1.671m ± 13%   1.612m ± 14%       ~ (p=0.315 n=10)
Track/direct/pre-2         1.602m ±  9%   1.815m ± 18%       ~ (p=0.218 n=10)
Track/direct/post-2        3.313m ± 11%   3.444m ± 20%       ~ (p=0.853 n=10)
Track/direct/post-sync-2   4.406m ± 16%   4.413m ±  9%       ~ (p=0.971 n=10)
geomean                    1.482m         1.500m        +1.26%

Δ compares medians; ~ means no significant difference (Mann-Whitney U, p ≥ 0.05). A scenario is flagged when it is more than 10% and more than 250µs slower with p < 0.05. Startup/floor runs true, not shelltime: it is an A/A check of runner noise. Runner: linux/amd64, AMD EPYC. go1.27.1, 10 rounds × 100 execs per scenario and binary.

@codecov

codecov Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.08602% with 11 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
daemon/heartbeat_resync.go 88.57% 8 Missing ⚠️
daemon/socket.go 90.90% 3 Missing ⚠️
Flag Coverage Δ
unittests 86.54% <94.08%> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
daemon/chan.go 96.61% <100.00%> (+2.30%) ⬆️
daemon/handlers.heartbeat.go 93.22% <100.00%> (+3.74%) ⬆️
daemon/shutdown.go 100.00% <100.00%> (ø)
model/api_heartbeat.go 100.00% <100.00%> (ø)
model/config.go 96.62% <100.00%> (+0.21%) ⬆️
daemon/socket.go 86.11% <90.90%> (+0.01%) ⬆️
daemon/heartbeat_resync.go 80.62% <88.57%> (+0.87%) ⬆️

... and 2 files with indirect coverage changes

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

@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

The topic processor handles messages one at a time, and a sync to a slow
server is not cut short when the daemon stops. A heartbeat queued behind
it, already acknowledged to the editor, outlived the drain and was dropped
when the channel closed.

GoChannel now keeps the payload of every published message until its
subscribers have handled it, next to the pending counter, and exposes the
ones still pending. When the drain times out, Shutdown saves each pending
heartbeat message to the resync file before closing the channel. The one
being handled at that moment may be both sent and saved; heartbeat ids
dedupe it on the server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019PvzR6GVwT4zShCyuHopyi
Resync treated any answer without an error as stored, so a batch the
handler saved because an old server answered 200 with every row as an
error was deleted on the next pass. It now applies the handler's rule
(keep it when errors exceed the rows without an id or entity).

Each saved line is resent in chunks of at most 500 heartbeats, so an old
server's single insert can't exceed Postgres' 65535 parameters and fail a
large saved batch on every pass, and only the chunks not stored are
written back, each as its own line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019PvzR6GVwT4zShCyuHopyi
The daemon now reads the config with WithSkipCache from several
goroutines, and each read wrote UserShellTimeConfig without a lock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019PvzR6GVwT4zShCyuHopyi
The suite slept and closed the channel without waiting for the processor,
so a sync handler could still read syncCircuitBreaker while a later test
swapped it, which failed go test -race ./daemon/ intermittently.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019PvzR6GVwT4zShCyuHopyi
TestStopTimer_ClearsGitCache stopped the ticker but left the loop running,
so a tick in progress could read CCInfoInactivityTimeout while
TearDownTest reset it, which failed go test -race ./daemon/ intermittently.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019PvzR6GVwT4zShCyuHopyi
A saved line is resent in chunks and only failed chunks are kept, so the
completion line counted chunks as successes and lines as remaining. It now
logs the heartbeats sent and kept, and the lines left in the file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019PvzR6GVwT4zShCyuHopyi
@AnnatarHe
AnnatarHe merged commit 97f56aa into main Oct 24, 2026
5 checks passed
@AnnatarHe
AnnatarHe deleted the claude/loving-cerf-xohla2 branch October 24, 2026 13:54
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