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>
419 lines
16 KiB
TypeScript
419 lines
16 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import {
|
|
isClaimable,
|
|
isExpiredClaim,
|
|
isReclaimable,
|
|
selectClaimableTask,
|
|
makeClaimStamp,
|
|
type ClaimCandidate,
|
|
} from '../../src/lib/claim.js';
|
|
import type { ParsedTask } from '../../src/lib/status-md.js';
|
|
|
|
function task(slug: string, over: Partial<ParsedTask> = {}): ParsedTask {
|
|
return { slug, status: 'ready', description: slug, next: null, ...over };
|
|
}
|
|
|
|
function cand(project: string, t: ParsedTask): ClaimCandidate {
|
|
return { project, task: t };
|
|
}
|
|
|
|
describe('isClaimable', () => {
|
|
it('true only for ⚪ ready + unowned', () => {
|
|
expect(isClaimable(task('a'))).toBe(true);
|
|
expect(isClaimable(task('a', { status: 'active' }))).toBe(false);
|
|
expect(isClaimable(task('a', { owner: 'm:r:s' }))).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('selectClaimableTask', () => {
|
|
const owner = 'win11:claude-opus:s1';
|
|
|
|
it('happy: returns first claimable candidate in board order', () => {
|
|
const out = selectClaimableTask(
|
|
[cand('p', task('first')), cand('p', task('second'))],
|
|
{ claimerOwner: owner },
|
|
);
|
|
expect(out.ok).toBe(true);
|
|
if (out.ok) expect(out.candidate.task.slug).toBe('first');
|
|
});
|
|
|
|
it('skips non-claimable (active / owned) silently', () => {
|
|
const out = selectClaimableTask(
|
|
[cand('p', task('busy', { status: 'active' })), cand('p', task('free'))],
|
|
{ claimerOwner: owner },
|
|
);
|
|
expect(out.ok).toBe(true);
|
|
if (out.ok) expect(out.candidate.task.slug).toBe('free');
|
|
});
|
|
|
|
it('reject: runtime not in task.runtimeAllowed', () => {
|
|
const out = selectClaimableTask(
|
|
[cand('p', task('locked', { runtimeAllowed: ['claude-opus'] }))],
|
|
{ runtime: 'hermes-glm51', claimerOwner: owner },
|
|
);
|
|
expect(out.ok).toBe(false);
|
|
if (!out.ok) {
|
|
expect(out.skipped).toHaveLength(1);
|
|
expect(out.skipped[0].reason).toBe('runtime-not-allowed');
|
|
expect(out.skipped[0].detail).toBe('claude-opus');
|
|
}
|
|
});
|
|
|
|
it('accepts when runtime IS allowed; unrestricted task ignores runtime', () => {
|
|
const allowed = selectClaimableTask(
|
|
[cand('p', task('locked', { runtimeAllowed: ['claude-opus', 'claude-sonnet'] }))],
|
|
{ runtime: 'claude-opus', claimerOwner: owner },
|
|
);
|
|
expect(allowed.ok).toBe(true);
|
|
const open = selectClaimableTask([cand('p', task('open'))], { claimerOwner: owner });
|
|
expect(open.ok).toBe(true);
|
|
});
|
|
|
|
it('reject: capabilities mismatch (task.requirements ⊄ self.capabilities)', () => {
|
|
const out = selectClaimableTask(
|
|
[cand('p', task('dbjob', { requirements: ['needs-db', 'needs-secrets'] }))],
|
|
{ capabilities: ['needs-db'], claimerOwner: owner },
|
|
);
|
|
expect(out.ok).toBe(false);
|
|
if (!out.ok) {
|
|
expect(out.skipped[0].reason).toBe('capabilities-missing');
|
|
expect(out.skipped[0].detail).toBe('needs-secrets');
|
|
}
|
|
});
|
|
|
|
it('accepts when requirements are a subset of capabilities', () => {
|
|
const out = selectClaimableTask(
|
|
[cand('p', task('dbjob', { requirements: ['needs-db'] }))],
|
|
{ capabilities: ['needs-db', 'needs-secrets'], claimerOwner: owner },
|
|
);
|
|
expect(out.ok).toBe(true);
|
|
});
|
|
|
|
it('no candidates → ok:false with empty skipped', () => {
|
|
const out = selectClaimableTask([], { claimerOwner: owner });
|
|
expect(out.ok).toBe(false);
|
|
if (!out.ok) expect(out.skipped).toEqual([]);
|
|
});
|
|
|
|
it('reject: weight needs-human is excluded from autonomous claim', () => {
|
|
const out = selectClaimableTask(
|
|
[cand('p', task('touchy', { weight: 'needs-human' }))],
|
|
{ claimerOwner: owner },
|
|
);
|
|
expect(out.ok).toBe(false);
|
|
if (!out.ok) {
|
|
expect(out.skipped).toHaveLength(1);
|
|
expect(out.skipped[0].reason).toBe('needs-human');
|
|
}
|
|
});
|
|
|
|
it('needs-human is skipped but a following claimable task is still picked', () => {
|
|
const out = selectClaimableTask(
|
|
[cand('p', task('touchy', { weight: 'needs-human' })), cand('p', task('ok'))],
|
|
{ claimerOwner: owner },
|
|
);
|
|
expect(out.ok).toBe(true);
|
|
if (out.ok) expect(out.candidate.task.slug).toBe('ok');
|
|
});
|
|
|
|
it('other weights (cheap-ok / needs-claude) remain claimable', () => {
|
|
expect(
|
|
selectClaimableTask([cand('p', task('c', { weight: 'cheap-ok' }))], { claimerOwner: owner }).ok,
|
|
).toBe(true);
|
|
expect(
|
|
selectClaimableTask([cand('p', task('n', { weight: 'needs-claude' }))], { claimerOwner: owner }).ok,
|
|
).toBe(true);
|
|
});
|
|
|
|
it('lazy revoke: selects an expired-claim task when now is provided', () => {
|
|
const stale = task('stale', {
|
|
status: 'active',
|
|
owner: 'old:claude-opus:s0',
|
|
claimExpiresAt: '2026-06-07T11:00:00.000Z',
|
|
});
|
|
const out = selectClaimableTask([cand('p', stale)], {
|
|
claimerOwner: owner,
|
|
now: new Date('2026-06-07T12:00:00.000Z'),
|
|
});
|
|
expect(out.ok).toBe(true);
|
|
if (out.ok) expect(out.candidate.task.slug).toBe('stale');
|
|
});
|
|
|
|
it('does NOT select a live (unexpired) claim even with now provided', () => {
|
|
const live = task('live', {
|
|
status: 'active',
|
|
owner: 'other:claude-opus:s2',
|
|
claimExpiresAt: '2026-06-07T12:30:00.000Z',
|
|
});
|
|
const out = selectClaimableTask([cand('p', live)], {
|
|
claimerOwner: owner,
|
|
now: new Date('2026-06-07T12:00:00.000Z'),
|
|
});
|
|
expect(out.ok).toBe(false);
|
|
});
|
|
|
|
describe('one-agent-per-project claim-gate', () => {
|
|
const now = new Date('2026-06-07T12:00:00.000Z');
|
|
|
|
// (a) a project with one live-claimed task → its OTHER ready tasks are skipped.
|
|
it('skips a ready task whose project already has a LIVE-claimed task', () => {
|
|
const liveClaimed = task('in-progress', {
|
|
status: 'active',
|
|
owner: 'other:claude-opus:s2',
|
|
claimExpiresAt: '2026-06-07T12:30:00.000Z', // future → live
|
|
});
|
|
const ready = task('also-ready');
|
|
const out = selectClaimableTask([cand('p', ready)], {
|
|
claimerOwner: owner,
|
|
boardTasks: { p: [liveClaimed, ready] },
|
|
now,
|
|
});
|
|
expect(out.ok).toBe(false);
|
|
if (!out.ok) {
|
|
expect(out.skipped).toHaveLength(1);
|
|
expect(out.skipped[0].reason).toBe('project-busy');
|
|
expect(out.skipped[0].slug).toBe('also-ready');
|
|
}
|
|
});
|
|
|
|
// a live claim with no explicit TTL (owned, in-flight) still marks the project busy.
|
|
it('treats an owned task without an expiry as a live claim (project busy)', () => {
|
|
const owned = task('owned-no-ttl', { status: 'active', owner: 'other:claude-opus:s2' });
|
|
const ready = task('also-ready');
|
|
const out = selectClaimableTask([cand('p', ready)], {
|
|
claimerOwner: owner,
|
|
boardTasks: { p: [owned, ready] },
|
|
now,
|
|
});
|
|
expect(out.ok).toBe(false);
|
|
if (!out.ok) expect(out.skipped[0].reason).toBe('project-busy');
|
|
});
|
|
|
|
// (b) a project whose only active task has an EXPIRED claim → its ready tasks ARE claimable.
|
|
it('does NOT mark a project busy when its only active task has an EXPIRED claim', () => {
|
|
const stale = task('stale', {
|
|
status: 'active',
|
|
owner: 'old:claude-opus:s0',
|
|
claimExpiresAt: '2026-06-07T11:00:00.000Z', // past → expired, reclaimable
|
|
});
|
|
const ready = task('also-ready');
|
|
const out = selectClaimableTask([cand('p', ready)], {
|
|
claimerOwner: owner,
|
|
boardTasks: { p: [stale, ready] },
|
|
now,
|
|
});
|
|
expect(out.ok).toBe(true);
|
|
if (out.ok) expect(out.candidate.task.slug).toBe('also-ready');
|
|
});
|
|
|
|
// (c) two DIFFERENT projects each with a ready task → no cross-project exclusion.
|
|
it('does not exclude across projects — a busy project P does not block project Q', () => {
|
|
const liveP = task('p-busy', {
|
|
status: 'active',
|
|
owner: 'other:claude-opus:s2',
|
|
claimExpiresAt: '2026-06-07T12:30:00.000Z',
|
|
});
|
|
const readyP = task('p-ready');
|
|
const readyQ = task('q-ready');
|
|
const out = selectClaimableTask([cand('p', readyP), cand('q', readyQ)], {
|
|
claimerOwner: owner,
|
|
boardTasks: { p: [liveP, readyP], q: [readyQ] },
|
|
now,
|
|
});
|
|
// P is busy → p-ready skipped, but q-ready claims.
|
|
expect(out.ok).toBe(true);
|
|
if (out.ok) expect(out.candidate.task.slug).toBe('q-ready');
|
|
});
|
|
|
|
// (d) regression: a project with no active task → its ready task claims normally.
|
|
it('claims normally when the project has no active task', () => {
|
|
const ready = task('clean-ready');
|
|
const out = selectClaimableTask([cand('p', ready)], {
|
|
claimerOwner: owner,
|
|
boardTasks: { p: [ready] },
|
|
now,
|
|
});
|
|
expect(out.ok).toBe(true);
|
|
if (out.ok) expect(out.candidate.task.slug).toBe('clean-ready');
|
|
});
|
|
|
|
// the claimer's own live claim still marks the project busy (no second task in same repo).
|
|
it('a project the claimer itself live-claimed is also busy', () => {
|
|
const mine = task('mine', {
|
|
status: 'active',
|
|
owner,
|
|
claimExpiresAt: '2026-06-07T12:30:00.000Z',
|
|
});
|
|
const ready = task('second');
|
|
const out = selectClaimableTask([cand('p', ready)], {
|
|
claimerOwner: owner,
|
|
boardTasks: { p: [mine, ready] },
|
|
now,
|
|
});
|
|
expect(out.ok).toBe(false);
|
|
if (!out.ok) expect(out.skipped[0].reason).toBe('project-busy');
|
|
});
|
|
});
|
|
|
|
describe('projectAllowlist (rollout scope guard)', () => {
|
|
const now = new Date('2026-06-07T12:00:00.000Z');
|
|
|
|
// (a) allowlist set → non-listed project skipped (out-of-scope); listed claimable.
|
|
it('skips a candidate whose project is NOT in the allowlist, claims a listed one', () => {
|
|
const out = selectClaimableTask(
|
|
[cand('owner/off-scope', task('a')), cand('owner/in-scope', task('b'))],
|
|
{ claimerOwner: owner, projectAllowlist: ['owner/in-scope'] },
|
|
);
|
|
expect(out.ok).toBe(true);
|
|
if (out.ok) expect(out.candidate.project).toBe('owner/in-scope');
|
|
});
|
|
|
|
it('reports out-of-scope when the ONLY candidate is not in the allowlist', () => {
|
|
const out = selectClaimableTask([cand('owner/off-scope', task('a'))], {
|
|
claimerOwner: owner,
|
|
projectAllowlist: ['owner/in-scope'],
|
|
});
|
|
expect(out.ok).toBe(false);
|
|
if (!out.ok) {
|
|
expect(out.skipped).toHaveLength(1);
|
|
expect(out.skipped[0].reason).toBe('out-of-scope');
|
|
expect(out.skipped[0].project).toBe('owner/off-scope');
|
|
}
|
|
});
|
|
|
|
// (b) empty / undefined allowlist → no filtering (regression / backward-compat).
|
|
it('does no filtering when the allowlist is undefined (federation-wide)', () => {
|
|
const out = selectClaimableTask([cand('owner/anything', task('a'))], {
|
|
claimerOwner: owner,
|
|
});
|
|
expect(out.ok).toBe(true);
|
|
if (out.ok) expect(out.candidate.project).toBe('owner/anything');
|
|
});
|
|
|
|
it('does no filtering when the allowlist is empty (federation-wide)', () => {
|
|
const out = selectClaimableTask([cand('owner/anything', task('a'))], {
|
|
claimerOwner: owner,
|
|
projectAllowlist: [],
|
|
});
|
|
expect(out.ok).toBe(true);
|
|
if (out.ok) expect(out.candidate.project).toBe('owner/anything');
|
|
});
|
|
|
|
// (c) allowlist + project-busy compose: a listed-but-busy project is still skipped.
|
|
it('composes with project-busy — listed but busy project → project-busy skip', () => {
|
|
const liveClaimed = task('in-progress', {
|
|
status: 'active',
|
|
owner: 'other:claude-opus:s2',
|
|
claimExpiresAt: '2026-06-07T12:30:00.000Z', // future → live
|
|
});
|
|
const ready = task('also-ready');
|
|
const out = selectClaimableTask([cand('owner/in-scope', ready)], {
|
|
claimerOwner: owner,
|
|
projectAllowlist: ['owner/in-scope'],
|
|
boardTasks: { 'owner/in-scope': [liveClaimed, ready] },
|
|
now,
|
|
});
|
|
expect(out.ok).toBe(false);
|
|
if (!out.ok) {
|
|
expect(out.skipped).toHaveLength(1);
|
|
expect(out.skipped[0].reason).toBe('project-busy');
|
|
}
|
|
});
|
|
|
|
it('out-of-scope takes precedence over project-busy for a non-listed busy project', () => {
|
|
const liveClaimed = task('in-progress', {
|
|
status: 'active',
|
|
owner: 'other:claude-opus:s2',
|
|
claimExpiresAt: '2026-06-07T12:30:00.000Z',
|
|
});
|
|
const ready = task('also-ready');
|
|
const out = selectClaimableTask([cand('owner/off-scope', ready)], {
|
|
claimerOwner: owner,
|
|
projectAllowlist: ['owner/in-scope'],
|
|
boardTasks: { 'owner/off-scope': [liveClaimed, ready] },
|
|
now,
|
|
});
|
|
expect(out.ok).toBe(false);
|
|
if (!out.ok) expect(out.skipped[0].reason).toBe('out-of-scope');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('isExpiredClaim / isReclaimable', () => {
|
|
const now = new Date('2026-06-07T12:00:00.000Z');
|
|
|
|
it('expired claim true, live claim false', () => {
|
|
const expired = task('x', { status: 'active', owner: 'm:r:s', claimExpiresAt: '2026-06-07T11:50:00.000Z' });
|
|
const live = task('y', { status: 'active', owner: 'm:r:s', claimExpiresAt: '2026-06-07T12:10:00.000Z' });
|
|
expect(isExpiredClaim(expired, now)).toBe(true);
|
|
expect(isExpiredClaim(live, now)).toBe(false);
|
|
});
|
|
|
|
it('owned-without-expiry and unowned are not expired claims', () => {
|
|
expect(isExpiredClaim(task('a', { owner: 'm:r:s' }), now)).toBe(false);
|
|
expect(isExpiredClaim(task('b'), now)).toBe(false);
|
|
});
|
|
|
|
it('isReclaimable: ready OR expired claim', () => {
|
|
expect(isReclaimable(task('r'), now)).toBe(true);
|
|
expect(isReclaimable(task('e', { status: 'active', owner: 'm:r:s', claimExpiresAt: '2026-06-07T11:00:00.000Z' }), now)).toBe(true);
|
|
expect(isReclaimable(task('busy', { status: 'active', owner: 'm:r:s', claimExpiresAt: '2026-06-07T13:00:00.000Z' }), now)).toBe(false);
|
|
});
|
|
|
|
it('terminal/non-active task with stale stamp is NEVER reclaimable', () => {
|
|
// root-cause-#3: a 🟢 done task whose claim-stamp lingered and TTL lapsed
|
|
// must NOT become reclaimable (was re-claimed in a loop, burning tokens).
|
|
const doneStale = task('d', { status: 'done', owner: 'm:r:s', claimExpiresAt: '2026-06-07T11:00:00.000Z' });
|
|
expect(isExpiredClaim(doneStale, now)).toBe(false);
|
|
expect(isReclaimable(doneStale, now)).toBe(false);
|
|
// 🔵 blocked/parked task with a stale stamp is equally not reclaimable.
|
|
const blockedStale = task('b', { status: 'blocked', owner: 'm:r:s', claimExpiresAt: '2026-06-07T11:00:00.000Z' });
|
|
expect(isExpiredClaim(blockedStale, now)).toBe(false);
|
|
expect(isReclaimable(blockedStale, now)).toBe(false);
|
|
// crash-recovery preserved: an ACTIVE owned task with lapsed TTL stays reclaimable.
|
|
const activeStale = task('a', { status: 'active', owner: 'm:r:s', claimExpiresAt: '2026-06-07T11:00:00.000Z' });
|
|
expect(isExpiredClaim(activeStale, now)).toBe(true);
|
|
expect(isReclaimable(activeStale, now)).toBe(true);
|
|
});
|
|
|
|
it('selectClaimableTask skips a done/blocked task with stale expired stamp', () => {
|
|
const owner = 'win11:claude-opus:s1';
|
|
const doneStale = task('d', { status: 'done', owner: 'm:r:s', claimExpiresAt: '2026-06-07T11:00:00.000Z' });
|
|
const blockedStale = task('b', { status: 'blocked', owner: 'm:r:s', claimExpiresAt: '2026-06-07T11:00:00.000Z' });
|
|
const out = selectClaimableTask([cand('p', doneStale), cand('p', blockedStale)], {
|
|
claimerOwner: owner,
|
|
now,
|
|
});
|
|
expect(out.ok).toBe(false);
|
|
});
|
|
|
|
it('selectClaimableTask still selects an active task with lapsed TTL (crash recovery)', () => {
|
|
const owner = 'win11:claude-opus:s1';
|
|
const activeStale = task('a', { status: 'active', owner: 'm:r:s', claimExpiresAt: '2026-06-07T11:00:00.000Z' });
|
|
const out = selectClaimableTask([cand('p', activeStale)], {
|
|
claimerOwner: owner,
|
|
now,
|
|
boardTasks: { p: [activeStale] },
|
|
});
|
|
expect(out.ok).toBe(true);
|
|
if (out.ok) expect(out.candidate.task.slug).toBe('a');
|
|
});
|
|
});
|
|
|
|
describe('makeClaimStamp', () => {
|
|
it('stamps owner, a uuid token, and now+ttl expiry', () => {
|
|
const now = new Date('2026-06-07T12:00:00.000Z');
|
|
const s = makeClaimStamp('win11:claude-opus:s1', now, 10 * 60 * 1000);
|
|
expect(s.owner).toBe('win11:claude-opus:s1');
|
|
expect(s.claimToken).toMatch(/^[0-9a-f-]{36}$/);
|
|
expect(s.claimExpiresAt).toBe('2026-06-07T12:10:00.000Z');
|
|
});
|
|
|
|
it('produces distinct tokens across calls', () => {
|
|
const now = new Date('2026-06-07T12:00:00.000Z');
|
|
const a = makeClaimStamp('o', now);
|
|
const b = makeClaimStamp('o', now);
|
|
expect(a.claimToken).not.toBe(b.claimToken);
|
|
});
|
|
});
|