Skip to content

fix(usage): cap usage.jsonl in scopes that never report (#788) - #790

Merged
jeff-r2026 merged 11 commits into
Tencent:mainfrom
SaulMoro:fix/788-usage-cap
Sep 24, 2026
Merged

jeff-r2026 merged 11 commits into
Tencent:mainfrom
SaulMoro:fix/788-usage-cap

Conversation

@SaulMoro

@SaulMoro SaulMoro commented Sep 24, 2026 •

Copy link
Copy Markdown
Collaborator

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 of DASHBOARD_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 record held at once with Date pinned 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; then teamai stats:

build side files while held held in the usage file after the fold teamai stats
97a394b 2, byte-identical 1 (one event lost) held 1
0f920ef 2, same event, own pendingId 2, no side file left held 2; no pendingId in the output

Real CLI, sandbox HOME, user scope (probe-refold.sh). A live process holds the usage lock and the Claude Skill hook records held-1 in 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:

build held-1 in the usage file after the next hook side files left
064370e 2 0
97a394b 1 0
0f920ef 1 (matched by its pendingId) 0

Real CLI on head fc5f536, sandbox HOME, legacy project install (config and data in <workspace>/.teamai, committed .gitignore that lists only usage.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):

build git status --porcelain --untracked-files=all
8ab9be0 (previous head) ?? .teamai/usage.jsonl.lock, ?? .teamai/usage.pending-<uuid>.jsonl
fc5f536 M .teamai/.gitignore (+usage.jsonl.*, +usage.pending-*.jsonl); no usage file listed

Real CLI on head 583f908, sandbox HOME, user scope. A live process holds the usage lock; hook-dispatch prompt-submit --tool claude fires; the lock goes away; the hook fires again:

step result
lock held, usage file 0600 user-usage.pending-<uuid>.jsonl is -rw------- (was -rw-r--r-- on 42fa895)
lock released, next hook folded in: first, second, third in order; no side file left

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's hook-dispatch post-tool-use --tool <agent> Skill hook fires, then pull:

step claude codex
hook while the lock is held 428 ms; usage file unchanged; 1 side file 442 ms; unchanged; 1 side file
pull while the lock is held 5100 lines, untouched same
holder exits, next hook side file folded in (held-1 + after-1 present), no side file or lock left same
pull 5000 lines, both events kept same

Representative git cell (matrix.sh, git × claude, on 42fa895). A user scope is seeded with 100 legacy then 5,000 review events, mode 0600, and hooks keep firing during the pull (at most 4 in flight):

scenario origin/main this PR
usageReport: false 5101 → 5101 5101 → 5000, then the 7 hooks after the cap; hooks 31/31 kept; mode 600; nothing left beside the file
rejected push, then retry 5101 kept; retry reports legacy 5101 → 5000; retry reports review 4999 + by-claude 1, no legacy; file empty after

The 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):

build ≤ 4 hooks in flight, 5 rounds hundreds of concurrent hooks, 2 rounds
2cce4a8 (carry-over) 43/43 kept, mode 644 202/202 kept
fa67878 (unlocked fallback) 48/48, mode 600 1 of 164 lost; 73 of 657 lost in a heavier run
this PR 45/45, mode 600 287/287 kept, no side file or lock left

Tests, red on the previous head → green:

