## Thinking Path > - Paperclip manages agent companies and their access requests. > - Pending join requests appear in the attention feed and can age into automatic archival. > - The maintenance tick reads those requests even when no archive is due. > - Existing request rows already store the activity timestamp needed for retention. > - Committed changes can wake a worker that registers the next actual deadline. > - This PR gives join-request retention its own bounded worker and removes its recurring feed scan. > - Idle and resolved history stop producing retention traffic without changing access approval. ## Linked Issues or Issue Description **What happened?** Every retention maintenance pass reads pending join requests through the attention feed. Empty, resolved, kept, and future-due states still participate in recurring discovery. **Expected behavior** Committed request and retention changes wake the owner. It waits until the next actual archive deadline and stops querying when no eligible request remains. Existing approval and visibility behavior is preserved. **Steps to reproduce** Start an idle company with no pending join requests, or with requests younger than the 90-day archive threshold. Observe the maintenance feed repeatedly select join_requests. No state change is needed to cause those reads. This PR targets master after #17505 merged. Approval retention is #17497. Other attention source kinds remain separate migration duties. No duplicate open join-retention PR was found. ## What Changed - Add a join-request retention owner that preserves pending status, active-company visibility, matching invite ownership, and keep/archive controls. Invite expiry or revocation retains the existing feed policy. - Reuse one `createDecisionRetentionSweep` for approval and join paging, atomic retention and finite scan cycles that revisit failures despite continuing arrivals. Eligibility and source-row locks stay with each owner. - Register real saved deadlines, rounded upward in SQL to millisecond precision, with the existing coordinator. Preserve 100-row pages and thirty-second notification coalescing without idle polling. - Materialize unseen join state inside the existing authorized keep/archive/revive transaction, preserving archive choices and source locking. - Notify before request inserts and updates in the existing acceptance and Slack transactions. Use a small SQL-only helper for replay and approval/rejection updates. Company status, budget pause/resume, deletion, and retention controls notify this owner too. - Preserve the approved-only API-key consumption path without a new wrapper; it cannot produce pending retention work. - Exclude join requests from maintenance feed collection while retaining normal UI reads. - Add an idempotent concurrent partial pending index and prune old schema snapshots through the existing generator. - Document producer coverage, boundaries, failure evidence, and remaining work. ## Verification Test revision: `df1815fbba`. Normal integration of landed workspace precision `d23dd19750` has an identical complete source tree. This remains only the join-retention duty and its shared approval-retention mechanics, targeting master. - **55 worker cases pass**, including two new PostgreSQL regressions that failed before repair: continuing overdue arrivals no longer postpone a recovered failure indefinitely, while an entire failed page still allows later healthy rows to progress. - The nine complete suites passed 341 cases but failed an attention cleanup hook after its 16,400-run case. After vacuum/analyze of only that disposable fixture, the full 31-case attention suite passes unchanged. Together with the original eight passing suites (311 cases), all 342 distinct cases pass across complete-suite executions. Recovery is established; the original cleanup slowdown is not claimed explained. Server typecheck and production build pass. - Earlier CI at `df1d4d0` failed the unchanged decision-expiry restart deadline assertion (a thirty-second retry appeared before the saved expiry). All twenty unchanged expiry cases pass locally, but the CI timing cause remains unproven. No retry, timeout relaxation or assertion change is used to dismiss it; fresh final-head CI remains required. - **Five regressions failed before repair:** three unseen join controls returned 404, and both owners advertised a microsecond deadline one millisecond early. All pass. Two old real-producer expectations failed after the correct rounding change; they now derive exact expected milliseconds independently from database integer microseconds instead of truncating a JavaScript Date. - The initial timing-only concurrency test still passed a missing-lock mutation. It now observes the actual PostgreSQL wait before releasing the writer. Production passes; removing the lock fails with zero waiters instead of one. Source is restored before commit. The archive-first committed-order case confirms that later activity stays archived until explicit revival; no broader race guarantee is claimed. - Generated concurrent/idempotent `0393_open_grandmaster.sql` adds only the pending join index. All 391 parent journal entries and historical SQL files remain unchanged. Five generated snapshots remain (0389–0393). Migration safety/numbering, schema drift and actual apply/replay on six isolated PostgreSQL fixtures pass. - Existing lifecycle cases retain real HTTP invite acceptance/approval/rejection, Slack identity linking, rollback/outer commit, source visibility, failure rotation, drain/resume and shutdown coverage. Earlier stack setup and CI failures remain in the owning audit and stack ledger; they are historical evidence, not this head's acceptance. Commands: server Vitest for `join-request-retention-work`, `approval-retention-work`, `approvals-service`, `attention-service`, `decision-retention-service`, `budgets-service`, `tool-gateway-service`, `server-startup-feedback-export`, `scoped-deadline-work`; server typecheck/build; DB migration checks, snapshot drift and actual apply/replay. Fresh exact-head CI, security and Greptile/Ponytail 5/5 remain required. No full local repository-suite, deployed suspension or cost claim is made. ## Risks - A future supported producer must signal its transaction. Independent SQL writers and another active server process are outside the process-local notification contract. - A writer that wins the source lock first is rechecked before archival. If archival commits first, later activity preserves the archived state until explicit revival. Existing invite replay expires before the retention cutoff and Slack replay deduplicates. Future producers needing renewed visibility must request revival. Cross-table visibility retains the existing contract. - The worker registers only under the existing maintenance enablement switch. The internal deadline does not add Cloud alarm coverage or relax conservative sleep blockers. - New eligibility can wait within the thirty-second aggregate notification window; known deadlines preempt it. - Archival preserves pending approval status. Other attention sources and aggregate maintenance remain separate work. ## 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>
12 KiB
Join request retention while idle
Pending join requests already have durable activity timestamps. Retention now uses their next 90-day archive deadline instead of including them in the main maintenance tick's attention-feed scan. The regular attention UI still includes join requests. This changes automatic retention discovery, not access approval.
Owner and eligibility
join-request-retention.ts owns candidate eligibility, its locked recheck and
deadline. decision-retention-sweep.ts shares bounded paging, per-row retention
transactions and failure handling with the approval-retention owner. The shared
sweep consumes normalized candidates; each owner retains its table/query policy.
A candidate is a pending_approval request belonging to an active company, with
an invite whose company matches the request. Kept and already archived items do
not schedule work. Invite expiration or revocation does not remove the existing
attention item and therefore does not disable its retention. No provider policy
is interpreted by the worker or shared scheduler.
The next deadline is the earliest eligible updatedAt plus 90 days, rounded up in SQL to the scheduler's millisecond resolution. It must not wake a fraction of a millisecond early and then wait thirty seconds for recovery. The partial pending index orders updated_at and id. Resolved request history is excluded by the index predicate. Each pass attempts at most 100 due rows. The private cursor preserves database timestamp precision so a failed page cannot starve later work. Errors keep the shared 30-second retry; successful full pages continue until work is exhausted. No eligible rows means no scheduled SQL or network scan.
Each candidate is re-read under its request row lock before retention changes. A writer that wins that lock first is rechecked before archival. If archival commits first, a later activity write leaves the item archived until explicit revival; updating activity does not clear existing retention choices. The tests cover the writer-first race and the archive-first committed outcome. Supported invite replay expires before the 90-day cutoff and Slack access replay deduplicates; a future producer that needs to reappear after archival must request revival. The shared retention owner still controls keep, archive/revive, version checks, activity logging, and notification creation. Archiving does not approve or reject a join. Join requests have no origin agent, so their archival creates no agent delivery. Company and invite joins preserve existing visibility policy; this does not add cross-table serializability beyond the existing retention contract.
Producer audit
| Producer | Notification boundary |
|---|---|
| Invite acceptance creates a pending request | Existing acceptance transaction, before the request insert |
| Supported gateway invite replay changes activity | Small writeJoinRequestChange transaction around the existing SQL update |
| Automatic human approval after acceptance | Same helper around the status update |
| Explicit approval and rejection | Same helper around the status update |
| Slack self-service access request | Existing platform transaction, before the request insert |
| Company pause, resume, archive, or deletion | Company-level retention notification inside the existing transaction |
| Budget-driven company pause or resume | Same company notification in the accounting transaction |
| Keep/unkeep, manual archive/revive, accepted archive proposal | Existing typed retention notification now routes join_request to its worker |
Provider calls, membership grants, and unrelated route behavior stay outside the small SQL mutation helper. Nested callers retain the existing outer transaction settlement and uncertain-outcome protocol. The access router itself requires a root database connection; it is not wrapped in a caller transaction.
API-key consumption only permits an already-approved request. It cannot create pending retention work and does not acquire a new wrapper or signal. Invite acceptance that reuses a human request does not update that request. Revocation, expiry, and acceptedAt changes do not change the feed predicate. Invite/company identity is immutable in supported routes; company deletion is the invite delete owner. A future producer that changes these facts must join the same contract. Process-local signals do not cover independent SQL writers or another instance.
Lifecycle and limits
Startup reconstructs pending deadlines. The worker is registered only when the existing heartbeat maintenance switch is enabled. Writes during drain retain a wake without passive SQL. Failed work retains its retry across drain. Shutdown joins an admitted archive and stops before subsequent candidates; restart picks up the durable remainder. There is no new queue table or permanent DB listener. The in-process deadline is not a Cloud alarm. Existing unsupported-future-work sleep blockers remain in place.
Earlier verification
Seventeen new real-PostgreSQL cases pass. They cover actual invite acceptance, rejection, human approval, gateway replay, and Slack access requests; empty and resolved-history quiet; saved deadlines; startup reconstruction; keep and manual archival controls; company pause/resume; unchanged expired/revoked invite policy; normal versus maintenance feeds; nested commit/rollback; first writes and failed work during drain; concurrent source updates; 101-row microsecond paging with a full failed page; and shutdown/restart.
Another 139 consumer cases pass: 71 invitation/startup cases, 21 approval worker cases, and 47 shared retention/attention/budget cases. This is 156 selected tests, not a full local suite claim. The first consumer command used a nonexistent retention test filename and ran only the approval suite; the corrected selection ran the actual retention suite against its dedicated external database.
Initial test failures exposed fixture assumptions: the access router cannot run on a transaction handle; unresolved recovery cannot be expected to become idle; Slack links require actual endpoint/principal ownership rows; and shutdown tests must establish admission and deterministic candidate order. Those fixtures were corrected without weakening assertions or production checks. The first replay fixture lacked required gateway credentials. The final fixture supplies test-only credentials. A reused transaction spy retained admission calls during the quiet assertion; restoring it before the quiet measurement fixed the measurement.
Migration 0387 applies, the full migration set replays, and the concurrent index statement replays idempotently. Migration safety and numbering checks pass; the five newest schema snapshots are retained. The actual next-deadline query with 40,000 resolved requests and one pending request uses the pending index and executes in 0.017 ms locally. The probe rolls back its data. This is query-plan evidence, not production latency or measured savings. Workspace typechecking and the production build pass. Fresh PR CI/review remain required before readiness.
Current review repair and verification
The duplicate approval/join archive loop is now one createDecisionRetentionSweep
implementation. Each owner supplies its candidate ordering boundary, page, locked current-state
lookup and next deadline. This keeps eligibility, company/invite joins and
source-row locks with their owner while sharing cursor/error/retention behavior.
The existing 100-row budget, PostgreSQL text cursor and failure rotation remain.
Both workers coalesce notification-driven global reads under a thirty-second
aggregate budget; saved deadlines preempt it and no idle scan is scheduled.
Manual keep/archive/revive can materialize an unseen join request in the existing retention write transaction, as approvals already can. The company-scoped source is locked, its real activity is read, and existing versioned state is synchronized. Authorization, archive choices and other source kinds retain their contracts. No route bookkeeping, queue table, permanent listener or Cloud exemption is added.
Five real regressions failed before repair: three unseen-request controls returned 404 and both owners rounded a saved microsecond deadline down. All pass. The first two-owner run passed 50 cases but failed two old producer expectations that used a truncated JavaScript Date. They now compute the expected scheduler millisecond independently from integer database microseconds; exact assertions remain.
The first missing-lock mutation exposed a weak test: its timing-only writer gate
released before proving the sweep had reached the lock, so it still passed without
FOR UPDATE. The strengthened test observes the actual PostgreSQL lock wait before
releasing the writer. It passes with the production lock and fails with zero waiters
when that lock is removed. The production source was restored in finally before
commit. A separate archive-first committed-order case preserves the existing
archived-stays-archived behavior and verifies explicit revival.
All 340 cases in nine complete suites pass at 405c7dcdef, including all 31
approval and 22 join worker cases. Server typecheck and production build pass.
Normal integration of parent 0b0e844b77 changes only four independently merged
Runner E2E accounting files and the ledger; runtime and acceptance sources remain
byte-identical. Existing results retain their tested revision.
Generated concurrent/idempotent migration 0393_open_grandmaster.sql adds only the
pending-request index. All 391 parent journal entries and historical SQL files are
unchanged; five generated snapshots remain (0389–0393). Migration safety/numbering,
schema drift and actual apply/replay on six isolated PostgreSQL fixtures pass.
Earlier setup, timing, mutation and CI failures remain recorded here and in the
stack ledger. Fresh final-head CI, security and both 5/5 reviews remain required.
Retry progress under continuing arrivals
Each scan cycle saves the final due (updated_at, id) tuple, at full database
precision. Its pages stop at that boundary. Later due rows cannot keep extending
the cycle and postpone failed rows forever. The cursor still advances past an
individual failure, so one bad request—or an entire failed page—does not block
later healthy rows. Once that finite cycle ends, the next cycle revisits failures.
There is no new durable queue or per-row retry registry. Restart reconstructs
eligibility from existing rows.
Two real PostgreSQL regressions failed before this repair: continued newer overdue
arrivals left a recovered approval and join request behind the cursor. Both now
archive in the next cycle. All 55 worker cases pass, including the existing
100-failure-page progress and microsecond-cursor cases. The nine-suite run passed
341 cases but failed the attention suite's cleanup hook after its 16,400-run case:
delete from heartbeat_runs exceeded thirty seconds. After vacuum/analyze of only
the disposable attention fixture, all 31 attention cases pass without code or
timeout changes. The other eight suites passed 311 cases in the original run;
342 distinct cases pass across those complete-suite executions. This demonstrates
recovery, not a proven explanation of the cleanup slowdown. Server typecheck and
production build pass at df1815fbba; the subsequent master integration has an
identical complete source tree.
Earlier CI at df1d4d0 also failed the unchanged decision-expiry restart test:
its earliest wake was a thirty-second retry rather than the saved expiry. The
complete unchanged expiry suite passes all twenty cases locally. Its CI timing
cause remains unproven; it is not labeled infrastructure or silently discarded.
Fresh final-head CI and reviews must pass before merging.