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>
1474 lines
55 KiB
TypeScript
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
|
|
});
|
|
});
|