fix(usage): cap usage.jsonl in scopes that never report (#788) - #790
Merged
Merged
Conversation
|
Findings
The PR description includes a test plan and real-CLI/e2e verification record, so no testing-description finding is needed. |
|
Findings
The PR description includes both a test plan and real-CLI/e2e verification, so no testing-description finding is needed. |
|
Findings
Resolved
|
|
Findings
Resolved
|
|
Findings
Resolved
|
|
Findings
Resolved
|
A scope that never reports (an http source, `usageReport: false`, a remote rejecting every push) never truncated its usage file, so it grew without bound. After the report step, pull now keeps each active scope's newest 5,000 events; a file at or below the cap is not rewritten. The cap runs after the report's truncate, never between its read and truncate, where it would shift the deleted lines onto unsent events (Tencent#750). Every writer of the usage file takes one lock beside it (acquireLock from update.ts with a bounded retry): hook appends wait at most ~250 ms and then write anyway, the truncate and the cap wait up to ~5 s. A rewrite therefore cannot drop an append made while it runs, and two pulls (http scopes included, which hold no sync lock) cannot interleave their rewrites. Both rewrites go through a uniquely named temp file beside the realpath'd file, created with its mode and renamed over it, so a kill or a full disk leaves the old file whole; the next locked rewrite removes temps a killed one left behind.
SaulMoro
force-pushed
the
fix/788-usage-cap
branch
from
September 24, 2026 11:37
fa67878 to
b8e857d
Compare
|
Findings
Resolved
|
The usage lock fell back to writing without it after its wait: a hook that gave up after ~250 ms appended to a file a slow rewrite was about to replace, and two rewrites past ~5 s could overwrite each other. No writer goes past the lock now. An append that cannot take it records its line in a side file of its own (<usage>.pending-<uuid>.jsonl, O_EXCL), and every lock holder first appends those to the usage file and removes them; one without its newline is still being written and is left for the next holder. A rewrite that cannot take the lock leaves the file as it is and says which lock to remove.
# Conflicts: # CHANGELOG.md
|
Findings
Resolved
|
|
Findings
Resolved
|
…itignore (Tencent#788) teamai init writes a project scope's .teamai/.gitignore only when it is missing, so a legacy in-workspace install keeps one that ignores usage.jsonl alone, and a hook that times out on the usage lock leaves a trackable usage.pending-<uuid>.jsonl in the business repo. The usage tracker now adds usage.jsonl.* and usage.pending-*.jsonl to that file before it writes a pending file or a rewrite's temp copy. Best-effort: a failure never costs the event, and single-repo mode keeps its own pull/push heal.
|
Findings
Resolved
|
The side-file heal rewrote the legacy project .teamai/.gitignore in place. A full disk or a kill after the truncate left it empty or partial, and that file also ignores `token` and `env`, so a later `git add .` could stage them. It now goes through writeFileAtomic (same-directory temp + rename, mode kept), so a failed write leaves the original byte-identical.
|
Findings
Resolved
|
migrateSelfModeGitignore rewrote an existing single-repo .teamai/.gitignore in place; a full disk or a kill after the truncate left it partial, and it also ignores `token` and `env.local`. It now goes through writeFileAtomic, like the project-scope heal. Folding a pending file was not idempotent: a holder that died, or whose rm failed, after the append left the side file for the next holder to append again. The fold now skips a line the usage file already holds (a line carries its millisecond timestamp) and still removes the side file.
|
Findings
Resolved
|
…#788) The fold skips a side file's line when the usage file already holds it, so a genuine event with the same skill, tool and millisecond as one already in the file was dropped as a duplicate. A side file's line now carries the id its file name already has; readers keep only skill, tool and timestamp, so nothing downstream sees it.
Two hooks recording the same skill and tool in the same millisecond while the lock is held write identical side files, and the fold kept only one. A side file's line now carries the id already in its file name, and the fold skips it only when the usage file holds that id. readUsageEvents keeps only skill, timestamp and tool, so the id never reaches stats or the report.
|
Findings
Resolved
|
|
Findings
Resolved
|
jeff-r2026
self-requested a review
September 24, 2026 15:43
jeff-r2026
approved these changes
Sep 24, 2026
Merged
5 of 9 tasks
SaulMoro
added a commit
to SaulMoro/teamai-cli
that referenced
this pull request
Sep 24, 2026
Merges Tencent#817 (docs mirror), Tencent#788/Tencent#790 and Tencent#809/Tencent#813. The docs mirror now targets the member's resolved docs set: it copies only the delivered files and prunes every local entry the team repo does not have, but a local copy of a team doc in an inactive namespace follows the Tencent#707 rule (removed when byte-equal, kept and named when edited). Withdrawal runs inside the mirror, after the single-repo early return, so it never deletes from the team repo's own docs/. Doctor expects the delivered set and does not report a withheld namespace's team doc as stale. Tencent#817's failure handling (docsSyncFailed, cleared revision, result.completed) is kept.
5 tasks done
ydflow
added a commit
to ydflow/teamai-cli
that referenced
this pull request
Sep 26, 2026
…#804) events.jsonl was appended lock-free by every dashboard hook on the machine and periodically rewritten by compactEvents (read -> filter -> temp file -> rename). The atomic rename only prevented a torn file: an append that landed between the rewrite's read and its rename was overwritten and lost, so the dashboard's sessions, interventions and prompt counts under-counted until the next rebuild. Two concurrent compactions also shared one fixed temp name, so their rewrites could clobber each other's copy. Both writers now take <events file>.lock (acquireLock from update.ts, the pattern the usage file took for the same lost update in Tencent#790/Tencent#803): a hook append waits up to ~250 ms, inside its foreground budget, and one that gives up records its line in an events.pending-<uuid>.jsonl side file that the next lock holder folds into the file before it writes. The line carries a pendingId, so a fold never appends a side file twice, identical events in separate side files are both kept, and no reader (readEventsRaw) and no compacted file keeps the id. A compaction waits up to ~5 s for a peer's rewrite and skips, leaving the file as it is for the next compaction, when a live holder outlasts the wait; a lock whose owner is gone is reclaimed, and a rewrite's temp copy a killed compaction left (events.jsonl.<pid>.<hex>.tmp) is removed by the next one, which now also preserves the file's mode. The side files and the lock live in ~/.teamai/dashboard/ beside the log, which no repository tracks.
jeff-r2026
pushed a commit
that referenced
this pull request
Sep 27, 2026
…841) * fix(dashboard): serialize events.jsonl append and compaction (#804) events.jsonl was appended lock-free by every dashboard hook on the machine and periodically rewritten by compactEvents (read -> filter -> temp file -> rename). The atomic rename only prevented a torn file: an append that landed between the rewrite's read and its rename was overwritten and lost, so the dashboard's sessions, interventions and prompt counts under-counted until the next rebuild. Two concurrent compactions also shared one fixed temp name, so their rewrites could clobber each other's copy. Both writers now take <events file>.lock (acquireLock from update.ts, the pattern the usage file took for the same lost update in #790/#803): a hook append waits up to ~250 ms, inside its foreground budget, and one that gives up records its line in an events.pending-<uuid>.jsonl side file that the next lock holder folds into the file before it writes. The line carries a pendingId, so a fold never appends a side file twice, identical events in separate side files are both kept, and no reader (readEventsRaw) and no compacted file keeps the id. A compaction waits up to ~5 s for a peer's rewrite and skips, leaving the file as it is for the next compaction, when a live holder outlasts the wait; a lock whose owner is gone is reclaimed, and a rewrite's temp copy a killed compaction left (events.jsonl.<pid>.<hex>.tmp) is removed by the next one, which now also preserves the file's mode. The side files and the lock live in ~/.teamai/dashboard/ beside the log, which no repository tracks. * test(dashboard-report-scope): provide the events lock in the update.js mock dashboard-collector now imports acquireLock and releaseLock from update.js (#804), and this file's factory mock replaced that module without them, so appendEvent lost every event to the missing export and 49 report tests failed on CI. Follow the pull-* tests' mock shape: acquireLock resolves true and releaseLock resolves undefined, keeping these tests lock-free, as they ran before the lock. * test(dashboard-collector): match the compaction read by file name macOS resolves a file under os.tmpdir() through /private, so compaction's realpath'd target never equals eventsPath() and the counterexample's injection never fired: live-b was never appended and the test failed on every macos job. Match the read by its file name instead, which the realpath keeps. * fix(dashboard): skip the events lock when compaction has nothing to do The detached compaction after every append took the events lock even when the log was below the threshold and no side files were pending — nearly always. On a state directory a test teardown deletes concurrently, that turned the no-op into lock-file churn racing the deletion, which CI caught as an unhandled ENOENT on macOS. Read first, lock-free: below the threshold with no side files the compaction returns without creating the lock, the I/O profile of the pre-#804 compaction; a rewrite, or side files to fold, still take it. * fix(dashboard): keep a surviving side file recognizable, fold and classify in time order Two review findings on #841: A fold that appended its side file but could not remove it left the file unrecognizable after the next compaction: the rewrite dropped the line's pendingId, so the next holder could not tell the side file was already in the log and appended the event a second time, double-counting it in the dashboard's metrics. The id now stays in the raw file — a rewrite keeps it, as the usage file's rewrite does (#788) — until the side file itself is gone; readEvents drops it, so no reader ever sees it. Side files also folded in readdir's arbitrary order, and the compaction classified sessions from that raw order: a side file that outlived newer appends could place an older event after them, re-mark a live session stopped once its stopped-display window had passed, and the rewrite would drop all its events. Side files now fold in their events' own time order, and the compaction classifies in time order too — the order every reader rebuilds from (dedupeEvents sorts); the rewritten file keeps the raw order. --------- Co-authored-by: ydflow <314143294+ydflow@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #788
Fixes #803
Part of #752
Summary
pull, report step (pendingUsageReport) for each reporting target N = readUsageEvents(scope).length reportUsageToTeam(...) if reported: truncateUsageAfterReport(N) # deletes first N lines + for each active scope (project, user), reporting or not + capUsageEvents(scope) # ≤ 5,000 lines → file untouched + every writer of the usage file takes <usage file>.lock (acquireLock from update.ts, bounded retry) + holder first: fold <usage>.pending-<uuid>.jsonl side files into the file (skip one whose pendingId the file already holds), remove them + hook append wait ≤ ~250 ms; else write own side file (O_EXCL, mode ≤ the usage file's, 0600 if none; line carries the file's uuid as `pendingId`) + readUsageEvents keeps skill, timestamp, tool only: pendingId never reaches stats, recommendations or stats/<user>.yaml + truncate / cap wait ≤ ~5 s; else leave the file as it is, log which lock to remove + rewrite = realpath(file); drop orphan temps; read; keep lines + write <file>.<pid>.<random>.tmp with the file's mode; rename over file + in-workspace .teamai/.gitignore (self, legacy project) + usage.jsonl + usage.jsonl.* # lock, its link temp, rewrite temps + usage.pending-*.jsonl # side files + existing self-mode file: added by migrateSelfModeGitignore on pull / push / contribute (writeFileAtomic) + existing project-scope file: added by ignoreUsageSideFiles before a side file or a rewrite temp is written (writeFileAtomic)The cap runs after every truncate, never between a report's read and its truncate: there it would shift the deleted lines onto unsent events (#750).
USAGE_EVENT_CAP = 5_000, the size ofDASHBOARD_COMPACTION_THRESHOLD. No writer goes past the lock, so a rewrite never races an append or another rewrite (http scopes included, which hold no sync lock).Evidence
Real CLI on head 0f920ef, sandbox HOME, user scope (
probe-same-ms.sh). A live process holds the usage lock; two Claude Skill hooks recordheldat once withDatepinned to one millisecond (node --require fixed-date.cjs), so the two side files hold the same event; the lock goes away; the hook fires again; thenteamai stats:heldin the usage file after the foldteamai statspendingIdpendingIdin the outputReal CLI, sandbox HOME, user scope (
probe-refold.sh). A live process holds the usage lock and the Claude Skill hook recordsheld-1in a side file; then the side file's line is appended to the usage file with the side file left behind (a holder that died between its append and its rm); then the hook fires again:held-1in the usage file after the next hookpendingId)Real CLI on head fc5f536, sandbox HOME, legacy project install (config and data in
<workspace>/.teamai, committed.gitignorethat lists onlyusage.jsonl). A live process holds the usage lock; the Claude Skill hook (hook-dispatch post-tool-use --tool claude --matcher Skill) fires and records into a side file (probe-legacy.sh):git status --porcelain --untracked-files=all?? .teamai/usage.jsonl.lock,?? .teamai/usage.pending-<uuid>.jsonlM .teamai/.gitignore(+usage.jsonl.*, +usage.pending-*.jsonl); no usage file listedReal CLI on head 583f908, sandbox HOME, user scope. A live process holds the usage lock;
hook-dispatch prompt-submit --tool claudefires; the lock goes away; the hook fires again:user-usage.pending-<uuid>.jsonlis-rw-------(was-rw-r--r--on 42fa895)The runs below are from before the merge and still describe the lock and cap.
Real CLI on head 42fa895, sandbox HOME.
probe-pending.sh: a live process holds the user scope's usage lock, then the agent'shook-dispatch post-tool-use --tool <agent>Skill hook fires, thenpull:pullwhile the lock is heldpullRepresentative git cell (
matrix.sh, git × claude, on 42fa895). A user scope is seeded with 100legacythen 5,000reviewevents, mode 0600, and hooks keep firing during the pull (at most 4 in flight):usageReport: falselegacylegacy; file empty afterThe full 12-cell matrix (git/gitlab/github × claude/codex/codebuddy/opencode) passed on fa67878, the same change before the pending side files: 15/15 … 24/24 hooks kept in every cell.
http scope, two
pulls at once while the Claude hook keeps firing (probe-http.sh):Tests, red on the previous head → green:
latelost)late0twice).gitignoreignores the lock, a rewrite temp and a side file (git check-ignore).gitignorethat lists onlyusage.jsonlgains those patterns.teamai/.gitignorelisting onlyusage.jsonl: a side file stays out ofgit status; after a cap the lock and a rewrite temp are ignored.gitignore: it stays byte-identical, no temp left, the cap still lands.gitignore: it stays byte-identical, no temp leftconfig.yam)btwice)['a','b'])['a','b'])readUsageEventsandteamai statsoutput carry nopendingId; the reportedstats/<user>.yamlneither#803's own repro (an append injected between the truncate's
readFileand itswriteFile), run on 42fa895: the event is kept. On origin/main 8cee7ab:expected [ 'd' ] to deeply equal [ 'c', 'd' ]. The "same for the truncate" test above covers that interleaving.Test plan
On head 0f920ef (origin/main ec56a67 merged in):
npx tsc --noEmit: cleannpx vitest run: 306 files, 4707 passed, 1 skippednpm run test:e2e: 46 files passed, 3 skipped; 242 tests passednpm run build+ the same-millisecond real-CLI run aboveCI review 5815927552 (on e80b19e, the tests-only push): P1 (the id was only in the tests; the fold still matched whole lines, so two identical concurrent events lost one) fixed in 0f920ef: side files carry
pendingId, the fold matches that id, readers drop it. P3 (a side file whose rm keeps failing is appended again after a report truncates its line) not changed: telling it apart would need a record of folded ids outside the file the report truncates, for a side file that cannot be removed at all; stated in Merge Danger.CI review 5815707212: P1 (fold deduped by the whole line, so identical concurrent events lost one) fixed in 0f920ef as above.
CI review 5815129038: P3 (self-mode
.gitignoreheal inmigrateSelfModeGitignorerewrote the file in place) fixed in 97a394b withwriteFileAtomic. P3 (non-idempotent fold) fixed in 97a394b (the fold skipped a line the usage file already held; by id since 0f920ef).CI review on fc5f536: P3 (the legacy
.gitignoreheal rewrote the file in place, so ENOSPC or a kill could leave it empty and exposetoken/env) fixed in 064370e withwriteFileAtomic. P3 (non-idempotent fold) fixed later in 97a394b.CI review 5813925880: P1 (an existing legacy project
.gitignorenever gains the patterns) fixed in fc5f536. P3 (non-idempotent fold) fixed later in 97a394b.CI review 5813562508: P1 (gitignore) and P2 (side-file mode) fixed above. P3 (a holder killed between appending a side file and removing it re-appends that event) fixed later in 97a394b.
Merge Danger
Door: one-way for the data it drops. The oldest events beyond 5,000 are deleted; a rollback stops the cap but does not restore them. In a reporting scope whose report does not complete while it holds more than 5,000 events, those events were never reported.
Blast Radius: local usage files and hook latency.
teamai statsand the report do not see that event.pendingIdthe usage file already holds is removed without being appended (a holder died or could not remove it after its append). A side file that can never be removed is appended again once a report has truncated its line away.<usage file>.lock.<uuid>.tmp; a rewrite killed before its rename leaves<file>.<pid>.<random>.tmp(removed by the next rewrite). An in-workspace.teamai/.gitignoreignores both, the lock and the side files. An existing self-mode one gains those entries on the next pull or push. An existing project-scope one gains them just before the first side file or rewrite temp is written, silently (hooks stay quiet), so where it is committed it shows as modified until the member commits it.