mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 08:22:20 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Automatic idle sleep must preserve accepted work, cleanup and recovery. > - Saved AI login connections are configuration without independent workers. > - Failed ephemeral leases can remain after their resources have stopped. > - Detached subscription observations and quota refreshes can outlive a response. > - This pull request distinguishes inert configuration and verified history from unfinished work. > - The benefit is safe idle eligibility while keeping credentials and audit history. ## Linked Issues or Issue Description **What happened?** An active saved AI login blocks idle sleep after its login and agent runs finish. A failed ephemeral lease also blocks after successful cleanup and provider termination. The connection guard currently hides detached quota and subscription work that can outlive the request that started it. **Expected behavior** An idle instance can sleep with saved AI credentials and verified failed-lease history. Accepted refresh work, recovery journals, unfinished login sessions, pending cleanup and run accounting must still block. **Steps to reproduce** 1. Save an AI connection with purpose `ai`, transport `runtime_auth` and status `active`. 2. Finish login and all agent work, then acquire an owned idle drain. 3. Read the idle safety report. The saved connection alone reports background work. 4. Repeat with a failed ephemeral lease whose exact provider termination and finished run are recorded. Its history also reports background work. **Paperclip version or commit** Based on `0bbd586a2f6`, after updates from master. **Deployment mode** Single-process managed or self-hosted server using the owned idle-drain protocol. Related work: #15522, #15761 and #15851. Searched open and closed pull requests for related idle-sleep changes. The open accounting recovery work in #15879 is separate; this change does not settle or discard accounting debt. ## What Changed - Exempt active saved AI runtime-auth configuration from the connection sleep check. Other connection types and unknown states keep their existing checks. - Track detached subscription discovery and provider observations until their actual promises settle. - Track underlying quota work through an HTTP timeout, including an OAuth exchange and the save of its replacement credential. - Inspect the credential-recovery directory without reading credential bytes. Journals, probes, temporary files, unexpected entries and read failures keep sleep blocked. Empty company directories left by successful recovery permit sleep. - Exempt a failed ephemeral lease only after successful cleanup, a release timestamp, an exact company/run/lease/resource termination receipt, and a finished run without retry or accounting debt. Export and cleanup intents still block. - Use the same failed-lease proof when checking finished login-session cleanup. - Add PostgreSQL and filesystem regression tests and document the behavior. ## Verification - Four focused suites passed: 186 tests covering durable guard decisions, local recovery, detached subscription work and quota timeouts. - `pnpm build` passed. - `pnpm -r typecheck` passed. - `pnpm --filter @paperclipai/server build` and a final server TypeScript check passed after the directory-scanner cleanup. - The final directory-scan recheck passed all 18 tests. - The original-base `pnpm test:run` attempt found three local failures. It was stopped before completion to update the branch after two master changes. All three also reproduce on unchanged master `e733bddc45e`: two Git default-branch fixtures in `workspace-runtime.test.ts` and one native runner recovery fixture in `native-session-resume.test.ts`. - Both Git fixtures pass with a command-scoped `init.defaultBranch=master` setting. The native fixture fails with the same result on both branches. No global Git setting was changed. - All CI gates passed on the final head `ff63497d9bef40f5f00567bc729fb17536cfe136`: [complete CI run](https://github.com/paperclipai/paperclip/actions/runs/38156312722). The only skipped checks are the two path-gated Storybook jobs. - Greptile Apex scored the final head 5/5 with no actionable findings and no unresolved review threads. The branch has no merge conflicts with master. - After the master update, `pnpm build` and `pnpm -r typecheck` passed again. All four focused suites passed again: 186 tests. - Staging deployment and live sleep/wake verification are pending the Core merge and the verified private Cloud build. No production rollout has been started. ## Risks - The failed-lease exception is deliberately narrow. Older records without exact termination evidence or a complete run receipt continue to block. - Pending OAuth recovery blocks sleep until it is recovered. No credential, history, cleanup obligation or accounting receipt is deleted. - The existing owned drain, ingress fence, local-work generation checks, plugin drain and backup checkpoint remain required. - No schema migration or provider mutation is introduced. Roll back to the previous application release to restore the conservative guard behavior. ## Model Used OpenAI GPT-6 (Codex), with repository inspection, code editing, test execution and read-only provider analysis. No additional model version or context-window size was supplied by the runtime. ## 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 linked existing issues or described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links - [x] My branch name describes the change and contains no internal ticket id or instance-derived details - [x] I have run focused 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 risks - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no unresolved comments - [x] I will address all reviewer comments before requesting merge Co-authored-by: Paperclip <noreply@paperclip.ing>
117 lines
5.9 KiB
TypeScript
117 lines
5.9 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { Db } from "@paperclipai/db";
|
|
import { refreshCompanySubscriptions, refreshSubscriptionConnection } from "../services/subscription-refresh.js";
|
|
import type { SubscriptionConnection } from "../services/subscriptions.js";
|
|
import { idleWorkSnapshot } from "../services/task-admission.js";
|
|
|
|
const mocks = vi.hoisted(() => ({ accounts: vi.fn(), credential: vi.fn(), register: vi.fn(), probe: vi.fn() }));
|
|
vi.mock("../services/ai-connections.js", () => ({ aiConnectionService: () => ({ subscriptionAccounts: mocks.accounts, credential: mocks.credential }) }));
|
|
vi.mock("../services/subscriptions.js", () => ({ subscriptionService: () => ({ register: mocks.register }) }));
|
|
vi.mock("../services/subscription-identity.js", () => ({ probeSubscriptionIdentity: mocks.probe }));
|
|
|
|
function deferred<T>() {
|
|
let resolve!: (value: T) => void;
|
|
const promise = new Promise<T>(done => { resolve = done; });
|
|
return { promise, resolve };
|
|
}
|
|
function database() {
|
|
return {
|
|
update: () => ({ set: () => ({ where: () => ({ returning: async () => [{ id: "claimed" }] }) }) }),
|
|
select: () => ({ from: () => ({ where: async () => [{ credentialKey: "fixture-key" }] }) }),
|
|
} as unknown as Db;
|
|
}
|
|
const input = (id: string): SubscriptionConnection => ({ companyId: id, connectionId: id, grantId: id, provider: "openai", name: id, ownerUserId: "alice", credential: "fixture" });
|
|
|
|
beforeEach(() => {
|
|
vi.resetAllMocks();
|
|
mocks.credential.mockResolvedValue("fixture");
|
|
mocks.register.mockImplementation(async (connection: SubscriptionConnection) => connection.grantId);
|
|
});
|
|
|
|
describe("subscription discovery capacity", () => {
|
|
it("tracks detached provider refresh through its final database write", async () => {
|
|
const db = database(), probe = deferred<{ credentialKey: string }>(), save = deferred<string>();
|
|
const before = idleWorkSnapshot();
|
|
mocks.probe.mockReturnValue(probe.promise);
|
|
mocks.register.mockReturnValue(save.promise);
|
|
const task = refreshSubscriptionConnection(db, input("tracked"), "tracked");
|
|
try {
|
|
await vi.waitFor(() => expect(mocks.probe).toHaveBeenCalledOnce());
|
|
expect(idleWorkSnapshot().active).toBe(before.active + 1);
|
|
expect(refreshSubscriptionConnection(db, input("tracked"), "tracked")).toBe(task);
|
|
expect(idleWorkSnapshot().active).toBe(before.active + 1);
|
|
probe.resolve({ credentialKey: "fixture-key" });
|
|
await vi.waitFor(() => expect(mocks.register).toHaveBeenCalledOnce());
|
|
expect(idleWorkSnapshot().active).toBe(before.active + 1);
|
|
} finally {
|
|
probe.resolve({ credentialKey: "fixture-key" }); save.resolve("tracked"); await task;
|
|
}
|
|
expect(idleWorkSnapshot().active).toBe(before.active);
|
|
expect(idleWorkSnapshot().generation).toBe(before.generation + 2);
|
|
});
|
|
|
|
it("tracks detached discovery while it waits for account credentials", async () => {
|
|
const db = database(), accounts = deferred<[]>();
|
|
const before = idleWorkSnapshot();
|
|
mocks.accounts.mockReturnValue(accounts.promise);
|
|
const task = refreshCompanySubscriptions(db, "tracked-company", "alice");
|
|
try {
|
|
expect(idleWorkSnapshot().active).toBe(before.active + 1);
|
|
expect(refreshCompanySubscriptions(db, "tracked-company", "alice")).toBe(task);
|
|
expect(idleWorkSnapshot().active).toBe(before.active + 1);
|
|
} finally { accounts.resolve([]); await task; }
|
|
expect(idleWorkSnapshot().active).toBe(before.active);
|
|
});
|
|
|
|
it.each([false, true])("waits for a provider slot without dropping accepted discovery (provider fails: %s)", async (fails) => {
|
|
const db = database();
|
|
const gates = Array.from({ length: 5 }, () => deferred<void>());
|
|
let started = 0, active = 0, peak = 0;
|
|
mocks.probe.mockImplementation(async () => {
|
|
const index = started++;
|
|
peak = Math.max(peak, ++active);
|
|
await gates[index].promise;
|
|
active--;
|
|
if (index === 0 && fails) throw new Error("fixture provider failure");
|
|
return { credentialKey: "fixture-key" };
|
|
});
|
|
mocks.accounts.mockResolvedValue([{ summary: { status: "connected", provider: "openai" },
|
|
connection: { id: "fifth", name: "Fifth account" }, grant: { id: "fifth", subjectUserId: "alice" } }]);
|
|
const running = Array.from({ length: 4 }, (_, i) => refreshSubscriptionConnection(db, input(`busy-${i}`), `busy-${i}`));
|
|
let discovery: Promise<void> | undefined;
|
|
try {
|
|
await vi.waitFor(() => expect(started).toBe(4));
|
|
let completed = false;
|
|
discovery = refreshCompanySubscriptions(db, "fifth", "alice").then(() => { completed = true; });
|
|
await vi.waitFor(() => expect(mocks.register).toHaveBeenCalledWith(expect.objectContaining({ grantId: "fifth" })));
|
|
expect(started).toBe(4);
|
|
expect(completed).toBe(false);
|
|
gates[0].resolve();
|
|
await vi.waitFor(() => expect(started).toBe(5));
|
|
expect(peak).toBe(4);
|
|
gates[4].resolve();
|
|
await discovery;
|
|
expect(mocks.register).toHaveBeenCalledWith(expect.objectContaining({ grantId: "fifth" }), { credentialKey: "fixture-key" });
|
|
} finally {
|
|
gates.forEach(gate => gate.resolve());
|
|
await Promise.all([...running, discovery]);
|
|
}
|
|
});
|
|
|
|
it("rejects excess discovery with a retryable error, deduplicates accepted work, and releases capacity", async () => {
|
|
const db = database(), gate = deferred<[]>();
|
|
mocks.accounts.mockReturnValue(gate.promise);
|
|
const accepted = Array.from({ length: 20 }, (_, i) => refreshCompanySubscriptions(db, "company", `user-${i}`));
|
|
try {
|
|
expect(refreshCompanySubscriptions(db, "company", "user-0")).toBe(accepted[0]);
|
|
expect(mocks.accounts).toHaveBeenCalledTimes(20);
|
|
expect(() => refreshCompanySubscriptions(db, "company", "overflow")).toThrow(expect.objectContaining({ status: 429 }));
|
|
} finally {
|
|
gate.resolve([]);
|
|
await Promise.all(accepted);
|
|
}
|
|
await refreshCompanySubscriptions(db, "company", "overflow");
|
|
expect(mocks.accounts).toHaveBeenCalledTimes(21);
|
|
});
|
|
});
|