fix: preserve terminal checkout guard on current master
This commit is contained in:
parent
aee63ad604
commit
6113ed70b3
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -56,7 +56,6 @@ export {
|
|||
buildSuccessfulRunHandoffRequiredNotice,
|
||||
decideSuccessfulRunHandoff,
|
||||
findExistingFinishSuccessfulRunHandoffWake,
|
||||
isSuccessfulRunHandoffRecoveryRequiredSkip,
|
||||
isSuccessfulRunHandoffValidPathSkip,
|
||||
isSuccessfulRunHandoffRequiredNoticeBody,
|
||||
noticeMetadataReferencesRecoveryAction,
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
]));
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue