mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-02 16:58:52 +01:00
## 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>
178 lines
10 KiB
Markdown
178 lines
10 KiB
Markdown
# 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.
|