Skip to content

fix(reports): keep teamai-reports reads fresh and read-only - #560

Merged
jeff-r2026 merged 6 commits into
Tencent:mainfrom
jimpablo:fix/557-558-reports-refresh
Sep 16, 2026
Merged

jeff-r2026 merged 6 commits into
Tencent:mainfrom
jimpablo:fix/557-558-reports-refresh

Conversation

@jimpablo

@jimpablo jimpablo commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes the two follow-ups to #489 for independent git clones:

A related writer-side problem (writers merge per-member files into a stale checkout) has been split out to #561 to keep this PR small.

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature causing existing behavior to change)
  • Documentation only
  • Refactor / internal cleanup

What changed

Source: 8 files, +134/−54.

  • fix(members): avoid publishing reports branch from read-only queries #558: read-only callers pass pushIfCreated: false. These are members, digest, projects members, stats, viz, the vote read in contribute, and pull's votes/stats reads.
  • Cold start reuses an unpublished local teamai-reports branch. Previously, removing reports-wt before the branch was published made the next cold start fail with a branch named 'teamai-reports' already exists.
  • refreshReportsWorktree now syncs under the reports lock. Before, it ran an unlocked git reset --hard origin/teamai-reports.
    • Offline: keeps the local copy.
    • No unpushed commits: fast-forwards to origin.
    • Uncommitted report files are carried along, never discarded.
    • Unpushed report commits (e.g. a push that failed offline) are rebased onto origin. They are dropped only when they conflict, so a diverged checkout does not stay stuck.
    • If a writer holds the lock, the refresh reads the local copy instead.
  • fix(pull): refresh reports worktree before reading votes and stats #557 pull refreshes the reports worktree once per scope, before the search index (votes → hotness) and skill recommendations. The pull auto-report runs after this, so its stats merge also starts from fresh data.
  • EN/ZH docs/usage-guide updated.

Test Plan

  • npx tsc --noEmit — clean
  • npx vitest run — 225 files / 3128 tests passed
  • npm run test:e2e — 27 files passed, 3 skipped (124 tests passed, 26 skipped)
  • Added/updated tests for the change:
    • git-kind-reports.test.ts (real git), cold start:
      • a read-only cold start does not publish the branch
      • an unpublished local branch is reused after reports-wt is removed, and a writer then publishes it
    • git-kind-reports.test.ts (real git), refresh:
      • picks up another member's push
      • an unpushed commit that rebases cleanly is kept and delivered by the next push
      • uncommitted report files survive the refresh
      • a conflicting unpushed commit is dropped and the checkout ends 0 ahead
    • reports-branch-readonly.test.ts:
      • reuse of the local branch, in read-only and writer mode
      • refresh while a writer holds the lock
      • refresh while offline
    • members.test.ts: listing passes pushIfCreated: false
  • Built-CLI end-to-end (npm run build, then node dist/index.js)
    • Setup: real git; a local bare remote whose update hook rejects pushes to main; an isolated HOME per checkout. Members are alice and bob, and alice has two machines (A and B).
    • The same 13-check script was run against main and against this branch.