test before
an append that gives up on the lock while a slow cap rewrites the file is kept fail on b8e857d (5001 lines, late lost)
lock held: the hook records in a side file within 1 s; the next holder folds it in fail on b8e857d (appended unlocked)
lock held: neither the cap nor the truncate rewrites the file fail on b8e857d (rewrote after 5 s)
append issued while the cap holds the lock lands after it; same for the truncate fail on 2cce4a8
two caps plus 5 appends at once: every append kept once fail on 2cce4a8 (late0 twice)
mode 0600 kept; orphan rewrite temp removed; dead-owner lock reclaimed fail on 2cce4a8
generated self-mode .gitignore ignores the lock, a rewrite temp and a side file (git check-ignore) fail on 42fa895
an older self-mode .gitignore that lists only usage.jsonl gains those patterns fail on 42fa895
side file gets the usage file's mode (0600); 0600 while there is no usage file fail on 42fa895 (0644)
legacy project .teamai/.gitignore listing only usage.jsonl: a side file stays out of git status; after a cap the lock and a rewrite temp are ignored fail on 8ab9be0
side file still being written (no newline) is left alone; failed temp write leaves the file byte-identical pass (guards)
disk fills partway through healing the legacy .gitignore: it stays byte-identical, no temp left, the cap still lands fail on fc5f536 (file truncated)
disk fills partway through healing a self-mode .gitignore: it stays byte-identical, no temp left fail on 064370e (config.yam)
a side file that outlives its append (rm fails) is folded once and removed by the next holder fail on 064370e (b twice)
a side-file event identical (skill, tool, ms) to one already in the file is kept fail on 97a394b (['a','b'])
two identical events recorded in two side files at once are both kept fail on 97a394b (['a','b'])
readUsageEvents and teamai stats output carry no pendingId; the reported stats/<user>.yaml neither fail on 97a394b (no id written); report test is a guard

#803's own repro (an append injected between the truncate's readFile and its writeFile), 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: clean
  • npx vitest run: 306 files, 4707 passed, 1 skipped
  • npm run test:e2e: 46 files passed, 3 skipped; 242 tests passed
  • npm run build + the same-millisecond real-CLI run above

CI 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 .gitignore heal in migrateSelfModeGitignore rewrote the file in place) fixed in 97a394b with writeFileAtomic. 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 .gitignore heal rewrote the file in place, so ENOSPC or a kill could leave it empty and expose token/env) fixed in 064370e with writeFileAtomic. P3 (non-idempotent fold) fixed later in 97a394b.

