Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ All notable changes to this project will be documented in this file. See [standa

### 🐛 Bug Fixes

- `teamai stats` no longer counts a session twice, and no longer counts another project's sessions. Its dashboard section added the whole machine's local `events.jsonl` metrics to the scope's already-reported totals from the team repo, so every session that a `pull` had reported — and that stays in the event log until compaction — was counted once by the team total and once again locally, and sessions whose `cwd` belonged to a different project were added to this scope's as well. It now filters the event log the way `teamai pull` reports it (a project scope keeps only the sessions under its own root; the user scope resolves no root, so nothing is excluded and the log passes through unfiltered) and adds only what that scope has not reported yet, derived from the same per-session `reported-*` snapshots the report path advances — so the local figure agrees with the team's instead of exceeding it. When the reported totals could not be read at all (no stats file, an unreadable one, a reports worktree that is not there), or when they exist but hold nothing yet, nothing is subtracted — the local `reported-*` snapshots are machine-global while the team file is per-scope, so a non-empty team total is what licenses trusting them, and a session the member can see happening is never hidden. The per-repo and by-hour breakdowns read that same filtered log, so they stay inside the scope; they answer a different question from the headline — what this machine's retained event log holds, per repo — and both the headings and the `--by-repo` / `--by-time` flag descriptions now name that source instead of presenting them as a split of the headline (for [#768](https://github.com/Tencent/teamai-cli/issues/768)).
- `teamai stats` no longer counts a session twice, and no longer counts another project's sessions. Its dashboard section added the whole machine's local `events.jsonl` metrics to the scope's already-reported totals from the team repo, so every session that a `pull` had reported — and that stays in the event log until compaction — was counted once by the team total and once again locally, and sessions whose `cwd` belonged to a different project were added to this scope's as well. It now filters the event log the way `teamai pull` reports it (only the sessions recorded in the current scope, see #785) and adds only what that scope has not reported yet, derived from the same per-scope `reported-*` snapshots the report path advances — so the local figure agrees with the team's instead of exceeding it. When the reported totals could not be read at all (no stats file, an unreadable one, a reports worktree that is not there), or when they exist but hold nothing yet, nothing is subtracted — a snapshot can name a session the team file never received, so a non-empty team total is what licenses trusting them, and a session the member can see happening is never hidden. The per-repo and by-hour breakdowns read that same filtered log, so they stay inside the scope; they answer a different question from the headline — what this machine's retained event log holds, per repo — and both the headings and the `--by-repo` / `--by-time` flag descriptions now name that source instead of presenting them as a split of the headline (for [#768](https://github.com/Tencent/teamai-cli/issues/768)).
- The data-partition migration no longer retires a legacy `<repo>/.teamai/` while the partition's `config.yaml` cannot be read. `teamai init`, `pull` and `push` took a partition config that merely existed as a finished migration and renamed the legacy directory to `.teamai.bak`, although it held the only config that still loaded. A partition `config.yaml` that is empty or cannot be opened, does not parse, does not validate, or is not `scope: project` now leaves the legacy directory in place and names the file to fix; once it is fixed, the next of those commands retires it as before. A partition directory whose `config.yaml` was moved aside is no longer replaced by a fresh copy of the legacy directory, which deleted what the partition held; the migration waits and says to restore the file or move the directory aside (for [#797](https://github.com/Tencent/teamai-cli/issues/797)).
- The legacy `teamai dashboard-report` command no longer records dashboard events in a directory that never set up teamai. A current install writes only `teamai hook-dispatch`, whose dashboard-report handler declares `requiresConfig` and is dropped when no config resolves for the hook's `cwd`; the old subcommand stayed ungated, so a hook left behind by an earlier install kept recording events for every project it fired in, and those sessions were then reported by whichever scope pulled next. It now applies the same gate `teamai contribute-check` was given, asked about the session's `cwd` — or, for a host that sends none, the directory the hook runs in (for [#768](https://github.com/Tencent/teamai-cli/issues/768)).
- The lock behind `pull`, `push`, the reports and learnings worktrees, learnings publishing, migration, self-mode bootstrap and the update check no longer hands one lock to two live processes. Reclaiming a stale lock renamed over whatever file was there once it had judged the lock stale, and it judged live locks stale: one that had just been released (and could be re-created by a third process before the rename), one whose owner had created it but not yet written it, one owned by a process running as another user (for example a `sudo teamai` run), and one it could not read. Under 16 processes contending on one lock, about 1% of acquisitions overlapped another holder, enough for two pulls to report the same usage twice. Now only a lock whose owner is provably gone is reclaimed; a lock that vanished gets one more exclusive create, and a new lock is published with its content already in place (written to a temp file, then hard-linked to the lock name; a filesystem without hard links falls back to the previous create). A lock that names no owner (empty, partly written, unreadable) is never reclaimed: if a crash left one, `pull` and `push` report busy until it is removed, and a warning names the file. Migration skips the lock's temporary files, which a contending pull creates and removes while the copy runs. With the change, the same stress run shows no overlap (for [#760](https://github.com/Tencent/teamai-cli/issues/760)).
Expand Down
50 changes: 42 additions & 8 deletions src/__tests__/stats-scope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,10 +297,10 @@ describe('showStats scope and idempotency', () => {
expect(outputNumber(out, 'Conversation turns:')).toBe(1);
});

it('applies no project exclusion in the user scope when no project resolves', async () => {
// A user-scope run from a plain directory: detectProjectConfig() finds no
// project here, exactly as `pull` sees it from the same directory, so the
// report path passes no exclusion list either. The display side matches.
it('keeps sessions recorded before events carried a data home out of the user scope', async () => {
// A user-scope run from a plain directory. These events carry no dataHome,
// and the user scope never reports such events (#785), so it does not count
// them either: the display side follows the report side.
await seedUserScopeWithProject();
await appendEvents([
...session('sess-1', DIRS.project),
Expand All @@ -321,8 +321,42 @@ describe('showStats scope and idempotency', () => {
fs.mkdirSync(plainDir, { recursive: true });
const out = await showStatsFromPlainDir(plainDir);

expect(outputNumber(out, 'Sessions:')).toBe(2);
expect(outputNumber(out, 'Conversation turns:')).toBe(2);
expect(out).toContain('No usage data yet.');
});

it('subtracts the scope\'s own reported snapshot, not the shared one', async () => {
// Each scope keeps its own snapshots since #786, and the shared file is no
// longer written. Here the shared one is stale and the project's own says
// sess-1 was reported: reading the shared one would count sess-1 twice.
await seedProjectConfig();
await appendEvents(session('sess-1', DIRS.project));

await writeReportedStats({
username: 'tester',
updatedAt: '2026-09-20T11:00:00.000Z',
skills: {},
prompts: 1,
tokens: SESSION_TOKENS,
interventions: { sessions: 1, interrupt: 0, toolReject: 0, correction: 0 },
});
await writeReportedSnapshots({}, {});
const anchors = await resolveAnchors(workspace);
if (!anchors) throw new Error('expected the seeded workspace to have git anchors');
const ownDir = path.join(await resolvePartitionDir(anchors.projectAnchor), 'dashboard');
await ensureDir(ownDir);
await writeFile(
path.join(ownDir, 'reported-interventions.json'),
JSON.stringify({ 'sess-1': { interrupt: 0, toolReject: 0, correction: 0 } }),
);
await writeFile(
path.join(ownDir, 'reported-prompt-tokens.json'),
JSON.stringify({ 'sess-1': { prompts: 1, tokens: SESSION_TOKENS } }),
);

const out = await showStatsFromProject();

expect(outputNumber(out, 'Sessions:')).toBe(1);
expect(outputNumber(out, 'Conversation turns:')).toBe(1);
});

it('still shows local sessions when the team stats file is missing', async () => {
Expand All @@ -347,8 +381,8 @@ describe('showStats scope and idempotency', () => {
});

it('does not subtract a snapshot the team file never received', async () => {
// The local snapshots are machine-global while the team file is per-scope,
// so a snapshot can name a session this team never got — an empty team file
// A scope's snapshot is first seeded from the machine-wide one, so it can
// name a session this team never got — an empty team file
// with a populated snapshot. Subtracting anyway undercounts to nothing.
await seedProjectConfig();
await appendEvents(session('sess-1', DIRS.project));
Expand Down
46 changes: 15 additions & 31 deletions src/stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,10 @@ import { readUsageEvents } from './usage-tracker.js';
import { readFileSafe } from './utils/fs.js';
import { resolveConfigForDir } from './config.js';
import { readEvents, aggregateSessionMetrics } from './dashboard-collector.js';
import { getUserHome } from './utils/home.js';
import { totalTokens, addTokenUsage, emptyTokenUsage } from './types.js';
import { attributeByRepo, timeAnalytics, renderHourSparkline } from './session-analytics.js';
import { formatTokenCount } from './digest.js';
import type { UsageEvent, UserStats, TokenUsage, SessionMetrics } from './types.js';
import type { UsageEvent, UserStats, TokenUsage, SessionMetrics, LocalConfig } from './types.js';

interface SkillStats {
name: string;
Expand Down Expand Up @@ -154,17 +153,14 @@ function aggregateDashboardStats(metrics: Map<string, SessionMetrics>): Aggregat
*/
async function unreportedDashboardStats(
metrics: Map<string, SessionMetrics>,
config: LocalConfig,
): Promise<AggregatedDashboardStats> {
const { computeInterventionDelta, computePromptTokenDelta } = await import('./team-push.js');
const { readJson } = await import('./utils/fs.js');
const dashboardDir = path.join(getUserHome(), '.teamai', 'dashboard');

const interventions = (await readJson<Parameters<typeof computeInterventionDelta>[1]>(
path.join(dashboardDir, 'reported-interventions.json'),
)) ?? {};
const promptTokens = (await readJson<Parameters<typeof computePromptTokenDelta>[1]>(
path.join(dashboardDir, 'reported-prompt-tokens.json'),
)) ?? {};
const {
computeInterventionDelta, computePromptTokenDelta, readReportedInterventions, readReportedPromptTokens,
} = await import('./team-push.js');
// The scope's own snapshots, the ones its report compares against (#786).
const interventions = await readReportedInterventions(config);
const promptTokens = await readReportedPromptTokens(config);

const interventionDelta = computeInterventionDelta(
new Map([...metrics].map(([sid, m]) => [sid, { interrupt: m.interrupt, toolReject: m.toolReject, correction: m.correction }])),
Expand Down Expand Up @@ -231,26 +227,14 @@ export async function showStats(options: ShowStatsOptions = {}): Promise<void> {
// is shown can agree with what the team holds: this scope's own sessions only,
// and only the part of them not already reported (reported sessions stay in
// events.jsonl until compaction, so counting the full local aggregate would
// count each one twice and pull in other projects' sessions).
//
// The project root is resolved on its own, exactly as `pull` does: it reads
// `detectProjectConfig()`, never the projectRoot of the scope config, because
// a user-scope config carries no projectRoot at all (the field is attached
// only when a PROJECT config is detected). Same call, same directory, same
// answer as the report path.
const { detectProjectConfig } = await import('./config.js');
const projectConfig = await detectProjectConfig();
const projectRoot = config?.scope === 'project' ? config.projectRoot : projectConfig?.projectRoot;
const scopeFilter = projectRoot
? (config?.scope === 'project'
? { projectRoot }
: { excludeProjectRoots: [projectRoot] })
: undefined;
// count each one twice and pull in other projects' sessions). Same filter,
// same scope config as the report path (#785).
const { filterEventsByScope } = await import('./team-push.js');
const scopedEvents = filterEventsByScope(await readEvents(), scopeFilter);
const scopedEvents = await filterEventsByScope(await readEvents(), config ?? undefined);
const metricsMap = aggregateSessionMetrics(scopedEvents);
// Only subtract what the team already holds. Two guards, because the local
// snapshots are machine-global while the team file is per user:
// Only subtract what the team already holds. Two guards, because a scope's
// snapshot is first seeded from the machine-wide one, so it can name sessions
// this team file never received:
//
// - `reported` null (no stats file, an unreadable one, a reports worktree
// that is not there): the snapshot says nothing about what the team
Expand All @@ -267,7 +251,7 @@ export async function showStats(options: ShowStatsOptions = {}): Promise<void> {
|| totalTokens(reported.tokens ?? emptyTokenUsage()) > 0
);
const localDashboard = config && teamHasReported
? await unreportedDashboardStats(metricsMap)
? await unreportedDashboardStats(metricsMap, config)
: aggregateDashboardStats(metricsMap);
const dashboard = mergeDashboardAndReported(localDashboard, reported);
const hasDashboardData =
Expand Down
4 changes: 2 additions & 2 deletions src/team-push.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ async function readSnapshot<T>(name: ReportedSnapshotName, config: LocalConfig |
return seed;
}

async function readReportedInterventions(config: LocalConfig | undefined): Promise<ReportedInterventions> {
export async function readReportedInterventions(config: LocalConfig | undefined): Promise<ReportedInterventions> {
const parsed = await readSnapshot<ReportedInterventions>('interventions', config);
return parsed && typeof parsed === 'object' ? parsed : {};
}
Expand Down Expand Up @@ -247,7 +247,7 @@ function hasInterventionDelta(d: UserInterventionStats): boolean {
// Separate snapshot from interventions so each metric stays independently idempotent.
//

async function readReportedPromptTokens(config: LocalConfig | undefined): Promise<ReportedPromptTokens> {
export async function readReportedPromptTokens(config: LocalConfig | undefined): Promise<ReportedPromptTokens> {
const parsed = await readSnapshot<ReportedPromptTokens>('prompt-tokens', config);
return parsed && typeof parsed === 'object' ? parsed : {};
}
Expand Down
Loading