mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 21:49:18 +01:00
## Thinking Path > - Paperclip manages AI agent work and its execution environments. > - Interactive image setup sessions must expire even when users leave. > - The server currently scans these sessions on every maintenance tick. > - Empty scans keep the instance and database active. > - Existing session rows already record status and expiry. > - This pull request uses transaction signals and the shared scheduler for those rows. > - Empty and completed setup history no longer create recurring cleanup queries. ## Linked Issues or Issue Description **What happened?** The server checks custom-image setup expiry every heartbeat maintenance tick, even when no active session exists. **What did you expect?** Session writes should update the next cleanup deadline. Existing provider cancellation and cleanup ownership must remain intact. Empty state should stay quiet. **Steps to reproduce** Run an instance with heartbeat scheduling enabled and no custom-image setup sessions. Observe repeated setup-expiry reads. Refs #17145. This is the first PR in the recurring server sleep stack. Its base is master. The full requested scope and verification contract are recorded in `doc/plans/2026-10-24-recurring-server-sleep-stack.md`. Follow-up PRs target their predecessor and each own a separate reason for recurring work. ## What Changed - Carry the historical cleanup retry-observation fixture repair and its existing external test-database hook at the stack foundation. Runtime scheduling is unchanged. - Carry the previously verified login-cleanup fixture settlement correction at the stack foundation so every intermediate CI run receives it. This is test-only maintenance; custom-image expiry behavior is unchanged. - Add a custom-image expiry worker to the existing delivery coordinator. Remove its maintenance-tick scan. - Signal initial creation, provider refresh, cancellation/failure, and promotion inside their owning transaction. - Keep the active-status predicate, earliest saved expiry, and provider behavior in the custom-image service. Shared code gains only a queue topic. - Preserve startup recovery, bounded retries, concurrent write generations, and drain deferral. No tables or external alarm changes. - Add PostgreSQL lifecycle tests and repair this suite's database cleanup callback. A dedicated external test URL permits disposable PostgreSQL when the host cannot start another embedded cluster. ## Verification Current head: `3507c6be1e054fdd93353d5dcf77d4899b44518f`. This remains a single-duty PR stacked on master. - Integrated pinned master `8548173fc9efdc0fa07644fb92dcc0b6851a95e5` with normal merges. Every branch passed generated-schema checks and its target-branch migration-order gate. Existing SQL is unchanged; the 25 stack migrations follow master’s new migration. - Duty-specific producer coverage, lifecycle tests, and earlier failure records: [2026-10-08-instance-sleep-and-scheduler-inventory.md](https://github.com/paperclipai/paperclip/blob/3507c6be1e054fdd93353d5dcf77d4899b44518f/doc/plans/2026-10-08-instance-sleep-and-scheduler-inventory.md), [2026-10-24-recurring-server-sleep-stack.md](https://github.com/paperclipai/paperclip/blob/3507c6be1e054fdd93353d5dcf77d4899b44518f/doc/plans/2026-10-24-recurring-server-sleep-stack.md). - Combined tip `51e6cbe67979b55b340ea9f0f434c5b5c682ccd6`: **355 startup/cancellation/recovery/dispatch/preservation tests**, **75 connection-management tests**, and production server typechecking pass. These combined checks do not mean every intermediate PR independently ran the full repository suite. - Real master-to-stack database upgrade passes: **379 unchanged prior migration receipts plus 25 new migrations**, with the gateway responsibility fixture preserved. - Owning-branch checks: 84 runner installation/settlement cases plus runner typechecking and generated TypeScript/Rust profile agreement; 253 GitHub review workflow cases. Stop-attribution and held-worker readiness regressions failed before their fixes. - Failure accounting: the initial combined run passed 349 cases and failed six idle assertions in a reused preservation database containing old cancelled runs. Repeating the combined command with a fresh preservation database passed. Earlier invalid project selection, merge import/type errors, and the interrupted full local suite are not counted as passing checks. - **Latest-head CI and review remain required.** Prior CI findings include GitHub idle observation, route teardown, attention cleanup, touch-picker E2E, workspace health, and voice-cleanup timing; local passes do not erase those failures. No full local repository-suite, deployed sleep, fleet cost, or live Copilot qualification claim is made. ## Risks - Signals cover supported service producers sharing the DB owner. Independent SQL and another process require the existing restart/reconciliation contract. - Failed provider cancellation retains the existing failed-session and historical-cleanup behavior. This PR changes scheduling, not teardown authority. - Active rows without an expiry preserve the existing behavior; no deadline is invented. Cloud sleep eligibility remains conservative. - The worker remains in the existing heartbeat-enabled startup path. It does not change disabled-scheduler behavior or prove deployed suspension. ## Model Used OpenAI Codex, GPT-6 family, with reasoning and code execution. The exact serving model identifier and context window were not exposed. ## 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 Review follow-up: Added a real producer commit during a paused deadline read. Updated startup assertions for coordinator ownership and removed added code comments. The three affected suites pass (102 tests). The separate login-cleanup suite also passes locally (16 tests); its prior CI consume test timed out, and the fresh complete CI run passed. External PostgreSQL tests now use the repository guard for disposable databases ending in `_test`. --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
76 lines
3.3 KiB
TypeScript
76 lines
3.3 KiB
TypeScript
import { createDb } from "@paperclipai/db";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { registerCustomImageCleanupWork } from "../services/custom-image-cleanup-work.js";
|
|
import { createDeliveryWorkCoordinator } from "../services/delivery-work-coordinator.js";
|
|
|
|
describe("custom-image expiry deadline precision", () => {
|
|
let db: ReturnType<typeof createDb>;
|
|
let coordinator: ReturnType<typeof createDeliveryWorkCoordinator>;
|
|
beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-10-26T12:00:00Z"));
|
|
db = createDb("postgres://localhost:1/deadline_precision_test"); });
|
|
afterEach(async () => { await coordinator?.stop(); await db.$client.end({ timeout: 1 }); vi.useRealTimers(); });
|
|
|
|
async function start(delay: number, failFirst = false) {
|
|
let due: number | null = Date.now() + delay;
|
|
let expired = 0;
|
|
const errors: unknown[] = [];
|
|
const cleanup = vi.fn(async () => {
|
|
if (due !== null && due <= Date.now()) { expired++; due = null; }
|
|
return { scanned: 0, timedOut: 0, failed: 0 };
|
|
});
|
|
if (failFirst) cleanup.mockRejectedValueOnce(new Error("temporary database failure"));
|
|
coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => true,
|
|
onError: error => errors.push(error) });
|
|
const worker = registerCustomImageCleanupWork(coordinator, {
|
|
cleanupExpiredSetupSessions: cleanup,
|
|
nextSetupCleanupAt: async () => due,
|
|
}, () => true);
|
|
await worker.ready;
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
return { cleanup, errors, worker, expired: () => expired, move: (at: number) => { due = at; worker.wake(); } };
|
|
}
|
|
|
|
it("expires at a saved five-second startup deadline and then becomes quiet", async () => {
|
|
const due = Date.now() + 5_000;
|
|
const work = await start(5_000);
|
|
expect(coordinator.nextWakeAt()).toBe(due);
|
|
await vi.advanceTimersByTimeAsync(4_999);
|
|
expect(work.expired()).toBe(0);
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
expect(work.expired()).toBe(1);
|
|
expect(coordinator.nextWakeAt()).toBeNull();
|
|
const calls = work.cleanup.mock.calls.length;
|
|
await vi.advanceTimersByTimeAsync(120_000);
|
|
expect(work.cleanup).toHaveBeenCalledTimes(calls);
|
|
});
|
|
|
|
it("replaces a future deadline with an earlier wake inside the retry window", async () => {
|
|
const work = await start(60_000);
|
|
await vi.advanceTimersByTimeAsync(1_000);
|
|
const due = Date.now() + 5_000;
|
|
work.move(due);
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
expect(coordinator.nextWakeAt()).toBe(due);
|
|
await vi.advanceTimersByTimeAsync(4_999);
|
|
expect(work.expired()).toBe(0);
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
expect(work.expired()).toBe(1);
|
|
});
|
|
|
|
it("retains failure retry spacing even when another wake arrives", async () => {
|
|
const retryAt = Date.now() + 30_000;
|
|
const work = await start(5_000, true);
|
|
expect(work.errors).toHaveLength(1);
|
|
work.worker.wake();
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
expect(coordinator.nextWakeAt()).toBe(retryAt);
|
|
await vi.advanceTimersByTimeAsync(29_999);
|
|
expect(work.cleanup).toHaveBeenCalledTimes(1);
|
|
expect(work.expired()).toBe(0);
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
expect(work.expired()).toBe(1);
|
|
expect(work.cleanup).toHaveBeenCalledTimes(2);
|
|
expect(coordinator.nextWakeAt()).toBeNull();
|
|
});
|
|
});
|