## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Open decisions have a saved expiry time. > - The main maintenance tick scans those times even when none is due. > - Committed creation and resolution can maintain the actual next deadline. > - The existing scheduler can run expiry only when that deadline arrives. > - This pull request gives decision TTL expiry its own worker. > - Terminal history becomes quiet while expiry and continuation delivery remain recoverable. ## Linked Issues or Issue Description Targets master after #17478 merged. This is slice 10 of the recurring server work migration. It owns decision TTL expiry. Target invalidation, interrupted effects, and retention remain separate migrations. The full ledger is in `doc/plans/2026-10-24-recurring-server-sleep-stack.md`. **What existing behavior does this improve?** Time-based decision expiry in the server. **Current behavior** The combined decision maintenance sweep queries expired open decisions on each tick. It mixes that query with target invalidation, effect recovery, and continuation delivery. **Proposed behavior** Creation, bundle creation, and existing decision updates notify the shared scheduler. A dedicated worker runs at the first saved open expiry. Resolving an open decision removes its deadline. The combined tick opts out of TTL selection while its other duties remain intact. **Reason and benefit** A future decision no longer requires repeated TTL queries before its deadline. Terminal history and an empty decision table leave no recurring TTL work. No queue table is added. ## What Changed - Add an expiry owner for bounded due selection, next-deadline lookup, and expiry settlement. Reuse it for expiry-on-use and the existing target expiration transition. - Use the central expiry-only creation notification before insert, including bundles and caller-owned transactions. Update and terminal transitions continue to notify expiry and continuation. - Register the enabled-heartbeat TTL worker before decision housekeeping. Preserve disabled-heartbeat behavior and the public combined sweep's default batch limit. - Honor `PAPERCLIP_DECISIONS_SWEEP_BATCH_SIZE` (default 100). Keep a fair expiry/ID cursor across failures. Stop between candidates on shutdown and reconstruct saved work after restart. - Recheck open status and due TTL during settlement. Merge metadata and persist activity atomically with expiry. Publish activity and deliver continuation after commit. Handle exact timestamp equality. - Continue successful overdue batches after one second through the existing deadline adapter; preserve thirty-second failure retry and all default callers. - Add generated migration `0389_brave_hulk.sql`, an idempotent concurrent partial index on open expiry and ID. Preserve five generated snapshots. - Add 20 real-PostgreSQL lifecycle cases and startup ownership assertions. Document contracts and limitations in `doc/idle-decision-expiry.md`. ## Verification Current candidate: `aa03d2c4c25af4e6c2acf49d3160bbaf6ada030b`. This PR targets master after #17478 merged. Creation now signals only its real expiry obligation; terminal updates preserve continuation delivery. - **117 current cases pass** across all 20 expiry, 54 decision/continuation, 42 startup and one scoped-deadline cases. Server typecheck/build pass. Separate migrated PostgreSQL fixtures exercise real transaction and notification behavior. - Single and bundle creation each failed a regression before the expiry-only signal repair: open rows unnecessarily notified continuation recovery. Both now arm their saved expiry without a continuation signal. Cancelling each row afterward still signals and delivers continuation normally. The distinction is in the existing central notification owner; there are no per-route flags or counters. - **Three new real PostgreSQL regressions failed before repair:** a configured batch of two was ignored, a successful 101-row backlog waited for the thirty-second error retry, and an activity insert failure left the row expired without its audit. All three pass. The audit failure rolls back expiry; retry records one activity, preserves unrelated metadata and wakes once. - The shared deadline adapter has optional `continuationMs`. Only this owner opts into one-second successful overdue continuation. Other callers keep the previous default, and errors still retry after thirty seconds. The archive consumer passes with the option absent. - Migration `0389_brave_hulk.sql` is regenerated from the parent's metadata. Parent SQL/journal entries are preserved, five generated snapshots remain, schema drift and migration numbering/safety pass. Fresh migration apply and replay pass. - Lifecycle coverage includes first producer activation, future-deadline quiet, terminal history, exact equality, commit/rollback, restart, drain/resume, failed expiry and wake delivery, fair paging through failed rows at PostgreSQL timestamp precision, shutdown and recovery. - Failure accounting: the first full repair selection passed 113 cases and failed a startup assertion that observed a pending follow-up notification before reconciliation. The assertion now waits for the exact saved future deadline, then still proves no early expiration. Earlier fault-fixture and timestamp-cursor failures remain recorded in [the behavior audit](doc/idle-decision-expiry.md). Extra upstream embedded-only tests attempted on the parent could not initialize locally; CI must cover that environment. Commands: server typecheck/build and Vitest for `decision-expiry-work`, `decisions-service`, `decision-retention-service`, `server-startup-feedback-export` and `scoped-deadline-work`; DB migration checks, snapshot drift and actual migration apply/replay. No full local repository-suite or deployed sleep/cost claim is made. Fresh current-head CI and Greptile/Ponytail remain required before merge. ## Risks - Expiry uses `expiresAt <= now`; the old combined loop skipped exact equality. The deadline boundary is now covered explicitly. - TTL expiry records the TTL reason. Target-driven expiry preserves its own reason. When both causes apply, the first terminal transition wins. - Supported producers share the application DB signal owner. Independent SQL writers or multiple active processes need a separate notification contract. New deadline-edit APIs must use the existing helper. - The index requires top-level concurrent migration execution. It is registered with the migration owner and replayed in verification. - Future in-process deadlines are not external Cloud alarms. Existing sleep guards remain conservative. This PR does not claim host/database suspension or measured savings. ## 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 --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
10 KiB
Decision TTL expiry while idle
Open decisions already contain an expiresAt deadline. The expiry worker now
uses that timestamp instead of scanning TTL candidates on each maintenance tick.
This slice owns time-based expiry only. Target invalidation, interrupted effect
recovery, and retention remain separate duties.
Producers and transaction ownership
Creation and bundle creation call notifyDecisionCreated before the decision
insert in their existing transaction. It signals expiry only: an open row cannot
owe continuation delivery yet. Existing decision updates share notifyDecisionChange, which now
signals expiry as well as continuation delivery. Cancellation, dismissal, and
execution claims therefore remove obsolete expiry deadlines. All production
inserts and updates are in the decision owner; acknowledgement only changes
terminal delivery metadata and cannot change an open expiry. No supported API
currently edits an existing decision's expiry timestamp. A future such writer
must use this same notification boundary.
The helper adds no recovery wrappers to routes. Outer transactions and nested savepoints retain the shared commit/rollback and uncertain-outcome protocol. Creation without a committed row cannot publish a business deadline. Startup reconstructs saved deadlines, including already-overdue work. Independent SQL writers and another active server process need their own wake contract.
decision-expiry.ts owns due selection, the next deadline, and expiry settlement.
decisions.ts delegates expiry-on-use and the existing target-driven expiration
transition to that owner. Target selection remains outside the TTL worker.
Startup registers TTL work before the combined decision sweep. The periodic
combined sweep opts out of TTL selection and cannot expire a row merely because
its TTL elapsed. Its public default still includes TTL and preserves the
configured combined-sweep batch limit.
Deadline, settlement, and recovery
The next deadline is the earliest open expiresAt. A partial index on
(expires_at, id) covers open decisions. The query selects one indexed row;
retained terminal history does not enlarge the deadline lookup. Expiry is due
when expiresAt <= now, including equality. The old combined path skipped the
exact equality despite selecting that row; this migration fixes that boundary.
Each worker pass honors PAPERCLIP_DECISIONS_SWEEP_BATCH_SIZE (default 100),
ordered by expiry and ID.
Its in-memory cursor advances across failures and wraps after the last page.
Remaining overdue work retains a one-second continuation through the saved
earliest deadline after a successful pass.
Failures retain the existing 30-second retry throttle. New earlier creation or
resolution of the earliest decision causes deadline reconciliation. When only a
future expiry remains, the worker does not query again until that deadline or a
committed change. No open decisions means no recurring expiry work.
Settlement requires the row to remain open. TTL settlement also rechecks that
the saved deadline is due. Metadata is merged into the current row. The existing
expiry activity is persisted in the same transaction as the terminal transition.
Publication and origin continuation follow commit. If the audit insert fails,
the expiry and continuation obligation both roll back. A deadline-driven expiry
records ttl; target-driven expiration retains its own reason. When both causes
are possible, the first successful terminal transition determines the reason.
Continuation failures belong to the continuation worker after the decision is
expired. Neither owner replays decision effects.
Drain and standby defer dispatch while retaining the request. The worker checks the coordinator's AbortSignal between candidates. Shutdown finishes the admitted expiry and leaves untouched candidates for restart. It does not process the whole backlog before stopping. There is no new queue table or permanent DB listener, and no provider call is held inside a new transaction.
Limits
The disabled-heartbeat branch keeps its existing behavior and does not register this worker. Expiry-on-use remains enforced independently of background work. The in-process deadline is not an external Cloud alarm. Existing conservative sleep blockers remain; this migration does not permit an instance to sleep with unsupported future obligations. Worker quiet is not proof of host or database suspension.
Verification
The original combined selection passed 103 tests: 14 initial real-PostgreSQL expiry worker cases, 50 decision service/continuation cases, and 39 startup cases. It covered empty and terminal-history quiet, actual create and bundle producers, exact expiry, moving the next deadline earlier, cancellation, restart, outer rollback and commit, first creation during drain, failed retry through drain, failed wake delivery, separate aggregate/TTL ownership, 101 candidates with a failed first row, creation during delivery, and shutdown/restart with outstanding candidates. The parent archive race and bounded continuation shutdown regressions also pass.
The first selection had 97 passes and one fixture failure. The fault test cleared its error collector before an already-admitted failed query had finished. It now checks all collected causes after recovery settles. The next selection passed 101 cases; after the parent review repairs and bounded expiry/shutdown coverage, all 103 pass. No production hold, retry, or timeout was weakened.
The original index applied and replayed successfully; after normal parent
integration it is regenerated as 0389_brave_hulk.sql. Fresh apply also passes. With 40,000 synthetic decisions
and one open row, the next-deadline query uses the partial index, reads two shared
buffers, and executes in 0.022 ms locally. The probe transaction rolls back. This
is query evidence, not a production latency or savings claim. Workspace
typecheck and build pass on the combined branch. Full PR CI and fresh review
remain required before readiness.
A follow-up paging regression uses 101 overdue rows sharing a PostgreSQL microsecond timestamp, with the first 100 writes failing. The original Date-based cursor rounded the timestamp to milliseconds and repeatedly selected that failed page, starving the last valid row. The cursor now carries PostgreSQL timestamp text into its tuple comparison; business deadline calculations still use Date. The regression failed before the fix and verifies progress past the failed page, then recovery of all failed rows after the fault is removed. The repaired expiry suite passes all 15 cases, and server typechecking passes.
Current review repairs and verification
Three review findings failed real PostgreSQL regressions before repair: the
standalone worker ignored the configured batch limit, a successful overdue
backlog waited thirty seconds between batches, and a failed audit write left the
row expired without durable activity. The worker now honors its existing batch
configuration and continues successful overdue work after one second. The
shared deadline adapter accepts an optional continuationMs; omitted values
preserve every existing consumer's retry interval. Errors still retain the
existing thirty-second retry. Expiry and durable activity commit atomically,
then publish activity and deliver the origin continuation. Provider calls stay
outside that transaction.
All 129 selected cases pass: 18 expiry, 53 decision/continuation, 15 archive, 42 startup and one scoped-deadline case. The archive selection exercises an existing shared-deadline consumer with the new option absent. Server typecheck/build and generated snapshot/schema drift pass. Parent journal entries remain unchanged and five generated snapshots remain. No snapshot was hand-edited.
The first complete selection passed 113 cases and failed the future-startup
assertion because it inspected the scheduler before an additional committed
notification reconciliation finished. Coordinator ready awaits the first
admitted pass, while a notification arriving during that pass can schedule
another immediate pass. The assertion now waits for reconciliation to expose
the exact saved future deadline, then still proves the decision does not expire
early. The three production regressions and this fixture failure remain
recorded; no error retry or business deadline was relaxed to pass the tests.
Fresh current-head CI and reviews remain merge gates.
After normal integration of continuation recovery's final coalescing and
deterministic contention tests and pinned master 8161f2a813, all 130 selected
cases pass: 76 expiry/archive/startup/deadline cases plus all 54 decision cases.
Server types/build pass. Existing notification coalescing remains thirty
seconds for continuation recovery; expiry's successful backlog continuation is
one second, and failure retry remains thirty seconds. Migration replay reports
no pending migrations and the parent journal remains an exact prefix. Extra
upstream embedded-only tests attempted during parent integration could not
initialize locally; fresh CI must cover that environment, as recorded in the
archive parent PR.
Normal integration with master-target continuation parent 18f682863c preserves
expiry, deadline and producer implementations exactly. All 61 expiry/startup/
scoped-deadline cases and server types/build pass with the rebuilt Runner vendor.
The previous full 130-case selection remains evidence at its stated revision.
No migration SQL changed, and five generated snapshots remain. Fresh head CI
and reviews are required after this parent integration.
Creation notification review repair
Single and bundle creation previously notified continuation recovery even though
the inserted decision was open. Two real producer regressions failed before
repair because continuation received intent and settlement. Creation now uses the
existing central notification file's expiry-only notifyDecisionCreated helper.
Update and terminal transitions keep notifyDecisionChange, including the normal
continuation signal. No optional flags, per-route bookkeeping or new table is
needed. Both regressions verify the exact future expiry, no early wake, and a
subsequent cancellation that still signals and delivers continuation normally.
All 117 current expiry/decision/startup/deadline cases pass, including 20 expiry cases. Server types/build pass. The preceding f7 candidate's identical-tree merge proof remains history; this subsequent creation-signal repair changes runtime behavior and has its own acceptance above. Fresh current-head CI and both reviews are required.