294 lines
8.9 KiB
TypeScript
294 lines
8.9 KiB
TypeScript
import * as fs from 'fs/promises';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
|
|
import { TeamTaskStallJournal } from '../../../../../src/main/services/team/stallMonitor/TeamTaskStallJournal';
|
|
import { setClaudeBasePathOverride } from '../../../../../src/main/utils/pathDecoder';
|
|
|
|
describe('TeamTaskStallJournal', () => {
|
|
let tmpDir: string | null = null;
|
|
|
|
afterEach(async () => {
|
|
setClaudeBasePathOverride(null);
|
|
if (tmpDir) {
|
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
tmpDir = null;
|
|
}
|
|
});
|
|
|
|
it('requires two scans before returning an alert-ready candidate', async () => {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'stall-journal-'));
|
|
setClaudeBasePathOverride(tmpDir);
|
|
await fs.mkdir(path.join(tmpDir, 'teams', 'demo'), { recursive: true });
|
|
|
|
const journal = new TeamTaskStallJournal();
|
|
const evaluation = {
|
|
status: 'alert',
|
|
taskId: 'task-a',
|
|
branch: 'work',
|
|
signal: 'turn_ended_after_touch',
|
|
epochKey: 'task-a:epoch-1',
|
|
reason: 'Potential work stall',
|
|
} as const;
|
|
|
|
const firstReady = await journal.reconcileScan({
|
|
teamName: 'demo',
|
|
evaluations: [evaluation],
|
|
activeTaskIds: ['task-a'],
|
|
now: '2026-04-19T12:10:00.000Z',
|
|
});
|
|
const secondReady = await journal.reconcileScan({
|
|
teamName: 'demo',
|
|
evaluations: [evaluation],
|
|
activeTaskIds: ['task-a'],
|
|
now: '2026-04-19T12:11:00.000Z',
|
|
});
|
|
|
|
expect(firstReady).toEqual([]);
|
|
expect(secondReady).toEqual([evaluation]);
|
|
});
|
|
|
|
it('allows the same stalled epoch to alert again after the cooldown expires', async () => {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'stall-journal-'));
|
|
setClaudeBasePathOverride(tmpDir);
|
|
await fs.mkdir(path.join(tmpDir, 'teams', 'demo'), { recursive: true });
|
|
|
|
const journal = new TeamTaskStallJournal({ alertCooldownMs: 10 * 60_000 });
|
|
const evaluation = {
|
|
status: 'alert',
|
|
taskId: 'task-a',
|
|
branch: 'work',
|
|
signal: 'turn_ended_after_touch',
|
|
epochKey: 'task-a:epoch-1',
|
|
reason: 'Potential work stall',
|
|
} as const;
|
|
|
|
await journal.reconcileScan({
|
|
teamName: 'demo',
|
|
evaluations: [evaluation],
|
|
activeTaskIds: ['task-a'],
|
|
now: '2026-04-19T12:00:00.000Z',
|
|
});
|
|
await expect(
|
|
journal.reconcileScan({
|
|
teamName: 'demo',
|
|
evaluations: [evaluation],
|
|
activeTaskIds: ['task-a'],
|
|
now: '2026-04-19T12:01:00.000Z',
|
|
})
|
|
).resolves.toEqual([evaluation]);
|
|
await journal.markAlerted('demo', 'task-a:epoch-1', '2026-04-19T12:01:00.000Z');
|
|
|
|
await expect(
|
|
journal.reconcileScan({
|
|
teamName: 'demo',
|
|
evaluations: [evaluation],
|
|
activeTaskIds: ['task-a'],
|
|
now: '2026-04-19T12:05:00.000Z',
|
|
})
|
|
).resolves.toEqual([]);
|
|
await expect(
|
|
journal.reconcileScan({
|
|
teamName: 'demo',
|
|
evaluations: [evaluation],
|
|
activeTaskIds: ['task-a'],
|
|
now: '2026-04-19T12:12:00.000Z',
|
|
})
|
|
).resolves.toEqual([evaluation]);
|
|
});
|
|
|
|
it('does not suppress a stalled epoch forever when alertedAt is in the future', async () => {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'stall-journal-'));
|
|
setClaudeBasePathOverride(tmpDir);
|
|
const teamDir = path.join(tmpDir, 'teams', 'demo');
|
|
await fs.mkdir(teamDir, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(teamDir, 'stall-monitor-journal.json'),
|
|
JSON.stringify([
|
|
{
|
|
epochKey: 'task-a:epoch-1',
|
|
teamName: 'demo',
|
|
taskId: 'task-a',
|
|
branch: 'work',
|
|
signal: 'turn_ended_after_touch',
|
|
state: 'alerted',
|
|
consecutiveScans: 2,
|
|
createdAt: '2026-04-19T12:00:00.000Z',
|
|
updatedAt: '2026-04-19T12:01:00.000Z',
|
|
alertedAt: '2026-04-19T13:00:00.000Z',
|
|
},
|
|
]),
|
|
'utf8'
|
|
);
|
|
|
|
const journal = new TeamTaskStallJournal({ alertCooldownMs: 10 * 60_000 });
|
|
const evaluation = {
|
|
status: 'alert',
|
|
taskId: 'task-a',
|
|
branch: 'work',
|
|
signal: 'turn_ended_after_touch',
|
|
epochKey: 'task-a:epoch-1',
|
|
reason: 'Potential work stall',
|
|
} as const;
|
|
|
|
await expect(
|
|
journal.reconcileScan({
|
|
teamName: 'demo',
|
|
evaluations: [evaluation],
|
|
activeTaskIds: ['task-a'],
|
|
now: '2026-04-19T12:05:00.000Z',
|
|
})
|
|
).resolves.toEqual([evaluation]);
|
|
});
|
|
|
|
it('does not prune journal entries outside an explicit task scope', async () => {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'stall-journal-'));
|
|
setClaudeBasePathOverride(tmpDir);
|
|
const teamDir = path.join(tmpDir, 'teams', 'demo');
|
|
await fs.mkdir(teamDir, { recursive: true });
|
|
const journalPath = path.join(teamDir, 'stall-monitor-journal.json');
|
|
await fs.writeFile(
|
|
journalPath,
|
|
JSON.stringify(
|
|
[
|
|
{
|
|
epochKey: 'task-codex:epoch-1',
|
|
teamName: 'demo',
|
|
taskId: 'task-codex',
|
|
branch: 'work',
|
|
signal: 'turn_ended_after_touch',
|
|
state: 'suspected',
|
|
consecutiveScans: 1,
|
|
createdAt: '2026-04-19T12:00:00.000Z',
|
|
updatedAt: '2026-04-19T12:00:00.000Z',
|
|
},
|
|
{
|
|
epochKey: 'task-opencode:epoch-1',
|
|
teamName: 'demo',
|
|
taskId: 'task-opencode',
|
|
branch: 'work',
|
|
signal: 'turn_ended_after_touch',
|
|
state: 'suspected',
|
|
consecutiveScans: 1,
|
|
createdAt: '2026-04-19T12:00:00.000Z',
|
|
updatedAt: '2026-04-19T12:00:00.000Z',
|
|
},
|
|
],
|
|
null,
|
|
2
|
|
)
|
|
);
|
|
|
|
const journal = new TeamTaskStallJournal();
|
|
await journal.reconcileScan({
|
|
teamName: 'demo',
|
|
evaluations: [],
|
|
activeTaskIds: ['task-codex', 'task-opencode'],
|
|
scopeTaskIds: ['task-opencode'],
|
|
now: '2026-04-19T12:10:00.000Z',
|
|
});
|
|
|
|
const saved = JSON.parse(await fs.readFile(journalPath, 'utf8')) as Array<{
|
|
epochKey: string;
|
|
}>;
|
|
expect(saved.map((entry) => entry.epochKey)).toEqual(['task-codex:epoch-1']);
|
|
});
|
|
|
|
it('backfills member name on existing stall entries before alerting', async () => {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'stall-journal-'));
|
|
setClaudeBasePathOverride(tmpDir);
|
|
const teamDir = path.join(tmpDir, 'teams', 'demo');
|
|
await fs.mkdir(teamDir, { recursive: true });
|
|
const journalPath = path.join(teamDir, 'stall-monitor-journal.json');
|
|
await fs.writeFile(
|
|
journalPath,
|
|
JSON.stringify([
|
|
{
|
|
epochKey: 'task-a:epoch-1',
|
|
teamName: 'demo',
|
|
taskId: 'task-a',
|
|
branch: 'work',
|
|
signal: 'turn_ended_after_touch',
|
|
state: 'suspected',
|
|
consecutiveScans: 1,
|
|
createdAt: '2026-04-19T12:00:00.000Z',
|
|
updatedAt: '2026-04-19T12:00:00.000Z',
|
|
},
|
|
]),
|
|
'utf8'
|
|
);
|
|
|
|
const journal = new TeamTaskStallJournal();
|
|
const evaluation = {
|
|
status: 'alert',
|
|
taskId: 'task-a',
|
|
memberName: 'bob',
|
|
branch: 'work',
|
|
signal: 'turn_ended_after_touch',
|
|
epochKey: 'task-a:epoch-1',
|
|
reason: 'Potential work stall',
|
|
} as const;
|
|
|
|
await expect(
|
|
journal.reconcileScan({
|
|
teamName: 'demo',
|
|
evaluations: [evaluation],
|
|
activeTaskIds: ['task-a'],
|
|
now: '2026-04-19T12:10:00.000Z',
|
|
})
|
|
).resolves.toEqual([evaluation]);
|
|
|
|
const saved = JSON.parse(await fs.readFile(journalPath, 'utf8')) as Array<{
|
|
epochKey: string;
|
|
memberName?: string;
|
|
state: string;
|
|
}>;
|
|
expect(saved).toEqual([
|
|
expect.objectContaining({
|
|
epochKey: 'task-a:epoch-1',
|
|
memberName: 'bob',
|
|
state: 'alert_ready',
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it('recovers from an invalid journal file on the next scan', async () => {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'stall-journal-'));
|
|
setClaudeBasePathOverride(tmpDir);
|
|
const teamDir = path.join(tmpDir, 'teams', 'demo');
|
|
await fs.mkdir(teamDir, { recursive: true });
|
|
const journalPath = path.join(teamDir, 'stall-monitor-journal.json');
|
|
await fs.writeFile(journalPath, '{bad json', 'utf8');
|
|
|
|
const journal = new TeamTaskStallJournal();
|
|
const evaluation = {
|
|
status: 'alert',
|
|
taskId: 'task-a',
|
|
branch: 'work',
|
|
signal: 'turn_ended_after_touch',
|
|
epochKey: 'task-a:epoch-1',
|
|
reason: 'Potential work stall',
|
|
} as const;
|
|
|
|
await expect(
|
|
journal.reconcileScan({
|
|
teamName: 'demo',
|
|
evaluations: [evaluation],
|
|
activeTaskIds: ['task-a'],
|
|
now: '2026-04-19T12:10:00.000Z',
|
|
})
|
|
).resolves.toEqual([]);
|
|
|
|
const saved = JSON.parse(await fs.readFile(journalPath, 'utf8')) as Array<{
|
|
epochKey: string;
|
|
state: string;
|
|
}>;
|
|
expect(saved).toEqual([
|
|
expect.objectContaining({
|
|
epochKey: 'task-a:epoch-1',
|
|
state: 'suspected',
|
|
}),
|
|
]);
|
|
});
|
|
});
|