mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-03 17:51:58 +01:00
## Thinking Path > - Paperclip manages work for companies and AI agents. > - Hosted workspaces can sleep when their required work has a safe wake path. > - The current idle check treats every open issue as background work. > - Backlog, unassigned and human-waiting records can therefore keep an idle process running. > - Future automated monitors still need their saved check time and execution claims. > - This PR adds a signed host capability for passive issues and verified monitor deadlines. > - The host can save one alarm for the earliest required routine or monitor time. ## Linked Issues or Issue Description **What happened?** A quiet workspace with a backlog or unassigned todo issue reports background work as present. A future issue monitor also prevents sleep even when the host can save a durable wake alarm. **Expected behavior** Proven passive records can sleep. A validated future monitor can sleep only after the host saves its alarm and validates the same owned report. Active, unknown and unresolved work stays awake. **Steps to reproduce** 1. Create a quiet workspace with an unassigned todo issue and no other work. 2. Acquire a bounded, signed Cloud idle hold. 3. Request the routine-only safety report with `scheduledWake=1`. The issue blocks sleep. 4. Request the new capability with `scheduledWake=2`. A passive issue can now report no work. 5. Add a valid future monitor to an assigned in-progress issue. The second capability returns its saved deadline for host acknowledgement. **Paperclip version or commit** Base: `c30fb7d2b75ea30f72d63656b2a7cfff7fe0929d`. **Deployment mode** Cloud-managed authenticated instance. Local scheduling remains in the existing scheduler. Related protocol: [#16215](https://github.com/paperclipai/paperclip/pull/16215). Open scheduler and monitor PRs were checked for overlap. This change leaves monitor admission, dispatch and quota recovery with their existing owners. The roadmap already includes scheduled work; this is a focused idle-safety extension. ## What Changed - Add signed scheduling capability `2`; capability `1` retains the routine-only rules. - Allow untouched onboarding prompts with idle assignees to sleep using the same awaiting-user predicate as recovery; typing, answering the opening card or a prior run removes the exemption. - Inspect passive issue state and future monitors in the same bounded, read-only snapshot as other work. - Check ownership, company scope, known states, claims, policy/deadline agreement, attempts, timeout and dependency repair. - Combine routine and monitor deadlines into a known proof kind and catalog generation. - Preserve other sleep blockers, including task watchdogs, conversations, live runs, external credentials and recovery. - Update the protected route, OpenAPI contract, documentation and PostgreSQL regression tests. ## Verification - Full workspace typecheck and build passed. Final server typecheck and full build passed after the final guards. - Initial safety, scheduling, protected-route and control-authentication checks: 326 tests passed. - Final safety and heartbeat scheduling checks: 253 tests passed. Includes reporting a future deadline, restoring scheduling from PostgreSQL and one dispatch across competing schedulers. - Local general server checks: 20,260 passed; UI: 8,460 passed; CLI: 534 passed, including isolated reruns of two embedded-PostgreSQL startup failures. All remaining workspace groups and 158 serialized suites passed. The suite was completed in groups after the isolated infrastructure retries; the final onboarding change also passed the focused 674-test recovery/safety run. Earlier unchanged Git fixtures failed under Xcode's system `init.defaultBranch=main`, reproduced on the clean base and passed with a test-scoped fixture default; no source or machine configuration was changed. - Rebased issue, protected-route and scheduler checks: 115 tests passed; server typecheck passed. - The expanded change passed 674 idle-safety, scheduling and recovery regression tests, including six onboarding admission/invalidation cases. Final server typecheck and build passed. - Current-head Apex is 5/5 with no unresolved threads. All 54 current-head checks passed, with two expected skips. Four jobs were interrupted by runner shutdowns or lost communication on their first attempts; their targeted reruns passed. Live hosting qualification awaits the verified master release and matching host capability. ## Risks This intentionally covers a limited set of passive states and valid future monitors. Assigned active tasks without a proved waiting path, unknown states, claimed monitors and quota recovery still block sleep. It does not make every open issue safe to sleep. A matching host must recognize `routines_and_issue_monitors`, acknowledge the durable alarm and revalidate the exact owner, generation and deadline before stopping. The report alone grants no stop authority. Older hosts retain capability `1`. Independent database writers remain outside the admission contract. Existing scheduler claims and execution authority are unchanged. No schema migration is required. Roll back host negotiation to capability `1` to restore conservative admission, while retaining alarm delivery until outstanding alarms have been handled. ## Model Used - OpenAI GPT-6 (Codex), with repository editing, command execution and GitHub tools. The exact deployment ID and context-window metadata 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>
63 lines
2.7 KiB
TypeScript
63 lines
2.7 KiB
TypeScript
import { createHash, randomUUID } from "node:crypto";
|
|
import { sql } from "drizzle-orm";
|
|
import type { Db } from "@paperclipai/db";
|
|
import { validateCron } from "./cron.js";
|
|
|
|
const processGeneration = randomUUID();
|
|
const limit = 10_000;
|
|
|
|
export interface ScheduledWake {
|
|
version: 1;
|
|
kind: "routines" | "routines_and_issue_monitors";
|
|
generation: string;
|
|
nextWakeAt: string;
|
|
}
|
|
|
|
export async function readIdleRoutineSchedule(tx: Pick<Db, "execute">, ownerId: string, now: number): Promise<{
|
|
blocked: boolean;
|
|
scheduledWake?: ScheduledWake;
|
|
}> {
|
|
const unknown = await tx.execute<{ blocked: boolean }>(sql`SELECT EXISTS (
|
|
SELECT 1 FROM routines WHERE status NOT IN ('active', 'paused', 'archived')
|
|
) AS blocked`);
|
|
if (unknown.length !== 1 || unknown[0]?.blocked !== false) return { blocked: true };
|
|
const rows = await tx.execute<{
|
|
id: string; routine_id: string; company_id: string; kind: string;
|
|
setup_pending: boolean; cron_expression: string | null; timezone: string | null;
|
|
next_run_at: Date | string | null; routine_updated: Date | string; trigger_updated: Date | string;
|
|
scoped: boolean;
|
|
}>(sql`SELECT t.id, t.routine_id, t.company_id, t.kind, t.setup_pending,
|
|
t.cron_expression, t.timezone, t.next_run_at,
|
|
r.updated_at AS routine_updated, t.updated_at AS trigger_updated,
|
|
(t.company_id = r.company_id) AS scoped
|
|
FROM routine_triggers t JOIN routines r ON r.id = t.routine_id
|
|
WHERE r.status = 'active' AND t.enabled AND NOT t.archived
|
|
ORDER BY t.id LIMIT ${limit + 1}`);
|
|
if (rows.length > limit) return { blocked: true };
|
|
let earliest = Infinity;
|
|
const validTimezones = new Set<string>();
|
|
const validCrons = new Set<string>();
|
|
for (const row of rows) {
|
|
if (row.scoped !== true || row.kind !== "schedule" || row.setup_pending !== false
|
|
|| !row.cron_expression || !row.timezone
|
|
|| row.next_run_at === null) return { blocked: true };
|
|
if (!validCrons.has(row.cron_expression)) {
|
|
if (validateCron(row.cron_expression)) return { blocked: true };
|
|
validCrons.add(row.cron_expression);
|
|
}
|
|
if (!validTimezones.has(row.timezone)) {
|
|
try { new Intl.DateTimeFormat("en", { timeZone: row.timezone }); }
|
|
catch { return { blocked: true }; }
|
|
validTimezones.add(row.timezone);
|
|
}
|
|
const at = new Date(row.next_run_at).getTime();
|
|
if (!Number.isFinite(at) || at <= now) return { blocked: true };
|
|
earliest = Math.min(earliest, at);
|
|
}
|
|
if (!Number.isFinite(earliest)) return { blocked: false };
|
|
const generation = createHash("sha256").update(JSON.stringify([processGeneration, ownerId, rows])).digest("hex");
|
|
return { blocked: false, scheduledWake: {
|
|
version: 1, kind: "routines", generation, nextWakeAt: new Date(earliest).toISOString(),
|
|
} };
|
|
}
|