diff --git a/docs/usage-guide.md b/docs/usage-guide.md index f02787f8..5c61a2ec 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -143,7 +143,7 @@ Resulting directory structure: Independent git clones use the same reports split as single-repo mode: `members/` `sessions/` `votes/` `stats/` are written to the `teamai-reports` orphan branch (the checkout sits **beside** the clone, not inside it). Knowledge (`skills/` `rules/` `docs/` `learnings/` `teamai.yaml`) stays on the default branch. Leftover report files already on `main` are left in place and ignored. -In both modes, commands that only read reports (`members`, `digest`, `projects members`, `stats`, `viz`) never create or push the `teamai-reports` branch. `teamai pull` refreshes the reports checkout from `origin` before it rebuilds the search index (vote hotness) and skill recommendations. +In both modes, commands that only read reports (`members`, `digest`, `projects members`, `stats`, `viz`) never create or push the `teamai-reports` branch. `teamai pull` refreshes the reports checkout from `origin` before it rebuilds the search index (vote hotness) and skill recommendations. Report writers (session save `--push`, Stop-hook votes, member registration, auto-report) merge into origin's latest copy of the member's file first, so the same member reporting from two machines does not lose a session, vote, or stats entry. Project machine-data (config, state, the team-repo clone, search index, MCP manifests, resource cache) lives in a per-project partition under diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 34afa160..870a54fa 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -142,7 +142,7 @@ teamai init https://github.com/yourorg/yourrepo 独立 git clone 与单仓模式使用同一套上报拆分:`members/` `sessions/` `votes/` `stats/` 写到 `teamai-reports` 孤儿分支(检出目录在 clone **旁边**,不嵌在 clone 里)。知识资产(`skills/` `rules/` `docs/` `learnings/` `teamai.yaml`)仍在默认分支。默认分支上已有的上报文件会留在原地并被忽略。 -两种模式下,只读取上报数据的命令(`members`、`digest`、`projects members`、`stats`、`viz`)都不会创建或推送 `teamai-reports` 分支。`teamai pull` 在重建检索索引(投票热度)和技能推荐之前,会先从 `origin` 刷新上报检出。 +两种模式下,只读取上报数据的命令(`members`、`digest`、`projects members`、`stats`、`viz`)都不会创建或推送 `teamai-reports` 分支。`teamai pull` 在重建检索索引(投票热度)和技能推荐之前,会先从 `origin` 刷新上报检出。写入上报(`session save --push`、Stop hook 投票、成员注册、自动上报)会先合并 `origin` 上该成员文件的最新副本,因此同一成员在两台机器上报时不会丢掉会话、投票或统计。 项目的机器数据(config、state、team-repo 克隆、搜索索引、MCP manifest、资源缓存) 存放在 `~/.teamai/projects//` 下的按项目分区里,**不再**放进业务仓库,因此工作区 diff --git a/src/__tests__/e2e/reports-writer-sync-561.test.ts b/src/__tests__/e2e/reports-writer-sync-561.test.ts new file mode 100644 index 00000000..5a8ef097 --- /dev/null +++ b/src/__tests__/e2e/reports-writer-sync-561.test.ts @@ -0,0 +1,163 @@ +/** + * Built-CLI repro for #561: the same member on two machines must not lose + * session-save entries when machine A already has a reports worktree (from + * `teamai members`) that is stale relative to origin. + */ +import { afterEach, describe, expect, it } from 'vitest'; +import { execFileSync, spawn } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import YAML from 'yaml'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); +const cli = path.join(root, 'dist/index.js'); +let sandbox: string; + +function git(args: string[], cwd: string, env: NodeJS.ProcessEnv): string { + return execFileSync('git', args, { cwd, env, encoding: 'utf8', windowsHide: true }); +} + +function machineEnv(home: string): NodeJS.ProcessEnv { + return { + ...process.env, + HOME: home, + USERPROFILE: home, + XDG_CONFIG_HOME: path.join(home, '.config'), + GIT_CONFIG_GLOBAL: path.join(home, '.gitconfig'), + GIT_CONFIG_NOSYSTEM: '1', + GIT_AUTHOR_NAME: 'alice', + GIT_AUTHOR_EMAIL: 'alice@example.invalid', + GIT_COMMITTER_NAME: 'alice', + GIT_COMMITTER_EMAIL: 'alice@example.invalid', + GIT_TERMINAL_PROMPT: '0', + FORCE_COLOR: '0', + GIT_CONFIG_COUNT: '1', + GIT_CONFIG_KEY_0: 'protocol.file.allow', + GIT_CONFIG_VALUE_0: 'always', + }; +} + +function runCli(home: string, args: string[], cwd: string): Promise<{ code: number | null; output: string }> { + const env = machineEnv(home); + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [cli, ...args], { + cwd, + env, + windowsHide: true, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let output = ''; + const timer = setTimeout(() => { + child.kill(); + reject(new Error(`CLI timed out\n${output}`)); + }, 45_000); + const capture = (data: Buffer) => { output += data.toString(); }; + child.stdout.on('data', capture); + child.stderr.on('data', capture); + child.on('error', (error) => { clearTimeout(timer); reject(error); }); + child.on('close', (code) => { + clearTimeout(timer); + resolve({ code, output }); + }); + }); +} + +function seedSession(home: string, sessionId: string, timestamp: string): void { + const dashboard = path.join(home, '.teamai', 'dashboard'); + fs.mkdirSync(dashboard, { recursive: true }); + fs.appendFileSync( + path.join(dashboard, 'events.jsonl'), + `${JSON.stringify({ type: 'session_start', timestamp, sessionId, tool: 'claude', cwd: sandbox })}\n`, + ); +} + +afterEach(() => { + if (sandbox && path.dirname(sandbox) === os.tmpdir() && path.basename(sandbox).startsWith('teamai-561-')) { + fs.rmSync(sandbox, { recursive: true, force: true }); + } +}); + +describe('real CLI reports writer sync (#561)', () => { + it('keeps every session save from two machines of the same member', async () => { + if (!fs.existsSync(cli)) { + throw new Error(`CLI binary not found at ${cli}. Run "npm run build" first.`); + } + + sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-561-')); + const origin = path.join(sandbox, 'origin.git'); + const seed = path.join(sandbox, 'seed'); + const homeA = path.join(sandbox, 'home-a'); + const homeB = path.join(sandbox, 'home-b'); + const env = machineEnv(homeA); + + fs.mkdirSync(seed, { recursive: true }); + fs.writeFileSync(path.join(seed, 'teamai.yaml'), YAML.stringify({ + team: 'acme', + repo: origin, + provider: 'git', + })); + git(['init', '-q', '-b', 'main'], seed, env); + git(['add', '.'], seed, env); + git(['commit', '-q', '-m', 'fixture'], seed, env); + git(['clone', '-q', '--bare', seed, origin], sandbox, env); + + const setupHome = (home: string) => { + fs.mkdirSync(path.join(home, '.claude'), { recursive: true }); + fs.writeFileSync( + path.join(home, '.gitconfig'), + [ + '[user]', + '\tname = alice', + '\temail = alice@example.invalid', + '[protocol "file"]', + '\tallow = always', + '', + ].join('\n'), + ); + const clone = path.join(home, '.teamai', 'team-repo'); + git(['clone', '-q', origin, clone], sandbox, env); + git(['config', 'user.name', 'alice'], clone, machineEnv(home)); + git(['config', 'user.email', 'alice@example.invalid'], clone, machineEnv(home)); + git(['config', 'protocol.file.allow', 'always'], clone, machineEnv(home)); + fs.writeFileSync(path.join(home, '.teamai', 'config.yaml'), YAML.stringify({ + repo: { localPath: clone, remote: origin, kind: 'git' }, + username: 'alice', + scope: 'user', + updatePolicy: 'skip', + enabledAgents: ['claude'], + additionalRoles: [], + })); + }; + + setupHome(homeA); + setupHome(homeB); + + // Required precondition: machine A already has a reports worktree. + const members = await runCli(homeA, ['members'], sandbox); + expect(members.code, members.output).toBe(0); + expect(fs.existsSync(path.join(homeA, '.teamai', 'reports-wt', '.git'))).toBe(true); + + const timestamp = '2026-09-16T12:00:00.000Z'; + const save = (home: string, sessionId: string) => + runCli(home, ['session', 'save', '--session-id', sessionId, '--push', '--force', '--scope', 'user'], sandbox); + + seedSession(homeB, 'alice-b0', timestamp); + const b0 = await save(homeB, 'alice-b0'); + expect(b0.code, b0.output).toBe(0); + + seedSession(homeA, 'alice-a1', timestamp); + const a1 = await save(homeA, 'alice-a1'); + expect(a1.code, a1.output).toBe(0); + + seedSession(homeB, 'alice-b1', timestamp); + const b1 = await save(homeB, 'alice-b1'); + expect(b1.code, b1.output).toBe(0); + + const monthLog = git(['show', 'teamai-reports:sessions/alice/2026-09.md'], origin, env); + expect(monthLog).toContain(''); + expect(monthLog).toContain(''); + expect(monthLog).toContain(''); + }, 60_000); +}); diff --git a/src/__tests__/git-kind-reports.test.ts b/src/__tests__/git-kind-reports.test.ts index 1cbd1793..3edc24c9 100644 --- a/src/__tests__/git-kind-reports.test.ts +++ b/src/__tests__/git-kind-reports.test.ts @@ -9,7 +9,7 @@ import path from 'node:path'; import { simpleGit } from 'simple-git'; import { getReportsDir, REPORTS_WORKTREE_DIRNAME, type LocalConfig } from '../types.js'; -import { commitAndPushReports, ensureReportsWorktree, refreshReportsWorktree } from '../utils/reports-branch.js'; +import { commitAndPushReports, ensureReportsWorktree, refreshReportsWorktree, updateReports } from '../utils/reports-branch.js'; import { pushRepoDirectly } from '../utils/git.js'; import { reportUsageToTeam } from '../team-push.js'; @@ -408,6 +408,26 @@ describe('git-kind reports: refresh before reading (#557)', () => { expect(await publish(machineB, 'members/alice.yaml', 'username: alice\n')).toBe(true); expect(await originReportsFile(origin, 'members/alice.yaml')).toBe('username: alice\n'); }); + + it('merges a stale checkout of the same member onto origin instead of diverging', async () => { + const { origin, clone } = await seedBareOrigin(); + const machineA = gitConfig(clone, origin); + const machineB = await cloneCheckout(origin, 'machine-b'); + + expect(await publish(machineA, 'stats/alice.yaml', 'n: 1\n')).toBe(true); + await ensureReportsWorktree(machineB); + expect(await publish(machineA, 'stats/alice.yaml', 'n: 2\n')).toBe(true); + + const pushed = await updateReports(machineB, async (wt) => { + const statsPath = path.join(wt, 'stats', 'alice.yaml'); + const current = Number(/n: (\d+)/.exec(fs.readFileSync(statsPath, 'utf-8'))![1]); + fs.writeFileSync(statsPath, `n: ${current + 10}\n`); + return { files: ['stats/alice.yaml'], message: '[teamai] Update usage stats for alice' }; + }); + + expect(pushed).toBe(true); + expect(await originReportsFile(origin, 'stats/alice.yaml')).toBe('n: 12\n'); + }); }); describe('self-mode reports: shared stash', () => { diff --git a/src/__tests__/hook-handlers.test.ts b/src/__tests__/hook-handlers.test.ts index 95406cbb..a3497198 100644 --- a/src/__tests__/hook-handlers.test.ts +++ b/src/__tests__/hook-handlers.test.ts @@ -86,18 +86,20 @@ vi.mock('../transcript-parser.js', () => ({ parseTranscriptForVotes: mockParseTranscriptForVotes, })); +const voteMocks = vi.hoisted(() => ({ + hasPendingVoteDeltas: vi.fn().mockResolvedValue(false), +})); vi.mock('../votes.js', () => ({ incrementUpvoted: mockIncrementUpvoted, syncVotesToTeam: mockSyncVotesToTeam, + hasPendingVoteDeltas: (...args: unknown[]) => voteMocks.hasPendingVoteDeltas(...args), })); const reportsBranchMocks = vi.hoisted(() => ({ - ensureReportsWorktree: vi.fn().mockResolvedValue('/tmp/reports-wt'), - commitAndPushReports: vi.fn().mockResolvedValue(true), + updateReports: vi.fn().mockResolvedValue(true), })); vi.mock('../utils/reports-branch.js', () => ({ - ensureReportsWorktree: (...args: unknown[]) => reportsBranchMocks.ensureReportsWorktree(...args), - commitAndPushReports: (...args: unknown[]) => reportsBranchMocks.commitAndPushReports(...args), + updateReports: (...args: unknown[]) => reportsBranchMocks.updateReports(...args), })); const mockSeedProjectAgentRoot = vi.fn().mockResolvedValue(undefined); @@ -665,6 +667,45 @@ describe('hook-handlers registry', () => { expect(mockIncrementUpvoted).not.toHaveBeenCalled(); }); + it('votes-sync skips updateReports when there are no pending vote deltas', async () => { + const registry = buildHandlerRegistry(); + const handler = registry.find( + (r) => r.event === 'stop' && r.handler.name === 'votes-sync', + )!.handler; + + reportsBranchMocks.updateReports.mockClear(); + await handler.execute( + { session_id: 'sid-skip-reports', cwd: '/x', transcript_path: '/t/transcript.jsonl' }, + 'claude', + ); + expect(reportsBranchMocks.updateReports).not.toHaveBeenCalled(); + }); + + it('votes-sync writes votes through updateReports when deltas are pending', async () => { + const registry = buildHandlerRegistry(); + const handler = registry.find( + (r) => r.event === 'stop' && r.handler.name === 'votes-sync', + )!.handler; + + voteMocks.hasPendingVoteDeltas.mockResolvedValueOnce(true); + mockSyncVotesToTeam.mockResolvedValueOnce(true); + reportsBranchMocks.updateReports.mockClear(); + reportsBranchMocks.updateReports.mockImplementationOnce( + async (_cfg: unknown, write: (wt: string) => Promise) => { + await write('/wt'); + return true; + }, + ); + + await handler.execute( + { session_id: 'sid-write-reports', cwd: '/x', transcript_path: '/t/transcript.jsonl' }, + 'claude', + ); + + expect(reportsBranchMocks.updateReports).toHaveBeenCalledOnce(); + expect(mockSyncVotesToTeam).toHaveBeenCalledWith('/wt', 'test', expect.any(String)); + }); + it('votes-sync: incrementUpvoted receives all ids when referenced and recalled are identical', async () => { const registry = buildHandlerRegistry(); const handler = registry.find( diff --git a/src/__tests__/reports-branch-readonly.test.ts b/src/__tests__/reports-branch-readonly.test.ts index 6b369727..2cdc596e 100644 --- a/src/__tests__/reports-branch-readonly.test.ts +++ b/src/__tests__/reports-branch-readonly.test.ts @@ -52,7 +52,7 @@ vi.mock('../update.js', () => ({ })); import { acquireLock, releaseLock } from '../update.js'; -import { commitAndPushReports, ensureReportsWorktree, refreshReportsWorktree } from '../utils/reports-branch.js'; +import { commitAndPushReports, ensureReportsWorktree, refreshReportsWorktree, updateReports } from '../utils/reports-branch.js'; const config: LocalConfig = { repo: { @@ -198,3 +198,19 @@ describe('refreshReportsWorktree', () => { expect(releaseLock).toHaveBeenCalledOnce(); }); }); + +describe('updateReports', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(acquireLock).mockResolvedValue(false); + }); + + it('does not run the write callback when another reports write holds the lock', async () => { + const write = vi.fn(); + + expect(await updateReports(config, write)).toBe(false); + + expect(write).not.toHaveBeenCalled(); + expect(releaseLock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/team-push-interventions.test.ts b/src/__tests__/team-push-interventions.test.ts index e590dd8e..f46d1915 100644 --- a/src/__tests__/team-push-interventions.test.ts +++ b/src/__tests__/team-push-interventions.test.ts @@ -9,8 +9,7 @@ import type { LocalConfig } from '../types.js'; // (delta → stats yaml → reported snapshot) without a real repo/remote. const pushRepoDirectly = vi.fn().mockResolvedValue(undefined); const reportsMocks = vi.hoisted(() => ({ - commitAndPushReports: vi.fn().mockResolvedValue(true), - ensureReportsWorktree: vi.fn(), + updateReports: vi.fn(), })); vi.mock('../utils/git.js', () => ({ createGit: vi.fn(() => ({})), @@ -20,8 +19,7 @@ vi.mock('../utils/git.js', () => ({ isDedicatedRepoRoot: vi.fn().mockResolvedValue(true), })); vi.mock('../utils/reports-branch.js', () => ({ - ensureReportsWorktree: (...args: unknown[]) => reportsMocks.ensureReportsWorktree(...args), - commitAndPushReports: (...args: unknown[]) => reportsMocks.commitAndPushReports(...args), + updateReports: (...args: unknown[]) => reportsMocks.updateReports(...args), })); vi.mock('../utils/logger.js', () => ({ log: { info: vi.fn(), success: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, @@ -63,11 +61,11 @@ beforeEach(() => { repoDir = path.join(tmpDir, 'repo'); fs.mkdirSync(repoDir, { recursive: true }); pushRepoDirectly.mockReset().mockResolvedValue(undefined); - reportsMocks.commitAndPushReports.mockClear().mockResolvedValue(true); - reportsMocks.ensureReportsWorktree.mockReset().mockImplementation(async (cfg: LocalConfig) => { + reportsMocks.updateReports.mockReset().mockImplementation(async (cfg: LocalConfig, write: (wt: string) => Promise<{ files: string[]; message: string } | null>) => { const dir = path.join(path.dirname(cfg.repo.localPath), 'reports-wt'); fs.mkdirSync(dir, { recursive: true }); - return dir; + const change = await write(dir); + return change != null; }); }); @@ -106,8 +104,16 @@ describe('reportUsageToTeam — intervention reporting', () => { const pushStarted = new Promise((resolve) => { started = resolve; }); const pushResult = new Promise((resolve) => { finish = resolve; }); const delayedPush = () => { started(); return pushResult; }; - if (backend === 'reports') reportsMocks.commitAndPushReports.mockImplementationOnce(delayedPush); - else pushRepoDirectly.mockImplementationOnce(delayedPush); + if (backend === 'reports') { + reportsMocks.updateReports.mockImplementationOnce(async (cfg: LocalConfig, write: (wt: string) => Promise<{ files: string[]; message: string } | null>) => { + const dir = path.join(path.dirname(cfg.repo.localPath), 'reports-wt'); + fs.mkdirSync(dir, { recursive: true }); + await write(dir); + return delayedPush(); + }); + } else { + pushRepoDirectly.mockImplementationOnce(delayedPush); + } const operation = reportUsageToTeam(repoDir, 'me', backend === 'reports' ? { selfConfig: gitConfig() } : undefined); const timeout = expect(withTimeout(operation, 5000, 'report pending')).rejects.toThrow('report pending'); @@ -133,8 +139,8 @@ describe('reportUsageToTeam — intervention reporting', () => { it.each(['false', 'rejection'])('retains events and snapshots when push returns %s', async (failure) => { const usagePath = seedReport(); const before = fs.readFileSync(usagePath, 'utf-8'); - if (failure === 'false') reportsMocks.commitAndPushReports.mockResolvedValueOnce(false); - else reportsMocks.commitAndPushReports.mockRejectedValueOnce(new Error('offline')); + if (failure === 'false') reportsMocks.updateReports.mockResolvedValueOnce(false); + else reportsMocks.updateReports.mockRejectedValueOnce(new Error('offline')); expect(await reportUsageToTeam(repoDir, 'me', { selfConfig: gitConfig() })).toBe(false); expect(fs.readFileSync(usagePath, 'utf-8')).toBe(before); for (const name of ['interventions', 'prompt-tokens', 'daily-sessions']) { @@ -164,8 +170,7 @@ describe('reportUsageToTeam — intervention reporting', () => { expect(stats.daily[ts.slice(0, 10)]).toMatchObject({ sessionsEnded: 1, sessionsSucceeded: 0 }); expect(pushRepoDirectly).not.toHaveBeenCalled(); - expect(reportsMocks.commitAndPushReports).toHaveBeenCalledTimes(1); - expect(reportsMocks.commitAndPushReports.mock.calls[0][2]).toContain('stats/me.yaml'); + expect(reportsMocks.updateReports).toHaveBeenCalledTimes(1); // reported snapshot persisted so a second run reports nothing new const reportedPath = path.join(tmpDir, '.teamai', 'dashboard', 'reported-interventions.json'); @@ -175,16 +180,16 @@ describe('reportUsageToTeam — intervention reporting', () => { const dailyPath = path.join(tmpDir, '.teamai', 'dashboard', 'reported-daily-sessions.json'); expect(JSON.parse(fs.readFileSync(dailyPath, 'utf-8')).s1.date).toBe(ts.slice(0, 10)); - reportsMocks.commitAndPushReports.mockClear(); + reportsMocks.updateReports.mockClear(); await reportUsageToTeam(repoDir, 'me', { selfConfig: gitConfig() }); // Nothing new (no usage, no intervention delta, no votes) → no push - expect(reportsMocks.commitAndPushReports).not.toHaveBeenCalled(); + expect(reportsMocks.updateReports).not.toHaveBeenCalled(); expect(pushRepoDirectly).not.toHaveBeenCalled(); }); it('does nothing when there are no events, interventions, or votes', async () => { await reportUsageToTeam(repoDir, 'me', { selfConfig: gitConfig() }); - expect(reportsMocks.commitAndPushReports).not.toHaveBeenCalled(); + expect(reportsMocks.updateReports).not.toHaveBeenCalled(); expect(pushRepoDirectly).not.toHaveBeenCalled(); expect(fs.existsSync(reportsStatsPath())).toBe(false); }); @@ -222,7 +227,7 @@ describe('reportUsageToTeam — preserve fields across partial reports (Issue #4 tokens: { input: 50, output: 20, cacheRead: 0, cacheCreation: 0 }, }, ]); - reportsMocks.commitAndPushReports.mockClear(); + reportsMocks.updateReports.mockClear(); await reportUsageToTeam(repoDir, 'me', { selfConfig: gitConfig() }); stats = YAML.parse(fs.readFileSync(statsPath, 'utf-8')); @@ -230,7 +235,7 @@ describe('reportUsageToTeam — preserve fields across partial reports (Issue #4 expect(stats.interventions).toEqual({ sessions: 1, interrupt: 2, toolReject: 1, correction: 0 }); expect(stats.prompts).toBe(2); expect(stats.tokens).toEqual({ input: 50, output: 20, cacheRead: 0, cacheCreation: 0 }); - expect(reportsMocks.commitAndPushReports).toHaveBeenCalledTimes(1); + expect(reportsMocks.updateReports).toHaveBeenCalledTimes(1); expect(pushRepoDirectly).not.toHaveBeenCalled(); }); @@ -262,7 +267,7 @@ describe('reportUsageToTeam — preserve fields across partial reports (Issue #4 tokens: { input: 10, output: 5, cacheRead: 0, cacheCreation: 0 }, }, ]); - reportsMocks.commitAndPushReports.mockClear(); + reportsMocks.updateReports.mockClear(); await reportUsageToTeam(repoDir, 'me', { selfConfig: gitConfig() }); stats = YAML.parse(fs.readFileSync(statsPath, 'utf-8')); @@ -270,7 +275,7 @@ describe('reportUsageToTeam — preserve fields across partial reports (Issue #4 // Must still have prompts/tokens after an intervention-only report expect(stats.prompts).toBe(1); expect(stats.tokens).toEqual({ input: 10, output: 5, cacheRead: 0, cacheCreation: 0 }); - expect(reportsMocks.commitAndPushReports).toHaveBeenCalledTimes(1); + expect(reportsMocks.updateReports).toHaveBeenCalledTimes(1); expect(pushRepoDirectly).not.toHaveBeenCalled(); }); }); diff --git a/src/__tests__/votes.test.ts b/src/__tests__/votes.test.ts index 68101f20..b826210b 100644 --- a/src/__tests__/votes.test.ts +++ b/src/__tests__/votes.test.ts @@ -14,6 +14,7 @@ import { mergeDeltas, syncVotesToTeam, recallFeedback, + hasPendingVoteDeltas, } from '../votes.js'; import type { UserVotes, UserVotesV2 } from '../types.js'; @@ -98,6 +99,29 @@ describe('loadUserVotes', () => { }); }); +describe('hasPendingVoteDeltas', () => { + it('is false when there is no votes file', async () => { + expect(await hasPendingVoteDeltas(tmpDir, 'alice')).toBe(false); + }); + + it('is true only when the local file still holds deltas', async () => { + const filePath = path.join(tmpDir, 'alice.yaml'); + await saveUserVotes(filePath, { + version: 2, + votes: { 'doc-a': { recalled_count: 1, upvoted_count: 0, last_recalled_at: '2026-06-01T00:00:00Z' } }, + deltas: { 'doc-a': { recalled_delta: 1, upvoted_delta: 0 } }, + }); + expect(await hasPendingVoteDeltas(tmpDir, 'alice')).toBe(true); + + await saveUserVotes(filePath, { + version: 2, + votes: { 'doc-a': { recalled_count: 1, upvoted_count: 0, last_recalled_at: '2026-06-01T00:00:00Z' } }, + deltas: {}, + }); + expect(await hasPendingVoteDeltas(tmpDir, 'alice')).toBe(false); + }); +}); + describe('incrementRecalled', () => { it('creates new entry and records delta', async () => { const filePath = path.join(tmpDir, 'user.yaml'); diff --git a/src/bootstrap.ts b/src/bootstrap.ts index 6d1ae318..885a7655 100644 --- a/src/bootstrap.ts +++ b/src/bootstrap.ts @@ -208,19 +208,19 @@ export async function bootstrapSelfRepo( // Register member on the reports orphan branch. Best-effort: no write access // just means the member isn't listed — they still get the knowledge. try { - const { ensureReportsWorktree, commitAndPushReports } = await import('./utils/reports-branch.js'); - const wt = await ensureReportsWorktree(localConfig); - const memberDir = path.join(wt, 'members'); - await ensureDir(memberDir); - const memberPath = path.join(memberDir, `${username}.yaml`); - if (!(await pathExists(memberPath))) { + const { updateReports } = await import('./utils/reports-branch.js'); + await updateReports(localConfig, async (wt) => { + const memberDir = path.join(wt, 'members'); + await ensureDir(memberDir); + const memberPath = path.join(memberDir, `${username}.yaml`); + if (await pathExists(memberPath)) return null; await writeFile(memberPath, YAML.stringify({ username, displayName: username, registeredAt: new Date().toISOString(), })); - await commitAndPushReports(localConfig, `[teamai] Register member: ${username}`, ['members/']); - } + return { files: ['members/'], message: `[teamai] Register member: ${username}` }; + }); } catch (e) { log.debug(`[bootstrap] member registration skipped (non-blocking): ${(e as Error).message}`); } diff --git a/src/hook-handlers.ts b/src/hook-handlers.ts index eb41f481..12d29b91 100644 --- a/src/hook-handlers.ts +++ b/src/hook-handlers.ts @@ -333,12 +333,19 @@ const votesSyncHandler: HookHandler = { if (usesReportsBranch(localConfig)) { // Votes are report data → the teamai-reports orphan branch, written // through an isolated worktree (never the default branch / active tree). + // Stop fires every turn: skip the fetch when nothing is pending. try { - const { ensureReportsWorktree, commitAndPushReports } = await import('./utils/reports-branch.js'); - const wt = await ensureReportsWorktree(localConfig); - const synced = await syncVotesToTeam(wt, localConfig.username, votesDir); - if (synced) { - await commitAndPushReports(localConfig, `[teamai] Update votes for ${localConfig.username}`, [`votes/${localConfig.username}.yaml`]); + const { hasPendingVoteDeltas } = await import('./votes.js'); + if (await hasPendingVoteDeltas(votesDir, localConfig.username)) { + const { updateReports } = await import('./utils/reports-branch.js'); + await updateReports(localConfig, async (wt) => ( + await syncVotesToTeam(wt, localConfig.username, votesDir) + ? { + files: [`votes/${localConfig.username}.yaml`], + message: `[teamai] Update votes for ${localConfig.username}`, + } + : null + )); } } catch { // Push failed — will retry next session diff --git a/src/init.ts b/src/init.ts index c6181700..eaa656e5 100644 --- a/src/init.ts +++ b/src/init.ts @@ -925,22 +925,30 @@ export async function initSelfRepo(options: GlobalOptions & { // Step 6: register member on the reports orphan branch (never touches main / active tree). if (!options.dryRun) { try { - const { ensureReportsWorktree, commitAndPushReports } = await import('./utils/reports-branch.js'); - const wt = await ensureReportsWorktree(localConfig); - const memberDir = path.join(wt, 'members'); - await ensureDir(memberDir); - const memberPath = path.join(memberDir, `${username}.yaml`); - const isNewSelfMember = !await pathExists(memberPath); - const existingSelfMember = await getMemberConfig(wt, username); - const { config: selfMemberConfig, changed: selfMemberChanged } = mergeMemberConfig(existingSelfMember, { - username, - projects: localConfig.projects, + const { updateReports } = await import('./utils/reports-branch.js'); + let isNewSelfMember = false; + let selfMemberChanged = false; + const pushed = await updateReports(localConfig, async (wt) => { + const memberDir = path.join(wt, 'members'); + await ensureDir(memberDir); + const memberPath = path.join(memberDir, `${username}.yaml`); + isNewSelfMember = !await pathExists(memberPath); + const existingSelfMember = await getMemberConfig(wt, username); + const merged = mergeMemberConfig(existingSelfMember, { + username, + projects: localConfig.projects, + }); + selfMemberChanged = merged.changed; + if (!merged.changed) return null; + await writeFile(memberPath, YAML.stringify(merged.config)); + return { + files: ['members/'], + message: isNewSelfMember + ? `[teamai] Register member: ${username}` + : `[teamai] Update member roster: ${username}`, + }; }); if (selfMemberChanged) { - await writeFile(memberPath, YAML.stringify(selfMemberConfig)); - const pushed = await commitAndPushReports(localConfig, isNewSelfMember - ? `[teamai] Register member: ${username}` - : `[teamai] Update member roster: ${username}`, ['members/']); if (pushed) { log.success(isNewSelfMember ? 'Member registered on the teamai-reports branch' @@ -1364,25 +1372,34 @@ export async function init(options: GlobalOptions & { let isNewMember = true; if (!options.dryRun) { try { - const { ensureReportsWorktree, commitAndPushReports } = await import('./utils/reports-branch.js'); - const wt = await ensureReportsWorktree(reportsConfig); - const memberDir = path.join(wt, 'members'); - await ensureDir(memberDir); - const memberPath = path.join(memberDir, `${username}.yaml`); - isNewMember = !await pathExists(memberPath); - const existingMember = await getMemberConfig(wt, username); - const { config: memberConfig, changed: memberChanged } = mergeMemberConfig(existingMember, { - username, - projects: resolvedProjects, + const { updateReports } = await import('./utils/reports-branch.js'); + let memberChanged = false; + let memberProjects: string[] | undefined; + const pushed = await updateReports(reportsConfig, async (wt) => { + const memberDir = path.join(wt, 'members'); + await ensureDir(memberDir); + const memberPath = path.join(memberDir, `${username}.yaml`); + isNewMember = !await pathExists(memberPath); + const existingMember = await getMemberConfig(wt, username); + const merged = mergeMemberConfig(existingMember, { + username, + projects: resolvedProjects, + }); + memberChanged = merged.changed; + memberProjects = merged.config.projects; + if (!merged.changed) return null; + await writeFile(memberPath, YAML.stringify(merged.config)); + return { + files: ['members/'], + message: isNewMember + ? `[teamai] Register member: ${username}` + : `[teamai] Update member roster: ${username}`, + }; }); if (memberChanged) { - await writeFile(memberPath, YAML.stringify(memberConfig)); log.success(isNewMember ? `Registered as team member: ${username}` - : `Updated member roster: ${username}${memberConfig.projects ? ` (projects: ${memberConfig.projects.join(', ')})` : ''}`); - const pushed = await commitAndPushReports(reportsConfig, isNewMember - ? `[teamai] Register member: ${username}` - : `[teamai] Update member roster: ${username}`, ['members/']); + : `Updated member roster: ${username}${memberProjects ? ` (projects: ${memberProjects.join(', ')})` : ''}`); if (pushed) { log.success(isNewMember ? 'Member registered on the teamai-reports branch' @@ -1390,9 +1407,8 @@ export async function init(options: GlobalOptions & { } else { log.warn('Member registration could not be pushed (no write access?). You are still set up locally.'); } - } else { + } else if (!isNewMember) { log.info(`Member ${username} already registered`); - isNewMember = false; } } catch (e) { log.warn(`Member registration skipped (non-blocking): ${(e as Error).message}`); diff --git a/src/save-session.ts b/src/save-session.ts index 5ef9fd2a..647da24c 100644 --- a/src/save-session.ts +++ b/src/save-session.ts @@ -138,20 +138,25 @@ export async function saveSession(options: SaveSessionOptions): Promise { if (usesReportsBranch(localConfig)) { const spin = spinner('Pushing session summary to team...').start(); try { - const { ensureReportsWorktree, commitAndPushReports } = await import('./utils/reports-branch.js'); - const wt = await ensureReportsWorktree(localConfig); - const teamDir = path.join(wt, 'sessions', username); - const written = await appendMonthlyLog(teamDir, summary, { includePrompt: options.includePrompt }); - if (!written) { - spin.info('Session already present in the team log — nothing to push.'); - return; - } - const rel = path.relative(wt, written); + const { updateReports } = await import('./utils/reports-branch.js'); + let rel: string | undefined; + let ran = false; const pushed = await withTimeout( - commitAndPushReports(localConfig, commitMsg, [rel]), + updateReports(localConfig, async (wt) => { + ran = true; + const teamDir = path.join(wt, 'sessions', username); + const written = await appendMonthlyLog(teamDir, summary, { includePrompt: options.includePrompt }); + if (!written) return null; + rel = path.relative(wt, written); + return { files: [rel], message: commitMsg }; + }), 10_000, 'Push timeout (10s)', ); + if (ran && !rel) { + spin.info('Session already present in the team log — nothing to push.'); + return; + } if (pushed) spin.succeed(`Pushed: ${rel}`); else spin.info('Nothing new to push.'); } catch (e) { diff --git a/src/team-push.ts b/src/team-push.ts index b66545b3..cd39ccde 100644 --- a/src/team-push.ts +++ b/src/team-push.ts @@ -396,13 +396,85 @@ export async function reportUsageToTeam( const hasPromptTokens = hasPromptTokenDelta(promptTokenDelta); const hasDaily = hasDailyDelta(dailyDelta); - // Resolve where report data is written. Non-HTTP repos use the reports - // orphan-branch worktree; HTTP / callers without a config still write the - // dedicated clone (legacy path used by unit tests of merge logic). - let writeRoot = repoPath; + const hasStats = hasUsage || hasInterventions || hasPromptTokens || hasDaily; + const commitMsg = hasUsage + ? `[teamai] Update usage stats for ${username}` + : (hasInterventions || hasPromptTokens || hasDaily) + ? `[teamai] Update session stats for ${username}` + : `[teamai] Update votes for ${username}`; + + const writeReportFiles = async (writeRoot: string): Promise => { + // Process usage and/or intervention/prompt/token stats if anything is new to report. + if (hasStats) { + const statsDir = path.join(writeRoot, 'stats'); + await ensureDir(statsDir); + const statsPath = path.join(statsDir, `${username}.yaml`); + + // See also: stats.ts mergeLocalAndReported() — same merge logic for display. + // mergeStats with [] preserves existing skills while refreshing username/updatedAt, + // and carries interventions/prompts/tokens so partial reports do not clobber them (#425). + const existing = await readExistingStats(statsPath); + if (useReportsBranch) { + const previousContent = await readFileSafe(statsPath); + // A failed push can leave an already-incremented file in the reports + // worktree. Restore its input so a normal retry does not add it twice. + restoreStats = () => writeFile(statsPath, previousContent ?? ''); + } + const newStats = hasUsage ? aggregateUsage(events) : []; + const merged = mergeStats(existing, username, newStats); + if (hasInterventions) { + merged.interventions = mergeInterventionStats(existing?.interventions, interventionDelta); + } + if (hasPromptTokens) { + const pt = mergePromptTokenStats(existing?.prompts, existing?.tokens, promptTokenDelta); + merged.prompts = pt.prompts; + merged.tokens = pt.tokens; + } + if (hasDaily) { + merged.daily = mergeDailyStats(existing?.daily, dailyDelta); + } + + await writeFile(statsPath, YAML.stringify(merged)); + filesToPush.push(`stats/${username}.yaml`); + } + + // Always stage pending local votes (V2 delta-aware merge) + try { + if (await pathExists(getUserVotesDir())) { + const { syncVotesToTeam } = await import('./votes.js'); + const synced = await syncVotesToTeam(writeRoot, username, getUserVotesDir()); + if (synced) { + filesToPush.push(`votes/${username}.yaml`); + } + } + } catch (e) { + log.error(`Vote staging skipped: ${(e as Error).message}`); + } + }; + + // Keep push and acknowledgement in the same operation. A caller timing out + // must not abandon the success bookkeeping below. if (useReportsBranch && reportsConfig) { - const { ensureReportsWorktree } = await import('./utils/reports-branch.js'); - writeRoot = await ensureReportsWorktree(reportsConfig); + let hasVotes = false; + if (!hasStats && await pathExists(getUserVotesDir())) { + const { hasPendingVoteDeltas } = await import('./votes.js'); + hasVotes = await hasPendingVoteDeltas(getUserVotesDir(), username); + } + if (!hasStats && !hasVotes) { + log.debug('No usage events or votes to report'); + return true; + } + const { updateReports } = await import('./utils/reports-branch.js'); + const pushed = await updateReports(reportsConfig, async (wt) => { + filesToPush.length = 0; + await writeReportFiles(wt); + return filesToPush.length > 0 ? { files: [...filesToPush], message: commitMsg } : null; + }, { pushIfUnchanged: true }); + if (!pushed) { + log.debug('Auto-report push was not confirmed; keeping local report data'); + await restoreStats?.(); + return false; + } } else { // The team repo is a disposable cache clone here — safe to discard local state // and reset to the default branch before pulling (same pattern as push.ts). @@ -449,76 +521,12 @@ export async function reportUsageToTeam( await writeFile(yamlPath, pendingTeamConfig); } } - } - - // Process usage and/or intervention/prompt/token stats if anything is new to report. - if (hasUsage || hasInterventions || hasPromptTokens || hasDaily) { - const statsDir = path.join(writeRoot, 'stats'); - await ensureDir(statsDir); - const statsPath = path.join(statsDir, `${username}.yaml`); - - // See also: stats.ts mergeLocalAndReported() — same merge logic for display. - // mergeStats with [] preserves existing skills while refreshing username/updatedAt, - // and carries interventions/prompts/tokens so partial reports do not clobber them (#425). - const existing = await readExistingStats(statsPath); - if (useReportsBranch) { - const previousContent = await readFileSafe(statsPath); - // A failed push can leave an already-incremented file in the reports - // worktree. Restore its input so a normal retry does not add it twice. - restoreStats = () => writeFile(statsPath, previousContent ?? ''); - } - const newStats = hasUsage ? aggregateUsage(events) : []; - const merged = mergeStats(existing, username, newStats); - if (hasInterventions) { - merged.interventions = mergeInterventionStats(existing?.interventions, interventionDelta); - } - if (hasPromptTokens) { - const pt = mergePromptTokenStats(existing?.prompts, existing?.tokens, promptTokenDelta); - merged.prompts = pt.prompts; - merged.tokens = pt.tokens; - } - if (hasDaily) { - merged.daily = mergeDailyStats(existing?.daily, dailyDelta); - } - - await writeFile(statsPath, YAML.stringify(merged)); - filesToPush.push(`stats/${username}.yaml`); - } - - // Always stage pending local votes (V2 delta-aware merge) - try { - if (await pathExists(getUserVotesDir())) { - const { syncVotesToTeam } = await import('./votes.js'); - const synced = await syncVotesToTeam(writeRoot, username, getUserVotesDir()); - if (synced) { - filesToPush.push(`votes/${username}.yaml`); - } - } - } catch (e) { - log.error(`Vote staging skipped: ${(e as Error).message}`); - } - - // Nothing to push — skip commit - if (filesToPush.length === 0) { - log.debug('No usage events or votes to report'); - return true; - } - // Keep push and acknowledgement in the same operation. A caller timing out - // must not abandon the success bookkeeping below. - const commitMsg = hasUsage - ? `[teamai] Update usage stats for ${username}` - : (hasInterventions || hasPromptTokens || hasDaily) - ? `[teamai] Update session stats for ${username}` - : `[teamai] Update votes for ${username}`; - if (useReportsBranch && reportsConfig) { - const { commitAndPushReports } = await import('./utils/reports-branch.js'); - if (!await commitAndPushReports(reportsConfig, commitMsg, filesToPush, { pushIfUnchanged: true })) { - log.debug('Auto-report push was not confirmed; keeping local report data'); - await restoreStats?.(); - return false; + await writeReportFiles(repoPath); + if (filesToPush.length === 0) { + log.debug('No usage events or votes to report'); + return true; } - } else { await pushRepoDirectly(repoPath, commitMsg, filesToPush); } restoreStats = undefined; diff --git a/src/utils/reports-branch.ts b/src/utils/reports-branch.ts index 59c516c5..bef9a05c 100644 --- a/src/utils/reports-branch.ts +++ b/src/utils/reports-branch.ts @@ -254,6 +254,60 @@ async function writeWorktreeGitignore(wt: string): Promise { const MAX_PUSH_RETRIES = 5; +export interface ReportsWrite { + files: string[]; + message: string; +} + +/** Commit `files` in an already-locked reports worktree and push them. */ +async function commitAndPushReportsAt( + wt: string, + message: string, + files: string[], + options: { pushIfUnchanged?: boolean } = {}, +): Promise { + const git = createGit(wt); + + await git.add(files); + const status = await git.status(); + if (status.staged.length === 0 && !options.pushIfUnchanged) { + log.debug('[reports] nothing to commit'); + return false; + } + + // A report retry may reconstruct the same tree as a previously committed + // but unconfirmed push. It still needs a push, without an empty commit. + if (status.staged.length > 0) await commitSkippingHooks(git, message); + + // Push with fetch+rebase retry. Each member only writes .yaml, so + // rebase conflicts are effectively impossible; retries handle the pure + // non-fast-forward race. + for (let attempt = 1; attempt <= MAX_PUSH_RETRIES; attempt++) { + try { + await git.push(['origin', REPORTS_BRANCH]); + return true; + } catch (pushErr) { + if (attempt === MAX_PUSH_RETRIES) { + log.debug(`[reports] push failed after ${attempt} attempts: ${(pushErr as Error).message}`); + return false; + } + try { + await git.fetch(['origin', REPORTS_BRANCH]); + await git.rebase([`origin/${REPORTS_BRANCH}`]); + } catch (rebaseErr) { + log.debug(`[reports] rebase failed, retrying: ${(rebaseErr as Error).message}`); + // Abort a half-finished rebase so the next attempt starts clean. + try { + await git.rebase(['--abort']); + } catch { + // no rebase in progress + } + } + } + } + return false; +} + /** * Commit the given files (relative to the reports worktree) to the reports orphan * branch and push, retrying with fetch + rebase on non-fast-forward races. @@ -262,6 +316,9 @@ const MAX_PUSH_RETRIES = 5; * worktree) BEFORE calling this. Best-effort: logs and returns false on failure * rather than throwing, matching the existing pushRepoDirectly contract. * + * Merge-writers (session / votes / stats / member roster) should use + * {@link updateReports} so the worktree is synced with origin before the write. + * * @returns true if something was committed & pushed, false if nothing to do or on failure. */ export async function commitAndPushReports( @@ -279,48 +336,52 @@ export async function commitAndPushReports( try { const wt = await ensureReportsWorktree(localConfig); - const git = createGit(wt); + return await commitAndPushReportsAt(wt, message, files, options); + } catch (e) { + log.debug(`[reports] commitAndPushReports failed (non-blocking): ${(e as Error).message}`); + return false; + } finally { + await releaseLock(lockPath); + } +} - await git.add(files); - const status = await git.status(); - if (status.staged.length === 0 && !options.pushIfUnchanged) { - log.debug('[reports] nothing to commit'); - return false; - } +/** + * Under the reports lock: sync the worktree with origin, run `write`, commit, push. + * The callback does not run when the lock is busy. + * + * @returns true if something was committed and pushed. false when the lock was + * busy, `write` returned null, or commit/push failed. + */ +export async function updateReports( + localConfig: LocalConfig, + write: (worktree: string) => Promise, + options: { pushIfUnchanged?: boolean } = {}, +): Promise { + if (!usesReportsBranch(localConfig)) { + throw new Error('updateReports requires a repo that stores reports on the teamai-reports branch'); + } - // A report retry may reconstruct the same tree as a previously committed - // but unconfirmed push. It still needs a push, without an empty commit. - if (status.staged.length > 0) await commitSkippingHooks(git, message); + const lockPath = reportsLockPath(localConfig); + if (!(await acquireLock(lockPath))) { + log.debug('[reports] another reports write is in progress; skipping'); + return false; + } - // Push with fetch+rebase retry. Each member only writes .yaml, so - // rebase conflicts are effectively impossible; retries handle the pure - // non-fast-forward race. - for (let attempt = 1; attempt <= MAX_PUSH_RETRIES; attempt++) { - try { - await git.push(['origin', REPORTS_BRANCH]); - return true; - } catch (pushErr) { - if (attempt === MAX_PUSH_RETRIES) { - log.debug(`[reports] push failed after ${attempt} attempts: ${(pushErr as Error).message}`); - return false; - } - try { - await git.fetch(['origin', REPORTS_BRANCH]); - await git.rebase([`origin/${REPORTS_BRANCH}`]); - } catch (rebaseErr) { - log.debug(`[reports] rebase failed, retrying: ${(rebaseErr as Error).message}`); - // Abort a half-finished rebase so the next attempt starts clean. - try { - await git.rebase(['--abort']); - } catch { - // no rebase in progress - } - } - } + try { + const wt = await ensureReportsWorktree(localConfig); + try { + await syncReportsWorktree(wt); + } catch (e) { + log.debug(`[reports] sync before write failed, writing onto the local copy: ${(e as Error).message}`); } - return false; + + const change = await write(wt); + if (!change || change.files.length === 0) { + return false; + } + return await commitAndPushReportsAt(wt, change.message, change.files, options); } catch (e) { - log.debug(`[reports] commitAndPushReports failed (non-blocking): ${(e as Error).message}`); + log.debug(`[reports] updateReports failed (non-blocking): ${(e as Error).message}`); return false; } finally { await releaseLock(lockPath); diff --git a/src/votes.ts b/src/votes.ts index ac7e9994..79a0945f 100644 --- a/src/votes.ts +++ b/src/votes.ts @@ -163,6 +163,12 @@ export function mergeDeltas(local: UserVotesV2, remote: UserVotesV2): UserVotesV return { version: 2, votes, deltas: {} }; } +/** True when the local votes file still has deltas not yet synced to the team repo. */ +export async function hasPendingVoteDeltas(localVotesDir: string, username: string): Promise { + const local = await loadUserVotes(path.join(localVotesDir, `${username}.yaml`)); + return Object.keys(local.deltas).length > 0; +} + /** * Sync local vote deltas to the team repo votes file for a given user. * Returns true if sync was performed, false if deltas were empty.