Files
Codeman/test/routes/agent-case-marker-routes.test.ts
T
Codeman maintainer 8ee7926e27 feat(agent-cases): tag agent-spawned case dirs and sweep their leftovers
A long orchestration creates one case directory per worker and deleting the
sessions never removed them, so ~/codeman-cases accumulated scratch folders
that were indistinguishable from real projects. They are now labelled and
have a cleanup path.

- src/agent-case-marker.ts: a case dir quick-start CREATES for an agent-driven
  spawn gets a .codeman-agent-case.json marker (when, by whom, parent session,
  mode). Only the create branch writes it, so a linked case, a cloned repo or
  any pre-existing path is never labelled; reading is total, so a malformed
  marker means "not agent-created" rather than a half-trusted entry.
- The signal is the new X-Codeman-Agent-Origin header the skill preamble sets
  on its shared curl (preamble bumped to 1.22.0), or an agentOrigin body
  field, falling back to a resolved parentSessionId so a worker spawned by a
  stale skill copy is still labelled.
- GET /api/cases publishes it as agentCreated; GET /api/cases/agent-created is
  a read-only cleanup listing adding inUse and modifiedAt; Add Case -> Manage
  badges each case and offers a review-then-delete sweep that names every
  directory in its confirm and skips any case a live session is working in.
  Removal stays on the existing DELETE /api/cases/:name.
- Agent preamble caches are collected too: ~/.cache/codeman-agent-<id>.sh was
  written per claude session and never removed (236 leftovers measured on a
  working machine). Now deleted with the session and swept at boot, guarded by
  a live-session keep set plus a 7-day age floor.

Verified end to end on an isolated instance: marker written for header, body
and lineage-only spawns, absent with no agent signal and for a pre-existing
directory; inUse flipping on session end; badge, sticky bar, confirm and sweep
driven in a browser; preamble seeded on create, removed on delete, boot sweep
taking only the aged orphans.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 19:09:24 +02:00

210 lines
8.3 KiB
TypeScript

