From 8fdd12e942381511e34a201ebb3c76f4d1dff95f Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Wed, 23 Sep 2026 14:38:46 +0200 Subject: [PATCH 1/6] fix(usage): discard pre-upgrade usage and stop falling back past an unreadable config (#748) Follow-up to #753, from its review. - resolveConfigForDir returns null when any project config was reported unreadable, even if a lower-priority one (a legacy .teamai/ behind a broken partition) loads: that one may name another team. - The user scope's usage.jsonl is the old shared file. #753 only emptied it on a machine's first user-scope init, so a machine that already had a user scope reported every project's pre-upgrade usage to it. The first access after the upgrade now discards what an earlier release left there and writes ~/.teamai/usage-per-scope. One process discards, under acquireLock; concurrent hooks wait for the marker, so none deletes what another recorded. --- CHANGELOG.md | 4 +- docs/designs/data-directory-layout.md | 4 +- docs/designs/team-intelligence-platform.md | 6 +- docs/usage-guide.md | 2 +- docs/usage-guide.zh-CN.md | 2 +- src/__tests__/hook-dispatch-scope.test.ts | 21 ++++++- src/__tests__/http-repo-integration.test.ts | 29 --------- src/__tests__/team-push-interventions.test.ts | 12 ++-- src/__tests__/usage-tracking.test.ts | 52 +++++++++++++-- src/config.ts | 10 +-- src/init.ts | 20 ++---- src/usage-tracker.ts | 63 +++++++++++++------ 12 files changed, 139 insertions(+), 86 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cda12dc0..a1973e0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,9 +29,9 @@ All notable changes to this project will be documented in this file. See [standa - 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)). -- Team hooks stay out of projects that never set up teamai. A project-scope install puts its hooks in the home directory, so they fire in every project on the machine, and with no config for the directory they used to run anyway: the end-of-session share reminder (shown there even with recall off, a case a configured team never sees), the TodoWrite recall nudge, and the local recording of sessions and skill usage that a later report from another project pushed to its team. A handler that needs a team now declares `requiresConfig`, and the dispatcher drops it when neither a project nor a user config resolves for the hook's `cwd`; only machine-level work runs there (CLI update check, session-start pull, local agent, package hints the pull stashed). A config that exists but fails to parse reads the same way, so it withholds team prompts rather than running all of them, and for hooks and skill usage an unreadable project config never falls back to the user scope; `teamai doctor` reports it. A host that sends no `cwd` (OpenClaw) resolves the project from the directory it runs the hook in, a `cwd` that no longer exists resolves to the user scope instead of failing the hook, and the legacy `teamai contribute-check` command that older installs still call follows the same rule (for [#748](https://github.com/Tencent/teamai-cli/issues/748)). +- Team hooks stay out of projects that never set up teamai. A project-scope install puts its hooks in the home directory, so they fire in every project on the machine, and with no config for the directory they used to run anyway: the end-of-session share reminder (shown there even with recall off, a case a configured team never sees), the TodoWrite recall nudge, and the local recording of sessions and skill usage that a later report from another project pushed to its team. A handler that needs a team now declares `requiresConfig`, and the dispatcher drops it when neither a project nor a user config resolves for the hook's `cwd`; only machine-level work runs there (CLI update check, session-start pull, local agent, package hints the pull stashed). A config that exists but fails to parse reads the same way, so it withholds team prompts rather than running all of them, and for hooks and skill usage an unreadable project config never falls back to the user scope, nor to a lower-priority project config such as a legacy `.teamai/` behind a broken partition; `teamai doctor` reports it. A host that sends no `cwd` (OpenClaw) resolves the project from the directory it runs the hook in, a `cwd` that no longer exists resolves to the user scope instead of failing the hook, and the legacy `teamai contribute-check` command that older installs still call follows the same rule (for [#748](https://github.com/Tencent/teamai-cli/issues/748)). - Every team hook handler now works in the scope `hook-dispatch` resolved for the hook's `cwd`. The team correction keywords, votes and webhooks read their config again from the directory the hook process ran in, so when that `cwd` no longer existed (a deleted worktree) and the host started the hook inside another project, that project's keywords were applied, the session's votes were recorded under its member and pushed to its team, and its webhooks fired. The background handlers (session-start pull, webhooks, update check) also run again when that `cwd` no longer exists: on macOS and Linux their detached process was started in it, so the start failed silently and none of them ran. It now starts in the temp directory, as on Windows (for [#752](https://github.com/Tencent/teamai-cli/issues/752)). -- Skill usage stays with the team of the project it was recorded in. Every scope used to append to one `~/.teamai/usage.jsonl`, so whichever project pulled next reported every project's skills to its own team, including skills that exist only in an unrelated private repo. Usage now goes to the data directory of the scope that resolves for the session's directory (`/usage.jsonl`: the project partition, `/.teamai` in single-repo mode, `~/.teamai` for the user scope), each report reads and truncates only its own file, and `teamai stats` shows the current scope's usage. A machine's first user scope starts with an empty file, since the events it held by then cannot be attributed; on a machine with only project scopes, events recorded before the upgrade stay unreported. Stats already pushed are not rewritten (for [#748](https://github.com/Tencent/teamai-cli/issues/748)). +- Skill usage stays with the team of the project it was recorded in. Every scope used to append to one `~/.teamai/usage.jsonl`, so whichever project pulled next reported every project's skills to its own team, including skills that exist only in an unrelated private repo. Usage now goes to the data directory of the scope that resolves for the session's directory (`/usage.jsonl`: the project partition, or `/.teamai` for an install not yet migrated to one, and `~/.teamai` for the user scope), each report reads and truncates only its own file, and `teamai stats` shows the current scope's usage. Events an earlier release left in `~/.teamai/usage.jsonl` name no project, so the user scope discards them the first time it reads or records usage after the upgrade (a `~/.teamai/usage-per-scope` marker keeps that to once), whether the user scope existed before the upgrade or is set up later; they are never reported. Stats already pushed are not rewritten (for [#748](https://github.com/Tencent/teamai-cli/issues/748)). - `teamai init` no longer hangs without a terminal. When the provider had no session it spawned `gh auth login --web` (or `gf auth login`, `cnb login`) with inherited stdio and waited for a browser device flow that nobody could complete, about five minutes for GitHub, then exited with the provider's error and no hint of the missing credential. Each login now refuses up front when the run is not interactive and names the credential to prepare (`GITHUB_TOKEN` / `GH_TOKEN`, `CNB_TOKEN`, or for TGit a prior `gf auth login`, since a `TGIT_TOKEN` PAT is REST-API-only and cannot clone). A run is non-interactive when stdin is not a TTY or when `CI` or `TEAMAI_NONINTERACTIVE` is set, so an agent sandbox with a pseudo-terminal can declare itself unattended, and every prompt in the CLI follows the same rule. `git` also runs with its prompts closed in that case — `GIT_TERMINAL_PROMPT=0`, `GIT_ASKPASS=echo` and `GCM_INTERACTIVE=never`, each only where the caller set nothing — so a missing clone credential fails at once instead of waiting on a terminal prompt or an askpass or credential-manager dialog. `ssh` keeps its own settings: its batch flag is only reachable through `GIT_SSH_COMMAND`, which would override each repository's `core.sshCommand` (for [#711](https://github.com/Tencent/teamai-cli/issues/711)). - A `manifest/roles.yaml` that exists but does not parse now fails the pull for that scope instead of warning and syncing with no role filter at all, for a member with no role as much as for one with a role. The same applies to `init` and `push`, which each fell back to a guess at the namespaces when any error came out of the loader. The legacy role migration skips with a warning instead of failing, so every command, `pull` included, still loads the config and can fetch the fixed manifest; until it can run, the member holds no role rather than every role, so hooks, MCP servers and env variables scoped by `roles:` reach them no more than skills do. For a member with no active project that fallback meant an unfiltered sync, so a broken manifest delivered every namespace it was written to gate. Only an absent manifest still means "this team does not use roles"; an unreadable or empty file is an error, as it now is for `manifest/projects.yaml` too. `push` stops at its scan for such a manifest (exit 2) even with `--role `, since the scan needs it to tell which namespaces are the member's. - `teamai members list` and `teamai projects members` read the roster registered before the reports switch, so a team upgrading past the orphan-branch split no longer sees "No team members registered" while its `members/` still lives on the default branch. The default-branch copy becomes a read-only inherited root, the way learnings' already was: listed in union with the `teamai-reports` copy, with the branch copy winning when the same file exists on both; nothing is copied or deleted, and a cold `members list` still does not publish the reports branch. Member registration merges against the inherited copy too, so a re-init keeps the original `registeredAt` and projects. Fixes [#735](https://github.com/Tencent/teamai-cli/issues/735). diff --git a/docs/designs/data-directory-layout.md b/docs/designs/data-directory-layout.md index 24f320b2..ba7d610f 100644 --- a/docs/designs/data-directory-layout.md +++ b/docs/designs/data-directory-layout.md @@ -309,7 +309,9 @@ were removed. the getters still return `~/.teamai/...`, unchanged. The project-scoped equivalents already route through `getDataHome()`. Skill usage moved there too (#748): `usage.jsonl` lives in each scope's `getDataHome()`, because one shared file let a -project's report carry every project's skills. The dashboard is likewise an A2 singleton +project's report carry every project's skills. The user scope's file is that old +shared path, so its first access discards what an earlier release left there and +writes `~/.teamai/usage-per-scope` to mark the file as the user scope's own. The dashboard is likewise an A2 singleton (events carry `cwd`/`sessionId`); "two projects' events don't mix" is satisfied by `getEventsPath()` reading `HOME` at call time, not by per-project dirs. diff --git a/docs/designs/team-intelligence-platform.md b/docs/designs/team-intelligence-platform.md index befe3b0f..d18c72e9 100644 --- a/docs/designs/team-intelligence-platform.md +++ b/docs/designs/team-intelligence-platform.md @@ -162,13 +162,15 @@ reports worktree → git add → git commit → git push (teamai-reports) │ recommend.ts │ │ ┌──────────────┐ │ digest.ts │ push --stats │ Local │ │ stats.ts │ push --sessions -│ / │◀───│ team-push.ts ──┼────▶(直接 commit, 无 MR) -│ usage.jsonl │ │ │ +│ ~/.teamai/ │◀───│ team-push.ts ──┼────▶(直接 commit, 无 MR) +│ usage.jsonl*│ │ │ │ sessions/ │ └────────────────┘ │ .md │ └──────────────┘ ``` +\* `usage.jsonl` 按 scope 存放在 `/`(项目分区或 user scope 的 `~/.teamai/`,#748);`sessions/` 仍是机器级别的 `~/.teamai/`。 + ## New Files | File | LOC | Purpose | diff --git a/docs/usage-guide.md b/docs/usage-guide.md index ccfbab62..d1a76142 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -533,7 +533,7 @@ teamai pull --dry-run # Dry run, no actual changes A manual `teamai pull` ends by running the `teamai doctor` checks and printing each one that failed, with its fix — including whether the skills it just reported syncing are readable on disk for every enabled tool. It prints nothing when they all pass, and the exit code is unchanged. The SessionStart hook path and `--dry-run` run no checks at all, so session startup stays as fast as before. Provider checks (`gh`/`gf` authentication) are left to `teamai doctor`: the pull just used the provider. -> Project scope is isolated by default. When the current working directory contains a project-scope `.teamai/config.yaml`, `pull` processes that project and skips user scope unless the local config has `inheritUserScope: true`; in that case it first refreshes the safe user-resource channel. Without a project config in the current directory, `pull` processes user scope. User `env`, MCP definitions, sources, reporting, and writes remain isolated in project mode. Hooks are the one exception: a project scope's hooks are injected into your **HOME** tool settings (`~/.claude/settings.json`, …), not ``, because the built-in hooks gate on the `cwd` handed to `hook-dispatch` and `~/.claude` always exists so the "installed tool" gate passes (see the Hooks section). In a directory with no teamai config (no project config and no user scope), the team hooks do nothing: no reminders, and no session or skill usage is recorded; only machine-level work runs (the CLI update check, the session-start pull, the local agent, and package hints a pull stashed). A project config that exists but cannot be read counts as none, never as the user scope. Self single-repo mode keeps its hooks in the business repo so they travel on clone. +> Project scope is isolated by default. When the current working directory contains a project-scope `.teamai/config.yaml`, `pull` processes that project and skips user scope unless the local config has `inheritUserScope: true`; in that case it first refreshes the safe user-resource channel. Without a project config in the current directory, `pull` processes user scope. User `env`, MCP definitions, sources, reporting, and writes remain isolated in project mode. Hooks are the one exception: a project scope's hooks are injected into your **HOME** tool settings (`~/.claude/settings.json`, …), not ``, because the built-in hooks gate on the `cwd` handed to `hook-dispatch` and `~/.claude` always exists so the "installed tool" gate passes (see the Hooks section). In a directory with no teamai config (no project config and no user scope), the team hooks do nothing: no reminders, and no session or skill usage is recorded; only machine-level work runs (the CLI update check, the session-start pull, the local agent, and package hints a pull stashed). A project config that exists but cannot be read counts as none, never as the user scope or as a lower-priority project config (such as a legacy `.teamai/config.yaml`) behind it. Self single-repo mode keeps its hooks in the business repo so they travel on clone. With role-based skills enabled, `pull`'s skill sync source becomes the contents of `skills//`, expanded according to `primaryRole + additionalRoles` and flattened into each local AI tool's skills directory. `rules/` and `docs/` keep their original sync behavior; `agents//` follows the role's `agents` namespaces (see [Agents Resource Type](#agents-resource-type)). `learnings/` at the root is shared with everyone, while `learnings//` subdirectories sync only for the directory's active projects (see [Multi-project](#multi-project-project-as-a-dimension-orthogonal-to-role)). diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index a953cdb7..8fc9e5da 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -498,7 +498,7 @@ teamai pull --dry-run # 试运行,不实际修改 手动执行 `teamai pull` 会在结束时运行 `teamai doctor` 的检查,并逐条打印失败项及其修复建议——包括它刚刚报告同步的 skill 是否真的落到每个启用工具的磁盘上、且可被读取。全部通过时不会有任何额外输出,退出码也不变。SessionStart hook 路径和 `--dry-run` 完全不运行检查,会话启动速度保持不变。托管平台相关的检查(`gh`/`gf` 认证)留给 `teamai doctor`:这次 pull 刚刚用过该平台。 -> Project scope 默认与 user scope 隔离。当前工作目录包含 project scope 的 `.teamai/config.yaml` 时,`pull` 会处理该项目并跳过 user scope;仅当本地配置包含 `inheritUserScope: true` 时,才会先刷新安全的 user 资源通道。当前目录没有 project 配置时,`pull` 处理 user scope。project 模式下,user 的 `env`、MCP 定义、sources、reporting 和写入行为仍保持隔离。hooks 是唯一例外:project scope 的 hooks 会注入到你的 **HOME** 工具设置(`~/.claude/settings.json` 等),而非 ``——因为内置 hooks 依据传给 `hook-dispatch` 的 `cwd` 门控,且 `~/.claude` 恒存在、能通过「已安装工具」门槛(详见 Hooks 章节)。在没有 teamai 配置的目录中(既没有 project 配置也没有 user scope),团队 hooks 不做任何事:不显示提醒,也不记录会话或 skill 使用;只运行机器级别的工作(CLI 更新检查、SessionStart 时的 pull、本地 agent,以及 pull 暂存的包提示)。存在但无法读取的 project 配置视为没有配置,而不会退回 user scope。self 单仓模式则把 hooks 保留在业务仓库里,随 clone 传播。 +> Project scope 默认与 user scope 隔离。当前工作目录包含 project scope 的 `.teamai/config.yaml` 时,`pull` 会处理该项目并跳过 user scope;仅当本地配置包含 `inheritUserScope: true` 时,才会先刷新安全的 user 资源通道。当前目录没有 project 配置时,`pull` 处理 user scope。project 模式下,user 的 `env`、MCP 定义、sources、reporting 和写入行为仍保持隔离。hooks 是唯一例外:project scope 的 hooks 会注入到你的 **HOME** 工具设置(`~/.claude/settings.json` 等),而非 ``——因为内置 hooks 依据传给 `hook-dispatch` 的 `cwd` 门控,且 `~/.claude` 恒存在、能通过「已安装工具」门槛(详见 Hooks 章节)。在没有 teamai 配置的目录中(既没有 project 配置也没有 user scope),团队 hooks 不做任何事:不显示提醒,也不记录会话或 skill 使用;只运行机器级别的工作(CLI 更新检查、SessionStart 时的 pull、本地 agent,以及 pull 暂存的包提示)。存在但无法读取的 project 配置视为没有配置,而不会退回 user scope,也不会退回其后优先级更低的 project 配置(如旧的 `.teamai/config.yaml`)。self 单仓模式则把 hooks 保留在业务仓库里,随 clone 传播。 启用角色化 skills 后,`pull` 的 skills 同步来源会变成 `skills//` 中的内容,按 `primaryRole + additionalRoles` 展开对应的 namespace,拍平安装到本地各 AI 工具 skills 目录。`rules/`、`docs/` 仍然保持原有同步逻辑;`agents//` 按角色的 `agents` namespace 同步(见 [Agents 资源类型](#agents-资源类型))。`learnings/` 根目录对所有人共享,而 `learnings//` 子目录只对本目录激活的项目同步(见 [多项目](#多项目project-作为与-role-正交的维度))。 diff --git a/src/__tests__/hook-dispatch-scope.test.ts b/src/__tests__/hook-dispatch-scope.test.ts index 26e52d00..8a558fc2 100644 --- a/src/__tests__/hook-dispatch-scope.test.ts +++ b/src/__tests__/hook-dispatch-scope.test.ts @@ -15,7 +15,10 @@ vi.mock('node:child_process', async (importOriginal) => ({ spawn: vi.fn(() => ({ on: vi.fn(), stdin: { on: vi.fn(), end: vi.fn((_: string, done: () => void) => done()) }, unref: vi.fn() })), })); vi.mock('../pull.js', () => ({ pull: vi.fn(async () => undefined) })); -vi.mock('../update.js', () => ({ doUpdate: vi.fn(async () => undefined) })); +vi.mock('../update.js', async (importOriginal) => ({ + ...(await importOriginal()), + doUpdate: vi.fn(async () => undefined), +})); vi.mock('../local-agent.js', () => ({ reportAndSyncFromHook: vi.fn(async () => null) })); vi.mock('../utils/reports-branch.js', () => ({ updateReports: vi.fn(async () => undefined) })); @@ -165,4 +168,20 @@ describe('hook runs and the scope they belong to (#748)', () => { expect(fs.existsSync(path.join(teamaiHome(), 'usage.jsonl'))).toBe(false); }); + + it('an unreadable partition config does not hand the project to its legacy .teamai config', async () => { + const root = gitRepo('project-a'); + const partition = await resolveProjectDataHome(root); + fs.mkdirSync(partition, { recursive: true }); + fs.writeFileSync(path.join(partition, 'config.yaml'), 'repo: [not: a, valid config\n'); + // A lower-priority legacy config that names another team. + const legacy = path.join(root, '.teamai'); + fs.mkdirSync(legacy); + fs.writeFileSync(path.join(legacy, 'config.yaml'), + `repo:\n localPath: ${path.join(legacy, 'team-repo')}\n remote: https://example.test/acme/other-team.git\nusername: tester\nscope: project\n`); + + await hook('post-tool-use', 'Skill', { session_id: 'sid-a', cwd: root, hook_event_name: 'PostToolUse', tool_name: 'Skill', tool_input: { skill: 'skill-a' } }); + + expect(fs.existsSync(path.join(legacy, 'usage.jsonl'))).toBe(false); + }); }); diff --git a/src/__tests__/http-repo-integration.test.ts b/src/__tests__/http-repo-integration.test.ts index 07e6e263..0064fdfb 100644 --- a/src/__tests__/http-repo-integration.test.ts +++ b/src/__tests__/http-repo-integration.test.ts @@ -60,35 +60,6 @@ describe('teamai init --http (read-only onboarding)', () => { }); }); -describe('user-scope init and leftover skill usage (#748)', () => { - const usagePath = () => path.join(tmpDir, '.teamai', 'usage.jsonl'); - const leftover = '{"skill":"from-another-project","timestamp":"2026-01-01T00:00:00Z","tool":"claude"}\n'; - - it('starts with no usage when the machine had no user scope before', async () => { - writeApiKey(); - // Recorded before any user scope existed: nothing says which project it came from. - fs.writeFileSync(usagePath(), leftover); - server = await startMockServer({ apiKey: API_KEY }); - - const { init } = await import('../init.js'); - await init({ http: server.url, force: true, scope: 'user' }); - - expect(fs.existsSync(usagePath()) ? fs.readFileSync(usagePath(), 'utf-8') : '').toBe(''); - }); - - it('keeps the usage of an existing user scope on re-init', async () => { - writeApiKey(); - server = await startMockServer({ apiKey: API_KEY }); - const { init } = await import('../init.js'); - await init({ http: server.url, force: true, scope: 'user' }); - fs.writeFileSync(usagePath(), leftover); - - await init({ http: server.url, force: true, scope: 'user' }); - - expect(fs.readFileSync(usagePath(), 'utf-8')).toBe(leftover); - }); -}); - describe('read-only protection (http kind)', () => { it('rejects teamai push', async () => { writeApiKey(); diff --git a/src/__tests__/team-push-interventions.test.ts b/src/__tests__/team-push-interventions.test.ts index 4cd44d43..0c7057e3 100644 --- a/src/__tests__/team-push-interventions.test.ts +++ b/src/__tests__/team-push-interventions.test.ts @@ -36,6 +36,7 @@ vi.mock('../utils/fs.js', async (importActual) => { import { reportUsageToTeam } from '../team-push.js'; import { withTimeout } from '../utils/async.js'; +import { appendUsageEvent } from '../usage-tracker.js'; let tmpDir: string; let repoDir: string; @@ -82,7 +83,7 @@ function writeDashboardEvents(lines: object[]): void { } describe('reportUsageToTeam — intervention reporting', () => { - function seedReport(): string { + async function seedReport(): Promise { const timestamp = new Date().toISOString(); writeDashboardEvents([ { type: 'session_start', timestamp, sessionId: 'slow', tool: 'claude', cwd: '/p' }, @@ -91,14 +92,13 @@ describe('reportUsageToTeam — intervention reporting', () => { interventions: { interrupt: 1, toolReject: 0 }, tokens: { input: 10, output: 5, cacheRead: 0, cacheCreation: 0 } }, ]); - const usagePath = path.join(tmpDir, '.teamai', 'usage.jsonl'); - fs.writeFileSync(usagePath, JSON.stringify({ skill: 'review', timestamp, tool: 'claude' }) + '\n'); - return usagePath; + await appendUsageEvent({ skill: 'review', timestamp, tool: 'claude' }, gitConfig()); + return path.join(tmpDir, '.teamai', 'usage.jsonl'); } it.each(['reports', 'legacy'])('finishes acknowledgement after the caller times out (%s)', async (backend) => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); - const usagePath = seedReport(); + const usagePath = await seedReport(); const seeded = fs.readFileSync(usagePath, 'utf-8'); let finish!: (value: boolean) => void; let started!: () => void; @@ -140,7 +140,7 @@ describe('reportUsageToTeam — intervention reporting', () => { }); it.each(['false', 'rejection'])('retains events and snapshots when push returns %s', async (failure) => { - const usagePath = seedReport(); + const usagePath = await seedReport(); const before = fs.readFileSync(usagePath, 'utf-8'); if (failure === 'false') reportsMocks.updateReports.mockResolvedValueOnce(false); else reportsMocks.updateReports.mockRejectedValueOnce(new Error('offline')); diff --git a/src/__tests__/usage-tracking.test.ts b/src/__tests__/usage-tracking.test.ts index 21f392f4..30c1565a 100644 --- a/src/__tests__/usage-tracking.test.ts +++ b/src/__tests__/usage-tracking.test.ts @@ -211,11 +211,9 @@ describe('readUsageEvents', () => { it('skips corrupted JSONL lines', async () => { const usagePath = path.join(tmpDir, '.teamai', 'usage.jsonl'); - await fse.ensureDir(path.dirname(usagePath)); - await fs.promises.writeFile( - usagePath, - '{"skill":"good","timestamp":"2026-01-01T00:00:00Z","tool":"claude"}\nNOT_JSON\n{"skill":"also-good","timestamp":"2026-01-02T00:00:00Z","tool":"claude"}\n', - ); + await appendUsageEvent({ skill: 'good', timestamp: '2026-01-01T00:00:00Z', tool: 'claude' }, userScope()); + await fs.promises.appendFile(usagePath, 'NOT_JSON\n'); + await appendUsageEvent({ skill: 'also-good', timestamp: '2026-01-02T00:00:00Z', tool: 'claude' }, userScope()); const events = await readUsageEvents(userScope()); expect(events).toHaveLength(2); @@ -257,6 +255,50 @@ describe('truncateUsageAfterReport', () => { }); }); +describe('usage recorded while every scope shared ~/.teamai/usage.jsonl (#748)', () => { + const usagePath = () => path.join(tmpDir, '.teamai', 'usage.jsonl'); + const legacy = '{"skill":"from-another-project","timestamp":"2026-01-01T00:00:00Z","tool":"claude"}\n'; + + it('is not read back as the user scope\'s own usage after an upgrade', async () => { + // Written by an earlier release on a machine that already had a user scope. + await fs.promises.writeFile(usagePath(), legacy); + + expect(await readUsageEvents(userScope())).toEqual([]); + expect(fs.existsSync(usagePath())).toBe(false); + }); + + it('is discarded once: usage recorded after the upgrade is kept', async () => { + await fs.promises.writeFile(usagePath(), legacy); + await appendUsageEvent({ skill: 'after-upgrade', timestamp: '2026-02-01T00:00:00Z', tool: 'claude' }, userScope()); + + expect((await readUsageEvents(userScope())).map((e) => e.skill)).toEqual(['after-upgrade']); + expect((await readUsageEvents(userScope())).map((e) => e.skill)).toEqual(['after-upgrade']); + }); + + it('is discarded by one process: a hook racing it keeps what the other recorded', async () => { + await fs.promises.writeFile(usagePath(), legacy); + const { acquireLock, releaseLock } = await import('../update.js'); + const lock = path.join(tmpDir, '.teamai', 'usage-per-scope.lock'); + expect(await acquireLock(lock)).toBe(true); + + let settled = false; + const racing = appendUsageEvent({ skill: 'second', timestamp: '2026-02-01T00:00:01Z', tool: 'claude' }, userScope()) + .then(() => { settled = true; }); + // It found no marker and is waiting on the lock, not writing. + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(settled).toBe(false); + expect(fs.readFileSync(usagePath(), 'utf-8')).toBe(legacy); + // Meanwhile the lock holder finishes the discard and records its own event. + fs.rmSync(usagePath()); + fs.writeFileSync(path.join(tmpDir, '.teamai', 'usage-per-scope'), ''); + fs.appendFileSync(usagePath(), '{"skill":"first","timestamp":"2026-02-01T00:00:00Z","tool":"claude"}\n'); + await releaseLock(lock); + await racing; + + expect((await readUsageEvents(userScope())).map((e) => e.skill)).toEqual(['first', 'second']); + }); +}); + describe('track', () => { it('tracks Skill tool calls', async () => { await track('Skill', JSON.stringify({ skill: 'code-review' })); diff --git a/src/config.ts b/src/config.ts index eedd6a25..c4e88bac 100644 --- a/src/config.ts +++ b/src/config.ts @@ -336,8 +336,10 @@ export async function resolveDataHomeForScope(scope: Scope, projectRoot?: string * The config that governs a directory (default: the process cwd): the project * teamai is set up for there, else the user scope, else null — teamai is not * set up here. A project config that exists but cannot be read is null too, - * never the user scope: that project's hooks and usage must not reach the - * user-scope team (#748). The hook dispatcher and every usage reader and writer + * never the user scope, nor a lower-priority project config that detection + * reads after it (a legacy `.teamai/` behind a broken partition may name + * another team): that project's hooks and usage must not reach a team it may + * not belong to (#748). The hook dispatcher and every usage reader and writer * resolve through this, so they always agree on the scope. A directory that no * longer exists (a hook payload naming a deleted worktree) holds no project * config; git refuses to open it, so it is not asked. @@ -347,8 +349,8 @@ export async function resolveConfigForDir(dir?: string): Promise { unreadable = true; }); - if (project) return project; - return unreadable ? null : loadLocalConfig(); + if (unreadable) return null; + return project ?? loadLocalConfig(); } /** diff --git a/src/init.ts b/src/init.ts index aa07b117..f87d665e 100644 --- a/src/init.ts +++ b/src/init.ts @@ -17,7 +17,6 @@ import { toolRootRejection, type TeamaiConfig, getTeamaiHomeDir, - getUserConfigPath, REPORTS_BRANCH, type GlobalOptions, type LocalConfig, @@ -26,7 +25,6 @@ import { getConfigPath, } from './types.js'; import { getUserHome } from './utils/home.js'; -import { discardUnattributedUsage } from './usage-tracker.js'; import { describeRoles, listRoleIds, loadRolesManifest, RolesManifestNotFoundError } from './roles.js'; import { loadProjectsManifest, listProjectIds } from './projects.js'; import { memberReadRoots, readMemberConfig, mergeMemberConfig } from './members.js'; @@ -415,18 +413,6 @@ async function isInsideGitRepo(dir: string): Promise { } } -/** - * Save the user-scope config. A machine's first user scope starts with no skill - * usage: what `~/.teamai/usage.jsonl` holds by then cannot be attributed to it - * (#748). - */ -async function saveUserScopeConfig(localConfig: LocalConfig): Promise { - const firstUserScope = !(await pathExists(getUserConfigPath())); - await ensureDir(getTeamaiHomeDir()); - await saveLocalConfig(localConfig); - if (firstUserScope) await discardUnattributedUsage(localConfig); -} - /** * Git-free HTTP onboarding (issue #1). A read-only consumer only needs an API * key: no git auth, no clone, no member/reviewer push. Skills/rules/CLAUDE.md are @@ -568,7 +554,8 @@ export async function initHttp( if (scope === 'project') { await saveLocalConfigForScope(localConfig, scope, projectRoot); } else { - await saveUserScopeConfig(localConfig); + await ensureDir(getTeamaiHomeDir()); + await saveLocalConfig(localConfig); } log.success(`Local config saved to ${teamaiHome}/config.yaml`); @@ -1735,7 +1722,8 @@ export async function init(options: GlobalOptions & { log.debug('Generated .teamai/.gitignore for project scope'); } } else { - await saveUserScopeConfig(localConfig); + await ensureDir(getTeamaiHomeDir()); + await saveLocalConfig(localConfig); log.success(`Local config saved to ${getTeamaiHomeDir()}/config.yaml`); } diff --git a/src/usage-tracker.ts b/src/usage-tracker.ts index 3cd14bb6..69b75294 100644 --- a/src/usage-tracker.ts +++ b/src/usage-tracker.ts @@ -5,6 +5,7 @@ import { normalizeToolName } from './utils/tool-names.js'; import { getCopilotHome, getDataHome, + getTeamaiHomeDir, SKILL_NAME_REGEX, type LocalConfig, type UsageEvent, @@ -14,13 +15,52 @@ import { getUserHome } from './utils/home.js'; import { resolveHookCwd } from './utils/hook-cwd.js'; import { resolveConfigForDir } from './config.js'; +/** Present once `~/.teamai/usage.jsonl` holds only the user scope's own usage. */ +const USAGE_PER_SCOPE_MARKER = 'usage-per-scope'; +/** How long a hook waits for another process to finish that one-time discard. */ +const USAGE_DISCARD_WAIT = { attempts: 40, delayMs: 25 }; + /** * The usage JSONL of one scope: `/usage.jsonl`, so each scope reports * only the skills used where it is set up (#748). Evaluated at call time to * respect HOME changes in tests. + * + * The user scope's file is where every scope used to record, so what an + * earlier release left there names no project. The first access after the + * upgrade discards it, before the user scope can report it to its team, and + * leaves a marker so later usage is kept. One process discards, under a lock; + * the others wait for the marker, so none deletes what another just recorded. + * Throws when the discard does not finish in time; every caller treats that + * as an I/O failure. */ -function getUsagePath(config: LocalConfig): string { - return path.join(getDataHome(config), 'usage.jsonl'); +async function getUsagePath(config: LocalConfig): Promise { + const usagePath = path.join(getDataHome(config), 'usage.jsonl'); + const sharedDir = getTeamaiHomeDir(); + if (path.resolve(path.dirname(usagePath)) !== path.resolve(sharedDir)) return usagePath; + const marker = path.join(sharedDir, USAGE_PER_SCOPE_MARKER); + if (await pathExists(marker)) return usagePath; + + const { acquireLock, releaseLock } = await import('./update.js'); + const lock = `${marker}.lock`; + for (let attempt = 0; attempt < USAGE_DISCARD_WAIT.attempts; attempt++) { + if (await acquireLock(lock)) { + try { + if (!(await pathExists(marker))) { + // Remove before marking: a run cut short in between discards again + // rather than keeping unattributed events. + await fs.promises.rm(usagePath, { force: true }); + await fs.promises.writeFile(marker, '', 'utf-8'); + log.debug(`Discarded ${usagePath}: recorded before usage was kept per scope, it names no project (#748)`); + } + } finally { + await releaseLock(lock); + } + return usagePath; + } + await new Promise((resolve) => setTimeout(resolve, USAGE_DISCARD_WAIT.delayMs)); + if (await pathExists(marker)) return usagePath; + } + throw new Error(`${lock} is held by another process; skill usage from before the upgrade is still being discarded`); } /** Get the known-skills.json path (evaluated at call time to respect HOME changes in tests). */ @@ -215,7 +255,7 @@ export async function skillExistsOnDisk(skillName: string): Promise { */ export async function appendUsageEvent(event: UsageEvent, config: LocalConfig): Promise { try { - const usagePath = getUsagePath(config); + const usagePath = await getUsagePath(config); await ensureDir(path.dirname(usagePath)); const line = JSON.stringify(event) + '\n'; await fs.promises.appendFile(usagePath, line, 'utf-8'); @@ -225,26 +265,13 @@ export async function appendUsageEvent(event: UsageEvent, config: LocalConfig): } } -/** - * Drop the user-scope usage file when a machine first gets a user scope. What - * it holds was recorded while every scope shared that file, so nothing says - * which project each event came from; the new user scope must not report it - * to its team (#748). - */ -export async function discardUnattributedUsage(userConfig: LocalConfig): Promise { - const usagePath = getUsagePath(userConfig); - if (!(await pathExists(usagePath))) return; - await fs.promises.rm(usagePath, { force: true }); - log.debug(`Discarded ${usagePath}: its events predate this user scope and name no project (#748)`); -} - /** * Read all usage events from a scope's JSONL file. * Skips corrupted lines gracefully. */ export async function readUsageEvents(config: LocalConfig): Promise { try { - const content = await fs.promises.readFile(getUsagePath(config), 'utf-8'); + const content = await fs.promises.readFile(await getUsagePath(config), 'utf-8'); const events: UsageEvent[] = []; for (const line of content.split('\n')) { const trimmed = line.trim(); @@ -270,7 +297,7 @@ export async function readUsageEvents(config: LocalConfig): Promise { try { - const usagePath = getUsagePath(config); + const usagePath = await getUsagePath(config); const content = await fs.promises.readFile(usagePath, 'utf-8'); const lines = content.split('\n').filter((l) => l.trim()); if (reportedCount >= lines.length) { From 952d66f87b71e157b1e10636d375f27f99981564 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Wed, 23 Sep 2026 15:23:11 +0200 Subject: [PATCH 2/6] fix(review): parse the empty usage file in its test; scope the fallback wording to team hooks (#748) - "handles empty file" wrote no marker, so the discard removed the file and the read passed on a missing file. A first read now settles the file as the scope's own, and the test asserts the file survives. - The session-start pull still resolves its project on its own, so the "never falls back to a lower-priority config" rule is stated for team hooks and skill usage only (CHANGELOG, usage guide en/zh-CN). --- CHANGELOG.md | 2 +- docs/usage-guide.md | 2 +- docs/usage-guide.zh-CN.md | 2 +- src/__tests__/usage-tracking.test.ts | 5 ++++- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a1973e0a..d3c400c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,7 +29,7 @@ All notable changes to this project will be documented in this file. See [standa - 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)). -- Team hooks stay out of projects that never set up teamai. A project-scope install puts its hooks in the home directory, so they fire in every project on the machine, and with no config for the directory they used to run anyway: the end-of-session share reminder (shown there even with recall off, a case a configured team never sees), the TodoWrite recall nudge, and the local recording of sessions and skill usage that a later report from another project pushed to its team. A handler that needs a team now declares `requiresConfig`, and the dispatcher drops it when neither a project nor a user config resolves for the hook's `cwd`; only machine-level work runs there (CLI update check, session-start pull, local agent, package hints the pull stashed). A config that exists but fails to parse reads the same way, so it withholds team prompts rather than running all of them, and for hooks and skill usage an unreadable project config never falls back to the user scope, nor to a lower-priority project config such as a legacy `.teamai/` behind a broken partition; `teamai doctor` reports it. A host that sends no `cwd` (OpenClaw) resolves the project from the directory it runs the hook in, a `cwd` that no longer exists resolves to the user scope instead of failing the hook, and the legacy `teamai contribute-check` command that older installs still call follows the same rule (for [#748](https://github.com/Tencent/teamai-cli/issues/748)). +- Team hooks stay out of projects that never set up teamai. A project-scope install puts its hooks in the home directory, so they fire in every project on the machine, and with no config for the directory they used to run anyway: the end-of-session share reminder (shown there even with recall off, a case a configured team never sees), the TodoWrite recall nudge, and the local recording of sessions and skill usage that a later report from another project pushed to its team. A handler that needs a team now declares `requiresConfig`, and the dispatcher drops it when neither a project nor a user config resolves for the hook's `cwd`; only machine-level work runs there (CLI update check, session-start pull, local agent, package hints the pull stashed). A config that exists but fails to parse reads the same way, so it withholds team prompts rather than running all of them, and for team hooks and skill usage an unreadable project config never falls back to the user scope, nor to a lower-priority project config such as a legacy `.teamai/` behind a broken partition (the session-start pull still resolves its project on its own); `teamai doctor` reports it. A host that sends no `cwd` (OpenClaw) resolves the project from the directory it runs the hook in, a `cwd` that no longer exists resolves to the user scope instead of failing the hook, and the legacy `teamai contribute-check` command that older installs still call follows the same rule (for [#748](https://github.com/Tencent/teamai-cli/issues/748)). - Every team hook handler now works in the scope `hook-dispatch` resolved for the hook's `cwd`. The team correction keywords, votes and webhooks read their config again from the directory the hook process ran in, so when that `cwd` no longer existed (a deleted worktree) and the host started the hook inside another project, that project's keywords were applied, the session's votes were recorded under its member and pushed to its team, and its webhooks fired. The background handlers (session-start pull, webhooks, update check) also run again when that `cwd` no longer exists: on macOS and Linux their detached process was started in it, so the start failed silently and none of them ran. It now starts in the temp directory, as on Windows (for [#752](https://github.com/Tencent/teamai-cli/issues/752)). - Skill usage stays with the team of the project it was recorded in. Every scope used to append to one `~/.teamai/usage.jsonl`, so whichever project pulled next reported every project's skills to its own team, including skills that exist only in an unrelated private repo. Usage now goes to the data directory of the scope that resolves for the session's directory (`/usage.jsonl`: the project partition, or `/.teamai` for an install not yet migrated to one, and `~/.teamai` for the user scope), each report reads and truncates only its own file, and `teamai stats` shows the current scope's usage. Events an earlier release left in `~/.teamai/usage.jsonl` name no project, so the user scope discards them the first time it reads or records usage after the upgrade (a `~/.teamai/usage-per-scope` marker keeps that to once), whether the user scope existed before the upgrade or is set up later; they are never reported. Stats already pushed are not rewritten (for [#748](https://github.com/Tencent/teamai-cli/issues/748)). - `teamai init` no longer hangs without a terminal. When the provider had no session it spawned `gh auth login --web` (or `gf auth login`, `cnb login`) with inherited stdio and waited for a browser device flow that nobody could complete, about five minutes for GitHub, then exited with the provider's error and no hint of the missing credential. Each login now refuses up front when the run is not interactive and names the credential to prepare (`GITHUB_TOKEN` / `GH_TOKEN`, `CNB_TOKEN`, or for TGit a prior `gf auth login`, since a `TGIT_TOKEN` PAT is REST-API-only and cannot clone). A run is non-interactive when stdin is not a TTY or when `CI` or `TEAMAI_NONINTERACTIVE` is set, so an agent sandbox with a pseudo-terminal can declare itself unattended, and every prompt in the CLI follows the same rule. `git` also runs with its prompts closed in that case — `GIT_TERMINAL_PROMPT=0`, `GIT_ASKPASS=echo` and `GCM_INTERACTIVE=never`, each only where the caller set nothing — so a missing clone credential fails at once instead of waiting on a terminal prompt or an askpass or credential-manager dialog. `ssh` keeps its own settings: its batch flag is only reachable through `GIT_SSH_COMMAND`, which would override each repository's `core.sshCommand` (for [#711](https://github.com/Tencent/teamai-cli/issues/711)). diff --git a/docs/usage-guide.md b/docs/usage-guide.md index d1a76142..7818681b 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -533,7 +533,7 @@ teamai pull --dry-run # Dry run, no actual changes A manual `teamai pull` ends by running the `teamai doctor` checks and printing each one that failed, with its fix — including whether the skills it just reported syncing are readable on disk for every enabled tool. It prints nothing when they all pass, and the exit code is unchanged. The SessionStart hook path and `--dry-run` run no checks at all, so session startup stays as fast as before. Provider checks (`gh`/`gf` authentication) are left to `teamai doctor`: the pull just used the provider. -> Project scope is isolated by default. When the current working directory contains a project-scope `.teamai/config.yaml`, `pull` processes that project and skips user scope unless the local config has `inheritUserScope: true`; in that case it first refreshes the safe user-resource channel. Without a project config in the current directory, `pull` processes user scope. User `env`, MCP definitions, sources, reporting, and writes remain isolated in project mode. Hooks are the one exception: a project scope's hooks are injected into your **HOME** tool settings (`~/.claude/settings.json`, …), not ``, because the built-in hooks gate on the `cwd` handed to `hook-dispatch` and `~/.claude` always exists so the "installed tool" gate passes (see the Hooks section). In a directory with no teamai config (no project config and no user scope), the team hooks do nothing: no reminders, and no session or skill usage is recorded; only machine-level work runs (the CLI update check, the session-start pull, the local agent, and package hints a pull stashed). A project config that exists but cannot be read counts as none, never as the user scope or as a lower-priority project config (such as a legacy `.teamai/config.yaml`) behind it. Self single-repo mode keeps its hooks in the business repo so they travel on clone. +> Project scope is isolated by default. When the current working directory contains a project-scope `.teamai/config.yaml`, `pull` processes that project and skips user scope unless the local config has `inheritUserScope: true`; in that case it first refreshes the safe user-resource channel. Without a project config in the current directory, `pull` processes user scope. User `env`, MCP definitions, sources, reporting, and writes remain isolated in project mode. Hooks are the one exception: a project scope's hooks are injected into your **HOME** tool settings (`~/.claude/settings.json`, …), not ``, because the built-in hooks gate on the `cwd` handed to `hook-dispatch` and `~/.claude` always exists so the "installed tool" gate passes (see the Hooks section). In a directory with no teamai config (no project config and no user scope), the team hooks do nothing: no reminders, and no session or skill usage is recorded; only machine-level work runs (the CLI update check, the session-start pull, the local agent, and package hints a pull stashed). For the team hooks and skill usage, a project config that exists but cannot be read counts as none, never as the user scope or as a lower-priority project config (such as a legacy `.teamai/config.yaml`) behind it; the session-start pull still resolves its project on its own. Self single-repo mode keeps its hooks in the business repo so they travel on clone. With role-based skills enabled, `pull`'s skill sync source becomes the contents of `skills//`, expanded according to `primaryRole + additionalRoles` and flattened into each local AI tool's skills directory. `rules/` and `docs/` keep their original sync behavior; `agents//` follows the role's `agents` namespaces (see [Agents Resource Type](#agents-resource-type)). `learnings/` at the root is shared with everyone, while `learnings//` subdirectories sync only for the directory's active projects (see [Multi-project](#multi-project-project-as-a-dimension-orthogonal-to-role)). diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 8fc9e5da..b79eeb18 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -498,7 +498,7 @@ teamai pull --dry-run # 试运行,不实际修改 手动执行 `teamai pull` 会在结束时运行 `teamai doctor` 的检查,并逐条打印失败项及其修复建议——包括它刚刚报告同步的 skill 是否真的落到每个启用工具的磁盘上、且可被读取。全部通过时不会有任何额外输出,退出码也不变。SessionStart hook 路径和 `--dry-run` 完全不运行检查,会话启动速度保持不变。托管平台相关的检查(`gh`/`gf` 认证)留给 `teamai doctor`:这次 pull 刚刚用过该平台。 -> Project scope 默认与 user scope 隔离。当前工作目录包含 project scope 的 `.teamai/config.yaml` 时,`pull` 会处理该项目并跳过 user scope;仅当本地配置包含 `inheritUserScope: true` 时,才会先刷新安全的 user 资源通道。当前目录没有 project 配置时,`pull` 处理 user scope。project 模式下,user 的 `env`、MCP 定义、sources、reporting 和写入行为仍保持隔离。hooks 是唯一例外:project scope 的 hooks 会注入到你的 **HOME** 工具设置(`~/.claude/settings.json` 等),而非 ``——因为内置 hooks 依据传给 `hook-dispatch` 的 `cwd` 门控,且 `~/.claude` 恒存在、能通过「已安装工具」门槛(详见 Hooks 章节)。在没有 teamai 配置的目录中(既没有 project 配置也没有 user scope),团队 hooks 不做任何事:不显示提醒,也不记录会话或 skill 使用;只运行机器级别的工作(CLI 更新检查、SessionStart 时的 pull、本地 agent,以及 pull 暂存的包提示)。存在但无法读取的 project 配置视为没有配置,而不会退回 user scope,也不会退回其后优先级更低的 project 配置(如旧的 `.teamai/config.yaml`)。self 单仓模式则把 hooks 保留在业务仓库里,随 clone 传播。 +> Project scope 默认与 user scope 隔离。当前工作目录包含 project scope 的 `.teamai/config.yaml` 时,`pull` 会处理该项目并跳过 user scope;仅当本地配置包含 `inheritUserScope: true` 时,才会先刷新安全的 user 资源通道。当前目录没有 project 配置时,`pull` 处理 user scope。project 模式下,user 的 `env`、MCP 定义、sources、reporting 和写入行为仍保持隔离。hooks 是唯一例外:project scope 的 hooks 会注入到你的 **HOME** 工具设置(`~/.claude/settings.json` 等),而非 ``——因为内置 hooks 依据传给 `hook-dispatch` 的 `cwd` 门控,且 `~/.claude` 恒存在、能通过「已安装工具」门槛(详见 Hooks 章节)。在没有 teamai 配置的目录中(既没有 project 配置也没有 user scope),团队 hooks 不做任何事:不显示提醒,也不记录会话或 skill 使用;只运行机器级别的工作(CLI 更新检查、SessionStart 时的 pull、本地 agent,以及 pull 暂存的包提示)。对团队 hooks 和 skill 使用记录而言,存在但无法读取的 project 配置视为没有配置,而不会退回 user scope,也不会退回其后优先级更低的 project 配置(如旧的 `.teamai/config.yaml`);SessionStart 时的 pull 仍自行解析其项目。self 单仓模式则把 hooks 保留在业务仓库里,随 clone 传播。 启用角色化 skills 后,`pull` 的 skills 同步来源会变成 `skills//` 中的内容,按 `primaryRole + additionalRoles` 展开对应的 namespace,拍平安装到本地各 AI 工具 skills 目录。`rules/`、`docs/` 仍然保持原有同步逻辑;`agents//` 按角色的 `agents` namespace 同步(见 [Agents 资源类型](#agents-资源类型))。`learnings/` 根目录对所有人共享,而 `learnings//` 子目录只对本目录激活的项目同步(见 [多项目](#多项目project-作为与-role-正交的维度))。 diff --git a/src/__tests__/usage-tracking.test.ts b/src/__tests__/usage-tracking.test.ts index 30c1565a..b7ac303e 100644 --- a/src/__tests__/usage-tracking.test.ts +++ b/src/__tests__/usage-tracking.test.ts @@ -223,11 +223,14 @@ describe('readUsageEvents', () => { it('handles empty file', async () => { const usagePath = path.join(tmpDir, '.teamai', 'usage.jsonl'); - await fse.ensureDir(path.dirname(usagePath)); + // A first read settles the file as this scope's own, so the empty file + // below is parsed rather than discarded as pre-upgrade usage. + await readUsageEvents(userScope()); await fs.promises.writeFile(usagePath, ''); const events = await readUsageEvents(userScope()); expect(events).toEqual([]); + expect(fs.existsSync(usagePath)).toBe(true); }); }); From db37d7d6ecb3411e0dd6f0df0c05503e7e25cbe8 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Wed, 23 Sep 2026 17:58:12 +0200 Subject: [PATCH 3/6] fix(usage): keep the user scope's usage in its own file, safe across a rollback (#748) The usage-per-scope marker could not tell a pre-upgrade event from one an earlier release appends after a rollback, so a reinstall reported those to the user-scope team. The user scope now records in ~/.teamai/user-usage.jsonl, which no earlier release writes; ~/.teamai/usage.jsonl is removed, never read. Drops the marker, its lock and the bounded wait. --- CHANGELOG.md | 2 +- docs/designs/data-directory-layout.md | 7 ++- docs/designs/team-intelligence-platform.md | 4 +- src/__tests__/e2e/report-timeout.test.ts | 2 +- src/__tests__/hook-dispatch-scope.test.ts | 11 ++-- src/__tests__/team-push-interventions.test.ts | 12 ++-- src/__tests__/usage-tracking.test.ts | 58 ++++++------------- src/usage-tracker.ts | 46 +++------------ 8 files changed, 43 insertions(+), 99 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d3c400c0..6f2c86ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,7 +31,7 @@ All notable changes to this project will be documented in this file. See [standa - 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)). - Team hooks stay out of projects that never set up teamai. A project-scope install puts its hooks in the home directory, so they fire in every project on the machine, and with no config for the directory they used to run anyway: the end-of-session share reminder (shown there even with recall off, a case a configured team never sees), the TodoWrite recall nudge, and the local recording of sessions and skill usage that a later report from another project pushed to its team. A handler that needs a team now declares `requiresConfig`, and the dispatcher drops it when neither a project nor a user config resolves for the hook's `cwd`; only machine-level work runs there (CLI update check, session-start pull, local agent, package hints the pull stashed). A config that exists but fails to parse reads the same way, so it withholds team prompts rather than running all of them, and for team hooks and skill usage an unreadable project config never falls back to the user scope, nor to a lower-priority project config such as a legacy `.teamai/` behind a broken partition (the session-start pull still resolves its project on its own); `teamai doctor` reports it. A host that sends no `cwd` (OpenClaw) resolves the project from the directory it runs the hook in, a `cwd` that no longer exists resolves to the user scope instead of failing the hook, and the legacy `teamai contribute-check` command that older installs still call follows the same rule (for [#748](https://github.com/Tencent/teamai-cli/issues/748)). - Every team hook handler now works in the scope `hook-dispatch` resolved for the hook's `cwd`. The team correction keywords, votes and webhooks read their config again from the directory the hook process ran in, so when that `cwd` no longer existed (a deleted worktree) and the host started the hook inside another project, that project's keywords were applied, the session's votes were recorded under its member and pushed to its team, and its webhooks fired. The background handlers (session-start pull, webhooks, update check) also run again when that `cwd` no longer exists: on macOS and Linux their detached process was started in it, so the start failed silently and none of them ran. It now starts in the temp directory, as on Windows (for [#752](https://github.com/Tencent/teamai-cli/issues/752)). -- Skill usage stays with the team of the project it was recorded in. Every scope used to append to one `~/.teamai/usage.jsonl`, so whichever project pulled next reported every project's skills to its own team, including skills that exist only in an unrelated private repo. Usage now goes to the data directory of the scope that resolves for the session's directory (`/usage.jsonl`: the project partition, or `/.teamai` for an install not yet migrated to one, and `~/.teamai` for the user scope), each report reads and truncates only its own file, and `teamai stats` shows the current scope's usage. Events an earlier release left in `~/.teamai/usage.jsonl` name no project, so the user scope discards them the first time it reads or records usage after the upgrade (a `~/.teamai/usage-per-scope` marker keeps that to once), whether the user scope existed before the upgrade or is set up later; they are never reported. Stats already pushed are not rewritten (for [#748](https://github.com/Tencent/teamai-cli/issues/748)). +- Skill usage stays with the team of the project it was recorded in. Every scope used to append to one `~/.teamai/usage.jsonl`, so whichever project pulled next reported every project's skills to its own team, including skills that exist only in an unrelated private repo. Usage now goes to the data directory of the scope that resolves for the session's directory (`/usage.jsonl`: the project partition, or `/.teamai` for an install not yet migrated to one, and `~/.teamai/user-usage.jsonl` for the user scope), each report reads and truncates only its own file, and `teamai stats` shows the current scope's usage. Events in `~/.teamai/usage.jsonl` name no project, whether an earlier release left them there or writes them again after a rollback, so they are never reported and are removed whenever the user scope reads or records usage. Stats already pushed are not rewritten (for [#748](https://github.com/Tencent/teamai-cli/issues/748)). - `teamai init` no longer hangs without a terminal. When the provider had no session it spawned `gh auth login --web` (or `gf auth login`, `cnb login`) with inherited stdio and waited for a browser device flow that nobody could complete, about five minutes for GitHub, then exited with the provider's error and no hint of the missing credential. Each login now refuses up front when the run is not interactive and names the credential to prepare (`GITHUB_TOKEN` / `GH_TOKEN`, `CNB_TOKEN`, or for TGit a prior `gf auth login`, since a `TGIT_TOKEN` PAT is REST-API-only and cannot clone). A run is non-interactive when stdin is not a TTY or when `CI` or `TEAMAI_NONINTERACTIVE` is set, so an agent sandbox with a pseudo-terminal can declare itself unattended, and every prompt in the CLI follows the same rule. `git` also runs with its prompts closed in that case — `GIT_TERMINAL_PROMPT=0`, `GIT_ASKPASS=echo` and `GCM_INTERACTIVE=never`, each only where the caller set nothing — so a missing clone credential fails at once instead of waiting on a terminal prompt or an askpass or credential-manager dialog. `ssh` keeps its own settings: its batch flag is only reachable through `GIT_SSH_COMMAND`, which would override each repository's `core.sshCommand` (for [#711](https://github.com/Tencent/teamai-cli/issues/711)). - A `manifest/roles.yaml` that exists but does not parse now fails the pull for that scope instead of warning and syncing with no role filter at all, for a member with no role as much as for one with a role. The same applies to `init` and `push`, which each fell back to a guess at the namespaces when any error came out of the loader. The legacy role migration skips with a warning instead of failing, so every command, `pull` included, still loads the config and can fetch the fixed manifest; until it can run, the member holds no role rather than every role, so hooks, MCP servers and env variables scoped by `roles:` reach them no more than skills do. For a member with no active project that fallback meant an unfiltered sync, so a broken manifest delivered every namespace it was written to gate. Only an absent manifest still means "this team does not use roles"; an unreadable or empty file is an error, as it now is for `manifest/projects.yaml` too. `push` stops at its scan for such a manifest (exit 2) even with `--role `, since the scan needs it to tell which namespaces are the member's. - `teamai members list` and `teamai projects members` read the roster registered before the reports switch, so a team upgrading past the orphan-branch split no longer sees "No team members registered" while its `members/` still lives on the default branch. The default-branch copy becomes a read-only inherited root, the way learnings' already was: listed in union with the `teamai-reports` copy, with the branch copy winning when the same file exists on both; nothing is copied or deleted, and a cold `members list` still does not publish the reports branch. Member registration merges against the inherited copy too, so a re-init keeps the original `registeredAt` and projects. Fixes [#735](https://github.com/Tencent/teamai-cli/issues/735). diff --git a/docs/designs/data-directory-layout.md b/docs/designs/data-directory-layout.md index ba7d610f..0dbbf503 100644 --- a/docs/designs/data-directory-layout.md +++ b/docs/designs/data-directory-layout.md @@ -309,9 +309,10 @@ were removed. the getters still return `~/.teamai/...`, unchanged. The project-scoped equivalents already route through `getDataHome()`. Skill usage moved there too (#748): `usage.jsonl` lives in each scope's `getDataHome()`, because one shared file let a -project's report carry every project's skills. The user scope's file is that old -shared path, so its first access discards what an earlier release left there and -writes `~/.teamai/usage-per-scope` to mark the file as the user scope's own. The dashboard is likewise an A2 singleton +project's report carry every project's skills. The user scope records in +`~/.teamai/user-usage.jsonl`, not that old shared `~/.teamai/usage.jsonl`, which +an earlier release still writes after a rollback; the shared file is removed, +never read. The dashboard is likewise an A2 singleton (events carry `cwd`/`sessionId`); "two projects' events don't mix" is satisfied by `getEventsPath()` reading `HOME` at call time, not by per-project dirs. diff --git a/docs/designs/team-intelligence-platform.md b/docs/designs/team-intelligence-platform.md index d18c72e9..e62fc012 100644 --- a/docs/designs/team-intelligence-platform.md +++ b/docs/designs/team-intelligence-platform.md @@ -69,7 +69,7 @@ Transform TeamAI from a simple skill-sharing CLI into a **Team Intelligence Plat **Storage:** `~/.teamai/sessions/.md` 按月聚合。 #### 2. Skill Usage Tracker (Local) -**What:** PostToolUse hook 检测 Claude Code 的 Skill 工具调用,追加写入该 scope 的 `/usage.jsonl`(会话所在目录对应的已配置项目,否则 user scope 的 `~/.teamai/`;未配置 teamai 的目录不记录,#748)。 +**What:** PostToolUse hook 检测 Claude Code 的 Skill 工具调用,追加写入该 scope 的 `/usage.jsonl`(会话所在目录对应的已配置项目,否则 user scope 的 `~/.teamai/user-usage.jsonl`;未配置 teamai 的目录不记录,#748)。 **Data format (JSONL, one event per line):** ```jsonl @@ -169,7 +169,7 @@ reports worktree → git add → git commit → git push (teamai-reports) └──────────────┘ ``` -\* `usage.jsonl` 按 scope 存放在 `/`(项目分区或 user scope 的 `~/.teamai/`,#748);`sessions/` 仍是机器级别的 `~/.teamai/`。 +\* `usage.jsonl` 按 scope 存放在 `/`(项目分区;user scope 为 `~/.teamai/user-usage.jsonl`,#748);`sessions/` 仍是机器级别的 `~/.teamai/`。 ## New Files diff --git a/src/__tests__/e2e/report-timeout.test.ts b/src/__tests__/e2e/report-timeout.test.ts index 2971e2cd..585372f0 100644 --- a/src/__tests__/e2e/report-timeout.test.ts +++ b/src/__tests__/e2e/report-timeout.test.ts @@ -37,7 +37,7 @@ function fixture(agent: keyof typeof agents, provider: string) { repo: { localPath: clone, remote, kind: 'git' }, username: 'alice', scope: 'user', updatePolicy: 'skip', enabledAgents: [agent], additionalRoles: [], })); - const usage = path.join(home, '.teamai/usage.jsonl'); + const usage = path.join(home, '.teamai/user-usage.jsonl'); const dashboard = path.join(home, '.teamai/dashboard'); const timestamp = new Date().toISOString(); const usageLine = JSON.stringify({ skill: 'review', tool: agent, timestamp }) + '\n'; diff --git a/src/__tests__/hook-dispatch-scope.test.ts b/src/__tests__/hook-dispatch-scope.test.ts index 8a558fc2..96471372 100644 --- a/src/__tests__/hook-dispatch-scope.test.ts +++ b/src/__tests__/hook-dispatch-scope.test.ts @@ -15,10 +15,7 @@ vi.mock('node:child_process', async (importOriginal) => ({ spawn: vi.fn(() => ({ on: vi.fn(), stdin: { on: vi.fn(), end: vi.fn((_: string, done: () => void) => done()) }, unref: vi.fn() })), })); vi.mock('../pull.js', () => ({ pull: vi.fn(async () => undefined) })); -vi.mock('../update.js', async (importOriginal) => ({ - ...(await importOriginal()), - doUpdate: vi.fn(async () => undefined), -})); +vi.mock('../update.js', () => ({ doUpdate: vi.fn(async () => undefined) })); vi.mock('../local-agent.js', () => ({ reportAndSyncFromHook: vi.fn(async () => null) })); vi.mock('../utils/reports-branch.js', () => ({ updateReports: vi.fn(async () => undefined) })); @@ -80,7 +77,7 @@ describe('hook runs and the scope they belong to (#748)', () => { await hook('stop', '*', { ...base, hook_event_name: 'Stop' }); expect(fs.existsSync(path.join(teamaiHome(), 'dashboard', 'events.jsonl'))).toBe(false); - expect(fs.existsSync(path.join(teamaiHome(), 'usage.jsonl'))).toBe(false); + expect(fs.existsSync(path.join(teamaiHome(), 'user-usage.jsonl'))).toBe(false); expect(fs.existsSync(path.join(teamaiHome(), 'sessions', 'sid-b.json'))).toBe(false); }); @@ -105,7 +102,7 @@ describe('hook runs and the scope they belong to (#748)', () => { await hook('post-tool-use', 'Skill', { session_id: 'sid-g', cwd: gone, hook_event_name: 'PostToolUse', tool_name: 'Skill', tool_input: { skill: 'skill-g' } }); - expect(fs.readFileSync(path.join(teamaiHome(), 'usage.jsonl'), 'utf-8')).toContain('skill-g'); + expect(fs.readFileSync(path.join(teamaiHome(), 'user-usage.jsonl'), 'utf-8')).toContain('skill-g'); }); it('handlers follow the scope the dispatcher resolved, not the directory the hook process runs in (#752)', async () => { @@ -166,7 +163,7 @@ describe('hook runs and the scope they belong to (#748)', () => { await hook('post-tool-use', 'Skill', { session_id: 'sid-a', cwd: root, hook_event_name: 'PostToolUse', tool_name: 'Skill', tool_input: { skill: 'skill-a' } }); - expect(fs.existsSync(path.join(teamaiHome(), 'usage.jsonl'))).toBe(false); + expect(fs.existsSync(path.join(teamaiHome(), 'user-usage.jsonl'))).toBe(false); }); it('an unreadable partition config does not hand the project to its legacy .teamai config', async () => { diff --git a/src/__tests__/team-push-interventions.test.ts b/src/__tests__/team-push-interventions.test.ts index 0c7057e3..64890fa0 100644 --- a/src/__tests__/team-push-interventions.test.ts +++ b/src/__tests__/team-push-interventions.test.ts @@ -36,7 +36,6 @@ vi.mock('../utils/fs.js', async (importActual) => { import { reportUsageToTeam } from '../team-push.js'; import { withTimeout } from '../utils/async.js'; -import { appendUsageEvent } from '../usage-tracker.js'; let tmpDir: string; let repoDir: string; @@ -83,7 +82,7 @@ function writeDashboardEvents(lines: object[]): void { } describe('reportUsageToTeam — intervention reporting', () => { - async function seedReport(): Promise { + function seedReport(): string { const timestamp = new Date().toISOString(); writeDashboardEvents([ { type: 'session_start', timestamp, sessionId: 'slow', tool: 'claude', cwd: '/p' }, @@ -92,13 +91,14 @@ describe('reportUsageToTeam — intervention reporting', () => { interventions: { interrupt: 1, toolReject: 0 }, tokens: { input: 10, output: 5, cacheRead: 0, cacheCreation: 0 } }, ]); - await appendUsageEvent({ skill: 'review', timestamp, tool: 'claude' }, gitConfig()); - return path.join(tmpDir, '.teamai', 'usage.jsonl'); + const usagePath = path.join(tmpDir, '.teamai', 'user-usage.jsonl'); + fs.writeFileSync(usagePath, JSON.stringify({ skill: 'review', timestamp, tool: 'claude' }) + '\n'); + return usagePath; } it.each(['reports', 'legacy'])('finishes acknowledgement after the caller times out (%s)', async (backend) => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); - const usagePath = await seedReport(); + const usagePath = seedReport(); const seeded = fs.readFileSync(usagePath, 'utf-8'); let finish!: (value: boolean) => void; let started!: () => void; @@ -140,7 +140,7 @@ describe('reportUsageToTeam — intervention reporting', () => { }); it.each(['false', 'rejection'])('retains events and snapshots when push returns %s', async (failure) => { - const usagePath = await seedReport(); + const usagePath = seedReport(); const before = fs.readFileSync(usagePath, 'utf-8'); if (failure === 'false') reportsMocks.updateReports.mockResolvedValueOnce(false); else reportsMocks.updateReports.mockRejectedValueOnce(new Error('offline')); diff --git a/src/__tests__/usage-tracking.test.ts b/src/__tests__/usage-tracking.test.ts index b7ac303e..a1082674 100644 --- a/src/__tests__/usage-tracking.test.ts +++ b/src/__tests__/usage-tracking.test.ts @@ -39,7 +39,7 @@ import type { LocalConfig, UsageEvent, UserStats } from '../types.js'; let tmpDir: string; const origHome = process.env.HOME; -/** The user scope seeded below; its usage file is `~/.teamai/usage.jsonl`. */ +/** The user scope seeded below; its usage file is `~/.teamai/user-usage.jsonl`. */ function userScope(): LocalConfig { return { repo: { localPath: path.join(tmpDir, '.teamai', 'team-repo'), remote: 'https://example.test/acme/team.git' }, @@ -185,7 +185,7 @@ describe('appendUsageEvent', () => { }; await appendUsageEvent(event, userScope()); - const usagePath = path.join(tmpDir, '.teamai', 'usage.jsonl'); + const usagePath = path.join(tmpDir, '.teamai', 'user-usage.jsonl'); const content = await fs.promises.readFile(usagePath, 'utf-8'); const parsed = JSON.parse(content.trim()); expect(parsed.skill).toBe('code-review'); @@ -210,10 +210,12 @@ describe('readUsageEvents', () => { }); it('skips corrupted JSONL lines', async () => { - const usagePath = path.join(tmpDir, '.teamai', 'usage.jsonl'); - await appendUsageEvent({ skill: 'good', timestamp: '2026-01-01T00:00:00Z', tool: 'claude' }, userScope()); - await fs.promises.appendFile(usagePath, 'NOT_JSON\n'); - await appendUsageEvent({ skill: 'also-good', timestamp: '2026-01-02T00:00:00Z', tool: 'claude' }, userScope()); + const usagePath = path.join(tmpDir, '.teamai', 'user-usage.jsonl'); + await fse.ensureDir(path.dirname(usagePath)); + await fs.promises.writeFile( + usagePath, + '{"skill":"good","timestamp":"2026-01-01T00:00:00Z","tool":"claude"}\nNOT_JSON\n{"skill":"also-good","timestamp":"2026-01-02T00:00:00Z","tool":"claude"}\n', + ); const events = await readUsageEvents(userScope()); expect(events).toHaveLength(2); @@ -222,15 +224,12 @@ describe('readUsageEvents', () => { }); it('handles empty file', async () => { - const usagePath = path.join(tmpDir, '.teamai', 'usage.jsonl'); - // A first read settles the file as this scope's own, so the empty file - // below is parsed rather than discarded as pre-upgrade usage. - await readUsageEvents(userScope()); + const usagePath = path.join(tmpDir, '.teamai', 'user-usage.jsonl'); + await fse.ensureDir(path.dirname(usagePath)); await fs.promises.writeFile(usagePath, ''); const events = await readUsageEvents(userScope()); expect(events).toEqual([]); - expect(fs.existsSync(usagePath)).toBe(true); }); }); @@ -259,46 +258,23 @@ describe('truncateUsageAfterReport', () => { }); describe('usage recorded while every scope shared ~/.teamai/usage.jsonl (#748)', () => { - const usagePath = () => path.join(tmpDir, '.teamai', 'usage.jsonl'); + const sharedPath = () => path.join(tmpDir, '.teamai', 'usage.jsonl'); const legacy = '{"skill":"from-another-project","timestamp":"2026-01-01T00:00:00Z","tool":"claude"}\n'; it('is not read back as the user scope\'s own usage after an upgrade', async () => { // Written by an earlier release on a machine that already had a user scope. - await fs.promises.writeFile(usagePath(), legacy); + await fs.promises.writeFile(sharedPath(), legacy); expect(await readUsageEvents(userScope())).toEqual([]); - expect(fs.existsSync(usagePath())).toBe(false); + expect(fs.existsSync(sharedPath())).toBe(false); }); - it('is discarded once: usage recorded after the upgrade is kept', async () => { - await fs.promises.writeFile(usagePath(), legacy); + it('is not read back when an earlier release writes it again after a rollback', async () => { await appendUsageEvent({ skill: 'after-upgrade', timestamp: '2026-02-01T00:00:00Z', tool: 'claude' }, userScope()); + // Rolled back: the earlier release records every project's usage here again. + await fs.promises.appendFile(sharedPath(), legacy); expect((await readUsageEvents(userScope())).map((e) => e.skill)).toEqual(['after-upgrade']); - expect((await readUsageEvents(userScope())).map((e) => e.skill)).toEqual(['after-upgrade']); - }); - - it('is discarded by one process: a hook racing it keeps what the other recorded', async () => { - await fs.promises.writeFile(usagePath(), legacy); - const { acquireLock, releaseLock } = await import('../update.js'); - const lock = path.join(tmpDir, '.teamai', 'usage-per-scope.lock'); - expect(await acquireLock(lock)).toBe(true); - - let settled = false; - const racing = appendUsageEvent({ skill: 'second', timestamp: '2026-02-01T00:00:01Z', tool: 'claude' }, userScope()) - .then(() => { settled = true; }); - // It found no marker and is waiting on the lock, not writing. - await new Promise((resolve) => setTimeout(resolve, 100)); - expect(settled).toBe(false); - expect(fs.readFileSync(usagePath(), 'utf-8')).toBe(legacy); - // Meanwhile the lock holder finishes the discard and records its own event. - fs.rmSync(usagePath()); - fs.writeFileSync(path.join(tmpDir, '.teamai', 'usage-per-scope'), ''); - fs.appendFileSync(usagePath(), '{"skill":"first","timestamp":"2026-02-01T00:00:00Z","tool":"claude"}\n'); - await releaseLock(lock); - await racing; - - expect((await readUsageEvents(userScope())).map((e) => e.skill)).toEqual(['first', 'second']); }); }); @@ -347,7 +323,7 @@ describe('skill tracking where teamai is not set up (#748)', () => { }); async function expectNothingRecorded(): Promise { - expect(fs.existsSync(path.join(tmpDir, '.teamai', 'usage.jsonl'))).toBe(false); + expect(fs.existsSync(path.join(tmpDir, '.teamai', 'user-usage.jsonl'))).toBe(false); expect((await readKnownSkills()).size).toBe(0); } diff --git a/src/usage-tracker.ts b/src/usage-tracker.ts index 69b75294..ba034ce9 100644 --- a/src/usage-tracker.ts +++ b/src/usage-tracker.ts @@ -15,52 +15,22 @@ import { getUserHome } from './utils/home.js'; import { resolveHookCwd } from './utils/hook-cwd.js'; import { resolveConfigForDir } from './config.js'; -/** Present once `~/.teamai/usage.jsonl` holds only the user scope's own usage. */ -const USAGE_PER_SCOPE_MARKER = 'usage-per-scope'; -/** How long a hook waits for another process to finish that one-time discard. */ -const USAGE_DISCARD_WAIT = { attempts: 40, delayMs: 25 }; - /** * The usage JSONL of one scope: `/usage.jsonl`, so each scope reports * only the skills used where it is set up (#748). Evaluated at call time to * respect HOME changes in tests. * - * The user scope's file is where every scope used to record, so what an - * earlier release left there names no project. The first access after the - * upgrade discards it, before the user scope can report it to its team, and - * leaves a marker so later usage is kept. One process discards, under a lock; - * the others wait for the marker, so none deletes what another just recorded. - * Throws when the discard does not finish in time; every caller treats that - * as an I/O failure. + * The user scope records in `~/.teamai/user-usage.jsonl` instead: every scope + * used to record in `~/.teamai/usage.jsonl`, and an earlier release still does + * after a rollback, so what that file holds names no project. It is removed, + * never read, so the user scope cannot report it to its team. */ async function getUsagePath(config: LocalConfig): Promise { - const usagePath = path.join(getDataHome(config), 'usage.jsonl'); + const dataHome = getDataHome(config); const sharedDir = getTeamaiHomeDir(); - if (path.resolve(path.dirname(usagePath)) !== path.resolve(sharedDir)) return usagePath; - const marker = path.join(sharedDir, USAGE_PER_SCOPE_MARKER); - if (await pathExists(marker)) return usagePath; - - const { acquireLock, releaseLock } = await import('./update.js'); - const lock = `${marker}.lock`; - for (let attempt = 0; attempt < USAGE_DISCARD_WAIT.attempts; attempt++) { - if (await acquireLock(lock)) { - try { - if (!(await pathExists(marker))) { - // Remove before marking: a run cut short in between discards again - // rather than keeping unattributed events. - await fs.promises.rm(usagePath, { force: true }); - await fs.promises.writeFile(marker, '', 'utf-8'); - log.debug(`Discarded ${usagePath}: recorded before usage was kept per scope, it names no project (#748)`); - } - } finally { - await releaseLock(lock); - } - return usagePath; - } - await new Promise((resolve) => setTimeout(resolve, USAGE_DISCARD_WAIT.delayMs)); - if (await pathExists(marker)) return usagePath; - } - throw new Error(`${lock} is held by another process; skill usage from before the upgrade is still being discarded`); + if (path.resolve(dataHome) !== path.resolve(sharedDir)) return path.join(dataHome, 'usage.jsonl'); + await fs.promises.rm(path.join(sharedDir, 'usage.jsonl'), { force: true }); + return path.join(sharedDir, 'user-usage.jsonl'); } /** Get the known-skills.json path (evaluated at call time to respect HOME changes in tests). */ From b427ce0961d95a3332ed2a4c1f83bb8d25fbf633 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Wed, 23 Sep 2026 18:14:27 +0200 Subject: [PATCH 4/6] fix(usage): a failed removal of the shared usage file does not stop the user scope (#748) Also names the user scope's own file where comments and the design diagram still described every scope's usage as /usage.jsonl. --- docs/designs/team-intelligence-platform.md | 6 +++--- src/__tests__/usage-tracking.test.ts | 8 ++++++++ src/pull.ts | 3 ++- src/team-push.ts | 2 +- src/usage-tracker.ts | 12 ++++++++---- 5 files changed, 22 insertions(+), 9 deletions(-) diff --git a/docs/designs/team-intelligence-platform.md b/docs/designs/team-intelligence-platform.md index e62fc012..53771acf 100644 --- a/docs/designs/team-intelligence-platform.md +++ b/docs/designs/team-intelligence-platform.md @@ -95,7 +95,7 @@ Skill Usage Statistics: teamai pull │ ▼ -聚合 /usage.jsonl → stats/.yaml +聚合该 scope 的 usage 文件 → stats/.yaml │ ▼ reports worktree → git add → git commit → git push (teamai-reports) @@ -163,13 +163,13 @@ reports worktree → git add → git commit → git push (teamai-reports) ┌──────────────┐ │ digest.ts │ push --stats │ Local │ │ stats.ts │ push --sessions │ ~/.teamai/ │◀───│ team-push.ts ──┼────▶(直接 commit, 无 MR) -│ usage.jsonl*│ │ │ +│ usage* │ │ │ │ sessions/ │ └────────────────┘ │ .md │ └──────────────┘ ``` -\* `usage.jsonl` 按 scope 存放在 `/`(项目分区;user scope 为 `~/.teamai/user-usage.jsonl`,#748);`sessions/` 仍是机器级别的 `~/.teamai/`。 +\* skill 使用记录按 scope 存放:项目为 `/usage.jsonl`(项目分区),user scope 为 `~/.teamai/user-usage.jsonl`(#748);`sessions/` 仍是机器级别的 `~/.teamai/`。 ## New Files diff --git a/src/__tests__/usage-tracking.test.ts b/src/__tests__/usage-tracking.test.ts index a1082674..d52a29a3 100644 --- a/src/__tests__/usage-tracking.test.ts +++ b/src/__tests__/usage-tracking.test.ts @@ -276,6 +276,14 @@ describe('usage recorded while every scope shared ~/.teamai/usage.jsonl (#748)', expect((await readUsageEvents(userScope())).map((e) => e.skill)).toEqual(['after-upgrade']); }); + + it('does not stop the user scope from recording when it cannot be removed', async () => { + // Stands in for a file another program holds open on Windows (EPERM/EBUSY). + await fs.promises.mkdir(sharedPath()); + await appendUsageEvent({ skill: 'kept', timestamp: '2026-02-01T00:00:00Z', tool: 'claude' }, userScope()); + + expect((await readUsageEvents(userScope())).map((e) => e.skill)).toEqual(['kept']); + }); }); describe('track', () => { diff --git a/src/pull.ts b/src/pull.ts index 0e498204..5de3020c 100644 --- a/src/pull.ts +++ b/src/pull.ts @@ -2011,7 +2011,8 @@ export async function pull( await reconcileCoAuthorAllScopes(reconcileUser, reconcileProject, options); // 4. Auto-report usage data to all active scopes. Skill usage lives in each - // scope's own `/usage.jsonl`, so each target reports and then + // scope's own file (`/usage.jsonl`, the user scope's + // `~/.teamai/user-usage.jsonl`), so each target reports and then // truncates only its own file. Dashboard sessions live in one shared file // and are filtered instead: project scope only gets sessions whose cwd is // under projectRoot; user scope excludes those sessions. diff --git a/src/team-push.ts b/src/team-push.ts index 80f63dc6..fbb91efe 100644 --- a/src/team-push.ts +++ b/src/team-push.ts @@ -49,7 +49,7 @@ interface PromptTokenDelta { // [git pull latest] ── get freshest remote state ── // │ // ▼ -// [read /usage.jsonl] ─has events?─▶ merge stats +// [read scope usage file] ─has events?─▶ merge stats // │ │ // ▼ ▼ // [stage pending votes from ~/.teamai/votes/] [write stats/.yaml] diff --git a/src/usage-tracker.ts b/src/usage-tracker.ts index ba034ce9..bf4bb86a 100644 --- a/src/usage-tracker.ts +++ b/src/usage-tracker.ts @@ -23,13 +23,17 @@ import { resolveConfigForDir } from './config.js'; * The user scope records in `~/.teamai/user-usage.jsonl` instead: every scope * used to record in `~/.teamai/usage.jsonl`, and an earlier release still does * after a rollback, so what that file holds names no project. It is removed, - * never read, so the user scope cannot report it to its team. + * never read, so the user scope cannot report it to its team; a removal that + * fails leaves it unread and does not stop the user scope's own file. */ async function getUsagePath(config: LocalConfig): Promise { const dataHome = getDataHome(config); const sharedDir = getTeamaiHomeDir(); if (path.resolve(dataHome) !== path.resolve(sharedDir)) return path.join(dataHome, 'usage.jsonl'); - await fs.promises.rm(path.join(sharedDir, 'usage.jsonl'), { force: true }); + const shared = path.join(sharedDir, 'usage.jsonl'); + await fs.promises.rm(shared, { force: true }).catch((e: unknown) => { + log.debug(`Could not remove ${shared}, it stays unread: ${e instanceof Error ? e.message : String(e)}`); + }); return path.join(sharedDir, 'user-usage.jsonl'); } @@ -60,7 +64,7 @@ function getKnownSkillsPath(): string { // [resolveConfigForDir(cwd)] ─null─▶ skip (#748) // │ // ▼ -// appendFile(/usage.jsonl, JSON line) +// appendFile(, JSON line) // │ // ▼ // updateKnownSkills(skill) → known-skills.json @@ -82,7 +86,7 @@ function getKnownSkillsPath(): string { // [extract & validate skill name after "/"] // │ // ▼ -// appendFile(/usage.jsonl) + updateKnownSkills() +// appendFile() + updateKnownSkills() // /** From 271b8570e7263c329c9e02f1c04f5294f0d447f4 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Wed, 23 Sep 2026 19:07:09 +0200 Subject: [PATCH 5/6] docs(changelog): drop the claim that teamai doctor reports an unreadable project config resolveDoctorContext falls back past an unreadable project config the way detection does, so doctor diagnoses the config it falls back to and says nothing about the broken one (#752). --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f2c86ff..27cf5ee2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,7 +29,7 @@ All notable changes to this project will be documented in this file. See [standa - 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)). -- Team hooks stay out of projects that never set up teamai. A project-scope install puts its hooks in the home directory, so they fire in every project on the machine, and with no config for the directory they used to run anyway: the end-of-session share reminder (shown there even with recall off, a case a configured team never sees), the TodoWrite recall nudge, and the local recording of sessions and skill usage that a later report from another project pushed to its team. A handler that needs a team now declares `requiresConfig`, and the dispatcher drops it when neither a project nor a user config resolves for the hook's `cwd`; only machine-level work runs there (CLI update check, session-start pull, local agent, package hints the pull stashed). A config that exists but fails to parse reads the same way, so it withholds team prompts rather than running all of them, and for team hooks and skill usage an unreadable project config never falls back to the user scope, nor to a lower-priority project config such as a legacy `.teamai/` behind a broken partition (the session-start pull still resolves its project on its own); `teamai doctor` reports it. A host that sends no `cwd` (OpenClaw) resolves the project from the directory it runs the hook in, a `cwd` that no longer exists resolves to the user scope instead of failing the hook, and the legacy `teamai contribute-check` command that older installs still call follows the same rule (for [#748](https://github.com/Tencent/teamai-cli/issues/748)). +- Team hooks stay out of projects that never set up teamai. A project-scope install puts its hooks in the home directory, so they fire in every project on the machine, and with no config for the directory they used to run anyway: the end-of-session share reminder (shown there even with recall off, a case a configured team never sees), the TodoWrite recall nudge, and the local recording of sessions and skill usage that a later report from another project pushed to its team. A handler that needs a team now declares `requiresConfig`, and the dispatcher drops it when neither a project nor a user config resolves for the hook's `cwd`; only machine-level work runs there (CLI update check, session-start pull, local agent, package hints the pull stashed). A config that exists but fails to parse reads the same way, so it withholds team prompts rather than running all of them, and for team hooks and skill usage an unreadable project config never falls back to the user scope, nor to a lower-priority project config such as a legacy `.teamai/` behind a broken partition (the session-start pull still resolves its project on its own). A host that sends no `cwd` (OpenClaw) resolves the project from the directory it runs the hook in, a `cwd` that no longer exists resolves to the user scope instead of failing the hook, and the legacy `teamai contribute-check` command that older installs still call follows the same rule (for [#748](https://github.com/Tencent/teamai-cli/issues/748)). - Every team hook handler now works in the scope `hook-dispatch` resolved for the hook's `cwd`. The team correction keywords, votes and webhooks read their config again from the directory the hook process ran in, so when that `cwd` no longer existed (a deleted worktree) and the host started the hook inside another project, that project's keywords were applied, the session's votes were recorded under its member and pushed to its team, and its webhooks fired. The background handlers (session-start pull, webhooks, update check) also run again when that `cwd` no longer exists: on macOS and Linux their detached process was started in it, so the start failed silently and none of them ran. It now starts in the temp directory, as on Windows (for [#752](https://github.com/Tencent/teamai-cli/issues/752)). - Skill usage stays with the team of the project it was recorded in. Every scope used to append to one `~/.teamai/usage.jsonl`, so whichever project pulled next reported every project's skills to its own team, including skills that exist only in an unrelated private repo. Usage now goes to the data directory of the scope that resolves for the session's directory (`/usage.jsonl`: the project partition, or `/.teamai` for an install not yet migrated to one, and `~/.teamai/user-usage.jsonl` for the user scope), each report reads and truncates only its own file, and `teamai stats` shows the current scope's usage. Events in `~/.teamai/usage.jsonl` name no project, whether an earlier release left them there or writes them again after a rollback, so they are never reported and are removed whenever the user scope reads or records usage. Stats already pushed are not rewritten (for [#748](https://github.com/Tencent/teamai-cli/issues/748)). - `teamai init` no longer hangs without a terminal. When the provider had no session it spawned `gh auth login --web` (or `gf auth login`, `cnb login`) with inherited stdio and waited for a browser device flow that nobody could complete, about five minutes for GitHub, then exited with the provider's error and no hint of the missing credential. Each login now refuses up front when the run is not interactive and names the credential to prepare (`GITHUB_TOKEN` / `GH_TOKEN`, `CNB_TOKEN`, or for TGit a prior `gf auth login`, since a `TGIT_TOKEN` PAT is REST-API-only and cannot clone). A run is non-interactive when stdin is not a TTY or when `CI` or `TEAMAI_NONINTERACTIVE` is set, so an agent sandbox with a pseudo-terminal can declare itself unattended, and every prompt in the CLI follows the same rule. `git` also runs with its prompts closed in that case — `GIT_TERMINAL_PROMPT=0`, `GIT_ASKPASS=echo` and `GCM_INTERACTIVE=never`, each only where the caller set nothing — so a missing clone credential fails at once instead of waiting on a terminal prompt or an askpass or credential-manager dialog. `ssh` keeps its own settings: its batch flag is only reachable through `GIT_SSH_COMMAND`, which would override each repository's `core.sshCommand` (for [#711](https://github.com/Tencent/teamai-cli/issues/711)). From 99485c4bfb900ce089fceae009ba1c75a7ff6ce0 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Wed, 23 Sep 2026 19:28:04 +0200 Subject: [PATCH 6/6] fix(usage): leave the shared usage file in place instead of removing it on every access (#748) getUsagePath deleted ~/.teamai/usage.jsonl on every user-scope call, including each hook append and the read-only `teamai stats`. The user scope never reads that file, which is what keeps its events off the team; the delete added a side effect to a path getter and a failure path to guard. --- CHANGELOG.md | 2 +- docs/designs/data-directory-layout.md | 4 ++-- src/__tests__/usage-tracking.test.ts | 11 ++--------- src/usage-tracker.ts | 17 ++++++----------- 4 files changed, 11 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 27cf5ee2..2fa0cbfc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,7 +31,7 @@ All notable changes to this project will be documented in this file. See [standa - 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)). - Team hooks stay out of projects that never set up teamai. A project-scope install puts its hooks in the home directory, so they fire in every project on the machine, and with no config for the directory they used to run anyway: the end-of-session share reminder (shown there even with recall off, a case a configured team never sees), the TodoWrite recall nudge, and the local recording of sessions and skill usage that a later report from another project pushed to its team. A handler that needs a team now declares `requiresConfig`, and the dispatcher drops it when neither a project nor a user config resolves for the hook's `cwd`; only machine-level work runs there (CLI update check, session-start pull, local agent, package hints the pull stashed). A config that exists but fails to parse reads the same way, so it withholds team prompts rather than running all of them, and for team hooks and skill usage an unreadable project config never falls back to the user scope, nor to a lower-priority project config such as a legacy `.teamai/` behind a broken partition (the session-start pull still resolves its project on its own). A host that sends no `cwd` (OpenClaw) resolves the project from the directory it runs the hook in, a `cwd` that no longer exists resolves to the user scope instead of failing the hook, and the legacy `teamai contribute-check` command that older installs still call follows the same rule (for [#748](https://github.com/Tencent/teamai-cli/issues/748)). - Every team hook handler now works in the scope `hook-dispatch` resolved for the hook's `cwd`. The team correction keywords, votes and webhooks read their config again from the directory the hook process ran in, so when that `cwd` no longer existed (a deleted worktree) and the host started the hook inside another project, that project's keywords were applied, the session's votes were recorded under its member and pushed to its team, and its webhooks fired. The background handlers (session-start pull, webhooks, update check) also run again when that `cwd` no longer exists: on macOS and Linux their detached process was started in it, so the start failed silently and none of them ran. It now starts in the temp directory, as on Windows (for [#752](https://github.com/Tencent/teamai-cli/issues/752)). -- Skill usage stays with the team of the project it was recorded in. Every scope used to append to one `~/.teamai/usage.jsonl`, so whichever project pulled next reported every project's skills to its own team, including skills that exist only in an unrelated private repo. Usage now goes to the data directory of the scope that resolves for the session's directory (`/usage.jsonl`: the project partition, or `/.teamai` for an install not yet migrated to one, and `~/.teamai/user-usage.jsonl` for the user scope), each report reads and truncates only its own file, and `teamai stats` shows the current scope's usage. Events in `~/.teamai/usage.jsonl` name no project, whether an earlier release left them there or writes them again after a rollback, so they are never reported and are removed whenever the user scope reads or records usage. Stats already pushed are not rewritten (for [#748](https://github.com/Tencent/teamai-cli/issues/748)). +- Skill usage stays with the team of the project it was recorded in. Every scope used to append to one `~/.teamai/usage.jsonl`, so whichever project pulled next reported every project's skills to its own team, including skills that exist only in an unrelated private repo. Usage now goes to the data directory of the scope that resolves for the session's directory (`/usage.jsonl`: the project partition, or `/.teamai` for an install not yet migrated to one, and `~/.teamai/user-usage.jsonl` for the user scope), each report reads and truncates only its own file, and `teamai stats` shows the current scope's usage. Events in `~/.teamai/usage.jsonl` name no project, whether an earlier release left them there or writes them again after a rollback, so they are never read or reported. Stats already pushed are not rewritten (for [#748](https://github.com/Tencent/teamai-cli/issues/748)). - `teamai init` no longer hangs without a terminal. When the provider had no session it spawned `gh auth login --web` (or `gf auth login`, `cnb login`) with inherited stdio and waited for a browser device flow that nobody could complete, about five minutes for GitHub, then exited with the provider's error and no hint of the missing credential. Each login now refuses up front when the run is not interactive and names the credential to prepare (`GITHUB_TOKEN` / `GH_TOKEN`, `CNB_TOKEN`, or for TGit a prior `gf auth login`, since a `TGIT_TOKEN` PAT is REST-API-only and cannot clone). A run is non-interactive when stdin is not a TTY or when `CI` or `TEAMAI_NONINTERACTIVE` is set, so an agent sandbox with a pseudo-terminal can declare itself unattended, and every prompt in the CLI follows the same rule. `git` also runs with its prompts closed in that case — `GIT_TERMINAL_PROMPT=0`, `GIT_ASKPASS=echo` and `GCM_INTERACTIVE=never`, each only where the caller set nothing — so a missing clone credential fails at once instead of waiting on a terminal prompt or an askpass or credential-manager dialog. `ssh` keeps its own settings: its batch flag is only reachable through `GIT_SSH_COMMAND`, which would override each repository's `core.sshCommand` (for [#711](https://github.com/Tencent/teamai-cli/issues/711)). - A `manifest/roles.yaml` that exists but does not parse now fails the pull for that scope instead of warning and syncing with no role filter at all, for a member with no role as much as for one with a role. The same applies to `init` and `push`, which each fell back to a guess at the namespaces when any error came out of the loader. The legacy role migration skips with a warning instead of failing, so every command, `pull` included, still loads the config and can fetch the fixed manifest; until it can run, the member holds no role rather than every role, so hooks, MCP servers and env variables scoped by `roles:` reach them no more than skills do. For a member with no active project that fallback meant an unfiltered sync, so a broken manifest delivered every namespace it was written to gate. Only an absent manifest still means "this team does not use roles"; an unreadable or empty file is an error, as it now is for `manifest/projects.yaml` too. `push` stops at its scan for such a manifest (exit 2) even with `--role `, since the scan needs it to tell which namespaces are the member's. - `teamai members list` and `teamai projects members` read the roster registered before the reports switch, so a team upgrading past the orphan-branch split no longer sees "No team members registered" while its `members/` still lives on the default branch. The default-branch copy becomes a read-only inherited root, the way learnings' already was: listed in union with the `teamai-reports` copy, with the branch copy winning when the same file exists on both; nothing is copied or deleted, and a cold `members list` still does not publish the reports branch. Member registration merges against the inherited copy too, so a re-init keeps the original `registeredAt` and projects. Fixes [#735](https://github.com/Tencent/teamai-cli/issues/735). diff --git a/docs/designs/data-directory-layout.md b/docs/designs/data-directory-layout.md index 0dbbf503..46df6a2f 100644 --- a/docs/designs/data-directory-layout.md +++ b/docs/designs/data-directory-layout.md @@ -311,8 +311,8 @@ already route through `getDataHome()`. Skill usage moved there too (#748): `usage.jsonl` lives in each scope's `getDataHome()`, because one shared file let a project's report carry every project's skills. The user scope records in `~/.teamai/user-usage.jsonl`, not that old shared `~/.teamai/usage.jsonl`, which -an earlier release still writes after a rollback; the shared file is removed, -never read. The dashboard is likewise an A2 singleton +an earlier release still writes after a rollback; the shared file is never +read. The dashboard is likewise an A2 singleton (events carry `cwd`/`sessionId`); "two projects' events don't mix" is satisfied by `getEventsPath()` reading `HOME` at call time, not by per-project dirs. diff --git a/src/__tests__/usage-tracking.test.ts b/src/__tests__/usage-tracking.test.ts index d52a29a3..633908d5 100644 --- a/src/__tests__/usage-tracking.test.ts +++ b/src/__tests__/usage-tracking.test.ts @@ -266,7 +266,8 @@ describe('usage recorded while every scope shared ~/.teamai/usage.jsonl (#748)', await fs.promises.writeFile(sharedPath(), legacy); expect(await readUsageEvents(userScope())).toEqual([]); - expect(fs.existsSync(sharedPath())).toBe(false); + // Reading usage (as `teamai stats` does) leaves the file alone. + expect(fs.readFileSync(sharedPath(), 'utf-8')).toBe(legacy); }); it('is not read back when an earlier release writes it again after a rollback', async () => { @@ -276,14 +277,6 @@ describe('usage recorded while every scope shared ~/.teamai/usage.jsonl (#748)', expect((await readUsageEvents(userScope())).map((e) => e.skill)).toEqual(['after-upgrade']); }); - - it('does not stop the user scope from recording when it cannot be removed', async () => { - // Stands in for a file another program holds open on Windows (EPERM/EBUSY). - await fs.promises.mkdir(sharedPath()); - await appendUsageEvent({ skill: 'kept', timestamp: '2026-02-01T00:00:00Z', tool: 'claude' }, userScope()); - - expect((await readUsageEvents(userScope())).map((e) => e.skill)).toEqual(['kept']); - }); }); describe('track', () => { diff --git a/src/usage-tracker.ts b/src/usage-tracker.ts index bf4bb86a..c58e1f95 100644 --- a/src/usage-tracker.ts +++ b/src/usage-tracker.ts @@ -22,18 +22,13 @@ import { resolveConfigForDir } from './config.js'; * * The user scope records in `~/.teamai/user-usage.jsonl` instead: every scope * used to record in `~/.teamai/usage.jsonl`, and an earlier release still does - * after a rollback, so what that file holds names no project. It is removed, - * never read, so the user scope cannot report it to its team; a removal that - * fails leaves it unread and does not stop the user scope's own file. + * after a rollback, so what that file holds names no project. It is never + * read, so the user scope cannot report it to its team. */ -async function getUsagePath(config: LocalConfig): Promise { +function getUsagePath(config: LocalConfig): string { const dataHome = getDataHome(config); const sharedDir = getTeamaiHomeDir(); if (path.resolve(dataHome) !== path.resolve(sharedDir)) return path.join(dataHome, 'usage.jsonl'); - const shared = path.join(sharedDir, 'usage.jsonl'); - await fs.promises.rm(shared, { force: true }).catch((e: unknown) => { - log.debug(`Could not remove ${shared}, it stays unread: ${e instanceof Error ? e.message : String(e)}`); - }); return path.join(sharedDir, 'user-usage.jsonl'); } @@ -229,7 +224,7 @@ export async function skillExistsOnDisk(skillName: string): Promise { */ export async function appendUsageEvent(event: UsageEvent, config: LocalConfig): Promise { try { - const usagePath = await getUsagePath(config); + const usagePath = getUsagePath(config); await ensureDir(path.dirname(usagePath)); const line = JSON.stringify(event) + '\n'; await fs.promises.appendFile(usagePath, line, 'utf-8'); @@ -245,7 +240,7 @@ export async function appendUsageEvent(event: UsageEvent, config: LocalConfig): */ export async function readUsageEvents(config: LocalConfig): Promise { try { - const content = await fs.promises.readFile(await getUsagePath(config), 'utf-8'); + const content = await fs.promises.readFile(getUsagePath(config), 'utf-8'); const events: UsageEvent[] = []; for (const line of content.split('\n')) { const trimmed = line.trim(); @@ -271,7 +266,7 @@ export async function readUsageEvents(config: LocalConfig): Promise { try { - const usagePath = await getUsagePath(config); + const usagePath = getUsagePath(config); const content = await fs.promises.readFile(usagePath, 'utf-8'); const lines = content.split('\n').filter((l) => l.trim()); if (reportedCount >= lines.length) {