Skip to content

fix(heartbeat): scale pulsetime with poll_time to prevent missing time#131

Open
TimeToBuildBob wants to merge 2 commits into
ActivityWatch:masterfrom
TimeToBuildBob:fix/pulsetime-scaling
Open

fix(heartbeat): scale pulsetime with poll_time to prevent missing time#131
TimeToBuildBob wants to merge 2 commits into
ActivityWatch:masterfrom
TimeToBuildBob:fix/pulsetime-scaling

Conversation

@TimeToBuildBob

Copy link
Copy Markdown
Contributor

Problem

At poll_time > 2s, OS scheduling jitter regularly exceeds the fixed 1-second
pulsetime margin (poll_time + 1), breaking heartbeat chains and causing
missing time in the activity timeline.

From a simulation (200 trials, 3-hour session, σ = 15% × poll_time) validated
against @TimoRenk-nebumind's measured data in ActivityWatch/activitywatch#1177:

poll_time old pulsetime time lost new pulsetime time lost
1s 2.0s 0.00% 2.0s 0.00%
2s 3.0s 0.07% 3.0s 0.07%
5s 6.0s 11.6% 7.5s 0.09%
10s 11.0s 30.0% 15.0s 0.12%

The fix explains why users with default poll_time=1s never see the problem
while users who increase polling to 5–10s lose significant session time.

Fix

# Before
pulsetime=poll_time + 1.0

# After
pulsetime=max(poll_time * 1.5, poll_time + 1.0)

max() (not min()) ensures:

  • Backward compatible: at poll_time ≤ 2s, the formula returns poll_time + 1
    (same as before)
  • Scales at higher intervals: at poll_time = 5s, pulsetime becomes 7.5s instead
    of 6.0s, absorbing the ~0.75s jitter typical at that interval

Note: @mrienstra's original min() proposal in ActivityWatch/activitywatch#1177
actually made things worse at high poll_time — max() is the correct operator.

Testing

Added parametrized regression test covering the four key boundary cases.

Closes ActivityWatch/activitywatch#1177

At high poll_time values (>2s), OS scheduling jitter regularly exceeds
the fixed 1s pulsetime margin (poll_time+1), breaking heartbeat chains and
causing missing time in the activity timeline.

Simulation (200 trials, 3h session) verified against measured user data:
- poll_time=5s: 11.6% time lost with old formula → 0.09% with fix
- poll_time=10s: 30.0% time lost with old formula → 0.12% with fix

Fix: max(poll_time * 1.5, poll_time + 1) ensures jitter tolerance scales
proportionally while staying backward-compatible at poll_time ≤ 2s.

Closes ActivityWatch/activitywatch#1177
@greptile-apps

greptile-apps Bot commented Jul 4, 2026

Copy link
Copy Markdown

Greptile Summary

This PR fixes missing time in the activity timeline when poll_time is larger than 2s, by replacing the hardcoded poll_time + 1.0 pulsetime with max(poll_time * 1.5, poll_time + 1.0) — extracting the logic into a dedicated compute_pulsetime helper that is also directly unit-tested.

  • compute_pulsetime is extracted to main.py and called from heartbeat_loop, preserving the old behavior for poll_time ≤ 2s while scaling the jitter tolerance proportionally at higher polling intervals.
  • Parametrized regression tests are added in tests/test_main.py that import the production helper directly, verifying all four boundary cases (1 s, 2 s, 5 s, 10 s).

Confidence Score: 5/5

Safe to merge — the change is a small, well-bounded formula update with no regressions at the default poll interval.

The formula max(poll_time * 1.5, poll_time + 1.0) is mathematically straightforward, its crossover point (poll_time = 2 s) is verified by the parametrized tests, and behavior at the default 1 s poll interval is identical to before. The helper is extracted rather than inlined, so both production code and tests operate on the same implementation. No risky side-effects, migrations, or interface changes are involved.

No files require special attention.

Important Files Changed

Filename Overview
aw_watcher_window/main.py Adds compute_pulsetime helper and wires it into heartbeat_loop; logic is correct and backward-compatible.
tests/test_main.py Adds parametrized regression test that imports and exercises the production compute_pulsetime function directly, covering the four documented boundary values.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant HB as heartbeat_loop
    participant CP as compute_pulsetime(poll_time)
    participant C as aw-client

    HB->>CP: compute_pulsetime(poll_time)
    CP-->>HB: "max(poll_time*1.5, poll_time+1.0)"
    Note over CP: poll_time ≤ 2s → poll_time+1.0<br/>poll_time > 2s → poll_time*1.5
    HB->>C: "client.heartbeat(bucket_id, event, pulsetime=…, queued=True)"
    C-->>HB: ok
    HB->>HB: sleep(poll_time)
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant HB as heartbeat_loop
    participant CP as compute_pulsetime(poll_time)
    participant C as aw-client

    HB->>CP: compute_pulsetime(poll_time)
    CP-->>HB: "max(poll_time*1.5, poll_time+1.0)"
    Note over CP: poll_time ≤ 2s → poll_time+1.0<br/>poll_time > 2s → poll_time*1.5
    HB->>C: "client.heartbeat(bucket_id, event, pulsetime=…, queued=True)"
    C-->>HB: ok
    HB->>HB: sleep(poll_time)
Loading

Reviews (2): Last reviewed commit: "refactor(heartbeat): extract compute_pul..." | Re-trigger Greptile

Comment thread tests/test_main.py Outdated
…oduction code

Greptile review noted the pulsetime parametrized test duplicated the formula
inline (max(poll_time * 1.5, poll_time + 1.0)) instead of calling the
production code path. If someone later edits the formula in main.py, the
inline test would still pass — a silent regression path.

Fix: extract compute_pulsetime() as a module-level function, use it at the
call site, and import it in the test. The test now catches drift in main.py.

No behavior change; formula is identical.
@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

Fixed in a1bd4dc: extracted as a module-level function and updated the test to import and call it directly. The test now exercises the production code path, so a formula change in main.py will break the test as expected. No behavior change — the formula itself is unchanged.

@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

@greptileai review

@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

All gates clear and ready to merge:

  • ✅ CI green (all platforms, tests, typecheck, CodeQL)
  • ✅ Greptile reviewed (5/5 confidence, safe to merge)
  • ✅ Review threads resolved
  • ✅ Feedback addressed

Waiting for maintainer to merge.

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.

Fundamental flaw in duration calculation

2 participants