mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 16:02:36 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - A remote task restores its saved workspace into a sandbox before the agent starts. > - When that restore fails, the run records only `remote_workspace_operation_failed: operation=restore exit=1`. > - The run then fails as `setup_failed`. No retry starts, and a `blocked` task gets no recovery action. > - An operator cannot tell which step failed, and nobody owns the next step. > - This pull request makes the sandbox scripts name their failed step in a closed grammar, and it gives a restore failure a bounded retry or a named board recovery action. > - The benefit is a visible cause and a clear owner. Saved work stays protected. ## Linked Issues or Issue Description Refs #19152 and #19187. Those changes name copy-back (save) failures. Refs #18852 and #19195. Those changes added the bounded bootstrap retry that this change reuses. **What happened?** A board reply woke an agent on a `blocked` parent task. The run failed during setup 13 seconds later. The only run event was `remote_workspace_operation_failed: operation=restore exit=1`. The run had no workspace operation row. The task stayed `blocked`, and its recovery actions showed `active: null`. The same opaque family also appeared for workspace preparation (`exit=1` only). **Expected behavior** The run names which restore step failed, without command output or paths. A failure before provider work uses a bounded retry when a retry can help. Otherwise, and when the retry budget is spent, the board gets a named recovery action. The saved version is never changed or overwritten. **Steps to reproduce** 1. Save a remote workspace, then start a new run on a different allocation. 2. Put a folder at the published workspace path in the sandbox before restore. 3. Observe that the base code records only `operation=restore exit=1`, then `setup_failed`, with no recovery action for a `blocked` task. **Paperclip version or commit** Base: `87dfba6818`. **Deployment mode** Legacy runtime with sandbox (Daytona) remote workspace storage. ## What Changed - The prepare, repository, and restore sandbox scripts track their current stage. On failure they print `remote_workspace_<op>_failed: stage=<stage>` plus closed fields: `reason=` (the script's own snake_case refusal), `archive=` (the archive helper's fixed reason), `code=` (errno), `disk=full`, `git=` (a fixed label chosen from Git output), and the existing `exit`/`signal`/`program` fields. - The server accepts that text only when it matches a strict grammar for the same operation. Other output is dropped, as before. - Provider refusals during the restore upload and restore command are recorded by closed labels, like copy-back. - A failed saved-workspace restore raises `RemoteWorkspaceRestoreError` with the server step (`snapshot_lookup`, `snapshot_download`, `sandbox_restore`). It is retryable only when the owner re-reads the same writer, epoch, and saved version with no unsaved work, and the sandbox did not name a refusal reason. - Setup failure handling classifies a pre-dispatch legacy restore failure as `remote_workspace_restore_failed`. A retryable failure uses the existing bounded bootstrap retry. A non-retryable or exhausted failure records a board-owned recovery action with the redacted cause, when stranded-work recovery does not scan the task status (for example `blocked`). - Stranded-work recovery gets a named notice for this code. Generic continuation recovery does not grant it a second budget, and legacy reconciliation does not invent provider actions for it. - `doc/execution-semantics.md` documents the contract. ## Verification - Red: the new transport tests failed on the base code with `remote_workspace_operation_failed: operation=restore exit=1` (and the same for prepare and repository). The new completion tests failed because the run became `setup_failed` with no retry and no recovery action. - Green: `npx vitest run src/services/remote-workspace-transport.test.ts src/services/heartbeat/run-completion.test.ts src/services/remote-workspaces.integration.test.ts src/__tests__/heartbeat-retry-scheduling.test.ts src/services/legacy-execution-recovery.test.ts src/services/recovery src/services/remote-workspace-save-concurrency.test.ts src/services/native-runtime/native-workspace-finalizer-failure.test.ts src/__tests__/native-workspace-finalization-work.test.ts` passes (563 tests). - The transport tests use real sandbox commands. They prove an occupied folder reports `stage=inspect_root reason=occupied_root` and keeps its files. They prove a wrong manifest reports `stage=extract archive=manifest_mismatch`. They prove a missing branch reports `stage=checkout_branch`, and a changed origin reports `stage=verify_origin`. Forged output with a path is dropped. - The real-PostgreSQL cold-restore test proves an occupied root gives a non-retryable `sandbox_restore` failure and keeps the unknown files. - The completion tests cover retried, exhausted, refused, post-dispatch, and `in_progress` cases. - `tsc --noEmit` for the server reports no errors in the changed files. ## Risks - A retry keeps the same restore path. If the cause is deterministic but unnamed, the run uses its two retries before the board action. Named refusals skip the retries. - The board action is recorded only for statuses that stranded-work recovery does not scan, and only when no other action is active. This prevents a second recovery identity for one failure. - Script stderr grammar is new. Old sandboxes run the script that the server sends, so no runner update is necessary. - No migration, deployment, or credential change. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, through Claude Code with tool use, code execution, and browser tools for read-only staging evidence. Context window and reasoning mode are host-managed. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2588 lines
99 KiB
TypeScript
2588 lines
99 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { and, eq, inArray, sql } from "drizzle-orm";
|
|
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
costEvents,
|
|
agents,
|
|
approvals,
|
|
issueApprovals,
|
|
issueThreadInteractions,
|
|
agentRuntimeState,
|
|
agentWakeupRequests,
|
|
activityLog,
|
|
budgetPolicies,
|
|
companies,
|
|
companySkills,
|
|
createDb,
|
|
environmentLeases,
|
|
executionWorkspaces,
|
|
heartbeatRunEvents,
|
|
heartbeatRuns,
|
|
issueRelations,
|
|
issues,
|
|
projects,
|
|
} from "@paperclipai/db";
|
|
import {
|
|
getEmbeddedPostgresTestSupport,
|
|
startEmbeddedPostgresTestDatabase,
|
|
} from "./helpers/embedded-postgres.js";
|
|
import { drainHeartbeatRunsToQuiescence } from "./helpers/drain-heartbeat-runs.js";
|
|
import { registerServerAdapter, unregisterServerAdapter } from "../adapters/index.ts";
|
|
import { createPostgresRunDispatchAdapter } from "../modules/run-dispatch/adapters/postgres.js";
|
|
|
|
const mockTelemetryClient = vi.hoisted(() => ({ track: vi.fn() }));
|
|
const mockTrackAgentTaskRun = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock("../telemetry.js", () => ({
|
|
getTelemetryClient: () => mockTelemetryClient,
|
|
}));
|
|
|
|
vi.mock("@paperclipai/shared/telemetry", async () => {
|
|
const actual = await vi.importActual<typeof import("@paperclipai/shared/telemetry")>(
|
|
"@paperclipai/shared/telemetry",
|
|
);
|
|
return {
|
|
...actual,
|
|
trackAgentTaskRun: mockTrackAgentTaskRun,
|
|
};
|
|
});
|
|
|
|
// Wraps the real implementation so most tests exercise genuine transactional
|
|
// writes; a test that needs to prove a rollback overrides one call with
|
|
// `mockRejectedValueOnce` and lets every other call fall through untouched.
|
|
vi.mock("../services/heartbeat-run-events.js", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("../services/heartbeat-run-events.js")>();
|
|
return { ...actual, appendHeartbeatRunEvent: vi.fn(actual.appendHeartbeatRunEvent) };
|
|
});
|
|
|
|
import { appendHeartbeatRunEvent } from "../services/heartbeat-run-events.js";
|
|
import { subscribeCompanyLiveEvents } from "../services/live-events.js";
|
|
import {
|
|
BOUNDED_TRANSIENT_HEARTBEAT_RETRY_DELAYS_MS,
|
|
INTERACTION_CONTINUATION_INFRA_RETRY_REASON,
|
|
INTERACTION_CONTINUATION_INFRA_WAKE_REASON,
|
|
MAX_TURN_CONTINUATION_RETRY_REASON,
|
|
MAX_TURN_CONTINUATION_WAKE_REASON,
|
|
heartbeatService,
|
|
} from "../services/heartbeat.ts";
|
|
|
|
const mockedAppendHeartbeatRunEvent = vi.mocked(appendHeartbeatRunEvent);
|
|
|
|
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
|
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
|
const PROVIDER_QUOTA_TEST_ADAPTER = "provider_quota_test";
|
|
|
|
if (!embeddedPostgresSupport.supported) {
|
|
console.warn(
|
|
`Skipping embedded Postgres heartbeat retry scheduling tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
|
);
|
|
}
|
|
|
|
async function waitForRunToFinish(
|
|
heartbeat: ReturnType<typeof heartbeatService>,
|
|
runId: string,
|
|
timeoutMs = 5_000,
|
|
) {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
const run = await heartbeat.getRun(runId);
|
|
if (run && !["queued", "running"].includes(run.status)) return run;
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
}
|
|
return await heartbeat.getRun(runId);
|
|
}
|
|
|
|
describeEmbeddedPostgres("heartbeat bounded retry scheduling", () => {
|
|
let db!: ReturnType<typeof createDb>;
|
|
let heartbeat!: ReturnType<typeof heartbeatService>;
|
|
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
|
|
|
beforeAll(async () => {
|
|
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-heartbeat-retry-scheduling-");
|
|
db = createDb(tempDb.connectionString);
|
|
heartbeat = heartbeatService(db);
|
|
registerServerAdapter({
|
|
type: PROVIDER_QUOTA_TEST_ADAPTER,
|
|
execute: async () => ({
|
|
exitCode: 1,
|
|
signal: null,
|
|
timedOut: false,
|
|
errorMessage: "You've hit your session limit - resets at 4pm (America/Chicago).",
|
|
errorCode: "provider_quota",
|
|
errorFamily: "provider_quota",
|
|
executionRecovery: { kind: "bootstrap", providerWorkStarted: false },
|
|
retryNotBefore: "2030-04-22T21:00:00.000Z",
|
|
resultJson: { executionRecovery: { kind: "bootstrap", providerWorkStarted: false },
|
|
errorFamily: "provider_quota",
|
|
retryNotBefore: "2030-04-22T21:00:00.000Z",
|
|
providerQuotaRetryNotBefore: "2030-04-22T21:00:00.000Z",
|
|
},
|
|
}),
|
|
testEnvironment: async () => ({
|
|
adapterType: PROVIDER_QUOTA_TEST_ADAPTER,
|
|
status: "pass",
|
|
checks: [],
|
|
testedAt: new Date().toISOString(),
|
|
}),
|
|
});
|
|
}, 20_000);
|
|
|
|
afterEach(async () => {
|
|
// Await every in-flight background heartbeat run to quiescence before the
|
|
// cleanup deletes. heartbeat.invoke claims a run and dispatches its
|
|
// execution fire-and-forget, and that run can schedule a follow-up retry
|
|
// wakeup, so a run or wakeup can still write heartbeat_runs and issues rows
|
|
// when teardown starts. The cleanup deletes issues before heartbeat_runs, so
|
|
// a late write races the deletes and can deadlock or break a foreign key.
|
|
await drainHeartbeatRunsToQuiescence(db, heartbeat);
|
|
await cleanupRetryFixture();
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
unregisterServerAdapter(PROVIDER_QUOTA_TEST_ADAPTER);
|
|
await tempDb?.cleanup();
|
|
});
|
|
|
|
async function cleanupRetryFixture() {
|
|
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
try {
|
|
await cleanupRetryFixtureOnce();
|
|
return;
|
|
} catch (error) {
|
|
if (attempt === 4) throw error;
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
}
|
|
}
|
|
}
|
|
|
|
async function cleanupHeartbeatRunDependents() {
|
|
await db.delete(heartbeatRunEvents);
|
|
await db.delete(activityLog);
|
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
await db.delete(heartbeatRunEvents);
|
|
await db.delete(activityLog);
|
|
}
|
|
|
|
async function cleanupRetryFixtureOnce() {
|
|
await db.delete(costEvents);
|
|
await db.delete(activityLog);
|
|
await db.delete(environmentLeases);
|
|
await db.delete(issueRelations);
|
|
await db.delete(issues);
|
|
await db.delete(approvals);
|
|
await db.delete(executionWorkspaces);
|
|
await db.delete(projects);
|
|
await cleanupHeartbeatRunDependents();
|
|
await db.delete(heartbeatRuns);
|
|
await db.delete(agentWakeupRequests);
|
|
await db.delete(agentRuntimeState);
|
|
await db.delete(budgetPolicies);
|
|
await db.delete(agents);
|
|
await db.delete(companySkills);
|
|
await db.delete(companies);
|
|
}
|
|
|
|
async function seedRetryFixture(input: {
|
|
runId: string;
|
|
companyId: string;
|
|
agentId: string;
|
|
now: Date;
|
|
errorCode: string;
|
|
errorFamily?: "transient_upstream" | "provider_quota" | null;
|
|
retryNotBefore?: string | null;
|
|
scheduledRetryAttempt?: number;
|
|
resultJson?: Record<string, unknown> | null;
|
|
adapterType?: string;
|
|
agentName?: string;
|
|
}) {
|
|
const adapterType = input.adapterType ?? "codex_local";
|
|
const agentName = input.agentName ?? (adapterType === "claude_local" ? "ClaudeCoder" : "CodexCoder");
|
|
await db.insert(companies).values({
|
|
id: input.companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: `T${input.companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
defaultResponsibleUserId: "responsible-user",
|
|
});
|
|
|
|
await db.insert(agents).values({
|
|
id: input.agentId,
|
|
companyId: input.companyId,
|
|
name: agentName,
|
|
role: "engineer",
|
|
status: "active",
|
|
adapterType,
|
|
adapterConfig: {},
|
|
runtimeConfig: {
|
|
heartbeat: {
|
|
wakeOnDemand: true,
|
|
maxConcurrentRuns: 1,
|
|
},
|
|
},
|
|
permissions: {},
|
|
});
|
|
|
|
await db.insert(heartbeatRuns).values({
|
|
id: input.runId,
|
|
companyId: input.companyId,
|
|
agentId: input.agentId,
|
|
invocationSource: "assignment",
|
|
status: "failed",
|
|
error: "upstream overload",
|
|
errorCode: input.errorCode,
|
|
finishedAt: input.now,
|
|
scheduledRetryAttempt: input.scheduledRetryAttempt ?? 0,
|
|
scheduledRetryReason: input.scheduledRetryAttempt ? "transient_failure" : null,
|
|
resultJson: input.resultJson ?? { executionRecovery: { kind: "bootstrap", providerWorkStarted: false },
|
|
...(input.errorFamily ? { errorFamily: input.errorFamily } : {}),
|
|
...(input.retryNotBefore
|
|
? {
|
|
retryNotBefore: input.retryNotBefore,
|
|
transientRetryNotBefore: input.retryNotBefore,
|
|
}
|
|
: {}),
|
|
},
|
|
contextSnapshot: {
|
|
issueId: randomUUID(),
|
|
wakeReason: "issue_assigned",
|
|
},
|
|
updatedAt: input.now,
|
|
createdAt: input.now,
|
|
});
|
|
}
|
|
|
|
|
|
it("never schedules invalid provider definitions even when a caller supplies a retry policy", async () => {
|
|
const runId = randomUUID(), companyId = randomUUID(), agentId = randomUUID();
|
|
const now = new Date("2026-04-20T12:00:00.000Z");
|
|
await seedRetryFixture({ runId, companyId, agentId, now, errorCode: "provider_tool_definition_invalid",
|
|
errorFamily: "transient_upstream" });
|
|
expect(await heartbeat.scheduleBoundedRetry(runId, { now, retryReason: "transient_failure", maxAttempts: 9 }))
|
|
.toMatchObject({ outcome: "not_scheduled", reason: expect.stringContaining("Repair") });
|
|
expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.retryOfRunId, runId))).toHaveLength(0);
|
|
});
|
|
|
|
it.each(["restore_unsafe_archive", "restore_lock_timeout"])("keeps the existing retry budget for %s", async (classification) => {
|
|
const runId = randomUUID(), companyId = randomUUID(), agentId = randomUUID();
|
|
const now = new Date("2026-04-20T12:00:00.000Z");
|
|
await seedRetryFixture({ runId, companyId, agentId, now, errorCode: "workspace_restore_failed", resultJson: {
|
|
workspaceRestoreFailure: classification, conversationContinuation: "continue_conversation_v1", errorFamily: "transient_upstream",
|
|
} });
|
|
const issueId = randomUUID();
|
|
await db.insert(issues).values({ id: issueId, companyId, title: "Restore fixture", status: "in_progress", assigneeAgentId: agentId });
|
|
await db.update(heartbeatRuns).set({ contextSnapshot: { issueId, wakeReason: "issue_assigned" } }).where(eq(heartbeatRuns.id, runId));
|
|
const result = await heartbeat.scheduleBoundedRetry(runId, { now, random: () => 0 });
|
|
if (classification === "restore_unsafe_archive") {
|
|
expect(result).toMatchObject({ outcome: "not_scheduled", errorCode: "legacy_execution_requires_reconciliation" });
|
|
expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.retryOfRunId, runId))).toHaveLength(0);
|
|
} else {
|
|
expect(result.outcome).toBe("scheduled");
|
|
if (result.outcome !== "scheduled" || !result.run) throw new Error("Expected a bounded retry");
|
|
await db.update(heartbeatRuns).set({ status: "failed", errorCode: "workspace_restore_failed", scheduledRetryAttempt: 2,
|
|
resultJson: { workspaceRestoreFailure: classification, conversationContinuation: "continue_conversation_v1", errorFamily: "transient_upstream" },
|
|
}).where(eq(heartbeatRuns.id, result.run.id));
|
|
expect(await heartbeat.scheduleBoundedRetry(result.run.id, { now, random: () => 0 })).toMatchObject({ outcome: "retry_exhausted" });
|
|
}
|
|
});
|
|
|
|
it("reuses one failure successor across concurrent and repeated scheduling", async () => {
|
|
const runId = randomUUID(), companyId = randomUUID(), agentId = randomUUID();
|
|
const now = new Date("2026-04-20T12:00:00.000Z");
|
|
await seedRetryFixture({ runId, companyId, agentId, now, errorCode: "adapter_failed" });
|
|
const outcomes = await Promise.all([
|
|
heartbeat.scheduleBoundedRetry(runId, { now, random: () => 0 }),
|
|
heartbeat.scheduleBoundedRetry(runId, { now, random: () => 0 }),
|
|
]);
|
|
expect(outcomes.every((outcome) => outcome.outcome === "scheduled")).toBe(true);
|
|
const children = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.retryOfRunId, runId));
|
|
expect(children).toHaveLength(1);
|
|
await db.update(heartbeatRuns).set({ status: "failed" }).where(eq(heartbeatRuns.id, children[0]!.id));
|
|
await heartbeat.scheduleBoundedRetry(runId, { now, random: () => 0, retryReason: "execution_review_participant_recovery" });
|
|
expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.retryOfRunId, runId))).toHaveLength(1);
|
|
});
|
|
|
|
it.each([
|
|
["workspace_busy", "failureRetriesBeforeWorkspaceWait"],
|
|
["ai_connection_busy", "failureRetriesBeforeAiConnectionWait"],
|
|
])("retains the failure budget after many pre-provider %s waits", async (reason, countKey) => {
|
|
const runId = randomUUID(), companyId = randomUUID(), agentId = randomUUID();
|
|
const now = new Date("2026-04-20T12:00:00.000Z");
|
|
await seedRetryFixture({ runId, companyId, agentId, now, errorCode: "overloaded", errorFamily: "transient_upstream" });
|
|
await db.update(heartbeatRuns).set({ scheduledRetryReason: reason, scheduledRetryAttempt: 12,
|
|
contextSnapshot: { [countKey]: 1 } }).where(eq(heartbeatRuns.id, runId));
|
|
const scheduled = await heartbeat.scheduleBoundedRetry(runId, { now, random: () => 0 });
|
|
expect(scheduled).toMatchObject({ outcome: "scheduled", run: { scheduledRetryAttempt: 2, scheduledRetryReason: "transient_failure" } });
|
|
if (scheduled.outcome !== "scheduled") throw new Error("Expected a bounded retry");
|
|
await db.update(heartbeatRuns).set({ status: "failed", errorCode: "overloaded",
|
|
resultJson: { executionRecovery: { kind: "bootstrap", providerWorkStarted: false } } }).where(eq(heartbeatRuns.id, scheduled.run!.id));
|
|
expect(await heartbeat.scheduleBoundedRetry(scheduled.run!.id, { now, random: () => 0 })).toMatchObject({ outcome: "retry_exhausted" });
|
|
});
|
|
it("records pre-provider quota rejection, schedules the reset-time retry, and leaves the agent idle", async () => {
|
|
const companyId = randomUUID();
|
|
const agentId = randomUUID();
|
|
|
|
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: "Quota Test",
|
|
role: "engineer",
|
|
status: "idle",
|
|
adapterType: PROVIDER_QUOTA_TEST_ADAPTER,
|
|
adapterConfig: {},
|
|
runtimeConfig: {
|
|
heartbeat: {
|
|
wakeOnDemand: true,
|
|
maxConcurrentRuns: 1,
|
|
},
|
|
},
|
|
permissions: {},
|
|
});
|
|
|
|
const run = await heartbeat.invoke(agentId, "on_demand", {}, "manual");
|
|
expect(run).not.toBeNull();
|
|
|
|
const failedRun = await waitForRunToFinish(heartbeat, run!.id);
|
|
expect(failedRun?.status).toBe("failed");
|
|
expect(failedRun?.errorCode).toBe("provider_quota");
|
|
expect((failedRun?.resultJson as Record<string, unknown> | null)?.errorFamily).toBe("provider_quota");
|
|
|
|
await expect
|
|
.poll(
|
|
() =>
|
|
db
|
|
.select({ id: heartbeatRuns.id })
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.retryOfRunId, run!.id))
|
|
.then((rows) => rows.length),
|
|
{ timeout: 5_000, interval: 50 },
|
|
)
|
|
.toBe(1);
|
|
|
|
const retryRun = await db
|
|
.select({
|
|
id: heartbeatRuns.id,
|
|
status: heartbeatRuns.status,
|
|
scheduledRetryAt: heartbeatRuns.scheduledRetryAt,
|
|
scheduledRetryReason: heartbeatRuns.scheduledRetryReason,
|
|
contextSnapshot: heartbeatRuns.contextSnapshot,
|
|
})
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.retryOfRunId, run!.id))
|
|
.then((rows) => rows[0] ?? null);
|
|
expect(retryRun?.status).toBe("scheduled_retry");
|
|
expect(retryRun?.scheduledRetryReason).toBe("transient_failure");
|
|
expect(retryRun?.scheduledRetryAt?.toISOString()).toBe("2030-04-22T21:00:00.000Z");
|
|
expect((retryRun?.contextSnapshot as Record<string, unknown> | null)?.errorFamily).toBe("provider_quota");
|
|
expect((retryRun?.contextSnapshot as Record<string, unknown> | null)?.providerQuotaRetryNotBefore).toBe(
|
|
"2030-04-22T21:00:00.000Z",
|
|
);
|
|
expect((retryRun?.contextSnapshot as Record<string, unknown> | null)?.codexTransientFallbackMode ?? null).toBeNull();
|
|
|
|
await expect
|
|
.poll(
|
|
() =>
|
|
db
|
|
.select({ status: agents.status, errorReason: agents.errorReason })
|
|
.from(agents)
|
|
.where(eq(agents.id, agentId))
|
|
.then((rows) => rows[0] ?? null),
|
|
{ timeout: 5_000, interval: 50 },
|
|
)
|
|
.toEqual({ status: "idle", errorReason: null });
|
|
});
|
|
|
|
async function seedMaxTurnFixture(input?: {
|
|
companyId?: string;
|
|
agentId?: string;
|
|
issueId?: string;
|
|
runId?: string;
|
|
now?: Date;
|
|
scheduledRetryAttempt?: number;
|
|
runtimeConfig?: Record<string, unknown>;
|
|
issueStatus?: string;
|
|
}) {
|
|
const companyId = input?.companyId ?? randomUUID();
|
|
const agentId = input?.agentId ?? randomUUID();
|
|
const issueId = input?.issueId ?? randomUUID();
|
|
const runId = input?.runId ?? randomUUID();
|
|
const now = input?.now ?? new Date("2026-04-20T12:00:00.000Z");
|
|
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
|
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix,
|
|
requireBoardApprovalForNewAgents: false,
|
|
defaultResponsibleUserId: "responsible-user",
|
|
});
|
|
|
|
await db.insert(agents).values({
|
|
id: agentId,
|
|
companyId,
|
|
name: "ClaudeCoder",
|
|
role: "engineer",
|
|
status: "active",
|
|
adapterType: "claude_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: input?.runtimeConfig ?? {
|
|
heartbeat: {
|
|
wakeOnDemand: true,
|
|
maxConcurrentRuns: 1,
|
|
maxTurnContinuation: {
|
|
enabled: true,
|
|
maxAttempts: 2,
|
|
delayMs: 1_000,
|
|
},
|
|
},
|
|
},
|
|
permissions: {},
|
|
});
|
|
|
|
await db.insert(heartbeatRuns).values({
|
|
id: runId,
|
|
companyId,
|
|
agentId,
|
|
invocationSource: "assignment",
|
|
triggerDetail: "system",
|
|
status: "failed",
|
|
error: "Maximum turns reached",
|
|
errorCode: "adapter_failed",
|
|
finishedAt: now,
|
|
scheduledRetryAttempt: input?.scheduledRetryAttempt ?? 0,
|
|
scheduledRetryReason: input?.scheduledRetryAttempt ? MAX_TURN_CONTINUATION_RETRY_REASON : null,
|
|
resultJson: { executionRecovery: { kind: "bootstrap", providerWorkStarted: false },
|
|
stopReason: "max_turns_exhausted",
|
|
},
|
|
contextSnapshot: {
|
|
issueId,
|
|
wakeReason: "issue_assigned",
|
|
},
|
|
updatedAt: now,
|
|
createdAt: now,
|
|
});
|
|
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Continue after max turns",
|
|
status: input?.issueStatus ?? "in_progress",
|
|
priority: "medium",
|
|
responsibleUserId: "responsible-user",
|
|
assigneeAgentId: agentId,
|
|
executionRunId: runId,
|
|
executionAgentNameKey: "claudecoder",
|
|
executionLockedAt: now,
|
|
issueNumber: 1,
|
|
identifier: `${issuePrefix}-1`,
|
|
});
|
|
|
|
// Complete the circular issue/run fixture after both FK targets exist.
|
|
await db.update(heartbeatRuns)
|
|
.set({ scopeKind: "issue", issueId })
|
|
.where(eq(heartbeatRuns.id, runId));
|
|
|
|
return { companyId, agentId, issueId, runId, now };
|
|
}
|
|
|
|
it.each(["remote_workspace_prepare_timeout", "reusable_sandbox_resume_failed", "remote_workspace_restore_failed"].flatMap(errorCode =>
|
|
(["resume", "paused", "Stop", "dependency"] as const).map(action => [errorCode, action] as const)))("bounds %s bootstrap retries across restart and honors %s", async (errorCode, action) => {
|
|
const { companyId, agentId, issueId, runId, now } = await seedMaxTurnFixture();
|
|
await db.update(agents).set({ adapterType: "paperclip_runner", adapterConfig: { provider: "codex", model: "gpt-6.1-sol" } })
|
|
.where(eq(agents.id, agentId));
|
|
const resultJson = { executionRecovery: { kind: "bootstrap", providerWorkStarted: false } };
|
|
await db.update(heartbeatRuns).set({ errorCode, resultJson }).where(eq(heartbeatRuns.id, runId));
|
|
let predecessor = runId;
|
|
for (const attempt of [1, 2]) {
|
|
const restarted = heartbeatService(db);
|
|
const outcomes = await Promise.all([
|
|
restarted.scheduleBoundedRetry(predecessor, { now, random: () => 0 }),
|
|
heartbeat.scheduleBoundedRetry(predecessor, { now, random: () => 0 }),
|
|
]);
|
|
expect(outcomes.every(outcome => outcome.outcome === "scheduled")).toBe(true);
|
|
const children = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.retryOfRunId, predecessor));
|
|
expect(children).toHaveLength(1);
|
|
const child = children[0]!;
|
|
expect(child).toMatchObject({ status: "scheduled_retry", scheduledRetryAttempt: attempt, scheduledRetryReason: "transient_failure" });
|
|
expect((await db.select().from(issues).where(eq(issues.id, issueId)))[0]?.executionRunId).toBe(child.id);
|
|
if (action !== "resume") {
|
|
if (action === "paused") await db.update(agents).set({ status: "paused" }).where(eq(agents.id, agentId));
|
|
if (action === "Stop") await restarted.cancelRun(child.id);
|
|
if (action === "dependency") {
|
|
const [blocker] = await db.insert(issues).values({ companyId, title: "Required dependency", status: "todo" }).returning();
|
|
await db.insert(issueRelations).values({ companyId, issueId: blocker.id, relatedIssueId: issueId, type: "blocks" });
|
|
}
|
|
const promotion = await createPostgresRunDispatchAdapter(db).promoteOrCancelDueRetry({ companyId, runId: child.id,
|
|
now: new Date(now.getTime() + 120_000) });
|
|
expect(promotion.outcome).not.toBe("promoted");
|
|
expect((await restarted.getRun(child.id))?.status).toBe("cancelled");
|
|
return;
|
|
}
|
|
predecessor = child.id;
|
|
await db.update(heartbeatRuns).set({ status: "failed", errorCode, resultJson, finishedAt: now })
|
|
.where(eq(heartbeatRuns.id, predecessor));
|
|
}
|
|
expect(await heartbeatService(db).scheduleBoundedRetry(predecessor, { now })).toMatchObject({ outcome: "retry_exhausted" });
|
|
await heartbeatService(db).reconcileStrandedAssignedIssues();
|
|
expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, companyId))).toHaveLength(3);
|
|
});
|
|
|
|
it("bounds interrupted conversations across restarts and concurrent scheduling", async () => {
|
|
const { companyId, issueId, runId, now } = await seedMaxTurnFixture();
|
|
const resultJson = { conversationContinuation: "continue_conversation_v1" };
|
|
await db.update(heartbeatRuns).set({ status: "interrupted", errorCode: "server_shutdown_interrupted", resultJson })
|
|
.where(eq(heartbeatRuns.id, runId));
|
|
let predecessor = runId;
|
|
for (const attempt of [1, 2]) {
|
|
const restarted = heartbeatService(db);
|
|
const outcomes = await Promise.all([
|
|
restarted.scheduleBoundedRetry(predecessor, { now, random: () => 0 }),
|
|
restarted.scheduleBoundedRetry(predecessor, { now, random: () => 0 }),
|
|
]);
|
|
expect(outcomes.every(outcome => outcome.outcome === "scheduled")).toBe(true);
|
|
const children = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.retryOfRunId, predecessor));
|
|
expect(children).toHaveLength(1);
|
|
expect(children[0]).toMatchObject({ scheduledRetryAttempt: attempt });
|
|
predecessor = children[0]!.id;
|
|
await db.update(heartbeatRuns).set({ status: "interrupted", finishedAt: now, resultJson })
|
|
.where(eq(heartbeatRuns.id, predecessor));
|
|
}
|
|
expect(await heartbeatService(db).scheduleBoundedRetry(predecessor, { now }))
|
|
.toMatchObject({ outcome: "retry_exhausted" });
|
|
await heartbeatService(db).reconcileStrandedAssignedIssues();
|
|
expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, companyId))).toHaveLength(3);
|
|
// Exhaustion leaves the task available to a new explicit request.
|
|
const { getExecutionBlocker } = await import("../services/execution-blocker.js");
|
|
expect(await getExecutionBlocker(db, companyId, issueId)).toBeNull();
|
|
});
|
|
|
|
it.each(["dependency", "disabled", "reassigned"])("respects the %s gate for interrupted conversations", async gate => {
|
|
const { companyId, agentId, issueId, runId, now } = await seedMaxTurnFixture();
|
|
await db.update(heartbeatRuns).set({ status: "interrupted", errorCode: "process_lost",
|
|
resultJson: { conversationContinuation: "continue_conversation_v1" } }).where(eq(heartbeatRuns.id, runId));
|
|
if (gate === "dependency") {
|
|
const blockerId = randomUUID();
|
|
await db.insert(issues).values({ id: blockerId, companyId, title: "Required work", status: "todo" });
|
|
await db.insert(issueRelations).values({ companyId, issueId: blockerId, relatedIssueId: issueId, type: "blocks" });
|
|
} else if (gate === "disabled") {
|
|
await db.update(agents).set({ runtimeConfig: { heartbeat: { wakeOnDemand: false } } }).where(eq(agents.id, agentId));
|
|
} else {
|
|
await db.update(issues).set({ assigneeAgentId: null }).where(eq(issues.id, issueId));
|
|
}
|
|
expect(await heartbeat.scheduleBoundedRetry(runId, { now })).toMatchObject({ outcome: "not_scheduled" });
|
|
expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.retryOfRunId, runId))).toHaveLength(0);
|
|
});
|
|
|
|
it.each([
|
|
["interaction", false], ["approval", false], ["interaction", true], ["approval", true],
|
|
] as const)("waits for a pending %s before continuing (already scheduled: %s)", async (kind, alreadyScheduled) => {
|
|
const { companyId, issueId, runId, now } = await seedMaxTurnFixture();
|
|
await db.update(heartbeatRuns).set({ status: "interrupted", errorCode: "process_lost",
|
|
resultJson: { conversationContinuation: "continue_conversation_v1" } }).where(eq(heartbeatRuns.id, runId));
|
|
let retryRunId = runId;
|
|
if (alreadyScheduled) {
|
|
const scheduled = await heartbeat.scheduleBoundedRetry(runId, { now, random: () => 0 });
|
|
expect(scheduled.outcome).toBe("scheduled");
|
|
if (scheduled.outcome !== "scheduled") throw new Error("Expected a retry");
|
|
retryRunId = scheduled.run.id;
|
|
}
|
|
if (kind === "interaction") {
|
|
await db.insert(issueThreadInteractions).values({ companyId, issueId, kind: "ask_user_questions",
|
|
status: "pending", payload: { version: 1, questions: [] } });
|
|
} else {
|
|
const approvalId = randomUUID();
|
|
await db.insert(approvals).values({ id: approvalId, companyId, type: "hire_agent", status: "pending", payload: {} });
|
|
await db.insert(issueApprovals).values({ companyId, issueId, approvalId });
|
|
}
|
|
if (alreadyScheduled) {
|
|
const adapter = createPostgresRunDispatchAdapter(db);
|
|
expect(await adapter.promoteOrCancelDueRetry({ companyId, runId: retryRunId, now: new Date(now.getTime() + 60_000) }))
|
|
.toMatchObject({ outcome: "gate_suppressed", errorCode: "issue_waiting_for_response" });
|
|
const stopped = await heartbeat.getRun(retryRunId);
|
|
expect(stopped?.status).toBe("cancelled");
|
|
const { legacyExecutionNeedsReconciliation } = await import("../services/legacy-execution-recovery.js");
|
|
expect(legacyExecutionNeedsReconciliation(stopped!)).toBe(false);
|
|
} else {
|
|
expect(await heartbeat.scheduleBoundedRetry(runId, { now }))
|
|
.toMatchObject({ outcome: "not_scheduled", errorCode: "issue_waiting_for_response" });
|
|
}
|
|
});
|
|
|
|
it("schedules a retry with durable metadata and only promotes it when due", async () => {
|
|
const companyId = randomUUID();
|
|
const agentId = randomUUID();
|
|
const sourceRunId = randomUUID();
|
|
const now = new Date("2026-04-20T12:00:00.000Z");
|
|
|
|
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(heartbeatRuns).values({
|
|
id: sourceRunId,
|
|
companyId,
|
|
agentId,
|
|
invocationSource: "assignment",
|
|
status: "failed",
|
|
error: "upstream overload",
|
|
errorCode: "adapter_failed",
|
|
resultJson: { executionRecovery: { kind: "bootstrap", providerWorkStarted: false } },
|
|
finishedAt: now,
|
|
contextSnapshot: {
|
|
issueId: randomUUID(),
|
|
wakeReason: "issue_assigned",
|
|
},
|
|
updatedAt: now,
|
|
createdAt: now,
|
|
});
|
|
|
|
const scheduled = await heartbeat.scheduleBoundedRetry(sourceRunId, {
|
|
now,
|
|
random: () => 0.5,
|
|
});
|
|
|
|
expect(scheduled.outcome).toBe("scheduled");
|
|
if (scheduled.outcome !== "scheduled") return;
|
|
|
|
const expectedDueAt = new Date(now.getTime() + BOUNDED_TRANSIENT_HEARTBEAT_RETRY_DELAYS_MS[0]);
|
|
expect(scheduled.attempt).toBe(1);
|
|
expect(scheduled.dueAt.toISOString()).toBe(expectedDueAt.toISOString());
|
|
|
|
const retryRun = await db
|
|
.select()
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, scheduled.run.id))
|
|
.then((rows) => rows[0] ?? null);
|
|
|
|
expect(retryRun).toMatchObject({
|
|
status: "scheduled_retry",
|
|
retryOfRunId: sourceRunId,
|
|
scheduledRetryAttempt: 1,
|
|
scheduledRetryReason: "transient_failure",
|
|
});
|
|
expect(retryRun?.contextSnapshot as Record<string, unknown>).not.toHaveProperty("modelProfile");
|
|
expect(retryRun?.scheduledRetryAt?.toISOString()).toBe(expectedDueAt.toISOString());
|
|
|
|
const earlyPromotion = await heartbeat.promoteDueScheduledRetries(new Date(expectedDueAt.getTime() - 1));
|
|
expect(earlyPromotion).toEqual({ promoted: 0, runIds: [] });
|
|
|
|
const stillScheduled = await db
|
|
.select({ status: heartbeatRuns.status })
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, scheduled.run.id))
|
|
.then((rows) => rows[0] ?? null);
|
|
expect(stillScheduled?.status).toBe("scheduled_retry");
|
|
|
|
const duePromotion = await heartbeat.promoteDueScheduledRetries(expectedDueAt);
|
|
expect(duePromotion).toEqual({ promoted: 1, runIds: [scheduled.run.id] });
|
|
|
|
const promotedRun = await db
|
|
.select({ status: heartbeatRuns.status })
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, scheduled.run.id))
|
|
.then((rows) => rows[0] ?? null);
|
|
expect(promotedRun?.status).toBe("queued");
|
|
});
|
|
|
|
it.each([1, 2])("ACCT-01 a failed disposition repair %s receives its own first infrastructure retry", async repairAttempt => {
|
|
const f = await seedMaxTurnFixture();
|
|
const episode = { id: f.runId, attempt: repairAttempt, maxAttempts: 2 };
|
|
await db.update(heartbeatRuns).set({
|
|
scheduledRetryAttempt: repairAttempt === 1 ? 0 : repairAttempt, scheduledRetryReason: "issue_disposition_repair",
|
|
resultJson: { executionRecovery: { kind: "bootstrap", providerWorkStarted: false } },
|
|
contextSnapshot: { issueId: f.issueId, legacyDispositionEpisode: episode,
|
|
legacyDispositionSourceRunId: f.runId, dispositionRepairAttempt: repairAttempt },
|
|
}).where(eq(heartbeatRuns.id, f.runId));
|
|
const scheduled = await heartbeat.scheduleBoundedRetry(f.runId, { now: f.now, random: () => 0.5 });
|
|
expect(scheduled.outcome).toBe("scheduled");
|
|
if (scheduled.outcome !== "scheduled") throw new Error("Missing infrastructure successor");
|
|
expect.soft(scheduled.attempt).toBe(1);
|
|
expect(scheduled.run.contextSnapshot).toMatchObject({ legacyDispositionEpisode: episode, dispositionRepairAttempt: repairAttempt });
|
|
expect(scheduled.run.retryOfRunId).toBe(f.runId);
|
|
expect(scheduled.run.scheduledRetryReason).toBe("transient_failure");
|
|
const replay = await heartbeatService(db).scheduleBoundedRetry(f.runId, { now: f.now, random: () => 0.5 });
|
|
expect(replay.outcome).toBe("scheduled");
|
|
if (replay.outcome === "scheduled") expect(replay.run.id).toBe(scheduled.run.id);
|
|
});
|
|
|
|
it("ACCT-01 infrastructure failures do not spend the first productive max-turn continuation", async () => {
|
|
const f = await seedMaxTurnFixture({ scheduledRetryAttempt: 2 });
|
|
await db.update(heartbeatRuns).set({ scheduledRetryReason: "transient_failure" }).where(eq(heartbeatRuns.id, f.runId));
|
|
const scheduled = await heartbeat.scheduleBoundedRetry(f.runId, {
|
|
now: f.now, retryReason: MAX_TURN_CONTINUATION_RETRY_REASON,
|
|
wakeReason: MAX_TURN_CONTINUATION_WAKE_REASON, maxAttempts: 2, delayMs: 1000,
|
|
});
|
|
expect(scheduled).toMatchObject({ outcome: "scheduled", attempt: 1 });
|
|
});
|
|
|
|
it("ACCT-01 alternating failures, productive continuations and waits preserve both allowances after restart", async () => {
|
|
const f = await seedMaxTurnFixture();
|
|
let current = (await heartbeat.getRun(f.runId))!;
|
|
for (const [reason, attempt, failures, continuations] of [
|
|
["transient_failure", 1, 1, 0],
|
|
[MAX_TURN_CONTINUATION_RETRY_REASON, 1, 1, 1],
|
|
["workspace_busy", 1, 1, 1],
|
|
["transient_failure", 2, 2, 1],
|
|
[MAX_TURN_CONTINUATION_RETRY_REASON, 2, 2, 2],
|
|
["ai_connection_busy", 1, 2, 2],
|
|
] as const) {
|
|
// Exercise the typed terminal outcome that requests each lane.
|
|
const waiting = reason === "workspace_busy" || reason === "ai_connection_busy";
|
|
await db.update(heartbeatRuns).set({
|
|
status: waiting ? "cancelled" : "failed", errorCode: waiting ? reason : "adapter_failed",
|
|
resultJson: { ...(reason === MAX_TURN_CONTINUATION_RETRY_REASON ? { stopReason: "max_turns_exhausted" } : {}),
|
|
executionRecovery: { kind: waiting ? (reason === "workspace_busy" ? "workspace_wait" : "ai_connection_wait") : "bootstrap", providerWorkStarted: false } },
|
|
}).where(eq(heartbeatRuns.id, current.id));
|
|
const restarted = heartbeatService(db);
|
|
const result = await restarted.scheduleBoundedRetry(current.id, { now: f.now, retryReason: reason, maxAttempts: 2, delayMs: 1 });
|
|
expect(result, JSON.stringify({ reason, result })).toMatchObject({ outcome: "scheduled", attempt });
|
|
if (result.outcome !== "scheduled") throw new Error("Missing successor");
|
|
expect(result.run.contextSnapshot?.executionRetryAccounting).toEqual({ version: 1, failureRetries: failures, maxTurnContinuations: continuations });
|
|
await db.update(heartbeatRuns).set({ status: "failed", finishedAt: f.now,
|
|
resultJson: { executionRecovery: { kind: "bootstrap", providerWorkStarted: false } },
|
|
}).where(eq(heartbeatRuns.id, result.run.id));
|
|
await db.update(issues).set({ executionRunId: result.run.id }).where(eq(issues.id, f.issueId));
|
|
current = (await restarted.getRun(result.run.id))!;
|
|
}
|
|
for (const retryReason of ["transient_failure", MAX_TURN_CONTINUATION_RETRY_REASON]) {
|
|
expect(await heartbeatService(db).scheduleBoundedRetry(current.id, { now: f.now, retryReason, maxAttempts: 2, delayMs: 1 }))
|
|
.toMatchObject({ outcome: "retry_exhausted", attempt: 3 });
|
|
}
|
|
expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, f.companyId))).toHaveLength(7);
|
|
});
|
|
|
|
it.each(["silent", "comments-and-tools"])("ACCT-02 exhausted infrastructure retries remain exhausted after %s and restart", async variant => {
|
|
const f = await seedMaxTurnFixture({ scheduledRetryAttempt: BOUNDED_TRANSIENT_HEARTBEAT_RETRY_DELAYS_MS.length });
|
|
await db.update(heartbeatRuns).set({ scheduledRetryReason: "transient_failure",
|
|
resultJson: { summary: variant === "silent" ? "" : "All done. Keep going. Making progress.",
|
|
toolCallCount: variant === "silent" ? 0 : 1000,
|
|
executionRecovery: { kind: "bootstrap", providerWorkStarted: false } },
|
|
}).where(eq(heartbeatRuns.id, f.runId));
|
|
if (variant !== "silent") {
|
|
await db.insert(heartbeatRunEvents).values(Array.from({ length: 10 }, (_, n) => ({ companyId: f.companyId, runId: f.runId, agentId: f.agentId, seq: n + 1, eventType: "tool.call", stream: "system", payload: { name: "read_file", status: "completed" } })));
|
|
await db.update(heartbeatRuns).set({ nextEventSeq: 11 }).where(eq(heartbeatRuns.id, f.runId));
|
|
}
|
|
const restarted = heartbeatService(db);
|
|
const outcomes = await Promise.all([heartbeat, restarted, restarted].map(service => service.scheduleBoundedRetry(f.runId, { now: f.now })));
|
|
expect(outcomes.every(outcome => outcome.outcome === "retry_exhausted")).toBe(true);
|
|
expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, f.companyId))).toHaveLength(1);
|
|
});
|
|
|
|
it("schedules max-turn continuations with distinct retry metadata", async () => {
|
|
const { runId, now } = await seedMaxTurnFixture();
|
|
|
|
const scheduled = await heartbeat.scheduleBoundedRetry(runId, {
|
|
now,
|
|
retryReason: MAX_TURN_CONTINUATION_RETRY_REASON,
|
|
wakeReason: MAX_TURN_CONTINUATION_WAKE_REASON,
|
|
maxAttempts: 2,
|
|
delayMs: 1_000,
|
|
});
|
|
|
|
expect(scheduled.outcome).toBe("scheduled");
|
|
if (scheduled.outcome !== "scheduled") return;
|
|
expect(scheduled.attempt).toBe(1);
|
|
expect(scheduled.dueAt.toISOString()).toBe(new Date(now.getTime() + 1_000).toISOString());
|
|
|
|
const retryRun = await db
|
|
.select({
|
|
retryOfRunId: heartbeatRuns.retryOfRunId,
|
|
status: heartbeatRuns.status,
|
|
scheduledRetryAttempt: heartbeatRuns.scheduledRetryAttempt,
|
|
scheduledRetryReason: heartbeatRuns.scheduledRetryReason,
|
|
contextSnapshot: heartbeatRuns.contextSnapshot,
|
|
wakeupRequestId: heartbeatRuns.wakeupRequestId,
|
|
})
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, scheduled.run.id))
|
|
.then((rows) => rows[0] ?? null);
|
|
|
|
expect(retryRun).toMatchObject({
|
|
retryOfRunId: runId,
|
|
status: "scheduled_retry",
|
|
scheduledRetryAttempt: 1,
|
|
scheduledRetryReason: MAX_TURN_CONTINUATION_RETRY_REASON,
|
|
});
|
|
expect((retryRun?.contextSnapshot as Record<string, unknown> | null)?.wakeReason).toBe(
|
|
MAX_TURN_CONTINUATION_WAKE_REASON,
|
|
);
|
|
expect((retryRun?.contextSnapshot as Record<string, unknown> | null)?.codexTransientFallbackMode ?? null).toBeNull();
|
|
|
|
const wakeupRequest = await db
|
|
.select({ reason: agentWakeupRequests.reason, payload: agentWakeupRequests.payload })
|
|
.from(agentWakeupRequests)
|
|
.where(eq(agentWakeupRequests.id, retryRun?.wakeupRequestId ?? ""))
|
|
.then((rows) => rows[0] ?? null);
|
|
expect(wakeupRequest?.reason).toBe(MAX_TURN_CONTINUATION_WAKE_REASON);
|
|
expect(wakeupRequest?.payload).toMatchObject({
|
|
retryOfRunId: runId,
|
|
retryReason: MAX_TURN_CONTINUATION_RETRY_REASON,
|
|
scheduledRetryAttempt: 1,
|
|
});
|
|
});
|
|
|
|
it("schedules accepted interaction continuation infra retries while the issue is in_review", async () => {
|
|
const { issueId, runId, now } = await seedMaxTurnFixture({ issueStatus: "in_review" });
|
|
const interactionId = randomUUID();
|
|
|
|
await db
|
|
.update(heartbeatRuns)
|
|
.set({
|
|
error: "workspace validation failed before dispatch",
|
|
errorCode: "workspace_validation_failed",
|
|
resultJson: { executionRecovery: { kind: "bootstrap", providerWorkStarted: false },},
|
|
contextSnapshot: {
|
|
issueId,
|
|
taskId: issueId,
|
|
wakeReason: "issue_commented",
|
|
mutation: "interaction",
|
|
interactionId,
|
|
interactionKind: "request_confirmation",
|
|
interactionStatus: "accepted",
|
|
},
|
|
})
|
|
.where(eq(heartbeatRuns.id, runId));
|
|
|
|
const scheduled = await heartbeat.scheduleBoundedRetry(runId, {
|
|
now,
|
|
random: () => 0.5,
|
|
retryReason: INTERACTION_CONTINUATION_INFRA_RETRY_REASON,
|
|
wakeReason: INTERACTION_CONTINUATION_INFRA_WAKE_REASON,
|
|
maxAttempts: 3,
|
|
});
|
|
|
|
expect(scheduled.outcome).toBe("scheduled");
|
|
if (scheduled.outcome !== "scheduled") return;
|
|
expect(scheduled.attempt).toBe(1);
|
|
expect(scheduled.maxAttempts).toBe(3);
|
|
|
|
const retryRun = await db
|
|
.select({
|
|
retryOfRunId: heartbeatRuns.retryOfRunId,
|
|
status: heartbeatRuns.status,
|
|
scheduledRetryAttempt: heartbeatRuns.scheduledRetryAttempt,
|
|
scheduledRetryReason: heartbeatRuns.scheduledRetryReason,
|
|
contextSnapshot: heartbeatRuns.contextSnapshot,
|
|
wakeupRequestId: heartbeatRuns.wakeupRequestId,
|
|
})
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, scheduled.run.id))
|
|
.then((rows) => rows[0] ?? null);
|
|
|
|
expect(retryRun).toMatchObject({
|
|
retryOfRunId: runId,
|
|
status: "scheduled_retry",
|
|
scheduledRetryAttempt: 1,
|
|
scheduledRetryReason: INTERACTION_CONTINUATION_INFRA_RETRY_REASON,
|
|
});
|
|
expect(retryRun?.contextSnapshot).toMatchObject({
|
|
issueId,
|
|
interactionId,
|
|
interactionStatus: "accepted",
|
|
retryReason: INTERACTION_CONTINUATION_INFRA_RETRY_REASON,
|
|
wakeReason: INTERACTION_CONTINUATION_INFRA_WAKE_REASON,
|
|
scheduledRetryAttempt: 1,
|
|
});
|
|
|
|
const wakeupRequest = await db
|
|
.select({ reason: agentWakeupRequests.reason, payload: agentWakeupRequests.payload })
|
|
.from(agentWakeupRequests)
|
|
.where(eq(agentWakeupRequests.id, retryRun?.wakeupRequestId ?? ""))
|
|
.then((rows) => rows[0] ?? null);
|
|
expect(wakeupRequest?.reason).toBe(INTERACTION_CONTINUATION_INFRA_WAKE_REASON);
|
|
expect(wakeupRequest?.payload).toMatchObject({
|
|
issueId,
|
|
interactionId,
|
|
retryOfRunId: runId,
|
|
retryReason: INTERACTION_CONTINUATION_INFRA_RETRY_REASON,
|
|
scheduledRetryAttempt: 1,
|
|
});
|
|
|
|
const issue = await db
|
|
.select({ executionRunId: issues.executionRunId })
|
|
.from(issues)
|
|
.where(eq(issues.id, issueId))
|
|
.then((rows) => rows[0] ?? null);
|
|
expect(issue?.executionRunId).toBe(scheduled.run.id);
|
|
});
|
|
|
|
it("coalesces duplicate accepted interaction continuation infra retry schedules", async () => {
|
|
const { issueId, runId, now } = await seedMaxTurnFixture({ issueStatus: "in_review" });
|
|
const interactionId = randomUUID();
|
|
await db
|
|
.update(heartbeatRuns)
|
|
.set({
|
|
error: "workspace validation failed before dispatch",
|
|
errorCode: "workspace_validation_failed",
|
|
resultJson: { executionRecovery: { kind: "bootstrap", providerWorkStarted: false },},
|
|
contextSnapshot: {
|
|
issueId,
|
|
taskId: issueId,
|
|
wakeReason: "issue_commented",
|
|
mutation: "interaction",
|
|
interactionId,
|
|
interactionKind: "request_confirmation",
|
|
interactionStatus: "accepted",
|
|
},
|
|
})
|
|
.where(eq(heartbeatRuns.id, runId));
|
|
|
|
const retryOptions = {
|
|
now,
|
|
retryReason: INTERACTION_CONTINUATION_INFRA_RETRY_REASON,
|
|
wakeReason: INTERACTION_CONTINUATION_INFRA_WAKE_REASON,
|
|
maxAttempts: 3,
|
|
};
|
|
const [first, second] = await Promise.all([
|
|
heartbeat.scheduleBoundedRetry(runId, retryOptions),
|
|
heartbeat.scheduleBoundedRetry(runId, retryOptions),
|
|
]);
|
|
|
|
expect(first.outcome).toBe("scheduled");
|
|
expect(second.outcome).toBe("scheduled");
|
|
if (first.outcome !== "scheduled" || second.outcome !== "scheduled") return;
|
|
expect(new Set([first.run.id, second.run.id]).size).toBe(1);
|
|
|
|
const retryRuns = await db
|
|
.select({ id: heartbeatRuns.id, wakeupRequestId: heartbeatRuns.wakeupRequestId })
|
|
.from(heartbeatRuns)
|
|
.where(and(
|
|
eq(heartbeatRuns.retryOfRunId, runId),
|
|
eq(heartbeatRuns.scheduledRetryReason, INTERACTION_CONTINUATION_INFRA_RETRY_REASON),
|
|
eq(heartbeatRuns.scheduledRetryAttempt, 1),
|
|
));
|
|
expect(retryRuns).toHaveLength(1);
|
|
|
|
const wakeups = await db
|
|
.select({
|
|
id: agentWakeupRequests.id,
|
|
coalescedCount: agentWakeupRequests.coalescedCount,
|
|
idempotencyKey: agentWakeupRequests.idempotencyKey,
|
|
})
|
|
.from(agentWakeupRequests)
|
|
.where(eq(agentWakeupRequests.reason, INTERACTION_CONTINUATION_INFRA_WAKE_REASON));
|
|
expect(wakeups).toHaveLength(1);
|
|
expect(wakeups[0]).toMatchObject({
|
|
id: retryRuns[0]?.wakeupRequestId,
|
|
coalescedCount: 1,
|
|
});
|
|
expect(wakeups[0]?.idempotencyKey).toContain(`:${issueId}:${runId}:1`);
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "renamed branch",
|
|
workspaceValidation: (workspaceId: string) => ({
|
|
reason: "git_worktree_branch_incoherence",
|
|
fingerprint: "workspace_incoherence:v1:sha256:renamed",
|
|
executionWorkspaceId: workspaceId,
|
|
expectedBranch: "stale-plan-approval-workspace",
|
|
actualBranch: "feat/skill-studio-test-runs",
|
|
cleanliness: "clean",
|
|
}),
|
|
},
|
|
{
|
|
name: "dirty worktree",
|
|
workspaceValidation: (workspaceId: string) => ({
|
|
reason: "git_worktree_branch_incoherence",
|
|
fingerprint: "workspace_incoherence:v1:sha256:dirty",
|
|
executionWorkspaceId: workspaceId,
|
|
expectedBranch: "stale-plan-approval-workspace",
|
|
actualBranch: "feat/skill-studio-test-runs",
|
|
cleanliness: "dirty",
|
|
safeRepair: {
|
|
eligible: false,
|
|
attempted: false,
|
|
succeeded: false,
|
|
reason: "worktree is not clean",
|
|
},
|
|
}),
|
|
},
|
|
])("quarantines a failed $name workspace before scheduling the accepted interaction retry", async ({ workspaceValidation }) => {
|
|
const { companyId, agentId, issueId, runId, now } = await seedMaxTurnFixture({ issueStatus: "in_review" });
|
|
const projectId = randomUUID();
|
|
const executionWorkspaceId = randomUUID();
|
|
const validation = workspaceValidation(executionWorkspaceId);
|
|
|
|
await db.insert(projects).values({
|
|
id: projectId,
|
|
companyId,
|
|
name: "Paperclip App",
|
|
status: "in_progress",
|
|
});
|
|
await db.insert(executionWorkspaces).values({
|
|
id: executionWorkspaceId,
|
|
companyId,
|
|
projectId,
|
|
sourceIssueId: issueId,
|
|
mode: "isolated_workspace",
|
|
strategyType: "git_worktree",
|
|
name: "stale-plan-approval-workspace",
|
|
status: "active",
|
|
cwd: "/workspace/stale-plan-approval-workspace",
|
|
baseRef: "origin/master",
|
|
branchName: "stale-plan-approval-workspace",
|
|
providerType: "git_worktree",
|
|
providerRef: "/workspace/stale-plan-approval-workspace",
|
|
metadata: { existing: true },
|
|
});
|
|
await db
|
|
.update(issues)
|
|
.set({
|
|
projectId,
|
|
executionWorkspaceId,
|
|
executionWorkspacePreference: "reuse_existing",
|
|
executionWorkspaceSettings: { mode: "isolated_workspace" },
|
|
})
|
|
.where(eq(issues.id, issueId));
|
|
|
|
const interactionId = randomUUID();
|
|
await db
|
|
.update(heartbeatRuns)
|
|
.set({
|
|
error: "workspace validation failed before dispatch",
|
|
errorCode: "workspace_validation_failed",
|
|
resultJson: { executionRecovery: { kind: "bootstrap", providerWorkStarted: false }, workspaceValidation: validation },
|
|
contextSnapshot: {
|
|
issueId,
|
|
taskId: issueId,
|
|
wakeReason: "issue_commented",
|
|
mutation: "interaction",
|
|
interactionId,
|
|
interactionKind: "request_confirmation",
|
|
interactionStatus: "accepted",
|
|
},
|
|
})
|
|
.where(eq(heartbeatRuns.id, runId));
|
|
|
|
const scheduled = await heartbeat.scheduleBoundedRetry(runId, {
|
|
now,
|
|
random: () => 0.5,
|
|
retryReason: INTERACTION_CONTINUATION_INFRA_RETRY_REASON,
|
|
wakeReason: INTERACTION_CONTINUATION_INFRA_WAKE_REASON,
|
|
maxAttempts: 3,
|
|
});
|
|
|
|
expect(scheduled.outcome).toBe("scheduled");
|
|
if (scheduled.outcome !== "scheduled") return;
|
|
|
|
const issue = await db
|
|
.select({
|
|
executionRunId: issues.executionRunId,
|
|
executionWorkspaceId: issues.executionWorkspaceId,
|
|
executionWorkspacePreference: issues.executionWorkspacePreference,
|
|
executionWorkspaceSettings: issues.executionWorkspaceSettings,
|
|
})
|
|
.from(issues)
|
|
.where(eq(issues.id, issueId))
|
|
.then((rows) => rows[0] ?? null);
|
|
expect(issue).toMatchObject({
|
|
executionRunId: scheduled.run.id,
|
|
executionWorkspaceId: null,
|
|
executionWorkspacePreference: null,
|
|
executionWorkspaceSettings: { mode: "isolated_workspace" },
|
|
});
|
|
|
|
const workspace = await db
|
|
.select({
|
|
status: executionWorkspaces.status,
|
|
closedAt: executionWorkspaces.closedAt,
|
|
cleanupEligibleAt: executionWorkspaces.cleanupEligibleAt,
|
|
cleanupReason: executionWorkspaces.cleanupReason,
|
|
metadata: executionWorkspaces.metadata,
|
|
})
|
|
.from(executionWorkspaces)
|
|
.where(eq(executionWorkspaces.id, executionWorkspaceId))
|
|
.then((rows) => rows[0] ?? null);
|
|
expect(workspace).toMatchObject({
|
|
status: "archived",
|
|
cleanupEligibleAt: null,
|
|
cleanupReason: "workspace_validation_failed",
|
|
});
|
|
expect(workspace?.closedAt?.toISOString()).toBe(now.toISOString());
|
|
expect(workspace?.metadata).toMatchObject({
|
|
existing: true,
|
|
workspaceValidationQuarantine: {
|
|
reason: "workspace_validation_failed",
|
|
retryReason: INTERACTION_CONTINUATION_INFRA_RETRY_REASON,
|
|
sourceRunId: runId,
|
|
retryRunId: scheduled.run.id,
|
|
issueId,
|
|
sourceIssueId: issueId,
|
|
workspaceValidation: validation,
|
|
},
|
|
});
|
|
|
|
const retryRun = await db
|
|
.select({ contextSnapshot: heartbeatRuns.contextSnapshot })
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, scheduled.run.id))
|
|
.then((rows) => rows[0] ?? null);
|
|
expect(retryRun?.contextSnapshot).toMatchObject({
|
|
workspaceValidationRecovery: {
|
|
strategy: "quarantine_failed_workspace_and_retry_clean",
|
|
sourceRunId: runId,
|
|
reason: "git_worktree_branch_incoherence",
|
|
fingerprint: validation.fingerprint,
|
|
failedExecutionWorkspaceId: executionWorkspaceId,
|
|
},
|
|
});
|
|
|
|
const activity = await db
|
|
.select({ action: activityLog.action, entityId: activityLog.entityId, details: activityLog.details })
|
|
.from(activityLog)
|
|
.where(and(
|
|
eq(activityLog.companyId, companyId),
|
|
eq(activityLog.action, "execution_workspace.workspace_validation_quarantined"),
|
|
))
|
|
.then((rows) => rows[0] ?? null);
|
|
expect(activity).toMatchObject({
|
|
action: "execution_workspace.workspace_validation_quarantined",
|
|
entityId: executionWorkspaceId,
|
|
details: expect.objectContaining({
|
|
retryRunId: scheduled.run.id,
|
|
workspaceValidation: validation,
|
|
}),
|
|
});
|
|
|
|
const agent = await db
|
|
.select({ id: agents.id })
|
|
.from(agents)
|
|
.where(eq(agents.id, agentId))
|
|
.then((rows) => rows[0] ?? null);
|
|
expect(agent?.id).toBe(agentId);
|
|
});
|
|
|
|
it("does not quarantine another issue's workspace when validation payload is stale", async () => {
|
|
const { companyId, issueId, runId, now } = await seedMaxTurnFixture({ issueStatus: "in_review" });
|
|
const projectId = randomUUID();
|
|
const currentWorkspaceId = randomUUID();
|
|
const foreignIssueId = randomUUID();
|
|
const foreignWorkspaceId = randomUUID();
|
|
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
|
const validation = {
|
|
reason: "git_worktree_branch_incoherence",
|
|
fingerprint: "workspace_incoherence:v1:sha256:stale",
|
|
executionWorkspaceId: foreignWorkspaceId,
|
|
expectedBranch: "current-issue-branch",
|
|
actualBranch: "foreign-issue-branch",
|
|
cleanliness: "clean",
|
|
};
|
|
|
|
await db.insert(projects).values({
|
|
id: projectId,
|
|
companyId,
|
|
name: "Paperclip App",
|
|
status: "in_progress",
|
|
});
|
|
await db.insert(issues).values({
|
|
id: foreignIssueId,
|
|
companyId,
|
|
title: "Other active issue",
|
|
status: "in_progress",
|
|
priority: "medium",
|
|
responsibleUserId: "responsible-user",
|
|
issueNumber: 2,
|
|
identifier: `${issuePrefix}-2`,
|
|
});
|
|
await db.insert(executionWorkspaces).values([
|
|
{
|
|
id: currentWorkspaceId,
|
|
companyId,
|
|
projectId,
|
|
sourceIssueId: issueId,
|
|
mode: "isolated_workspace",
|
|
strategyType: "git_worktree",
|
|
name: "current-issue-branch",
|
|
status: "active",
|
|
cwd: "/workspace/current-issue-branch",
|
|
baseRef: "origin/master",
|
|
branchName: "current-issue-branch",
|
|
providerType: "git_worktree",
|
|
providerRef: "/workspace/current-issue-branch",
|
|
metadata: { current: true },
|
|
},
|
|
{
|
|
id: foreignWorkspaceId,
|
|
companyId,
|
|
projectId,
|
|
sourceIssueId: foreignIssueId,
|
|
mode: "isolated_workspace",
|
|
strategyType: "git_worktree",
|
|
name: "foreign-issue-branch",
|
|
status: "active",
|
|
cwd: "/workspace/foreign-issue-branch",
|
|
baseRef: "origin/master",
|
|
branchName: "foreign-issue-branch",
|
|
providerType: "git_worktree",
|
|
providerRef: "/workspace/foreign-issue-branch",
|
|
metadata: { foreign: true },
|
|
},
|
|
]);
|
|
await db
|
|
.update(issues)
|
|
.set({
|
|
projectId,
|
|
executionWorkspaceId: foreignWorkspaceId,
|
|
executionWorkspacePreference: "reuse_existing",
|
|
executionWorkspaceSettings: { mode: "isolated_workspace" },
|
|
})
|
|
.where(eq(issues.id, issueId));
|
|
|
|
const interactionId = randomUUID();
|
|
await db
|
|
.update(heartbeatRuns)
|
|
.set({
|
|
error: "workspace validation failed before dispatch",
|
|
errorCode: "workspace_validation_failed",
|
|
resultJson: { executionRecovery: { kind: "bootstrap", providerWorkStarted: false }, workspaceValidation: validation },
|
|
contextSnapshot: {
|
|
issueId,
|
|
taskId: issueId,
|
|
wakeReason: "issue_commented",
|
|
mutation: "interaction",
|
|
interactionId,
|
|
interactionKind: "request_confirmation",
|
|
interactionStatus: "accepted",
|
|
},
|
|
})
|
|
.where(eq(heartbeatRuns.id, runId));
|
|
|
|
const scheduled = await heartbeat.scheduleBoundedRetry(runId, {
|
|
now,
|
|
random: () => 0.5,
|
|
retryReason: INTERACTION_CONTINUATION_INFRA_RETRY_REASON,
|
|
wakeReason: INTERACTION_CONTINUATION_INFRA_WAKE_REASON,
|
|
maxAttempts: 3,
|
|
});
|
|
|
|
expect(scheduled.outcome).toBe("scheduled");
|
|
if (scheduled.outcome !== "scheduled") return;
|
|
|
|
const issue = await db
|
|
.select({
|
|
executionRunId: issues.executionRunId,
|
|
executionWorkspaceId: issues.executionWorkspaceId,
|
|
executionWorkspacePreference: issues.executionWorkspacePreference,
|
|
})
|
|
.from(issues)
|
|
.where(eq(issues.id, issueId))
|
|
.then((rows) => rows[0] ?? null);
|
|
expect(issue).toMatchObject({
|
|
executionRunId: scheduled.run.id,
|
|
executionWorkspaceId: foreignWorkspaceId,
|
|
executionWorkspacePreference: "reuse_existing",
|
|
});
|
|
|
|
const workspaces = await db
|
|
.select({ id: executionWorkspaces.id, status: executionWorkspaces.status, metadata: executionWorkspaces.metadata })
|
|
.from(executionWorkspaces)
|
|
.where(inArray(executionWorkspaces.id, [currentWorkspaceId, foreignWorkspaceId]));
|
|
expect(workspaces).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ id: currentWorkspaceId, status: "active", metadata: expect.objectContaining({ current: true }) }),
|
|
expect.objectContaining({ id: foreignWorkspaceId, status: "active", metadata: expect.objectContaining({ foreign: true }) }),
|
|
]));
|
|
|
|
const activity = await db
|
|
.select({ id: activityLog.id })
|
|
.from(activityLog)
|
|
.where(and(
|
|
eq(activityLog.companyId, companyId),
|
|
eq(activityLog.action, "execution_workspace.workspace_validation_quarantined"),
|
|
));
|
|
expect(activity).toHaveLength(0);
|
|
});
|
|
|
|
it("does not quarantine an owned workspace that is no longer attached to the issue", async () => {
|
|
const { companyId, issueId, runId, now } = await seedMaxTurnFixture({ issueStatus: "in_review" });
|
|
const projectId = randomUUID();
|
|
const staleWorkspaceId = randomUUID();
|
|
const currentWorkspaceId = randomUUID();
|
|
const validation = {
|
|
reason: "git_worktree_branch_incoherence",
|
|
fingerprint: "workspace_incoherence:v1:sha256:stale-owned",
|
|
executionWorkspaceId: staleWorkspaceId,
|
|
expectedBranch: "old-plan-approval-workspace",
|
|
actualBranch: "current-plan-approval-workspace",
|
|
cleanliness: "clean",
|
|
};
|
|
|
|
await db.insert(projects).values({
|
|
id: projectId,
|
|
companyId,
|
|
name: "Paperclip App",
|
|
status: "in_progress",
|
|
});
|
|
await db.insert(executionWorkspaces).values([
|
|
{
|
|
id: staleWorkspaceId,
|
|
companyId,
|
|
projectId,
|
|
sourceIssueId: issueId,
|
|
mode: "isolated_workspace",
|
|
strategyType: "git_worktree",
|
|
name: "old-plan-approval-workspace",
|
|
status: "active",
|
|
cwd: "/workspace/old-plan-approval-workspace",
|
|
baseRef: "origin/master",
|
|
branchName: "old-plan-approval-workspace",
|
|
providerType: "git_worktree",
|
|
providerRef: "/workspace/old-plan-approval-workspace",
|
|
metadata: { stale: true },
|
|
},
|
|
{
|
|
id: currentWorkspaceId,
|
|
companyId,
|
|
projectId,
|
|
sourceIssueId: issueId,
|
|
mode: "isolated_workspace",
|
|
strategyType: "git_worktree",
|
|
name: "current-plan-approval-workspace",
|
|
status: "active",
|
|
cwd: "/workspace/current-plan-approval-workspace",
|
|
baseRef: "origin/master",
|
|
branchName: "current-plan-approval-workspace",
|
|
providerType: "git_worktree",
|
|
providerRef: "/workspace/current-plan-approval-workspace",
|
|
metadata: { current: true },
|
|
},
|
|
]);
|
|
await db
|
|
.update(issues)
|
|
.set({
|
|
projectId,
|
|
executionWorkspaceId: currentWorkspaceId,
|
|
executionWorkspacePreference: "reuse_existing",
|
|
executionWorkspaceSettings: { mode: "isolated_workspace" },
|
|
})
|
|
.where(eq(issues.id, issueId));
|
|
|
|
const interactionId = randomUUID();
|
|
await db
|
|
.update(heartbeatRuns)
|
|
.set({
|
|
error: "workspace validation failed before dispatch",
|
|
errorCode: "workspace_validation_failed",
|
|
resultJson: { executionRecovery: { kind: "bootstrap", providerWorkStarted: false }, workspaceValidation: validation },
|
|
contextSnapshot: {
|
|
issueId,
|
|
taskId: issueId,
|
|
wakeReason: "issue_commented",
|
|
mutation: "interaction",
|
|
interactionId,
|
|
interactionKind: "request_confirmation",
|
|
interactionStatus: "accepted",
|
|
},
|
|
})
|
|
.where(eq(heartbeatRuns.id, runId));
|
|
|
|
const scheduled = await heartbeat.scheduleBoundedRetry(runId, {
|
|
now,
|
|
random: () => 0.5,
|
|
retryReason: INTERACTION_CONTINUATION_INFRA_RETRY_REASON,
|
|
wakeReason: INTERACTION_CONTINUATION_INFRA_WAKE_REASON,
|
|
maxAttempts: 3,
|
|
});
|
|
|
|
expect(scheduled.outcome).toBe("scheduled");
|
|
if (scheduled.outcome !== "scheduled") return;
|
|
|
|
const issue = await db
|
|
.select({
|
|
executionRunId: issues.executionRunId,
|
|
executionWorkspaceId: issues.executionWorkspaceId,
|
|
executionWorkspacePreference: issues.executionWorkspacePreference,
|
|
})
|
|
.from(issues)
|
|
.where(eq(issues.id, issueId))
|
|
.then((rows) => rows[0] ?? null);
|
|
expect(issue).toMatchObject({
|
|
executionRunId: scheduled.run.id,
|
|
executionWorkspaceId: currentWorkspaceId,
|
|
executionWorkspacePreference: "reuse_existing",
|
|
});
|
|
|
|
const workspaces = await db
|
|
.select({ id: executionWorkspaces.id, status: executionWorkspaces.status, metadata: executionWorkspaces.metadata })
|
|
.from(executionWorkspaces)
|
|
.where(inArray(executionWorkspaces.id, [staleWorkspaceId, currentWorkspaceId]));
|
|
expect(workspaces).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ id: staleWorkspaceId, status: "active", metadata: expect.objectContaining({ stale: true }) }),
|
|
expect.objectContaining({ id: currentWorkspaceId, status: "active", metadata: expect.objectContaining({ current: true }) }),
|
|
]));
|
|
|
|
const activity = await db
|
|
.select({ id: activityLog.id })
|
|
.from(activityLog)
|
|
.where(and(
|
|
eq(activityLog.companyId, companyId),
|
|
eq(activityLog.action, "execution_workspace.workspace_validation_quarantined"),
|
|
));
|
|
expect(activity).toHaveLength(0);
|
|
});
|
|
|
|
it("does not schedule accepted interaction continuation infra retries after terminal issue status", async () => {
|
|
const { issueId, runId, now } = await seedMaxTurnFixture({ issueStatus: "done" });
|
|
|
|
await db
|
|
.update(heartbeatRuns)
|
|
.set({
|
|
error: "workspace validation failed before dispatch",
|
|
errorCode: "workspace_validation_failed",
|
|
resultJson: { executionRecovery: { kind: "bootstrap", providerWorkStarted: false },},
|
|
contextSnapshot: {
|
|
issueId,
|
|
taskId: issueId,
|
|
wakeReason: "issue_commented",
|
|
mutation: "interaction",
|
|
interactionId: randomUUID(),
|
|
interactionKind: "request_confirmation",
|
|
interactionStatus: "accepted",
|
|
},
|
|
})
|
|
.where(eq(heartbeatRuns.id, runId));
|
|
|
|
const scheduled = await heartbeat.scheduleBoundedRetry(runId, {
|
|
now,
|
|
retryReason: INTERACTION_CONTINUATION_INFRA_RETRY_REASON,
|
|
wakeReason: INTERACTION_CONTINUATION_INFRA_WAKE_REASON,
|
|
maxAttempts: 3,
|
|
});
|
|
|
|
expect(scheduled).toMatchObject({
|
|
outcome: "not_scheduled",
|
|
errorCode: "issue_terminal_status",
|
|
issueId,
|
|
});
|
|
});
|
|
|
|
it("coalesces duplicate max-turn continuation schedules for the same source run and attempt", async () => {
|
|
const { issueId, runId, now } = await seedMaxTurnFixture();
|
|
const retryOptions = {
|
|
now,
|
|
retryReason: MAX_TURN_CONTINUATION_RETRY_REASON,
|
|
wakeReason: MAX_TURN_CONTINUATION_WAKE_REASON,
|
|
maxAttempts: 2,
|
|
delayMs: 1_000,
|
|
};
|
|
|
|
const [first, second] = await Promise.all([
|
|
heartbeat.scheduleBoundedRetry(runId, retryOptions),
|
|
heartbeat.scheduleBoundedRetry(runId, retryOptions),
|
|
]);
|
|
|
|
expect(first.outcome).toBe("scheduled");
|
|
expect(second.outcome).toBe("scheduled");
|
|
if (first.outcome !== "scheduled" || second.outcome !== "scheduled") return;
|
|
|
|
expect(new Set([first.run.id, second.run.id]).size).toBe(1);
|
|
|
|
const retryRuns = await db
|
|
.select({
|
|
id: heartbeatRuns.id,
|
|
wakeupRequestId: heartbeatRuns.wakeupRequestId,
|
|
})
|
|
.from(heartbeatRuns)
|
|
.where(
|
|
and(
|
|
eq(heartbeatRuns.retryOfRunId, runId),
|
|
eq(heartbeatRuns.scheduledRetryReason, MAX_TURN_CONTINUATION_RETRY_REASON),
|
|
eq(heartbeatRuns.scheduledRetryAttempt, 1),
|
|
),
|
|
);
|
|
expect(retryRuns).toHaveLength(1);
|
|
|
|
const wakeups = await db
|
|
.select({
|
|
id: agentWakeupRequests.id,
|
|
coalescedCount: agentWakeupRequests.coalescedCount,
|
|
idempotencyKey: agentWakeupRequests.idempotencyKey,
|
|
})
|
|
.from(agentWakeupRequests)
|
|
.where(eq(agentWakeupRequests.reason, MAX_TURN_CONTINUATION_WAKE_REASON));
|
|
expect(wakeups).toHaveLength(1);
|
|
expect(wakeups[0]).toMatchObject({
|
|
id: retryRuns[0]?.wakeupRequestId,
|
|
coalescedCount: 1,
|
|
});
|
|
expect(wakeups[0]?.idempotencyKey).toContain(`:${issueId}:${runId}:1`);
|
|
|
|
const issue = await db
|
|
.select({ executionRunId: issues.executionRunId })
|
|
.from(issues)
|
|
.where(eq(issues.id, issueId))
|
|
.then((rows) => rows[0] ?? null);
|
|
expect(issue?.executionRunId).toBe(retryRuns[0]?.id);
|
|
});
|
|
|
|
it.each(["schedule", "transaction", "promote", "dispatch"] as const)(
|
|
"suppresses a busy-subscription retry whose task lock was cleared before %s",
|
|
async (phase) => {
|
|
const { companyId, issueId, runId, now } = await seedMaxTurnFixture();
|
|
await db.update(heartbeatRuns).set({
|
|
status: "cancelled", errorCode: "ai_connection_busy",
|
|
resultJson: { executionRecovery: { kind: "ai_connection_wait", providerWorkStarted: false } },
|
|
}).where(eq(heartbeatRuns.id, runId));
|
|
const clearLock = () => db.update(issues).set({ executionRunId: null }).where(eq(issues.id, issueId));
|
|
if (phase === "schedule") await clearLock();
|
|
const transaction = db.transaction.bind(db);
|
|
let raceApplied = false;
|
|
const transactionSpy = phase === "transaction"
|
|
? vi.spyOn(db, "transaction").mockImplementationOnce(async (callback, config) => {
|
|
await clearLock();
|
|
raceApplied = true;
|
|
return transaction(callback, config);
|
|
})
|
|
: null;
|
|
const scheduled = await heartbeat.scheduleBoundedRetry(runId, {
|
|
now, retryReason: "ai_connection_busy", wakeReason: "ai_connection_busy_retry",
|
|
maxAttempts: 1, delayMs: 1_000,
|
|
}).finally(() => transactionSpy?.mockRestore());
|
|
if (phase === "transaction") expect(raceApplied).toBe(true);
|
|
if (phase === "schedule" || phase === "transaction") {
|
|
expect(scheduled).toMatchObject({ outcome: "not_scheduled", errorCode: "issue_execution_lock_changed" });
|
|
expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.retryOfRunId, runId))).toHaveLength(0);
|
|
return;
|
|
}
|
|
expect(scheduled.outcome).toBe("scheduled");
|
|
if (scheduled.outcome !== "scheduled") return;
|
|
if (phase === "promote") await clearLock();
|
|
const promotion = await heartbeat.promoteDueScheduledRetries(scheduled.dueAt);
|
|
if (phase === "promote") {
|
|
expect(promotion).toEqual({ promoted: 0, runIds: [] });
|
|
} else {
|
|
expect(promotion.runIds).toContain(scheduled.run.id);
|
|
await clearLock();
|
|
const adapter = createPostgresRunDispatchAdapter(db);
|
|
expect(await adapter.cancelStaleQueuedRun({
|
|
companyId, runId: scheduled.run.id, expectedStatus: "queued", now: scheduled.dueAt,
|
|
})).toMatchObject({ outcome: "cancelled", errorCode: "issue_execution_lock_changed" });
|
|
}
|
|
expect(await heartbeat.getRun(scheduled.run.id)).toMatchObject({
|
|
status: "cancelled", errorCode: "issue_execution_lock_changed",
|
|
});
|
|
},
|
|
);
|
|
|
|
it("does not promote a duplicate max-turn continuation that does not own the issue lock", async () => {
|
|
const { companyId, agentId, issueId, runId, now } = await seedMaxTurnFixture();
|
|
|
|
const scheduled = await heartbeat.scheduleBoundedRetry(runId, {
|
|
now,
|
|
retryReason: MAX_TURN_CONTINUATION_RETRY_REASON,
|
|
wakeReason: MAX_TURN_CONTINUATION_WAKE_REASON,
|
|
maxAttempts: 2,
|
|
delayMs: 1_000,
|
|
});
|
|
expect(scheduled.outcome).toBe("scheduled");
|
|
if (scheduled.outcome !== "scheduled") return;
|
|
|
|
const duplicateWakeupId = randomUUID();
|
|
const duplicateRunId = randomUUID();
|
|
await db.insert(agentWakeupRequests).values({
|
|
id: duplicateWakeupId,
|
|
companyId,
|
|
agentId,
|
|
source: "automation",
|
|
triggerDetail: "system",
|
|
reason: MAX_TURN_CONTINUATION_WAKE_REASON,
|
|
payload: {
|
|
issueId,
|
|
retryOfRunId: runId,
|
|
retryReason: MAX_TURN_CONTINUATION_RETRY_REASON,
|
|
scheduledRetryAttempt: 1,
|
|
},
|
|
status: "queued",
|
|
requestedByActorType: "system",
|
|
});
|
|
await db.insert(heartbeatRuns).values({
|
|
id: duplicateRunId,
|
|
companyId,
|
|
agentId,
|
|
invocationSource: "automation",
|
|
triggerDetail: "system",
|
|
status: "scheduled_retry",
|
|
wakeupRequestId: duplicateWakeupId,
|
|
retryOfRunId: runId,
|
|
scheduledRetryAt: scheduled.dueAt,
|
|
scheduledRetryAttempt: 1,
|
|
scheduledRetryReason: MAX_TURN_CONTINUATION_RETRY_REASON,
|
|
contextSnapshot: {
|
|
issueId,
|
|
wakeReason: MAX_TURN_CONTINUATION_WAKE_REASON,
|
|
retryReason: MAX_TURN_CONTINUATION_RETRY_REASON,
|
|
},
|
|
});
|
|
await db
|
|
.update(agentWakeupRequests)
|
|
.set({ runId: duplicateRunId })
|
|
.where(eq(agentWakeupRequests.id, duplicateWakeupId));
|
|
|
|
const promotion = await heartbeat.promoteDueScheduledRetries(scheduled.dueAt);
|
|
expect(promotion).toEqual({ promoted: 1, runIds: [scheduled.run.id] });
|
|
|
|
const duplicate = await db
|
|
.select({
|
|
status: heartbeatRuns.status,
|
|
errorCode: heartbeatRuns.errorCode,
|
|
})
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, duplicateRunId))
|
|
.then((rows) => rows[0] ?? null);
|
|
expect(duplicate).toEqual({
|
|
status: "cancelled",
|
|
errorCode: "issue_execution_lock_changed",
|
|
});
|
|
|
|
const duplicateWakeup = await db
|
|
.select({ status: agentWakeupRequests.status })
|
|
.from(agentWakeupRequests)
|
|
.where(eq(agentWakeupRequests.id, duplicateWakeupId))
|
|
.then((rows) => rows[0] ?? null);
|
|
expect(duplicateWakeup?.status).toBe("cancelled");
|
|
});
|
|
|
|
it.each(["blocked", "todo", "backlog"] as const)(
|
|
"cancels a due max-turn continuation when the issue moves to %s before retry promotion",
|
|
async (issueStatus) => {
|
|
const { issueId, runId, now } = await seedMaxTurnFixture();
|
|
|
|
const scheduled = await heartbeat.scheduleBoundedRetry(runId, {
|
|
now,
|
|
retryReason: MAX_TURN_CONTINUATION_RETRY_REASON,
|
|
wakeReason: MAX_TURN_CONTINUATION_WAKE_REASON,
|
|
maxAttempts: 2,
|
|
delayMs: 1_000,
|
|
});
|
|
expect(scheduled.outcome).toBe("scheduled");
|
|
if (scheduled.outcome !== "scheduled") return;
|
|
|
|
await db.update(issues).set({
|
|
status: issueStatus,
|
|
updatedAt: new Date(now.getTime() + 500),
|
|
}).where(eq(issues.id, issueId));
|
|
|
|
const promotion = await heartbeat.promoteDueScheduledRetries(scheduled.dueAt);
|
|
expect(promotion).toEqual({ promoted: 0, runIds: [] });
|
|
|
|
const retryRun = await db
|
|
.select({
|
|
status: heartbeatRuns.status,
|
|
errorCode: heartbeatRuns.errorCode,
|
|
wakeupRequestId: heartbeatRuns.wakeupRequestId,
|
|
})
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, scheduled.run.id))
|
|
.then((rows) => rows[0] ?? null);
|
|
expect(retryRun).toMatchObject({
|
|
status: "cancelled",
|
|
errorCode: "issue_not_in_progress",
|
|
});
|
|
|
|
const wakeupRequest = await db
|
|
.select({ status: agentWakeupRequests.status })
|
|
.from(agentWakeupRequests)
|
|
.where(eq(agentWakeupRequests.id, retryRun?.wakeupRequestId ?? ""))
|
|
.then((rows) => rows[0] ?? null);
|
|
expect(wakeupRequest?.status).toBe("cancelled");
|
|
|
|
const issue = await db
|
|
.select({
|
|
executionRunId: issues.executionRunId,
|
|
executionAgentNameKey: issues.executionAgentNameKey,
|
|
executionLockedAt: issues.executionLockedAt,
|
|
})
|
|
.from(issues)
|
|
.where(eq(issues.id, issueId))
|
|
.then((rows) => rows[0] ?? null);
|
|
expect(issue).toEqual({
|
|
executionRunId: null,
|
|
executionAgentNameKey: null,
|
|
executionLockedAt: null,
|
|
});
|
|
|
|
const event = await db
|
|
.select({
|
|
message: heartbeatRunEvents.message,
|
|
payload: heartbeatRunEvents.payload,
|
|
})
|
|
.from(heartbeatRunEvents)
|
|
.where(eq(heartbeatRunEvents.runId, scheduled.run.id))
|
|
.orderBy(sql`${heartbeatRunEvents.seq} desc`)
|
|
.then((rows) => rows[0] ?? null);
|
|
expect(event?.message).toContain("no longer in_progress");
|
|
expect(event?.payload).toMatchObject({
|
|
currentStatus: issueStatus,
|
|
requiredStatus: "in_progress",
|
|
scheduledRetryReason: MAX_TURN_CONTINUATION_RETRY_REASON,
|
|
});
|
|
},
|
|
);
|
|
|
|
it("does not defer a new assignee behind the previous assignee's scheduled retry", async () => {
|
|
const companyId = randomUUID();
|
|
const oldAgentId = randomUUID();
|
|
const newAgentId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const sourceRunId = randomUUID();
|
|
const now = new Date("2026-04-20T13:00:00.000Z");
|
|
|
|
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: oldAgentId,
|
|
companyId,
|
|
name: "ClaudeCoder",
|
|
role: "engineer",
|
|
status: "active",
|
|
adapterType: "claude_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {
|
|
heartbeat: {
|
|
wakeOnDemand: true,
|
|
maxConcurrentRuns: 1,
|
|
},
|
|
},
|
|
permissions: {},
|
|
},
|
|
{
|
|
id: newAgentId,
|
|
companyId,
|
|
name: "CodexCoder",
|
|
role: "engineer",
|
|
status: "active",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {
|
|
heartbeat: {
|
|
wakeOnDemand: true,
|
|
maxConcurrentRuns: 1,
|
|
},
|
|
},
|
|
permissions: {},
|
|
},
|
|
]);
|
|
|
|
await db.insert(heartbeatRuns).values({
|
|
id: sourceRunId,
|
|
companyId,
|
|
agentId: oldAgentId,
|
|
invocationSource: "assignment",
|
|
triggerDetail: "system",
|
|
status: "failed",
|
|
error: "upstream overload",
|
|
errorCode: "adapter_failed",
|
|
resultJson: { executionRecovery: { kind: "bootstrap", providerWorkStarted: false } },
|
|
finishedAt: now,
|
|
contextSnapshot: {
|
|
issueId,
|
|
wakeReason: "issue_assigned",
|
|
},
|
|
updatedAt: now,
|
|
createdAt: now,
|
|
});
|
|
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Retry reassignment",
|
|
status: "todo",
|
|
priority: "medium",
|
|
responsibleUserId: "responsible-user",
|
|
assigneeAgentId: oldAgentId,
|
|
executionRunId: sourceRunId,
|
|
executionAgentNameKey: "claudecoder",
|
|
executionLockedAt: now,
|
|
issueNumber: 1,
|
|
identifier: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}-1`,
|
|
});
|
|
|
|
const scheduled = await heartbeat.scheduleBoundedRetry(sourceRunId, {
|
|
now,
|
|
random: () => 0.5,
|
|
});
|
|
expect(scheduled.outcome).toBe("scheduled");
|
|
if (scheduled.outcome !== "scheduled") return;
|
|
|
|
await db.update(issues).set({
|
|
assigneeAgentId: newAgentId,
|
|
updatedAt: now,
|
|
}).where(eq(issues.id, issueId));
|
|
|
|
// Keep the new agent's queue from auto-claiming/executing during this unit test.
|
|
await db.insert(heartbeatRuns).values(
|
|
Array.from({ length: 5 }, () => ({
|
|
id: randomUUID(),
|
|
companyId,
|
|
agentId: newAgentId,
|
|
invocationSource: "automation",
|
|
triggerDetail: "system",
|
|
status: "running",
|
|
contextSnapshot: {
|
|
wakeReason: "test_busy_slot",
|
|
},
|
|
startedAt: now,
|
|
updatedAt: now,
|
|
createdAt: now,
|
|
})),
|
|
);
|
|
|
|
const newAssigneeRun = await heartbeat.wakeup(newAgentId, {
|
|
source: "assignment",
|
|
triggerDetail: "system",
|
|
reason: "issue_assigned",
|
|
payload: {
|
|
issueId,
|
|
mutation: "update",
|
|
},
|
|
contextSnapshot: {
|
|
issueId,
|
|
source: "issue.update",
|
|
},
|
|
requestedByActorType: "user",
|
|
requestedByActorId: "local-board",
|
|
});
|
|
|
|
expect(newAssigneeRun).not.toBeNull();
|
|
expect(newAssigneeRun?.agentId).toBe(newAgentId);
|
|
expect(newAssigneeRun?.status).toBe("queued");
|
|
|
|
const oldRetry = await db
|
|
.select({
|
|
status: heartbeatRuns.status,
|
|
errorCode: heartbeatRuns.errorCode,
|
|
})
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, scheduled.run.id))
|
|
.then((rows) => rows[0] ?? null);
|
|
expect(oldRetry).toEqual({
|
|
status: "cancelled",
|
|
errorCode: "issue_reassigned",
|
|
});
|
|
|
|
const deferredWakeups = await db
|
|
.select({ count: sql<number>`count(*)::int` })
|
|
.from(agentWakeupRequests)
|
|
.where(eq(agentWakeupRequests.status, "deferred_issue_execution"))
|
|
.then((rows) => rows[0]?.count ?? 0);
|
|
expect(deferredWakeups).toBe(0);
|
|
|
|
// The stale-retry cancel runs inside enqueueWakeup's transaction, and
|
|
// the run's own required lifecycle work never awaits the telemetry
|
|
// emission, so wait for it here instead of asserting it fired
|
|
// synchronously.
|
|
await vi.waitFor(() => {
|
|
expect(mockTrackAgentTaskRun).toHaveBeenCalledWith(
|
|
mockTelemetryClient,
|
|
expect.objectContaining({
|
|
agentId: oldAgentId,
|
|
state: "cancelled",
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
it("exhausts bounded retries after the hard cap", async () => {
|
|
const companyId = randomUUID();
|
|
const agentId = randomUUID();
|
|
const cappedRunId = randomUUID();
|
|
const now = new Date("2026-04-20T18:00:00.000Z");
|
|
|
|
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(heartbeatRuns).values({
|
|
id: cappedRunId,
|
|
companyId,
|
|
agentId,
|
|
invocationSource: "automation",
|
|
status: "failed",
|
|
error: "still transient",
|
|
errorCode: "adapter_failed",
|
|
resultJson: { executionRecovery: { kind: "bootstrap", providerWorkStarted: false } },
|
|
finishedAt: now,
|
|
scheduledRetryAttempt: BOUNDED_TRANSIENT_HEARTBEAT_RETRY_DELAYS_MS.length,
|
|
scheduledRetryReason: "transient_failure",
|
|
contextSnapshot: {
|
|
wakeReason: "transient_failure_retry",
|
|
},
|
|
updatedAt: now,
|
|
createdAt: now,
|
|
});
|
|
|
|
const exhausted = await heartbeat.scheduleBoundedRetry(cappedRunId, {
|
|
now,
|
|
random: () => 0.5,
|
|
});
|
|
|
|
expect(exhausted).toEqual({
|
|
outcome: "retry_exhausted",
|
|
attempt: BOUNDED_TRANSIENT_HEARTBEAT_RETRY_DELAYS_MS.length + 1,
|
|
maxAttempts: BOUNDED_TRANSIENT_HEARTBEAT_RETRY_DELAYS_MS.length,
|
|
});
|
|
|
|
const runCount = await db
|
|
.select({ count: sql<number>`count(*)::int` })
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.companyId, companyId))
|
|
.then((rows) => rows[0]?.count ?? 0);
|
|
expect(runCount).toBe(1);
|
|
|
|
const exhaustionEvent = await db
|
|
.select({
|
|
message: heartbeatRunEvents.message,
|
|
payload: heartbeatRunEvents.payload,
|
|
})
|
|
.from(heartbeatRunEvents)
|
|
.where(eq(heartbeatRunEvents.runId, cappedRunId))
|
|
.orderBy(sql`${heartbeatRunEvents.id} desc`)
|
|
.then((rows) => rows[0] ?? null);
|
|
|
|
expect(exhaustionEvent?.message).toContain("Bounded retry exhausted");
|
|
expect(exhaustionEvent?.payload).toMatchObject({
|
|
retryReason: "transient_failure",
|
|
scheduledRetryAttempt: BOUNDED_TRANSIENT_HEARTBEAT_RETRY_DELAYS_MS.length,
|
|
maxAttempts: BOUNDED_TRANSIENT_HEARTBEAT_RETRY_DELAYS_MS.length,
|
|
});
|
|
|
|
const onLiveEvent = vi.fn();
|
|
const unsubscribe = subscribeCompanyLiveEvents(companyId, onLiveEvent);
|
|
try {
|
|
const restartedHeartbeat = heartbeatService(createDb(tempDb!.connectionString));
|
|
const repeated = await Promise.all(Array.from({ length: 8 }, (_, index) =>
|
|
(index % 2 ? heartbeat : restartedHeartbeat).scheduleBoundedRetry(cappedRunId, {
|
|
now, random: () => 0.5,
|
|
})));
|
|
expect(repeated).toEqual(Array.from({ length: 8 }, () => exhausted));
|
|
expect(await db.select().from(heartbeatRunEvents)
|
|
.where(eq(heartbeatRunEvents.runId, cappedRunId))).toHaveLength(1);
|
|
expect((await heartbeat.getRun(cappedRunId))?.nextEventSeq).toBe(2);
|
|
expect(onLiveEvent).not.toHaveBeenCalled();
|
|
} finally {
|
|
unsubscribe();
|
|
}
|
|
});
|
|
|
|
it("advances codex transient fallback stages across bounded retry attempts", async () => {
|
|
const fallbackModes = [
|
|
"same_session",
|
|
"safer_invocation",
|
|
] as const;
|
|
|
|
for (const [index, expectedMode] of fallbackModes.entries()) {
|
|
const companyId = randomUUID();
|
|
const agentId = randomUUID();
|
|
const runId = randomUUID();
|
|
const now = new Date(`2026-04-20T1${index}:00:00.000Z`);
|
|
|
|
await seedRetryFixture({
|
|
runId,
|
|
companyId,
|
|
agentId,
|
|
now,
|
|
errorCode: "adapter_failed",
|
|
errorFamily: "transient_upstream",
|
|
scheduledRetryAttempt: index,
|
|
});
|
|
|
|
const scheduled = await heartbeat.scheduleBoundedRetry(runId, {
|
|
now,
|
|
random: () => 0.5,
|
|
});
|
|
|
|
expect(scheduled.outcome).toBe("scheduled");
|
|
if (scheduled.outcome !== "scheduled") continue;
|
|
|
|
const retryRun = await db
|
|
.select({
|
|
contextSnapshot: heartbeatRuns.contextSnapshot,
|
|
wakeupRequestId: heartbeatRuns.wakeupRequestId,
|
|
})
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, scheduled.run.id))
|
|
.then((rows) => rows[0] ?? null);
|
|
expect((retryRun?.contextSnapshot as Record<string, unknown> | null)?.codexTransientFallbackMode).toBe(expectedMode);
|
|
|
|
const wakeupRequest = await db
|
|
.select({ payload: agentWakeupRequests.payload })
|
|
.from(agentWakeupRequests)
|
|
.where(eq(agentWakeupRequests.id, retryRun?.wakeupRequestId ?? ""))
|
|
.then((rows) => rows[0] ?? null);
|
|
expect((wakeupRequest?.payload as Record<string, unknown> | null)?.codexTransientFallbackMode).toBe(expectedMode);
|
|
|
|
await cleanupRetryFixture();
|
|
}
|
|
});
|
|
|
|
|
|
it("requires reconciliation for an error-code-only Codex harness crash", async () => {
|
|
const companyId = randomUUID();
|
|
const agentId = randomUUID();
|
|
const runId = randomUUID();
|
|
const now = new Date("2026-07-24T13:00:00.000Z");
|
|
|
|
await seedRetryFixture({
|
|
runId,
|
|
companyId,
|
|
agentId,
|
|
now,
|
|
errorCode: "codex_harness_crash",
|
|
errorFamily: null,
|
|
});
|
|
|
|
await db.update(heartbeatRuns).set({ resultJson: null }).where(eq(heartbeatRuns.id, runId));
|
|
|
|
const scheduled = await heartbeat.scheduleBoundedRetry(runId, {
|
|
now,
|
|
random: () => 0.5,
|
|
});
|
|
|
|
expect(scheduled).toMatchObject({ outcome: "not_scheduled", errorCode: "legacy_execution_requires_reconciliation" });
|
|
|
|
await cleanupRetryFixture();
|
|
});
|
|
|
|
it("honors codex retry-not-before timestamps when they exceed the default bounded backoff", async () => {
|
|
const companyId = randomUUID();
|
|
const agentId = randomUUID();
|
|
const runId = randomUUID();
|
|
const now = new Date(2026, 3, 22, 22, 29, 0);
|
|
const retryNotBefore = new Date(2026, 3, 22, 23, 31, 0);
|
|
|
|
await seedRetryFixture({
|
|
runId,
|
|
companyId,
|
|
agentId,
|
|
now,
|
|
errorCode: "adapter_failed",
|
|
errorFamily: "transient_upstream",
|
|
retryNotBefore: retryNotBefore.toISOString(),
|
|
});
|
|
|
|
const scheduled = await heartbeat.scheduleBoundedRetry(runId, {
|
|
now,
|
|
random: () => 0.5,
|
|
});
|
|
|
|
expect(scheduled.outcome).toBe("scheduled");
|
|
if (scheduled.outcome !== "scheduled") return;
|
|
expect(scheduled.dueAt.getTime()).toBe(retryNotBefore.getTime());
|
|
|
|
const retryRun = await db
|
|
.select({
|
|
contextSnapshot: heartbeatRuns.contextSnapshot,
|
|
scheduledRetryAt: heartbeatRuns.scheduledRetryAt,
|
|
wakeupRequestId: heartbeatRuns.wakeupRequestId,
|
|
})
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, scheduled.run.id))
|
|
.then((rows) => rows[0] ?? null);
|
|
|
|
expect(retryRun?.scheduledRetryAt?.getTime()).toBe(retryNotBefore.getTime());
|
|
expect((retryRun?.contextSnapshot as Record<string, unknown> | null)?.transientRetryNotBefore).toBe(
|
|
retryNotBefore.toISOString(),
|
|
);
|
|
|
|
const wakeupRequest = await db
|
|
.select({ payload: agentWakeupRequests.payload })
|
|
.from(agentWakeupRequests)
|
|
.where(eq(agentWakeupRequests.id, retryRun?.wakeupRequestId ?? ""))
|
|
.then((rows) => rows[0] ?? null);
|
|
|
|
expect((wakeupRequest?.payload as Record<string, unknown> | null)?.transientRetryNotBefore).toBe(
|
|
retryNotBefore.toISOString(),
|
|
);
|
|
});
|
|
|
|
it("schedules bounded retries for claude_transient_upstream and honors its retry-not-before hint", async () => {
|
|
const companyId = randomUUID();
|
|
const agentId = randomUUID();
|
|
const runId = randomUUID();
|
|
const now = new Date(2026, 3, 22, 10, 0, 0);
|
|
const retryNotBefore = new Date(2026, 3, 22, 16, 0, 0);
|
|
|
|
await seedRetryFixture({
|
|
runId,
|
|
companyId,
|
|
agentId,
|
|
now,
|
|
errorCode: "adapter_failed",
|
|
errorFamily: "transient_upstream",
|
|
adapterType: "claude_local",
|
|
retryNotBefore: retryNotBefore.toISOString(),
|
|
});
|
|
|
|
const scheduled = await heartbeat.scheduleBoundedRetry(runId, {
|
|
now,
|
|
random: () => 0.5,
|
|
});
|
|
|
|
expect(scheduled.outcome).toBe("scheduled");
|
|
if (scheduled.outcome !== "scheduled") return;
|
|
expect(scheduled.dueAt.getTime()).toBe(retryNotBefore.getTime());
|
|
|
|
const retryRun = await db
|
|
.select({
|
|
contextSnapshot: heartbeatRuns.contextSnapshot,
|
|
scheduledRetryAt: heartbeatRuns.scheduledRetryAt,
|
|
wakeupRequestId: heartbeatRuns.wakeupRequestId,
|
|
})
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, scheduled.run.id))
|
|
.then((rows) => rows[0] ?? null);
|
|
|
|
expect(retryRun?.scheduledRetryAt?.getTime()).toBe(retryNotBefore.getTime());
|
|
const contextSnapshot = (retryRun?.contextSnapshot as Record<string, unknown> | null) ?? {};
|
|
expect(contextSnapshot.transientRetryNotBefore).toBe(retryNotBefore.toISOString());
|
|
// Claude does not participate in the Codex fallback-mode ladder.
|
|
expect(contextSnapshot.codexTransientFallbackMode ?? null).toBeNull();
|
|
|
|
const wakeupRequest = await db
|
|
.select({ payload: agentWakeupRequests.payload })
|
|
.from(agentWakeupRequests)
|
|
.where(eq(agentWakeupRequests.id, retryRun?.wakeupRequestId ?? ""))
|
|
.then((rows) => rows[0] ?? null);
|
|
|
|
expect((wakeupRequest?.payload as Record<string, unknown> | null)?.transientRetryNotBefore).toBe(
|
|
retryNotBefore.toISOString(),
|
|
);
|
|
});
|
|
|
|
describe("run-dispatch module transactions", () => {
|
|
it("promotes a due scheduled retry exactly once under concurrent promotion attempts", async () => {
|
|
const companyId = randomUUID();
|
|
const agentId = randomUUID();
|
|
const sourceRunId = randomUUID();
|
|
const now = new Date("2026-05-01T00:00:00.000Z");
|
|
|
|
await seedRetryFixture({ runId: sourceRunId, companyId, agentId, now, errorCode: "adapter_failed" });
|
|
const scheduled = await heartbeat.scheduleBoundedRetry(sourceRunId, { now, random: () => 0.5 });
|
|
expect(scheduled.outcome).toBe("scheduled");
|
|
if (scheduled.outcome !== "scheduled") return;
|
|
|
|
const [first, second] = await Promise.all([
|
|
heartbeat.promoteDueScheduledRetries(scheduled.dueAt),
|
|
heartbeat.promoteDueScheduledRetries(scheduled.dueAt),
|
|
]);
|
|
|
|
expect(first.promoted + second.promoted).toBe(1);
|
|
const [row] = await db
|
|
.select({ status: heartbeatRuns.status })
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, scheduled.run.id));
|
|
expect(row?.status).toBe("queued");
|
|
});
|
|
|
|
it("rolls back the run-status update when the run-event write fails during promotion", async () => {
|
|
const companyId = randomUUID();
|
|
const agentId = randomUUID();
|
|
const sourceRunId = randomUUID();
|
|
const now = new Date("2026-05-02T00:00:00.000Z");
|
|
|
|
await seedRetryFixture({ runId: sourceRunId, companyId, agentId, now, errorCode: "adapter_failed" });
|
|
const scheduled = await heartbeat.scheduleBoundedRetry(sourceRunId, { now, random: () => 0.5 });
|
|
expect(scheduled.outcome).toBe("scheduled");
|
|
if (scheduled.outcome !== "scheduled") return;
|
|
|
|
mockedAppendHeartbeatRunEvent.mockRejectedValueOnce(new Error("injected promotion event fault"));
|
|
|
|
await expect(heartbeat.promoteDueScheduledRetries(scheduled.dueAt)).rejects.toThrow(
|
|
"injected promotion event fault",
|
|
);
|
|
|
|
const [row] = await db
|
|
.select({ status: heartbeatRuns.status })
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, scheduled.run.id));
|
|
expect(row?.status).toBe("scheduled_retry");
|
|
});
|
|
|
|
it("rolls back the wakeup-request update when the run-event write fails during a gate-suppressed cancellation", async () => {
|
|
const companyId = randomUUID();
|
|
const agentId = randomUUID();
|
|
const sourceRunId = randomUUID();
|
|
const missingIssueId = randomUUID();
|
|
const now = new Date("2026-05-03T00:00:00.000Z");
|
|
|
|
await seedRetryFixture({ runId: sourceRunId, companyId, agentId, now, errorCode: "adapter_failed" });
|
|
|
|
const wakeupRequestId = randomUUID();
|
|
await db.insert(agentWakeupRequests).values({
|
|
id: wakeupRequestId,
|
|
companyId,
|
|
agentId,
|
|
source: "retry",
|
|
status: "queued",
|
|
});
|
|
|
|
// A max-turn continuation whose issue no longer exists trips the gate's
|
|
// "issue_not_found" rejection without the legacy transient-retry
|
|
// exception, so promotion routes it to the cancel-suppressed-retry write.
|
|
const retryRunId = randomUUID();
|
|
await db.insert(heartbeatRuns).values({
|
|
id: retryRunId,
|
|
companyId,
|
|
agentId,
|
|
invocationSource: "retry",
|
|
status: "scheduled_retry",
|
|
scheduledRetryAt: now,
|
|
scheduledRetryAttempt: 1,
|
|
scheduledRetryReason: MAX_TURN_CONTINUATION_RETRY_REASON,
|
|
wakeupRequestId,
|
|
contextSnapshot: { issueId: missingIssueId, wakeReason: "issue_continuation_needed" },
|
|
updatedAt: now,
|
|
createdAt: now,
|
|
});
|
|
|
|
mockedAppendHeartbeatRunEvent.mockRejectedValueOnce(new Error("injected cancellation event fault"));
|
|
|
|
await expect(heartbeat.promoteDueScheduledRetries(now)).rejects.toThrow(
|
|
"injected cancellation event fault",
|
|
);
|
|
|
|
const [run] = await db
|
|
.select({ status: heartbeatRuns.status })
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, retryRunId));
|
|
expect(run?.status).toBe("scheduled_retry");
|
|
|
|
const [wake] = await db
|
|
.select({ status: agentWakeupRequests.status })
|
|
.from(agentWakeupRequests)
|
|
.where(eq(agentWakeupRequests.id, wakeupRequestId));
|
|
expect(wake?.status).toBe("queued");
|
|
});
|
|
|
|
it("keeps promotion and stale-queued-run cancellation company-scoped", async () => {
|
|
const adapter = createPostgresRunDispatchAdapter(db);
|
|
const companyId = randomUUID();
|
|
const otherCompanyId = randomUUID();
|
|
const agentId = randomUUID();
|
|
const runId = randomUUID();
|
|
const now = new Date("2026-05-04T00:00:00.000Z");
|
|
|
|
await seedRetryFixture({ runId, companyId, agentId, now, errorCode: "adapter_failed" });
|
|
await db
|
|
.update(heartbeatRuns)
|
|
.set({ status: "scheduled_retry", scheduledRetryAt: now })
|
|
.where(eq(heartbeatRuns.id, runId));
|
|
|
|
const wrongCompanyPromotion = await adapter.promoteOrCancelDueRetry({
|
|
runId,
|
|
companyId: otherCompanyId,
|
|
now,
|
|
});
|
|
expect(wrongCompanyPromotion).toEqual({ outcome: "not_promoted" });
|
|
const [afterWrongCompanyPromotion] = await db
|
|
.select({ status: heartbeatRuns.status })
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, runId));
|
|
expect(afterWrongCompanyPromotion?.status).toBe("scheduled_retry");
|
|
|
|
const rightCompanyPromotion = await adapter.promoteOrCancelDueRetry({ runId, companyId, now });
|
|
expect(rightCompanyPromotion.outcome).toBe("promoted");
|
|
|
|
const issueId = randomUUID();
|
|
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Stale queued run target",
|
|
status: "cancelled",
|
|
priority: "medium",
|
|
responsibleUserId: "responsible-user",
|
|
issueNumber: 2,
|
|
identifier: `${issuePrefix}-2`,
|
|
});
|
|
const queuedRunId = randomUUID();
|
|
await db.insert(heartbeatRuns).values({
|
|
id: queuedRunId,
|
|
companyId,
|
|
agentId,
|
|
invocationSource: "assignment",
|
|
status: "queued",
|
|
contextSnapshot: { issueId },
|
|
updatedAt: now,
|
|
createdAt: now,
|
|
});
|
|
|
|
await expect(
|
|
adapter.cancelStaleQueuedRun({
|
|
runId: queuedRunId,
|
|
companyId: otherCompanyId,
|
|
expectedStatus: "queued",
|
|
now,
|
|
}),
|
|
).rejects.toThrow();
|
|
const [afterWrongCompanyCancel] = await db
|
|
.select({ status: heartbeatRuns.status })
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, queuedRunId));
|
|
expect(afterWrongCompanyCancel?.status).toBe("queued");
|
|
|
|
const lostRaceCancel = await adapter.cancelStaleQueuedRun({
|
|
runId: queuedRunId,
|
|
companyId,
|
|
expectedStatus: "running",
|
|
now,
|
|
});
|
|
expect(lostRaceCancel).toEqual({ outcome: "lost_race" });
|
|
const [afterLostRaceCancel] = await db
|
|
.select({ status: heartbeatRuns.status })
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, queuedRunId));
|
|
expect(afterLostRaceCancel?.status).toBe("queued");
|
|
|
|
const rightCompanyCancel = await adapter.cancelStaleQueuedRun({
|
|
runId: queuedRunId,
|
|
companyId,
|
|
expectedStatus: "queued",
|
|
now,
|
|
});
|
|
expect(rightCompanyCancel.outcome).toBe("cancelled");
|
|
});
|
|
|
|
it("never writes another company's wakeup request during suppressed-retry or stale-queued-run cancellation", async () => {
|
|
const adapter = createPostgresRunDispatchAdapter(db);
|
|
const companyId = randomUUID();
|
|
const otherCompanyId = randomUUID();
|
|
const agentId = randomUUID();
|
|
const otherAgentId = randomUUID();
|
|
const now = new Date("2026-05-05T00:00:00.000Z");
|
|
|
|
await seedRetryFixture({ runId: randomUUID(), companyId, agentId, now, errorCode: "adapter_failed" });
|
|
await db.insert(companies).values({
|
|
id: otherCompanyId,
|
|
name: "Other Co",
|
|
issuePrefix: `T${otherCompanyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
defaultResponsibleUserId: "responsible-user",
|
|
});
|
|
await db.insert(agents).values({
|
|
id: otherAgentId,
|
|
companyId: otherCompanyId,
|
|
name: "OtherCoder",
|
|
role: "engineer",
|
|
status: "active",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: { heartbeat: { wakeOnDemand: true, maxConcurrentRuns: 1 } },
|
|
permissions: {},
|
|
});
|
|
|
|
// Each wakeup request belongs to `otherCompanyId`, standing in for a
|
|
// mismatched cross-company reference on the run — the scenario the
|
|
// company predicate on the wakeup write must guard against.
|
|
const suppressedWakeupId = randomUUID();
|
|
await db.insert(agentWakeupRequests).values({
|
|
id: suppressedWakeupId,
|
|
companyId: otherCompanyId,
|
|
agentId: otherAgentId,
|
|
source: "retry",
|
|
status: "queued",
|
|
});
|
|
const suppressedRunId = randomUUID();
|
|
await db.insert(heartbeatRuns).values({
|
|
id: suppressedRunId,
|
|
companyId,
|
|
agentId,
|
|
invocationSource: "retry",
|
|
status: "scheduled_retry",
|
|
scheduledRetryAt: now,
|
|
wakeupRequestId: suppressedWakeupId,
|
|
scheduledRetryReason: MAX_TURN_CONTINUATION_RETRY_REASON,
|
|
contextSnapshot: { issueId: randomUUID() },
|
|
updatedAt: now,
|
|
createdAt: now,
|
|
});
|
|
|
|
const suppressedCancel = await adapter.promoteOrCancelDueRetry({
|
|
runId: suppressedRunId,
|
|
companyId,
|
|
now,
|
|
});
|
|
expect(suppressedCancel.outcome).toBe("gate_suppressed");
|
|
|
|
const [suppressedWakeup] = await db
|
|
.select({ status: agentWakeupRequests.status })
|
|
.from(agentWakeupRequests)
|
|
.where(eq(agentWakeupRequests.id, suppressedWakeupId));
|
|
expect(suppressedWakeup?.status).toBe("queued");
|
|
|
|
const staleWakeupId = randomUUID();
|
|
await db.insert(agentWakeupRequests).values({
|
|
id: staleWakeupId,
|
|
companyId: otherCompanyId,
|
|
agentId: otherAgentId,
|
|
source: "assignment",
|
|
status: "queued",
|
|
});
|
|
const staleIssueId = randomUUID();
|
|
const staleIssuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
|
await db.insert(issues).values({
|
|
id: staleIssueId,
|
|
companyId,
|
|
title: "Stale queued run target",
|
|
status: "cancelled",
|
|
priority: "medium",
|
|
responsibleUserId: "responsible-user",
|
|
issueNumber: 3,
|
|
identifier: `${staleIssuePrefix}-3`,
|
|
});
|
|
const staleRunId = randomUUID();
|
|
await db.insert(heartbeatRuns).values({
|
|
id: staleRunId,
|
|
companyId,
|
|
agentId,
|
|
invocationSource: "assignment",
|
|
status: "queued",
|
|
wakeupRequestId: staleWakeupId,
|
|
contextSnapshot: { issueId: staleIssueId },
|
|
updatedAt: now,
|
|
createdAt: now,
|
|
});
|
|
|
|
const staleCancel = await adapter.cancelStaleQueuedRun({
|
|
runId: staleRunId,
|
|
companyId,
|
|
expectedStatus: "queued",
|
|
now,
|
|
});
|
|
expect(staleCancel.outcome).toBe("cancelled");
|
|
|
|
const [staleWakeup] = await db
|
|
.select({ status: agentWakeupRequests.status })
|
|
.from(agentWakeupRequests)
|
|
.where(eq(agentWakeupRequests.id, staleWakeupId));
|
|
expect(staleWakeup?.status).toBe("queued");
|
|
});
|
|
|
|
it("orders due retries by due time, honors the cutoff, and caps a sweep at 50 runs", async () => {
|
|
const adapter = createPostgresRunDispatchAdapter(db);
|
|
const companyId = randomUUID();
|
|
const agentId = randomUUID();
|
|
const now = new Date("2026-05-05T00:00:00.000Z");
|
|
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
|
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix,
|
|
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: {},
|
|
});
|
|
|
|
// Due, but created well before the cutoff: the cutoff must exclude it
|
|
// even though it is the single most-overdue run in the table.
|
|
const beforeCutoffRunId = randomUUID();
|
|
await db.insert(heartbeatRuns).values({
|
|
id: beforeCutoffRunId,
|
|
companyId,
|
|
agentId,
|
|
invocationSource: "retry",
|
|
status: "scheduled_retry",
|
|
scheduledRetryAt: new Date(now.getTime() - 1_000),
|
|
contextSnapshot: {},
|
|
createdAt: new Date(now.getTime() - 1_000_000),
|
|
updatedAt: now,
|
|
});
|
|
|
|
// 52 due, in-cutoff runs, strictly ordered by scheduledRetryAt/createdAt.
|
|
const dueRunIds = Array.from({ length: 52 }, () => randomUUID());
|
|
for (let i = 0; i < dueRunIds.length; i += 1) {
|
|
const dueAt = new Date(now.getTime() - (dueRunIds.length - i) * 1_000);
|
|
await db.insert(heartbeatRuns).values({
|
|
id: dueRunIds[i],
|
|
companyId,
|
|
agentId,
|
|
invocationSource: "retry",
|
|
status: "scheduled_retry",
|
|
scheduledRetryAt: dueAt,
|
|
contextSnapshot: {},
|
|
createdAt: dueAt,
|
|
updatedAt: now,
|
|
});
|
|
}
|
|
|
|
const cutoff = new Date(now.getTime() - 500_000);
|
|
const result = await adapter.listDueRetries({ now, cutoff, limit: 50 });
|
|
|
|
expect(result).toHaveLength(50);
|
|
expect(result.map((r) => r.runId)).toEqual(dueRunIds.slice(0, 50));
|
|
const resultIds = new Set(result.map((r) => r.runId));
|
|
expect(resultIds.has(beforeCutoffRunId)).toBe(false);
|
|
expect(resultIds.has(dueRunIds[50])).toBe(false);
|
|
expect(resultIds.has(dueRunIds[51])).toBe(false);
|
|
});
|
|
});
|
|
});
|