mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
* fix(daemon): dedupe scheduled routine slots Co-authored-by: multica-agent <github@multica.ai> * fix(daemon): claim scheduled routine runs atomically Co-authored-by: multica-agent <github@multica.ai> * Fix routine loser snapshot rollback Co-authored-by: multica-agent <github@multica.ai> * fix(daemon): defer scheduled routine side effects Co-authored-by: multica-agent <github@multica.ai> * fix(daemon): terminate in-memory run on scheduled prepare failure If `prepare()` throws after `persistPreparedRun()` has mutated the routine run with real project/conversation/agentRunId values, the catch in `RoutineService.start_` previously left the in-memory chat run queued (no `discard()`), so its `completion` promise hung waiting on `design.runs.wait(run)` forever, and the `routine_runs` row stayed pinned to `routine-pending-*` placeholders even though the underlying project/conversation rows for those real IDs had been created. The catch now calls `handlerStart.discard?.()` so the in-memory run terminates as `canceled`, releasing `completion`, and passes the real IDs through `updateRun` so the persisted failed row reflects what was attempted instead of the placeholder sentinels. A cleanup failure inside `discard()` is logged via `console.error` rather than swallowed, following the same surface-don't-swallow rule the loser cleanup path uses. The original prepare error is still rethrown so the scheduler advances to the next cadence (the slot claim is already terminal, so retrying the same slot would just duplicate-claim and lose). Added regression coverage in `apps/daemon/tests/routines.test.ts` for both the normal prepare-failure path (real IDs persisted, discard fired, completion resolved) and the case where the cleanup itself also throws (failure surfaces via console.error, the row is still finalized with the real IDs). Co-authored-by: multica-agent <github@multica.ai> * fix(daemon): clear placeholder IDs on scheduled prepare failure Co-authored-by: multica-agent <github@multica.ai> * fix(daemon): finalize routine prepare failures * fix(daemon): defer manual routine setup cleanup Co-authored-by: multica-agent <github@multica.ai> * fix(daemon): drop loser chat runs and rollback partial snapshot pins Two follow-ups from the latest scheduler-claim review: - Duplicate scheduled losers used to call `design.runs.finish(run, 'canceled')`, exposing a phantom canceled routine run on `/api/runs` even though no `routine_runs` row, conversation, or messages were ever committed. Split the handler tear-down into `discardUnstarted` (used for never-inserted paths — drops the in-memory run via the new `design.runs.drop()`) and the existing `discard` (used after `prepare()` runs — still finalizes as canceled and rolls back partial state). - `resolvePluginSnapshot()` calls `linkSnapshotToProject()` before linking the conversation/run, so a failure mid-link could leave the reused project pinned to a snapshot the routine never durably claimed while `resolvedRoutineSnapshot` stayed null. Capture the intermediate snapshot id in `partiallyAppliedSnapshotId` when the resolver throws, and let `discard()` fall back to it for `restoreProjectSnapshotLink` so the previous project pin is restored either way. Regression coverage added in `tests/routine-schedule-claims.test.ts`: - A scheduled loser does not surface a phantom canceled chat run via `/api/runs` after the slot is lost. - A resolver that throws after `linkSnapshotToProject()` (forced via a SQLite trigger on `conversations.applied_plugin_snapshot_id`) still restores the reused project's previous pin in `discard()`. * fix(daemon): return prepared routine run ids Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: multica-agent <github@multica.ai> Co-authored-by: kami.c <kami.c@chative.com>
661 lines
22 KiB
TypeScript
661 lines
22 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import {
|
|
nextRunAtForSchedule,
|
|
RoutineService,
|
|
type Routine,
|
|
type RoutineRun,
|
|
type RoutineRunHandlerStart,
|
|
validateSchedule,
|
|
validateTarget,
|
|
} from '../src/routines.js';
|
|
|
|
function partsIn(timezone: string, at: Date): Record<string, string> {
|
|
const dtf = new Intl.DateTimeFormat('en-US', {
|
|
timeZone: timezone,
|
|
hour12: false,
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
});
|
|
const out: Record<string, string> = {};
|
|
for (const part of dtf.formatToParts(at)) {
|
|
if (part.type !== 'literal') out[part.type] = part.value;
|
|
}
|
|
if (out.hour === '24') out.hour = '00';
|
|
return out;
|
|
}
|
|
|
|
class SharedRoutinePersistence {
|
|
readonly runs: RoutineRun[] = [];
|
|
readonly claimedSlots = new Set<string>();
|
|
failScheduledInsertAttempts = 0;
|
|
|
|
constructor(private readonly routines: Routine[]) {}
|
|
|
|
list(): Routine[] {
|
|
return this.routines;
|
|
}
|
|
|
|
insertRun(run: RoutineRun, options: { scheduledSlotAt?: number } = {}): boolean {
|
|
if (options.scheduledSlotAt != null) {
|
|
if (this.failScheduledInsertAttempts > 0) {
|
|
this.failScheduledInsertAttempts -= 1;
|
|
throw new Error('scheduled slot claim unavailable');
|
|
}
|
|
const key = `${run.routineId}:${options.scheduledSlotAt}`;
|
|
if (this.claimedSlots.has(key)) return false;
|
|
this.claimedSlots.add(key);
|
|
}
|
|
this.runs.push(run);
|
|
return true;
|
|
}
|
|
|
|
updateRun(id: string, patch: Partial<RoutineRun>): void {
|
|
const run = this.runs.find((candidate) => candidate.id === id);
|
|
if (run) Object.assign(run, patch);
|
|
}
|
|
|
|
getLatestRun(routineId: string): RoutineRun | null {
|
|
return this.runs.find((run) => run.routineId === routineId) ?? null;
|
|
}
|
|
}
|
|
|
|
function fixtureRoutine(overrides: Partial<Routine> = {}): Routine {
|
|
return {
|
|
id: 'routine-1',
|
|
name: 'Daily brief',
|
|
prompt: 'Summarize the day',
|
|
schedule: { kind: 'hourly', minute: 1 },
|
|
target: { mode: 'create_each_run' },
|
|
skillId: null,
|
|
agentId: null,
|
|
context: {},
|
|
enabled: true,
|
|
nextRunAt: null,
|
|
lastRun: null,
|
|
createdAt: Date.UTC(2026, 4, 17, 0, 0),
|
|
updatedAt: Date.UTC(2026, 4, 17, 0, 0),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function handlerStart(agentRunId: string, onStart?: () => void): RoutineRunHandlerStart {
|
|
const start = onStart ? { start: onStart } : {};
|
|
return {
|
|
projectId: 'project-1',
|
|
conversationId: 'conversation-1',
|
|
agentRunId,
|
|
completion: Promise.resolve({ status: 'succeeded' }),
|
|
...start,
|
|
};
|
|
}
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
describe('nextRunAtForSchedule DST handling', () => {
|
|
it('does not fire before the requested wall time on a spring-forward gap day', () => {
|
|
// 2026-03-08 in America/New_York: clocks jump 02:00 EST → 03:00 EDT, so
|
|
// a daily routine scheduled at 02:30 has no valid wall clock that day.
|
|
// Prior to the fix, tzWallToUtc returned 06:30Z which renders back as
|
|
// 01:30 EST — an hour before the requested time. The fixed scheduler
|
|
// must instead advance to a valid post-gap instant on the same day.
|
|
const now = new Date('2026-03-08T05:00:00Z');
|
|
const next = nextRunAtForSchedule(
|
|
{ kind: 'daily', time: '02:30', timezone: 'America/New_York' },
|
|
now,
|
|
);
|
|
expect(next).not.toBeNull();
|
|
if (!next) return;
|
|
|
|
const parts = partsIn('America/New_York', next);
|
|
expect(parts.year).toBe('2026');
|
|
expect(parts.month).toBe('03');
|
|
expect(parts.day).toBe('08');
|
|
|
|
const wallMinutes = Number(parts.hour) * 60 + Number(parts.minute);
|
|
expect(wallMinutes).toBeGreaterThanOrEqual(2 * 60 + 30);
|
|
});
|
|
|
|
it('still fires the second occurrence when the wall time itself is in the repeated hour', () => {
|
|
// 2026-11-01 in America/New_York: 01:30 happens twice — first at
|
|
// 05:30Z (EDT) and again at 06:30Z (EST) after clocks fall back.
|
|
// If the daemon checks at 05:45Z (between the two occurrences),
|
|
// a daily routine at 01:30 must still fire today at 06:30Z, not
|
|
// skip to 2026-11-02 because the EDT instance is already past.
|
|
const now = new Date('2026-11-01T05:45:00Z');
|
|
const next = nextRunAtForSchedule(
|
|
{ kind: 'daily', time: '01:30', timezone: 'America/New_York' },
|
|
now,
|
|
);
|
|
expect(next).not.toBeNull();
|
|
if (!next) return;
|
|
|
|
expect(next.getTime()).toBe(Date.UTC(2026, 10, 1, 6, 30));
|
|
const parts = partsIn('America/New_York', next);
|
|
expect(parts.year).toBe('2026');
|
|
expect(parts.month).toBe('11');
|
|
expect(parts.day).toBe('01');
|
|
expect(parts.hour).toBe('01');
|
|
expect(parts.minute).toBe('30');
|
|
});
|
|
|
|
it('returns the first occurrence in the repeated hour when now is before either instance', () => {
|
|
// Before 05:30Z on the fall-back day, the next 01:30 NY is the
|
|
// first (EDT) occurrence at 05:30Z.
|
|
const now = new Date('2026-11-01T05:00:00Z');
|
|
const next = nextRunAtForSchedule(
|
|
{ kind: 'daily', time: '01:30', timezone: 'America/New_York' },
|
|
now,
|
|
);
|
|
expect(next).not.toBeNull();
|
|
if (!next) return;
|
|
expect(next.getTime()).toBe(Date.UTC(2026, 10, 1, 5, 30));
|
|
});
|
|
|
|
it('selects the post-fall-back instance on a fall-back day with ambiguous wall times', () => {
|
|
// 2026-11-01 in America/New_York: 01:30 happens twice (EDT and EST).
|
|
// For a daily routine at 02:30, the only valid instance is 02:30 EST,
|
|
// which renders to 07:30Z. Make sure we pick that one regardless of
|
|
// candidate ordering inside tzWallToUtc.
|
|
const now = new Date('2026-11-01T05:00:00Z');
|
|
const next = nextRunAtForSchedule(
|
|
{ kind: 'daily', time: '02:30', timezone: 'America/New_York' },
|
|
now,
|
|
);
|
|
expect(next).not.toBeNull();
|
|
if (!next) return;
|
|
|
|
const parts = partsIn('America/New_York', next);
|
|
expect(parts.year).toBe('2026');
|
|
expect(parts.month).toBe('11');
|
|
expect(parts.day).toBe('01');
|
|
expect(parts.hour).toBe('02');
|
|
expect(parts.minute).toBe('30');
|
|
});
|
|
|
|
it('returns the requested wall time on non-transition days', () => {
|
|
const now = new Date('2026-05-01T00:00:00Z');
|
|
const next = nextRunAtForSchedule(
|
|
{ kind: 'daily', time: '02:30', timezone: 'America/New_York' },
|
|
now,
|
|
);
|
|
expect(next).not.toBeNull();
|
|
if (!next) return;
|
|
|
|
const parts = partsIn('America/New_York', next);
|
|
expect(parts.hour).toBe('02');
|
|
expect(parts.minute).toBe('30');
|
|
});
|
|
|
|
it('returns the next hourly slot strictly after now', () => {
|
|
const now = new Date('2026-05-13T10:45:30Z');
|
|
const next = nextRunAtForSchedule({ kind: 'hourly', minute: 15 }, now);
|
|
expect(next).not.toBeNull();
|
|
if (!next) return;
|
|
expect(next.toISOString()).toBe('2026-05-13T11:15:00.000Z');
|
|
});
|
|
|
|
it('returns the next weekday occurrence for weekday schedules', () => {
|
|
const now = new Date('2026-05-16T00:00:00Z'); // Saturday
|
|
const next = nextRunAtForSchedule(
|
|
{ kind: 'weekdays', time: '09:00', timezone: 'UTC' },
|
|
now,
|
|
);
|
|
expect(next).not.toBeNull();
|
|
if (!next) return;
|
|
|
|
const parts = partsIn('UTC', next);
|
|
expect(parts.year).toBe('2026');
|
|
expect(parts.month).toBe('05');
|
|
expect(parts.day).toBe('18');
|
|
expect(parts.hour).toBe('09');
|
|
expect(parts.minute).toBe('00');
|
|
});
|
|
|
|
it('returns the next requested weekday for weekly schedules', () => {
|
|
const now = new Date('2026-05-13T10:00:00Z'); // Wednesday
|
|
const next = nextRunAtForSchedule(
|
|
{ kind: 'weekly', weekday: 5, time: '08:30', timezone: 'UTC' },
|
|
now,
|
|
);
|
|
expect(next).not.toBeNull();
|
|
if (!next) return;
|
|
|
|
const parts = partsIn('UTC', next);
|
|
expect(parts.year).toBe('2026');
|
|
expect(parts.month).toBe('05');
|
|
expect(parts.day).toBe('15');
|
|
expect(parts.hour).toBe('08');
|
|
expect(parts.minute).toBe('30');
|
|
});
|
|
});
|
|
|
|
describe('RoutineService scheduled run idempotency', () => {
|
|
it('starts only one scheduled run when two scheduler instances fire the same slot', async () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date('2026-05-17T10:00:00.000Z'));
|
|
|
|
const persistence = new SharedRoutinePersistence([fixtureRoutine()]);
|
|
const first = new RoutineService(persistence);
|
|
const second = new RoutineService(persistence);
|
|
const starts: string[] = [];
|
|
|
|
first.setRunHandler(async ({ runId }) => {
|
|
return handlerStart('agent-run-1', () => starts.push(runId));
|
|
});
|
|
second.setRunHandler(async ({ runId }) => {
|
|
return handlerStart('agent-run-2', () => starts.push(runId));
|
|
});
|
|
|
|
try {
|
|
first.start();
|
|
second.start();
|
|
|
|
await vi.advanceTimersByTimeAsync(61_000);
|
|
|
|
expect(starts).toHaveLength(1);
|
|
expect(persistence.runs).toHaveLength(1);
|
|
expect(persistence.claimedSlots).toEqual(new Set(['routine-1:1779012060000']));
|
|
} finally {
|
|
first.stop();
|
|
second.stop();
|
|
}
|
|
});
|
|
|
|
it('retries the same scheduled slot when durable run insertion fails', async () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date('2026-05-17T10:00:00.000Z'));
|
|
|
|
const persistence = new SharedRoutinePersistence([fixtureRoutine()]);
|
|
persistence.failScheduledInsertAttempts = 1;
|
|
const service = new RoutineService(persistence);
|
|
const starts: string[] = [];
|
|
const errors = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
|
|
service.setRunHandler(async ({ runId }) => {
|
|
return handlerStart('agent-run-1', () => starts.push(runId));
|
|
});
|
|
|
|
try {
|
|
service.start();
|
|
|
|
await vi.advanceTimersByTimeAsync(60_000);
|
|
|
|
expect(starts).toHaveLength(0);
|
|
expect(persistence.runs).toHaveLength(0);
|
|
expect(persistence.claimedSlots.size).toBe(0);
|
|
|
|
await vi.advanceTimersByTimeAsync(1_000);
|
|
|
|
expect(starts).toHaveLength(1);
|
|
expect(persistence.runs).toHaveLength(1);
|
|
expect(persistence.claimedSlots).toEqual(new Set(['routine-1:1779012060000']));
|
|
} finally {
|
|
service.stop();
|
|
errors.mockRestore();
|
|
}
|
|
});
|
|
|
|
it('terminates the in-memory run and persists real IDs when prepare fails after assigning them', async () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date('2026-05-17T10:00:00.000Z'));
|
|
|
|
const persistence = new SharedRoutinePersistence([fixtureRoutine()]);
|
|
const updatePatches: Array<Partial<RoutineRun>> = [];
|
|
const originalUpdate = persistence.updateRun.bind(persistence);
|
|
persistence.updateRun = (id: string, patch: Partial<RoutineRun>) => {
|
|
updatePatches.push({ ...patch });
|
|
originalUpdate(id, patch);
|
|
};
|
|
|
|
const service = new RoutineService(persistence);
|
|
const errors = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
|
|
let discardCalls = 0;
|
|
let completionResolved = false;
|
|
let resolveCompletion!: () => void;
|
|
const completion = new Promise<{ status: 'canceled' }>((resolve) => {
|
|
resolveCompletion = () => {
|
|
completionResolved = true;
|
|
resolve({ status: 'canceled' });
|
|
};
|
|
});
|
|
|
|
service.setRunHandler(async () => {
|
|
return {
|
|
// Placeholder IDs mirror server.ts's `scheduledPlaceholder*`
|
|
// values — these are what the row gets inserted with before
|
|
// `prepare()` patches them with real IDs.
|
|
projectId: 'routine-pending-project',
|
|
conversationId: 'routine-pending-conversation',
|
|
agentRunId: 'routine-pending-run',
|
|
completion,
|
|
prepare: (run: RoutineRun) => {
|
|
// Match persistPreparedRun(): mutate the routine run with real
|
|
// IDs before any later fallible work could throw.
|
|
run.projectId = 'real-project';
|
|
run.conversationId = 'real-conversation';
|
|
run.agentRunId = 'real-agent-run';
|
|
throw new Error('prepare exploded');
|
|
},
|
|
discard: () => {
|
|
discardCalls += 1;
|
|
resolveCompletion();
|
|
},
|
|
start: () => {
|
|
throw new Error('start should not run after a failed prepare');
|
|
},
|
|
};
|
|
});
|
|
|
|
try {
|
|
service.start();
|
|
|
|
await vi.advanceTimersByTimeAsync(60_000);
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
|
|
// The in-memory chat run was terminated, releasing the completion
|
|
// promise so it does not leak.
|
|
expect(discardCalls).toBe(1);
|
|
expect(completionResolved).toBe(true);
|
|
|
|
// The persisted row ends in the terminal failed state and carries
|
|
// the real IDs that prepare() assigned — no `routine-pending-*`
|
|
// placeholders left behind.
|
|
expect(persistence.runs).toHaveLength(1);
|
|
const stored = persistence.runs[0]!;
|
|
expect(stored.status).toBe('failed');
|
|
expect(stored.projectId).toBe('real-project');
|
|
expect(stored.conversationId).toBe('real-conversation');
|
|
expect(stored.agentRunId).toBe('real-agent-run');
|
|
expect(stored.completedAt).toBeTypeOf('number');
|
|
expect(stored.error).toContain('prepare exploded');
|
|
|
|
// The failure-path updateRun explicitly carried the real IDs so the
|
|
// real persistence layer (column-level UPDATE) replaces the
|
|
// placeholders, not just the in-memory shared reference.
|
|
const failurePatch = updatePatches.find((patch) => patch.status === 'failed');
|
|
expect(failurePatch).toBeDefined();
|
|
expect(failurePatch?.projectId).toBe('real-project');
|
|
expect(failurePatch?.conversationId).toBe('real-conversation');
|
|
expect(failurePatch?.agentRunId).toBe('real-agent-run');
|
|
} finally {
|
|
service.stop();
|
|
errors.mockRestore();
|
|
}
|
|
});
|
|
|
|
it('does not persist scheduled placeholder IDs when prepare fails before assigning real IDs', async () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date('2026-05-17T10:00:00.000Z'));
|
|
|
|
const persistence = new SharedRoutinePersistence([fixtureRoutine()]);
|
|
const updatePatches: Array<Partial<RoutineRun>> = [];
|
|
const originalUpdate = persistence.updateRun.bind(persistence);
|
|
persistence.updateRun = (id: string, patch: Partial<RoutineRun>) => {
|
|
updatePatches.push({ ...patch });
|
|
originalUpdate(id, patch);
|
|
};
|
|
|
|
const service = new RoutineService(persistence);
|
|
const errors = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
|
|
let discardCalls = 0;
|
|
|
|
service.setRunHandler(async ({ runId }) => {
|
|
return {
|
|
projectId: `routine-pending-project-${runId}`,
|
|
conversationId: `routine-pending-conv-${runId}`,
|
|
agentRunId: 'agent-run-1',
|
|
completion: Promise.resolve({ status: 'canceled' as const }),
|
|
prepare: () => {
|
|
// Mirrors createRoutineConversation() failing before
|
|
// persistPreparedRun() can copy real IDs onto the chat run or
|
|
// routine run.
|
|
throw new Error('project create failed');
|
|
},
|
|
discard: () => {
|
|
discardCalls += 1;
|
|
},
|
|
start: () => {
|
|
throw new Error('start should not run after a failed prepare');
|
|
},
|
|
};
|
|
});
|
|
|
|
try {
|
|
service.start();
|
|
|
|
await vi.advanceTimersByTimeAsync(60_000);
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
|
|
expect(discardCalls).toBe(1);
|
|
expect(persistence.runs).toHaveLength(1);
|
|
const stored = persistence.runs[0]!;
|
|
expect(stored.status).toBe('failed');
|
|
expect(stored.completedAt).toBeTypeOf('number');
|
|
expect(stored.error).toContain('project create failed');
|
|
expect(stored.projectId).toBe('');
|
|
expect(stored.conversationId).toBe('');
|
|
expect(stored.agentRunId).toBe('agent-run-1');
|
|
expect(stored.projectId).not.toContain('routine-pending-project');
|
|
expect(stored.conversationId).not.toContain('routine-pending-conv');
|
|
|
|
const failurePatch = updatePatches.find((patch) => patch.status === 'failed');
|
|
expect(failurePatch).toBeDefined();
|
|
expect(failurePatch?.projectId).toBe('');
|
|
expect(failurePatch?.conversationId).toBe('');
|
|
expect(failurePatch?.agentRunId).toBe('agent-run-1');
|
|
} finally {
|
|
service.stop();
|
|
errors.mockRestore();
|
|
}
|
|
});
|
|
|
|
it('prepares manual runs exactly once through the service path', async () => {
|
|
const persistence = new SharedRoutinePersistence([fixtureRoutine()]);
|
|
const service = new RoutineService(persistence);
|
|
let prepareCalls = 0;
|
|
|
|
service.setRunHandler(async () => ({
|
|
projectId: 'project-1',
|
|
conversationId: 'conversation-1',
|
|
agentRunId: 'agent-run-1',
|
|
completion: Promise.resolve({ status: 'succeeded' as const }),
|
|
prepare: () => {
|
|
prepareCalls += 1;
|
|
},
|
|
}));
|
|
|
|
await service.runNow('routine-1');
|
|
await Promise.resolve();
|
|
|
|
expect(prepareCalls).toBe(1);
|
|
expect(persistence.runs).toHaveLength(1);
|
|
expect(persistence.runs[0]).toMatchObject({
|
|
trigger: 'manual',
|
|
projectId: 'project-1',
|
|
conversationId: 'conversation-1',
|
|
agentRunId: 'agent-run-1',
|
|
});
|
|
});
|
|
|
|
it('returns prepared IDs from successful manual runs', async () => {
|
|
const persistence = new SharedRoutinePersistence([fixtureRoutine()]);
|
|
const service = new RoutineService(persistence);
|
|
|
|
service.setRunHandler(async () => ({
|
|
projectId: 'routine-pending-project',
|
|
conversationId: 'routine-pending-conversation',
|
|
agentRunId: 'routine-pending-run',
|
|
completion: Promise.resolve({ status: 'succeeded' as const }),
|
|
prepare: (run: RoutineRun) => {
|
|
run.projectId = 'real-project';
|
|
run.conversationId = 'real-conversation';
|
|
run.agentRunId = 'real-agent-run';
|
|
},
|
|
}));
|
|
|
|
const started = await service.runNow('routine-1');
|
|
await Promise.resolve();
|
|
|
|
expect(started).toMatchObject({
|
|
projectId: 'real-project',
|
|
conversationId: 'real-conversation',
|
|
agentRunId: 'real-agent-run',
|
|
});
|
|
expect(persistence.runs).toHaveLength(1);
|
|
expect(persistence.runs[0]).toMatchObject({
|
|
trigger: 'manual',
|
|
projectId: 'real-project',
|
|
conversationId: 'real-conversation',
|
|
agentRunId: 'real-agent-run',
|
|
});
|
|
});
|
|
|
|
it('still finalizes the failed row when prepare cleanup itself throws', async () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date('2026-05-17T10:00:00.000Z'));
|
|
|
|
const persistence = new SharedRoutinePersistence([fixtureRoutine()]);
|
|
const service = new RoutineService(persistence);
|
|
const errors = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
|
|
let discardCalls = 0;
|
|
|
|
service.setRunHandler(async () => {
|
|
return {
|
|
projectId: 'routine-pending-project',
|
|
conversationId: 'routine-pending-conversation',
|
|
agentRunId: 'routine-pending-run',
|
|
completion: Promise.resolve({ status: 'canceled' as const }),
|
|
prepare: (run: RoutineRun) => {
|
|
run.projectId = 'real-project';
|
|
run.conversationId = 'real-conversation';
|
|
run.agentRunId = 'real-agent-run';
|
|
throw new Error('prepare exploded');
|
|
},
|
|
discard: () => {
|
|
discardCalls += 1;
|
|
throw new Error('cleanup blew up');
|
|
},
|
|
start: () => {},
|
|
};
|
|
});
|
|
|
|
try {
|
|
service.start();
|
|
|
|
await vi.advanceTimersByTimeAsync(60_000);
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
|
|
expect(discardCalls).toBe(1);
|
|
|
|
// The cleanup failure is surfaced via console.error and does not
|
|
// swallow the prepare failure — the routine row is still finalized
|
|
// and the original prepare error reaches the scheduler.
|
|
expect(errors.mock.calls.some((call) =>
|
|
call.some((value) => String(value).includes('cleanup blew up')),
|
|
)).toBe(true);
|
|
expect(errors.mock.calls.some((call) =>
|
|
call.some((value) => String(value).includes('prepare exploded')),
|
|
)).toBe(true);
|
|
|
|
expect(persistence.runs).toHaveLength(1);
|
|
const stored = persistence.runs[0]!;
|
|
expect(stored.status).toBe('failed');
|
|
expect(stored.projectId).toBe('real-project');
|
|
expect(stored.conversationId).toBe('real-conversation');
|
|
expect(stored.agentRunId).toBe('real-agent-run');
|
|
expect(stored.error).toContain('prepare exploded');
|
|
} finally {
|
|
service.stop();
|
|
errors.mockRestore();
|
|
}
|
|
});
|
|
|
|
it('retries the same scheduled slot when duplicate loser cleanup fails', async () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date('2026-05-17T10:00:00.000Z'));
|
|
|
|
const persistence = new SharedRoutinePersistence([fixtureRoutine()]);
|
|
persistence.claimedSlots.add('routine-1:1779012060000');
|
|
const service = new RoutineService(persistence);
|
|
let discardAttempts = 0;
|
|
let discardFailures = 1;
|
|
const errors = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
|
|
service.setRunHandler(async ({ runId }) => {
|
|
return {
|
|
...handlerStart(runId),
|
|
discard: () => {
|
|
discardAttempts += 1;
|
|
if (discardFailures > 0) {
|
|
discardFailures -= 1;
|
|
throw new Error('duplicate loser cleanup failed');
|
|
}
|
|
},
|
|
};
|
|
});
|
|
|
|
try {
|
|
service.start();
|
|
|
|
await vi.advanceTimersByTimeAsync(60_000);
|
|
|
|
expect(discardAttempts).toBe(1);
|
|
expect(persistence.runs).toHaveLength(0);
|
|
expect(persistence.claimedSlots).toEqual(new Set(['routine-1:1779012060000']));
|
|
|
|
await vi.advanceTimersByTimeAsync(1_000);
|
|
|
|
expect(discardAttempts).toBe(2);
|
|
expect(persistence.runs).toHaveLength(0);
|
|
expect(errors.mock.calls.some((call) =>
|
|
call.some((value) => String(value).includes('duplicate loser cleanup failed')),
|
|
)).toBe(true);
|
|
} finally {
|
|
service.stop();
|
|
errors.mockRestore();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('routine validation', () => {
|
|
it('accepts valid schedule and target shapes', () => {
|
|
expect(() =>
|
|
validateSchedule({ kind: 'weekly', weekday: 1, time: '09:00', timezone: 'UTC' }),
|
|
).not.toThrow();
|
|
expect(() => validateTarget({ mode: 'create_each_run' })).not.toThrow();
|
|
expect(() => validateTarget({ mode: 'reuse', projectId: 'proj-1' })).not.toThrow();
|
|
});
|
|
|
|
it('rejects invalid wall times and timezones', () => {
|
|
expect(() =>
|
|
validateSchedule({ kind: 'daily', time: '25:00', timezone: 'UTC' }),
|
|
).toThrow(/Invalid time/);
|
|
expect(() =>
|
|
validateSchedule({ kind: 'daily', time: '09:00', timezone: 'Mars\/Olympus' }),
|
|
).toThrow(/Invalid timezone/);
|
|
});
|
|
|
|
it('rejects invalid weekday and unsupported target mode', () => {
|
|
expect(() =>
|
|
validateSchedule({ kind: 'weekly', weekday: 9 as 0, time: '09:00', timezone: 'UTC' }),
|
|
).toThrow(/weekly\.weekday/);
|
|
expect(() =>
|
|
validateTarget({ mode: 'teleport' } as unknown as Parameters<typeof validateTarget>[0]),
|
|
).toThrow(/Unsupported routine target mode/);
|
|
});
|
|
|
|
it('rejects reuse targets without a project id', () => {
|
|
expect(() =>
|
|
validateTarget({ mode: 'reuse', projectId: '' }),
|
|
).toThrow(/projectId/);
|
|
});
|
|
});
|