Files
PaperClipAI/doc/idle-join-request-retention.md
T
DottaandPaperclip 8406047b4e fix(server): schedule join request retention from durable deadlines (#17508)
## 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>
2026-10-27 20:03:16 -05:00

12 KiB
Raw Blame History

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.