mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 06:04:11 +01:00
## Thinking Path > - Paperclip manages AI agents and their work. > - Native runs retain finalization records for recovery and audit history. > - Idle sleep checks durable work before it stops an instance. > - The current check treats every retained finalization as pending work. > - A committed record can remain after its run and status effects have finished. > - This pull request recognizes complete records without deleting them. > - Instances can sleep while unfinished execution and recovery work remain protected. ## Linked Issues or Issue Description **What happened?** A retained `native_run_finalizations` row prevents idle sleep even after the run has finished and its status decision has been applied. The current gate checks whether any row exists. **Expected behavior** Complete finalization history should remain available without counting as pending work. Active leases, retries, accounting, recovery, and incomplete status effects must still prevent sleep. **Steps to reproduce** 1. Finish a native run and retain its committed finalization. 2. Confirm that its accepted result, assessment, applied decision, and delivered effects agree. 3. Request an idle report under a valid quiet admission hold. 4. Observe that the retained row reports background work before this change. **Paperclip version or commit** Reproduced on master at `cd0c86e5e1d427c8fa8aa8c3a9499391ee9b425b` with a local PostgreSQL fixture. **Deployment mode** Server with PostgreSQL. No provider calls are needed to reproduce the gate failure. Related PR and issue searches found no duplicate for this check. ## What Changed - Replace the blanket finalization-row check with a completion proof bound to the same company, issue, run, accepted result, assessment, and applied decision. - Require the owning run's successful workspace-finalization receipt; retain unresolved leases, retries, accounting, recovery, and status effects. - Share final-reply candidate discovery with the presentation recovery worker, so a completed run cannot hide a required reply. The pure query stays with native presentation; idle inspection uses it in the existing read-only transaction and five-second statement limit. - Register open warm sessions and in-flight close/checkpoint promises with local idle inspection. Historical controller/provider process metadata remains preserved. - Add PostgreSQL and local-warm regressions and document the retained-history rule. ## Verification - PostgreSQL sleep/finalization and chat-monitor continuation suites: 300 tests passed, including 56 finalization cases. - Local-warm regression: passed; an open session and its pending close/checkpoint both block sleep until retirement completes. - Server typecheck and server build: passed after review fixes. - Full workspace typecheck and build: passed before the review additions; hosted checks cover the updated revision. - An earlier local full invocation encountered two chat recovery failures that passed in isolation, and two existing Git workspace fixture failures caused by Xcode system Git initializing `main`. Those two pass with `init.defaultBranch=master` scoped to the test subprocess. That superseded full invocation was stopped for review revisions; no full local pass is claimed. No machine configuration or unrelated source was changed. - Fresh Apex review: 5/5 on `6846203fe2383bae1ede888fbaaedf83e9946e73`, with all findings addressed. - Hosted CI: fully green on `6846203fe2383bae1ede888fbaaedf83e9946e73`, including the final verify gate. Twelve jobs were interrupted by a simultaneous runner shutdown; rerunning the failed jobs completed successfully. - No hosted deployment of this change is claimed. ## Risks This changes an automatic-stop gate. An incomplete proof could stop an instance with pending work. The SQL fails closed and retains the existing admission, local work, plugin, schedule, backup, and provider-allocation gates. PostgreSQL cases cover unresolved leases, retry/recovery intent, accounting, projections, rejected results, unapplied decisions, and pending effects. No migration or history deletion is required. Reverting the predicate restores the conservative gate. ## Model Used OpenAI GPT-6 through Codex, with repository tools and local PostgreSQL verification. ## 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
3.9 KiB
TypeScript
63 lines
3.9 KiB
TypeScript
import { nativeResponsePresentationCandidates } from "./native-response-presentation-query.js";
|
|
import { asc } from "drizzle-orm";
|
|
import type { Db } from "@paperclipai/db";
|
|
import type { createDeliveryWorkCoordinator } from "../delivery-work-coordinator.js";
|
|
import { DELIVERY_QUEUES } from "../delivery-work-notifications.js";
|
|
import type { NativeResponsePresentationScope } from "../native-response-presentation-notifications.js";
|
|
import { onCommittedChatResponseAuthorityRegistered } from "../durable-chat-wakeup.js";
|
|
import { registerScopedDeadlineWork } from "../scoped-deadline-work.js";
|
|
import { recoverCommittedNativeChatResponse } from "./native-run-finalizer.js";
|
|
|
|
export function registerNativeResponsePresentationWork(db: Db,
|
|
coordinator: ReturnType<typeof createDeliveryWorkCoordinator>, canRun: () => boolean) {
|
|
return nativeResponsePresentationWork(db).register(coordinator, canRun);
|
|
}
|
|
|
|
type Entry = { id: string; companyId: string; issueId: string; agentId: string; dueAt: number };
|
|
|
|
export function nativeResponsePresentationWork(db: Db) {
|
|
const candidates = (scope?: NativeResponsePresentationScope, after?: string, runId?: string) => {
|
|
const query = nativeResponsePresentationCandidates(scope, after, runId).as("pending_native_response");
|
|
return db.select().from(query).orderBy(asc(query.id));
|
|
};
|
|
async function load(scope?: NativeResponsePresentationScope, signal?: AbortSignal, canRun = () => true): Promise<Entry[]> {
|
|
const entries: Entry[] = [];
|
|
let after: string | undefined;
|
|
while (canRun() && !signal?.aborted) {
|
|
const rows = await candidates(scope, after);
|
|
for (const row of rows) entries.push({ id: row.id, companyId: row.companyId, issueId: row.issueId, agentId: row.agentId, dueAt: row.nextAttemptAt?.getTime() ?? 0 });
|
|
if (rows.length < 100) return entries;
|
|
after = rows[rows.length - 1].id;
|
|
}
|
|
throw new Error("Native response presentation discovery stopped");
|
|
}
|
|
async function check(entry: Entry, signal?: AbortSignal): Promise<Entry | null> {
|
|
if (signal?.aborted) throw new Error("Native response presentation recovery stopped");
|
|
const scope = { companyId: entry.companyId, issueId: entry.issueId };
|
|
const [current] = await candidates(scope, undefined, entry.id);
|
|
if (!current) return null;
|
|
if (current.nextAttemptAt && current.nextAttemptAt.getTime() > Date.now()) return { ...entry, dueAt: current.nextAttemptAt.getTime() };
|
|
if (signal?.aborted) throw new Error("Native response presentation recovery stopped");
|
|
const recoveryStartedAt = Date.now();
|
|
await recoverCommittedNativeChatResponse(db, { ...scope, runId: entry.id, onlyWhenDue: true });
|
|
const [remaining] = await candidates(scope, undefined, entry.id);
|
|
return remaining?.nextAttemptAt && remaining.nextAttemptAt.getTime() > recoveryStartedAt
|
|
? { ...entry, dueAt: remaining.nextAttemptAt.getTime() } : null;
|
|
}
|
|
function register(coordinator: ReturnType<typeof createDeliveryWorkCoordinator>, canRun: () => boolean) {
|
|
const worker = registerScopedDeadlineWork(coordinator, {
|
|
queue: DELIVERY_QUEUES.nativeResponsePresentation,
|
|
decodeScope: (hint: string): NativeResponsePresentationScope | undefined => hint === "*" ? undefined : JSON.parse(hint),
|
|
matches: (entry: Entry, scope?: NativeResponsePresentationScope) => !scope || entry.companyId === scope.companyId && (!scope.issueId || entry.issueId === scope.issueId) && (!scope.runId || entry.id === scope.runId) && (!scope.agentId || entry.agentId === scope.agentId),
|
|
load: (scope, signal) => load(scope, signal, canRun),
|
|
check: (entry, signal) => {
|
|
if (!canRun()) throw new Error("Native response presentation recovery paused");
|
|
return check(entry, signal);
|
|
},
|
|
}, canRun);
|
|
const unsubscribe = onCommittedChatResponseAuthorityRegistered(db, () => worker.invalidate());
|
|
return { ...worker, stop: unsubscribe };
|
|
}
|
|
return { load, check, register };
|
|
}
|