/**
* @fileoverview End-to-end wiring of the agent-case label: quick-start writes the
* marker, the case list publishes it, and `GET /api/cases/agent-created` reports it
* for cleanup.
*
* The rules under test are the ones that decide whether the cleanup list can be
* trusted: only a directory quick-start CREATES is ever labelled (a pre-existing
* case — a linked repo, a real project — never is), a spawn with no agent signal at
* all leaves no marker, the lineage header alone is enough to label one (that is how
* a stale skill copy still gets swept up), and a case a live session is working in is
* reported as `inUse` rather than silently offered up for deletion.
*
* Real filesystem against the per-file temp HOME from test/setup.ts, so the marker is
* asserted as bytes on disk rather than through a mock.
*
* Port: N/A (app.inject()).
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { mkdir, rm, readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { createMockRouteContext, createMockSession, type MockRouteContext } from '../mocks/index.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
import { registerCaseRoutes } from '../../src/web/routes/case-routes.js';
import { getCasesDir } from '../../src/config/cases-dir.js';
import { AGENT_CASE_MARKER_FILE } from '../../src/agent-case-marker.js';
import type { AgentCaseSummary, CaseInfo } from '../../src/types.js';
const PARENT_ID = 'test-session-1'; // the id the mock context pre-populates
interface Harness {
app: FastifyInstance;
ctx: MockRouteContext;
}
async function createHarness(): Promise<Harness> {
const app = Fastify({ logger: false });
await app.register(fastifyCookie);
const ctx = createMockRouteContext();
registerSessionRoutes(app, ctx);
registerCaseRoutes(app, ctx);
installRouteErrorHandler(app);
await app.ready();
return { app, ctx };
}
describe('agent-created case marker', () => {
let harness: Harness;
const created: string[] = [];
/** Spawn a worker through quick-start, the skill's usual route. */
async function quickStart(caseName: string, opts: { headers?: Record<string, string>; payload?: object } = {}) {
created.push(caseName);
return harness.app.inject({
method: 'POST',
url: '/api/quick-start',
headers: opts.headers,
payload: { caseName, mode: 'claude', ...(opts.payload ?? {}) },
});
}
const markerPath = (caseName: string) => join(getCasesDir(), caseName, AGENT_CASE_MARKER_FILE);
async function readMarker(caseName: string): Promise<Record<string, unknown> | null> {
try {
return JSON.parse(await readFile(markerPath(caseName), 'utf-8'));
} catch {
return null;
}
}
async function listCases(): Promise<CaseInfo[]> {
const res = await harness.app.inject({ method: 'GET', url: '/api/cases' });
const body = JSON.parse(res.body);
return (body.data ?? body) as CaseInfo[];
}
async function listAgentCases(): Promise<AgentCaseSummary[]> {
const res = await harness.app.inject({ method: 'GET', url: '/api/cases/agent-created' });
expect(res.statusCode).toBe(200);
return JSON.parse(res.body).data.cases as AgentCaseSummary[];
}
beforeEach(async () => {
harness = await createHarness();
});
afterEach(async () => {
await harness.app.close();
for (const name of created.splice(0)) {
await rm(join(getCasesDir(), name), { recursive: true, force: true });
}
});
it('labels a case directory created for a spawn carrying the skill origin header', async () => {
const res = await quickStart('agentcase1', {
headers: { 'x-codeman-agent-origin': 'codeman-skill', 'x-codeman-parent-session': PARENT_ID },
});
expect(res.statusCode).toBe(200);
const marker = await readMarker('agentcase1');
expect(marker).toMatchObject({
version: 1,
createdBy: 'codeman-skill',
parentSessionId: PARENT_ID,
mode: 'claude',
});
expect(Date.parse(String(marker?.createdAt))).not.toBeNaN();
});
it('accepts the origin as a body field too, with the body winning', async () => {
await quickStart('agentcase2', {
headers: { 'x-codeman-agent-origin': 'codeman-skill' },
payload: { agentOrigin: 'my-orchestrator' },
});
expect(await readMarker('agentcase2')).toMatchObject({ createdBy: 'my-orchestrator' });
});
it('labels a spawn that carries only the lineage header, which is how an older skill copy still gets swept up', async () => {
await quickStart('agentcase3', { headers: { 'x-codeman-parent-session': PARENT_ID } });
expect(await readMarker('agentcase3')).toMatchObject({
createdBy: 'agent-session',
parentSessionId: PARENT_ID,
});
});
it('writes NO marker for a spawn with no agent signal at all', async () => {
// A human clicking Run in the browser sets neither header, and their case must
// not turn up in a cleanup list.
await quickStart('humancase1');
expect(await readMarker('humancase1')).toBeNull();
});
it('never labels a directory that already existed', async () => {
// The linchpin: a linked case or a real repo is not ours to offer for deletion,
// and the create branch is the only place the marker may be written.
const name = 'preexisting1';
created.push(name);
await mkdir(join(getCasesDir(), name), { recursive: true });
const res = await harness.app.inject({
method: 'POST',
url: '/api/quick-start',
headers: { 'x-codeman-agent-origin': 'codeman-skill' },
payload: { caseName: name, mode: 'claude' },
});
expect(res.statusCode).toBe(200);
expect(await readMarker(name)).toBeNull();
});
it('drops an unrecognised origin token rather than storing it', async () => {
await quickStart('agentcase4', { payload: { agentOrigin: '<script>alert(1)</script>' } });
// No parent either, so nothing labels this one at all.
expect(await readMarker('agentcase4')).toBeNull();
});
it('still spawns the worker when the origin is bogus', async () => {
const res = await quickStart('agentcase5', { payload: { agentOrigin: 'not a token' } });
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body).sessionId ?? JSON.parse(res.body).data?.sessionId).toBeTruthy();
});
it('publishes the label on GET /api/cases and hides it from cases without one', async () => {
await quickStart('agentcase6', { headers: { 'x-codeman-agent-origin': 'codeman-skill' } });
await quickStart('humancase2');
const cases = await listCases();
expect(cases.find((c) => c.name === 'agentcase6')?.agentCreated).toMatchObject({ createdBy: 'codeman-skill' });
expect(cases.find((c) => c.name === 'humancase2')?.agentCreated).toBeUndefined();
});
it('lists only agent cases in the cleanup listing, newest first', async () => {
await quickStart('agentcase7', { headers: { 'x-codeman-agent-origin': 'codeman-skill' } });
await quickStart('humancase3');
const listed = await listAgentCases();
expect(listed.map((c) => c.name)).toContain('agentcase7');
expect(listed.map((c) => c.name)).not.toContain('humancase3');
const sorted = [...listed].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
expect(listed.map((c) => c.name)).toEqual(sorted.map((c) => c.name));
});
it('flags a case a live session is still working in as inUse', async () => {
// Deleting one of these would pull the rug out from under a running worker, so
// the UI excludes it rather than confirming it away.
await quickStart('agentcase8', { headers: { 'x-codeman-agent-origin': 'codeman-skill' } });
const busyPath = join(getCasesDir(), 'agentcase8');
const busy = createMockSession('busy-session') as unknown as { workingDir: string };
busy.workingDir = busyPath;
harness.ctx.sessions.set('busy-session', busy as never);
const entry = (await listAgentCases()).find((c) => c.name === 'agentcase8');
expect(entry?.inUse).toBe(true);
expect(entry?.path).toBe(busyPath);
});
it('reports a marker-less case space as an empty list rather than failing', async () => {
expect(await listAgentCases()).toEqual([]);
});
});