Skip to content
66 changes: 66 additions & 0 deletions server/src/__tests__/execution-lock-acquisition-helper.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import { readFile } from "node:fs/promises";
import path from "node:path";
import { ISSUE_EXECUTION_LOCK_TTL_MS, executionLockAcquisitionFields } from "../services/issues.js";

describe("execution lock acquisition helper usage", () => {
it("always schedules the exact execution-lock reaper deadline", () => {
const now = new Date("2026-08-08T03:15:00.000Z");

expect(executionLockAcquisitionFields("run-1", now)).toEqual({
executionRunId: "run-1",
executionLockedAt: now,
monitorNextCheckAt: new Date(now.getTime() + ISSUE_EXECUTION_LOCK_TTL_MS),
monitorWakeRequestedAt: null,
});
});

it("keeps an earlier validated review-monitor deadline ahead of the lock reaper", () => {
const now = new Date("2026-08-08T03:15:00.000Z");
const reviewDeadline = new Date("2026-08-08T03:17:00.000Z");

expect(executionLockAcquisitionFields("run-1", now, reviewDeadline)).toMatchObject({
monitorNextCheckAt: reviewDeadline,
});
});

it("keeps production execution lock acquisition writes centralized", async () => {
const root = process.cwd();
const productionFiles = [
"server/src/services/issues.ts",
"server/src/services/heartbeat.ts",
"server/src/services/recovery/service.ts",
"server/src/routes/issues.ts",
];

const directAcquisitions: string[] = [];
for (const file of productionFiles) {
const source = await readFile(path.join(root, file), "utf8");
source.split(/\r?\n/).forEach((line, index) => {
if (/executionLockedAt:\s*(now|new Date\()/.test(line) && !file.endsWith("issues.ts")) {
directAcquisitions.push(`${file}:${index + 1}:${line.trim()}`);
}
});
}

expect(directAcquisitions).toEqual([]);
});

it("removes scheduleMonitor option plumbing from every production acquisition path", async () => {
const root = process.cwd();
const productionFiles = [
"server/src/services/issues.ts",
"server/src/services/heartbeat.ts",
];

const optionUses: string[] = [];
for (const file of productionFiles) {
const source = await readFile(path.join(root, file), "utf8");
source.split(/\r?\n/).forEach((line, index) => {
if (/scheduleMonitor/.test(line)) optionUses.push(`${file}:${index + 1}:${line.trim()}`);
});
}

expect(optionUses).toEqual([]);
});
});
27 changes: 27 additions & 0 deletions server/src/__tests__/execution-lock-orphan-cleanup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { heartbeatService } from "../services/heartbeat.ts";
import { issueService } from "../services/issues.ts";

const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
Expand Down Expand Up @@ -90,6 +91,32 @@ describeEmbeddedPostgres("execution lock orphan cleanup", () => {
}

describe("heartbeat run finalization", () => {
it("schedules the issue monitor when checkout acquires the execution lock", async () => {
const companyId = await seedCompany();
const agentId = await seedAgent(companyId, "Coder");
const issueId = await seedIssue(companyId, {
status: "todo",
assigneeAgentId: agentId,
});
const runId = randomUUID();
await db.insert(heartbeatRuns).values({
id: runId,
companyId,
agentId,
invocationSource: "assignment",
status: "running",
startedAt: new Date(),
updatedAt: new Date(),
});

await issueService(db).checkout(issueId, agentId, ["todo"], runId);

const [issueAfter] = await db.select().from(issues).where(eq(issues.id, issueId));
expect(issueAfter?.executionLockedAt).toBeInstanceOf(Date);
expect(issueAfter?.monitorNextCheckAt).toBeInstanceOf(Date);
expect(issueAfter!.monitorNextCheckAt!.getTime()).toBeGreaterThan(issueAfter!.executionLockedAt!.getTime());
});

it("clears execution_run_id on every issue that references the finalized run, not just the run's contextSnapshot issue", async () => {
// Regression test for the "stale execution lock" bug:
//
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
await db.delete(documents);
await db.delete(issueRelations);
await db.delete(issueTreeHolds);
await db.delete(issueComments);
await db.delete(issues);
await db.delete(heartbeatRunEvents);
await db.delete(activityLog);
Expand Down
38 changes: 38 additions & 0 deletions server/src/__tests__/issue-liveness.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { classifyIssueGraphLiveness } from "../services/issue-liveness.ts";
import { hasScheduledIssueMonitorPath } from "../services/recovery/issue-graph-liveness.ts";

const companyId = "company-1";
const managerId = "manager-1";
Expand Down Expand Up @@ -46,6 +47,43 @@ const manager = agent({
const blocks = [{ companyId, blockerIssueId: blockerId, blockedIssueId: blockedId }];

describe("issue graph liveness classifier", () => {
it("counts only validated future typed review monitors, never a raw lock-reaper timestamp", () => {
const now = new Date("2026-08-08T03:15:00.000Z");
const future = new Date("2026-08-08T03:20:00.000Z").toISOString();
const expired = new Date("2026-08-08T03:10:00.000Z").toISOString();
const executionState = (monitor: Record<string, unknown>) => ({
status: "idle",
currentStageId: null,
currentStageIndex: null,
currentStageType: null,
currentParticipant: null,
returnAssignee: null,
reviewRequest: null,
completedStageIds: [],
lastDecisionId: null,
lastDecisionOutcome: null,
monitor,
changesRequestedCount: 0,
});

const scheduled = executionState({
status: "scheduled", nextCheckAt: future, lastTriggeredAt: null, attemptCount: 0,
notes: null, scheduledBy: "assignee", kind: null, serviceName: null, externalRef: null,
timeoutAt: null, maxAttempts: null, recoveryPolicy: null, clearedAt: null, clearReason: null,
});
const triggered = { ...scheduled, monitor: { ...scheduled.monitor, status: "triggered", nextCheckAt: null, lastTriggeredAt: expired } };
const cleared = { ...scheduled, monitor: { ...scheduled.monitor, status: "cleared", nextCheckAt: null, clearedAt: expired, clearReason: "manual" } };
const expiredState = { ...scheduled, monitor: { ...scheduled.monitor, timeoutAt: expired } };
const exhaustedState = { ...scheduled, monitor: { ...scheduled.monitor, maxAttempts: 1, attemptCount: 1 } };
const invalid = { ...scheduled, monitor: { ...scheduled.monitor, nextCheckAt: "not-a-date" } };

expect(hasScheduledIssueMonitorPath(issue({ status: "in_review", monitorNextCheckAt: future }), now)).toBe(false);
expect(hasScheduledIssueMonitorPath(issue({ status: "in_review", monitorNextCheckAt: future, executionState: scheduled }), now)).toBe(true);
for (const state of [triggered, cleared, expiredState, exhaustedState, invalid]) {
expect(hasScheduledIssueMonitorPath(issue({ status: "in_review", monitorNextCheckAt: future, executionState: state }), now)).toBe(false);
}
});

it("detects a PAP-1703-style blocked chain with an unassigned blocker and stable incident key", () => {
const findings = classifyIssueGraphLiveness({
issues: [
Expand Down
199 changes: 198 additions & 1 deletion server/src/__tests__/issue-stale-execution-lock-routes.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { randomUUID } from "node:crypto";
import express from "express";
import request from "supertest";
import { eq } from "drizzle-orm";
import { eq, sql } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
activityLog,
Expand Down Expand Up @@ -111,6 +111,31 @@ describeEmbeddedPostgres("stale issue execution lock routes", () => {
return { companyId, agentId, failedRunId, currentRunId };
}

async function seedStaleRunningRun(companyId: string, agentId: string) {
const staleRunId = randomUUID();
await db.insert(heartbeatRuns).values({
id: staleRunId,
companyId,
agentId,
status: "running",
invocationSource: "manual",
startedAt: new Date(Date.now() - 10 * 60 * 1000),
updatedAt: new Date(Date.now() - 10 * 60 * 1000),
});
return staleRunId;
}

async function waitForHeartbeatLockWait() {
for (let attempt = 0; attempt < 40; attempt += 1) {
const waiting = await db.execute(sql`
select 1 from pg_locks where not granted limit 1
`);
if (waiting.length > 0) return;
await new Promise((resolve) => setTimeout(resolve, 10));
}
throw new Error("release did not wait for the heartbeat row lock");
}

function agentActor(companyId: string, agentId: string, runId: string): Express.Request["actor"] {
return {
type: "agent",
Expand Down Expand Up @@ -269,6 +294,136 @@ describeEmbeddedPostgres("stale issue execution lock routes", () => {
});
});

it("allows the rightful assignee to force-release a TTL-stale running owner", async () => {
const { companyId, agentId, currentRunId } = await seedCompanyAgentAndRuns();
const staleRunId = await seedStaleRunningRun(companyId, agentId);
const issueId = randomUUID();
await db.insert(issues).values({
id: issueId,
companyId,
title: "TTL stale run release",
status: "in_progress",
priority: "high",
assigneeAgentId: agentId,
checkoutRunId: staleRunId,
executionRunId: staleRunId,
executionAgentNameKey: "codexcoder",
executionLockedAt: new Date(Date.now() - 10 * 60 * 1000),
});

const res = await request(createApp(agentActor(companyId, agentId, currentRunId)))
.post(`/api/issues/${issueId}/release`)
.send({ force: true });

expect(res.status, JSON.stringify(res.body)).toBe(200);
const row = await db
.select({
status: issues.status,
assigneeAgentId: issues.assigneeAgentId,
checkoutRunId: issues.checkoutRunId,
executionRunId: issues.executionRunId,
executionLockedAt: issues.executionLockedAt,
})
.from(issues)
.where(eq(issues.id, issueId))
.then((rows) => rows[0]);
expect(row).toEqual({
status: "todo",
assigneeAgentId: null,
checkoutRunId: null,
executionRunId: null,
executionLockedAt: null,
});
});

it("does not force-release a fresh live owner", async () => {
const { companyId, agentId, currentRunId } = await seedCompanyAgentAndRuns();
const liveOwnerRunId = randomUUID();
const issueId = randomUUID();
await db.insert(heartbeatRuns).values({
id: liveOwnerRunId,
companyId,
agentId,
status: "running",
invocationSource: "manual",
startedAt: new Date(),
updatedAt: new Date(),
});
await db.insert(issues).values({
id: issueId,
companyId,
title: "Live run force release",
status: "in_progress",
priority: "high",
assigneeAgentId: agentId,
checkoutRunId: liveOwnerRunId,
executionRunId: liveOwnerRunId,
executionAgentNameKey: "codexcoder",
executionLockedAt: new Date(),
});

const res = await request(createApp(agentActor(companyId, agentId, currentRunId)))
.post(`/api/issues/${issueId}/release`)
.send({ force: true });

expect(res.status, JSON.stringify(res.body)).toBe(409);
expect(res.body?.error).toBe("Issue run ownership conflict");

const row = await db
.select({
checkoutRunId: issues.checkoutRunId,
executionRunId: issues.executionRunId,
})
.from(issues)
.where(eq(issues.id, issueId))
.then((rows) => rows[0]);
expect(row).toEqual({
checkoutRunId: liveOwnerRunId,
executionRunId: liveOwnerRunId,
});
});

it("does not force-release when the owner refreshes while release waits for its row lock", async () => {
const { companyId, agentId, currentRunId } = await seedCompanyAgentAndRuns();
const liveOwnerRunId = await seedStaleRunningRun(companyId, agentId);
const issueId = randomUUID();
await db.insert(issues).values({
id: issueId,
companyId,
title: "Heartbeat refresh during force release",
status: "in_progress",
priority: "high",
assigneeAgentId: agentId,
checkoutRunId: liveOwnerRunId,
executionRunId: liveOwnerRunId,
executionAgentNameKey: "codexcoder",
executionLockedAt: new Date(Date.now() - 10 * 60 * 1000),
});

let response!: Promise<any>;
await db.transaction(async (tx) => {
await tx.execute(sql`select id from heartbeat_runs where id = ${liveOwnerRunId} for update`);
response = request(createApp(agentActor(companyId, agentId, currentRunId)))
.post(`/api/issues/${issueId}/release`)
.send({ force: true })
.then((res) => res);
await waitForHeartbeatLockWait();
await tx
.update(heartbeatRuns)
.set({ updatedAt: new Date() })
.where(eq(heartbeatRuns.id, liveOwnerRunId));
});
const res = await response;
expect(res.status, JSON.stringify(res.body)).toBe(409);

const row = await db
.select({ checkoutRunId: issues.checkoutRunId, executionRunId: issues.executionRunId })
.from(issues)
.where(eq(issues.id, issueId))
.then((rows) => rows[0]);
expect(row).toEqual({ checkoutRunId: liveOwnerRunId, executionRunId: liveOwnerRunId });
});

it("lets the current assignee recover a timed_out stale checkout owner during PATCH", async () => {
const { companyId, agentId, currentRunId } = await seedCompanyAgentAndRuns();
const timedOutRunId = randomUUID();
Expand Down Expand Up @@ -316,6 +471,48 @@ describeEmbeddedPostgres("stale issue execution lock routes", () => {
});
});

it("lets the current assignee adopt a TTL-stale running checkout owner during PATCH", async () => {
const { companyId, agentId, currentRunId } = await seedCompanyAgentAndRuns();
const staleRunId = await seedStaleRunningRun(companyId, agentId);
const issueId = randomUUID();
await db.insert(issues).values({
id: issueId,
companyId,
title: "TTL stale checkout lock",
status: "in_progress",
priority: "high",
assigneeAgentId: agentId,
checkoutRunId: staleRunId,
executionRunId: staleRunId,
executionAgentNameKey: "codexcoder",
executionLockedAt: new Date(Date.now() - 10 * 60 * 1000),
});
await db.update(heartbeatRuns)
.set({ contextSnapshot: { issueId } })
.where(eq(heartbeatRuns.id, currentRunId));

const res = await request(createApp(agentActor(companyId, agentId, currentRunId)))
.patch(`/api/issues/${issueId}`)
.set("X-Paperclip-Run-Id", currentRunId)
.send({ title: "Recovered TTL stale checkout lock" });

expect(res.status, JSON.stringify(res.body)).toBe(200);
const row = await db
.select({
title: issues.title,
checkoutRunId: issues.checkoutRunId,
executionRunId: issues.executionRunId,
monitorNextCheckAt: issues.monitorNextCheckAt,
})
.from(issues)
.where(eq(issues.id, issueId))
.then((rows) => rows[0]);
expect(row?.title).toBe("Recovered TTL stale checkout lock");
expect(row?.checkoutRunId).toBe(currentRunId);
expect(row?.executionRunId).toBe(currentRunId);
expect(row?.monitorNextCheckAt).toBeInstanceOf(Date);
});

it("still returns 409 when a different live checkout owner is active", async () => {
const { companyId, agentId, failedRunId } = await seedCompanyAgentAndRuns();
const liveOwnerRunId = randomUUID();
Expand Down
Loading
Loading