mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 09:41:04 +01:00
Add runtime heartbeat release gate tests
This commit is contained in:
1 parent
4b9f8165c5
commit
77bc236c2a
2 files changed
+135
No files matched your search
@@ -811,6 +811,40 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
return { companyId, agentId, issueId };
|
||||
}
|
||||
|
||||
async function seedIdleTimerAgentFixture() {
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "CodexCoder",
|
||||
role: "engineer",
|
||||
status: "idle",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {
|
||||
heartbeat: {
|
||||
enabled: true,
|
||||
intervalSec: 60,
|
||||
wakeOnDemand: true,
|
||||
skipTimerWhenNoActionableWork: true,
|
||||
},
|
||||
},
|
||||
permissions: {},
|
||||
});
|
||||
|
||||
return { companyId, agentId };
|
||||
}
|
||||
|
||||
async function expectSourceScopedStrandedRecoveryAction(input: {
|
||||
companyId: string;
|
||||
agentId: string;
|
||||
@@ -1042,6 +1076,46 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
expect(wakeup?.status).toBe("claimed");
|
||||
});
|
||||
|
||||
it("skips generic timer wakes without invoking an adapter when no assigned work is actionable", async () => {
|
||||
const { companyId, agentId } = await seedIdleTimerAgentFixture();
|
||||
const heartbeat = heartbeatService(db);
|
||||
|
||||
const run = await heartbeat.wakeup(agentId, {
|
||||
source: "timer",
|
||||
triggerDetail: "system",
|
||||
reason: "heartbeat_timer",
|
||||
requestedByActorType: "system",
|
||||
requestedByActorId: "heartbeat_scheduler",
|
||||
contextSnapshot: {
|
||||
source: "scheduler",
|
||||
reason: "interval_elapsed",
|
||||
now: "2026-03-19T00:00:00.000Z",
|
||||
},
|
||||
});
|
||||
|
||||
expect(run).toBeNull();
|
||||
expect(mockAdapterExecute).not.toHaveBeenCalled();
|
||||
|
||||
const requests = await db
|
||||
.select()
|
||||
.from(agentWakeupRequests)
|
||||
.where(eq(agentWakeupRequests.agentId, agentId));
|
||||
expect(requests).toHaveLength(1);
|
||||
expect(requests[0]).toMatchObject({
|
||||
companyId,
|
||||
source: "timer",
|
||||
reason: "heartbeat.timer.no_actionable_work",
|
||||
status: "skipped",
|
||||
error: null,
|
||||
});
|
||||
|
||||
const runs = await db
|
||||
.select()
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.agentId, agentId));
|
||||
expect(runs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("queues exactly one retry when the recorded local pid is dead", async () => {
|
||||
const { agentId, runId, issueId } = await seedRunFixture({
|
||||
agentStatus: "idle",
|
||||
|
||||
@@ -290,6 +290,67 @@ describeEmbeddedPostgres("stale issue execution lock routes", () => {
|
||||
expect(res.body?.error).toBe("Issue run ownership conflict");
|
||||
});
|
||||
|
||||
it("preserves live checkout ownership on checkout conflicts without retry side effects", async () => {
|
||||
const { companyId, agentId, currentRunId } = await seedCompanyAgentAndRuns();
|
||||
const contenderRunId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: contenderRunId,
|
||||
companyId,
|
||||
agentId,
|
||||
status: "running",
|
||||
invocationSource: "assignment",
|
||||
startedAt: new Date(),
|
||||
});
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
title: "Live checkout race",
|
||||
status: "in_progress",
|
||||
priority: "high",
|
||||
assigneeAgentId: agentId,
|
||||
checkoutRunId: currentRunId,
|
||||
executionRunId: currentRunId,
|
||||
executionAgentNameKey: "codexcoder",
|
||||
executionLockedAt: new Date(),
|
||||
});
|
||||
|
||||
const res = await request(createApp(agentActor(companyId, agentId, contenderRunId)))
|
||||
.post(`/api/issues/${issueId}/checkout`)
|
||||
.send({
|
||||
agentId,
|
||||
expectedStatuses: ["todo", "backlog", "blocked", "in_review"],
|
||||
});
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(409);
|
||||
expect(res.body).toMatchObject({
|
||||
error: "Issue checkout conflict",
|
||||
});
|
||||
|
||||
const row = await db
|
||||
.select({
|
||||
status: issues.status,
|
||||
assigneeAgentId: issues.assigneeAgentId,
|
||||
checkoutRunId: issues.checkoutRunId,
|
||||
executionRunId: issues.executionRunId,
|
||||
})
|
||||
.from(issues)
|
||||
.where(eq(issues.id, issueId))
|
||||
.then((rows) => rows[0]);
|
||||
expect(row).toEqual({
|
||||
status: "in_progress",
|
||||
assigneeAgentId: agentId,
|
||||
checkoutRunId: currentRunId,
|
||||
executionRunId: currentRunId,
|
||||
});
|
||||
|
||||
const checkoutActivity = await db
|
||||
.select()
|
||||
.from(activityLog)
|
||||
.where(eq(activityLog.action, "issue.checked_out"));
|
||||
expect(checkoutActivity).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("restricts admin force-release to board users with company access and writes an audit event", async () => {
|
||||
const { companyId, agentId, failedRunId, currentRunId } = await seedCompanyAgentAndRuns();
|
||||
const issueId = randomUUID();
|
||||
|
||||
Reference in new issue
Block a user