CI review 5813925880: P1 (an existing legacy project .gitignore never 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.

  • A lock that is never released (one that names no owner is never reclaimed, [bug] acquireLock can hand one lock to two live processes #760) stops every rewrite: each cap is skipped after its wait, and a truncate that cannot run leaves the reported events in place, so the next report sends them again. The error names the lock to remove. Meanwhile hooks write side files, which the first holder after removal folds in.
  • A side file is folded by the next lock holder: the next hook append, or the next pull's truncate or cap. Until then teamai stats and the report do not see that event.
  • A side file whose pendingId the 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.
  • Each hook append does the lock's create, link and remove, plus a directory listing for side files: a few ms uncontended.
  • A hook killed mid-acquire (its 4.5 s deadline under extreme load) leaves <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/.gitignore ignores 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.
  • A pull whose report hangs (rather than fails) past the session-start exit is capped by the next pull that runs to completion.
  • Windows: not run (CI is macOS/Ubuntu).

@jeff-r2026 jeff-r2026 self-assigned this Sep 24, 2026
@github-actions

Copy link
Copy Markdown

Findings

  • [P1 blocking] src/usage-tracker.ts:315 — The snapshot-and-rename sequence can delete newly appended usage events. If a hook calls appendUsageEvent() after the read at line 311 but before this rename, it appends to the old file, which is then replaced by the stale 5,000-line snapshot. HTTP and usageReport: false scopes have no synchronization preventing this, so the newest unreported event can be lost. Per-PID temp names avoid temp-file collisions but do not protect concurrent appends to the destination.
  • [P2 non-blocking] src/pull.ts:2023 — The new mocked export was added only in pull-scope-isolation.test.ts. The usage-tracker mocks in pull-learnings-deletion.test.ts, pull-placement-reconcile.test.ts, pull-publishes-queue.test.ts, and pull-sync-truth.test.ts omit capUsageEvents. Accessing the missing mock export throws inside the broad auto-report catch, so those tests silently skip the entire reporting block instead of exercising it.

The PR description includes a test plan and real-CLI/e2e verification record, so no testing-description finding is needed.

@github-actions

Copy link
Copy Markdown

Findings

  • [P1 blocking] src/usage-tracker.ts:311 — The read-then-writeFile cap can delete events appended after the read. Scope sync locks do not cover hook writers, and HTTP scopes have no lock at all, so a concurrent hook or pull can append a fresh event that is then overwritten by the stale 5,000-line snapshot. The new slow-report guard only appends before truncation/capping and does not exercise this race.
  • [P2 non-blocking] src/pull.ts:2023 — The full usage-tracker mocks in pull-learnings-deletion.test.ts, pull-placement-reconcile.test.ts, pull-publishes-queue.test.ts, and pull-sync-truth.test.ts still omit capUsageEvents. Its invocation therefore throws inside the broad auto-report catch, allowing those tests to pass without completing the new reporting/capping path.

The PR description includes both a test plan and real-CLI/e2e verification, so no testing-description finding is needed.

@github-actions

Copy link
Copy Markdown

Findings

  • [P1 blocking] src/usage-tracker.ts:311 — The read-then-writeFile cap still races with hook writers. An event appended after the read at line 308 can be erased when the stale 5,000-line snapshot truncates and overwrites the file. Scope locks do not cover hooks, and HTTP scopes have no lock.
  • [P1 blocking] src/usage-tracker.ts:311 — In-place writeFile truncates the usage file before the replacement is durable. A process termination, disk-full error, or partial write can destroy or corrupt all retained events—especially serious for HTTP and usageReport: false scopes where this file is the only statistics source.

Resolved

  • The previously reported missing capUsageEvents exports were added to all affected full-module mocks.
  • The PR description contains a test plan and real-CLI/e2e record. Its record names older commit 0ea7884, which is a note rather than a blocking finding under the review rules.

@github-actions

Copy link
Copy Markdown

Findings

  • [P1 blocking] src/usage-tracker.ts:342 — The carry-over still loses hooks that opened the old inode before the rename but append after source.stat(). The copied range is fixed from that one size snapshot, so later bytes remain on the unlinked inode and disappear when the handle closes. The original concurrent-append data-loss problem therefore remains, albeit with a smaller window.
  • [P1 blocking] src/pull.ts:2076 — Concurrent HTTP pulls can lose events despite the carry-over. If both caps open the original file, the first renames its replacement, a hook appends to that replacement, and the second then renames its stale temp over it, the event exists in neither second pull’s old-file handle nor its temp. HTTP scopes deliberately have no cross-process lock.
  • [P1 blocking] src/usage-tracker.ts:327 — After the rename, failure or termination during copyAppended() cannot restore the old file. An ENOSPC, short/failed append, or process kill at this point can discard or partially copy the old inode’s concurrent tail, contradicting the claimed crash/full-disk safety.
  • [P2 non-blocking] src/usage-tracker.ts:326 — Replacing the file with a newly created temp resets its permissions to the process umask. A user-restricted usage file such as mode 0600 can become 0644; preserve the source mode on the replacement.

Resolved

  • The direct in-place truncation/durability issue is fixed for the initial capped snapshot.
  • The missing capUsageEvents exports were added to the affected mocks.
  • The PR description includes a test plan and real-CLI/e2e verification, so no testing-description finding is needed.

@jeff-r2026 jeff-r2026 assigned jeff-r2026 and unassigned jeff-r2026 Sep 24, 2026
@github-actions

Copy link
Copy Markdown

Findings

  • [P1 blocking] src/usage-tracker.ts:342 — A hook that opens the old file before the rename but writes after source.stat() leaves its event on the unlinked inode; copyAppended() never sees it, so the event is permanently lost.
  • [P1 blocking] src/pull.ts:2076 — Concurrent HTTP pulls can overwrite newly recorded events: both read the original file, the first renames its snapshot, a hook appends there, then the second renames its stale snapshot over it. HTTP scopes have no synchronization preventing this.
  • [P1 blocking] src/usage-tracker.ts:329 — Once the rename succeeds, an ENOSPC, I/O failure, or process termination during copyAppended() cannot restore the old file path, permanently discarding some or all concurrently appended tail bytes.
  • [P2 non-blocking] src/usage-tracker.ts:326 — Replacing the file with a newly created temporary file resets permissions. For example, a 0600 usage file becomes 0644 under a typical umask; preserve the source mode before renaming.

Resolved

  • All affected full-module mocks now export capUsageEvents.
  • The pre-rename write failure leaves the original file intact.
  • The PR description includes a test plan and real-CLI/e2e verification, so no testing-description finding is needed.

@github-actions

Copy link
Copy Markdown

Findings

  • [P1 blocking] src/usage-tracker.ts:336 — withUsageLock() deliberately runs writers without the lock after timeout. If rewriting an oversized or slow usage file takes over 250 ms, a hook times out, appends to the old path, and the subsequent rename replaces that file, permanently losing the event. The 5-second rewrite fallback similarly allows concurrent caps to overwrite each other, so the original data-loss race remains.

Resolved

  • The old-inode carry-over and post-rename copy failure paths were removed.
  • Replacement files now preserve the original mode.
  • All affected full-module mocks export capUsageEvents.
  • The PR description includes a sufficient real-CLI/e2e record; its older commit reference is non-blocking.

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.
@github-actions

Copy link
Copy Markdown

Findings

  • [P1 blocking] src/usage-tracker.ts:336 — Writers proceed without synchronization after the lock timeout. If capping an existing large file takes over 250 ms, a hook can time out, append to the current file, and then have that event erased when the cap renames its stale snapshot over the file. Likewise, a rewrite exceeding five seconds allows concurrent caps to overwrite each other. This preserves the original data-loss race for precisely the oversized files this change targets.

Resolved

  • The old-inode carry-over and post-rename copy failure paths are removed.
  • Replacement files now preserve the original mode, and all affected mocks export capUsageEvents.
  • The PR description includes sufficient tests and real-CLI verification; the record referencing an older commit is non-blocking under the review rules.

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.
@github-actions

Copy link
Copy Markdown

Findings

  • [P1 blocking] src/usage-tracker.ts:246 — The new pending files are not covered by the generated in-directory .teamai/.gitignore, which ignores only usage.jsonl. In a supported legacy/self in-workspace data home, a hook timing out on the lock leaves .teamai/usage.pending-<uuid>.jsonl; a subsequent git add . stages that machine-local usage event and can publish it. Add the pending and rewrite-temp patterns to every applicable generated/migrated gitignore.
  • [P2 non-blocking] src/usage-tracker.ts:247 — Pending files are created with the process umask rather than the usage file’s permissions. A user who restricted usage.jsonl to 0600 can have the same event exposed through a typically 0644 pending file.
  • [P3 nit] src/usage-tracker.ts:374 — Folding is not idempotent: termination or an rm failure after appendFile succeeds leaves the pending file in place, so the next lock holder appends the same event again and inflates usage statistics.

Resolved

  • The usage lock and pending-file fallback resolve the previously reported concurrent append/cap races.
  • Atomic replacement now preserves the original file mode and avoids in-place truncation corruption.
  • All affected mocks export capUsageEvents.
  • The PR description includes sufficient tests and real-CLI verification; its record naming older commit 2cce4a8 is non-blocking under the review rules.

@github-actions

Copy link
Copy Markdown

Findings

  • [P1 blocking] src/init.ts:1725 — These patterns are added only when the project-scope .teamai/.gitignore is newly created. Existing legacy project installs retain an ignore file containing only usage.jsonl; a concurrent hook can therefore create a trackable usage.pending-<uuid>.jsonl, and git add . can commit machine-local usage data. Migrate existing project-scope ignore files as well.
  • [P3 nit] src/usage-tracker.ts:377 — Pending-file folding remains non-idempotent. If the process exits or rm fails after appendFile succeeds, the next lock holder appends the same event again, inflating statistics.

Resolved

  • The earlier append/cap races, unsafe in-place rewrite, permission regression, and incomplete mocks are fixed.
  • The PR description includes sufficient testing and representative real-CLI verification; its older commit reference is non-blocking.

…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.
@github-actions

Copy link
Copy Markdown

Findings

  • [P3 nit] src/usage-tracker.ts:440 — The legacy project .gitignore migration rewrites the file in place. An ENOSPC error or termination after truncation can leave it empty/partial, exposing entries such as token and env to a later git add .; use an atomic same-directory replacement.
  • [P3 nit] src/usage-tracker.ts:378 — Pending-file folding remains non-idempotent. If termination or rm failure occurs after the append, the pending file is processed again by the next holder, duplicating the event and inflating statistics.

Resolved

  • The previous append/cap races, unsafe usage-file rewrite, permission regression, legacy gitignore omission, and incomplete mocks are fixed.
  • The PR description includes sufficient tests and representative real-CLI verification; the older commit references are non-blocking.

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.
@github-actions

Copy link
Copy Markdown

Findings

  • [P3 nit] src/init.ts:705 — Existing self-mode .teamai/.gitignore files missing the new patterns are still rewritten with the non-atomic writeFile at line 726. An ENOSPC error or termination after truncation can leave the file partial and expose entries such as token or env.local; use writeFileAtomic.
  • [P3 nit] src/usage-tracker.ts:378 — Pending-file folding remains non-idempotent. If the process exits or rm fails after the append succeeds, the next lock holder appends the same event again, inflating usage statistics.

Resolved

  • The earlier append/cap races, unsafe usage-file rewrite, permission regression, legacy project gitignore omission, and incomplete mocks are fixed.
  • The PR description includes sufficient tests and representative real-CLI verification; no testing-description finding is needed.

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.
@github-actions

Copy link
Copy Markdown

Findings

  • [P1 blocking] src/usage-tracker.ts:382 — Deduplicating by the complete JSON line drops legitimate events. If two concurrent hooks record the same skill/tool within the same millisecond while the lock is held, their pending files are identical; the first is appended, while the second is treated as already folded and deleted, silently undercounting usage. Use a unique event identifier rather than payload equality for idempotency.

Resolved

  • The previously reported locking races, atomic-write and permission issues, gitignore migration gaps, and incomplete mocks are fixed.
  • The PR description includes sufficient tests and representative real-CLI verification; its older tested commit is non-blocking under the review rules.

…#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.
@github-actions

Copy link
Copy Markdown

Findings

  • [P1 blocking] src/usage-tracker.ts:239 — The promised unique pending-event ID was added only to tests, not production code. Side files still contain JSON.stringify(event), and foldPendingEvents() still deduplicates by the complete line at src/usage-tracker.ts:382. Two identical concurrent events therefore cause the second side file to be deleted without recording its event. The new assertion expecting "pendingId" at src/__tests__/usage-tracking.test.ts:506 cannot pass against this commit.
  • [P3 nit] src/usage-tracker.ts:386 — Folding is still not idempotent across reporting. If removal fails after a side file is appended, a successful report can truncate that line; the next lock holder then sees no match and appends the pending event again, duplicating its statistics.

Resolved

  • The earlier append/cap races, unsafe rewrites, permission regression, gitignore migration gaps, and incomplete mocks are fixed.
  • The PR description contains a test plan and representative real-CLI verification, so no missing-testing-description finding is needed.

@github-actions

Copy link
Copy Markdown

Findings

  • [P3 nit] src/usage-tracker.ts:240 — Legacy project .gitignore migration occurs only after lock acquisition, and successful appends never invoke it. If the process exits while an ordinary append holds the first usage.jsonl.lock, that machine-local lock remains unignored and can be staged by git add .; heal the ignore file before creating the first usage lock.
  • [P3 nit] src/usage-tracker.ts:393 — Folding is not fully idempotent if pending-file removal repeatedly fails. A successful report can subsequently truncate the ID-bearing line while the side file remains; once removal starts succeeding, a later holder appends that already-reported event again and inflates statistics.

Resolved

  • The pending-event ID is now written by production code, so identical concurrent events are preserved.
  • The previously reported append/cap races, unsafe rewrites, permission regression, and missing mocks are fixed.
  • The PR description includes representative real-CLI verification. Its latest recorded test commit predates 0f920ef, but that is non-blocking under the review rules.

@jeff-r2026
jeff-r2026 self-requested a review September 24, 2026 15:43
@jeff-r2026
jeff-r2026 merged commit a725574 into Tencent:main Sep 24, 2026
11 checks passed
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.
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>
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.

Concurrency: usage.jsonl lost update — lock-free append races truncate rewrite (data loss) Cap usage.jsonl in scopes that never report

2 participants