mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 15:35:12 +01:00
test: await async outcomes and drive saved deadlines deterministically (#18416)
Reviewed and verified at ab0b15d234.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
f2db909393
commit
ab51cabbbd
5 files changed
+56
-11
No files matched your search
@@ -1796,8 +1796,22 @@ describeEmbeddedPostgres("heartbeat stale queued-run invalidation", () => {
|
|||||||
const interactionId = randomUUID();
|
const interactionId = randomUUID();
|
||||||
const wakeCommentId = randomUUID();
|
const wakeCommentId = randomUUID();
|
||||||
let claimedIssue: { status: string; executionRunId: string | null } | null = null;
|
let claimedIssue: { status: string; executionRunId: string | null } | null = null;
|
||||||
|
let resumedRunId: string | undefined;
|
||||||
|
let signalAdapterStarted!: () => void;
|
||||||
|
const adapterStarted = new Promise<void>(resolve => { signalAdapterStarted = resolve; });
|
||||||
|
const execute = mockAdapterExecute.getMockImplementation()!;
|
||||||
|
mockAdapterExecute.mockImplementation(async (...args: unknown[]) => {
|
||||||
|
if ((args[0] as { runId: string }).runId === resumedRunId) {
|
||||||
|
signalAdapterStarted();
|
||||||
|
// A claimed continuation can finish after the old terminal poll.
|
||||||
|
if (interactionStatus === "rejected" && interactionKind === "connection_intent") {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 3100));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return execute();
|
||||||
|
});
|
||||||
afterContinuationDispatchCheck = async ({ runId: checkedRunId, issueId: checkedIssueId }) => {
|
afterContinuationDispatchCheck = async ({ runId: checkedRunId, issueId: checkedIssueId }) => {
|
||||||
if (checkedIssueId !== issueId) return;
|
if (checkedIssueId !== issueId || checkedRunId !== resumedRunId) return;
|
||||||
claimedIssue = await db
|
claimedIssue = await db
|
||||||
.select({ status: issues.status, executionRunId: issues.executionRunId })
|
.select({ status: issues.status, executionRunId: issues.executionRunId })
|
||||||
.from(issues)
|
.from(issues)
|
||||||
@@ -1831,13 +1845,14 @@ describeEmbeddedPostgres("heartbeat stale queued-run invalidation", () => {
|
|||||||
contextExtras: { interactionId, interactionKind, interactionStatus, wakeCommentId,
|
contextExtras: { interactionId, interactionKind, interactionStatus, wakeCommentId,
|
||||||
originCommentIds: [wakeCommentId],
|
originCommentIds: [wakeCommentId],
|
||||||
interactionResolvedAt: new Date().toISOString(), mutation: "interaction", source: `${interactionKind}.resolved`, forceFreshSession: true } });
|
interactionResolvedAt: new Date().toISOString(), mutation: "interaction", source: `${interactionKind}.resolved`, forceFreshSession: true } });
|
||||||
|
resumedRunId = runId;
|
||||||
await heartbeat.resumeQueuedRuns();
|
await heartbeat.resumeQueuedRuns();
|
||||||
expect(await waitForCondition(async () => (await db.select({ status: heartbeatRuns.status }).from(heartbeatRuns).where(eq(heartbeatRuns.id, runId)))[0]?.status === "succeeded")).toBe(true);
|
// Adapter entry guarantees this exact execution is registered. Await its
|
||||||
// Terminal status precedes completion bookkeeping. Drain those writes before
|
// owning completion barrier before terminal assertions and fixture cleanup.
|
||||||
// afterEach truncates the fixture, otherwise PostgreSQL can deadlock.
|
await adapterStarted;
|
||||||
await heartbeat.waitForRunExecutionDrain(runId);
|
await heartbeat.waitForRunExecutionDrain(runId);
|
||||||
|
expect((await db.select({ status: heartbeatRuns.status }).from(heartbeatRuns).where(eq(heartbeatRuns.id, runId)))[0]?.status).toBe("succeeded");
|
||||||
expect(countExecuteCallsForRun(runId)).toBe(1);
|
expect(countExecuteCallsForRun(runId)).toBe(1);
|
||||||
await waitForCondition(async () => claimedIssue !== null);
|
|
||||||
expect(claimedIssue).toEqual({ status: "in_progress", executionRunId: runId });
|
expect(claimedIssue).toEqual({ status: "in_progress", executionRunId: runId });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -83,6 +83,7 @@ describeEmbeddedPostgres("skill version snapshot retention", () => {
|
|||||||
let coordinator: ReturnType<typeof createDeliveryWorkCoordinator> | undefined;
|
let coordinator: ReturnType<typeof createDeliveryWorkCoordinator> | undefined;
|
||||||
let worker: ReturnType<typeof registerSkillVersionRetentionWork> | undefined;
|
let worker: ReturnType<typeof registerSkillVersionRetentionWork> | undefined;
|
||||||
let admitted = true;
|
let admitted = true;
|
||||||
|
const passes = new Set<Promise<unknown>>();
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
worker?.unsubscribe();
|
worker?.unsubscribe();
|
||||||
await coordinator?.stop();
|
await coordinator?.stop();
|
||||||
@@ -160,12 +161,20 @@ describeEmbeddedPostgres("skill version snapshot retention", () => {
|
|||||||
async function startRetention(
|
async function startRetention(
|
||||||
graceMs = 20_000,
|
graceMs = 20_000,
|
||||||
service = skillVersionRetentionService(db, { graceMs }),
|
service = skillVersionRetentionService(db, { graceMs }),
|
||||||
advanceAutomatically = true,
|
clock: boolean | { shouldAdvanceTime?: boolean; now?: number } = true,
|
||||||
) {
|
) {
|
||||||
|
const clockOptions = typeof clock === "boolean" ? { shouldAdvanceTime: clock } : clock;
|
||||||
|
const advanceAutomatically = clockOptions.shouldAdvanceTime ?? true;
|
||||||
await Promise.all(Array.from({ length: 6 }, () => db.execute(sql`select 1`)));
|
await Promise.all(Array.from({ length: 6 }, () => db.execute(sql`select 1`)));
|
||||||
vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout", "setInterval", "clearInterval"], shouldAdvanceTime: advanceAutomatically });
|
vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout", "setInterval", "clearInterval"], shouldAdvanceTime: advanceAutomatically, ...clockOptions });
|
||||||
coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => admitted, canReconcile: () => true,
|
coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => admitted, canReconcile: () => true,
|
||||||
onError: error => { throw error; } });
|
onError: error => { throw error; } });
|
||||||
|
const register = coordinator.register.bind(coordinator);
|
||||||
|
coordinator.register = (queue, task) => register(queue, { ...task, run: signal => {
|
||||||
|
const pass = task.run(signal); passes.add(pass);
|
||||||
|
void pass.then(() => passes.delete(pass), () => passes.delete(pass));
|
||||||
|
return pass;
|
||||||
|
} });
|
||||||
worker = registerSkillVersionRetentionWork(coordinator, service, () => admitted);
|
worker = registerSkillVersionRetentionWork(coordinator, service, () => admitted);
|
||||||
if (advanceAutomatically) await worker.ready;
|
if (advanceAutomatically) await worker.ready;
|
||||||
else await waitForIo(worker.ready);
|
else await waitForIo(worker.ready);
|
||||||
@@ -194,6 +203,7 @@ describeEmbeddedPostgres("skill version snapshot retention", () => {
|
|||||||
for (;;) {
|
for (;;) {
|
||||||
if (process.hrtime.bigint() >= deadline) throw new Error("Retention work did not settle within 10000ms");
|
if (process.hrtime.bigint() >= deadline) throw new Error("Retention work did not settle within 10000ms");
|
||||||
if (idleWorkSnapshot().active > 0) {
|
if (idleWorkSnapshot().active > 0) {
|
||||||
|
if (passes.size) await waitForIo(Promise.all([...passes]));
|
||||||
await vi.advanceTimersByTimeAsync(0);
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
await realDelay(0);
|
await realDelay(0);
|
||||||
continue;
|
continue;
|
||||||
@@ -631,7 +641,6 @@ describeEmbeddedPostgres("skill version snapshot retention", () => {
|
|||||||
it("keeps directory pages moving while another company's grace deadline is due", async () => {
|
it("keeps directory pages moving while another company's grace deadline is due", async () => {
|
||||||
const dueCompany = await createCompany(), pagedCompany = await createCompany();
|
const dueCompany = await createCompany(), pagedCompany = await createCompany();
|
||||||
const dueSkill = await createSkill(dueCompany, 2), pagedSkill = await createSkill(pagedCompany, 1);
|
const dueSkill = await createSkill(dueCompany, 2), pagedSkill = await createSkill(pagedCompany, 1);
|
||||||
const expiring = await writeSnapshot(dueCompany, dueSkill.skillId, dueSkill.versionIds[0]!, 0);
|
|
||||||
const parent = path.join(resolveCompanySkillVersionsRoot(pagedCompany), pagedSkill.skillId);
|
const parent = path.join(resolveCompanySkillVersionsRoot(pagedCompany), pagedSkill.skillId);
|
||||||
await fs.mkdir(parent, { recursive: true });
|
await fs.mkdir(parent, { recursive: true });
|
||||||
for (let index = 0; index < 410; index++) await fs.mkdir(path.join(parent, `operator-${index}`));
|
for (let index = 0; index < 410; index++) await fs.mkdir(path.join(parent, `operator-${index}`));
|
||||||
@@ -642,7 +651,13 @@ describeEmbeddedPostgres("skill version snapshot retention", () => {
|
|||||||
if (id === pagedCompany) pageTimes.push(Date.now());
|
if (id === pagedCompany) pageTimes.push(Date.now());
|
||||||
return prune(id, options);
|
return prune(id, options);
|
||||||
};
|
};
|
||||||
await startRetention(5000, service, false); await settledAtDeadlines();
|
const expiring = await writeSnapshot(dueCompany, dueSkill.skillId, dueSkill.versionIds[0]!, 0);
|
||||||
|
const accessedAt = (await lastUsedAt(skillVersionCachePaths(dueCompany, dueSkill.skillId, dueSkill.versionIds[0]!).access))!;
|
||||||
|
await startRetention(5000, service, { shouldAdvanceTime: false, now: accessedAt });
|
||||||
|
expect(await exists(expiring)).toBe(true);
|
||||||
|
// Keep virtual time fixed during real I/O; measure scheduler fairness,
|
||||||
|
// independent of filesystem latency and CI CPU scheduling.
|
||||||
|
await settledAtDeadlines();
|
||||||
expect(pageTimes.length).toBeGreaterThan(5);
|
expect(pageTimes.length).toBeGreaterThan(5);
|
||||||
expect(Math.max(...pageTimes.slice(1).map((at, index) => at - pageTimes[index]!))).toBeLessThan(3000);
|
expect(Math.max(...pageTimes.slice(1).map((at, index) => at - pageTimes[index]!))).toBeLessThan(3000);
|
||||||
expect(await exists(expiring)).toBe(false); await quiet();
|
expect(await exists(expiring)).toBe(false); await quiet();
|
||||||
|
|||||||
@@ -459,12 +459,15 @@ test("plan approval hands the preserved revision to an assigned project task", a
|
|||||||
{ timeout: 60_000 },
|
{ timeout: 60_000 },
|
||||||
)
|
)
|
||||||
.toBe(1);
|
.toBe(1);
|
||||||
|
// Task completion is delivered back to the chat as another real turn.
|
||||||
|
await idle(request, f.chatPath, 3);
|
||||||
const task = (
|
const task = (
|
||||||
await json(await request.get(`/api/companies/${f.company.id}/issues`))
|
await json(await request.get(`/api/companies/${f.company.id}/issues`))
|
||||||
)[0];
|
)[0];
|
||||||
expect(task.parentId).toBeNull();
|
expect(task.parentId).toBeNull();
|
||||||
expect(task.projectId).toBeTruthy();
|
expect(task.projectId).toBeTruthy();
|
||||||
expect(task.assigneeAgentId).toBe(f.agent.id);
|
expect(task.assigneeAgentId).toBe(f.agent.id);
|
||||||
|
await expect(page.getByText(`Delegated work completed: ${task.identifier}`, { exact: true })).toBeVisible();
|
||||||
const plan = await json(
|
const plan = await json(
|
||||||
await request.get(`/api/issues/${task.id}/documents/plan`),
|
await request.get(`/api/issues/${task.id}/documents/plan`),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ if (!task.conversationAgentId) {
|
|||||||
});
|
});
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
if (ctx.wakeReason === "chat_task_completed") {
|
||||||
|
await comment(`Delegated work completed: ${ctx.chatCompletionUpdates.map((update) => update.identifier).join(", ")}`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
const comments = await api(`/issues/${task.id}/comments?order=asc`);
|
const comments = await api(`/issues/${task.id}/comments?order=asc`);
|
||||||
const current =
|
const current =
|
||||||
comments.find((c) => c.id === ctx.wakeCommentId) ??
|
comments.find((c) => c.id === ctx.wakeCommentId) ??
|
||||||
|
|||||||
@@ -143,9 +143,17 @@ it("discovers a panel started after the task is already open, then hides it on s
|
|||||||
get.mockRejectedValue(new Error("Access denied")); await refresh(); expect(current).toEqual([]);
|
get.mockRejectedValue(new Error("Access denied")); await refresh(); expect(current).toEqual([]);
|
||||||
});
|
});
|
||||||
it("clears automatic activation when the run ends even if its view is still ready", async () => {
|
it("clears automatic activation when the run ends even if its view is still ready", async () => {
|
||||||
await render(); expect(current).not.toEqual([]);
|
await render();
|
||||||
|
await vi.waitFor(async () => {
|
||||||
|
await act(async () => {});
|
||||||
|
expect(current).not.toEqual([]);
|
||||||
|
});
|
||||||
await act(async () => { client.setQueryData(queryKeys.issues.liveRuns("task"), []); });
|
await act(async () => { client.setQueryData(queryKeys.issues.liveRuns("task"), []); });
|
||||||
await render(); expect(current).toEqual([]);
|
await render();
|
||||||
|
await vi.waitFor(async () => {
|
||||||
|
await act(async () => {});
|
||||||
|
expect(current).toEqual([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
it("does not carry an old task's ready panel to a new task or disabled plugin", async () => {
|
it("does not carry an old task's ready panel to a new task or disabled plugin", async () => {
|
||||||
await render(); expect(current).not.toEqual([]);
|
await render(); expect(current).not.toEqual([]);
|
||||||
|
|||||||
Reference in new issue
Block a user