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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 71 additions & 1 deletion src/__tests__/e2e/index-local-votes-1233.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,9 +123,11 @@ describe('local vote index freshness (#1233)', () => {
if (reporting === 'unconfirmed') {
// The real push lands, but a live lock prevents local confirmation.
// The hook only controls the crash/lock boundary; it never edits votes.
// Extensionless Git hooks also run under an ESM package boundary.
fs.writeFileSync(path.join(origin, 'hooks', 'package.json'), '{"type":"module"}');
const hook = path.join(origin, 'hooks', 'post-receive');
fs.writeFileSync(hook, `#!${process.execPath}
require('node:fs').writeFileSync(${JSON.stringify(votePath + '.lock')}, ${JSON.stringify(JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString(), owner: 'acceptance' }))});
import('node:fs').then(fs => fs.writeFileSync(${JSON.stringify(votePath + '.lock')}, ${JSON.stringify(JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString(), owner: 'acceptance' }))}));
`);
fs.chmodSync(hook, 0o755);
const delivered = await runCli(home, ['pull'], sandbox);
Expand Down Expand Up @@ -178,3 +180,71 @@ require('node:fs').writeFileSync(${JSON.stringify(votePath + '.lock')}, ${JSON.s
else expect(localVotes(home).deltas).toEqual({ retry: { recalled_delta: 1, upvoted_delta: 1 } });
}, 120_000);
});

describe('confirmed votes after narrow-clone pushes (#1233)', () => {
it.each(['full', 'single', 'shallow'] as const)('keeps delivered votes through contribute and recall rebuilds: %s', async (mode) => {
sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-1233-'));
const origin = path.join(sandbox, 'origin.git');
const seed = path.join(sandbox, 'seed');
const home = path.join(sandbox, 'home');
const env = machineEnv(home);
fs.mkdirSync(seed, { recursive: true });
fs.writeFileSync(path.join(seed, 'teamai.yaml'), YAML.stringify({
team: 'acme', repo: origin, provider: 'git', usageReport: true,
sharing: { recall: { enabled: true } },
}));
const learnings = path.join(seed, 'learnings');
fs.mkdirSync(learnings, { recursive: true });
fs.writeFileSync(path.join(learnings, 'retry.md'), '---\ntitle: Retry failed requests\ndate: 2026-10-21\n---\nRetry failed requests with backoff.\n');
git(['init', '-q', '-b', 'main'], seed, env);
git(['add', '.'], seed, env); git(['commit', '-q', '-m', 'fixture'], seed, env);
git(['clone', '-q', '--bare', seed, origin], sandbox, env);
fs.mkdirSync(path.join(home, '.claude'), { recursive: true });
fs.writeFileSync(path.join(home, '.gitconfig'), '[user]\nname = alice\nemail = alice@example.invalid\n');
const clone = path.join(home, '.teamai', 'team-repo');
git(['clone', '-q', ...(mode === 'single' ? ['--single-branch'] : mode === 'shallow' ? ['--depth=1'] : []), 'file://' + origin, clone], sandbox, env);
fs.writeFileSync(path.join(home, '.teamai', 'config.yaml'), YAML.stringify({
repo: { localPath: clone, remote: origin, kind: 'git' }, username: 'alice', scope: 'user',
updatePolicy: 'skip', enabledAgents: ['claude'], additionalRoles: [],
}));
// A prior recall credited by Stop; manual feedback is then sent via the real CLI.
const votesDir = path.join(home, '.teamai', 'user-votes');
fs.mkdirSync(votesDir, { recursive: true });
fs.writeFileSync(path.join(votesDir, 'alice.yaml'), YAML.stringify({
version: 2,
votes: { retry: { recalled_count: 1, upvoted_count: 0, last_recalled_at: new Date().toISOString() } },
deltas: { retry: { recalled_delta: 1, upvoted_delta: 0 } },
}));
const initial = await runCli(home, ['pull'], sandbox);
expect(initial.code, initial.output).toBe(0);
const initialIndex = JSON.parse(fs.readFileSync(path.join(home, '.teamai', 'search-index.json'), 'utf8'));
expect(initialIndex.entries.find((e: { filename: string }) => e.filename === 'retry.md')).toMatchObject({ votes: 0.3 });
const feedback = await runCli(home, ['recall', 'feedback', '--positive', 'retry'], sandbox);
expect(feedback.code, feedback.output).toBe(0);
const delivered = await runCli(home, ['pull'], sandbox);
expect(delivered.code, delivered.output).toBe(0);
const note = path.join(sandbox, 'note.md');
fs.writeFileSync(note, '# Backoff\nRetry with backoff.\n');
const contributed = await runCli(home, ['contribute', '--file', note, '--title', 'Backoff', '--scope', 'user'], sandbox);
expect(contributed.code, contributed.output).toBe(0);
const indexPath = path.join(home, '.teamai', 'search-index.json');
const votes = () => JSON.parse(fs.readFileSync(indexPath, 'utf8')).entries.find((e: { filename: string }) => e.filename === 'retry.md').votes;
const afterContribute = votes();
const remote = YAML.parse(git(['show', 'teamai-reports:votes/alice.yaml'], origin, env));
const tracking = git(['rev-parse', 'refs/remotes/origin/teamai-reports'], clone, env);
const confirmed = git(['rev-parse', 'teamai-reports'], origin, env);
fs.unlinkSync(indexPath);
const recall = await runCli(home, ['recall', '--check', 'retry failed requests'], sandbox);
expect(recall.code, recall.output).toBe(0);
const afterRecall = votes();
const deltas = localVotes(home).deltas;
const pull = await runCli(home, ['pull'], sandbox);
expect(pull.code, pull.output).toBe(0);
expect(tracking).toBe(confirmed);
expect(remote.votes.retry.upvoted_count).toBe(1);
expect((deltas as Record<string, unknown>).retry).toBeUndefined();
expect(afterContribute).toBe(1.3);
expect(afterRecall).toBe(1.3);
expect(votes()).toBe(1.3);
}, 120_000);
});
59 changes: 58 additions & 1 deletion src/__tests__/search-index-local-votes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { indexInMemory } from '../utils/search-index.js';
import * as searchIndex from '../utils/search-index.js';
import { buildVizData } from '../viz.js';
import { acquireLock, releaseLock } from '../update.js';
import * as gitUtils from '../utils/git.js';
import { indexableVotesDir } from '../utils/reports-branch.js';

vi.mock('../utils/reports-branch.js', () => ({
Expand Down Expand Up @@ -174,8 +175,64 @@ describe('index local votes (#1233)', () => {
it('clamps a negative pending delta at zero and clears its timestamp in memory', async () => {
await confirmed(snapshot(0, 1)); await pending(-2);
const files = await readIndexVoteFiles(config);
const data = YAML.parse(files[0]);
const data = YAML.parse(files[0].content);
expect(data.votes[docId].upvoted_count).toBe(0);
expect(data.votes[docId].last_upvoted_at).toBeUndefined();
});
it('keeps unrelated same-member v1 scores while applying local feedback', async () => {
await confirmed({ votes: { retry: { at: date }, other: { at: date } } });
const before = await fs.readFile(path.join(config.repo.localPath, 'votes', 'alice.yaml'), 'utf8');
await fs.mkdir(path.dirname(localPath), { recursive: true });
await fs.writeFile(localPath, YAML.stringify({ version: 2, votes: { other: entry(0, 1) }, deltas: { other: { recalled_delta: 0, upvoted_delta: 1 } } }));
await fs.writeFile(path.join(dir, 'learnings', 'other.md'), '# Other\nOther knowledge.\n');
const index = await indexInMemory({ learningsDir: path.join(dir, 'learnings'), voteScope: config });
expect(index.entries.find(e => e.filename === 'retry.md')?.votes).toBe(1);
expect(index.entries.find(e => e.filename === 'other.md')?.votes).toBe(1.3);
expect(await fs.readFile(path.join(config.repo.localPath, 'votes', 'alice.yaml'), 'utf8')).toBe(before);
});
it.each([false, true])('names a corrupt votes file, including with local deltas: %s', async (withLocal) => {
const { log } = await import('../utils/logger.js');
const error = vi.spyOn(log, 'error').mockImplementation(() => {});
try {
await confirmed(snapshot());
const file = path.join(config.repo.localPath, 'votes', 'alice.yaml');
await fs.writeFile(file, 'votes: [');
git('add', '.'); git('commit', '-qm', 'corrupt'); git('update-ref', 'refs/remotes/origin/teamai-reports', 'HEAD');
if (withLocal) await pending();
expect((await indexed()).votes).toBe(withLocal ? 1.3 : 0);
expect(error).toHaveBeenCalledWith(`Failed to parse index votes: ${file}`);
} finally { error.mockRestore(); }
});

it('releases the votes lock before reading member blobs and keeps a coherent snapshot', async () => {
await confirmed(snapshot()); await pending();
const createGit = gitUtils.createGit;
let checked = false;
const spy = vi.spyOn(gitUtils, 'createGit').mockImplementation((...args) => {
const client = createGit(...args);
return new Proxy(client, {
get(target, key, receiver) {
if (key !== 'raw') return Reflect.get(target, key, receiver);
return async (...rawArgs: unknown[]) => {
if (!checked) {
const held = await acquireLock(`${localPath}.lock`);
expect(held).toBe(true);
if (held) await releaseLock(`${localPath}.lock`);
// A writer can proceed during the slow read; this index must still
// use the local snapshot captured with its pinned remote ref.
await incrementUpvoted(localPath, [docId]);
checked = true;
}
return Reflect.apply(target.raw, target, rawArgs);
};
},
});
});
try {
expect((await indexed()).votes).toBe(2.6);
expect(checked).toBe(true);
} finally { spy.mockRestore(); }
expect((await indexed()).votes).toBe(3.6);
});

});
4 changes: 0 additions & 4 deletions src/contribute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,6 @@ export async function rebuildIndexAfterContribute(localConfig: LocalConfig): Pro
const repoPath = localConfig.repo.localPath;
const docsRepoDir = path.join(repoPath, 'docs');
const rulesRepoDir = path.join(repoPath, 'rules');
// Not another repository's reports checkout (#808).
const { indexableVotesDir } = await import('./utils/reports-branch.js');
const votesDir = await indexableVotesDir(localConfig);

// A workspace's children's too (#913).
const activeLearningsNamespaces = await deliveredLearningsNamespaces(localConfig);
Expand All @@ -81,7 +78,6 @@ export async function rebuildIndexAfterContribute(localConfig: LocalConfig): Pro
rulesDir: (await pathExists(rulesRepoDir)) ? rulesRepoDir : undefined,
// The docs and skills pull delivers here, not the whole trees (#707).
...await deliveredIndexSources(localConfig),
votesDir: votesDir && (await pathExists(votesDir)) ? votesDir : undefined,
voteScope: localConfig,
indexPath,
children: localConfig.children?.fingerprint,
Expand Down
5 changes: 1 addition & 4 deletions src/pull.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1510,8 +1510,7 @@ async function pullForScope(
const docsRepoDir = path.join(localConfig.repo.localPath, 'docs');
const rulesRepoDir = path.join(localConfig.repo.localPath, 'rules');
const skillsRepoDir = path.join(localConfig.repo.localPath, 'skills');
const reportsRoot = await reportsRootReady;
const votesDir = reportsRoot ? path.join(reportsRoot, 'votes') : undefined;
await reportsRootReady;

// user scope: sync learnings to ~/.teamai/learnings/ (legacy behavior)
// project scope: use learnings directly from repo
Expand Down Expand Up @@ -1590,7 +1589,6 @@ async function pullForScope(
// An index already on disk follows the last source away (#1006): left
// alone, it would keep serving what the member no longer receives.
if (hasAnySource || effectiveCodebaseDir || await pathExists(indexPath)) {
const votesExist = votesDir ? await pathExists(votesDir) : false;
const { buildIndex, dropOtherCheckoutIndexes } = await import('./utils/search-index.js');
// Other checkouts' indexes: this checkout's rebuild goes ahead without them.
await dropOtherCheckoutIndexes(localConfig)
Expand Down Expand Up @@ -1630,7 +1628,6 @@ async function pullForScope(
unreadable: unresolved,
children: localConfig.children?.fingerprint,
codebaseDir: undefined, // codebase now served by teamwiki/ graph engine
votesDir: votesExist ? votesDir : undefined,
voteScope: localConfig,
indexPath,
});
Expand Down
7 changes: 0 additions & 7 deletions src/recall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -422,12 +422,6 @@ async function loadOrBuildScopeIndex(
const needsRebuild = !index || isLegacyIndex(index) || index.children !== localConfig.children?.fingerprint;
// A legacy index is rebuilt even when no source can be seen, or it would serve what it holds whole (#1006).
if (needsRebuild && (index || effectiveLearningsDir || await pathExists(path.join(localConfig.repo.localPath, 'docs')) || await pathExists(path.join(localConfig.repo.localPath, 'rules')) || await pathExists(path.join(localConfig.repo.localPath, 'skills')))) {
// Legacy votesDir callers use the reports checkout; voteScope also supplies
// local feedback when no reports votes directory exists yet.
// Not another repository's reports checkout (#808).
const { indexableVotesDir } = await import('./utils/reports-branch.js');
const votesDir = await indexableVotesDir(localConfig);
const votesExist = votesDir !== undefined && await pathExists(votesDir);
const docsDir = path.join(localConfig.repo.localPath, 'docs');
const rulesDir = path.join(localConfig.repo.localPath, 'rules');
const repoCodebaseDir = path.join(localConfig.repo.localPath, 'docs', 'team-codebase');
Expand Down Expand Up @@ -491,7 +485,6 @@ async function loadOrBuildScopeIndex(
// The docs and skills pull delivers here, not the whole trees (#707).
...delivered,
codebaseDir: undefined, // codebase now served by teamwiki/ graph engine
votesDir: votesExist ? votesDir : undefined,
voteScope: localConfig,
indexPath,
unreadable,
Expand Down
9 changes: 9 additions & 0 deletions src/utils/branch-worktree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -643,7 +643,16 @@ async function commitAndPushAt(
// when it refuses one. Do NOT re-check the remote-tracking ref here — it
// is only updated through the remote's fetch refspec, so a `--single-branch`
// clone would report failure for every successful side-branch push.
const trackingRef = `refs/remotes/origin/${spec.branch}`;
const before = await git.revparse(['--verify', trackingRef]).then(r => r.trim()).catch(() => '');
const sent = (await git.revparse([`refs/heads/${spec.branch}`])).trim();
await git.push(['--progress', 'origin', spec.branch]);
// A narrow fetch refspec does not update this ref on push. Record only
// the commit actually accepted by origin, never an unpushed worktree HEAD.
// Compare-and-swap preserves a newer concurrent fetch (and a normal push
// may already have updated the ref). Failure cannot undo delivery.
await git.raw(['update-ref', trackingRef, sent, before || '0'.repeat(sent.length)])
.catch(() => { /* Another updater owns the newer tracking snapshot. */ });
return { status: 'published' };
} catch (pushErr) {
// Killed by the block timeout: origin stopped answering, which no
Expand Down
6 changes: 3 additions & 3 deletions src/utils/search-index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -355,9 +355,9 @@ async function aggregateVotes(votesDir?: string, voteScope?: LocalConfig): Promi
? await (await import('../votes.js')).readIndexVoteFiles(voteScope)
: await Promise.all((votesDir ? await listFiles(votesDir) : [])
.filter((file) => /\.ya?ml$/.test(file))
.map((file) => readFileSafe(path.join(votesDir!, file))));
.map(async (file) => ({ path: path.join(votesDir!, file), content: await readFileSafe(path.join(votesDir!, file)) })));

for (const content of contents) {
for (const { path: votePath, content } of contents) {
if (!content) continue;

try {
Expand Down Expand Up @@ -392,7 +392,7 @@ async function aggregateVotes(votesDir?: string, voteScope?: LocalConfig): Promi
}
}
} catch {
log.error('Failed to parse index votes');
log.error(`Failed to parse index votes: ${votePath}`);
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/viz.ts
Original file line number Diff line number Diff line change
Expand Up @@ -341,7 +341,7 @@ async function loadEntries(paths: VizPaths): Promise<SearchIndexEntry[]> {
rulesDir: path.join(paths.knowledgeRoot, 'rules'),
skillsDir: path.join(paths.knowledgeRoot, 'skills'),
...delivered,
votesDir: paths.votesDir,
votesDir: paths.scope ? undefined : paths.votesDir,
voteScope: paths.scope,
indexPath: tmpIndexPath,
});
Expand Down
Loading
Loading