From 6113ed70b3fbec81f724a7eecd6e47be3ddf2df2 Mon Sep 17 00:00:00 2001 From: Engineer Date: Fri, 11 Sep 2026 22:23:13 -0500 Subject: [PATCH] fix: preserve terminal checkout guard on current master --- .github/workflows/pr.yml | 23 -- packages/shared/src/validators/issue.test.ts | 33 ++ .../heartbeat-dependency-scheduling.test.ts | 189 --------- .../heartbeat-process-recovery.test.ts | 229 ----------- .../src/__tests__/invite-create-route.test.ts | 70 ++-- .../__tests__/issue-recovery-actions.test.ts | 362 ------------------ server/src/services/heartbeat.ts | 66 ++-- server/src/services/issue-tree-control.ts | 16 - server/src/services/issues.ts | 12 +- .../services/recovery/disposition-repair.ts | 84 +--- server/src/services/recovery/index.ts | 1 - server/src/services/recovery/service.ts | 16 +- .../recovery/successful-run-handoff.test.ts | 13 - .../recovery/successful-run-handoff.ts | 13 - server/src/services/routines.ts | 31 -- .../workspace-runtime-exposure.test.ts | 2 +- 16 files changed, 118 insertions(+), 1042 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index ad8a3a6d35..f2dff59b78 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -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 diff --git a/packages/shared/src/validators/issue.test.ts b/packages/shared/src/validators/issue.test.ts index f804076907..b5ea093007 100644 --- a/packages/shared/src/validators/issue.test.ts +++ b/packages/shared/src/validators/issue.test.ts @@ -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( diff --git a/server/src/__tests__/heartbeat-dependency-scheduling.test.ts b/server/src/__tests__/heartbeat-dependency-scheduling.test.ts index b074b631f1..d7a8fe5e2b 100644 --- a/server/src/__tests__/heartbeat-dependency-scheduling.test.ts +++ b/server/src/__tests__/heartbeat-dependency-scheduling.test.ts @@ -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((resolve) => { - finishFirstRun = resolve; - }); - const continuationRunCanFinish = new Promise((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(); diff --git a/server/src/__tests__/heartbeat-process-recovery.test.ts b/server/src/__tests__/heartbeat-process-recovery.test.ts index 3e351b0f1f..f77f756285 100644 --- a/server/src/__tests__/heartbeat-process-recovery.test.ts +++ b/server/src/__tests__/heartbeat-process-recovery.test.ts @@ -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(); diff --git a/server/src/__tests__/invite-create-route.test.ts b/server/src/__tests__/invite-create-route.test.ts index ff5630b5fc..4ceedd3856 100644 --- a/server/src/__tests__/invite-create-route.test.ts +++ b/server/src/__tests__/invite-create-route.test.ts @@ -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("../routes/access.js"), - vi.importActual("../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") diff --git a/server/src/__tests__/issue-recovery-actions.test.ts b/server/src/__tests__/issue-recovery-actions.test.ts index 7397380d9c..75ea06fe50 100644 --- a/server/src/__tests__/issue-recovery-actions.test.ts +++ b/server/src/__tests__/issue-recovery-actions.test.ts @@ -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((resolve) => { - publishPath = resolve; - }); - let pathLocked!: () => void; - const pathHasLock = new Promise((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((resolve) => { - publishRetry = resolve; - }); - let retryLocked!: () => void; - const retryHasLock = new Promise((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((resolve) => { - publishRetry = resolve; - }); - let retryLocked!: () => void; - const retryHasLock = new Promise((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({ diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 6cf69cd075..6456843a17 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -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.", diff --git a/server/src/services/issue-tree-control.ts b/server/src/services/issue-tree-control.ts index afce7b6e75..c3b20dbdda 100644 --- a/server/src/services/issue-tree-control.ts +++ b/server/src/services/issue-tree-control.ts @@ -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({ diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index e5e4d176d8..a4a2703f99 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -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, diff --git a/server/src/services/recovery/disposition-repair.ts b/server/src/services/recovery/disposition-repair.ts index 5feefc7c7a..ed315f19e1 100644 --- a/server/src/services/recovery/disposition-repair.ts +++ b/server/src/services/recovery/disposition-repair.ts @@ -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 { 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"); diff --git a/server/src/services/recovery/index.ts b/server/src/services/recovery/index.ts index 52c0dac536..6d35159ed3 100644 --- a/server/src/services/recovery/index.ts +++ b/server/src/services/recovery/index.ts @@ -56,7 +56,6 @@ export { buildSuccessfulRunHandoffRequiredNotice, decideSuccessfulRunHandoff, findExistingFinishSuccessfulRunHandoffWake, - isSuccessfulRunHandoffRecoveryRequiredSkip, isSuccessfulRunHandoffValidPathSkip, isSuccessfulRunHandoffRequiredNoticeBody, noticeMetadataReferencesRecoveryAction, diff --git a/server/src/services/recovery/service.ts b/server/src/services/recovery/service.ts index 981583ad42..0361621ec1 100644 --- a/server/src/services/recovery/service.ts +++ b/server/src/services/recovery/service.ts @@ -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 && diff --git a/server/src/services/recovery/successful-run-handoff.test.ts b/server/src/services/recovery/successful-run-handoff.test.ts index 47d1683910..67baafc129 100644 --- a/server/src/services/recovery/successful-run-handoff.test.ts +++ b/server/src/services/recovery/successful-run-handoff.test.ts @@ -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", - }), ]), }), ])); diff --git a/server/src/services/recovery/successful-run-handoff.ts b/server/src/services/recovery/successful-run-handoff.ts index 10a8b5c6b5..cf33b61cf5 100644 --- a/server/src/services/recovery/successful-run-handoff.ts +++ b/server/src/services/recovery/successful-run-handoff.ts @@ -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 { - 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)] - : []), ], }, ], diff --git a/server/src/services/routines.ts b/server/src/services/routines.ts index 35fa123a25..9414d5ea45 100644 --- a/server/src/services/routines.ts +++ b/server/src/services/routines.ts @@ -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({ diff --git a/server/src/services/workspace-runtime-exposure.test.ts b/server/src/services/workspace-runtime-exposure.test.ts index 977670e7ca..9f8dfeac42 100644 --- a/server/src/services/workspace-runtime-exposure.test.ts +++ b/server/src/services/workspace-runtime-exposure.test.ts @@ -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);