mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 15:35:12 +01:00
## Thinking Path > - Paperclip lets people manage AI agents and their saved work. > - A stopped run can owe workspace preservation before saved input can continue. > - Retry saved a recovery request but did not wake the worker that owns preservation. > - Older recovery records could also take the bounded scan slots before a due request. > - A second Retry could change the revision while the physical owner was still saving files. > - This pull request wakes a separate worker after commit, selects due work first, and keeps the active owner's revision. > - Saved work can progress without replaying the stopped provider turn or blocking other finalization. ## Linked Issues or Issue Description **What happened?** An operator approved a saved design and selected Retry after native workspace finalization failed. The API accepted one canonical request. The source remained scheduled at zero preservation attempts for more than 30 minutes. No new provider turn started. A local regression also found that repeated Retry during export invalidated the physical owner's completion revision. **Expected behavior** Committed Retry must wake bounded source preservation. Due recovery must progress ahead of passive cleanup obligations and unrelated history. Duplicate requests must keep one physical attempt. Saved input must still pass normal admission after verified recovery. **Steps to reproduce** 1. Retain a terminal run with an exhausted workspace recovery and saved authorized input. 2. Let the delivery coordinator become quiet, then request Retry. 3. Observe the recovery state and physical preservation receipt. Repeat Retry while the export is held. **Paperclip version or commit** Observed on served commit `4b89eec644d6e1a99d58926e77ae58927c3fa87b`. Local baseline: `a1f77821b9cd5c9c846fdf7a0fa5cc620ad2b6bb`. Related work: Refs #17053, #17267, #17094. This change repairs explicit Retry delivery. It is separate from the run instruction projection in #17724 and historical unsafe-export discovery in #17687. ## What Changed - Notify a dedicated workspace recovery duty from the transaction that saves Retry, recovery admission, adoption, or recovery progress. - Reconstruct work and saved retry deadlines at startup. Fill each free export slot independently, track physical work for idle drain, and join the dedicated worker's exports on shutdown. - Drain one bounded pass through completed cleanup and saved-input obligations. Restart that pass after committed changes without repeatedly rechecking paused input. - Share source eligibility between deadline selection and candidate consumption. Keep resolved files-only obligations and ignore unrelated resolved or cancelled history. - Select due unfinished preservation before passive historical records. Rotate past source locks held by other workers and retain the indexed source lookup. - Bind the canonical Retry intent without changing an active physical attempt's generation or revision. Preserve that binding across later progress writes. - Add real producer, physical preservation, startup, deadline, idle, and independent finalization regressions. Document the delivery contract. ## Verification - Red controls: Retry without a notification stayed scheduled; duplicate Retry invalidated the active export; whole-batch waiting stranded a third Retry; a scan without cursor rotation starved a source behind two actual advisory locks. Removing unfinished-first selection left HTTP Retry scheduled behind five real recovered sources still owing cleanup. The fixture explicitly verifies their eligibility with the worker's actual query filter. - Further red controls: the previous worker visits only two of three eligible completed sources. Disabling the committed-change cursor reset misses a new earlier source arriving while two callbacks are held. Both pass with the final worker. - Green: the public Retry HTTP route, registered production worker, real file copyback, duplicate canonical receipts, and public recovery receipt reload passed on an owned local fixture. Its agent is paused to keep this files-only check from starting a provider turn. - Full workspace recovery suite passed 56 tests, including the foreign-lock regression. - Execution-control lifecycle suite passed 24 tests. It covers idle SQL, stored deadlines, cancelled and resolved history, independent finalization, and shutdown. One, three, and seven paused completed sources are each visited in a bounded pass, then stay deferred without a successor or SQL during 90 seconds of fake-clock idle time. A committed earlier source is evaluated when it arrives during two held callbacks. - Both GitHub and Telegram `automatic_running` continuation regressions passed with the new canonical intent binding. - `pnpm -r typecheck` passed. - `pnpm build` passed. - `node scripts/check-maps.mjs` and `node scripts/check-module-boundaries.mjs` passed. - Local `pnpm test:run` could not complete: embedded PostgreSQL repeatedly failed to start. The owned run was stopped after this setup failure repeated. Latest-head CI supplies broad coverage. - Earlier setup failures included a localhost sandbox denial and loss of the owned disposable Docker database. These were separate from the product red controls above. - Local database tests use an explicitly named disposable PostgreSQL `_test` database. That opt-in database is reset before the suite. Default embedded test behavior is unchanged. - Exact-head CI is green at `ef7dcf1ba1060864c9d216c33b51f4d9f8f57fbd`; Greptile and Ponytail both rate it 5/5, with no unresolved review threads. Earlier CI caught the indexed discovery assertion and two exact chat intent-binding regressions; all three were fixed and pass locally and in CI. A separate chat shard lost contact with its self-hosted runner without a product assertion result. A later shared runner shutdown interrupted several jobs; one infrastructure rerun passed before the final test-and-worker follow-up triggered a fresh green run. - No staging deployment is part of this PR. The original approval remains saved; deployed recovery still needs acceptance after rollout. ## Risks - More recovery writes now wake a bounded maintenance duty. Completed history must not keep it active. Idle and deadline tests cover this boundary. - Resolved files-only obligations must remain eligible without granting replay. The startup regression checks that path. - Provider stop proof, copyback ownership, source binding, retry budgets, and normal execution admission remain required. There is no schema or public API change. ## Model Used - OpenAI Codex based on GPT-6, with code execution and browser tools. The exact serving model ID and context window are not exposed in this session. ## 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 - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
415 lines
28 KiB
TypeScript
415 lines
28 KiB
TypeScript
import { writeExecutionControl } from "../services/execution-control-notifications.js";
|
|
import { reconcileSafeNativeReplacements } from "../services/native-runtime/native-safe-replacement.js";
|
|
import { localAiLoginService } from "../services/local-ai-login.js";
|
|
import { randomUUID } from "node:crypto";
|
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { eq, sql } from "drizzle-orm";
|
|
import { agents, agentWakeupRequests, companies, createDb, heartbeatRuns, issues, issueRecoveryActions, nativeRunFinalizations, applyPendingMigrations } from "@paperclipai/db";
|
|
import { installDatabaseWorkSignals } from "../../../packages/db/src/work-signals.js";
|
|
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
|
import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js";
|
|
import { createDeliveryWorkCoordinator } from "../services/delivery-work-coordinator.js";
|
|
import { DELIVERY_QUEUES, notifyDeliveryWork } from "../services/delivery-work-notifications.js";
|
|
import { executionControlSchedule, registerExecutionControlWork } from "../services/execution-control-work.js";
|
|
import { deliverExecutionStatuses } from "../services/execution-status-delivery.js";
|
|
import { terminalizeLegacyExecution } from "../services/legacy-execution-recovery.js";
|
|
import { reconcileAbandonedExecutionControl } from "../services/execution-control-reconciliation.js";
|
|
import { issueRecoveryActionService } from "../services/issue-recovery-actions.js";
|
|
import { markExecutionReconciliation, deliverReconciledExecutions, settleUnrecoverableExecutions } from "../services/execution-recovery-resolution.js";
|
|
import { nextWorkspaceRecoveryAt } from "../services/system-recovery.js";
|
|
import { createSystemRecovery } from "../services/system-recovery-state.js";
|
|
import { retainedWorkspaceCandidates } from "../services/workspace-recovery.js";
|
|
import { registerWorkspaceRecoveryWork } from "../services/workspace-recovery-work.js";
|
|
import { idleWorkSnapshot } from "../services/task-admission.js";
|
|
import { heartbeatService } from "../services/heartbeat.js";
|
|
import { requestTaskRetry } from "../services/task-retry.js";
|
|
|
|
const coordinators: ReturnType<typeof createDeliveryWorkCoordinator>[] = [];
|
|
afterEach(async () => { for (const worker of coordinators.splice(0)) await worker.stop(); vi.useRealTimers(); });
|
|
function coordinator(owner: object, canRun = () => true, onError: (error: unknown) => void = vi.fn()) {
|
|
const c = createDeliveryWorkCoordinator({ owner, canRun, canReconcile: () => true, onError });
|
|
coordinators.push(c); return c;
|
|
}
|
|
function fakeOwner() {
|
|
const execute = vi.fn(async () => [{ xid: "42" }]);
|
|
const owner = installDatabaseWorkSignals({ execute, async transaction(fn: (tx: any) => Promise<any>): Promise<any> {
|
|
return fn({ execute, transaction: owner.transaction });
|
|
} });
|
|
return owner;
|
|
}
|
|
|
|
describe("execution-control lifecycle", () => {
|
|
it.each(["startup", "commit", "failure"])("retains %s work through drain without querying while paused", async kind => {
|
|
vi.useFakeTimers();
|
|
const owner = fakeOwner(); let draining = kind === "startup";
|
|
const errors = vi.fn(); const c = coordinator(owner, () => !draining, errors);
|
|
const run = vi.fn(async () => {}), next = vi.fn(async () => null);
|
|
await registerExecutionControlWork(c, [{ queue: DELIVERY_QUEUES.executionStatus, run, next }]).ready;
|
|
if (kind !== "startup") {
|
|
if (kind === "failure") run.mockRejectedValueOnce(new Error("delivery unavailable"));
|
|
else draining = true;
|
|
await owner.transaction(tx => notifyDeliveryWork(tx, DELIVERY_QUEUES.executionStatus));
|
|
await vi.advanceTimersByTimeAsync(15_000);
|
|
draining = true;
|
|
}
|
|
const reads = next.mock.calls.length, runs = run.mock.calls.length;
|
|
await vi.advanceTimersByTimeAsync(60_000);
|
|
expect(next).toHaveBeenCalledTimes(reads); expect(run).toHaveBeenCalledTimes(runs);
|
|
draining = false;
|
|
await vi.advanceTimersByTimeAsync(15_000);
|
|
expect(run).toHaveBeenCalledTimes(runs + 1);
|
|
expect(c.nextWakeAt()).toBeNull();
|
|
});
|
|
it("keeps a change arriving during a sweep and does not overlap it", async () => {
|
|
vi.useFakeTimers(); const owner = fakeOwner(); const c = coordinator(owner);
|
|
let release!: () => void;
|
|
const gate = new Promise<void>(resolve => { release = resolve; });
|
|
const run = vi.fn(async () => {});
|
|
await registerExecutionControlWork(c, [{ queue: DELIVERY_QUEUES.executionStatus, run, next: async () => null }]).ready;
|
|
run.mockImplementationOnce(() => gate);
|
|
await owner.transaction(tx => notifyDeliveryWork(tx, DELIVERY_QUEUES.executionStatus));
|
|
await vi.advanceTimersByTimeAsync(15_000);
|
|
await owner.transaction(tx => notifyDeliveryWork(tx, DELIVERY_QUEUES.executionStatus));
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
expect(run).toHaveBeenCalledTimes(2);
|
|
release(); await vi.advanceTimersByTimeAsync(15_000);
|
|
expect(run).toHaveBeenCalledTimes(3); expect(c.nextWakeAt()).toBeNull();
|
|
});
|
|
it("coalesces tenant write bursts without repeatedly scanning global queues", async () => {
|
|
vi.useFakeTimers(); const owner = fakeOwner(); const c = coordinator(owner);
|
|
const run = vi.fn(async () => {}), next = vi.fn(async () => null);
|
|
await registerExecutionControlWork(c, [{ queue: DELIVERY_QUEUES.executionStatus, run, next }]).ready;
|
|
for (let i = 0; i < 100; i++) {
|
|
await owner.transaction(tx => notifyDeliveryWork(tx, DELIVERY_QUEUES.executionStatus, `company-${i % 2}`));
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
}
|
|
expect(run).toHaveBeenCalledTimes(1); expect(next).toHaveBeenCalledTimes(1);
|
|
await vi.advanceTimersByTimeAsync(14_000);
|
|
expect(run).toHaveBeenCalledTimes(2); expect(next).toHaveBeenCalledTimes(2);
|
|
expect(c.nextWakeAt()).toBeNull();
|
|
await vi.advanceTimersByTimeAsync(60_000);
|
|
expect(run).toHaveBeenCalledTimes(2);
|
|
});
|
|
it.each([false, true])("does not start sweeps or inspect deadlines once server shutdown begins (notification=%s)", async notification => {
|
|
vi.useFakeTimers(); const owner = fakeOwner(); const c = coordinator(owner); let stopping = false;
|
|
const run = vi.fn(async () => {}), next = vi.fn(async () => Date.now() + 1000);
|
|
await registerExecutionControlWork(c, [{ queue: DELIVERY_QUEUES.executionStatus, run, next }], () => !stopping).ready;
|
|
stopping = true;
|
|
if (notification) await owner.transaction(tx => notifyDeliveryWork(tx, DELIVERY_QUEUES.executionStatus));
|
|
await vi.advanceTimersByTimeAsync(30_000);
|
|
expect(run).toHaveBeenCalledTimes(1); expect(next).toHaveBeenCalledTimes(1);
|
|
await c.stop(); expect(c.nextWakeAt()).toBeNull();
|
|
});
|
|
it.each([DELIVERY_QUEUES.executionReplacement, DELIVERY_QUEUES.workspaceRecovery])("keeps finalization responsive while %s is waiting, and awaits shutdown", async heldQueue => {
|
|
vi.useFakeTimers(); const owner = fakeOwner(); const c = coordinator(owner);
|
|
let release!: () => void; const gate = new Promise<void>(resolve => { release = resolve; });
|
|
const status = vi.fn(async () => {});
|
|
const started = registerExecutionControlWork(c, [
|
|
{ queue: heldQueue, run: () => gate, next: async () => null },
|
|
{ queue: DELIVERY_QUEUES.executionFinalization, run: status, next: async () => null },
|
|
]);
|
|
await vi.advanceTimersByTimeAsync(1); expect(status).toHaveBeenCalledTimes(1);
|
|
let stopped = false; const stopping = c.stop().then(() => { stopped = true; });
|
|
await vi.advanceTimersByTimeAsync(1); expect(stopped).toBe(false);
|
|
release(); await started.ready; await stopping; expect(stopped).toBe(true);
|
|
});
|
|
});
|
|
|
|
const external = resolveExternalTestDatabaseUrl("PAPERCLIP_EXECUTION_SLEEP_TEST_DATABASE_URL");
|
|
const support = external ? { supported: true } : await getEmbeddedPostgresTestSupport();
|
|
describe.skipIf(!support.supported)("execution-control durable work", () => {
|
|
let db: ReturnType<typeof createDb>, observed: typeof db;
|
|
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | undefined;
|
|
let calls: number, companyId: string, agentId: string, issueId: string;
|
|
const errors: unknown[] = [];
|
|
beforeAll(async () => {
|
|
if (external) await applyPendingMigrations(external);
|
|
else temporary = await startEmbeddedPostgresTestDatabase("execution-sleep-");
|
|
db = createDb(external ?? temporary!.connectionString, { maxConnections: 6 });
|
|
await Promise.all(Array.from({ length: 6 }, () => db.execute(sql`select pg_sleep(0.05)`)));
|
|
}, 90000);
|
|
afterAll(async () => { await db?.$client.end({ timeout: 1 }); await temporary?.cleanup(); });
|
|
beforeEach(async () => {
|
|
await db.execute(sql`TRUNCATE TABLE companies CASCADE`);
|
|
companyId = randomUUID(); agentId = randomUUID(); issueId = randomUUID(); calls = 0; errors.length = 0;
|
|
await db.insert(companies).values({ id: companyId, name: "Idle execution", issuePrefix: "EXE" });
|
|
await db.insert(agents).values({ id: agentId, companyId, name: "Worker", adapterType: "codex_local" });
|
|
await db.insert(issues).values({ id: issueId, companyId, title: "Execution", status: "in_progress", assigneeAgentId: agentId });
|
|
observed = new Proxy(db, { get(target, key) {
|
|
if (!["select", "update", "insert", "delete", "execute", "transaction"].includes(String(key))) return Reflect.get(target, key);
|
|
return (...args: unknown[]) => { calls++; return Reflect.apply(Reflect.get(target, key), target, args); };
|
|
} });
|
|
vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout", "setInterval", "clearInterval"], shouldAdvanceTime: true });
|
|
});
|
|
async function settle() {
|
|
await vi.advanceTimersByTimeAsync(15_000);
|
|
await vi.waitFor(() => expect(idleWorkSnapshot().active).toBe(0), { timeout: 5000 });
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
await vi.waitFor(() => expect(idleWorkSnapshot().active).toBe(0), { timeout: 5000 });
|
|
expect(errors).toEqual([]);
|
|
}
|
|
async function quiet(c: ReturnType<typeof coordinator>) {
|
|
await settle(); expect(c.nextWakeAt()).toBeNull(); const before = calls;
|
|
await vi.advanceTimersByTimeAsync(90_000); expect(calls).toBe(before);
|
|
}
|
|
async function runRow(status = "running", sourceIssueId = issueId) {
|
|
const [run] = await db.insert(heartbeatRuns).values({ companyId, agentId, status, runtimeMode: "legacy", contextSnapshot: { issueId: sourceIssueId } }).returning();
|
|
return run!;
|
|
}
|
|
async function pausedRecoveredSource(actionId = randomUUID()) {
|
|
const sourceIssueId = randomUUID();
|
|
await db.insert(issues).values({ id: sourceIssueId, companyId, title: "Recovered execution",
|
|
status: "in_progress", assigneeAgentId: agentId });
|
|
const run = await runRow("failed", sourceIssueId);
|
|
await db.update(heartbeatRuns).set({ resultJson: { startupCancellation: { beforeNativeSelection: true } } })
|
|
.where(eq(heartbeatRuns.id, run.id));
|
|
await db.update(agents).set({ status: "paused" }).where(eq(agents.id, agentId));
|
|
await db.insert(issueRecoveryActions).values({ id: actionId, companyId, sourceIssueId,
|
|
kind: "active_run_watchdog", ownerType: "system", cause: "native_workspace_sync_out_retry_exhausted",
|
|
fingerprint: run.id, nextAction: "Saved work recovered", evidence: { runId: run.id,
|
|
systemRecovery: { ...createSystemRecovery(run.id), state: "complete" },
|
|
workspaceRecovery: { schema: "paperclip.workspace-recovery.v1", runId: run.id,
|
|
status: "recovered", stage: "saving_receipts", attempt: 1, attemptLimit: 6, requestIds: [] } } });
|
|
const retry = await requestTaskRetry({ db, companyId, issueId: sourceIssueId, actorId: "board",
|
|
observedRunId: run.id, idempotencyKey: randomUUID() });
|
|
expect(retry.accepted).toBe(true);
|
|
return { run, retry };
|
|
}
|
|
it("stops all pending/deadline scans with empty queues and completed history", async () => {
|
|
await runRow("succeeded");
|
|
const schedule = executionControlSchedule(observed);
|
|
const c = coordinator(db, undefined, error => { errors.push(error); });
|
|
const topics = [DELIVERY_QUEUES.executionFinalization, DELIVERY_QUEUES.workspaceRecovery, DELIVERY_QUEUES.executionReplacement, DELIVERY_QUEUES.executionContinuation,
|
|
DELIVERY_QUEUES.executionStatus, DELIVERY_QUEUES.executionDisposition, DELIVERY_QUEUES.localLoginCleanup];
|
|
const runs = [() => reconcileAbandonedExecutionControl(observed), async () => {}, () => reconcileSafeNativeReplacements(observed),
|
|
() => deliverReconciledExecutions(observed, vi.fn(async () => null)), () => deliverExecutionStatuses(observed),
|
|
() => settleUnrecoverableExecutions(observed), () => localAiLoginService(observed).reapExpired()];
|
|
await registerExecutionControlWork(c, Object.values(schedule).map((next, i) => ({ queue: topics[i]!, run: runs[i]!, next }))).ready;
|
|
await quiet(c);
|
|
});
|
|
it("schedules only a current unfinished workspace recovery and becomes quiet with retained history", async () => {
|
|
const run = await runRow("failed");
|
|
const state = createSystemRecovery(run.id);
|
|
const evidence = { runId: run.id, systemRecovery: state,
|
|
workspaceRecovery: { schema: "paperclip.workspace-recovery.v1", runId: run.id,
|
|
status: "queued", stage: "verifying_stop", attempt: 0, attemptLimit: 6, requestIds: [] } };
|
|
const [action] = await db.insert(issueRecoveryActions).values({ companyId, sourceIssueId: issueId,
|
|
kind: "active_run_watchdog", ownerType: "system", cause: "native_workspace_sync_out_retry_exhausted",
|
|
fingerprint: run.id, nextAction: "Save work", evidence }).returning();
|
|
expect(await nextWorkspaceRecoveryAt(db)).toBe(0);
|
|
const due = Date.now() + 60_000;
|
|
await db.update(issueRecoveryActions).set({ evidence: { ...evidence, systemRecovery: { ...state, nextAttemptAt: due } } })
|
|
.where(eq(issueRecoveryActions.id, action!.id));
|
|
expect(await executionControlSchedule(db).workspaceRecovery()).toBe(due);
|
|
for (const status of ["cancelled", "resolved"] as const) {
|
|
await db.update(issueRecoveryActions).set({ status }).where(eq(issueRecoveryActions.id, action!.id));
|
|
expect(await nextWorkspaceRecoveryAt(db)).toBeNull();
|
|
expect(await retainedWorkspaceCandidates(db)).toEqual([]);
|
|
}
|
|
for (const system of [{ ...state, sourceRunId: randomUUID() }, { ...state, state: "complete" }, { ...state, state: "exhausted" }]) {
|
|
await db.update(issueRecoveryActions).set({ status: "active", evidence: { ...evidence, systemRecovery: system } })
|
|
.where(eq(issueRecoveryActions.id, action!.id));
|
|
expect(await nextWorkspaceRecoveryAt(db)).toBeNull();
|
|
}
|
|
await db.update(issueRecoveryActions).set({ evidence, cause: "agent_unreachable" })
|
|
.where(eq(issueRecoveryActions.id, action!.id));
|
|
expect(await nextWorkspaceRecoveryAt(db)).toBeNull();
|
|
await db.update(issueRecoveryActions).set({ cause: "native_workspace_sync_out_retry_exhausted",
|
|
evidence: { ...evidence, workspaceRecovery: { ...evidence.workspaceRecovery, runId: randomUUID() } } })
|
|
.where(eq(issueRecoveryActions.id, action!.id));
|
|
expect(await nextWorkspaceRecoveryAt(db)).toBeNull();
|
|
expect(await retainedWorkspaceCandidates(db)).toEqual([]);
|
|
await db.update(issueRecoveryActions).set({ status: "cancelled", evidence: { ...evidence,
|
|
systemRecovery: { ...state, state: "running" } } }).where(eq(issueRecoveryActions.id, action!.id));
|
|
const c = coordinator(db, undefined, error => { errors.push(error); });
|
|
const worker = registerWorkspaceRecoveryWork(observed, c,
|
|
{} as Parameters<typeof registerWorkspaceRecoveryWork>[2], async () => {});
|
|
try { await worker.ready; await quiet(c); } finally { await worker.stop(); }
|
|
});
|
|
|
|
it.each([1, 3, 7])("visits all %i paused Retry sources once and becomes quiet after file recovery", async count => {
|
|
const sources = [];
|
|
for (let index = 0; index < count; index++) sources.push(await pausedRecoveredSource());
|
|
const resume = vi.fn(heartbeatService(observed).resumeRemoteStopComments);
|
|
const c = coordinator(db, undefined, error => { errors.push(error); });
|
|
const worker = registerWorkspaceRecoveryWork(observed, c,
|
|
{} as Parameters<typeof registerWorkspaceRecoveryWork>[2], resume);
|
|
try {
|
|
await worker.ready;
|
|
for (let pass = 0; pass < 10 && resume.mock.calls.length < count; pass++) await settle();
|
|
expect(resume).toHaveBeenCalledTimes(count);
|
|
await quiet(c);
|
|
for (const result of resume.mock.results) {
|
|
await expect(result.value).rejects.toThrow("Agent is not invokable in its current state");
|
|
}
|
|
for (const { retry } of sources) {
|
|
const [saved] = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, retry.requestId!));
|
|
expect(saved).toMatchObject({ status: "deferred_issue_execution", runId: null });
|
|
}
|
|
expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, companyId))).toHaveLength(count);
|
|
expect((await db.select().from(agents).where(eq(agents.id, agentId)))[0]?.status).toBe("paused");
|
|
} finally { await c.stop(); await worker.stop(); }
|
|
});
|
|
|
|
it("visits a newly committed earlier Retry during a busy recovered-source pass, then becomes quiet", async () => {
|
|
const sources = [];
|
|
for (let index = 0; index < 3; index++) {
|
|
sources.push(await pausedRecoveredSource(`ffffffff-000${index}-4000-8000-${randomUUID().slice(-12)}`));
|
|
}
|
|
let release!: () => void;
|
|
const gate = new Promise<void>(resolve => { release = resolve; });
|
|
const heartbeat = heartbeatService(observed);
|
|
const resume = vi.fn(async (run: Parameters<typeof heartbeat.resumeRemoteStopComments>[0]) => {
|
|
await gate;
|
|
return heartbeat.resumeRemoteStopComments(run);
|
|
});
|
|
const c = coordinator(db, undefined, error => { errors.push(error); });
|
|
const worker = registerWorkspaceRecoveryWork(observed, c,
|
|
{} as Parameters<typeof registerWorkspaceRecoveryWork>[2], resume);
|
|
try {
|
|
await worker.ready;
|
|
await vi.waitFor(() => expect(resume).toHaveBeenCalledTimes(2));
|
|
const earlier = await pausedRecoveredSource(`00000000-0000-4000-8000-${randomUUID().slice(-12)}`);
|
|
sources.push(earlier);
|
|
release();
|
|
for (let pass = 0; pass < 10 && sources.some(({ run }) =>
|
|
!resume.mock.calls.some(([visited]) => visited.id === run.id)); pass++) await settle();
|
|
expect(resume.mock.calls.map(([run]) => run.id)).toContain(earlier.run.id);
|
|
await quiet(c);
|
|
for (const { run, retry } of sources) {
|
|
expect(resume.mock.calls.map(([visited]) => visited.id)).toContain(run.id);
|
|
const [saved] = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, retry.requestId!));
|
|
expect(saved).toMatchObject({ status: "deferred_issue_execution", runId: null });
|
|
}
|
|
expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, companyId))).toHaveLength(4);
|
|
expect((await db.select().from(agents).where(eq(agents.id, agentId)))[0]?.status).toBe("paused");
|
|
} finally { release(); await c.stop(); await worker.stop(); }
|
|
});
|
|
|
|
it("bounds real producer bursts with another company's completed history present", async () => {
|
|
const otherCompany = randomUUID(), otherAgent = randomUUID();
|
|
await db.insert(companies).values({ id: otherCompany, name: "Other tenant", issuePrefix: "OTH" });
|
|
await db.insert(agents).values({ id: otherAgent, companyId: otherCompany, name: "Other worker", adapterType: "codex_local" });
|
|
await db.insert(heartbeatRuns).values({ companyId: otherCompany, agentId: otherAgent, status: "succeeded" });
|
|
const run = await runRow(); const publish = vi.fn(); const c = coordinator(db, undefined, error => { errors.push(error); });
|
|
await registerExecutionControlWork(c, [{ queue: DELIVERY_QUEUES.executionStatus,
|
|
run: () => deliverExecutionStatuses(observed, { publish }), next: executionControlSchedule(observed).status }]).ready;
|
|
const before = calls;
|
|
for (let i = 0; i < 20; i++) {
|
|
await terminalizeLegacyExecution({ db, run, status: "succeeded", fromStatuses: ["running", "succeeded"] });
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
}
|
|
expect(calls).toBe(before); expect(publish).not.toHaveBeenCalled();
|
|
await settle(); expect(publish).toHaveBeenCalledTimes(1);
|
|
expect(publish.mock.calls[0]?.[0]).toMatchObject({ companyId, payload: { runId: run.id } });
|
|
await quiet(c);
|
|
});
|
|
it("wakes status delivery through the real producer only after outer commit, then becomes quiet", async () => {
|
|
const run = await runRow(); const publish = vi.fn(); const c = coordinator(db, undefined, error => { errors.push(error); });
|
|
await registerExecutionControlWork(c, [{ queue: DELIVERY_QUEUES.executionStatus,
|
|
run: () => deliverExecutionStatuses(observed, { publish }), next: executionControlSchedule(observed).status }]).ready;
|
|
await quiet(c);
|
|
await db.transaction(async tx => {
|
|
await terminalizeLegacyExecution({ db: tx as unknown as typeof db, run, status: "succeeded", patch: { finishedAt: new Date() } });
|
|
await vi.advanceTimersByTimeAsync(1); expect(publish).not.toHaveBeenCalled();
|
|
});
|
|
await settle(); expect(publish).toHaveBeenCalledTimes(1);
|
|
await quiet(c);
|
|
const [saved] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, run.id));
|
|
expect(saved?.status).toBe("succeeded"); expect(saved?.executionStatusDeliveryId).toBeNull();
|
|
});
|
|
it("rolls back a nested producer without delivering or leaving periodic work", async () => {
|
|
const run = await runRow(), publish = vi.fn(); const c = coordinator(db, undefined, error => { errors.push(error); });
|
|
await registerExecutionControlWork(c, [{ queue: DELIVERY_QUEUES.executionStatus,
|
|
run: () => deliverExecutionStatuses(observed, { publish }), next: executionControlSchedule(observed).status }]).ready;
|
|
await expect(db.transaction(async tx => {
|
|
await terminalizeLegacyExecution({ db: tx as unknown as typeof db, run, status: "succeeded" });
|
|
throw new Error("rollback");
|
|
})).rejects.toThrow("rollback");
|
|
await quiet(c); expect(publish).not.toHaveBeenCalled();
|
|
});
|
|
it("restores an exact control deadline on restart without querying before it", async () => {
|
|
const run = await runRow("succeeded"), due = new Date(Date.now() + 60_000);
|
|
await db.update(heartbeatRuns).set({ executionControlDeadlineAt: due }).where(eq(heartbeatRuns.id, run.id));
|
|
const c = coordinator(db, undefined, error => { errors.push(error); });
|
|
await registerExecutionControlWork(c, [{ queue: DELIVERY_QUEUES.executionFinalization,
|
|
run: () => reconcileAbandonedExecutionControl(observed), next: executionControlSchedule(observed).finalization }]).ready;
|
|
expect(c.nextWakeAt()).toBe(due.getTime()); const before = calls;
|
|
await vi.advanceTimersByTimeAsync(55_000); expect(calls).toBe(before);
|
|
await vi.advanceTimersByTimeAsync(5000); await settle(); await quiet(c);
|
|
});
|
|
it("retains replacement retries for an unresolved failed execution, excludes completed/denied history", async () => {
|
|
const run = await runRow("failed");
|
|
await db.update(heartbeatRuns).set({ nativeIssueId: issueId }).where(eq(heartbeatRuns.id, run.id));
|
|
await db.insert(nativeRunFinalizations).values({ runId: run.id, companyId, issueId, phase: "terminal_failure", failureCode: "provider_transport_failed" });
|
|
const schedule = executionControlSchedule(observed);
|
|
expect(await schedule.replacement()).toBeGreaterThan(Date.now());
|
|
await db.update(nativeRunFinalizations).set({ failureDetail: { replacementDenied: "operator_reconciled" } }).where(eq(nativeRunFinalizations.runId, run.id));
|
|
expect(await schedule.replacement()).toBeNull();
|
|
});
|
|
it("a recovery-action producer wakes automatic disposition and leaves resolved history quiet", async () => {
|
|
const run = await runRow("failed"); const c = coordinator(db, undefined, error => { errors.push(error); });
|
|
await registerExecutionControlWork(c, [{ queue: DELIVERY_QUEUES.executionDisposition,
|
|
run: () => settleUnrecoverableExecutions(observed), next: executionControlSchedule(observed).disposition }]).ready;
|
|
await quiet(c);
|
|
const action = await issueRecoveryActionService(db).upsertSourceScoped({ companyId, sourceIssueId: issueId,
|
|
kind: "active_run_watchdog", cause: "execution_finalization_deadline_exceeded", fingerprint: run.id,
|
|
returnOwnerAgentId: agentId, evidence: { runId: run.id }, nextAction: "Recover execution" });
|
|
await settle();
|
|
expect(await issueRecoveryActionService(db).getActiveForIssue(companyId, issueId)).toBeNull();
|
|
expect(action.id).toBeTruthy(); await quiet(c);
|
|
});
|
|
it("retries a failed broadcast only while its durable marker remains, including after restart", async () => {
|
|
const run = await runRow();
|
|
const publish = vi.fn(() => { throw new Error("receiver unavailable"); });
|
|
let c = coordinator(db, undefined, error => { errors.push(error); });
|
|
const duty = { queue: DELIVERY_QUEUES.executionStatus,
|
|
run: () => deliverExecutionStatuses(observed, { publish }), next: executionControlSchedule(observed).status };
|
|
await registerExecutionControlWork(c, [duty]).ready;
|
|
await terminalizeLegacyExecution({ db, run, status: "succeeded" });
|
|
await settle(); expect(publish).toHaveBeenCalledTimes(1);
|
|
const before = calls;
|
|
await vi.advanceTimersByTimeAsync(10_000); expect(calls).toBe(before);
|
|
await c.stop();
|
|
publish.mockImplementation(() => undefined);
|
|
c = coordinator(db, undefined, error => { errors.push(error); });
|
|
await registerExecutionControlWork(c, [duty]).ready;
|
|
expect(publish).toHaveBeenCalledTimes(2); await quiet(c);
|
|
});
|
|
it("reschedules an earlier deadline and disarms a cancelled deadline", async () => {
|
|
const run = await runRow("succeeded"); const c = coordinator(db, undefined, error => { errors.push(error); });
|
|
await registerExecutionControlWork(c, [{ queue: DELIVERY_QUEUES.executionFinalization,
|
|
run: () => reconcileAbandonedExecutionControl(observed), next: executionControlSchedule(observed).finalization }]).ready;
|
|
const due = new Date(Date.now() + 120_000), earlier = new Date(Date.now() + 60_000);
|
|
for (const at of [due, earlier]) {
|
|
await writeExecutionControl(db, DELIVERY_QUEUES.executionFinalization, tx => tx.update(heartbeatRuns)
|
|
.set({ executionControlDeadlineAt: at }).where(eq(heartbeatRuns.id, run.id)));
|
|
await settle(); expect(c.nextWakeAt()).toBe(at.getTime());
|
|
}
|
|
await writeExecutionControl(db, DELIVERY_QUEUES.executionFinalization, tx => tx.update(heartbeatRuns)
|
|
.set({ executionControlDeadlineAt: null }).where(eq(heartbeatRuns.id, run.id)));
|
|
await quiet(c);
|
|
});
|
|
it("delivers a reconciled continuation when marking and resolving commit together", async () => {
|
|
const run = await runRow("failed"), successor = await runRow("queued");
|
|
const action = await issueRecoveryActionService(db).upsertSourceScoped({ companyId, sourceIssueId: issueId,
|
|
kind: "active_run_watchdog", cause: "execution_finalization_deadline_exceeded", fingerprint: run.id,
|
|
returnOwnerAgentId: agentId, evidence: { runId: run.id }, nextAction: "Recover execution" });
|
|
await db.update(heartbeatRuns).set({ contextSnapshot: { issueId, recoveryActionId: action.id, previousRunId: run.id } })
|
|
.where(eq(heartbeatRuns.id, successor.id));
|
|
const wake = vi.fn(async () => successor), c = coordinator(db, undefined, error => { errors.push(error); });
|
|
await registerExecutionControlWork(c, [{ queue: DELIVERY_QUEUES.executionContinuation,
|
|
run: () => deliverReconciledExecutions(observed, wake), next: executionControlSchedule(observed).continuation }]).ready;
|
|
await db.transaction(async tx => {
|
|
await markExecutionReconciliation(tx as unknown as typeof db, action,
|
|
{ runId: run.id, providerStopped: true, actionOutcome: "not_performed", outcomeEvidence: "Verified the provider stopped without performing an external action." }, "operator");
|
|
await issueRecoveryActionService(tx).resolveActiveForIssue({ companyId, sourceIssueId: issueId, actionId: action.id, status: "resolved", outcome: "restored" });
|
|
await vi.advanceTimersByTimeAsync(1); expect(wake).not.toHaveBeenCalled();
|
|
});
|
|
await settle(); expect(wake).toHaveBeenCalledTimes(1); await quiet(c);
|
|
const [saved] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, successor.id));
|
|
expect(saved?.retryOfRunId).toBe(run.id);
|
|
});
|
|
|
|
});
|