Files
PaperClipAI/doc/idle-decision-expiry.md
T
DottaandPaperclip 3421366903 fix(server): schedule decision TTL expiry from saved deadlines (#17479)
## 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>
2026-10-27 18:10:36 -05:00

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.