- core/index.ts: the FTS5 'search' table has no UNIQUE on id, so INSERT OR REPLACE appended a new row every update and the search JOIN returned an id N times. Now delete-then-insert by id -> exactly one FTS row per entity. - taskService.claimTask: race-guarded — only an OPEN task can be claimed; same-agent re-claim is an idempotent no-op; a task claimed by another agent or past open is refused. getTask+updateTask are sync, so within the single-thread event loop the read-check-write is effectively atomic (first claim wins). - routes.ts (spec 'nicht brechen'): PATCH in_progress surfaces a lost/non-open claim as a clean 400 instead of 500, so the board drag reverts gracefully and a second agent can't clobber the first's claim. - tests: +coreCorrectness.test.ts (no FTS dup after updates; concurrent claims -> one wins; idempotent re-claim; refuse non-open) + server.test.ts route guard Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
67 lines
2.6 KiB
TypeScript
67 lines
2.6 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
import { mkdtempSync, rmSync } from 'fs';
|
|
import { tmpdir } from 'os';
|
|
import { join } from 'path';
|
|
import { init } from '../src/cli/commands/init.js';
|
|
import { createTask, claimTask, reviewTask } from '../src/core/services/taskService.js';
|
|
import { searchMemory } from '../src/core/services/memoryService.js';
|
|
|
|
describe('core correctness (TSK-0007)', () => {
|
|
let cwd: string;
|
|
|
|
beforeEach(() => {
|
|
cwd = mkdtempSync(join(tmpdir(), 'ah-core-'));
|
|
init(cwd, { projectName: 'core-test', yes: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(cwd, { recursive: true, force: true });
|
|
});
|
|
|
|
// ── FTS5 duplicate-on-update ──────────────────────────────────────────────
|
|
it('search returns no duplicates after an entity is updated repeatedly', () => {
|
|
const t = createTask(cwd, { title: 'Zephyr indexing widget', role: 'implementer' });
|
|
// Each mutation re-upserts into the FTS index.
|
|
claimTask(cwd, t.id, 'kimi');
|
|
reviewTask(cwd, t.id);
|
|
|
|
const hits = searchMemory(cwd, 'Zephyr');
|
|
const forTask = hits.filter((r) => r.id === t.id);
|
|
expect(forTask).toHaveLength(1); // exactly one, not one-per-update
|
|
});
|
|
|
|
// ── claimTask race-guard ──────────────────────────────────────────────────
|
|
it('only one of two concurrent claims on the same open task wins', () => {
|
|
const t = createTask(cwd, { title: 'contended', role: 'implementer' });
|
|
|
|
const winners: string[] = [];
|
|
let refused = 0;
|
|
for (const agent of ['kimi', 'codex']) {
|
|
try {
|
|
const claimed = claimTask(cwd, t.id, agent);
|
|
winners.push(claimed.assignedTo ?? '');
|
|
} catch {
|
|
refused += 1;
|
|
}
|
|
}
|
|
expect(winners).toHaveLength(1);
|
|
expect(refused).toBe(1);
|
|
expect(winners[0]).toBe('kimi'); // the first to act wins
|
|
});
|
|
|
|
it('a re-claim by the same agent is an idempotent no-op', () => {
|
|
const t = createTask(cwd, { title: 'mine', role: 'implementer' });
|
|
claimTask(cwd, t.id, 'kimi');
|
|
const again = claimTask(cwd, t.id, 'kimi'); // no throw
|
|
expect(again.status).toBe('in_progress');
|
|
expect(again.claimedBy).toBe('kimi');
|
|
});
|
|
|
|
it('refuses to claim a task that is not open', () => {
|
|
const t = createTask(cwd, { title: 'x', role: 'implementer' });
|
|
claimTask(cwd, t.id, 'kimi');
|
|
reviewTask(cwd, t.id); // now in review
|
|
expect(() => claimTask(cwd, t.id, 'codex')).toThrow(/cannot be claimed/i);
|
|
});
|
|
});
|