Files
PaperClipAI/doc/idle-environment-cleanup.md
T
DottaandPaperclip 5982246771 fix(server): schedule custom-image setup expiry from committed changes (#17443)
## Thinking Path

> - Paperclip manages AI agent work and its execution environments.
> - Interactive image setup sessions must expire even when users leave.
> - The server currently scans these sessions on every maintenance tick.
> - Empty scans keep the instance and database active.
> - Existing session rows already record status and expiry.
> - This pull request uses transaction signals and the shared scheduler
for those rows.
> - Empty and completed setup history no longer create recurring cleanup
queries.

## Linked Issues or Issue Description

**What happened?**

The server checks custom-image setup expiry every heartbeat maintenance
tick, even when no active session exists.

**What did you expect?**

Session writes should update the next cleanup deadline. Existing
provider cancellation and cleanup ownership must remain intact. Empty
state should stay quiet.

**Steps to reproduce**

Run an instance with heartbeat scheduling enabled and no custom-image
setup sessions. Observe repeated setup-expiry reads.

Refs #17145. This is the first PR in the recurring server sleep stack.
Its base is master. The full requested scope and verification contract
are recorded in `doc/plans/2026-10-24-recurring-server-sleep-stack.md`.
Follow-up PRs target their predecessor and each own a separate reason
for recurring work.

## What Changed

- Carry the historical cleanup retry-observation fixture repair and its
existing external test-database hook at the stack foundation. Runtime
scheduling is unchanged.

- Carry the previously verified login-cleanup fixture settlement
correction at the stack foundation so every intermediate CI run receives
it. This is test-only maintenance; custom-image expiry behavior is
unchanged.

- Add a custom-image expiry worker to the existing delivery coordinator.
Remove its maintenance-tick scan.
- Signal initial creation, provider refresh, cancellation/failure, and
promotion inside their owning transaction.
- Keep the active-status predicate, earliest saved expiry, and provider
behavior in the custom-image service. Shared code gains only a queue
topic.
- Preserve startup recovery, bounded retries, concurrent write
generations, and drain deferral. No tables or external alarm changes.
- Add PostgreSQL lifecycle tests and repair this suite's database
cleanup callback. A dedicated external test URL permits disposable
PostgreSQL when the host cannot start another embedded cluster.

## Verification

Current head: `3507c6be1e054fdd93353d5dcf77d4899b44518f`. This remains a
single-duty PR stacked on master.

- Integrated pinned master `8548173fc9efdc0fa07644fb92dcc0b6851a95e5`
with normal merges. Every branch passed generated-schema checks and its
target-branch migration-order gate. Existing SQL is unchanged; the 25
stack migrations follow master’s new migration.
- Duty-specific producer coverage, lifecycle tests, and earlier failure
records:
[2026-10-08-instance-sleep-and-scheduler-inventory.md](https://github.com/paperclipai/paperclip/blob/3507c6be1e054fdd93353d5dcf77d4899b44518f/doc/plans/2026-10-08-instance-sleep-and-scheduler-inventory.md),
[2026-10-24-recurring-server-sleep-stack.md](https://github.com/paperclipai/paperclip/blob/3507c6be1e054fdd93353d5dcf77d4899b44518f/doc/plans/2026-10-24-recurring-server-sleep-stack.md).
- Combined tip `51e6cbe67979b55b340ea9f0f434c5b5c682ccd6`: **355
startup/cancellation/recovery/dispatch/preservation tests**, **75
connection-management tests**, and production server typechecking pass.
These combined checks do not mean every intermediate PR independently
ran the full repository suite.
- Real master-to-stack database upgrade passes: **379 unchanged prior
migration receipts plus 25 new migrations**, with the gateway
responsibility fixture preserved.
- Owning-branch checks: 84 runner installation/settlement cases plus
runner typechecking and generated TypeScript/Rust profile agreement; 253
GitHub review workflow cases. Stop-attribution and held-worker readiness
regressions failed before their fixes.
- Failure accounting: the initial combined run passed 349 cases and
failed six idle assertions in a reused preservation database containing
old cancelled runs. Repeating the combined command with a fresh
preservation database passed. Earlier invalid project selection, merge
import/type errors, and the interrupted full local suite are not counted
as passing checks.
- **Latest-head CI and review remain required.** Prior CI findings
include GitHub idle observation, route teardown, attention cleanup,
touch-picker E2E, workspace health, and voice-cleanup timing; local
passes do not erase those failures. No full local repository-suite,
deployed sleep, fleet cost, or live Copilot qualification claim is made.

## Risks

- Signals cover supported service producers sharing the DB owner.
Independent SQL and another process require the existing
restart/reconciliation contract.
- Failed provider cancellation retains the existing failed-session and
historical-cleanup behavior. This PR changes scheduling, not teardown
authority.
- Active rows without an expiry preserve the existing behavior; no
deadline is invented. Cloud sleep eligibility remains conservative.
- The worker remains in the existing heartbeat-enabled startup path. It
does not change disabled-scheduler behavior or prove deployed
suspension.

## 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

Review follow-up: Added a real producer commit during a paused deadline
read. Updated startup assertions for coordinator ownership and removed
added code comments. The three affected suites pass (102 tests). The
separate login-cleanup suite also passes locally (16 tests); its prior
CI consume test timed out, and the fresh complete CI run passed.
External PostgreSQL tests now use the repository guard for disposable
databases ending in `_test`.

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-27 14:17:01 -05:00

149 lines
9.4 KiB
Markdown

# Idle environment cleanup
Pending lease cleanup and historical resource verification use the existing
transaction signals, delivery coordinator, and in-process scheduler. They no
longer run unconditionally from the server maintenance tick. The run reaper
performs its inline pending-cleanup sweep only after actually recovering a run
or an orphaned active lease. Explicit user retry keeps its existing direct path.
## Work and ownership
| Duty | Truth | Trigger and next deadline | Quiet condition |
| --- | --- | --- | --- |
| Failed teardown | Existing `environment_leases` rows in `pending_cleanup` | Commit signal; later of row backoff and saved retry/ownership deadline | No pending lease or deferred orphan |
| Failed acquisition before a DB row exists | Existing local orphan spool and the producing runtime's buffer | Local buffer registration; five-minute retry while buffered; startup spool load | Durable row owns recovery and spool copy is removed |
| Historical provider verification | Existing failed ephemeral leases and finished custom-image setups without matching cleanup proof | Commit signal; five-minute retry while an unverified candidate remains | No unverified candidates, including retained verified history |
`environment-cleanup-state.ts` owns the deadline query and the shared safe reader
for retry/ownership metadata. Cleanup claims and the scheduler use that same
reader, including malformed-number handling and the existing maximum horizon.
Startup retains the immediate scan, but never bypasses a saved in-flight claim or
retry cooldown. Subsequent scans respect the five-minute row backoff. Claims,
provider readiness, Stop authority, workspace retention, copyback, receipt
validation, and escalation rules stay with their current owners.
`environment-cleanup-work.ts` registers two independent workers. Their generation
counters preserve a wake until successful reconciliation, including a first write
during drain, changes during a sweep, and failure followed by drain. The existing
heartbeat interval bounds pending-cleanup starts after bursts of writes. Historical
verification has a separate minimum five-minute spacing, including commit-triggered
passes, and does not delay server readiness. No timer remains once
there is no obligation. A self-generated write can require a final reconciliation
before quiet. A failed sweep retains a retry. Shutdown unsubscribes local buffer
wakes; the delivery coordinator stops subscriptions/timers and joins active work.
## Producer coverage
The shared `writeEnvironmentCleanup` helper registers and awaits intent before a
write in a transaction. Existing transaction owners call
`notifyEnvironmentCleanup(tx)` directly. Nested calls retain outer transaction
settlement and uncertainty handling through the shared DB implementation.
- `environments.ts`: orphan insertion, release, metadata replacement, and run-wide
release. These stores cover normal provider teardown and failed acquisition.
- `environment-runtime.ts`: reusable sandbox cleanup claims and their settlement,
and Stop/export cleanup claims. Renewal of an already active claim retains its
bounded timer; it cannot create new pending work.
- `heartbeat/recovery.ts` and `heartbeat/run-control.ts`: orphan-active recovery,
retry settlement, and explicit recovery of historical leases.
- `environment-task-runtime.ts`: unconfirmed plugin task termination and plugin
replacement orphans, including a registered deferred flusher when the row
cannot be written. The flusher retains an idle hold until the row is durable.
- `sandbox-stop-and-retain.ts`: persisted Stop intent and its settlement.
- `native-workspace-export-resume.ts` and `native-workspace-export-retry.ts`:
terminal-export cleanup, resumed-export cleanup claims, and settlement.
- `workspace-recovery.ts`: verified copyback schedules destruction.
- `native-workspace-sync.ts` and the native session executor's backup stamp:
writes that can invalidate an existing historical row-version proof.
- `environment-custom-images.ts`: provider state, terminal state, and promotion.
Initial active setup insertion is not a historical verification candidate.
Other lease metadata writes neither create pending cleanup nor change the
row-version/provider identity of a matching historical proof. Changes to an
already pending claim remain covered by its existing deadline. Deletion removes
work; an already scheduled reconciliation observes that removal. Independent SQL
writers and another server process are not covered by these process-local signals.
## Deferred orphan buffers
Runtimes that hold an unwritten orphan register their flusher in a registry scoped
to the shared database handle. Registration wakes the worker without contacting
the database. A sweep flushes registered runtimes as well as its own startup spool;
this reaches a producer runtime's buffer even when it differs from the heartbeat's
runtime. The registry retains only runtimes with unresolved buffer ownership.
Unreadable directories, records, and malformed records retain a retry; a missing
spool directory is an empty spool. A spool read failure still allows other runtimes to flush their buffers and
persisted leases to clean up, then fails the sweep so recovery stays scheduled.
Each concrete cleanup pass reloads the spool, including entries appended by
plugin task replacement after startup and entries beyond buffer capacity.
No empty-spool filesystem or database polling is introduced. Existing limits on a simultaneous database,
spool, and buffer-capacity failure remain; this is not a new durable queue.
## Verification and boundaries
The PostgreSQL worker tests exercise quiet configured state and verified history,
real store activation, persisted backoff, failed teardown and restart, nested
commit/rollback, drain/resume, cross-runtime deferred work, failed spool reads,
shutdown joining, provider uncertainty, and historical pagination. Existing
runtime, reaper, Stop, custom-image, and workspace tests cover the underlying
cleanup authority and provider contracts.
The runtime fixture now gives each simulated instance its own DB handle and spool.
Its journal-write failure injection targets the transaction boundary rather than
the direct update that moved inside that transaction. Fake-clock worker tests wait
for PostgreSQL I/O and distinguish an executing sweep from a retained next wake.
This does not migrate discovery of orphaned active leases or legacy shutdown
workspace settlement from the broader run-recovery loop. Custom-image *active
session expiry* also remains on its existing tick; only historical verification
moves here. Other maintenance duties, external probes, and connected transports
still affect idle traffic. No Cloud alarm coverage or host-sleep exemption changes.
These tests prove the changed workers become quiet and recover; they do not prove
deployed suspension or fleet savings.
## Custom-image setup expiry
Setup expiry uses a separate `custom-image-expiry` worker. The custom-image
service owns the active-status predicate, the earliest saved `expiresAt`, and
provider cancellation. Startup registers the worker in the existing enabled
heartbeat housekeeping path; the maintenance tick no longer calls its sweep.
All supported setup producers live in `environment-custom-images.ts`: initial
creation, provider refresh, cancellation/failure status, and promotion. They
register intent in their owning transaction. A provider refresh can move the
deadline earlier; a terminal transition removes it. Historical verification
metadata does not change active expiry eligibility and does not need this signal.
Environment deletion can only remove obligations; an already scheduled pass
observes the removal. Independent SQL and other DB roots are outside the existing
process-local notification contract.
No active dated session means no recurring reads or provider calls. Overdue
batches and failures retain a bounded retry, while first writes and failed
reconciliation survive drain. A missing expiry retains the existing behavior
(no inferred expiration); sleep safety still treats unsupported live resources
conservatively. Provider cancellation, failure state, and historical resource
verification keep their existing semantics. No Cloud alarm coverage changes.
## Historical retry test observation
The historical verification worker intentionally does not delay server readiness.
Its provider sweep finishes before the following database pending-state read
registers a retry deadline. The regression test now waits for that future
deadline before inspecting it; sweep completion alone is not scheduler settlement.
The runtime worker, retry interval, and readiness contract are unchanged.
A controlled 150ms delay before the real pending-state query reproduced the
current CI failure: the immediate assertion observed a null deadline. The
corrected assertion passed with the same delay. The temporary delay was removed,
and all 14 cleanup tests passed against an isolated PostgreSQL database. The
existing external test-database hook is carried at the stack foundation so
intermediate branches can use this same verification path.
An initial attempt at the foundation could not start embedded PostgreSQL because
that earlier branch lacked the external fixture hook. A broader fixture wait for
all idle holds to clear then failed three cases that legitimately retain pending
cleanup obligations. That wait was removed. Only the historical deadline assertion
changed; its requirement is now a deadline strictly in the future. The complete
suite passes with the original readiness and idle-hold behavior preserved.