Check main (c83b9b1) this PR
teamai members on a cold start leaves origin without teamai-reports (#558) ❌ branch published
teamai digest — same
teamai stats — same
teamai projects members demo — same
teamai pull with nothing to report — same
Writer session save --push publishes teamai-reports
bob pushes votes/bob.yaml, alice pull --forceguide.md hotness 1 (#557) ❌ hotness missing
alice's reports checkout has votes/bob.yaml after pull
Default branch still has no report files
pull auto-report from A publishes stats/alice.yaml (1 session) ❌ nothing published
pull auto-report from B merges onto A (1 + 2 = 3 sessions) ❌ 2
teamai members still runs
Out of scope: session save --push B(b0) → A(a1) → B(b1), all three on origin alice-a1 lost ❌ same — writer side, #561
Total (in scope) 3/12 12/12
  • Matrix on this PR: teamai.yaml provider git / gitlab / github × config agent claude / codex / codebuddy / opencode. All 12 combinations passed 12/12 in-scope checks. The out-of-scope writer check behaves as on main in every combination.
    • The remote is a local bare repo in every run. The reports-branch code only uses plain git fetch/push, so no hosted GitLab/GitHub API is involved.
  • Self mode shares this code path. It is covered by the unit suites (reports-branch-readonly uses a self config; self-mode-no-business-reset, single-repo-mode), not by the CLI E2E above.

Related Issues

Closes #557
Closes #558

Follow-up to #489. Writer-side follow-up: #561.

Notes for Reviewers

https://claude.ai/code/session_01TFtw1GRR7pJNTGzNCikoKe

Tencent#489 moved independent git clones onto the teamai-reports worktree but kept
the single-repo reader/writer patterns, which regressed git-kind clones:
read-only commands published a missing teamai-reports branch (Tencent#558), pull
indexed votes and read stats from a stale reports checkout (Tencent#557), and
writers merged per-member files into that stale copy, so a second machine of
the same member stayed diverged and its reports never reached origin.

- Read-only callers (members, digest, projects members, stats, viz, the vote
  read in contribute, pull's votes/stats reads) pass pushIfCreated: false.
- A cold start reuses an unpublished local teamai-reports branch instead of
  failing with "a branch named 'teamai-reports' already exists".
- refreshReportsWorktree syncs under the reports lock: offline keeps the local
  copy, unpushed report commits are rebased onto origin and dropped only when
  they conflict, and a busy lock reads the local copy.
- pull refreshes the reports worktree once per scope before the search index
  and skill recommendations.
- updateReports() replaces ensure + write + commitAndPushReports: it syncs,
  runs the merge/write, commits and pushes under one lock. Pull auto-report,
  Stop-hook votes, session save --push, init and bootstrap member
  registration use it; auto-report and the Stop hook skip the round-trip when
  nothing is pending.

Closes Tencent#557
Closes Tencent#558

Claude-Session: https://claude.ai/code/session_01TFtw1GRR7pJNTGzNCikoKe
…iter changes to Tencent#561

Keep this PR to what the two issues ask for: read-only callers never publish
teamai-reports, cold start reuses an unpublished local branch, and pull
refreshes the reports worktree (under the reports lock) before reading votes
and stats. Pull's auto-report runs after that refresh, so its stats merge
also starts from fresh data.

The writer-side refactor (updateReports replacing commitAndPushReports in
init, bootstrap, session save, the Stop hook and auto-report) is reverted
here and proposed separately in Tencent#561.

Because writers still write before taking the lock, the refresh no longer
discards uncommitted report files: it fast-forwards, or rebases unpushed
commits with --autostash, and only resets a clean worktree whose unpushed
commits conflict with origin.

Claude-Session: https://claude.ai/code/session_01TFtw1GRR7pJNTGzNCikoKe
@jeff-r2026

Copy link
Copy Markdown
Collaborator

Found a locally reproducible issue that I recommend fixing before merging.

[P1] Handle autostash conflicts after a successful rebase

In src/utils/reports-branch.ts, syncReportsWorktree() returns immediately after git.rebase(['--autostash', upstream]). Git can return success when the rebase itself succeeds but reapplying the autostash conflicts, so the catch/abort path does not run. A read-only command can then leave report YAML containing conflict markers and an unmerged index.

Reproduced against PR head c77571db with a real local bare remote and two independent clones:

  1. Publish stats/alice.yaml containing n: 1.
  2. On machine A, commit a new session report locally without pushing it.
  3. On A, change stats/alice.yaml to n: 3 without committing.
  4. On machine B, update the same stats file to n: 2 and push.
  5. On A, run the built CLI: node dist/index.js members.

The command exits successfully (No team members registered), but the stats file becomes:

<<<<<<< Updated upstream
n: 2
=======
n: 3
>>>>>>> Stashed changes

git status --porcelain reports UU stats/alice.yaml, and git stash list retains an autostash entry. This leaves invalid report content for subsequent readers and an unresolved conflict for writers.

Suggestion: explicitly handle autostash restoration conflicts rather than treating a successful rebase as proof that the worktree is safe. Check the post-rebase index/worktree state and provide a recovery path that preserves the original uncommitted report content without leaving conflict markers. A conservative fallback is to skip rebasing a dirty checkout and keep its local copy until it can be refreshed safely. Please add a real-git regression covering the combination of an unpushed commit, a dirty tracked report, and an upstream change to that same report.

Validation: npm run build passed; the existing 20 tests across git-kind-reports.test.ts and reports-branch-readonly.test.ts passed. An added regression failed with the conflict content above, both through refreshReportsWorktree() directly and through the built members CLI command.

git rebase --autostash can exit 0 when the rebase itself succeeds but
reapplying the autostash conflicts. That left UU paths, conflict markers
in report YAML, and a leftover autostash stash, so a read-only command
could serve invalid stats.

After rebase/merge, inspect the worktree. If the rebase has finished and
stash-apply conflicts remain, restore the original uncommitted content
(the stash --theirs side) and drop the autostash instead of treating
exit 0 as proof that the checkout is clean.
@jimpablo

Copy link
Copy Markdown
Contributor Author

Addressed the P1 autostash leftover in fdf3349 (appended, no force-push).

git rebase --autostash can exit 0 after the rebase itself succeeds while reapplying the autostash conflicts. The refresh used to treat that as a clean update, which left UU paths, conflict markers in report YAML, and a leftover autostash stash.

syncReportsWorktree() now inspects the worktree after rebase/merge. If the rebase has finished and stash-apply conflicts remain, it restores the original uncommitted content (stash --theirs), unstages it, and drops the autostash. A rebase that is still in progress is left for the existing abort path.

Added a real-git regression in git-kind-reports.test.ts: unpushed commit + dirty tracked stats/alice.yaml + origin changing that same file. After refreshReportsWorktree(), the file is still n: 3, there are no conflict markers, the index is not unmerged, and no autostash remains.

Validation:

  • npx tsc --noEmit clean
  • npx vitest run — 225 files / 3129 tests passed
  • npm run test:e2e — 27 files passed, 3 skipped
  • Built CLI: same two-clone setup as the report, then node dist/index.js members — exits 0 (No team members registered), stats/alice.yaml stays n: 3, porcelain is M stats/alice.yaml (not UU), stash list empty

@jeff-r2026

Copy link
Copy Markdown
Collaborator

Need to resolve conflicts

Resolve the conflict with Tencent#562 in reports-branch-readonly tests by using
path.join for the reports worktree path. Keep Tencent#560's read-only refresh
and autostash recovery together with Tencent#562's pushIfUnchanged retry and
pull report-timeout bookkeeping.

The late-report isolation test now only asserts that partition sync-locks
stay held; .reports-lock is released after the earlier read-only refresh.
@jimpablo

Copy link
Copy Markdown
Contributor Author

Merged current main into this branch (96dd874, no force-push).

The conflict was with #562 (fix(stats): finish report bookkeeping after pull timeout):

The late-report isolation test now only asserts that partition .sync-lock stays held until the late report finishes. .reports-lock is acquired and released around the earlier read-only refresh, which is independent of that wait.

Validation after the merge:

@jeff-r2026

Copy link
Copy Markdown
Collaborator

Local review of 96dd87426bba3cd98afbee4bcf96caa0899aa6d2: I recommend fixing the following P1 before merging.

[P1] Reports refresh deletes unrelated business-worktree stashes

In dropRebaseAutostash (src/utils/reports-branch.ts, lines 344–352), the cleanup scans the entire stash list and repeatedly drops entries whose display text contains autostash.

Git worktrees share refs/stash. In self mode, this can delete an existing stash containing business-code changes from the main worktree, even though the triggering command is read-only.

Reproduced locally with the built CLI, using an isolated HOME, a local bare origin, a self-mode business checkout with its reports worktree, and a second reports checkout:

  1. Publish stats/alice.yaml with n: 1.
  2. Create an independent, unpushed session commit in the reports worktree.
  3. In the business main worktree, change app.txt and save it with git stash push -m autostash.
  4. Leave the tracked report stats/alice.yaml dirty locally with n: 3.
  5. From the second checkout, publish stats/alice.yaml with n: 2.
  6. Run node dist/index.js members. The reports rebase succeeds, applying its autostash conflicts, and cleanup deletes both its own autostash and the pre-existing business stash.
Observation origin/main (556293ee) This PR (96dd874)
CLI exit code 0 0
Stash before stash@{0}: On main: autostash stash@{0}: On main: autostash
Stash after Original stash preserved Empty

The stash reference is silently removed; its objects may remain recoverable until Git garbage collection.

Suggested fix: identify the specific stash object created by the current refresh and clean up only that object after restoring its contents successfully. Do not select stash entries by message text. Please add a real-Git self-mode regression test that preserves a pre-existing business-worktree stash.

Validation on PR head: npm run build, npx tsc --noEmit, and the four focused suites (git-kind-reports, reports-branch-readonly, pull-scope-isolation, members) passed: 52 tests, using the lockfile-installed dependencies and Vitest 3.2.7. The built-CLI reproduction was also run against main to confirm this is an introduced regression.

Git worktrees share refs/stash. dropRebaseAutostash scanned the whole
stash list and dropped every entry whose message contained autostash, so
a self-mode members refresh could delete a business-tree stash created
with `git stash push -m autostash`.

Snapshot stash SHAs before rebase --autostash and drop only objects
created by that refresh. Do not select stashes by message text.
@jimpablo

Copy link
Copy Markdown
Contributor Author

Addressed the P1 shared-stash deletion in 824ccd4 (appended, no force-push).

dropRebaseAutostash was matching every stash whose message contained autostash. Worktrees of the same repo share refs/stash, so a self-mode members refresh could delete a business-tree stash created with git stash push -m autostash.

The refresh now snapshots stash SHAs before rebase --autostash and drops only objects created by that rebase. It does not select stashes by message. Leftover UU recovery still restores the report files but does not walk the shared stash list by name.

Added a real-git self-mode regression: unpushed report commit + dirty stats/alice.yaml + a pre-existing business stash titled autostash + origin changing the same stats file. After refreshReportsWorktree(), the report stays n: 3 with no conflict markers, and the business stash is unchanged.

Validation:

  • npx tsc --noEmit clean
  • npx vitest run — 226 files / 3151 tests passed
  • npm run build then npm run test:e2e — 28 files passed, 3 skipped

@jeff-r2026

Copy link
Copy Markdown
Collaborator

Rechecked 824ccd4d45915f401ef4c058650d655eaa55e318: the original pre-existing-stash reproduction is fixed, and the new regression test passes. However, I reproduced a remaining P1 that should be addressed before merging.

[P1] A concurrently created business stash is mistaken for the reports autostash

stashShasCreatedSince at lines 356–358 selects every stash SHA added since the pre-rebase snapshot. This identifies when a stash appeared, not which operation created it. Because worktrees share refs/stash, a business-worktree stash created while the reports rebase is running is included and subsequently deleted by dropStashShas.

The reports lock does not protect ordinary Git commands in the business worktree. This no longer requires a stash message containing autostash; an ordinary business-wip stash can be removed.

Deterministic reproduction with the built CLI:

Use the previous self-mode setup: an independent unpushed report commit, a dirty tracked report, and a conflicting upstream update. In the isolated test repository, install a temporary pre-rebase hook that signals a marker and waits for a release file. The hook only controls timing; it does not create or drop stashes.

  1. Start node dist/index.js members as a child process.
  2. Wait for the hook marker, after the refresh has taken its stash snapshot.
  3. From a separate process, run git stash push -m business-wip in the business main worktree, saving changes to app.txt.
  4. Release the hook and let the CLI finish.
  5. Inspect the shared stash list.
Observation Before this fix (96dd874) Latest head (824ccd4)
Stash during rebase stash@{0}: On main: business-wip stash@{0}: On main: business-wip
Stash after CLI exits Business stash preserved Empty
CLI exit code 0 0

Reproduced twice on 824ccd4, with 96dd874 as the control.

Suggested fix: identify the exact autostash belonging to this operation, rather than all SHAs added during a time window. A conservative alternative is to skip rebasing a dirty reports checkout or retain a stash when ownership cannot be proven. Please add a real-Git regression where another worktree creates a stash during refresh.

Validation: npm run build, npx tsc --noEmit, and the four focused suites (git-kind-reports, reports-branch-readonly, pull-scope-isolation, members) passed: 53 tests with Vitest 3.2.7. The original built-CLI reproduction now preserves the pre-existing business stash; the concurrent scenario above still deletes it.

stashShasCreatedSince treated every stash SHA that appeared after the
pre-rebase snapshot as this refresh's autostash. Worktrees share
refs/stash, and the reports lock does not serialize ordinary git stash
in the business tree, so a stash created during rebase was deleted.

Stop using rebase --autostash. Snapshot dirty tracked files with
git stash create (a dangling commit, not stored in refs/stash), rebase
on a clean tree, then stash apply that object. Never drop from the
shared stash list.
@jimpablo

Copy link
Copy Markdown
Contributor Author

Confirmed the remaining P1 on 824ccd4: the original pre-existing-stash case was fixed, but stashShasCreatedSince still treated every stash SHA that appeared after the pre-rebase snapshot as this refresh's autostash. The reports lock does not serialize git stash in the business worktree, so a stash created during rebase (even with message business-wip) was deleted.

Addressed in the next commit (appended, no force-push):

  • Stop using git rebase --autostash.
  • Snapshot dirty tracked report files with git stash create (returns a dangling commit, not stored in refs/stash).
  • Rebase on a clean tree, then git stash apply that object. Stash-apply conflicts still restore the original uncommitted files without markers.
  • The refresh no longer reads or drops the shared stash list.

Added a real-git self-mode regression with a pre-rebase hook that only waits: while rebase is in progress, another process runs git stash push -m business-wip in the business tree. After refresh, that stash is unchanged and stats/alice.yaml stays n: 3 with no conflict markers.

Validation:

  • npx tsc --noEmit clean
  • npx vitest run — 226 files / 3152 tests passed
  • npm run build then npm run test:e2e — 28 files passed, 3 skipped

@jeff-r2026
jeff-r2026 merged commit 1734c31 into Tencent:main Sep 16, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(members): avoid publishing reports branch from read-only queries fix(pull): refresh reports worktree before reading votes and stats

2 participants