fix: keep reading git while work is still landing - #18
Conversation
A session is only measured against the repo its cwd sits in. Plenty of sessions are started a level up instead — from a parent holding `api/` and `web/` side by side, or a plain `~/dev`. That directory has no HEAD, so the session records `startSha: ''` and can never score above idle no matter how much shipped inside it. Sessions now watch a set of repos rather than one: the repo cwd is in, or, when cwd is not itself a repo, the repos directly inside it. Stats are summed across them, so work spread over several repos in one session is counted once, in full. Discovery is a filesystem check one level deep, bounded at 100 entries and 10 repos, so it costs no git spawn per candidate. The baseline moves from a single `startSha` to a `repos` array. `startSha` is still written and still read as a fallback, so sessions opened by an older CLI keep scoring across the upgrade. Hook sessions from a directory with no repos in or under it are now skipped rather than opened as permanently-idle rows — a quick question from the home folder leaves no trace. The shell wrapper still tracks a non-repo directory by wall clock, as before; you invoke it deliberately.
A session is measured from the HEAD of the checkout it started in. Commits made in a linked worktree never move that HEAD, so an agent that works in `.claude/worktrees/<name>` — the default for Claude Code Desktop — leaves the session reading zero commits and a clean tree. It scores idle however much shipped. Committed work is now the set of commits reachable from any current checkout tip but from no baseline, asked for in one rev-list per repo. A worktree's commits are reachable from its own tip and from no baseline, so they count. Merging that worktree back mid-session doesn't double count: the commits sit in the same reachability set whether one tip or two can see them. Each worktree carries its own baseline, taken when the session opens, so a worktree parked on a branch that diverged last month reads as zero — only what moved during the session counts. A worktree created mid-session has no baseline of its own and is measured from the repo's, since its commits are new by definition. Committed lines are now summed per commit rather than diffed net across a range: a range diff needs a single tip, and running one per tip would count shared history twice. Merge commits report no numstat, so merged work is counted once, where it was written. Uncommitted work is still read per checkout — only one working tree can hold it.
|
Checked the parts this leans on and they all hold up: the submittedAt clear works, wasReapedInterrupted still matches, and the server cap query excludes the session's own id so resubmits don't bounce. Refresh cost is basically zero. Two small things:
Needs a rebase once #17 is fixed since the refresh reads through the same counting code. |
Two counting bugs from review. A repo cloned next to a linked worktree of itself has a `.git` on both sides, so discoverRepos surfaced both as separate top-level repos. Each one's worktree list then enumerates the same pair of checkouts, so every commit and line landed in both entries and was counted twice. Candidates are now deduped by git-common-dir, keeping the main checkout — its worktree list is the one that enumerates every linked checkout; a linked worktree's list of itself does not. Resolving through realpath before comparing matters here: a linked worktree's --git-common-dir comes back already resolved (e.g. through /private/var on macOS), while the main checkout's relative ".git" resolves through the unresolved cwd — same directory, two strings, no match without it. Separately, filesTouched summed the committed file set and the uncommitted one, so a file both committed and then re-edited in the same session counted twice. committedStats now returns the file set instead of a count, and the two sets are unioned before counting.
Two ways a session loses the work it did. The reaper closes anything idle for 30 minutes, but it only writes a duration — stats stay frozen at whatever the last hook event saw. Commit, then shut the laptop without ending the session, and the commit is never read: the session is submitted with zero. This is the case for anyone who steps away mid-task rather than closing things down cleanly. The other is narrower but more common: you close the session and commit a minute later from the terminal. The work was there — it was sitting in the diff when the session ended — but only as uncommitted lines, so the session scores tinkering instead of shipped. Sessions are now re-read from git while their stats can still move: while open, and for 30 minutes after they close. The refresh runs before the reaper, so a session it is about to close carries the work it actually did. Corrected stats clear submittedAt and resubmit; the server upserts on id. A commit is only credited to one session. If a later session watching the same repo has already started, its baseline covers anything committed from then on, so the earlier one leaves it alone. Past the 30 minute window a closed session is final — work that lands hours later belongs to whatever session was open at the time, not to this one.
…y owns Review found the grace-window fix could double count. Two sessions watching the same repo don't have to be sequential — one can open while the other is still running. If the earlier one closes first, it was still eligible for a refresh during its grace window, and would pick up the same commit the still-open session was already going to count on its own. ownedByAnotherSession (renamed from supersededBy) now also skips a closed session's refresh when another session on the same repo is open right now, regardless of which one started first — an open session always claims new work on its own, so a closed one never needs to.
f5719ed to
61051c2
Compare
Problem
Two ways a session loses work it genuinely did.
1. You step away instead of closing down. The reaper finalizes anything idle for 30 minutes, but it only writes a duration — stats stay frozen at whatever the last hook event happened to see. Commit, then shut the laptop without ending the session, and that commit is never read. The session is submitted with zero.
2. You commit a minute after closing the tab. The work was already there — it was in the diff when the session ended — but as uncommitted lines, so
commits: 0and the session scorestinkeringinstead ofshipped.Approach
Sessions are re-read from git while their stats can still move: while open, and for 30 minutes after they close (
GRACE_MS, the same inactivity constant used everywhere else).The refresh runs before the reaper, so a session the reaper is about to close carries the work it actually did rather than whatever the last event saw. Corrected stats clear
submittedAtand resubmit on the next flush — the server upserts on id, the same route #14's reaper-recovery fix relies on.Wired into the existing
reapOrphanedSessions()call sites viarefreshAndReap(), sovibe status,vibe log,vibe share, a new session opening, and the wrapper all pick it up. No new commands, no new timers.Notes / decisions
ownedByAnotherSession) — covers both a strictly later session and two sessions genuinely overlapping in time.shippedcap makes that worth being careful about.interruptedis left alone. A reaped session keepsexitCode: 1and reads asinterruptedlocally; only its stats are corrected. The server scores from raw stats and ignores the tier, so the leaderboard sees the work either way, andwasReapedInterruptedkeeps working unchanged.src/rescore.ts,db.tskeeps its single responsibility.Testing
Against a throwaway
$HOMEwith real repos:SessionEnd: credited, rescoredshipped,submittedAtcleared for resubmissionstartShaand norepos: untouchedtsccleanUpdate: addressed both review comments — a closed session no longer double-credits a commit that a still-open session on the same repo will also count (
ownedByAnotherSessionnow checks for that, not just later start times), and the description above no longer claims the walk-away example readsshippedlocally; it readsinterruptedlocally andshippedonly on the leaderboard, which was the actual, correct behavior all along. Rebased onto #17's fix.