mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 08:22:20 +02:00
test(heartbeat): reproduce invisible monitor dispatch deferrals
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
8193abb936
commit
3f895b8403
2 files changed
+59
-1
No files matched your search
@@ -311,6 +311,40 @@ describeEmbeddedPostgres("issue monitor scheduler", () => {
|
||||
expect((await db.select().from(agentWakeupRequests)).filter(wake => wake.reason === "issue_monitor_due")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("explains held monitor dispatch without replaying uncertain execution", async () => {
|
||||
const { companyId, issueId, agentId, nextCheckAt } = await seedFixture();
|
||||
const sourceRunId = randomUUID();
|
||||
await db.insert(heartbeatRuns).values({ id: sourceRunId, companyId, agentId,
|
||||
runtimeMode: "legacy", status: "interrupted", errorCode: "process_lost",
|
||||
finishedAt: new Date(), contextSnapshot: { issueId } });
|
||||
const [hold] = await db.insert(issueRecoveryActions).values({ companyId, sourceIssueId: issueId,
|
||||
kind: "active_run_watchdog", status: "active", ownerType: "board",
|
||||
cause: "legacy_execution_requires_reconciliation", fingerprint: sourceRunId,
|
||||
evidence: { runId: sourceRunId }, nextAction: "Inspect the interrupted execution before continuing." }).returning();
|
||||
const heartbeat = heartbeatService(db);
|
||||
const tickAt = new Date("2026-04-11T12:31:00.000Z");
|
||||
expect((await heartbeat.tickTimers(tickAt)).enqueued).toBe(0);
|
||||
expect((await heartbeat.tickTimers(new Date(tickAt.getTime() + 30_000))).enqueued).toBe(0);
|
||||
const [waiting] = await db.select().from(issues).where(eq(issues.id, issueId));
|
||||
expect(waiting.monitorNextCheckAt).toEqual(nextCheckAt);
|
||||
expect(waiting.monitorWakeRequestedAt).toBeNull();
|
||||
expect(waiting.monitorAttemptCount).toBe(0);
|
||||
expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.agentId, agentId))).toHaveLength(1);
|
||||
const diagnostics = (await db.select().from(activityLog).where(eq(activityLog.entityId, issueId)))
|
||||
.filter(row => row.action === "issue.monitor_deferred");
|
||||
expect(diagnostics).toHaveLength(1);
|
||||
expect(diagnostics[0]?.details).toMatchObject({ nextCheckAt: nextCheckAt.toISOString(),
|
||||
reason: "execution_reconciliation_required", blockerCause: "legacy_execution_requires_reconciliation", attemptCount: 0 });
|
||||
expect((await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.id, hold!.id)))[0]?.status).toBe("active");
|
||||
await db.update(issueRecoveryActions).set({ status: "resolved", resolvedAt: new Date(),
|
||||
evidence: { runId: sourceRunId, executionReconciliation: { action: "continue" } } }).where(eq(issueRecoveryActions.id, hold!.id));
|
||||
expect((await heartbeat.tickTimers(new Date(tickAt.getTime() + 60_000))).enqueued).toBe(1);
|
||||
expect((await heartbeat.tickTimers(new Date(tickAt.getTime() + 90_000))).enqueued).toBe(0);
|
||||
const [triggered] = await db.select().from(issues).where(eq(issues.id, issueId));
|
||||
expect(triggered.monitorAttemptCount).toBe(1);
|
||||
expect(triggered.monitorNextCheckAt).toBeNull();
|
||||
});
|
||||
|
||||
it("recovers a rejected native monitor call and delivers the corrected schedule once", async () => {
|
||||
const { companyId, issueId, agentId } = await seedFixture();
|
||||
const runId = randomUUID();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
agents, agentSessionGoalActions, agentTaskSessions, companies, createDb,
|
||||
activityLog, agents, agentSessionGoalActions, agentTaskSessions, companies, createDb,
|
||||
EMBEDDED_POSTGRES_TEST_TIMEOUT_MS, heartbeatRuns, issues,
|
||||
} from "@paperclipai/db";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
@@ -201,6 +201,30 @@ describe.skipIf(!postgres.supported)("heartbeat scheduling database boundary", (
|
||||
expect(await f.scheduler.tickTimers(now)).toEqual({ checked: 1, enqueued: 1, skipped: 0 });
|
||||
});
|
||||
|
||||
it("records bounded dispatch failure diagnostics while retaining the schedule", async () => {
|
||||
const f = await fixture();
|
||||
f.deps.enqueueWakeup.mockRejectedValue(new Error("PRIVATE_PROVIDER_FAILURE"));
|
||||
await f.scheduler.tickTimers(now);
|
||||
await f.scheduler.tickTimers(new Date(now.getTime() + 30_000));
|
||||
expect(await readIssue(f.issue.id)).toMatchObject({ monitorNextCheckAt: dueAt, monitorWakeRequestedAt: null, monitorAttemptCount: 0 });
|
||||
const events = await db.select().from(activityLog).where(eq(activityLog.entityId, f.issue.id));
|
||||
expect(events.filter(event => event.action === "issue.monitor_dispatch_failed")).toHaveLength(1);
|
||||
expect(events[0]?.details).toMatchObject({ reason: "monitor_dispatch_failed", nextCheckAt: dueAt.toISOString(), attemptCount: 0 });
|
||||
expect(JSON.stringify(events)).not.toContain("PRIVATE_PROVIDER_FAILURE");
|
||||
});
|
||||
|
||||
it("does not attach old dispatch diagnostics to a replacement monitor", async () => {
|
||||
const f = await fixture();
|
||||
const replacement = new Date(now.getTime() + 60_000);
|
||||
f.deps.enqueueWakeup.mockImplementationOnce(async () => {
|
||||
await db.update(issues).set({ monitorNextCheckAt: replacement, monitorWakeRequestedAt: null }).where(eq(issues.id, f.issue.id));
|
||||
return null;
|
||||
});
|
||||
await f.scheduler.tickTimers(now);
|
||||
expect(await readIssue(f.issue.id)).toMatchObject({ monitorNextCheckAt: replacement });
|
||||
expect(await db.select().from(activityLog).where(eq(activityLog.entityId, f.issue.id))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("leaves monitors alone while a native run owns the issue", async () => {
|
||||
const f = await fixture();
|
||||
const [run] = await db.insert(heartbeatRuns).values({ companyId: f.company.id, agentId: f.agent.id, nativeIssueId: f.issue.id, runtimeMode: "native", status: "running", contextSnapshot: { issueId: f.issue.id } }).returning();
|
||||
|
||||
Reference in new issue
Block a user