agenthub/tests/coreCorrectness.test.ts
chahinebrini 08667637e6 fix(agenthub): TSK-0007 — FTS5 duplicate-on-update + claimTask race-guard
- 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>
2026-07-12 01:24:34 +02:00

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);
});
});