fix: preserve terminal checkout guard on current master

This commit is contained in:
Engineer 2026-09-11 22:23:13 -05:00
parent aee63ad604
commit 6113ed70b3
16 changed files with 118 additions and 1042 deletions

View File

@ -9,29 +9,6 @@ permissions:
pull-requests: read
jobs:
branch-freshness:
name: branch-freshness
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Verify head contains the triggering base
shell: bash
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
GH_TOKEN: ${{ github.token }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
set -euo pipefail
[[ "$BASE_SHA" =~ ^[0-9a-f]{40}$ ]]
[[ "$HEAD_SHA" =~ ^[0-9a-f]{40}$ ]]
comparison="$(curl --fail-with-body --silent --show-error \
--header "Accept: application/vnd.github+json" \
--header "Authorization: Bearer $GH_TOKEN" \
--header "X-GitHub-Api-Version: 2022-11-28" \
"https://api.github.com/repos/$GITHUB_REPOSITORY/compare/$BASE_SHA...$HEAD_SHA")"
jq --exit-status '.status == "ahead" or .status == "identical"' <<<"$comparison"
ci:
# Pin: #13300 merge — restore-only dependency caches and parallel native verification.
uses: paperclipai/paperclip/.github/workflows/pr-trusted.yml@44dde2dec42a22746a2f36b595acacc9ccfa1df6

View File

@ -15,6 +15,39 @@ import {
import { createAgentSchema } from "./agent.js";
describe("issue validators", () => {
it("rejects terminal issue statuses as checkout expectations", () => {
const agentId = "11111111-1111-4111-8111-111111111111";
expect(
checkoutIssueSchema.safeParse({
agentId,
expectedStatuses: [
"backlog",
"todo",
"in_progress",
"in_review",
"blocked",
],
}).success,
).toBe(true);
expect(
checkoutIssueSchema.safeParse({ agentId, expectedStatuses: ["done"] })
.success,
).toBe(false);
expect(
checkoutIssueSchema.safeParse({
agentId,
expectedStatuses: ["cancelled"],
}).success,
).toBe(false);
expect(
checkoutIssueSchema.safeParse({
agentId,
expectedStatuses: ["todo", "done"],
}).success,
).toBe(false);
});
it("uses the same bounded unique upload ID contract for comment and update requests", () => {
const id = "9af8228f-0be7-45ae-a104-6fbe0af6f1d3";
expect(

View File

@ -28,7 +28,6 @@ import {
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { heartbeatService } from "../services/heartbeat.ts";
import { issueService } from "../services/issues.ts";
import { runningProcesses } from "../adapters/index.ts";
const mockAdapterExecute = vi.hoisted(() =>
@ -788,194 +787,6 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
}
}, 40_000);
it("keeps a live continuation when checkout commits before run completion", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
const issueId = randomUUID();
let finishFirstRun!: () => void;
let finishContinuationRun!: () => void;
const firstRunCanFinish = new Promise<void>((resolve) => {
finishFirstRun = resolve;
});
const continuationRunCanFinish = new Promise<void>((resolve) => {
finishContinuationRun = resolve;
});
mockAdapterExecute
.mockImplementationOnce(async () => {
await firstRunCanFinish;
return {
exitCode: 0,
signal: null,
timedOut: false,
errorMessage: null,
summary: "Checkout-first run completed.",
provider: "test",
model: "test-model",
};
})
.mockImplementationOnce(async () => {
await continuationRunCanFinish;
return {
exitCode: 0,
signal: null,
timedOut: false,
errorMessage: null,
summary: "Corrective continuation completed.",
provider: "test",
model: "test-model",
};
});
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
defaultResponsibleUserId: "responsible-user",
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "CodexCoder",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {
heartbeat: {
wakeOnDemand: true,
maxConcurrentRuns: 1,
},
},
permissions: {},
});
await db.insert(issues).values({
id: issueId,
companyId,
title: "Checkout first, completion second",
status: "todo",
priority: "critical",
assigneeAgentId: agentId,
responsibleUserId: "responsible-user",
});
try {
const firstWake = await heartbeat.wakeup(agentId, {
source: "assignment",
triggerDetail: "system",
reason: "issue_assigned",
payload: { issueId },
contextSnapshot: { issueId, wakeReason: "issue_assigned" },
});
expect(firstWake).not.toBeNull();
const firstAdapterStarted = await waitForCondition(
async () => mockAdapterExecute.mock.calls.length === 1,
30_000,
);
expect(firstAdapterStarted).toBe(true);
const checkedOut = await issueService(db).checkout(
issueId,
agentId,
["todo"],
firstWake!.id,
);
expect(checkedOut).toMatchObject({
status: "in_progress",
checkoutRunId: firstWake!.id,
executionRunId: firstWake!.id,
});
await db.insert(issueComments).values({
companyId,
issueId,
authorAgentId: agentId,
authorType: "agent",
createdByRunId: firstWake!.id,
body: "Checkout committed before this run completed.",
});
finishFirstRun();
const correctiveRunStarted = await waitForCondition(async () => {
const run = await db
.select({ status: heartbeatRuns.status })
.from(heartbeatRuns)
.where(
and(
sql`${heartbeatRuns.id} <> ${firstWake!.id}`,
sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${issueId}`,
sql`${heartbeatRuns.contextSnapshot} ->> 'wakeReason' = 'finish_successful_run_handoff'`,
),
)
.then((rows) => rows[0] ?? null);
return run?.status === "running" && mockAdapterExecute.mock.calls.length === 2;
}, 30_000);
expect(correctiveRunStarted).toBe(true);
const [firstRun, correctiveRun, issueAfterCompletion] = await Promise.all([
db
.select({ status: heartbeatRuns.status })
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, firstWake!.id))
.then((rows) => rows[0] ?? null),
db
.select({ id: heartbeatRuns.id, status: heartbeatRuns.status })
.from(heartbeatRuns)
.where(
and(
sql`${heartbeatRuns.id} <> ${firstWake!.id}`,
sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${issueId}`,
sql`${heartbeatRuns.contextSnapshot} ->> 'wakeReason' = 'finish_successful_run_handoff'`,
),
)
.then((rows) => rows[0] ?? null),
db
.select({
status: issues.status,
checkoutRunId: issues.checkoutRunId,
executionRunId: issues.executionRunId,
})
.from(issues)
.where(eq(issues.id, issueId))
.then((rows) => rows[0] ?? null),
]);
expect(firstRun?.status).toBe("succeeded");
expect(correctiveRun?.status).toBe("running");
expect(issueAfterCompletion).toMatchObject({ status: "in_progress" });
expect(issueAfterCompletion?.checkoutRunId).toBe(correctiveRun?.id);
expect(issueAfterCompletion?.executionRunId).toBe(correctiveRun?.id);
await db
.update(issues)
.set({ status: "done", completedAt: new Date(), updatedAt: new Date() })
.where(eq(issues.id, issueId));
finishContinuationRun();
const correctiveRunSucceeded = await waitForCondition(async () => {
const run = await db
.select({ status: heartbeatRuns.status })
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, correctiveRun!.id))
.then((rows) => rows[0] ?? null);
return run?.status === "succeeded";
}, 30_000);
expect(correctiveRunSucceeded).toBe(true);
const finalIssue = await db
.select({ status: issues.status, executionRunId: issues.executionRunId })
.from(issues)
.where(eq(issues.id, issueId))
.then((rows) => rows[0] ?? null);
expect(finalIssue).toEqual({ status: "done", executionRunId: null });
} finally {
finishFirstRun();
finishContinuationRun();
}
}, 60_000);
it("cancels stale queued runs when issue blockers are still unresolved", async () => {
const companyId = randomUUID();
const agentId = randomUUID();

View File

@ -220,7 +220,6 @@ import {
} from "../services/hot-restart.ts";
import { secretService } from "../services/secrets.ts";
import {
FINISH_SUCCESSFUL_RUN_HANDOFF_REASON,
SUCCESSFUL_RUN_HANDOFF_EXHAUSTED_NOTICE_BODY,
SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY,
SUCCESSFUL_RUN_MISSING_STATE_REASON,
@ -5304,234 +5303,6 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
).toBe(true);
});
it("persists board recovery when a successful-run handoff is denied after the agent pauses", async () => {
const { companyId, agentId, runId, issueId } = await seedQueuedIssueRunFixture();
mockAdapterExecute.mockImplementationOnce(async () => {
await db.update(agents).set({ status: "paused" }).where(eq(agents.id, agentId));
return {
exitCode: 0,
signal: null,
timedOut: false,
errorMessage: null,
summary: "Implemented the requested repair, but did not choose a final issue state.",
provider: "test",
model: "test-model",
};
});
const heartbeat = heartbeatService(db);
await heartbeat.resumeQueuedRuns();
await waitForRunToSettle(heartbeat, runId, 5_000);
await waitForHeartbeatIdle(db, 5_000);
const handoffWakeups = await db
.select()
.from(agentWakeupRequests)
.where(and(
eq(agentWakeupRequests.agentId, agentId),
eq(agentWakeupRequests.reason, "finish_successful_run_handoff"),
));
expect(handoffWakeups).toHaveLength(0);
const recoveryAction = await waitForValue(() => db
.select()
.from(issueRecoveryActions)
.where(eq(issueRecoveryActions.sourceIssueId, issueId))
.then((rows) => rows[0] ?? null), 5_000);
expect(recoveryAction).toMatchObject({
companyId,
sourceIssueId: issueId,
kind: "missing_disposition",
cause: SUCCESSFUL_RUN_MISSING_STATE_REASON,
status: "active",
ownerType: "board",
ownerAgentId: null,
returnOwnerAgentId: agentId,
evidence: expect.objectContaining({
sourceRunId: runId,
correctiveRunId: null,
handoffDenialReason: "agent status paused is not invokable",
}),
});
const sourceIssue = await waitForValue(() => db
.select()
.from(issues)
.where(eq(issues.id, issueId))
.then((rows) => rows[0]?.status === "blocked" ? rows[0] : null), 5_000);
expect(sourceIssue).toMatchObject({ status: "blocked", assigneeAgentId: agentId });
});
it("persists board recovery when a successful-run handoff is budget-blocked", async () => {
const { companyId, agentId, runId, issueId } = await seedQueuedIssueRunFixture();
mockAdapterExecute.mockImplementationOnce(async () => {
await db.insert(budgetPolicies).values({
companyId,
scopeType: "agent",
scopeId: agentId,
metric: "billed_cents",
windowKind: "calendar_month_utc",
amount: 1,
hardStopEnabled: true,
isActive: true,
});
await db.insert(costEvents).values({
companyId,
agentId,
issueId,
provider: "test",
biller: "test",
billingType: "tokens",
model: "test-model",
costCents: 1,
occurredAt: new Date(),
});
return {
exitCode: 0,
signal: null,
timedOut: false,
errorMessage: null,
summary: "Implemented the requested repair, but did not choose a final issue state.",
provider: "test",
model: "test-model",
};
});
const heartbeat = heartbeatService(db);
await heartbeat.resumeQueuedRuns();
await waitForRunToSettle(heartbeat, runId, 5_000);
await waitForHeartbeatIdle(db, 5_000);
const handoffWakeups = await db
.select()
.from(agentWakeupRequests)
.where(and(
eq(agentWakeupRequests.agentId, agentId),
eq(agentWakeupRequests.reason, "finish_successful_run_handoff"),
));
expect(handoffWakeups).toHaveLength(0);
const recoveryAction = await waitForValue(() => db
.select()
.from(issueRecoveryActions)
.where(eq(issueRecoveryActions.sourceIssueId, issueId))
.then((rows) => rows[0] ?? null), 5_000);
expect(recoveryAction).toMatchObject({
companyId,
sourceIssueId: issueId,
kind: "missing_disposition",
cause: SUCCESSFUL_RUN_MISSING_STATE_REASON,
status: "active",
ownerType: "board",
ownerAgentId: null,
returnOwnerAgentId: agentId,
evidence: expect.objectContaining({
sourceRunId: runId,
correctiveRunId: null,
handoffDenialReason: "budget hard stop blocks corrective wake",
}),
});
const sourceIssue = await waitForValue(() => db
.select()
.from(issues)
.where(eq(issues.id, issueId))
.then((rows) => rows[0]?.status === "blocked" ? rows[0] : null), 5_000);
expect(sourceIssue).toMatchObject({ status: "blocked", assigneeAgentId: agentId });
});
it("does not publish a corrective wake after successful-run recovery blocks the source first", async () => {
const { companyId, agentId, runId, issueId } = await seedStrandedIssueFixture({
status: "in_progress",
runStatus: "succeeded",
livenessState: "advanced",
});
const heartbeat = heartbeatService(db);
const recoveryActionId = randomUUID();
const idempotencyKey = `finish_successful_run_handoff:${issueId}:${runId}:1`;
await db.transaction(async (tx) => {
await tx
.select({ id: issues.id })
.from(issues)
.where(and(eq(issues.id, issueId), eq(issues.companyId, companyId)))
.for("update");
const now = new Date("2026-03-19T00:06:00.000Z");
await tx.insert(issueRecoveryActions).values({
id: recoveryActionId,
companyId,
sourceIssueId: issueId,
kind: "missing_disposition",
status: "active",
ownerType: "board",
ownerAgentId: null,
previousOwnerAgentId: agentId,
returnOwnerAgentId: agentId,
cause: SUCCESSFUL_RUN_MISSING_STATE_REASON,
fingerprint: `source_scoped_recovery:${companyId}:${issueId}:${SUCCESSFUL_RUN_MISSING_STATE_REASON}`,
evidence: { sourceRunId: runId },
nextAction: "Choose a valid issue disposition.",
wakePolicy: { type: "board_escalation" },
attemptCount: 1,
lastAttemptAt: now,
createdAt: now,
updatedAt: now,
});
await tx
.update(issues)
.set({ status: "blocked", updatedAt: now })
.where(and(eq(issues.id, issueId), eq(issues.companyId, companyId)));
});
const correctiveWake = await heartbeat.wakeup(agentId, {
source: "automation",
triggerDetail: "system",
reason: FINISH_SUCCESSFUL_RUN_HANDOFF_REASON,
payload: {
issueId,
sourceRunId: runId,
handoffRequired: true,
handoffReason: SUCCESSFUL_RUN_MISSING_STATE_REASON,
},
contextSnapshot: {
issueId,
taskId: issueId,
wakeReason: FINISH_SUCCESSFUL_RUN_HANDOFF_REASON,
},
idempotencyKey,
requestedByActorType: "system",
requestedByActorId: "heartbeat",
});
expect(correctiveWake).toBeNull();
const [sourceIssue, recoveryAction, handoffRequests, correctiveRuns] = await Promise.all([
db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0] ?? null),
db
.select()
.from(issueRecoveryActions)
.where(eq(issueRecoveryActions.id, recoveryActionId))
.then((rows) => rows[0] ?? null),
db
.select()
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.idempotencyKey, idempotencyKey)),
db
.select()
.from(heartbeatRuns)
.where(and(
eq(heartbeatRuns.companyId, companyId),
sql`${heartbeatRuns.contextSnapshot} ->> 'wakeReason' = ${FINISH_SUCCESSFUL_RUN_HANDOFF_REASON}`,
)),
]);
expect(sourceIssue).toMatchObject({ status: "blocked", assigneeAgentId: agentId });
expect(recoveryAction).toMatchObject({ status: "active", ownerType: "board" });
expect(handoffRequests).toEqual([
expect.objectContaining({
status: "skipped",
reason: "successful_run_handoff_source_changed",
}),
]);
expect(correctiveRuns).toHaveLength(0);
});
it("requeues a missing-disposition handoff when the previous corrective wake was cancelled", async () => {
const { companyId, agentId, runId, issueId } =
await seedQueuedIssueRunFixture();

View File

@ -1,7 +1,6 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { hoistModuleGraph } from "./helpers/hoist-module-graph.js";
const logActivityMock = vi.fn();
@ -76,48 +75,49 @@ function createDbStub() {
};
}
describe("POST /companies/:companyId/invites", () => {
const routeModules = hoistModuleGraph(registerModuleMocks, async () => {
const [{ accessRoutes }, { errorHandler }] = await Promise.all([
vi.importActual<typeof import("../routes/access.js")>("../routes/access.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
]);
return { accessRoutes, errorHandler };
async function createApp() {
const [{ accessRoutes }, { errorHandler }] = await Promise.all([
import("../routes/access.js"),
import("../middleware/index.js"),
]);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = {
type: "board",
source: "local_implicit",
userId: null,
companyIds: ["company-1"],
};
next();
});
app.use(
"/api",
accessRoutes(createDbStub() as any, {
deploymentMode: "local_trusted",
deploymentExposure: "private",
bindHost: "127.0.0.1",
allowedHostnames: [],
}),
);
app.use(errorHandler);
return app;
}
function createApp() {
const { accessRoutes, errorHandler } = routeModules.value;
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = {
type: "board",
source: "local_implicit",
userId: null,
companyIds: ["company-1"],
};
next();
});
app.use(
"/api",
accessRoutes(createDbStub() as any, {
deploymentMode: "local_trusted",
deploymentExposure: "private",
bindHost: "127.0.0.1",
allowedHostnames: [],
}),
);
app.use(errorHandler);
return app;
}
describe("POST /companies/:companyId/invites", () => {
beforeEach(() => {
vi.resetModules();
vi.doUnmock("../services/index.js");
vi.doUnmock("../routes/access.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
registerModuleMocks();
vi.clearAllMocks();
logActivityMock.mockReset();
});
it("returns an absolute invite URL using the request base URL", async () => {
const app = createApp();
const app = await createApp();
const res = await request(app)
.post("/api/companies/company-1/invites")

View File

@ -18,9 +18,7 @@ import {
issueInboxArchives,
issueRecoveryActions,
issueRelations,
issueTreeHolds,
issues,
routines,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
@ -148,8 +146,6 @@ describeEmbeddedPostgres("issue recovery actions", () => {
await db.delete(agentWakeupRequests);
await db.delete(environments);
await db.delete(issueInboxArchives);
await db.delete(routines);
await db.delete(issueTreeHolds);
await db.delete(issues);
await db.delete(agentRuntimeState);
await db.delete(agents);
@ -671,364 +667,6 @@ describeEmbeddedPostgres("issue recovery actions", () => {
},
);
it.each(["terminal", "active_execution"] as const)(
"does not overwrite a concurrent %s path during successful-run handoff escalation",
async (concurrentPath) => {
const { companyId, coderId, sourceIssueId, sourceIssue } = await seedCompany();
const recovery = recoveryService(db, { enqueueWakeup: vi.fn(async () => null) });
const sourceRunId = randomUUID();
if (concurrentPath === "terminal") {
await db.update(issues).set({ status: "done", completedAt: new Date() }).where(eq(issues.id, sourceIssueId));
} else {
await seedHeartbeatRun({
companyId,
agentId: coderId,
runId: randomUUID(),
issueId: sourceIssueId,
status: "queued",
});
}
const result = await recovery.escalateStrandedAssignedIssue({
issue: sourceIssue,
previousStatus: "in_progress",
latestRun: {
id: sourceRunId,
agentId: coderId,
status: "succeeded",
error: null,
errorCode: null,
contextSnapshot: { issueId: sourceIssueId },
livenessState: "needs_followup",
},
recoveryCause: "successful_run_missing_state",
successfulRunHandoffEvidence: {
sourceRunId,
correctiveRunId: null,
missingDisposition: "clear_next_step",
handoffAttempt: 0,
maxHandoffAttempts: 1,
handoffDenialReason: "corrective wake was not durably queued",
},
});
expect(result).toBeNull();
const [currentIssue] = await db.select().from(issues).where(eq(issues.id, sourceIssueId));
expect(currentIssue?.status).toBe(concurrentPath === "terminal" ? "done" : "in_progress");
const activeActions = await db
.select()
.from(issueRecoveryActions)
.where(and(
eq(issueRecoveryActions.sourceIssueId, sourceIssueId),
eq(issueRecoveryActions.status, "active"),
));
expect(activeActions).toHaveLength(0);
},
);
it.each(["pause_hold", "routine_continuation"] as const)(
"preserves a %s that commits before successful-run recovery acquires the issue lock",
async (durablePath) => {
const { companyId, coderId, sourceIssueId, sourceIssue } = await seedCompany();
const recovery = recoveryService(db, { enqueueWakeup: vi.fn(async () => null) });
const sourceRunId = randomUUID();
let publishPath!: () => void;
const pathMayPublish = new Promise<void>((resolve) => {
publishPath = resolve;
});
let pathLocked!: () => void;
const pathHasLock = new Promise<void>((resolve) => {
pathLocked = resolve;
});
const pathPublication = db.transaction(async (tx) => {
await tx
.select({ id: issues.id })
.from(issues)
.where(eq(issues.id, sourceIssueId))
.for("update");
pathLocked();
await pathMayPublish;
if (durablePath === "pause_hold") {
await tx.insert(issueTreeHolds).values({
companyId,
rootIssueId: sourceIssueId,
mode: "pause",
status: "active",
reason: "Pause owns the next action.",
});
} else {
await tx.insert(routines).values({
companyId,
parentIssueId: sourceIssueId,
title: "Continue source issue",
assigneeAgentId: coderId,
status: "active",
});
}
});
await pathHasLock;
const escalation = recovery.escalateStrandedAssignedIssue({
issue: sourceIssue,
previousStatus: "in_progress",
latestRun: {
id: sourceRunId,
agentId: coderId,
status: "succeeded",
error: null,
errorCode: null,
contextSnapshot: { issueId: sourceIssueId },
livenessState: "needs_followup",
},
recoveryCause: "successful_run_missing_state",
successfulRunHandoffEvidence: {
sourceRunId,
correctiveRunId: null,
missingDisposition: "clear_next_step",
handoffAttempt: 0,
maxHandoffAttempts: 1,
handoffDenialReason: "corrective wake was not durably queued",
},
});
publishPath();
await pathPublication;
await expect(escalation).resolves.toBeNull();
const [currentIssue] = await db.select().from(issues).where(eq(issues.id, sourceIssueId));
expect(currentIssue?.status).toBe("in_progress");
const activeActions = await db
.select()
.from(issueRecoveryActions)
.where(and(
eq(issueRecoveryActions.sourceIssueId, sourceIssueId),
eq(issueRecoveryActions.status, "active"),
));
expect(activeActions).toHaveLength(0);
},
);
it("keeps a concurrently published process-loss retry active and resolves provisional handoff recovery", async () => {
const { companyId, coderId, sourceIssueId, sourceIssue } = await seedCompany();
const recovery = recoveryService(db, { enqueueWakeup: vi.fn(async () => null) });
const sourceRunId = randomUUID();
const retryRunId = randomUUID();
const wakeupRequestId = randomUUID();
await seedHeartbeatRun({
companyId,
agentId: coderId,
runId: sourceRunId,
issueId: sourceIssueId,
status: "succeeded",
});
let publishRetry!: () => void;
const retryMayPublish = new Promise<void>((resolve) => {
publishRetry = resolve;
});
let retryLocked!: () => void;
const retryHasLock = new Promise<void>((resolve) => {
retryLocked = resolve;
});
const retryPublication = db.transaction(async (tx) => {
await tx
.select({ id: issues.id })
.from(issues)
.where(eq(issues.id, sourceIssueId))
.for("update");
retryLocked();
await retryMayPublish;
await tx.insert(agentWakeupRequests).values({
id: wakeupRequestId,
companyId,
agentId: coderId,
source: "automation",
triggerDetail: "system",
reason: "process_lost_retry",
payload: { issueId: sourceIssueId, retryOfRunId: sourceRunId },
status: "queued",
});
await tx.insert(heartbeatRuns).values({
id: retryRunId,
companyId,
agentId: coderId,
invocationSource: "automation",
triggerDetail: "system",
status: "queued",
wakeupRequestId,
retryOfRunId: sourceRunId,
contextSnapshot: {
issueId: sourceIssueId,
wakeReason: "process_lost_retry",
retryOfRunId: sourceRunId,
},
});
await tx
.update(agentWakeupRequests)
.set({ runId: retryRunId })
.where(eq(agentWakeupRequests.id, wakeupRequestId));
await tx
.update(issues)
.set({
executionRunId: retryRunId,
executionAgentNameKey: "coder",
executionLockedAt: new Date("2026-05-13T18:01:00.000Z"),
updatedAt: new Date("2026-05-13T18:01:00.000Z"),
})
.where(eq(issues.id, sourceIssueId));
});
await retryHasLock;
const escalation = recovery.escalateStrandedAssignedIssue({
issue: sourceIssue,
previousStatus: "in_progress",
latestRun: {
id: sourceRunId,
agentId: coderId,
status: "succeeded",
error: null,
errorCode: null,
contextSnapshot: { issueId: sourceIssueId },
livenessState: "needs_followup",
},
recoveryCause: "successful_run_missing_state",
successfulRunHandoffEvidence: {
sourceRunId,
correctiveRunId: null,
missingDisposition: "clear_next_step",
handoffAttempt: 0,
maxHandoffAttempts: 1,
handoffDenialReason: "corrective wake was not durably queued",
},
});
publishRetry();
await retryPublication;
await expect(escalation).resolves.toBeNull();
const [currentIssue] = await db.select().from(issues).where(eq(issues.id, sourceIssueId));
expect(currentIssue).toMatchObject({
status: "in_progress",
executionRunId: retryRunId,
});
const activeActions = await db
.select()
.from(issueRecoveryActions)
.where(and(
eq(issueRecoveryActions.sourceIssueId, sourceIssueId),
eq(issueRecoveryActions.status, "active"),
));
expect(activeActions).toHaveLength(0);
});
it("keeps a process-loss retry that commits before non-successful recovery acquires the issue lock", async () => {
const { companyId, coderId, sourceIssueId, sourceIssue } = await seedCompany();
const recovery = recoveryService(db, { enqueueWakeup: vi.fn(async () => null) });
const sourceRunId = randomUUID();
const retryRunId = randomUUID();
const wakeupRequestId = randomUUID();
await seedHeartbeatRun({
companyId,
agentId: coderId,
runId: sourceRunId,
issueId: sourceIssueId,
status: "failed",
});
let publishRetry!: () => void;
const retryMayPublish = new Promise<void>((resolve) => {
publishRetry = resolve;
});
let retryLocked!: () => void;
const retryHasLock = new Promise<void>((resolve) => {
retryLocked = resolve;
});
const retryPublication = db.transaction(async (tx) => {
await tx
.select({ id: issues.id })
.from(issues)
.where(eq(issues.id, sourceIssueId))
.for("update");
retryLocked();
await retryMayPublish;
await tx.insert(agentWakeupRequests).values({
id: wakeupRequestId,
companyId,
agentId: coderId,
source: "automation",
triggerDetail: "system",
reason: "process_lost_retry",
payload: { issueId: sourceIssueId, retryOfRunId: sourceRunId },
status: "queued",
});
await tx.insert(heartbeatRuns).values({
id: retryRunId,
companyId,
agentId: coderId,
invocationSource: "automation",
triggerDetail: "system",
status: "queued",
wakeupRequestId,
retryOfRunId: sourceRunId,
contextSnapshot: {
issueId: sourceIssueId,
wakeReason: "process_lost_retry",
retryOfRunId: sourceRunId,
},
});
await tx
.update(agentWakeupRequests)
.set({ runId: retryRunId })
.where(eq(agentWakeupRequests.id, wakeupRequestId));
await tx
.update(issues)
.set({
executionRunId: retryRunId,
executionAgentNameKey: "coder",
executionLockedAt: new Date("2026-05-13T18:01:00.000Z"),
updatedAt: new Date("2026-05-13T18:01:00.000Z"),
})
.where(eq(issues.id, sourceIssueId));
});
await retryHasLock;
const escalation = recovery.escalateStrandedAssignedIssue({
issue: sourceIssue,
previousStatus: "in_progress",
latestRun: {
id: sourceRunId,
agentId: coderId,
status: "failed",
error: "agent process exited unexpectedly",
errorCode: "process_lost",
contextSnapshot: { issueId: sourceIssueId },
livenessState: "failed",
},
recoveryCause: "process_lost",
});
publishRetry();
await retryPublication;
await expect(escalation).resolves.toBeNull();
const [currentIssue] = await db.select().from(issues).where(eq(issues.id, sourceIssueId));
expect(currentIssue).toMatchObject({
status: "in_progress",
executionRunId: retryRunId,
});
const activeActions = await db
.select()
.from(issueRecoveryActions)
.where(and(
eq(issueRecoveryActions.sourceIssueId, sourceIssueId),
eq(issueRecoveryActions.status, "active"),
));
expect(activeActions).toHaveLength(0);
});
it("stands down while the latest run was cancelled by a board operator", async () => {
const { companyId, coderId, sourceIssueId } = await seedCompany();
await db.insert(heartbeatRuns).values({

View File

@ -449,7 +449,6 @@ import {
isExecutionForcedToKubernetes,
} from "./execution-allowlist.js";
import {
DEFAULT_MAX_SUCCESSFUL_RUN_HANDOFF_ATTEMPTS,
RECOVERY_ORIGIN_KINDS,
FINISH_SUCCESSFUL_RUN_HANDOFF_REASON,
SUCCESSFUL_RUN_MISSING_STATE_REASON,
@ -461,7 +460,6 @@ import {
decideSuccessfulRunHandoff,
findExistingFinishSuccessfulRunHandoffWake,
findExistingRunLivenessContinuationWake,
isSuccessfulRunHandoffRecoveryRequiredSkip,
isSuccessfulRunHandoffValidPathSkip,
SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY,
readContinuationAttempt,
@ -12829,11 +12827,7 @@ export function heartbeatService(
async function handleSuccessfulRunHandoff(
run: typeof heartbeatRuns.$inferSelect,
_agent: typeof agents.$inferSelect,
options: {
persistRecoveryIfStillUnqueued?: boolean;
handoffDenialReason?: string;
} = {},
agent: typeof agents.$inferSelect,
) {
if (run.status !== "succeeded") return;
const context = parseObject(run.contextSnapshot);
@ -12852,18 +12846,24 @@ export function heartbeatService(
if (goalProjection?.goal?.status !== "complete") return;
}
const [issue, currentAgent] = await Promise.all([
db
.select()
.from(issues)
.where(and(eq(issues.id, issueId), eq(issues.companyId, run.companyId)))
.then((rows) => rows[0] ?? null),
db
.select()
.from(agents)
.where(and(eq(agents.id, run.agentId), eq(agents.companyId, run.companyId)))
.then((rows) => rows[0] ?? null),
]);
const issue = await db
.select({
id: issues.id,
companyId: issues.companyId,
identifier: issues.identifier,
title: issues.title,
description: issues.description,
status: issues.status,
assigneeAgentId: issues.assigneeAgentId,
assigneeUserId: issues.assigneeUserId,
executionState: issues.executionState,
monitorNextCheckAt: issues.monitorNextCheckAt,
projectId: issues.projectId,
originKind: issues.originKind,
})
.from(issues)
.where(and(eq(issues.id, issueId), eq(issues.companyId, run.companyId)))
.then((rows) => rows[0] ?? null);
const idempotencyKey = issue
? buildFinishSuccessfulRunHandoffIdempotencyKey({
issueId: issue.id,
@ -13051,7 +13051,7 @@ export function heartbeatService(
const decision = decideSuccessfulRunHandoff({
run,
issue,
agent: currentAgent,
agent,
livenessState: run.livenessState as RunLivenessState | null,
detectedProgressSummary,
finalReport,
@ -13082,29 +13082,7 @@ export function heartbeatService(
});
}
const recoveryRequired = isSuccessfulRunHandoffRecoveryRequiredSkip(decision) ||
(options.persistRecoveryIfStillUnqueued && decision.kind === "enqueue");
if (recoveryRequired && issue) {
const handoffDenialReason = options.handoffDenialReason ??
(decision.kind === "skip" ? decision.reason : "corrective wake was not durably queued");
await recovery.escalateStrandedAssignedIssue({
issue,
previousStatus: "in_progress",
latestRun: run,
recoveryCause: SUCCESSFUL_RUN_MISSING_STATE_REASON,
successfulRunHandoffEvidence: {
sourceRunId: run.id,
correctiveRunId: null,
missingDisposition: "clear_next_step",
handoffAttempt: 0,
maxHandoffAttempts: DEFAULT_MAX_SUCCESSFUL_RUN_HANDOFF_ATTEMPTS,
handoffDenialReason,
},
});
return;
}
if (decision.kind !== "enqueue" || !issue || !currentAgent) return;
if (decision.kind !== "enqueue" || !issue) return;
if (hasUnmanagedBackgroundTaskEvidence(parseObject(run.resultJson))) {
await db
@ -13138,7 +13116,7 @@ export function heartbeatService(
await addSuccessfulRunHandoffCommentOnce({
issue,
run,
agent: currentAgent,
agent,
detectedProgressSummary:
detectedProgressSummary ??
"The run reported progress, but did not choose a next step.",

View File

@ -804,22 +804,6 @@ export function issueTreeControlService(db: Db) {
}
const { hold, members } = await db.transaction(async (tx) => {
if (input.mode === "pause") {
// Successful-run and stranded-work recovery holds these same issue
// rows while deciding whether a durable path exists. Lock every pause
// member before publishing the hold so a pause that wins first is
// visible to recovery's in-lock revalidation.
const issueIds = [...new Set(holdPreview.issues.map((issue) => issue.id))].sort();
if (issueIds.length > 0) {
await tx
.select({ id: issues.id })
.from(issues)
.where(and(eq(issues.companyId, companyId), inArray(issues.id, issueIds)))
.orderBy(asc(issues.id))
.for("update");
}
}
const [createdHold] = await tx
.insert(issueTreeHolds)
.values({

View File

@ -11213,6 +11213,16 @@ export function issueService(db: Db) {
expectedStatuses: string[],
checkoutRunId: string | null,
) => {
const terminalExpectedStatuses = expectedStatuses.filter(
(status) => status === "done" || status === "cancelled",
);
if (terminalExpectedStatuses.length > 0) {
throw unprocessable(
"Issue checkout cannot expect terminal issue statuses",
{ terminalExpectedStatuses },
);
}
const issueCompany = await db
.select({ companyId: issues.companyId })
.from(issues)
@ -11301,7 +11311,7 @@ export function issueService(db: Db) {
eq(issues.executionRunId, checkoutRunId),
)
: isNull(issues.executionRunId);
const updateIssue = (dbOrTx: DbOrTransaction) => dbOrTx
const updateIssue = (dbOrTx: Db | DbTransaction) => dbOrTx
.update(issues)
.set({
assigneeAgentId: agentId,

View File

@ -1,5 +1,5 @@
import { createHash } from "node:crypto";
import { and, eq, inArray, isNull, ne, notInArray, or, sql } from "drizzle-orm";
import { and, eq, inArray, ne, notInArray, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
agentWakeupRequests,
@ -8,11 +8,8 @@ import {
issueApprovals,
issueRelations,
issueThreadInteractions,
issueTreeHoldMembers,
issueTreeHolds,
issueWorkProducts,
issues,
routines,
} from "@paperclipai/db";
import { parseIssueExecutionState } from "../issue-execution-policy.js";
@ -87,17 +84,7 @@ export async function collectDispositionRepairSourceState(
},
): Promise<DispositionRepairSourceState> {
const issue = input.issue;
const [
blockers,
children,
interactions,
linkedApprovals,
workProducts,
activeRuns,
queuedWakes,
activePauseHolds,
activeRoutineContinuations,
] =
const [blockers, children, interactions, linkedApprovals, workProducts, activeRuns, queuedWakes] =
await Promise.all([
db
.select({ id: issues.id, status: issues.status, assigneeAgentId: issues.assigneeAgentId })
@ -192,48 +179,11 @@ export async function collectDispositionRepairSourceState(
.where(
and(
eq(agentWakeupRequests.companyId, issue.companyId),
inArray(agentWakeupRequests.status, ["queued", "claimed", "deferred_issue_execution"]),
inArray(agentWakeupRequests.status, ["queued", "deferred_issue_execution"]),
sql`${agentWakeupRequests.payload} ->> 'issueId' = ${issue.id}`,
input.excludeWakeupRequestId
? ne(agentWakeupRequests.id, input.excludeWakeupRequestId)
: sql`true`,
input.excludeRunId
? or(
isNull(agentWakeupRequests.runId),
ne(agentWakeupRequests.runId, input.excludeRunId),
)
: sql`true`,
),
),
db
.select({ id: issueTreeHolds.id, rootIssueId: issueTreeHolds.rootIssueId })
.from(issueTreeHolds)
.leftJoin(
issueTreeHoldMembers,
and(
eq(issueTreeHoldMembers.companyId, issueTreeHolds.companyId),
eq(issueTreeHoldMembers.holdId, issueTreeHolds.id),
),
)
.where(
and(
eq(issueTreeHolds.companyId, issue.companyId),
eq(issueTreeHolds.status, "active"),
eq(issueTreeHolds.mode, "pause"),
or(
eq(issueTreeHolds.rootIssueId, issue.id),
eq(issueTreeHoldMembers.issueId, issue.id),
),
),
),
db
.select({ id: routines.id })
.from(routines)
.where(
and(
eq(routines.companyId, issue.companyId),
eq(routines.parentIssueId, issue.id),
eq(routines.status, "active"),
),
),
]);
@ -245,21 +195,17 @@ export async function collectDispositionRepairSourceState(
);
const durablePathReason = issue.assigneeUserId
? "user_owner"
: activePauseHolds.length > 0
? "pause_hold"
: activeRoutineContinuations.length > 0
? "routine_continuation"
: blockers.length > 0
? "blocker"
: issue.monitorNextCheckAt && issue.monitorNextCheckAt.getTime() > Date.now()
? "monitor"
: pendingExecutionState?.status === "pending"
? "execution_stage"
: pendingInteraction
? "interaction"
: pendingApproval
? "approval"
: null;
: blockers.length > 0
? "blocker"
: issue.monitorNextCheckAt && issue.monitorNextCheckAt.getTime() > Date.now()
? "monitor"
: pendingExecutionState?.status === "pending"
? "execution_stage"
: pendingInteraction
? "interaction"
: pendingApproval
? "approval"
: null;
const durableState = {
source: {
@ -281,8 +227,6 @@ export async function collectDispositionRepairSourceState(
workProducts: workProducts
.map((row) => ({ ...row, updatedAt: row.updatedAt.toISOString() }))
.sort((a, b) => a.id.localeCompare(b.id)),
activePauseHolds: activePauseHolds.sort((a, b) => a.id.localeCompare(b.id)),
activeRoutineContinuations: activeRoutineContinuations.sort((a, b) => a.id.localeCompare(b.id)),
};
const digest = createHash("sha256").update(stableJson(durableState)).digest("hex");

View File

@ -56,7 +56,6 @@ export {
buildSuccessfulRunHandoffRequiredNotice,
decideSuccessfulRunHandoff,
findExistingFinishSuccessfulRunHandoffWake,
isSuccessfulRunHandoffRecoveryRequiredSkip,
isSuccessfulRunHandoffValidPathSkip,
isSuccessfulRunHandoffRequiredNoticeBody,
noticeMetadataReferencesRecoveryAction,

View File

@ -260,11 +260,10 @@ type StrandedPreviousStatus = "todo" | "in_progress" | "in_review";
type SuccessfulRunHandoffRecoveryEvidence = {
sourceRunId: string | null;
correctiveRunId: string | null;
correctiveRunId: string;
missingDisposition: string;
handoffAttempt: number;
maxHandoffAttempts: number;
handoffDenialReason?: string | null;
};
function compactRecoveryPresentation(title: string): IssueCommentPresentation {
@ -3759,18 +3758,7 @@ export function recoveryService(
status: "blocked",
blockedByIssueIds: blockerIds,
});
if (!transition) {
await recoveryActionsSvc.resolveActiveForIssue({
companyId: input.issue.companyId,
sourceIssueId: input.issue.id,
actionId: recoveryAction.id,
status: "resolved",
outcome: "restored",
resolutionNote: "concurrent_source_path_restored",
});
return null;
}
const { updated, blockerIds } = transition;
if (!updated) return null;
if (isProviderQuotaWait) return updated;
const sourceAssigneePreserved =
updated.assigneeAgentId === input.issue.assigneeAgentId &&

View File

@ -10,7 +10,6 @@ import {
buildSuccessfulRunHandoffRequiredNotice,
decideSuccessfulRunHandoff,
isIdempotentFinishSuccessfulRunHandoffWakeStatus,
isSuccessfulRunHandoffRecoveryRequiredSkip,
isSuccessfulRunHandoffValidPathSkip,
isPluginManagedIssueLifecycle,
isSuccessfulRunHandoffRequiredNoticeBody,
@ -318,12 +317,6 @@ describe("successful run handoff decision", () => {
expect(isSuccessfulRunHandoffValidPathSkip(decide({ budgetBlocked: true }))).toBe(false);
});
it("identifies denial-path skips that require explicit recovery", () => {
expect(isSuccessfulRunHandoffRecoveryRequiredSkip(decide({ budgetBlocked: true }))).toBe(true);
expect(isSuccessfulRunHandoffRecoveryRequiredSkip(decide({ agent: { ...agent, status: "paused" } }))).toBe(true);
expect(isSuccessfulRunHandoffRecoveryRequiredSkip(decide({ hasQueuedWake: true }))).toBe(false);
});
it("does not treat killed background-task evidence as a missing live path when a durable monitor owns the wait", () => {
expect(decide({
detectedProgressSummary: UNMANAGED_BACKGROUND_TASK_LIVENESS_REASON,
@ -572,7 +565,6 @@ describe("successful run handoff decision", () => {
latestIssueStatus: "in_progress",
latestHandoffRunStatus: "failed",
missingDisposition: "clear_next_step",
handoffDenialReason: "agent status paused is not invokable",
});
expect(notice.body).toBe(SUCCESSFUL_RUN_HANDOFF_EXHAUSTED_NOTICE_BODY);
@ -605,11 +597,6 @@ describe("successful run handoff decision", () => {
}),
expect.objectContaining({ type: "run_link", label: "Corrective handoff run" }),
expect.objectContaining({ type: "key_value", label: "Missing disposition", value: "clear_next_step" }),
expect.objectContaining({
type: "key_value",
label: "Corrective handoff outcome",
value: "agent status paused is not invokable",
}),
]),
}),
]));

View File

@ -147,15 +147,6 @@ export function isSuccessfulRunHandoffValidPathSkip(
return decision.kind === "skip" && SUCCESSFUL_RUN_HANDOFF_VALID_PATH_SKIP_REASONS.has(decision.reason);
}
export function isSuccessfulRunHandoffRecoveryRequiredSkip(
decision: SuccessfulRunHandoffDecision,
): decision is Extract<SuccessfulRunHandoffDecision, { kind: "skip" }> {
return decision.kind === "skip" && (
decision.reason === "budget hard stop blocks corrective wake" ||
decision.reason.endsWith(" is not invokable")
);
}
export function isSuccessfulRunHandoffRequiredNoticeBody(body: string) {
const trimmed = body.trim();
return trimmed === SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY ||
@ -216,7 +207,6 @@ export function buildSuccessfulRunHandoffExhaustedNotice(input: {
latestIssueStatus: string;
latestHandoffRunStatus: string;
missingDisposition: string;
handoffDenialReason?: string | null;
}): SuccessfulRunHandoffNotice {
return {
body: SUCCESSFUL_RUN_HANDOFF_EXHAUSTED_NOTICE_BODY,
@ -251,9 +241,6 @@ export function buildSuccessfulRunHandoffExhaustedNotice(input: {
keyValueRow("Latest handoff run status", input.latestHandoffRunStatus),
keyValueRow("Normalized cause", SUCCESSFUL_RUN_MISSING_STATE_REASON),
keyValueRow("Missing disposition", input.missingDisposition),
...(input.handoffDenialReason
? [keyValueRow("Corrective handoff outcome", input.handoffDenialReason)]
: []),
],
},
],

View File

@ -618,21 +618,6 @@ function routineCurrentFieldsMatch(left: RoutineRow, right: RoutineRow) {
);
}
async function lockActiveRoutineContinuationParent(
executor: Db,
input: { companyId: string; parentIssueId: string | null; status: string },
) {
if (input.status !== "active" || !input.parentIssueId) return;
// Recovery uses the parent issue row as the serialization boundary for
// durable continuation publication. Take it before an active routine is
// inserted or updated so routine-first recovery revalidation sees the path.
await executor
.select({ id: issues.id })
.from(issues)
.where(and(eq(issues.companyId, input.companyId), eq(issues.id, input.parentIssueId)))
.for("update");
}
function mapRoutineRevision(row: typeof routineRevisions.$inferSelect): RoutineRevision {
return {
...row,
@ -2210,11 +2195,6 @@ export function routineService(
}
const createdRoutine = await db.transaction(async (tx) => {
const txDb = tx as unknown as Db;
await lockActiveRoutineContinuationParent(txDb, {
companyId,
parentIssueId: input.parentIssueId ?? null,
status,
});
const [created] = await txDb
.insert(routines)
.values({
@ -2354,12 +2334,6 @@ export function routineService(
updatedByUserId: actor.userId ?? null,
};
await lockActiveRoutineContinuationParent(txDb, {
companyId: candidate.companyId,
parentIssueId: candidate.parentIssueId,
status: candidate.status,
});
const folderChanged = patch.folderId !== undefined && locked.folderId !== candidate.folderId;
if (locked.latestRevisionId && routineCurrentFieldsMatch(locked, candidate)) {
if (!folderChanged) return locked;
@ -2741,11 +2715,6 @@ export function routineService(
}
const now = new Date();
await lockActiveRoutineContinuationParent(txDb, {
companyId: locked.companyId,
parentIssueId: routineSnapshot.parentIssueId,
status: routineSnapshot.status,
});
const [restoredRoutine] = await txDb
.update(routines)
.set({

View File

@ -86,7 +86,7 @@ const p = Number(process.env.PORT);
// Even a pre-exposure checkout answered /api/health semantically; these guests
// model bind behaviour, not health behaviour.
const health = (rq, r) => { if (rq.url === "/api/health") { r.setHeader("content-type", "application/json"); r.end(JSON.stringify({ status: "ok" })); return true; } return false; };
for (const q of [p, p + 10000].filter((candidate) => candidate <= 65535)) {
for (const q of [p, p + 10000]) {
http.createServer((rq, r) => { if (health(rq, r)) return; r.statusCode = 200; r.end("ok"); }).listen(q, host);
}
setInterval(() => {}, 1000);