Files
factory/lib/projects-meta-mcp/tests/tools/tasks.test.ts
vitya ca669d96e1 feat(lib): bundle MCP servers from .common/lib into factory/lib/
Copies source (no node_modules, dist, .tasks, .wiki, __pycache__) for:
- projects-meta-mcp v2.25.0 (TypeScript/Node)
- wiki-graph v0.3.1 (TypeScript/Node)
- interns-mcp v0.3.3 (Python/FastMCP)

.gitignore: exclude lib build artefacts (node_modules, dist, .venv, __pycache__, *.pyc)
bootstrap.ps1: add MCP build step — npm install+build for TS servers, venv+pip for Python

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 13:17:29 +03:00

1474 lines
55 KiB
TypeScript

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<string, FileWithSha> = {}): {
backend: Backend;
commits: RecordedCommit[];
files: Record<string, FileWithSha>;
} {
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<CommitResult> {
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<typeof makeTasksTools>, name: string, args: Record<string, unknown>) {
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 <owner>/<repo> 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 <owner>/<repo> 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('<!-- created-by: u@host-x / from: modules-db / 2026-04-29T12:00:00.000Z -->');
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('<!-- closed-by: u@host-x / 2026-04-29T12:00:00.000Z / note: shipped -->');
});
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<string, FileWithSha> = {
'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<string, FileWithSha> = {
'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<string, FileWithSha> = {
'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<string, FileWithSha> = {
'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
});
});