From 63655edfb1b71b6936f9a41d74037ccf16b3807c Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Thu, 24 Sep 2026 11:35:59 +0200 Subject: [PATCH 1/5] fix(recall): search nothing in a project whose config cannot be read (#796) Detection skips a project config it cannot read and returns what loads next: a legacy .teamai/ behind a broken partition, which may name another team, or the user scope. recall searched that knowledge, recorded recalled counts for it, and `recall --check` answered for it; with nothing behind the broken file it printed NOT_RELEVANT, so the recall subagent told the member the team had no knowledge and nobody learned the config was broken. recall() now listens for the unreadable config before anything else, searches and records nothing, prints the problem with BROKEN_CONFIG_ADVICE and exits 1, `--check` included. A silent caller records it in debug.log only, the rule pull follows since #784. The teamai-recall agent relays that line instead of skipping the precheck. --- CHANGELOG.md | 1 + agents/teamai-recall.md | 16 +- docs/usage-guide.md | 1 + docs/usage-guide.zh-CN.md | 1 + skill-data/core/references/troubleshooting.md | 2 + .../recall-unreadable-config.test.ts | 247 ++++++++++++++++++ src/recall.ts | 48 ++-- 7 files changed, 295 insertions(+), 21 deletions(-) create mode 100644 src/__tests__/recall-unreadable-config.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 93661b644..5f036b16a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ All notable changes to this project will be documented in this file. See [standa - `teamai stats` no longer counts a session twice, and no longer counts another project's sessions. Its dashboard section added the whole machine's local `events.jsonl` metrics to the scope's already-reported totals from the team repo, so every session that a `pull` had reported — and that stays in the event log until compaction — was counted once by the team total and once again locally, and sessions whose `cwd` belonged to a different project were added to this scope's as well. It now filters the event log the way `teamai pull` reports it (only the sessions recorded in the current scope, see #785) and adds only what that scope has not reported yet, derived from the same per-scope `reported-*` snapshots the report path advances — so the local figure agrees with the team's instead of exceeding it. When the reported totals could not be read at all (no stats file, an unreadable one, a reports worktree that is not there), or when they exist but hold nothing yet, nothing is subtracted — a snapshot can name a session the team file never received, so a non-empty team total is what licenses trusting them, and a session the member can see happening is never hidden. The per-repo and by-hour breakdowns read that same filtered log, so they stay inside the scope; they answer a different question from the headline — what this machine's retained event log holds, per repo — and both the headings and the `--by-repo` / `--by-time` flag descriptions now name that source instead of presenting them as a split of the headline (for [#768](https://github.com/Tencent/teamai-cli/issues/768)). - The data-partition migration no longer retires a legacy `/.teamai/` while the partition's `config.yaml` cannot be read. `teamai init`, `pull` and `push` took a partition config that merely existed as a finished migration and renamed the legacy directory to `.teamai.bak`, although it held the only config that still loaded. A partition `config.yaml` that is empty or cannot be opened, does not parse, does not validate, or is not `scope: project` now leaves the legacy directory in place and names the file to fix; once it is fixed, the next of those commands retires it as before. A partition directory whose `config.yaml` was moved aside is no longer replaced by a fresh copy of the legacy directory, which deleted what the partition held; the migration waits and says to restore the file or move the directory aside (for [#797](https://github.com/Tencent/teamai-cli/issues/797)). +- `teamai recall` searches nothing in a project whose config exists but cannot be read, and says why. Detection skipped the broken file and searched whatever loaded next: the user scope, or a legacy `.teamai/` behind a broken partition that may belong to another team, whose knowledge was returned and whose documents got recalled counts. It now prints ``Nothing was searched: : . Fix the file, or move it aside and run `teamai init` to write a new one.`` and exits 1, and `recall --check` does the same instead of printing `NOT_RELEVANT`, which told the recall subagent the team had no knowledge. The subagent now relays that line to the member. (for [#796](https://github.com/Tencent/teamai-cli/issues/796)). - `teamai pull` syncs nothing in a project whose config exists but cannot be read, and says why. Detection skipped the broken file and pulled whatever loaded next: the user scope, or a legacy `.teamai/` behind a broken partition that may belong to another team, whose skills, rules and docs were deployed and to which the project's usage was reported. It now prints ``Nothing was synced: : . Fix the file, or move it aside and run `teamai init` to write a new one.`` and exits 1. A session start there runs no pull, seeds no agent directory and stashes no package hint; `teamai pull --silent`, which hooks from before `hook-dispatch` still run, prints nothing, writes the reason to `~/.teamai/debug.log` and exits 1. This is the rule team hooks and usage follow since [#748](https://github.com/Tencent/teamai-cli/issues/748) (for [#784](https://github.com/Tencent/teamai-cli/issues/784)). - 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)). diff --git a/agents/teamai-recall.md b/agents/teamai-recall.md index 2dd0b91fa..c3414fbd0 100644 --- a/agents/teamai-recall.md +++ b/agents/teamai-recall.md @@ -43,8 +43,13 @@ teamai recall --check "<3-6 keywords from the task>" computed over titles and tags only, so a term reported missing may still be discussed in a body that a full recall (or a `Grep`) will surface. Only `NOT_RELEVANT` short-circuits the flow. -- If the command fails or `teamai` is not on PATH: skip the precheck and - continue to Step 1 (do not block on precheck failure). +- If the output (stdout or stderr) contains `Nothing was searched:`: this + project's teamai config cannot be read, so no team knowledge was searched. + Return that line from `Nothing was searched:` to its end, verbatim (it names + the file and the fix), and **stop** — do not report "no relevant team + knowledge", and do not proceed to Step 1–5. +- If the command fails in any other way or `teamai` is not on PATH: skip the + precheck and continue to Step 1 (do not block on precheck failure). #### Complexity quick-judge (after RELEVANT) @@ -199,9 +204,10 @@ in `teamwiki/` with BM25 + graph-boost. Capture the full output. If the first call returns insufficient results, you may retry once with `--depth lookup` to broaden the search to raw symbol pages. -If the command fails, knowledge base is empty, or returns zero hits, -emit a single line `No relevant team knowledge found for: ` and -stop. +If the output contains `Nothing was searched:`, return that line from the +marker on and stop, as in Step 0. If the command fails otherwise, knowledge base is +empty, or returns zero hits, emit a single line +`No relevant team knowledge found for: ` and stop. ### Step 4 — Read the top hits and drill into codebase diff --git a/docs/usage-guide.md b/docs/usage-guide.md index dc0bf7da5..25c66c982 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1028,6 +1028,7 @@ teamai recall "GPU out of memory" - Searches the project scope when the current working directory contains its config; with `inheritUserScope: true`, searches project first and user second, labeling results `[project]`/`[user]`. Otherwise searches user scope - For the same resource type and filename, the project entry wins; different resource types with the same filename remain separate - Consulted active-scope knowledge is automatically upvoted. Inherited user hits remain read-only while the project is active +- In a project whose config exists but cannot be read, recall searches and records nothing, neither the user scope nor a lower-priority project config (such as a legacy `.teamai/config.yaml`) behind it: it prints ``Nothing was searched: : . Fix the file, or move it aside and run `teamai init` to write a new one.`` and exits 1, with `--check` too, which prints no verdict. The recall subagent relays that line instead of reporting no knowledge. With no config at all, recall still says no learnings are available and exits 0 - A lightweight relevance precheck is available via `teamai recall --check ""`, which prints `RELEVANT score= threshold=` or `NOT_RELEVANT score= threshold=` without reading files or upvoting — the recall subagent uses it to skip retrieval on unrelated tasks. For a `RELEVANT` top hit it also reports `matched=`/`missing=` — the query terms that hit its title/tags and those that did not - `RELEVANT` means a hit cleared the score threshold, i.e. reading files is worth the cost — it does not mean the knowledge base covers your subject. Use the `matched=`/`missing=` terms (and the `Matched:`/`Missing:` lines on full results) to make that judgement: a hit missing all your distinctive terms is topically adjacent, not an answer diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 8f07c6009..327186b07 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -981,6 +981,7 @@ teamai recall "GPU 内存不足" - 当前工作目录包含 project scope 配置时搜索该项目;配置 `inheritUserScope: true` 后先搜索 project、再搜索 user,并标注 `[project]`/`[user]` 来源;否则搜索 user scope - 资源类型和文件名都相同时由 project 条目优先;不同资源类型即使文件名相同也分别保留 - 当前 scope 中被查阅的知识自动 upvote;项目运行期间继承的 user 命中保持只读 +- 当 project 配置存在但无法读取时,recall 不检索也不记录任何内容,既不退回 user scope,也不退回其后优先级更低的 project 配置(如旧的 `.teamai/config.yaml`):输出 ``Nothing was searched: : . Fix the file, or move it aside and run `teamai init` to write a new one.`` 并以 exit 1 退出;`--check` 同样如此,不输出任何判定。recall subagent 会原样转述这一行,而不是报告没有团队知识。完全没有配置时,recall 仍提示没有可用的 learnings 并以 exit 0 退出 - 提供轻量相关性预检 `teamai recall --check "<关键词>"`,输出 `RELEVANT score= threshold=` 或 `NOT_RELEVANT score= threshold=`,不读取文件、不 upvote —— recall subagent 用它在任务与团队知识无关时跳过检索。当 top 命中为 `RELEVANT` 时,还会输出 `matched=`/`missing=`,即命中/未命中其 title 与 tag 的查询词 - `RELEVANT` 表示分数越过阈值、值得花成本读文件,**不代表**知识库覆盖了你要找的主题。请用 `matched=`/`missing=`(以及完整结果里的 `Matched:`/`Missing:` 行)自行判断:若关键区分词全部落在 missing 里,那条只是主题相邻,并非答案 diff --git a/skill-data/core/references/troubleshooting.md b/skill-data/core/references/troubleshooting.md index a7c255a17..0d9b60ee6 100644 --- a/skill-data/core/references/troubleshooting.md +++ b/skill-data/core/references/troubleshooting.md @@ -54,6 +54,8 @@ This is the #1 onboarding issue. In order: file and the reason; `teamai doctor` checks another config and can pass here. Moving it aside and re-running `teamai init` replaces their settings for that project: do it only with their consent. + `recall` refuses the same way with `Nothing was searched: : `: + no team knowledge was searched, so do not report that the team has none. ## Permission / access denied diff --git a/src/__tests__/recall-unreadable-config.test.ts b/src/__tests__/recall-unreadable-config.test.ts new file mode 100644 index 000000000..5a0381723 --- /dev/null +++ b/src/__tests__/recall-unreadable-config.test.ts @@ -0,0 +1,247 @@ +/** + * `teamai recall` in a project whose config cannot be read (#796): real config + * files and a real search index in a sandbox HOME, so detection and search run + * for real. What recall searches (stdout) and records (votes, recall quality) + * is what these tests observe. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +vi.mock('../utils/logger.js', () => ({ + log: { info: vi.fn(), success: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), dim: vi.fn(), persist: vi.fn() }, +})); +vi.mock('../code-knowledge-recall.js', () => ({ + queryCodeKnowledge: vi.fn().mockResolvedValue([]), +})); + +const { recall } = await import('../recall.js'); +const { resolveProjectDataHome, saveLocalConfigForScope } = await import('../config.js'); +const { readRecallQuality } = await import('../recall-quality.js'); +const { log } = await import('../utils/logger.js'); + +const SESSION = 'recall-unreadable-session'; +const QUERY = 'deployment timeout'; + +let tmp: string; +let originalCwd: string; +let originalExitCode: typeof process.exitCode; +let stdout: string; + +beforeEach(() => { + vi.clearAllMocks(); + tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-recall-unreadable-'))); + originalCwd = process.cwd(); + originalExitCode = process.exitCode; + vi.stubEnv('HOME', path.join(tmp, 'home')); + vi.stubEnv('CLAUDE_SESSION_ID', SESSION); + vi.stubEnv('TEAMAI_RECALL_DISABLED', ''); + fs.mkdirSync(path.join(tmp, 'home')); + stdout = ''; + vi.spyOn(process.stdout, 'write').mockImplementation((chunk: string | Uint8Array) => { + stdout += String(chunk); + return true; + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + process.chdir(originalCwd); + process.exitCode = originalExitCode; + fs.rmSync(tmp, { recursive: true, force: true }); +}); + +/** A team repo holding one doc that matches QUERY, titled after the team. */ +function teamRepo(dir: string, team: string): string { + fs.mkdirSync(path.join(dir, 'docs'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'teamai.yaml'), `team: ${team}\nrepo: https://example.test/acme/${team}.git\n`); + fs.writeFileSync(path.join(dir, 'docs', `${team}-timeout.md`), + `---\ntitle: "${team} deployment timeout fix"\nauthor: tester\ndate: 2026-05-01\ntags: [deployment, timeout]\n---\n\nRaise the deployment timeout.\n`); + return dir; +} + +function userScope(): string { + const home = path.join(tmp, 'home', '.teamai'); + const repo = teamRepo(path.join(home, 'team-repo'), 'user-team'); + fs.writeFileSync(path.join(home, 'config.yaml'), + `repo:\n localPath: ${repo}\n remote: https://example.test/acme/user-team.git\nusername: tester\nscope: user\n`); + return repo; +} + +function gitRepo(name: string): string { + const dir = path.join(tmp, name); + fs.mkdirSync(dir); + execFileSync('git', ['init', '-q'], { cwd: dir }); + return dir; +} + +async function brokenPartition(root: string): Promise { + const partition = await resolveProjectDataHome(root); + fs.mkdirSync(partition, { recursive: true }); + const configPath = path.join(partition, 'config.yaml'); + fs.writeFileSync(configPath, 'repo: [not: a, valid config\n'); + return configPath; +} + +function legacyOtherTeam(root: string): void { + const legacy = path.join(root, '.teamai'); + const repo = teamRepo(path.join(legacy, 'team-repo'), 'other-team'); + fs.writeFileSync(path.join(legacy, 'config.yaml'), + `repo:\n localPath: ${repo}\n remote: https://example.test/acme/other-team.git\nusername: tester\nscope: project\n`); +} + +async function readableProject(root: string, inheritUserScope: boolean): Promise { + const dataHome = await resolveProjectDataHome(root); + const repo = teamRepo(path.join(dataHome, 'team-repo'), 'team-a'); + await saveLocalConfigForScope({ + repo: { localPath: repo, remote: 'https://example.test/acme/team-a.git' }, + username: 'tester', scope: 'project', projectRoot: root, additionalRoles: [], dataHome, + inheritUserScope, + }); +} + +/** Everything recall records about a search: recalled counts and the quality signal. */ +function recorded(): { votes: boolean; quality: boolean } { + return { + votes: fs.existsSync(path.join(tmp, 'home', '.teamai', 'votes')), + quality: readRecallQuality(SESSION) !== null, + }; +} + +function errors(): string[] { + return vi.mocked(log.error).mock.calls.map(([msg]) => String(msg)); +} + +describe('recall in a project whose config cannot be read (#796)', () => { + it('searches nothing from a legacy .teamai/ of another team behind a broken partition, and says why', async () => { + const root = gitRepo('project-a'); + const configPath = await brokenPartition(root); + legacyOtherTeam(root); + process.chdir(root); + + await recall(QUERY, {}); + + expect(stdout).toBe(''); + expect(recorded()).toEqual({ votes: false, quality: false }); + expect(process.exitCode).toBe(1); + expect(errors()).toHaveLength(1); + expect(errors()[0].startsWith(`Nothing was searched: ${configPath}: `)).toBe(true); + expect(errors()[0].endsWith('. Fix the file, or move it aside and run `teamai init` to write a new one.')).toBe(true); + // A parse error's code frame spans several lines; only the first is printed. + expect(errors()[0]).not.toContain('\n'); + expect(errors()[0]).not.toContain('teamai doctor'); + }); + + it('does not search or record the user scope in its place', async () => { + userScope(); + const root = gitRepo('project-a'); + await brokenPartition(root); + process.chdir(root); + + await recall(QUERY, {}); + + expect(stdout).toBe(''); + expect(recorded()).toEqual({ votes: false, quality: false }); + expect(process.exitCode).toBe(1); + }); + + it('--check refuses with the same message instead of a verdict', async () => { + userScope(); + const root = gitRepo('project-a'); + const configPath = await brokenPartition(root); + legacyOtherTeam(root); + process.chdir(root); + + await recall(QUERY, { check: true }); + + expect(stdout).toBe(''); + expect(process.exitCode).toBe(1); + expect(errors()).toHaveLength(1); + expect(errors()[0].startsWith(`Nothing was searched: ${configPath}: `)).toBe(true); + }); + + it('--check refuses even for an empty query, which would otherwise print NOT_RELEVANT', async () => { + const root = gitRepo('project-a'); + await brokenPartition(root); + process.chdir(root); + + await recall('', { check: true }); + + expect(stdout).not.toContain('RELEVANT'); + expect(process.exitCode).toBe(1); + expect(errors()).toEqual([expect.stringMatching(/^Nothing was searched: /)]); + }); + + it('a silent caller gets no output and exit 0: the reason goes to debug.log', async () => { + userScope(); + const root = gitRepo('project-a'); + await brokenPartition(root); + legacyOtherTeam(root); + process.chdir(root); + + await recall(QUERY, { silent: true }); + + expect(stdout).toBe(''); + expect(recorded()).toEqual({ votes: false, quality: false }); + expect(log.error).not.toHaveBeenCalled(); + expect(vi.mocked(log.persist).mock.calls.map(([msg]) => msg)).toEqual([ + expect.stringMatching(/^Nothing was searched: /), + ]); + expect(process.exitCode).toBe(originalExitCode); + }); + + it('searches and records the project scope of a readable project config, as before', async () => { + userScope(); + const root = gitRepo('project-a'); + await readableProject(root, false); + process.chdir(root); + + await recall(QUERY, {}); + + expect(stdout).toContain('team-a deployment timeout fix'); + expect(stdout).not.toContain('user-team'); + expect(recorded()).toEqual({ votes: true, quality: true }); + expect(process.exitCode).toBe(originalExitCode); + expect(log.error).not.toHaveBeenCalled(); + }); + + it('also searches the user scope when the project inherits it, as before', async () => { + userScope(); + const root = gitRepo('project-a'); + await readableProject(root, true); + process.chdir(root); + + await recall(QUERY, {}); + + expect(stdout).toContain('team-a deployment timeout fix'); + expect(stdout).toContain('user-team deployment timeout fix'); + expect(process.exitCode).toBe(originalExitCode); + }); + + it('searches the user scope where there is no project config, as before', async () => { + userScope(); + process.chdir(gitRepo('project-b')); + + await recall(QUERY, {}); + + expect(stdout).toContain('user-team deployment timeout fix'); + expect(recorded()).toEqual({ votes: true, quality: true }); + expect(process.exitCode).toBe(originalExitCode); + }); + + it('with no config at all, says no learnings are available and exits 0, as before', async () => { + process.chdir(gitRepo('project-b')); + + await recall(QUERY, {}); + + expect(stdout).toBe(''); + expect(vi.mocked(log.info).mock.calls.map(([msg]) => msg)).toEqual([ + expect.stringMatching(/^No learnings available\./), + ]); + expect(process.exitCode).toBe(originalExitCode); + expect(log.error).not.toHaveBeenCalled(); + }); +}); diff --git a/src/recall.ts b/src/recall.ts index 68058c2a5..0043bc665 100644 --- a/src/recall.ts +++ b/src/recall.ts @@ -1,6 +1,6 @@ import path from 'node:path'; import { existsSync } from 'node:fs'; -import { requireInit, detectProjectConfig, loadLocalConfigForScope } from './config.js'; +import { requireInit, detectProjectConfig, describeUnreadableConfig, loadLocalConfigForScope } from './config.js'; import { loadIndex, buildIndex, search, isLegacyIndex } from './utils/search-index.js'; import type { SearchResult } from './utils/search-index.js'; import { readFileSafe, ensureDir, pathExists } from './utils/fs.js'; @@ -400,6 +400,37 @@ export async function recall( process.stdout.write(`${line}\n`); }; + let projectConfig: LocalConfig | null = null; + const unreadable: string[] = []; + // A detection that throws still searches what loads next, but its votes must + // not reach that scope's team (#787). + let projectUnreadable = false; + try { + projectConfig = await detectProjectConfig(undefined, (configPath, error) => { unreadable.push(`${configPath}: ${error}`); }); + } catch (e) { + // A cwd that no longer exists holds no project: user scope, as in + // resolveConfigForDir. + const gone = typeof e === 'object' && e !== null && 'code' in e && e.code === 'ENOENT'; + if (!gone) projectUnreadable = true; + log.debug('recall: project scope detection failed'); + } + // Detection skips a project config it cannot read and answers with what + // loads next — a legacy `.teamai/` that may name another team, or the user + // scope — so recall would search and record for a team this project may not + // belong to (#796). An empty result or NOT_RELEVANT would tell the agent the + // team has no knowledge, so refuse instead: the rule `pull` follows (#784). + const [problem] = unreadable; + if (problem !== undefined) { + const message = `Nothing was searched: ${describeUnreadableConfig(problem)}`; + if (options.silent) { + log.persist(message); + return; + } + log.error(message); + process.exitCode = 1; + return; + } + if (!query || !query.trim()) { if (options.check) { emitCheckVerdict(0); @@ -420,21 +451,6 @@ export async function recall( // opt into searching the user index after the project index. const scopeIndexes: Array<{ index: SearchIndex; scope: 'user' | 'project'; config: LocalConfig; learningsBase: string }> = []; - let projectConfig: LocalConfig | null = null; - // A project config that cannot be read (or checked) makes detection fall back - // to another scope; searching there is #796's, but its votes must not reach - // that scope's team (#787). - let projectUnreadable = false; - try { - projectConfig = await detectProjectConfig(undefined, () => { projectUnreadable = true; }); - } catch (e) { - // A cwd that no longer exists holds no project: user scope, as in - // resolveConfigForDir. - const gone = typeof e === 'object' && e !== null && 'code' in e && e.code === 'ENOENT'; - if (!gone) projectUnreadable = true; - log.debug('recall: project scope detection failed'); - } - if (projectConfig) { // Project mode: project scope first. try { From 73002deb3ccff1b2e37d1b2a3752b4f5a235814f Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Thu, 24 Sep 2026 12:13:09 +0200 Subject: [PATCH 2/5] fix(recall): address pre-review findings (#796) - The relayed line ends with "move it aside and run `teamai init`", and the main conversation may not have loaded the teamai skill that asks for consent first. The recall agent now tells it to show the line to the user and not act on it without their consent. - Tools that run `teamai recall` directly (the Bash method of the recall rule, deployed to every tool) get the same instruction. - CHANGELOG: only the subagent a pull from this release deploys relays the line; a project that broke before the upgrade keeps the old one until a pull succeeds there. - The legacy-team test also asserts no votes land in that team's repo. --- CHANGELOG.md | 2 +- agents/teamai-recall.md | 4 +++- src/__tests__/recall-unreadable-config.test.ts | 1 + src/builtin-rules.ts | 5 +++++ 4 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f036b16a..8daae1ca3 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 - `teamai stats` no longer counts a session twice, and no longer counts another project's sessions. Its dashboard section added the whole machine's local `events.jsonl` metrics to the scope's already-reported totals from the team repo, so every session that a `pull` had reported — and that stays in the event log until compaction — was counted once by the team total and once again locally, and sessions whose `cwd` belonged to a different project were added to this scope's as well. It now filters the event log the way `teamai pull` reports it (only the sessions recorded in the current scope, see #785) and adds only what that scope has not reported yet, derived from the same per-scope `reported-*` snapshots the report path advances — so the local figure agrees with the team's instead of exceeding it. When the reported totals could not be read at all (no stats file, an unreadable one, a reports worktree that is not there), or when they exist but hold nothing yet, nothing is subtracted — a snapshot can name a session the team file never received, so a non-empty team total is what licenses trusting them, and a session the member can see happening is never hidden. The per-repo and by-hour breakdowns read that same filtered log, so they stay inside the scope; they answer a different question from the headline — what this machine's retained event log holds, per repo — and both the headings and the `--by-repo` / `--by-time` flag descriptions now name that source instead of presenting them as a split of the headline (for [#768](https://github.com/Tencent/teamai-cli/issues/768)). - The data-partition migration no longer retires a legacy `/.teamai/` while the partition's `config.yaml` cannot be read. `teamai init`, `pull` and `push` took a partition config that merely existed as a finished migration and renamed the legacy directory to `.teamai.bak`, although it held the only config that still loaded. A partition `config.yaml` that is empty or cannot be opened, does not parse, does not validate, or is not `scope: project` now leaves the legacy directory in place and names the file to fix; once it is fixed, the next of those commands retires it as before. A partition directory whose `config.yaml` was moved aside is no longer replaced by a fresh copy of the legacy directory, which deleted what the partition held; the migration waits and says to restore the file or move the directory aside (for [#797](https://github.com/Tencent/teamai-cli/issues/797)). -- `teamai recall` searches nothing in a project whose config exists but cannot be read, and says why. Detection skipped the broken file and searched whatever loaded next: the user scope, or a legacy `.teamai/` behind a broken partition that may belong to another team, whose knowledge was returned and whose documents got recalled counts. It now prints ``Nothing was searched: : . Fix the file, or move it aside and run `teamai init` to write a new one.`` and exits 1, and `recall --check` does the same instead of printing `NOT_RELEVANT`, which told the recall subagent the team had no knowledge. The subagent now relays that line to the member. (for [#796](https://github.com/Tencent/teamai-cli/issues/796)). +- `teamai recall` searches nothing in a project whose config exists but cannot be read, and says why. Detection skipped the broken file and searched whatever loaded next: the user scope, or a legacy `.teamai/` behind a broken partition that may belong to another team, whose knowledge was returned and whose documents got recalled counts. It now prints ``Nothing was searched: : . Fix the file, or move it aside and run `teamai init` to write a new one.`` and exits 1, and `recall --check` does the same instead of printing `NOT_RELEVANT`, which told the recall subagent the team had no knowledge. The `teamai-recall` subagent a pull from this release deploys relays that line to the member, telling the main conversation to move the file or re-run `teamai init` only with their consent. (for [#796](https://github.com/Tencent/teamai-cli/issues/796)). - `teamai pull` syncs nothing in a project whose config exists but cannot be read, and says why. Detection skipped the broken file and pulled whatever loaded next: the user scope, or a legacy `.teamai/` behind a broken partition that may belong to another team, whose skills, rules and docs were deployed and to which the project's usage was reported. It now prints ``Nothing was synced: : . Fix the file, or move it aside and run `teamai init` to write a new one.`` and exits 1. A session start there runs no pull, seeds no agent directory and stashes no package hint; `teamai pull --silent`, which hooks from before `hook-dispatch` still run, prints nothing, writes the reason to `~/.teamai/debug.log` and exits 1. This is the rule team hooks and usage follow since [#748](https://github.com/Tencent/teamai-cli/issues/748) (for [#784](https://github.com/Tencent/teamai-cli/issues/784)). - 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)). diff --git a/agents/teamai-recall.md b/agents/teamai-recall.md index c3414fbd0..aa132007a 100644 --- a/agents/teamai-recall.md +++ b/agents/teamai-recall.md @@ -47,7 +47,9 @@ teamai recall --check "<3-6 keywords from the task>" project's teamai config cannot be read, so no team knowledge was searched. Return that line from `Nothing was searched:` to its end, verbatim (it names the file and the fix), and **stop** — do not report "no relevant team - knowledge", and do not proceed to Step 1–5. + knowledge", and do not proceed to Step 1–5. Add that the main conversation + should show it to the user and must not move the file or run `teamai init` + without the user's consent: that replaces their settings for this project. - If the command fails in any other way or `teamai` is not on PATH: skip the precheck and continue to Step 1 (do not block on precheck failure). diff --git a/src/__tests__/recall-unreadable-config.test.ts b/src/__tests__/recall-unreadable-config.test.ts index 5a0381723..630f77971 100644 --- a/src/__tests__/recall-unreadable-config.test.ts +++ b/src/__tests__/recall-unreadable-config.test.ts @@ -126,6 +126,7 @@ describe('recall in a project whose config cannot be read (#796)', () => { expect(stdout).toBe(''); expect(recorded()).toEqual({ votes: false, quality: false }); + expect(fs.existsSync(path.join(root, '.teamai', 'team-repo', 'votes'))).toBe(false); expect(process.exitCode).toBe(1); expect(errors()).toHaveLength(1); expect(errors()[0].startsWith(`Nothing was searched: ${configPath}: `)).toBe(true); diff --git a/src/builtin-rules.ts b/src/builtin-rules.ts index 376b1c91c..07a69356a 100644 --- a/src/builtin-rules.ts +++ b/src/builtin-rules.ts @@ -155,6 +155,11 @@ teamai recall "<关键词1> <关键词2> ..." 从任务描述中提取 3-6 个高信号关键词进行检索。 +If the output contains \`Nothing was searched:\`, this project's teamai config cannot be +read and no team knowledge was searched: show that line to the user rather than +concluding the team has no knowledge, and do not move the file or run \`teamai init\` +without their consent. + **务必中英双语检索(跨语言召回)**:知识库中英文混杂,检索是纯词法匹配, 中文 query 无法命中纯英文文档,反之亦然。因此每个领域术语都应**同时给出中英两种写法** 一起检索,例如「重启推理集群」应扩展为 \`重启 推理 集群 restart inference cluster\`; From d915ced8a6f3443aa89b89e208e030d5edb9cb15 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Thu, 24 Sep 2026 12:24:36 +0200 Subject: [PATCH 3/5] fix(recall): address pre-review findings (#796) - CHANGELOG: the entry covers `teamai recall ` and `--check`. The recall subcommands (enable, disable, status, feedback, maintenance, promote) still resolve their scope as before; that is a follow-up. --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8daae1ca3..cb11e05f7 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 - `teamai stats` no longer counts a session twice, and no longer counts another project's sessions. Its dashboard section added the whole machine's local `events.jsonl` metrics to the scope's already-reported totals from the team repo, so every session that a `pull` had reported — and that stays in the event log until compaction — was counted once by the team total and once again locally, and sessions whose `cwd` belonged to a different project were added to this scope's as well. It now filters the event log the way `teamai pull` reports it (only the sessions recorded in the current scope, see #785) and adds only what that scope has not reported yet, derived from the same per-scope `reported-*` snapshots the report path advances — so the local figure agrees with the team's instead of exceeding it. When the reported totals could not be read at all (no stats file, an unreadable one, a reports worktree that is not there), or when they exist but hold nothing yet, nothing is subtracted — a snapshot can name a session the team file never received, so a non-empty team total is what licenses trusting them, and a session the member can see happening is never hidden. The per-repo and by-hour breakdowns read that same filtered log, so they stay inside the scope; they answer a different question from the headline — what this machine's retained event log holds, per repo — and both the headings and the `--by-repo` / `--by-time` flag descriptions now name that source instead of presenting them as a split of the headline (for [#768](https://github.com/Tencent/teamai-cli/issues/768)). - The data-partition migration no longer retires a legacy `/.teamai/` while the partition's `config.yaml` cannot be read. `teamai init`, `pull` and `push` took a partition config that merely existed as a finished migration and renamed the legacy directory to `.teamai.bak`, although it held the only config that still loaded. A partition `config.yaml` that is empty or cannot be opened, does not parse, does not validate, or is not `scope: project` now leaves the legacy directory in place and names the file to fix; once it is fixed, the next of those commands retires it as before. A partition directory whose `config.yaml` was moved aside is no longer replaced by a fresh copy of the legacy directory, which deleted what the partition held; the migration waits and says to restore the file or move the directory aside (for [#797](https://github.com/Tencent/teamai-cli/issues/797)). -- `teamai recall` searches nothing in a project whose config exists but cannot be read, and says why. Detection skipped the broken file and searched whatever loaded next: the user scope, or a legacy `.teamai/` behind a broken partition that may belong to another team, whose knowledge was returned and whose documents got recalled counts. It now prints ``Nothing was searched: : . Fix the file, or move it aside and run `teamai init` to write a new one.`` and exits 1, and `recall --check` does the same instead of printing `NOT_RELEVANT`, which told the recall subagent the team had no knowledge. The `teamai-recall` subagent a pull from this release deploys relays that line to the member, telling the main conversation to move the file or re-run `teamai init` only with their consent. (for [#796](https://github.com/Tencent/teamai-cli/issues/796)). +- `teamai recall ` searches nothing in a project whose config exists but cannot be read, and says why. Detection skipped the broken file and searched whatever loaded next: the user scope, or a legacy `.teamai/` behind a broken partition that may belong to another team, whose knowledge was returned and whose documents got recalled counts. It now prints ``Nothing was searched: : . Fix the file, or move it aside and run `teamai init` to write a new one.`` and exits 1, and `recall --check` does the same instead of printing `NOT_RELEVANT`, which told the recall subagent the team had no knowledge. The `teamai-recall` subagent a pull from this release deploys relays that line to the member, telling the main conversation to move the file or re-run `teamai init` only with their consent. (for [#796](https://github.com/Tencent/teamai-cli/issues/796)). - `teamai pull` syncs nothing in a project whose config exists but cannot be read, and says why. Detection skipped the broken file and pulled whatever loaded next: the user scope, or a legacy `.teamai/` behind a broken partition that may belong to another team, whose skills, rules and docs were deployed and to which the project's usage was reported. It now prints ``Nothing was synced: : . Fix the file, or move it aside and run `teamai init` to write a new one.`` and exits 1. A session start there runs no pull, seeds no agent directory and stashes no package hint; `teamai pull --silent`, which hooks from before `hook-dispatch` still run, prints nothing, writes the reason to `~/.teamai/debug.log` and exits 1. This is the rule team hooks and usage follow since [#748](https://github.com/Tencent/teamai-cli/issues/748) (for [#784](https://github.com/Tencent/teamai-cli/issues/784)). - 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)). From f4e1bb6c3726f676177bd77af0ecba83ab2b297f Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Thu, 24 Sep 2026 13:08:24 +0200 Subject: [PATCH 4/5] fix(recall): address CI review (#796) Reject a missing query before resolving the project, so a bare `teamai recall` runs no detection (and no self-mode bootstrap). An empty `--check` still resolves first: it must refuse rather than print NOT_RELEVANT in a project whose config cannot be read. Drop the silent branch: `recall` has no --silent flag and no caller passes `silent`, so it was a contract nothing could invoke. --- .../recall-unreadable-config.test.ts | 14 +++-------- src/recall.ts | 23 ++++++++----------- 2 files changed, 13 insertions(+), 24 deletions(-) diff --git a/src/__tests__/recall-unreadable-config.test.ts b/src/__tests__/recall-unreadable-config.test.ts index 630f77971..b761161e6 100644 --- a/src/__tests__/recall-unreadable-config.test.ts +++ b/src/__tests__/recall-unreadable-config.test.ts @@ -176,22 +176,14 @@ describe('recall in a project whose config cannot be read (#796)', () => { expect(errors()).toEqual([expect.stringMatching(/^Nothing was searched: /)]); }); - it('a silent caller gets no output and exit 0: the reason goes to debug.log', async () => { - userScope(); + it('a missing query is rejected before the project is resolved, as before', async () => { const root = gitRepo('project-a'); await brokenPartition(root); - legacyOtherTeam(root); process.chdir(root); - await recall(QUERY, { silent: true }); + await recall('', {}); - expect(stdout).toBe(''); - expect(recorded()).toEqual({ votes: false, quality: false }); - expect(log.error).not.toHaveBeenCalled(); - expect(vi.mocked(log.persist).mock.calls.map(([msg]) => msg)).toEqual([ - expect.stringMatching(/^Nothing was searched: /), - ]); - expect(process.exitCode).toBe(originalExitCode); + expect(vi.mocked(log.error).mock.calls.map(([msg]) => msg)).toEqual(['Usage: teamai recall ']); }); it('searches and records the project scope of a readable project config, as before', async () => { diff --git a/src/recall.ts b/src/recall.ts index 0043bc665..b6222b4cf 100644 --- a/src/recall.ts +++ b/src/recall.ts @@ -400,6 +400,13 @@ export async function recall( process.stdout.write(`${line}\n`); }; + const noQuery = !query || !query.trim(); + if (noQuery && !options.check) { + log.error('Usage: teamai recall '); + log.info('Example: teamai recall "api timeout"'); + return; + } + let projectConfig: LocalConfig | null = null; const unreadable: string[] = []; // A detection that throws still searches what loads next, but its votes must @@ -421,23 +428,13 @@ export async function recall( // team has no knowledge, so refuse instead: the rule `pull` follows (#784). const [problem] = unreadable; if (problem !== undefined) { - const message = `Nothing was searched: ${describeUnreadableConfig(problem)}`; - if (options.silent) { - log.persist(message); - return; - } - log.error(message); + log.error(`Nothing was searched: ${describeUnreadableConfig(problem)}`); process.exitCode = 1; return; } - if (!query || !query.trim()) { - if (options.check) { - emitCheckVerdict(0); - return; - } - log.error('Usage: teamai recall '); - log.info('Example: teamai recall "api timeout"'); + if (noQuery) { + emitCheckVerdict(0); return; } From 22824a1fe7222b6148551ab832200fab5295592b Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Thu, 24 Sep 2026 14:14:17 +0200 Subject: [PATCH 5/5] test(recall): follow #787's per-scope votes (#796) #787 moved recalled counts from the shared ~/.teamai/votes/ into each scope's votes directory, so the #796 tests look for any votes directory in the sandbox. #787's broken-project recall test expected a search to run; #796 searches nothing there, which it now asserts, while its checks that no scope received a vote stay. --- src/__tests__/recall-unreadable-config.test.ts | 5 +++-- src/__tests__/votes-scope.test.ts | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/__tests__/recall-unreadable-config.test.ts b/src/__tests__/recall-unreadable-config.test.ts index b761161e6..79d4734e2 100644 --- a/src/__tests__/recall-unreadable-config.test.ts +++ b/src/__tests__/recall-unreadable-config.test.ts @@ -103,10 +103,11 @@ async function readableProject(root: string, inheritUserScope: boolean): Promise }); } -/** Everything recall records about a search: recalled counts and the quality signal. */ +/** Everything recall records about a search: recalled counts (in any scope's + * votes directory, #787) and the quality signal. */ function recorded(): { votes: boolean; quality: boolean } { return { - votes: fs.existsSync(path.join(tmp, 'home', '.teamai', 'votes')), + votes: fs.readdirSync(tmp, { recursive: true, encoding: 'utf8' }).some((entry) => ['votes', 'user-votes'].includes(path.basename(entry))), quality: readRecallQuality(SESSION) !== null, }; } diff --git a/src/__tests__/votes-scope.test.ts b/src/__tests__/votes-scope.test.ts index ce7805b52..da31d1c4a 100644 --- a/src/__tests__/votes-scope.test.ts +++ b/src/__tests__/votes-scope.test.ts @@ -392,7 +392,8 @@ describe('votes stay with the scope they were cast in (#787)', () => { stdout.mockRestore(); } - expect(out).toContain('API timeout fix'); + // Nothing is searched there (#796). + expect(out).toBe(''); expect(fs.existsSync(path.join(teamaiHome(), 'user-votes'))).toBe(false); expect(fs.existsSync(path.join(teamaiHome(), 'votes'))).toBe(false); expect(fs.existsSync(path.join(dataHome, 'votes'))).toBe(false);