import { describe, it, expect, beforeEach } from 'vitest'; import { mkdtemp } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { writeCache, type CacheFile } from '../../src/lib/cache.js'; import { makeTasksTools } from '../../src/tools/tasks.js'; import type { Backend, CommitFileArgs, CommitResult, FileWithSha } from '../../src/lib/backend.js'; interface RecordedCommit extends CommitFileArgs {} function recordingBackend(initial: Record = {}): { backend: Backend; commits: RecordedCommit[]; files: Record; } { const files = { ...initial }; const commits: RecordedCommit[] = []; const backend: Backend = { async listUserRepos() { return []; }, async getRawFile(_u, repo, path) { return files[`${repo}:${path}`]?.content ?? null; }, async getFileWithSha(_u, repo, path) { return files[`${repo}:${path}`] ?? null; }, async commitFile(args: CommitFileArgs): Promise { commits.push({ ...args }); const fileSha = `sha-${commits.length}`; files[`${args.repo}:${args.path}`] = { content: args.content, sha: fileSha }; return { fileSha, commitSha: `commit-${commits.length}` }; }, }; return { backend, commits, files }; } const fixture: CacheFile = { schemaVersion: 3, synced_at: '2026-04-29T12:00:00.000Z', synced_from: 'https://g', machine: 'm', projects: [ { name: 'alpha', default_branch: 'main', fetched_at: '2026-04-29T12:00:01.000Z', active_tasks: [ { slug: 'auth-rewrite', status: 'active', next: 'add tests' }, { slug: 'docs', status: 'paused', next: 'screencast' }, ], all_tasks_count: 2, raw: '# Task Board\n## 🔴 [auth-rewrite] — Auth\n', }, { name: 'beta', default_branch: 'main', fetched_at: '2026-04-29T12:00:02.000Z', active_tasks: [{ slug: 'login', status: 'active', next: 'fix bug' }], all_tasks_count: 1, raw: '# Task Board\n## 🔴 [login] — Login\n', }, ], errors: [], }; let cacheFile: string; beforeEach(async () => { const dir = await mkdtemp(join(tmpdir(), 'tasks-tools-')); cacheFile = join(dir, 'tasks.json'); await writeCache(cacheFile, fixture); }); function call(tools: ReturnType, name: string, args: Record) { const t = tools.find((x) => x.name === name); if (!t) throw new Error(`tool ${name} not registered`); return t.handler(args); } describe('tasks.aggregate', () => { it('lists everything by default', async () => { const tools = makeTasksTools({ cacheFile }); const r = await call(tools, 'tasks.aggregate', {}); const parsed = JSON.parse(r.content[0].text); expect(parsed.tasks).toHaveLength(3); expect(parsed.tasks[0]).toEqual({ project: 'alpha', slug: 'auth-rewrite', status: 'active', next: 'add tests', }); }); it('filters by status', async () => { const tools = makeTasksTools({ cacheFile }); const r = await call(tools, 'tasks.aggregate', { status: 'active' }); const parsed = JSON.parse(r.content[0].text); expect(parsed.tasks.map((t: { slug: string }) => t.slug)).toEqual(['auth-rewrite', 'login']); }); it('filters by project', async () => { const tools = makeTasksTools({ cacheFile }); const r = await call(tools, 'tasks.aggregate', { project: 'beta' }); const parsed = JSON.parse(r.content[0].text); expect(parsed.tasks.map((t: { slug: string }) => t.slug)).toEqual(['login']); }); // Regression [aggregate-blocked-no-slug-invisible]: a blocked task is indexed // in the cache regardless of whether its blocker names another task's slug or // is free text. aggregate(status=blocked) must surface a text-blocked task. it('surfaces a blocked task with a non-slug (text) blocker', async () => { await writeCache(cacheFile, { ...fixture, projects: [ { name: 'gamma', default_branch: 'main', fetched_at: '2026-04-29T12:00:01.000Z', active_tasks: [ { slug: 'held-text', status: 'blocked', next: 'resume after design' }, { slug: 'held-slug', status: 'blocked', next: 'wait on dep' }, ], all_tasks_count: 2, raw: '# Task Board\n## 🔵 [held-text] — Held\n', }, ], }); const tools = makeTasksTools({ cacheFile }); const r = await call(tools, 'tasks.aggregate', { status: 'blocked' }); const parsed = JSON.parse(r.content[0].text); expect(parsed.tasks.map((t: { slug: string }) => t.slug)).toEqual(['held-text', 'held-slug']); }); it('returns empty when cache missing', async () => { const tools = makeTasksTools({ cacheFile: '/nonexistent/tasks.json' }); const r = await call(tools, 'tasks.aggregate', {}); const parsed = JSON.parse(r.content[0].text); expect(parsed.tasks).toEqual([]); expect(parsed.cache_missing).toBe(true); }); it('filters by qualified / project name', async () => { await writeCache(cacheFile, { ...fixture, projects: [ { name: 'victor/books', default_branch: 'main', fetched_at: '2026-04-29T12:00:01.000Z', active_tasks: [{ slug: 'paginate', status: 'active', next: 'wire infinite scroll' }], all_tasks_count: 1, raw: '# Task Board\n## 🔴 [paginate]\n', }, { name: 'OpeItcLoc03/common', default_branch: 'master', fetched_at: '2026-04-29T12:00:01.000Z', active_tasks: [{ slug: 'multi-owner', status: 'active', next: 'ship' }], all_tasks_count: 1, raw: '# Task Board\n## 🔴 [multi-owner]\n', }, ], }); const tools = makeTasksTools({ cacheFile }); const r = await call(tools, 'tasks.aggregate', { project: 'victor/books' }); const parsed = JSON.parse(r.content[0].text); expect(parsed.tasks).toHaveLength(1); expect(parsed.tasks[0].project).toBe('victor/books'); expect(parsed.tasks[0].slug).toBe('paginate'); }); }); describe('tasks.search', () => { it('matches case-insensitive substring on slug/next', async () => { const tools = makeTasksTools({ cacheFile }); const r = await call(tools, 'tasks.search', { query: 'AUTH' }); const parsed = JSON.parse(r.content[0].text); expect(parsed.tasks.map((t: { slug: string }) => t.slug)).toEqual(['auth-rewrite']); }); it('matches on next-action text', async () => { const tools = makeTasksTools({ cacheFile }); const r = await call(tools, 'tasks.search', { query: 'screencast' }); const parsed = JSON.parse(r.content[0].text); expect(parsed.tasks.map((t: { slug: string }) => t.slug)).toEqual(['docs']); }); }); describe('tasks.get', () => { it('returns full raw STATUS.md', async () => { const tools = makeTasksTools({ cacheFile }); const r = await call(tools, 'tasks.get', { project: 'alpha' }); expect(r.content[0].text).toContain('[auth-rewrite]'); }); it('returns error for unknown project', async () => { const tools = makeTasksTools({ cacheFile }); const r = await call(tools, 'tasks.get', { project: 'ghost' }); expect(r.isError).toBe(true); }); }); describe('aggregateSkipOwners filter (read tools)', () => { // Cache contains both a regular owner's project and an infra-skip-owner's // projects; aggregation views must hide skip-owner projects (mutate path // is unaffected — see `tasks.create` test below). const skipFixture: CacheFile = { schemaVersion: 3, synced_at: '2026-04-29T12:00:00.000Z', synced_from: 'https://g', machine: 'm', projects: [ { name: 'victor/books', default_branch: 'main', fetched_at: '2026-04-29T12:00:01.000Z', active_tasks: [{ slug: 'paginate', status: 'active', next: 'wire infinite scroll' }], all_tasks_count: 1, raw: '# Task Board\n## 🔴 [paginate] — Pagination\n', }, { name: 'OpeItcLoc03/common', default_branch: 'master', fetched_at: '2026-04-29T12:00:02.000Z', active_tasks: [{ slug: 'infra-task', status: 'active', next: 'fix sync' }], all_tasks_count: 1, raw: '# Task Board\n## 🔴 [infra-task] — Infra\n', }, { name: 'OpeItcLoc03/claude-skills', default_branch: 'master', fetched_at: '2026-04-29T12:00:03.000Z', active_tasks: [{ slug: 'skill-update', status: 'active', next: 'bump version' }], all_tasks_count: 1, raw: '# Task Board\n## 🔴 [skill-update] — Skill\n', }, { name: 'agenda', default_branch: 'main', fetched_at: '2026-04-29T12:00:04.000Z', active_tasks: [{ slug: 'agenda-task', status: 'active', next: 'review' }], all_tasks_count: 1, raw: '# Task Board\n## 🔴 [agenda-task]\n', }, ], errors: [], }; beforeEach(async () => { await writeCache(cacheFile, skipFixture); }); it('tasks.aggregate hides projects whose owner is in aggregateSkipOwners', async () => { const tools = makeTasksTools({ cacheFile, aggregateSkipOwners: ['OpeItcLoc03'] }); const r = await call(tools, 'tasks.aggregate', {}); const parsed = JSON.parse(r.content[0].text); const projectNames = parsed.tasks.map((t: { project: string }) => t.project).sort(); // victor/books and agenda visible; OpeItcLoc03/* hidden. expect(projectNames).toEqual(['agenda', 'victor/books']); }); it('tasks.aggregate keeps `agenda` pseudo-project even if "agenda" appears in aggregateSkipOwners', async () => { // Defensive: agenda is a pseudo-project (`name === 'agenda'`, no owner // segment), parseQualified yields no owner — filter must not match it. const tools = makeTasksTools({ cacheFile, aggregateSkipOwners: ['agenda', 'OpeItcLoc03'] }); const r = await call(tools, 'tasks.aggregate', {}); const parsed = JSON.parse(r.content[0].text); const projectNames = parsed.tasks.map((t: { project: string }) => t.project).sort(); expect(projectNames).toEqual(['agenda', 'victor/books']); }); it('tasks.aggregate is a no-op filter when aggregateSkipOwners is empty (backwards-compat)', async () => { const tools = makeTasksTools({ cacheFile }); const r = await call(tools, 'tasks.aggregate', {}); const parsed = JSON.parse(r.content[0].text); expect(parsed.tasks).toHaveLength(4); }); it('tasks.search hides matches in skip-owner projects', async () => { const tools = makeTasksTools({ cacheFile, aggregateSkipOwners: ['OpeItcLoc03'] }); const r = await call(tools, 'tasks.search', { query: 'sync' }); const parsed = JSON.parse(r.content[0].text); // 'fix sync' is in OpeItcLoc03/common — must be hidden. expect(parsed.tasks).toEqual([]); }); it('tasks.get refuses to return raw for skip-owner project (read-aggregation parity)', async () => { const tools = makeTasksTools({ cacheFile, aggregateSkipOwners: ['OpeItcLoc03'] }); const r = await call(tools, 'tasks.get', { project: 'OpeItcLoc03/common' }); expect(r.isError).toBe(true); }); it('tasks.aggregate.project filter: explicitly requesting a skip-owner project returns no rows (semantics-consistent with aggregation)', async () => { const tools = makeTasksTools({ cacheFile, aggregateSkipOwners: ['OpeItcLoc03'] }); const r = await call(tools, 'tasks.aggregate', { project: 'OpeItcLoc03/common' }); const parsed = JSON.parse(r.content[0].text); expect(parsed.tasks).toEqual([]); }); }); const fixedNow = () => new Date('2026-04-29T12:00:00Z'); // Mutate-tool tests use qualified / cache keys per multi-owner spec. const mutateFixture: CacheFile = { schemaVersion: 3, synced_at: '2026-04-29T12:00:00.000Z', synced_from: 'https://g', machine: 'm', projects: [ { name: 'OpeItcLoc03/alpha', default_branch: 'main', fetched_at: '2026-04-29T12:00:01.000Z', active_tasks: [], all_tasks_count: 0, raw: '', }, { name: 'OpeItcLoc03/beta', default_branch: 'main', fetched_at: '2026-04-29T12:00:02.000Z', active_tasks: [], all_tasks_count: 0, raw: '', }, { name: 'victor/books', default_branch: 'main', fetched_at: '2026-04-29T12:00:03.000Z', active_tasks: [], all_tasks_count: 0, raw: '', }, ], errors: [], }; function makeWriteTools(backend: Backend, aggregateSkipOwners?: string[]) { return makeTasksTools({ cacheFile, backend, giteaUser: 'u', agendaTasksRepo: 'projects-tasks', machine: 'host-x', sourceProject: 'modules-db', now: fixedNow, aggregateSkipOwners, }); } async function seedMutateCache() { await writeCache(cacheFile, mutateFixture); } describe('tasks.create — preview mode', () => { beforeEach(seedMutateCache); it('returns preview without committing', async () => { const { backend, commits } = recordingBackend({ 'alpha:.tasks/STATUS.md': { content: '# board\n', sha: 'old-sha' }, }); const tools = makeWriteTools(backend); const r = await call(tools, 'tasks.create', { target_project: 'OpeItcLoc03/alpha', slug: 'new-task', description: 'a thing', next_action: 'do it', }); const parsed = JSON.parse(r.content[0].text); expect(parsed.preview).toBe(true); expect(parsed.target_owner).toBe('OpeItcLoc03'); expect(parsed.target_repo).toBe('alpha'); expect(parsed.target_branch).toBe('main'); expect(parsed.commit_message).toContain('create [new-task] in OpeItcLoc03/alpha'); expect(parsed.block).toContain('## ⚪ [new-task] — a thing'); expect(commits).toEqual([]); }); it('rejects bare target_project with hint', async () => { const { backend, commits } = recordingBackend({}); const tools = makeWriteTools(backend); const r = await call(tools, 'tasks.create', { target_project: 'alpha', slug: 'x', description: 'd', next_action: 'n', }); expect(r.isError).toBe(true); expect(r.content[0].text).toContain('must be qualified'); expect(r.content[0].text).toContain('victor/books'); expect(commits).toEqual([]); }); it('write path is unaffected by aggregateSkipOwners — tasks.create succeeds for skip-owner project', async () => { // The whole point of the skip-list: cache contains the qualified key, so // write tools resolve normally; only aggregation views hide the project. const { backend, commits } = recordingBackend({}); const tools = makeWriteTools(backend, ['OpeItcLoc03']); const r = await call(tools, 'tasks.create', { target_project: 'OpeItcLoc03/alpha', slug: 'infra-x', description: 'fix infra', next_action: 'ship', confirm: true, }); const parsed = JSON.parse(r.content[0].text); expect(parsed.committed).toBe(true); expect(parsed.target_owner).toBe('OpeItcLoc03'); expect(parsed.target_repo).toBe('alpha'); expect(commits).toHaveLength(1); expect(commits[0].message).toContain('create [infra-x] in OpeItcLoc03/alpha'); }); }); describe('tasks.create — confirm', () => { beforeEach(seedMutateCache); it('appends a new task block via PUT (sha-based update)', async () => { const { backend, commits, files } = recordingBackend({ 'alpha:.tasks/STATUS.md': { content: '# Task Board\n_Updated: 2026-04-29_\n\n## 🔴 [old] — old\n**Next action:** x\n\n---\n', sha: 'old-sha' }, }); const tools = makeWriteTools(backend); const r = await call(tools, 'tasks.create', { target_project: 'OpeItcLoc03/alpha', slug: 'new-task', description: 'a thing', next_action: 'do it', where_stopped: '(not started)', confirm: true, }); const parsed = JSON.parse(r.content[0].text); expect(parsed.committed).toBe(true); expect(parsed.commit_sha).toBe('commit-1'); expect(commits).toHaveLength(1); expect(commits[0].user).toBe('OpeItcLoc03'); expect(commits[0].repo).toBe('alpha'); expect(commits[0].sha).toBe('old-sha'); expect(commits[0].content).toContain('## ⚪ [new-task] — a thing'); expect(commits[0].content).toContain('## 🔴 [old] — old'); expect(commits[0].content).toContain(''); expect(files['alpha:.tasks/STATUS.md'].sha).toBe('sha-1'); }); it('routes victor/books to owner=victor in commitFile', async () => { const { backend, commits } = recordingBackend({}); const tools = makeWriteTools(backend); const r = await call(tools, 'tasks.create', { target_project: 'victor/books', slug: 'first', description: 'init', next_action: 'go', confirm: true, }); const parsed = JSON.parse(r.content[0].text); expect(parsed.committed).toBe(true); expect(commits[0].user).toBe('victor'); expect(commits[0].repo).toBe('books'); }); it('creates new STATUS.md from canonical template when missing (POST, no sha)', async () => { const { backend, commits } = recordingBackend({}); const tools = makeWriteTools(backend); const r = await call(tools, 'tasks.create', { target_project: 'OpeItcLoc03/beta', slug: 'first', description: 'init', next_action: 'go', confirm: true, }); const parsed = JSON.parse(r.content[0].text); expect(parsed.committed).toBe(true); expect(commits[0].sha).toBeUndefined(); expect(commits[0].content).toContain('# Task Board'); expect(commits[0].content).toContain('## ⚪ [first] — init'); }); it('rejects duplicate slug', async () => { const { backend, commits } = recordingBackend({ 'alpha:.tasks/STATUS.md': { content: '## 🔴 [dup] — d\n**Next action:** n\n', sha: 's' }, }); const tools = makeWriteTools(backend); const r = await call(tools, 'tasks.create', { target_project: 'OpeItcLoc03/alpha', slug: 'dup', description: 'd', next_action: 'n', confirm: true, }); expect(r.isError).toBe(true); expect(r.content[0].text).toContain('already exists'); expect(commits).toEqual([]); }); it('rejects unknown target_project (cache miss)', async () => { const { backend, commits } = recordingBackend({}); const tools = makeWriteTools(backend); const r = await call(tools, 'tasks.create', { target_project: 'ghost/repo', slug: 's', description: 'd', next_action: 'n', confirm: true, }); expect(r.isError).toBe(true); expect(r.content[0].text).toContain('not in cache'); expect(commits).toEqual([]); }); it('rejects bare target_project with hint message (no commit)', async () => { const { backend, commits } = recordingBackend({}); const tools = makeWriteTools(backend); const r = await call(tools, 'tasks.create', { target_project: 'alpha', slug: 's', description: 'd', next_action: 'n', confirm: true, }); expect(r.isError).toBe(true); expect(r.content[0].text).toContain('must be qualified'); expect(commits).toEqual([]); }); it('rejects agenda when agenda source is local (Gitea repo missing)', async () => { await writeCache(cacheFile, { ...mutateFixture, projects: [ ...mutateFixture.projects, { name: 'agenda', default_branch: 'local', fetched_at: '2026-04-29T12:00:00Z', active_tasks: [], all_tasks_count: 0, raw: '' }, ], }); const { backend, commits } = recordingBackend({}); const tools = makeWriteTools(backend); const r = await call(tools, 'tasks.create', { target_project: 'agenda', slug: 's', description: 'd', next_action: 'n', confirm: true, }); expect(r.isError).toBe(true); expect(r.content[0].text).toContain('Gitea repo "projects-tasks" not found'); expect(commits).toEqual([]); }); it('routes agenda writes to agendaTasksRepo (under giteaUser fallback owner)', async () => { await writeCache(cacheFile, { ...mutateFixture, projects: [ ...mutateFixture.projects, { name: 'agenda', default_branch: 'main', fetched_at: '2026-04-29T12:00:00Z', active_tasks: [], all_tasks_count: 0, raw: '' }, ], }); const { backend, commits } = recordingBackend({ 'projects-tasks:.tasks/STATUS.md': { content: '# Agenda\n', sha: 's' }, }); const tools = makeWriteTools(backend); const r = await call(tools, 'tasks.create', { target_project: 'agenda', slug: 'cross-cutting', description: 'global', next_action: 'do', confirm: true, }); expect(JSON.parse(r.content[0].text).committed).toBe(true); expect(commits[0].user).toBe('u'); expect(commits[0].repo).toBe('projects-tasks'); expect(commits[0].branch).toBe('main'); }); it('rejects legacy "_meta" literal with hint message (renamed to "agenda" in 2.0)', async () => { const { backend, commits } = recordingBackend({}); const tools = makeWriteTools(backend); const r = await call(tools, 'tasks.create', { target_project: '_meta', slug: 's', description: 'd', next_action: 'n', confirm: true, }); expect(r.isError).toBe(true); expect(r.content[0].text).toContain('must be qualified'); expect(r.content[0].text).toContain('Got: `_meta`'); expect(commits).toEqual([]); }); it('write tools disabled when no backend/auth', async () => { const tools = makeTasksTools({ cacheFile }); // no write deps const r = await call(tools, 'tasks.create', { target_project: 'OpeItcLoc03/alpha', slug: 's', description: 'd', next_action: 'n', }); expect(r.isError).toBe(true); expect(r.content[0].text).toContain('Write tools disabled'); }); it('writes **Notify:** line in block when notify is passed', async () => { const { backend, commits } = recordingBackend({}); const tools = makeWriteTools(backend); await call(tools, 'tasks.create', { target_project: 'OpeItcLoc03/alpha', slug: 'n-task', description: 'notify test', next_action: 'go', notify: 'OpeItcLoc03/workshop', confirm: true, }); expect(commits[0].content).toContain('**Notify:** OpeItcLoc03/workshop'); }); }); describe('tasks.update — confirm', () => { beforeEach(seedMutateCache); it('updates fields and PUTs with sha', async () => { const { backend, commits } = recordingBackend({ 'alpha:.tasks/STATUS.md': { content: `# board\n\n## 🔴 [t1] — desc\n**Status:** active\n**Where I stopped:** old\n**Next action:** old\n**Branch:** main\n\n---\n`, sha: 's-1', }, }); const tools = makeWriteTools(backend); const r = await call(tools, 'tasks.update', { target_project: 'OpeItcLoc03/alpha', slug: 't1', where_stopped: 'NEW', next_action: 'NEXT', confirm: true, }); expect(JSON.parse(r.content[0].text).committed).toBe(true); expect(commits[0].user).toBe('OpeItcLoc03'); expect(commits[0].repo).toBe('alpha'); expect(commits[0].content).toContain('**Where I stopped:** NEW'); expect(commits[0].content).toContain('**Next action:** NEXT'); expect(commits[0].sha).toBe('s-1'); }); it('updates weight and notify fields', async () => { const { backend, commits } = recordingBackend({ 'alpha:.tasks/STATUS.md': { content: `# board\n\n## ⚪ [t1] — desc\n**Status:** ready\n**Next action:** start\n**Branch:** main\n\n---\n`, sha: 's-1', }, }); const tools = makeWriteTools(backend); const r = await call(tools, 'tasks.update', { target_project: 'OpeItcLoc03/alpha', slug: 't1', weight: 'needs-human', notify: 'OpeItcLoc03/workshop', confirm: true, }); expect(JSON.parse(r.content[0].text).committed).toBe(true); expect(commits[0].content).toContain('**Weight:** needs-human'); expect(commits[0].content).toContain('**Notify:** OpeItcLoc03/workshop'); }); it('errors when slug not found', async () => { const { backend, commits } = recordingBackend({ 'alpha:.tasks/STATUS.md': { content: '## 🔴 [other] — d\n**Next action:** x\n', sha: 's' }, }); const tools = makeWriteTools(backend); const r = await call(tools, 'tasks.update', { target_project: 'OpeItcLoc03/alpha', slug: 'ghost', where_stopped: 'x', confirm: true, }); expect(r.isError).toBe(true); expect(commits).toEqual([]); }); it('errors when no fields provided', async () => { const { backend, commits } = recordingBackend({}); const tools = makeWriteTools(backend); const r = await call(tools, 'tasks.update', { target_project: 'OpeItcLoc03/alpha', slug: 't1', confirm: true, }); expect(r.isError).toBe(true); expect(r.content[0].text).toContain('no fields to update'); expect(commits).toEqual([]); }); it('rejects bare target_project', async () => { const { backend, commits } = recordingBackend({}); const tools = makeWriteTools(backend); const r = await call(tools, 'tasks.update', { target_project: 'alpha', slug: 't1', where_stopped: 'x', confirm: true, }); expect(r.isError).toBe(true); expect(r.content[0].text).toContain('must be qualified'); expect(commits).toEqual([]); }); }); describe('tasks.close — confirm', () => { beforeEach(seedMutateCache); it('marks task done with closed-by metadata', async () => { const { backend, commits } = recordingBackend({ 'alpha:.tasks/STATUS.md': { content: `# board\n\n## 🔴 [t1] — desc\n**Status:** active\n**Next action:** ship\n**Branch:** main\n\n---\n`, sha: 's', }, }); const tools = makeWriteTools(backend); const r = await call(tools, 'tasks.close', { target_project: 'OpeItcLoc03/alpha', slug: 't1', note: 'shipped', confirm: true, }); expect(JSON.parse(r.content[0].text).committed).toBe(true); expect(commits[0].user).toBe('OpeItcLoc03'); expect(commits[0].repo).toBe('alpha'); expect(commits[0].content).toMatch(/^## 🟢 \[t1\]/m); expect(commits[0].content).toContain('**Status:** done'); expect(commits[0].content).toContain(''); }); it('preview mode does not commit', async () => { const { backend, commits } = recordingBackend({ 'alpha:.tasks/STATUS.md': { content: `## 🔴 [t1] — d\n**Status:** active\n**Next action:** n\n**Branch:** m\n\n---\n`, sha: 's', }, }); const tools = makeWriteTools(backend); const r = await call(tools, 'tasks.close', { target_project: 'OpeItcLoc03/alpha', slug: 't1', }); const parsed = JSON.parse(r.content[0].text); expect(parsed.preview).toBe(true); expect(parsed.target_owner).toBe('OpeItcLoc03'); expect(parsed.target_repo).toBe('alpha'); expect(commits).toEqual([]); }); it('rejects bare target_project', async () => { const { backend, commits } = recordingBackend({}); const tools = makeWriteTools(backend); const r = await call(tools, 'tasks.close', { target_project: 'alpha', slug: 't1', confirm: true, }); expect(r.isError).toBe(true); expect(r.content[0].text).toContain('must be qualified'); expect(commits).toEqual([]); }); it('appends body_append to task block before closing comment', async () => { const { backend, commits } = recordingBackend({ 'alpha:.tasks/STATUS.md': { content: `## 🔴 [t1] — desc\n**Status:** active\n**Next action:** ship\n**Branch:** main\n\n---\n`, sha: 's', }, }); const tools = makeWriteTools(backend); const r = await call(tools, 'tasks.close', { target_project: 'OpeItcLoc03/alpha', slug: 't1', body_append: '## Review outcome\n\nstatus: approve\nsummary: LGTM', confirm: true, }); expect(JSON.parse(r.content[0].text).committed).toBe(true); expect(commits[0].content).toContain('## Review outcome'); expect(commits[0].content).toContain('status: approve'); expect(commits[0].content).toMatch(/^## 🟢 \[t1\]/m); }); }); describe('tasks.close — retry on 409 conflict', () => { beforeEach(seedMutateCache); const activeBoard = `# board\n\n## 🔴 [t1] — desc\n**Status:** active\n**Next action:** ship\n**Branch:** main\n\n---\n`; it('retries once on 409 and succeeds on second attempt', async () => { let commitAttempt = 0; const files: Record = { 'alpha:.tasks/STATUS.md': { content: activeBoard, sha: 's0' }, }; const commits: CommitFileArgs[] = []; const backend: Backend = { async listUserRepos() { return []; }, async getRawFile(_u, repo, path) { return files[`${repo}:${path}`]?.content ?? null; }, async getFileWithSha(_u, repo, path) { return files[`${repo}:${path}`] ?? null; }, async commitFile(args) { commitAttempt++; if (commitAttempt === 1) { const e = new Error('sha mismatch') as Error & { status: number }; e.status = 409; throw e; } commits.push({ ...args }); files[`${args.repo}:${args.path}`] = { content: args.content, sha: 'sha-2' }; return { fileSha: 'sha-2', commitSha: 'commit-2' }; }, }; const tools = makeWriteTools(backend); const r = await call(tools, 'tasks.close', { target_project: 'OpeItcLoc03/alpha', slug: 't1', note: 'shipped', confirm: true, }); expect(JSON.parse(r.content[0].text).committed).toBe(true); expect(commitAttempt).toBe(2); expect(commits[0].content).toMatch(/^## 🟢 \[t1\]/m); }); it('succeeds immediately when task already done on re-read after 409', async () => { let commitAttempt = 0; const doneBoardAfterConflict = activeBoard.replace('🔴', '🟢').replace('active', 'done'); const files: Record = { 'alpha:.tasks/STATUS.md': { content: activeBoard, sha: 's0' }, }; const backend: Backend = { async listUserRepos() { return []; }, async getRawFile(_u, repo, path) { return files[`${repo}:${path}`]?.content ?? null; }, async getFileWithSha(_u, repo, path) { // After first read, concurrent agent closes the task if (commitAttempt > 0) return { content: doneBoardAfterConflict, sha: 's1' }; return files[`${repo}:${path}`] ?? null; }, async commitFile() { commitAttempt++; const e = new Error('sha mismatch') as Error & { status: number }; e.status = 409; throw e; }, }; const tools = makeWriteTools(backend); const r = await call(tools, 'tasks.close', { target_project: 'OpeItcLoc03/alpha', slug: 't1', confirm: true, }); expect(JSON.parse(r.content[0].text).committed).toBe(true); expect(JSON.parse(r.content[0].text).already_done).toBe(true); expect(commitAttempt).toBe(1); }); it('returns error after 3 consecutive 409 conflicts', async () => { const files: Record = { 'alpha:.tasks/STATUS.md': { content: activeBoard, sha: 's0' }, }; const backend: Backend = { async listUserRepos() { return []; }, async getRawFile(_u, repo, path) { return files[`${repo}:${path}`]?.content ?? null; }, async getFileWithSha(_u, repo, path) { return files[`${repo}:${path}`] ?? null; }, async commitFile() { const e = new Error('sha mismatch') as Error & { status: number }; e.status = 409; throw e; }, }; const tools = makeWriteTools(backend); const r = await call(tools, 'tasks.close', { target_project: 'OpeItcLoc03/alpha', slug: 't1', confirm: true, }); expect(r.isError).toBe(true); expect(r.content[0].text).toMatch(/conflict.*3|3.*attempt/i); }); it('does not retry on non-conflict errors (e.g. 500)', async () => { let commitAttempt = 0; const files: Record = { 'alpha:.tasks/STATUS.md': { content: activeBoard, sha: 's0' }, }; const backend: Backend = { async listUserRepos() { return []; }, async getRawFile(_u, repo, path) { return files[`${repo}:${path}`]?.content ?? null; }, async getFileWithSha(_u, repo, path) { return files[`${repo}:${path}`] ?? null; }, async commitFile() { commitAttempt++; const e = new Error('internal server error') as Error & { status: number }; e.status = 500; throw e; }, }; const tools = makeWriteTools(backend); const r = await call(tools, 'tasks.close', { target_project: 'OpeItcLoc03/alpha', slug: 't1', confirm: true, }); expect(r.isError).toBe(true); expect(commitAttempt).toBe(1); }); }); describe('tasks.claim_next', () => { beforeEach(seedMutateCache); const readyBoard = (slug: string, extra = '') => `# Task Board\n_Updated: 2026-04-29_\n\n## ⚪ [${slug}] — do a thing\n**Status:** ready\n**Next action:** start\n**Branch:** master\n${extra}\n---\n`; it('preview: returns a candidate without committing', async () => { const { backend, commits } = recordingBackend({ 'alpha:.tasks/STATUS.md': { content: readyBoard('ready-one'), sha: 's-1' }, }); const tools = makeWriteTools(backend); const r = await call(tools, 'tasks.claim_next', { claimer_identity: 'win11:claude-opus:s1', }); const parsed = JSON.parse(r.content[0].text); expect(parsed.ok).toBe(true); expect(parsed.preview).toBe(true); expect(parsed.project).toBe('OpeItcLoc03/alpha'); expect(parsed.slug).toBe('ready-one'); expect(commits).toEqual([]); }); it('confirm: atomically claims — status ⚪→🔴 + owner/token/expiry via sha-CAS', async () => { const { backend, commits } = recordingBackend({ 'alpha:.tasks/STATUS.md': { content: readyBoard('ready-one'), sha: 's-1' }, }); const tools = makeWriteTools(backend); const r = await call(tools, 'tasks.claim_next', { claimer_identity: 'win11:claude-opus:s1', confirm: true, }); const parsed = JSON.parse(r.content[0].text); expect(parsed.ok).toBe(true); expect(parsed.claimed).toBe(true); expect(parsed.project).toBe('OpeItcLoc03/alpha'); expect(parsed.slug).toBe('ready-one'); expect(parsed.owner).toBe('win11:claude-opus:s1'); expect(parsed.claim_token).toMatch(/^[0-9a-f-]{36}$/); expect(parsed.claim_expires_at).toBe('2026-04-29T12:10:00.000Z'); // Task body travels with the claim so the spawning poller can render a real // prompt (without it the agent gets an empty `Task: [slug] —`). expect(parsed.description).toBe('do a thing'); expect(parsed.next).toBe('start'); expect(commits).toHaveLength(1); expect(commits[0].sha).toBe('s-1'); expect(commits[0].user).toBe('OpeItcLoc03'); expect(commits[0].repo).toBe('alpha'); expect(commits[0].content).toMatch(/^## 🔴 \[ready-one\]/m); expect(commits[0].content).toContain('**Status:** active'); expect(commits[0].content).toContain('**Owner:** win11:claude-opus:s1'); expect(commits[0].content).toContain('**Claim token:** '); expect(commits[0].content).toContain('**Claim expires at:** 2026-04-29T12:10:00.000Z'); }); it('claim response includes notify from task block', async () => { const { backend } = recordingBackend({ 'alpha:.tasks/STATUS.md': { content: readyBoard('with-notify', '**Notify:** OpeItcLoc03/workshop\n'), sha: 's-1', }, }); const tools = makeWriteTools(backend); const r = await call(tools, 'tasks.claim_next', { claimer_identity: 'win11:claude-opus:s1', confirm: true, }); const parsed = JSON.parse(r.content[0].text); expect(parsed.ok).toBe(true); expect(parsed.notify).toBe('OpeItcLoc03/workshop'); }); it('reject: runtime not in task.runtime_allowed → no-eligible-task', async () => { const { backend, commits } = recordingBackend({ 'alpha:.tasks/STATUS.md': { content: readyBoard('locked', '**Runtime allowed:** claude-opus\n'), sha: 's-1', }, }); const tools = makeWriteTools(backend); const r = await call(tools, 'tasks.claim_next', { claimer_identity: 'win11:hermes-glm51:s1', filter: { runtime: 'hermes-glm51' }, confirm: true, }); const parsed = JSON.parse(r.content[0].text); expect(parsed.ok).toBe(false); expect(parsed.reason).toBe('no-eligible-task'); expect(parsed.skipped[0].reason).toBe('runtime-not-allowed'); expect(commits).toEqual([]); }); it('concurrent: loses the race when the task is claimed between read and commit', async () => { // First commit throws a conflict (stale sha); on re-read the task is already // owned by another worker → atomic semantic: this claimer gets lost-race. let firstCommit = true; const board = readyBoard('contended'); const ownedBoard = board .replace('## ⚪ [contended]', '## 🔴 [contended]') .replace('**Status:** ready', '**Status:** active\n**Owner:** other:claude-opus:s9'); const backend: Backend = { async listUserRepos() { return []; }, async getRawFile() { return null; }, async getFileWithSha(_u, repo, path) { if (`${repo}:${path}` !== 'alpha:.tasks/STATUS.md') return null; // Before the conflict: ready. After (re-read): already owned. return firstCommit ? { content: board, sha: 's-1' } : { content: ownedBoard, sha: 's-2' }; }, async commitFile() { if (firstCommit) { firstCommit = false; throw new Error('422 sha mismatch'); } return { fileSha: 'x', commitSha: 'y' }; }, }; const tools = makeWriteTools(backend); const r = await call(tools, 'tasks.claim_next', { claimer_identity: 'win11:claude-opus:s1', filter: { project: 'OpeItcLoc03/alpha' }, confirm: true, }); const parsed = JSON.parse(r.content[0].text); expect(parsed.ok).toBe(false); expect(parsed.reason).toBe('lost-race'); expect(parsed.slug).toBe('contended'); }); it('conflict re-read: a DIFFERENT task in the same repo became live-claimed → project-busy, no second claim', async () => { // Cross-machine race the board-level busy gate must close on retry: // machines A and B both snapshot repo `alpha` at T0 (no live claim, gate // passes); A claims `task-a`, B selects the still-ready `task-b`. B's commit // 409s (same STATUS.md sha). On re-read `task-a` is now live-claimed by A, // but `task-b` itself is still ⚪ ready. The retry must re-evaluate the // project-busy gate (not just isReclaimable(task-b)) and abort — otherwise // two agents land in repo `alpha`, the exact divergence the gate targets. let firstCommit = true; const board = '# Task Board\n_Updated: 2026-04-29_\n\n' + '## ⚪ [task-b] — do b\n**Status:** ready\n**Next action:** start\n**Branch:** master\n\n---\n'; // After the conflict, task-a appears live-claimed by another machine; task-b // is untouched (still ready, still reclaimable on its own). const busyBoard = '# Task Board\n_Updated: 2026-04-29_\n\n' + '## 🔴 [task-a] — do a\n**Status:** active\n**Next action:** go\n**Branch:** master\n' + '**Owner:** other:claude-opus:s9\n**Claim token:** tok-a\n' + '**Claim expires at:** 2026-04-29T12:10:00.000Z\n\n---\n' + '## ⚪ [task-b] — do b\n**Status:** ready\n**Next action:** start\n**Branch:** master\n\n---\n'; let commits = 0; const backend: Backend = { async listUserRepos() { return []; }, async getRawFile() { return null; }, async getFileWithSha(_u, repo, path) { if (`${repo}:${path}` !== 'alpha:.tasks/STATUS.md') return null; return firstCommit ? { content: board, sha: 's-1' } : { content: busyBoard, sha: 's-2' }; }, async commitFile() { commits += 1; if (firstCommit) { firstCommit = false; throw new Error('422 sha mismatch'); } return { fileSha: 'x', commitSha: 'y' }; }, }; const tools = makeWriteTools(backend); const r = await call(tools, 'tasks.claim_next', { claimer_identity: 'win11:claude-opus:s1', filter: { project: 'OpeItcLoc03/alpha' }, confirm: true, }); const parsed = JSON.parse(r.content[0].text); expect(parsed.ok).toBe(false); expect(parsed.reason).toBe('project-busy'); expect(parsed.slug).toBe('task-b'); // Critically: the second (would-be) claim commit must NOT have fired. expect(commits).toBe(1); }); it('transport error (500) on commit → backend-error, not retried as a conflict', async () => { // A 500/network failure must not be mistaken for a sha-CAS conflict, which // would mask it as lost-race / conflict-retries-exhausted. const board = readyBoard('contended'); let attempts = 0; const backend: Backend = { async listUserRepos() { return []; }, async getRawFile() { return null; }, async getFileWithSha(_u, repo, path) { return `${repo}:${path}` === 'alpha:.tasks/STATUS.md' ? { content: board, sha: 's-1' } : null; }, async commitFile() { attempts += 1; throw Object.assign(new Error('Gitea commitFile PUT 500: boom'), { status: 500 }); }, }; const tools = makeWriteTools(backend); const r = await call(tools, 'tasks.claim_next', { claimer_identity: 'win11:claude-opus:s1', filter: { project: 'OpeItcLoc03/alpha' }, confirm: true, }); const parsed = JSON.parse(r.content[0].text); expect(parsed.ok).toBe(false); expect(parsed.reason).toBe('backend-error'); expect(attempts).toBe(1); // no conflict retry on a transport error }); it('lazy revoke: an expired 🔴 claim is re-claimable by a new owner', async () => { // fixedNow = 2026-04-29T12:00:00Z; claim expired an hour ago. const expiredBoard = '# Task Board\n_Updated: 2026-04-29_\n\n## 🔴 [stale-job] — work\n**Status:** active\n' + '**Next action:** go\n**Branch:** master\n**Owner:** old:claude-opus:s0\n' + '**Claim token:** tok-old\n**Claim expires at:** 2026-04-29T11:00:00.000Z\n\n---\n'; const { backend, commits } = recordingBackend({ 'alpha:.tasks/STATUS.md': { content: expiredBoard, sha: 's-1' }, }); const tools = makeWriteTools(backend); const r = await call(tools, 'tasks.claim_next', { claimer_identity: 'win11:claude-opus:s9', filter: { project: 'OpeItcLoc03/alpha' }, confirm: true, }); const parsed = JSON.parse(r.content[0].text); expect(parsed.ok).toBe(true); expect(parsed.claimed).toBe(true); expect(parsed.slug).toBe('stale-job'); expect(parsed.owner).toBe('win11:claude-opus:s9'); expect(commits[0].content).toContain('**Owner:** win11:claude-opus:s9'); }); }); describe('tasks.claim_next — policy.toml', () => { beforeEach(seedMutateCache); const plainBoard = '# Task Board\n_Updated: 2026-04-29_\n\n## ⚪ [open-task] — work\n**Status:** ready\n**Next action:** go\n**Branch:** master\n\n---\n'; it('applies project default_runtime_allowed when the task omits runtime_allowed (reject)', async () => { const { backend, commits } = recordingBackend({ 'alpha:.tasks/STATUS.md': { content: plainBoard, sha: 's-1' }, 'alpha:.tasks/policy.toml': { content: 'default_runtime_allowed = ["claude-opus"]\n', sha: 'p-1' }, }); const tools = makeWriteTools(backend); const r = await call(tools, 'tasks.claim_next', { claimer_identity: 'win11:hermes-glm51:s1', filter: { project: 'OpeItcLoc03/alpha', runtime: 'hermes-glm51' }, confirm: true, }); const parsed = JSON.parse(r.content[0].text); expect(parsed.ok).toBe(false); expect(parsed.reason).toBe('no-eligible-task'); expect(parsed.skipped[0].reason).toBe('runtime-not-allowed'); expect(commits).toEqual([]); }); it('applies project policy positively when runtime matches', async () => { const { backend } = recordingBackend({ 'alpha:.tasks/STATUS.md': { content: plainBoard, sha: 's-1' }, 'alpha:.tasks/policy.toml': { content: 'default_runtime_allowed = ["claude-opus"]\n', sha: 'p-1' }, }); const tools = makeWriteTools(backend); const r = await call(tools, 'tasks.claim_next', { claimer_identity: 'win11:claude-opus:s1', filter: { project: 'OpeItcLoc03/alpha', runtime: 'claude-opus' }, confirm: true, }); const parsed = JSON.parse(r.content[0].text); expect(parsed.ok).toBe(true); expect(parsed.slug).toBe('open-task'); }); it('task-level runtime_allowed overrides project policy completely', async () => { const board = '# Task Board\n\n## ⚪ [special] — work\n**Status:** ready\n**Next action:** go\n**Branch:** master\n**Runtime allowed:** hermes-glm51\n\n---\n'; const { backend } = recordingBackend({ 'alpha:.tasks/STATUS.md': { content: board, sha: 's-1' }, 'alpha:.tasks/policy.toml': { content: 'default_runtime_allowed = ["claude-opus"]\n', sha: 'p-1' }, }); const tools = makeWriteTools(backend); const r = await call(tools, 'tasks.claim_next', { claimer_identity: 'win11:hermes-glm51:s1', filter: { project: 'OpeItcLoc03/alpha', runtime: 'hermes-glm51' }, confirm: true, }); const parsed = JSON.parse(r.content[0].text); expect(parsed.ok).toBe(true); expect(parsed.slug).toBe('special'); }); it('malformed policy.toml → structured error, no commit', async () => { const { backend, commits } = recordingBackend({ 'alpha:.tasks/STATUS.md': { content: plainBoard, sha: 's-1' }, 'alpha:.tasks/policy.toml': { content: 'default_runtime_allowed = [oops\n', sha: 'p-1' }, }); const tools = makeWriteTools(backend); const r = await call(tools, 'tasks.claim_next', { claimer_identity: 'win11:claude-opus:s1', filter: { project: 'OpeItcLoc03/alpha', runtime: 'claude-opus' }, confirm: true, }); const parsed = JSON.parse(r.content[0].text); expect(parsed.ok).toBe(false); expect(parsed.reason).toBe('malformed-policy'); expect(parsed.project).toBe('OpeItcLoc03/alpha'); expect(commits).toEqual([]); }); }); describe('tasks.claim_next — weight:needs-human gate + consult_policy passthrough', () => { beforeEach(seedMutateCache); const board = (slug: string, extra = '') => `# Task Board\n_Updated: 2026-04-29_\n\n## ⚪ [${slug}] — work\n**Status:** ready\n**Next action:** go\n**Branch:** master\n${extra}\n---\n`; it('a task whose weight is needs-human is NOT claimable', async () => { const { backend, commits } = recordingBackend({ 'alpha:.tasks/STATUS.md': { content: board('touchy', '**Weight:** needs-human\n'), sha: 's-1' }, }); const tools = makeWriteTools(backend); const r = await call(tools, 'tasks.claim_next', { claimer_identity: 'win11:claude-opus:s1', filter: { project: 'OpeItcLoc03/alpha' }, confirm: true, }); const parsed = JSON.parse(r.content[0].text); expect(parsed.ok).toBe(false); expect(parsed.reason).toBe('no-eligible-task'); expect(parsed.skipped[0].reason).toBe('needs-human'); expect(commits).toEqual([]); }); it('project default_weight=needs-human excludes a task that omits weight', async () => { const { backend, commits } = recordingBackend({ 'alpha:.tasks/STATUS.md': { content: board('inherits'), sha: 's-1' }, 'alpha:.tasks/policy.toml': { content: 'default_weight = "needs-human"\n', sha: 'p-1' }, }); const tools = makeWriteTools(backend); const r = await call(tools, 'tasks.claim_next', { claimer_identity: 'win11:claude-opus:s1', filter: { project: 'OpeItcLoc03/alpha' }, confirm: true, }); const parsed = JSON.parse(r.content[0].text); expect(parsed.ok).toBe(false); expect(parsed.skipped[0].reason).toBe('needs-human'); expect(commits).toEqual([]); }); it('claim result carries the effective consult_policy (built-in default human-only)', async () => { const { backend } = recordingBackend({ 'alpha:.tasks/STATUS.md': { content: board('plain'), sha: 's-1' }, }); const tools = makeWriteTools(backend); const r = await call(tools, 'tasks.claim_next', { claimer_identity: 'win11:claude-opus:s1', filter: { project: 'OpeItcLoc03/alpha' }, confirm: true, }); const parsed = JSON.parse(r.content[0].text); expect(parsed.ok).toBe(true); expect(parsed.consult_policy).toBe('human-only'); }); it('claim result reflects a task-level consult_policy override', async () => { const { backend } = recordingBackend({ 'alpha:.tasks/STATUS.md': { content: board('strict', '**Consult policy:** strict-human\n'), sha: 's-1' }, }); const tools = makeWriteTools(backend); const r = await call(tools, 'tasks.claim_next', { claimer_identity: 'win11:claude-opus:s1', filter: { project: 'OpeItcLoc03/alpha' }, confirm: true, }); const parsed = JSON.parse(r.content[0].text); expect(parsed.ok).toBe(true); expect(parsed.consult_policy).toBe('strict-human'); }); }); describe('tasks.heartbeat', () => { beforeEach(seedMutateCache); const claimedBoard = '# Task Board\n_Updated: 2026-04-29_\n\n## 🔴 [job] — work\n**Status:** active\n' + '**Next action:** go\n**Branch:** master\n**Owner:** win11:claude-opus:s1\n' + '**Claim token:** tok-xyz\n**Claim expires at:** 2026-04-29T12:05:00.000Z\n\n---\n'; it('happy: extends claim_expires_at by TTL and commits with sha', async () => { const { backend, commits } = recordingBackend({ 'alpha:.tasks/STATUS.md': { content: claimedBoard, sha: 's-1' }, }); const tools = makeWriteTools(backend); const r = await call(tools, 'tasks.heartbeat', { slug: 'job', claim_token: 'tok-xyz', target_project: 'OpeItcLoc03/alpha', }); const parsed = JSON.parse(r.content[0].text); expect(parsed.ok).toBe(true); expect(parsed.new_expires_at).toBe('2026-04-29T12:10:00.000Z'); expect(commits).toHaveLength(1); expect(commits[0].sha).toBe('s-1'); expect(commits[0].content).toContain('**Claim expires at:** 2026-04-29T12:10:00.000Z'); // status unchanged expect(commits[0].content).toMatch(/^## 🔴 \[job\]/m); expect(commits[0].content).toContain('**Status:** active'); }); it('reject: token mismatch (no commit)', async () => { const { backend, commits } = recordingBackend({ 'alpha:.tasks/STATUS.md': { content: claimedBoard, sha: 's-1' }, }); const tools = makeWriteTools(backend); const r = await call(tools, 'tasks.heartbeat', { slug: 'job', claim_token: 'WRONG', target_project: 'OpeItcLoc03/alpha', }); const parsed = JSON.parse(r.content[0].text); expect(parsed.ok).toBe(false); expect(parsed.reason).toBe('token-mismatch'); expect(commits).toEqual([]); }); it('reject: task not claimed (⚪ ready, no token)', async () => { const { backend, commits } = recordingBackend({ 'alpha:.tasks/STATUS.md': { content: '# Task Board\n\n## ⚪ [fresh] — d\n**Status:** ready\n**Next action:** n\n**Branch:** m\n\n---\n', sha: 's-1', }, }); const tools = makeWriteTools(backend); const r = await call(tools, 'tasks.heartbeat', { slug: 'fresh', claim_token: 'tok', target_project: 'OpeItcLoc03/alpha', }); const parsed = JSON.parse(r.content[0].text); expect(parsed.ok).toBe(false); expect(parsed.reason).toBe('task-not-claimed'); expect(commits).toEqual([]); }); it('reject: task not found', async () => { const { backend, commits } = recordingBackend({ 'alpha:.tasks/STATUS.md': { content: claimedBoard, sha: 's-1' }, }); const tools = makeWriteTools(backend); const r = await call(tools, 'tasks.heartbeat', { slug: 'ghost', claim_token: 'tok-xyz', target_project: 'OpeItcLoc03/alpha', }); const parsed = JSON.parse(r.content[0].text); expect(parsed.ok).toBe(false); expect(parsed.reason).toBe('task-not-found'); expect(commits).toEqual([]); }); it('conflict: stale sha (409) → re-read → retry with fresh sha → success', async () => { // A concurrent commit makes our sha stale; our token still holds on the new // revision, so the second attempt commits with the fresh sha. const commits: CommitFileArgs[] = []; const backend: Backend = { async listUserRepos() { return []; }, async getRawFile() { return null; }, async getFileWithSha(_u, repo, path) { if (`${repo}:${path}` !== 'alpha:.tasks/STATUS.md') return null; return commits.length === 0 ? { content: claimedBoard, sha: 's-1' } : { content: claimedBoard, sha: 's-2' }; }, async commitFile(args) { commits.push({ ...args }); if (commits.length === 1) throw Object.assign(new Error('conflict'), { status: 409 }); return { fileSha: 'x', commitSha: 'y' }; }, }; const tools = makeWriteTools(backend); const r = await call(tools, 'tasks.heartbeat', { slug: 'job', claim_token: 'tok-xyz', target_project: 'OpeItcLoc03/alpha', }); const parsed = JSON.parse(r.content[0].text); expect(parsed.ok).toBe(true); expect(parsed.commit_sha).toBe('y'); expect(commits).toHaveLength(2); expect(commits[1].sha).toBe('s-2'); // retried with the fresh sha, not the stale one }); it('conflict: re-read shows the claim was taken by another owner → token-mismatch', async () => { const reclaimedBoard = claimedBoard.replace('tok-xyz', 'tok-OTHER'); const commits: CommitFileArgs[] = []; const backend: Backend = { async listUserRepos() { return []; }, async getRawFile() { return null; }, async getFileWithSha(_u, repo, path) { if (`${repo}:${path}` !== 'alpha:.tasks/STATUS.md') return null; return commits.length === 0 ? { content: claimedBoard, sha: 's-1' } : { content: reclaimedBoard, sha: 's-2' }; }, async commitFile(args) { commits.push({ ...args }); throw Object.assign(new Error('conflict'), { status: 422 }); }, }; const tools = makeWriteTools(backend); const r = await call(tools, 'tasks.heartbeat', { slug: 'job', claim_token: 'tok-xyz', target_project: 'OpeItcLoc03/alpha', }); const parsed = JSON.parse(r.content[0].text); expect(parsed.ok).toBe(false); expect(parsed.reason).toBe('token-mismatch'); expect(commits).toHaveLength(1); // no second attempt — the claim is gone }); it('transport error (500) is reported as backend-error, not retried as a conflict', async () => { const commits: CommitFileArgs[] = []; const backend: Backend = { async listUserRepos() { return []; }, async getRawFile() { return null; }, async getFileWithSha(_u, repo, path) { return `${repo}:${path}` === 'alpha:.tasks/STATUS.md' ? { content: claimedBoard, sha: 's-1' } : null; }, async commitFile(args) { commits.push({ ...args }); throw Object.assign(new Error('Gitea commitFile PUT 500: boom'), { status: 500 }); }, }; const tools = makeWriteTools(backend); const r = await call(tools, 'tasks.heartbeat', { slug: 'job', claim_token: 'tok-xyz', target_project: 'OpeItcLoc03/alpha', }); const parsed = JSON.parse(r.content[0].text); expect(parsed.ok).toBe(false); expect(parsed.reason).toBe('backend-error'); expect(commits).toHaveLength(1); // single attempt — no conflict retry }); });