fix(server): schedule historical unsafe export recovery (#17687)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Native runs can retain accepted results after an unsafe workspace
export fails.
> - Historical recovery can retire an old notice or resume the accepted
result without another provider turn.
> - The periodic reaper repeatedly scans failures that cannot currently
recover.
> - Existing rows and committed changes can identify the actual recovery
obligation.
> - This pull request wakes recovery from those changes and retries only
owed work.
> - Dormant history becomes quiet while ownership and explicit operator
decisions remain protected.

## Linked Issues or Issue Description

**What happened?**

The native finalization reaper repeatedly visits historical unsafe
workspace-export failures. Cancelled tasks, newer runs, changed
contracts, and explicit operator decisions can make those rows
permanently ineligible. They still produce periodic database reads.

**What did you expect?**

Retire stale unsafe-export notices and recover eligible accepted
results. Stop reading dormant history until a relevant committed change
occurs. Keep a retry while a real obligation is blocked by ownership or
a transient failure.

**Steps to reproduce**

Retain an unsafe-export terminal failure whose task is cancelled or
superseded. Leave the instance idle after startup. The periodic reaper
continues reading that history.

Prerequisite #17680 is merged into master. Related: #14402 established
safe export finalization; #14329 was an earlier historical recovery
proposal. #17679 owns separate policy-review cleanup. This PR changes
only historical unsafe-export scheduling and its producers.

## What Changed

- Register a scoped worker through the existing coordinator and
scheduler. Remove this duty from periodic reaping. Preserve the
manual/startup recovery entry point.
- Distinguish stale notices, eligible accepted results, dormant history,
and concrete ownership contention. Reuse the existing mutation owner and
lock order. Preserve explicit settlement vetoes and exact
result/contract identity.
- Signal relevant task, run, action, and deletion writes before they
commit. Existing transaction boundaries handle nested commits and
uncertain outcomes. Notifications contain only scope IDs.
- Retain a retry only for owed work. Reconstruct work at startup,
preserve drain/resume, and stop through the shared lifecycle.
- Add concurrent, idempotent partial index migration 0410 for unsafe
terminal failures. Keep the five newest generated schema snapshots.
- Document producer coverage, query plans, supported writer boundaries,
and test failures in the unsafe-export audit.

## Verification

- Source `2a13dd08a6cc52f0adab09e3df9ce31e97b746b9` passes all 2188
cases across 24 complete files, without skips. The main selection passes
2129 cases in 192.58 seconds; safe-replacement passes 59 in 26.04
seconds. Coverage includes all 968 native-session cases, real
export/action producers, explicit continuation, workspace
reconstruction, interaction routing, Stop, queue and Slack consumers.
The audit gives per-file counts.
- Twenty separate fresh PostgreSQL 17 databases migrate through 0410
before setup. Four embedded-only fixture imports temporarily use their
own external database on this IPC-exhausted host, then are restored byte
for byte. Assertions and timing limits stay unchanged. This is qualified
external-database acceptance, not an unmodified embedded CI run.
- Database build, server typecheck/build, module boundaries and
exact-head migration order pass. Normal integration preserves the
complete original runtime/schema patch and unrelated upstream blobs.
Original concurrent index SQL, all prior executable history, journal
entries and retained snapshot bytes are unchanged; five snapshots 0406
through 0410 remain. Added comment removal preserves the full
non-comment TypeScript token streams.
- A populated 0409-to0410 upgrade preserves all 408 prior migration
receipts and 38 companies. It appends exactly one receipt; replay adds
none. The index is valid and ready on fresh and upgraded databases.
- The owning audit retains earlier fixture failures, skipped checks and
notice-retirement regression history. Older combined stack checks retain
their original scope in their owning audits. Actual parent
`73d24aa5acfcf17b3f676b4da7cd05eb70b60b99` is reconciled through a
normal merge. Its tree equals the reviewed ancestor and the complete
accepted child tree is unchanged. Fresh published-head CI and Greptile
5/5 remain required. No deployed sleep, fleet-cost or live provider
qualification claim is made.

- Inherited prefix correction: current integration
`8f43c9f81fac23b50997e7f0e1d0d9cf383a8109` changes only two values in a
previously unselected corpus fixture. All production code, the complete
package tree and all 24 accepted test files remain byte-identical to the
2188-case source above. The additional complete 72-case corpus passes in
14.00 seconds and again on the same database in 13.08 seconds. Retained
PSC/OUT and sentinel rows remain unchanged. The repeat is not
double-counted. The parent audit retains the original collision and
rejected cleanup attempt.

- Latest parent context: integration
`4d51231abe8b68c21ce896d0d2ac16865978c8ab` carries only the parent's
one-argument GitHub observation correction and an independent master
cleanup assertion. All production code, packages, 24 accepted suite
files and the additional corpus are unchanged. The two focused
native-presentation cases pass in 15.14 seconds and the complete
cleanup-removal suite passes all six cases in 8.31 seconds on two fresh
0410 PostgreSQL fixtures. This does not claim the full parent workflow
was rerun here.

Current head `b28399babac72ba9db1c4a4ef882c5e87aded38c` has passing CI
and Greptile 5/5 with no actionable findings. The first CI attempt lost
runners: fourteen jobs explicitly received simultaneous shutdown
signals, two reported lost runner communication, and two aggregate gates
failed downstream. One failed-job retry on unchanged source passes. The
underlying host shutdown cause remains unconfirmed; no code fix or extra
retry is claimed. [CI run and
attempts](https://github.com/paperclipai/paperclip/actions/runs/40819408528).
No Ponytail verdict is present.

## Risks

- A missing future producer can delay dormant-history discovery until
restart. The audit names current producers. Independent SQL writers and
other server processes remain outside the process-local signal contract.
- Physical ownership and uncertain operations retain retries. Time alone
does not authorize taking workspace ownership. No provider turn or
unsafe archive retry is added.
- The recovery owner remains authoritative if state changes after
discovery. Terminal UI status does not authorize resource destruction or
accepted-result replay.
- The partial index adds matching-row write/storage cost. It builds
concurrently through the existing restart-safe migration runner.
- This proves the changed worker becomes quiet. It does not prove host
suspension, external wake coverage, or fleet cost savings. Other sleep
migrations remain. This slice still requires fresh current-head CI and
reviews.

## Model Used

OpenAI Codex, based on GPT-6, with reasoning and code-execution tools.
The exact serving model ID and context-window size are not exposed in
this session.

## 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
- [x] All Paperclip CI gates are green
- [x] 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>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-10-29 00:13:22 -05:00
1 parent 73d24aa5ac
commit 4e715eb7ea
45 files changed
+1269 -226

No files matched your search

@@ -2629,3 +2629,36 @@ The native finalization audit found separate historical unsafe-export recovery,
Obsolete policy-review cleanup is draft [#17679](https://github.com/paperclipai/paperclip/pull/17679) at `e671c66bba6e9a42aa570826e6f3420dba199399`. All 53 checks pass, Greptile reviewed that head at 5/5, and no unresolved threads remain. It is mergeable and unmerged.
Slice 45 owns automatic completion-review retirement only. It preserves direct finalizer ordering and the durable withdrawal trigger for reassessment. The worker's raw lookup propagates failures so failed discovery cannot look empty. See [the audit](2026-10-25-automatic-completion-review-sleep-audit.md). All 370 local owner/startup/consumer tests pass, as do workspace types/build. Exact-head remote gates remain required. Older stack CI/review gaps, master reconciliation and the remaining original duties are still outstanding.
## Slice 45 accepted; slice 46 unsafe-export audit
[#17680](https://github.com/paperclipai/paperclip/pull/17680) at
`be668fb975f119efeb4830405d4bb1ab65791f87` has 53 successful remote gates,
Greptile 5/5 at that head, and no unresolved review threads. It remains draft
and unmerged. The unsafe-export baseline passed all 51 tests on a fresh external
PostgreSQL database (`/tmp/sleep46-baseline.log`). Slice 46 must distinguish
action retirement and eligible result recovery from dormant historical failure.
The periodic backstop remains until all reactivation producers are covered.
Slice 46 now has a scoped worker and initial producer coverage, with the periodic
backstop deliberately retained until the direct-writer audit is complete. See
[the unsafe-export audit](2026-10-25-unsafe-workspace-export-sleep-audit.md) for
work predicates, remaining producers, test evidence, and preserved failures.
## Slice 46 producer wiring and verification
The unsafe-export worker now registers through heartbeat/startup and the periodic
reaper skips its historical scan. Direct task/run/action producers signal their
existing transaction boundaries. Physical-owner contention, real admin/stale-lock
release, queued-run cancellation, cancellation during ownership contention,
shutdown/restart, and multiple discovery pages are tested. The owner/startup and
consumer selections establish 626 distinct passing cases across 13 files after
running the initially skipped lifecycle cases against an explicit database.
A measured retained-history scan justified partial index migration 0398; fresh
application, replay, and validity checks passed. The owner audit records timings,
producer coverage, fixture failures, and the supported writer boundary. Workspace
typechecks and build passed; remote acceptance remains outstanding. Origin/master was fetched at
`6336c34041`; the full stack still needs final reconciliation with master. No PR
in this stack has been merged, and the original remaining scope is unchanged.
@@ -0,0 +1,361 @@
# Historical unsafe workspace export recovery
Current integration: 2026-10-29. Prepared parent: automatic completion-review
retirement #17680 at actual squash `73d24aa5acfcf17b3f676b4da7cd05eb70b60b99`.
Earlier draft parent: `be668fb975f119efeb4830405d4bb1ab65791f87`.
Startup registers the worker when maintenance is enabled. The periodic reaper
skips this duty. Explicit/manual recovery remains available. Fresh published-head
CI and review gates remain required; older parent scores do not prove them.
## Problem and owner
`native-runtime/native-workspace-export-recovery.ts` recovers old controllers'
`native_workspace_sync_out_unsafe_archive` failures. It does not retry an unsafe
archive or launch another provider turn. It can retire stale repair notices and
readmit an already accepted result after recording an explicit workspace omission.
The old caller in `native-finalization-reconciler.ts` visits 25 terminal failure
rows per pass. Ineligible historical rows remain in that set forever. Merely
putting those same rows on a 30-second deadline would preserve idle polling.
The new scoped worker uses the existing delivery coordinator, transaction-aware
notifications, scoped deadline helper, and scheduler. No table, provider branch,
independent timer, or host-sleep exemption is added. One partial index supports
scoped discovery without scanning completed history. The existing recovery owner
still performs all mutations and validates current state under its original
physical-owner and database-lock boundaries. The manual/startup scan still uses
that owner; extraction alone does not remove its existing behavior.
## Work predicate
Discovery joins the coordinator, exact native run, issue, and accepted result.
Company, issue, run, completion-contract, and result identities must agree.
The coordinator must still have the historical unsafe terminal failure.
One of two obligations must exist:
1. A stale unsafe action for this run is active/escalated, or resolved with the
old `new_source_execution_path` note. Retire that notice even if later work
prevents result readmission.
2. The existing readmission conditions hold: no explicit action settlement,
no newer run or other queued/running run for this issue, no competing active
recovery action, the latest contract matches, the issue is blocked/in-review
and assigned to this run's agent, the run is failed/succeeded with terminal
native failure, and checkout/execution ownership is absent or belongs to it.
A cancelled recovery action is an explicit veto. A resolved action with a null
or other non-automatic resolution note is also a veto. These are preserved.
Only the old automatic-resolution notes permit readmission.
A coordinator lease or physical owner can delay an actual obligation. The worker
retains its retry; it does not infer that the owner stopped because time elapsed.
Dormant history has no retry. Relevant committed state changes must rediscover
it. The producer audit below records the supported local writers.
Discovery uses 100-row keyset pages. Dispatch uses the shared fair company batch
with a five-entry limit. Exact pending work retries after 30 seconds; exceptions
use the shared bounded backoff. The check reads the exact run again before
calling the owner, then retains a deadline only if the work predicate still
holds. Successful recovery leaves an omission barrier, never a copyback receipt.
## Producer coverage checkpoint
Implemented:
- The real finalization-failure writer signals before committing an unsafe
terminal failure. It does not signal unrelated failure codes.
- `issueService.update` signals changed status, assignee-agent, execution-run,
and checkout-run fields in its existing transaction.
- Recovery-action insertion, identity supersession, and relevant cause/run
identity changes signal in their existing transactions.
- Recovery-action resolution checks for a matching active action before
registering an intent. Repeated resolution of an already-settled action does
not wake this worker. Operator-guidance/attempt metadata alone does not wake it.
- The shared heartbeat run-status writer signals changed status, using the
already-loaded native issue and context issue identities. No extra lookup is
needed to identify those issues. Both distinct issue references are covered.
Additional producer coverage:
| State that changes eligibility | Existing owner paths now signalling before their writes |
| --- | --- |
| Task status, assignment, or stale ownership | Task update, terminal checkout/execution release, admin force release, task-tree restoration, archived-member reassignment, chat conversation settlement, queued-comment release, status-decision effects, queue dependency blocking, stale-lock recovery |
| Competing queued/running run becomes terminal or leaves that set | Lifecycle status writes, queue cancellation, dispatch suppression/stale-run cancellation, retry deferral, active-run watchdog, execution-control recovery, orphan recovery, legacy settlement, native finalizer, finalization reconciler, restart recovery, and session executor |
| Recovery action status or lineage | Recovery-action service, issue repair routes, execution/legacy recovery resolution, workspace reconstruction, safe replacement, explicit continuation |
| Historical unsafe failure first appears | Finalizer failure, interaction bridge finalization error, coordinator-store retry marking |
| Deletion removes a competing run | Agent and company deletion signal the company before deleting runs |
Signals carry only company and optional issue IDs. Run writers use their already
loaded native issue and legacy context issue; both distinct references are sent.
Signals do not pass service contexts, provider data, or database objects through
JSON. Existing transaction owners remain authoritative. The few root writes
without a transaction now wrap only their database mutation and signal, not
provider calls. No caller gains new recovery authority.
The writer inventory inspected issue status/assignment/ownership, run status/
native phase/context, and action status/cause/evidence writes, including variable
patches. Metadata-only writes do not create eligibility. Monitor patches change
only monitor policy/state/deadlines. Workspace-binding patches change workspace
fields. Ordinary checkout/adoption moves to in-progress, and ordinary release
clears assignment; neither enables this accepted-result path. New-run ownership
also introduces a newer or active run, which prevents old-result readmission.
Queued-run suppression restores queued state, which remains a competing run.
Run coalescence retains the admitted task scope; ordinary context enrichment adds
comments, routing, session, or delivery metadata. Accepted result identity and
completion-contract history are immutable in supported service paths. The raw
SQL audit found no additional relevant state mutation.
Independent SQL writers and another server process are outside the existing
process-local notification contract. Startup reconstructs committed work. This
slice adds no cross-process listener or provider wake contract.
The heartbeat facade supplies admission state. Startup registers only in the
enabled-maintenance branch. The periodic reaper passes
`skipUnsafeWorkspaceExports`; direct/manual calls default to the old scan.
Shutdown stops the shared coordinator and joins its work. Ownership contention
keeps a concrete retry; stopped/drained execution cannot silently discard it.
## Query cost and migration
The measured SQL was captured from the actual worker through the PostgreSQL
client debug callback. A rolled-back fixture retained 10,000 completed native
results/coordinators and one stale unsafe notice. Without an index, the startup
query visited 10,074 result rows and took 9.827 ms. Scoped discovery removed
10,075 coordinator rows by filter and took 2.625 ms. LIMIT alone did not bound
history work.
The partial index on `(company_id, issue_id, run_id)`, restricted to the exact
unsafe terminal-failure state, reduced these measurements to 0.105 ms and
0.046 ms. The plans selected one coordinator from the partial index. These are
local fixture timings, not deployment latency or fleet cost claims. Dormant
unsafe history still requires eligibility evaluation when its scope changes.
Current migration `0410_freezing_kinsey_walden.sql` creates the index concurrently with
`IF NOT EXISTS` through the existing top-level migration runner. Fresh database
application, migration replay, direct SQL replay, and ready/valid index inspection
passed. The current generated snapshot set retains 0406–0410. Historical SQL and journal
entries remain intact. The index adds write/storage cost only for matching rows.
## Evidence and preserved failures
Baseline: 51 existing export tests passed on fresh PostgreSQL in
`/tmp/sleep46-baseline.log`.
Initial worker run: 65 passed, four failed (`/tmp/sleep46-owner-v1.log`). The new
producer fixture incorrectly inserted a preexisting unsafe action with a
non-production fingerprint. The actual failure writer superseded it with a
cancelled record. The existing explicit-settlement veto correctly prevented
readmission. The fixture now starts before that recovery action exists. No
production veto was weakened. The failed database remains available as
`paperclip_sleep46_owner_v1_20261025_test` on the local test Postgres instance.
The next export run passed all 70 cases (`/tmp/sleep46-owner-v2.log`). Real issue
and recovery-action producers then replaced the direct-hint fixture; all 71
export cases passed. A combined command also ran one non-database recovery test
but skipped 88 PostgreSQL cases because their separate environment variable was
unset (`/tmp/sleep46-owner-v3.log`). Those skips are not acceptance evidence.
With its dedicated database explicitly set, all 89 recovery-action tests passed
(`/tmp/sleep46-recovery-v1.log`).
Server typechecks passed after the worker, producer, and no-op notification
changes. The final combined run passed all 161 tests with no skips: 72 export-owner
cases and 89 recovery-action cases, including the no-op notification regression,
with separate explicit databases (`/tmp/sleep46-producers-v4.log`).
Current tests establish empty/history quiet, explicit settlement fences, stale
notice retirement without result replay, real failure creation, outer transaction
commit/rollback, restart, drain/resume, coordinator-lease retry, failed discovery,
and real task/recovery-action reactivation. The post-wiring selection below adds startup, ownership, and direct-producer
coverage beyond this initial checkpoint.
Post-wiring verification:
- The first expanded selection passed 226 cases and failed one new physical-owner
assertion (`/tmp/sleep46-owner-v5.log`). It inspected the next deadline while a
worker attempt was in flight. The test now waits for that attempt to settle;
no production ownership or scheduling rule was weakened.
- The next selection passed 229 cases. After adding real queued-run cancellation
and the index migration, all 230 owner/recovery-action/startup cases passed on
fresh databases (`/tmp/sleep46-owner-v7.log`). This includes physical ownership,
task cancellation during contention, admin and stale-lock release, shutdown/
restart, and 103 stale notices across discovery pages.
- Ten affected consumer suites passed 374 cases and skipped 22 lifecycle database
cases because embedded PostgreSQL was unavailable. The lifecycle suite now
accepts the validated explicit external-database option. Its 31 cases then all
passed, including those 22. Combined with the owner selection, this establishes
626 distinct passing cases across 13 files. Logs: `/tmp/sleep46-consumers.log`
and `/tmp/sleep46-lifecycle.log`.
- Query-plan fixture setup first violated the existing unique active-action rule;
the fixture now settles its old active action before inserting the owed notice.
All fixture data is rolled back. The capture script initially used a missing
root runner and then the wrong module format; using the database package runner
with an ES module captured the real SQL successfully.
Workspace typecheck and build passed. Exact-head remote CI/review remain required
before this slice is accepted. Other stack gates and migration reconciliation with
master remain outstanding.
## Notice-retirement follow-up
Retiring an older run's stale unsafe-export notice can make a newer accepted
result on the same issue eligible. The owner now registers an issue-scoped
notification in its existing transaction before that retirement, using the
already-loaded action rows to avoid signalling an unchanged notice. The next
pass rediscovers the issue, rather than only rechecking the older run. Existing
physical ownership and eligibility fences are unchanged.
A real PostgreSQL regression first reproduced the newer result remaining in
terminal failure after the older notice resolved. It now proves automatic
readmission without another write or restart, preservation of both accepted
results, no extra run or provider call, and quiet after settlement. All 80
export-owner tests and 63 startup tests passed. The first combined rerun failed
export fixture setup because the reused database retained its fixed company
prefix; the complete export suite subsequently passed on a fresh dedicated
database. Final integrated typecheck, build, and remote checks remain required.
## Current migration410 integration and complete acceptance
Only this slice's unlanded 0402 migration artifacts were deferred before a
normal merge of the current automatic-review parent. There are no conflicts.
The complete original runtime/schema patch and every unrelated upstream blob
are preserved. The migration directory and client matched the exact parent
before generation. `pnpm db:generate` generated
`0410_freezing_kinsey_walden.sql`. Its concurrent, idempotent SQL is byte-identical
to the original. All prior SQL, journal entries and retained snapshot bytes
remain unchanged, with five snapshots 0406 through 0410. Nine newly added
comment lines in two files were removed under CONTRIBUTING.md. The full
non-comment TypeScript token streams are identical. The explanations above
retain the notice-retirement, omission barrier and diagnostic ownership facts.
Source `2a13dd08a6cc52f0adab09e3df9ce31e97b746b9` passes **2188 cases across
24 complete files**, without skips. The main selection passes 2129 cases in
192.58 seconds; the independent safe-replacement suite passes 59 in 26.04
seconds. The selection covers real export and recovery-action producers,
workspace reconstruction, explicit continuation, interaction routing, Stop,
queue admission, Slack and all 968 native-session cases. Per-file counts:
| Complete file | Passing cases |
| --- | ---: |
| src/__tests__/issue-recovery-actions.test.ts | 89 |
| src/__tests__/native-finalization-recovery.test.ts | 32 |
| src/__tests__/native-interaction-bridge.test.ts | 7 |
| src/__tests__/native-workspace-sync.test.ts | 10 |
| src/__tests__/recovery-stale-issue-lock-sweep.test.ts | 40 |
| src/__tests__/server-startup-feedback-export.test.ts | 65 |
| src/__tests__/slack-conversation-lifecycle.test.ts | 41 |
| src/modules/run-dispatch/adapters/postgres.test.ts | 69 |
| src/modules/wake-queue/adapters/postgres.test.ts | 93 |
| src/modules/wake-queue/adapters/queued-comment-postgres.test.ts | 5 |
| src/services/execution-control-reconciliation.test.ts | 10 |
| src/services/explicit-native-continuation.test.ts | 335 |
| src/services/heartbeat/queue.test.ts | 32 |
| src/services/heartbeat/recovery.test.ts | 45 |
| src/services/heartbeat/run-control.test.ts | 51 |
| src/services/heartbeat/run-lifecycle.test.ts | 31 |
| src/services/heartbeat/upgrade-readiness.test.ts | 7 |
| src/services/legacy-execution-recovery.test.ts | 15 |
| src/services/native-runtime/native-restart-recovery.test.ts | 25 |
| src/services/native-runtime/native-run-finalizer-telemetry.test.ts | 29 |
| src/services/native-runtime/native-safe-replacement.test.ts | 59 |
| src/services/native-runtime/native-session-executor.test.ts | 968 |
| src/services/native-runtime/native-workspace-export-retry.test.ts | 80 |
| src/services/native-runtime/native-workspace-reconstruction.test.ts | 50 |
Twenty separate fresh PostgreSQL 17 databases migrate through 0410 before
setup. Destructive fixtures do not share databases. Four embedded-only test
imports temporarily use their own external database on this IPC-exhausted
host: interaction bridge, workspace reconstruction, Slack lifecycle and
wake-queue PostgreSQL. They are restored byte for byte. All assertions and
original timing limits remain unchanged. The lifecycle suite uses the supported
external URL already added by this slice. This is qualified external-database
acceptance, not an unmodified embedded CI reproduction. Actual database build,
server types/build, module boundaries and exact-head migration order pass.
A populated 0409-to0410 upgrade preserves all 408 prior migration receipts and
all 38 companies, appends exactly one receipt and adds none on replay. The
unsafe-export index is valid and ready on fresh and upgraded databases.
These checks preserve all earlier failures recorded above and verify current
worker, ownership and affected-consumer behavior. Publication remains held until
the actual prerequisite merges and its ancestry is reconciled. Fresh
current-head CI and reviews remain required. These tests do not establish
deployed host/database suspension, external wake delivery or fleet savings.
## Prepared parent carrying published cleanup ancestry
Normal merge `57ddb838cc93d5d8d0e7f8a0c2d1a2beed8b4394` adopts prepared parent
`790a27d28f23be0f436eb96a884dc7988ff10823`. Its source equals the prior accepted parent;
only parent documentation and ancestry change. Complete server, package, UI
and test trees remain identical to the accepted 2188-case source, including
the whole 0410 database tree. Publication remains held for the actual
automatic-review merge and fresh published-head CI and reviews.
## Inherited corpus repeatability fix and source-preserving acceptance
Normal integration `8f43c9f81fac23b50997e7f0e1d0d9cf383a8109` carries the
verified prefix correction through the updated automatic-review parent. The
only changed source file relative to the accepted child is the status-authority
corpus: its two fixed prefix values now use their own company UUIDs. All
production source, all packages including the complete 0410 database tree, and
all 24 files in the accepted 2188-case selection remain byte-identical to their
accepted source. No assertion, timing limit or teardown changes. The prior
2188-case evidence retains that precise source scope.
The additional complete 72-case status-authority corpus passes at this source
in 14.00 seconds, then passes again on the same fresh 0410 database in 13.08
seconds. Three retained PSC/OUT/sentinel company rows remain unchanged in the
inspected ID/name/prefix fields after both runs; each invocation appends two
distinct UUID-derived prefixes. Database build and exact-head migration order
pass. This adds one independently checked file to the previous selection; the
repeat is not counted again as distinct coverage. Previous collision and
rejected-deletion failures remain recorded in the parent audit.
The actual automatic-review prerequisite still must merge before publication.
Fresh published-head CI and review are required, with existing sleep and
external-wake limitations unchanged.
## Published automatic-review parent
Normal merge `6771a6c648c6ac1e9f0f73a91710d61bf5608aaf` adopts published parent
`40f84082526d6ba405f206475c919c3b4b67ec4c`. All server, package, UI and test trees
remain identical to the accepted child, including the 2188-case runtime/test
scope, the additional 72-case corpus with its same-database repeat, and the
complete 0410 database tree. Only parent documentation and ancestry change.
Publication stays held until the actual automatic-review squash and its
ancestry reconciliation. Fresh current-head CI and reviews remain required.
## Inherited CI observation correction and independent master assertion
Normal integration `4d51231abe8b68c21ce896d0d2ac16865978c8ab` adopts
published automatic-review parent `181bf800a79449961e09c995f2785dd7f8060bb3`.
The only source changes are the initial GitHub idle poll's established
five-second observation argument and master's company-skill cleanup assertion.
All production source, all packages including migration 0410, all 24 accepted
suite files and the additional 72-case corpus remain byte-identical. The
previous acceptance keeps that precise source scope; it is not rerun or
recounted here. The parent audit retains the original CI failure and controlled
comparison, including its inability to reconstruct the exact CI token timeline.
The two native-presentation GitHub idle variants pass in 15.14 seconds on this
runtime. All six cases in the complete cleanup-removal suite pass in 8.31 seconds.
Two separate fresh PostgreSQL 17 fixtures migrate through 0410. These suites
use supported external URLs with no source adaptation. The other 1779 cases
in the large chat file are outside the focused selection and are not claimed.
This does not claim the parent's full 274-case workflow was rerun on this slice.
Publication remains held for the actual prerequisite merge and fresh CI/reviews.
## Actual automatic-review squash ancestry
Parent #17680 landed as `73d24aa5acfcf17b3f676b4da7cd05eb70b60b99`. Its whole tree
equals reviewed parent `181bf800a79449961e09c995f2785dd7f8060bb3`, already present
in this child history. Normal merge `6eb13207800b85d00e74a9803278d52e7a9fd890`
resolves inspected duplicate squash-ancestry conflicts and preserves the entire
accepted child tree. Complete server, package, UI and test trees remain equal
to the accepted context source, including the entire 0410 database source.
All original upgrade and suite evidence retains its stated scope. Fresh
published-head CI and reviews remain required before merge.
+1
View File
@@ -559,6 +559,7 @@ const MIGRATIONS_WITH_TOP_LEVEL_STATEMENTS = new Set([
"0407_greedy_star_brand.sql",
"0408_short_red_skull.sql",
"0409_uneven_zuras.sql",
"0410_freezing_kinsey_walden.sql",
"0384_funny_adam_warlock.sql",
@@ -0,0 +1 @@
CREATE INDEX CONCURRENTLY IF NOT EXISTS "native_run_finalizations_unsafe_export_recovery_idx" ON "native_run_finalizations" USING btree ("company_id","issue_id","run_id") WHERE "native_run_finalizations"."phase" = 'terminal_failure' AND "native_run_finalizations"."failure_code" = 'native_workspace_sync_out_unsafe_archive';
@@ -1,6 +1,6 @@
{
"id": "c87ad360-f306-459b-b814-fde37327c6da",
"prevId": "d1e8fe9b-c8c9-46ed-b8e0-55bada43eeed",
"id": "c02426e4-de1a-4ce3-a541-99bb970c272a",
"prevId": "94acf3fe-50f3-4a97-a7fe-865abbc9d0a3",
"version": "7",
"dialect": "postgresql",
"tables": {
@@ -2665,6 +2665,50 @@
}
},
"indexes": {
"agent_instruction_copies_collection_idx": {
"name": "agent_instruction_copies_collection_idx",
"columns": [
{
"expression": "run_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "company_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"where": "NOT (coalesce(\"agent_instruction_working_copies\".\"receipt\", '{}'::jsonb) ? 'retainedByRunId') AND (\n (\"agent_instruction_working_copies\".\"attempts\" < 3 AND (\"agent_instruction_working_copies\".\"state\" IN ('prepared', 'pending_collection', 'unchanged_turn', 'warm_saved')\n OR (\"agent_instruction_working_copies\".\"state\" = 'preparing' AND \"agent_instruction_working_copies\".\"receipt\"->>'schema' = 'paperclip.agent-files.v1')))\n OR (\"agent_instruction_working_copies\".\"state\" = 'unavailable' AND \"agent_instruction_working_copies\".\"process_stopped_at\" IS NULL\n AND \"agent_instruction_working_copies\".\"location\" LIKE 'remote:%' AND \"agent_instruction_working_copies\".\"receipt\"->>'schema' = 'paperclip.agent-files.v1'))",
"concurrently": false,
"method": "btree",
"with": {}
},
"agent_instruction_copies_cleanup_idx": {
"name": "agent_instruction_copies_cleanup_idx",
"columns": [
{
"expression": "run_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "company_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"where": "\"agent_instruction_working_copies\".\"state\" IN ('saved', 'unchanged', 'resolved', 'unavailable')\n AND \"agent_instruction_working_copies\".\"process_stopped_at\" IS NOT NULL\n AND \"agent_instruction_working_copies\".\"receipt\"->>'schema' = 'paperclip.agent-files.v1'\n AND coalesce(\"agent_instruction_working_copies\".\"receipt\"->>'retainedByRunId', '') = ''\n AND \"agent_instruction_working_copies\".\"receipt\"->>'cleanupPending' IS DISTINCT FROM 'false'\n AND (\"agent_instruction_working_copies\".\"receipt\" ? 'baseline' OR \"agent_instruction_working_copies\".\"receipt\"->>'cleanupPending' = 'true')",
"concurrently": false,
"method": "btree",
"with": {}
},
"agent_instruction_copies_pending_idx": {
"name": "agent_instruction_copies_pending_idx",
"columns": [
@@ -33727,6 +33771,28 @@
}
},
"indexes": {
"heartbeat_runs_cancelled_workspace_preservation_idx": {
"name": "heartbeat_runs_cancelled_workspace_preservation_idx",
"columns": [
{
"expression": "id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "company_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"where": "\"heartbeat_runs\".\"runtime_mode\" = 'native' AND \"heartbeat_runs\".\"status\" = 'cancelled'\n AND \"heartbeat_runs\".\"runner_profile_json\"->'nativeWorkspaceSync'->>'state' = 'prepared'\n AND \"heartbeat_runs\".\"result_json\"->'nativeCancellation'->>'dispatchState' = 'acknowledged'\n AND (\"heartbeat_runs\".\"result_json\"->'workspaceRestoreRecovery'->>'schema' = 'paperclip.workspace-restore-recovery.v1'\n AND \"heartbeat_runs\".\"result_json\"->>'workspaceRestoreFailure' IN ('restore_permission_denied', 'restore_lock_timeout', 'restore_unsafe_archive', 'restore_failed')) IS NOT TRUE",
"concurrently": false,
"method": "btree",
"with": {}
},
"heartbeat_runs_retained_recovery_adoption_idx": {
"name": "heartbeat_runs_retained_recovery_adoption_idx",
"columns": [
@@ -33743,6 +33809,22 @@
"method": "btree",
"with": {}
},
"heartbeat_runs_scheduled_retry_cancellation_idx": {
"name": "heartbeat_runs_scheduled_retry_cancellation_idx",
"columns": [
{
"expression": "id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"where": "\"heartbeat_runs\".\"status\" = 'scheduled_retry' and jsonb_typeof(\"heartbeat_runs\".\"result_json\"->'startupCancellation') = 'object'",
"concurrently": false,
"method": "btree",
"with": {}
},
"heartbeat_runs_cost_accounting_pending_idx": {
"name": "heartbeat_runs_cost_accounting_pending_idx",
"columns": [
@@ -43026,6 +43108,34 @@
}
},
"indexes": {
"native_run_finalizations_unsafe_export_recovery_idx": {
"name": "native_run_finalizations_unsafe_export_recovery_idx",
"columns": [
{
"expression": "company_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "issue_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "run_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"where": "\"native_run_finalizations\".\"phase\" = 'terminal_failure' AND \"native_run_finalizations\".\"failure_code\" = 'native_workspace_sync_out_unsafe_archive'",
"concurrently": false,
"method": "btree",
"with": {}
},
"native_run_finalizations_control_deadline_idx": {
"name": "native_run_finalizations_control_deadline_idx",
"columns": [
+7
View File
@@ -2857,6 +2857,13 @@
"when": 1793243052342,
"tag": "0409_uneven_zuras",
"breakpoints": true
},
{
"idx": 410,
"version": "7",
"when": 1793244017955,
"tag": "0410_freezing_kinsey_walden",
"breakpoints": true
}
]
}
@@ -50,6 +50,9 @@ export const nativeRunFinalizations = pgTable(
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
unsafeExportRecoveryIdx: index("native_run_finalizations_unsafe_export_recovery_idx")
.on(table.companyId, table.issueId, table.runId)
.where(sql`${table.phase} = 'terminal_failure' AND ${table.failureCode} = 'native_workspace_sync_out_unsafe_archive'`),
controlDeadlineIdx: index("native_run_finalizations_control_deadline_idx")
.on(table.controlDeadlineAt).where(sql`${table.controlDeadlineAt} is not null`),
issueCompanyFk: foreignKey({
@@ -116,6 +116,7 @@ const {
reapOrphanedRuns: vi.fn(async () => ({ reaped: 0, runIds: [] })),
registerScheduledRetryCancellationWork: vi.fn((_coordinator: unknown, _canRun: () => boolean) => ({ ready: Promise.resolve() })),
registerStoppedInstructionCollectionWork: vi.fn((_coordinator: unknown, _canRun: () => boolean) => ({ ready: Promise.resolve() })),
registerUnsafeWorkspaceExportWork: vi.fn((_coordinator: unknown, _canRun: () => boolean) => ({ ready: Promise.resolve() })),
registerAutomaticCompletionReviewWork: vi.fn((_coordinator: unknown, _canRun: () => boolean) => ({ ready: Promise.resolve() })),
registerObsoletePolicyReviewWork: vi.fn((_coordinator: unknown, _canRun: () => boolean) => ({ ready: Promise.resolve() })),
registerCancelledWorkspacePreservationWork: vi.fn((_coordinator: unknown, _canRun: () => boolean) => ({ ready: Promise.resolve() })),
@@ -556,6 +557,13 @@ describe("startServer feedback export wiring", () => {
process.env.BETTER_AUTH_SECRET = "test-secret";
});
it.each([false, true])("registers unsafe export recovery only when maintenance is enabled (%s)", async enabled => {
loadConfigMock.mockReturnValue(buildTestConfig({ heartbeatSchedulerEnabled: enabled }));
await startServer(); await settleStartupHousekeeping();
expect(heartbeatServiceMock.registerUnsafeWorkspaceExportWork).toHaveBeenCalledTimes(enabled ? 1 : 0);
if (enabled) expect(heartbeatServiceMock.registerUnsafeWorkspaceExportWork.mock.calls[0][1]()).toBe(true);
});
it.each([false, true])("registers automatic completion review cleanup only when maintenance is enabled (%s)", async enabled => {
loadConfigMock.mockReturnValue(buildTestConfig({ heartbeatSchedulerEnabled: enabled }));
await startServer(); await settleStartupHousekeeping();
@@ -989,7 +997,7 @@ describe("startServer feedback export wiring", () => {
intervalCallback?.();
await new Promise<void>((resolve) => setImmediate(resolve));
expect(heartbeatServiceMock.sweepStaleIssueLocks).toHaveBeenCalledTimes(2);
expect(heartbeatServiceMock.reapOrphanedRuns).toHaveBeenLastCalledWith({ staleThresholdMs: 300_000, skipScheduledRetryCancellation: true, skipCapturedInstructionCommit: true, skipInstructionDirectoryCleanup: true, skipStoppedInstructionCollection: true, skipCancelledWorkspacePreservation: true, skipObsoletePolicyReviews: true, skipAutomaticCompletionReviews: true });
expect(heartbeatServiceMock.reapOrphanedRuns).toHaveBeenLastCalledWith({ staleThresholdMs: 300_000, skipScheduledRetryCancellation: true, skipCapturedInstructionCommit: true, skipInstructionDirectoryCleanup: true, skipStoppedInstructionCollection: true, skipCancelledWorkspacePreservation: true, skipObsoletePolicyReviews: true, skipAutomaticCompletionReviews: true, skipUnsafeWorkspaceExports: true });
expect(heartbeatServiceMock.reconcileCostAccounting).toHaveBeenCalledTimes(1);
expect(retiredDetector).not.toHaveBeenCalled();
} finally {
+3 -1
View File
@@ -1824,6 +1824,8 @@ async function startServerWithDatabaseTeardown(
() => !isIdleTaskDrainActive() && !heartbeatSchedulerStopped && !isWarmStandby()).ready;
if (config.heartbeatSchedulerEnabled) await heartbeat.registerInstructionDirectoryCleanupWork(deliveryWork,
() => !isIdleTaskDrainActive() && !heartbeatSchedulerStopped && !isWarmStandby()).ready;
if (config.heartbeatSchedulerEnabled) await heartbeat.registerUnsafeWorkspaceExportWork(deliveryWork,
() => !isIdleTaskDrainActive() && !heartbeatSchedulerStopped && !isWarmStandby()).ready;
if (config.heartbeatSchedulerEnabled) await heartbeat.registerAutomaticCompletionReviewWork(deliveryWork,
() => !isIdleTaskDrainActive() && !heartbeatSchedulerStopped && !isWarmStandby()).ready;
if (config.heartbeatSchedulerEnabled) await heartbeat.registerObsoletePolicyReviewWork(deliveryWork,
@@ -1960,7 +1962,7 @@ async function startServerWithDatabaseTeardown(
// Periodically reap orphaned runs (5-min staleness threshold) and make sure
// persisted queued work is still being driven forward.
runPeriodicRecovery(() => heartbeat
.reapOrphanedRuns({ staleThresholdMs: 5 * 60 * 1000, skipScheduledRetryCancellation: true, skipCapturedInstructionCommit: true, skipInstructionDirectoryCleanup: true, skipStoppedInstructionCollection: true, skipCancelledWorkspacePreservation: true, skipObsoletePolicyReviews: true, skipAutomaticCompletionReviews: true })
.reapOrphanedRuns({ staleThresholdMs: 5 * 60 * 1000, skipScheduledRetryCancellation: true, skipCapturedInstructionCommit: true, skipInstructionDirectoryCleanup: true, skipStoppedInstructionCollection: true, skipCancelledWorkspacePreservation: true, skipObsoletePolicyReviews: true, skipAutomaticCompletionReviews: true, skipUnsafeWorkspaceExports: true })
.then(() => heartbeat.promoteDueScheduledRetries())
.then(async (promotion) => {
await heartbeat.resumeQueuedRuns();
@@ -1,3 +1,4 @@
import { notifyUnsafeWorkspaceExportRecovery } from "../../../services/unsafe-workspace-export-notifications.js";
import { notifyCancelledWorkspacePreservation } from "../../../services/cancelled-workspace-preservation-notifications.js";
import { notifyInstructionCollection } from "../../../services/instruction-collection-notifications.js";
import { and, asc, desc, eq, gt, gte, inArray, ne, notInArray, sql } from "drizzle-orm";
@@ -341,6 +342,7 @@ export function createPostgresWatchdogAdapter(db: Db): WatchdogRunReader & Watch
const transactionResult = await db.transaction(async (tx) => {
await notifyInstructionCollection(tx, { companyId, runId: input.run.id });
await notifyCancelledWorkspacePreservation(tx, { companyId, runId: input.run.id });
await notifyUnsafeWorkspaceExportRecovery(tx, { companyId, issueId: input.sourceIssue.id });
const [updatedRun] = await tx
.update(heartbeatRuns)
.set({
@@ -1,3 +1,4 @@
import { notifyUnsafeWorkspaceExportRecovery } from "../../../services/unsafe-workspace-export-notifications.js";
import { notifyCancelledWorkspacePreservation } from "../../../services/cancelled-workspace-preservation-notifications.js";
import { notifyInstructionCollection } from "../../../services/instruction-collection-notifications.js";
import { notifyScheduledRetryCancellation } from "../../../services/scheduled-retry-cancellation-notifications.js";
@@ -717,6 +718,7 @@ export function createPostgresRunDispatchAdapter(
await notifyInstructionCollection(tx, { companyId: input.companyId, runId: input.runId });
await notifyCancelledWorkspacePreservation(tx, { companyId: input.companyId, runId: input.runId });
await notifyScheduledRetryCancellation(tx, { companyId: input.companyId, runId: input.runId });
await notifyUnsafeWorkspaceExportRecovery(tx, { companyId: input.companyId, issueId: input.issueId ?? undefined });
await notifyChatRunMilestones(tx, { companyId: input.companyId, issueId: input.issueId ?? undefined });
const [row] = await tx
.update(heartbeatRuns)
@@ -926,6 +928,7 @@ export function createPostgresRunDispatchAdapter(
await notifyInstructionCollection(tx, { companyId: run.companyId, runId: run.id });
await notifyCancelledWorkspacePreservation(tx, { companyId: run.companyId, runId: run.id });
await notifyDeliveryWork(tx, DELIVERY_QUEUES.accounting, run.companyId);
await notifyUnsafeWorkspaceExportRecovery(tx, { companyId: run.companyId, issueId });
await notifyChatRunMilestones(tx, { companyId: run.companyId, issueId });
const [row] = await tx
.update(heartbeatRuns)
@@ -1097,6 +1100,7 @@ export function createPostgresRunDispatchAdapter(
const scheduledRetryAt = new Date(now.getTime() + 30_000);
const executionWait = { issueId, runId: blocker.runId, cause: blocker.cause };
await notifyScheduledRetryCancellation(tx, { companyId: run.companyId, runId: run.id });
if (run.status === "queued") await notifyUnsafeWorkspaceExportRecovery(tx, { companyId: run.companyId, issueId });
const [waiting] = await tx.update(heartbeatRuns)
.set({
status: "scheduled_retry",
@@ -1,3 +1,4 @@
import { notifyUnsafeWorkspaceExportRecovery } from "../../../services/unsafe-workspace-export-notifications.js";
import { notifyChatInboundWakeNotices } from "../../../services/chat-inbound-wake-notice-notifications.js";
import { notifyChatRunMilestones } from "../../../services/chat-run-milestone-notifications.js";
import { DELIVERY_QUEUES, notifyDeliveryWork } from "../../../services/delivery-work-notifications.js";
@@ -1121,6 +1122,11 @@ export function createPostgresWakeQueueAdapter(db: Db, deps: WakeQueuePostgresAd
)
.orderBy(asc(issues.id));
for (const issue of candidateIssues) {
if (issue.executionRunId === run.id || issue.checkoutRunId === run.id) {
await notifyUnsafeWorkspaceExportRecovery(tx, { companyId: input.companyId, issueId: issue.id });
}
}
// Two separate updates: a retry can move `executionRunId` to a new run
// while `checkoutRunId` still points at this one finishing.
await tx
@@ -1,3 +1,4 @@
import { notifyUnsafeWorkspaceExportRecovery } from "../../../services/unsafe-workspace-export-notifications.js";
import { notifyCancelledWorkspacePreservation } from "../../../services/cancelled-workspace-preservation-notifications.js";
import { notifyInstructionCollection } from "../../../services/instruction-collection-notifications.js";
import { notifyChatInboundWakeNotices } from "../../../services/chat-inbound-wake-notice-notifications.js";
@@ -105,6 +106,7 @@ function buildTransaction(tx: Db, companyId: string, issueId: string, deps: Queu
},
async cancelQueueRun({ queueRunId, reason, now }) {
await notifyUnsafeWorkspaceExportRecovery(tx, { companyId, issueId });
await notifyInstructionCollection(tx, { companyId, runId: queueRunId });
await notifyCancelledWorkspacePreservation(tx, { companyId, runId: queueRunId });
await notifyChatRunMilestones(tx, { companyId, issueId });
@@ -118,6 +120,7 @@ function buildTransaction(tx: Db, companyId: string, issueId: string, deps: Queu
},
async clearExecutionLockAndTouchIssue({ issueId, executionRunId, updatedAt }) {
await notifyUnsafeWorkspaceExportRecovery(tx, { companyId, issueId });
await tx
.update(issues)
.set({ executionRunId: null, executionAgentNameKey: null, executionLockedAt: null, updatedAt })
+3
View File
@@ -1,3 +1,4 @@
import { notifyUnsafeWorkspaceExportRecovery } from "../services/unsafe-workspace-export-notifications.js";
import { notifyChatInboundWakeNotices } from "../services/chat-inbound-wake-notice-notifications.js";
import { completeIssueCommentReceipt } from "../services/issue-comment-receipt.js";
import { taskInterruptionPresentation, taskInterruptionPresentations } from "../services/task-interruption-presentation.js";
@@ -9881,6 +9882,7 @@ export function issueRoutes(
}
await markExecutionReconciliation(tx as unknown as Db, workspaceRepairAction,
executionReconciliation!, actor.actorId, undefined, { workspaceRepairOnly: true });
await notifyUnsafeWorkspaceExportRecovery(tx, { companyId: lockedIssue.companyId, issueId: lockedIssue.id });
const [recoveryAction] = await tx.update(issueRecoveryActions).set({ status: "resolved", outcome: "restored",
resolutionNote: resolutionNote ?? null, resolvedAt: new Date(), updatedAt: new Date(),
}).where(eq(issueRecoveryActions.id, workspaceRepairAction.id)).returning();
@@ -9929,6 +9931,7 @@ export function issueRoutes(
"Verified outcomes must restore this source recovery without replacing another active recovery action.",
);
}
await notifyUnsafeWorkspaceExportRecovery(tx, { companyId: lockedIssue.companyId, issueId: lockedIssue.id });
const [reopened] = await tx
.update(issueRecoveryActions)
.set({ status: "active", outcome: null, resolvedAt: null })
+2
View File
@@ -1,3 +1,4 @@
import { notifyUnsafeWorkspaceExportRecovery } from "./unsafe-workspace-export-notifications.js";
import { notifyChatRunMilestones } from "./chat-run-milestone-notifications.js";
import { notifyVoiceChange } from "./voice/voice-recovery-notifications.js";
import { notifyChatEndpointWork } from "./chat-work-notifications.js";
@@ -751,6 +752,7 @@ export function accessService(db: Db) {
})
.where(and(assignedOpenIssueWhere, eq(issues.status, "in_progress")))
.returning({ id: issues.id });
if (assignmentPatch.assigneeAgentId) await notifyUnsafeWorkspaceExportRecovery(tx, { companyId });
const reassigned = await tx
.update(issues)
.set(assignmentPatch)
@@ -1,3 +1,4 @@
import { notifyUnsafeWorkspaceExportRecovery } from "./unsafe-workspace-export-notifications.js";
import { FAST_RESPONSE_AGENT_GUIDANCE, fastResponseHistoryBody } from "@paperclipai/shared";
import {
persistActivity,
@@ -385,6 +386,7 @@ export async function settleConversationTurn(
issue.conversationState === conversationState
)
return true;
if (status === "in_review") await notifyUnsafeWorkspaceExportRecovery(tx, { companyId: issue.companyId, issueId: issue.id });
await tx
.update(issues)
.set({
+2
View File
@@ -1,3 +1,4 @@
import { notifyUnsafeWorkspaceExportRecovery } from "./unsafe-workspace-export-notifications.js";
import { notifyCancelledWorkspacePreservation } from "./cancelled-workspace-preservation-notifications.js";
import { notifyInstructionCollection } from "./instruction-collection-notifications.js";
import { notifyDirectoryCleanup } from "./directory-cleanup-notifications.js";
@@ -52,6 +53,7 @@ export async function deleteAgentDependencies(tx: Db, companyId: string, id: str
await notifyDirectoryCleanup(tx, { companyId: companyId });
await notifyInstructionCollection(tx, { companyId: companyId });
await notifyCancelledWorkspacePreservation(tx, { companyId: companyId });
await notifyUnsafeWorkspaceExportRecovery(tx, { companyId });
await tx.delete(heartbeatRuns).where(eq(heartbeatRuns.agentId, id));
await tx.delete(agentWakeupRequests).where(eq(agentWakeupRequests.agentId, id));
await tx.delete(agentApiKeys).where(eq(agentApiKeys.agentId, id));
+2
View File
@@ -1,3 +1,4 @@
import { notifyUnsafeWorkspaceExportRecovery } from "./unsafe-workspace-export-notifications.js";
import { notifyCancelledWorkspacePreservation } from "./cancelled-workspace-preservation-notifications.js";
import { notifyInstructionCollection } from "./instruction-collection-notifications.js";
import { notifyDirectoryCleanup } from "./directory-cleanup-notifications.js";
@@ -106,6 +107,7 @@ export async function deleteCompany(db: Db, id: string) {
await notifyDirectoryCleanup(tx, { companyId: id });
await notifyInstructionCollection(tx, { companyId: id });
await notifyCancelledWorkspacePreservation(tx, { companyId: id });
await notifyUnsafeWorkspaceExportRecovery(tx, { companyId: id });
await tx.delete(heartbeatRuns).where(eq(heartbeatRuns.companyId, id));
await tx.delete(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, id));
await tx.delete(agentApiKeys).where(eq(agentApiKeys.companyId, id));
@@ -2,6 +2,7 @@ import { signalDatabaseWork, subscribeDatabaseWork } from "@paperclipai/db";
/** Topics for existing durable queues; register intent before their writes. */
export const DELIVERY_QUEUES = {
unsafeWorkspaceExportRecovery: "unsafe-workspace-export-recovery",
automaticCompletionReviews: "automatic-completion-reviews",
obsoletePolicyReviews: "obsolete-policy-reviews",
cancelledWorkspacePreservation: "cancelled-workspace-preservation",
@@ -1,3 +1,4 @@
import { notifyUnsafeWorkspaceExportRunChange } from "./unsafe-workspace-export-notifications.js";
import { notifyCancelledWorkspacePreservation } from "./cancelled-workspace-preservation-notifications.js";
import { notifyInstructionCollection } from "./instruction-collection-notifications.js";
import { notifyExecutionRunWork } from "./execution-control-notifications.js";
@@ -187,6 +188,7 @@ export async function reconcileAbandonedExecutionControl(
.where(eq(nativeRunFinalizations.runId, run.id));
await notifyInstructionCollection(tx, { companyId: run.companyId, runId: run.id });
await notifyCancelledWorkspacePreservation(tx, { companyId: run.companyId, runId: run.id });
await notifyUnsafeWorkspaceExportRunChange(tx, run);
await notifyExecutionRunWork(tx);
const [updatedRun] = await tx
.update(heartbeatRuns)
@@ -1,3 +1,4 @@
import { notifyUnsafeWorkspaceExportRecovery } from "./unsafe-workspace-export-notifications.js";
import { notifyChatResponseAvailability } from "./chat-response-availability.js";
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
import { notifyExecutionRunWork } from "./execution-control-notifications.js";
@@ -450,6 +451,7 @@ export async function settleUnrecoverableExecutions(
.limit(25).for("update", { skipLocked: true });
for (const candidate of foldable) {
if (await getConversationOwnershipBlocker(tx as unknown as Db, candidate.companyId, candidate.sourceIssueId)) continue;
await notifyUnsafeWorkspaceExportRecovery(tx, { companyId: candidate.companyId, issueId: candidate.sourceIssueId });
const [action] = await tx.update(issueRecoveryActions).set({
status: "resolved",
outcome: "cancelled",
@@ -585,6 +587,7 @@ export async function settleUnrecoverableExecutions(
? "Workspace repair required. Verify safe staging or repair before continuing. Saved work and approval decisions remain in force."
: "Automatic recovery stopped. Recorded work is preserved; actions with unverified outcomes will not be repeated."
: "Recovery closed because the task's owner, execution, or status changed. No work was replayed.";
await notifyUnsafeWorkspaceExportRecovery(tx, { companyId: task.companyId, issueId: task.id });
let nativeFailureBlock = action.evidence.nativeFailureBlock;
let nativeBootstrapFailureBlock = action.evidence.nativeBootstrapFailureBlock;
if (current) {
@@ -1,3 +1,4 @@
import { notifyUnsafeWorkspaceExportRecovery } from "./unsafe-workspace-export-notifications.js";
import { assertDurableChatWakeupRequest, assertDurableChatWakeupReceipt, type DurableChatWakeupRequest } from "./durable-chat-wakeup.js";
import { notifyExecutionRunWork } from "./execution-control-notifications.js";
import { canSelectFreshWorkspace, type ContinuationReadiness } from "./fresh-workspace-continuation.js";
@@ -490,8 +491,10 @@ export async function admitExplicitNativeContinuation(input: {
failureDetail: sql`coalesce(${nativeRunFinalizations.failureDetail}, '{}'::jsonb) || ${JSON.stringify({ replacementDenied: "explicit_user_continuation" })}::jsonb`,
updatedAt: new Date(),
}).where(and(eq(nativeRunFinalizations.companyId, companyId), inArray(nativeRunFinalizations.runId, nativeSources.map(run => run.id))));
if (actions.length) await db.transaction(async tx => {
await notifyUnsafeWorkspaceExportRecovery(tx, { companyId, issueId });
for (const action of actions) {
await db.update(issueRecoveryActions).set({
await tx.update(issueRecoveryActions).set({
status: "resolved", outcome: "cancelled", resolvedAt: new Date(), updatedAt: new Date(),
nextAction: "The user started a fresh conversation turn. Prior action outcomes remain recorded.",
resolutionNote: "The user continued after the prior execution stopped. No action outcomes were inferred.",
@@ -507,6 +510,7 @@ export async function admitExplicitNativeContinuation(input: {
},
}).where(eq(issueRecoveryActions.id, action.id));
}
});
await persistActivity(db, { companyId, actorType: "user", actorId,
action: "issue.execution_recovery_settled", entityType: "issue", entityId: issueId,
details: { continuation: retry ? "explicit_user_retry" : "explicit_user_message", ...authorization,
+3
View File
@@ -1,3 +1,4 @@
import { registerUnsafeWorkspaceExportWork } from "./native-runtime/native-workspace-export-recovery.js";
import { registerAutomaticCompletionReviewWork } from "./native-runtime/automatic-completion-reviews.js";
import { registerObsoletePolicyReviewWork } from "./native-runtime/obsolete-policy-reviews.js";
import { registerCancelledWorkspacePreservationWork } from "./native-runtime/native-failed-workspace-recovery.js";
@@ -4087,6 +4088,8 @@ export function heartbeatService(
triggerIssueMonitor,
reportRunActivity: clearDetachedRunWarning,
registerUnsafeWorkspaceExportWork: (coordinator: Parameters<typeof registerUnsafeWorkspaceExportWork>[1], canRun: () => boolean) =>
registerUnsafeWorkspaceExportWork(db, coordinator, () => canRun() && readTaskDrain(new Date()) === null),
registerAutomaticCompletionReviewWork: (coordinator: Parameters<typeof registerAutomaticCompletionReviewWork>[1], canRun: () => boolean) =>
registerAutomaticCompletionReviewWork(db, coordinator, () => canRun() && readTaskDrain(new Date()) === null),
registerObsoletePolicyReviewWork: (coordinator: Parameters<typeof registerObsoletePolicyReviewWork>[1], canRun: () => boolean) =>
+13 -1
View File
@@ -1,3 +1,4 @@
import { notifyUnsafeWorkspaceExportRecovery, notifyUnsafeWorkspaceExportRunChange } from "../unsafe-workspace-export-notifications.js";
import { notifyCancelledWorkspacePreservation } from "../cancelled-workspace-preservation-notifications.js";
import { notifyInstructionCollection } from "../instruction-collection-notifications.js";
import { notifyScheduledRetryCancellation } from "../scheduled-retry-cancellation-notifications.js";
@@ -854,6 +855,7 @@ export function createHeartbeatQueue(db: Db, dependencies: HeartbeatQueueDepende
await notifyChatRunMilestones(tx, { companyId: current.companyId, issueId });
await notifyInstructionCollection(tx, { companyId: current.companyId, runId: current.id });
await notifyCancelledWorkspacePreservation(tx, { companyId: current.companyId, runId: current.id });
await notifyUnsafeWorkspaceExportRunChange(tx, current);
[terminal] = await tx
.update(heartbeatRuns)
.set({
@@ -977,6 +979,7 @@ export function createHeartbeatQueue(db: Db, dependencies: HeartbeatQueueDepende
await notifyChatRunMilestones(tx, { companyId: run.companyId });
await notifyInstructionCollection(tx, { companyId: run.companyId, runId: run.id });
await notifyCancelledWorkspacePreservation(tx, { companyId: run.companyId, runId: run.id });
await notifyUnsafeWorkspaceExportRunChange(tx, run);
const [row] = await tx.update(heartbeatRuns).set({ status: "cancelled", finishedAt: new Date(),
errorCode: "case_handoff_superseded", error: "This case handoff was cancelled or superseded.", updatedAt: new Date() })
.where(and(eq(heartbeatRuns.id, run.id), eq(heartbeatRuns.companyId, run.companyId), eq(heartbeatRuns.status, "queued"))).returning();
@@ -1339,6 +1342,7 @@ export function createHeartbeatQueue(db: Db, dependencies: HeartbeatQueueDepende
await notifyChatRunMilestones(tx, { companyId: run.companyId, issueId });
await notifyInstructionCollection(tx, { companyId: run.companyId, runId: run.id });
await notifyCancelledWorkspacePreservation(tx, { companyId: run.companyId, runId: run.id });
await notifyUnsafeWorkspaceExportRunChange(tx, run);
const [cancelled] = await tx
.update(heartbeatRuns)
.set({
@@ -1462,6 +1466,7 @@ export function createHeartbeatQueue(db: Db, dependencies: HeartbeatQueueDepende
await notifyChatRunMilestones(tx, { companyId: run.companyId, issueId });
await notifyInstructionCollection(tx, { companyId: run.companyId, runId: run.id });
await notifyCancelledWorkspacePreservation(tx, { companyId: run.companyId, runId: run.id });
await notifyUnsafeWorkspaceExportRunChange(tx, run);
const [cancelled] = await tx
.update(heartbeatRuns)
.set({
@@ -1795,7 +1800,9 @@ export function createHeartbeatQueue(db: Db, dependencies: HeartbeatQueueDepende
error: reason,
});
await db
await db.transaction(async tx => {
await notifyUnsafeWorkspaceExportRecovery(tx, { companyId: run.companyId, issueId });
await tx
.update(issues)
.set({
executionRunId: null,
@@ -1810,6 +1817,7 @@ export function createHeartbeatQueue(db: Db, dependencies: HeartbeatQueueDepende
eq(issues.executionRunId, run.id),
),
);
});
await appendRunEvent(cancelled, {
eventType: "lifecycle",
@@ -3501,6 +3509,7 @@ export function createHeartbeatQueue(db: Db, dependencies: HeartbeatQueueDepende
: "Cancelled because the issue was reassigned before the scheduled retry became due";
await notifyInstructionCollection(tx, { companyId: scheduledRun.companyId, runId: scheduledRun.id });
await notifyCancelledWorkspacePreservation(tx, { companyId: scheduledRun.companyId, runId: scheduledRun.id });
await notifyUnsafeWorkspaceExportRunChange(tx, scheduledRun);
await notifyScheduledRetryCancellation(tx, { companyId: scheduledRun.companyId, runId: scheduledRun.id });
await notifyChatRunMilestones(tx, { companyId: scheduledRun.companyId, issueId: issue.id });
const cancelled = await tx
@@ -3641,6 +3650,7 @@ export function createHeartbeatQueue(db: Db, dependencies: HeartbeatQueueDepende
) {
await notifyInstructionCollection(tx, { companyId: activeExecutionRun.companyId, runId: activeExecutionRun.id });
await notifyCancelledWorkspacePreservation(tx, { companyId: activeExecutionRun.companyId, runId: activeExecutionRun.id });
await notifyUnsafeWorkspaceExportRunChange(tx, activeExecutionRun);
await notifyChatRunMilestones(tx, { companyId: activeExecutionRun.companyId, issueId: issue.id });
const cancelled = await tx
.update(heartbeatRuns)
@@ -3683,6 +3693,7 @@ export function createHeartbeatQueue(db: Db, dependencies: HeartbeatQueueDepende
}
if (!activeExecutionRun && issue.executionRunId) {
await notifyUnsafeWorkspaceExportRecovery(tx, { companyId: issue.companyId, issueId: issue.id });
await tx
.update(issues)
.set({
@@ -3880,6 +3891,7 @@ export function createHeartbeatQueue(db: Db, dependencies: HeartbeatQueueDepende
`- Reason: ${WORKSPACE_WORKTREE_REQUIRES_PROJECT_MESSAGE}`,
`- Next action: ${WORKSPACE_WORKTREE_REQUIRES_PROJECT_REMEDIATION}`,
].join("\n");
await notifyUnsafeWorkspaceExportRecovery(tx, { companyId: issue.companyId, issueId: issue.id });
if (issue.status !== "blocked") await notifyChatResponseAvailability(tx, { companyId: issue.companyId, issueId: issue.id });
await tx
.update(issues)
+2 -1
View File
@@ -1947,7 +1947,7 @@ export function createHeartbeatRecovery(db: Db, dependencies: HeartbeatRecoveryD
let settledProcessesDiscovered = false;
async function reapOrphanedRuns(opts?: { staleThresholdMs?: number; skipScheduledRetryCancellation?: boolean; skipCapturedInstructionCommit?: boolean; skipInstructionDirectoryCleanup?: boolean; skipStoppedInstructionCollection?: boolean; skipCancelledWorkspacePreservation?: boolean; skipObsoletePolicyReviews?: boolean; skipAutomaticCompletionReviews?: boolean }) {
async function reapOrphanedRuns(opts?: { staleThresholdMs?: number; skipScheduledRetryCancellation?: boolean; skipCapturedInstructionCommit?: boolean; skipInstructionDirectoryCleanup?: boolean; skipStoppedInstructionCollection?: boolean; skipCancelledWorkspacePreservation?: boolean; skipObsoletePolicyReviews?: boolean; skipAutomaticCompletionReviews?: boolean; skipUnsafeWorkspaceExports?: boolean }) {
if (!opts?.skipCancelledWorkspacePreservation) await preserveCancelledNativeWorkspaces(db);
const staleThresholdMs = opts?.staleThresholdMs ?? 0;
const now = new Date();
@@ -1967,6 +1967,7 @@ export function createHeartbeatRecovery(db: Db, dependencies: HeartbeatRecoveryD
await reconcileNativeFinalizations(db, undefined, {
skipObsoletePolicyReviews: opts?.skipObsoletePolicyReviews,
skipAutomaticCompletionReviews: opts?.skipAutomaticCompletionReviews,
skipUnsafeWorkspaceExports: opts?.skipUnsafeWorkspaceExports,
environmentRuntime,
pluginWorkerManager: dependencies.pluginWorkerManager,
onWorkspaceSettled: settleRecoveredNativeWorkspace,
@@ -1,3 +1,4 @@
import { resolveExternalTestDatabaseUrl } from "../../__tests__/helpers/external-test-database.js";
import { randomBytes } from "node:crypto";
import type { Db } from "@paperclipai/db";
import { agents, agentWakeupRequests, companies, createDb, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS, heartbeatRunEvents, heartbeatRuns } from "@paperclipai/db";
@@ -107,14 +108,15 @@ describe("heartbeat lifecycle module boundary", () => {
});
});
const support = await getEmbeddedPostgresTestSupport();
const external = resolveExternalTestDatabaseUrl("PAPERCLIP_LIFECYCLE_TEST_DATABASE_URL");
const support = external ? { supported: true as const } : await getEmbeddedPostgresTestSupport();
if (!support.supported) console.warn(`Skipping lifecycle module database tests: ${support.reason}`);
describe.skipIf(!support.supported)("heartbeat lifecycle database wiring", () => {
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: Db;
beforeAll(async () => {
database = await startEmbeddedPostgresTestDatabase("paperclip-heartbeat-lifecycle-module");
db = createDb(database.connectionString);
if (!external) database = await startEmbeddedPostgresTestDatabase("paperclip-heartbeat-lifecycle-module");
db = createDb(external ?? database.connectionString);
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
afterAll(async () => {
await db?.$client.end();
@@ -1,3 +1,4 @@
import { notifyUnsafeWorkspaceExportRunChange } from "../unsafe-workspace-export-notifications.js";
import { notifyCancelledWorkspacePreservation } from "../cancelled-workspace-preservation-notifications.js";
import { notifyInstructionCollection } from "../instruction-collection-notifications.js";
import { notifyScheduledRetryCancellation } from "../scheduled-retry-cancellation-notifications.js";
@@ -1016,13 +1017,14 @@ export function createHeartbeatLifecycle(db: Db, dependencies: HeartbeatLifecycl
return recordHeartbeatRunRuntimeProgress(currentRun, update, issueId);
}
async function writeRunStatus<T>(status: string, previous: Pick<typeof heartbeatRuns.$inferSelect, "id" | "companyId" | "status"> | null,
async function writeRunStatus<T>(status: string, previous: Pick<typeof heartbeatRuns.$inferSelect, "id" | "companyId" | "status" | "nativeIssueId" | "contextSnapshot"> | null,
write: (tx: Db) => Promise<T>) {
const companyId = previous?.companyId;
// Register on the outer transaction before the terminal write. An unknown
// commit cannot strand newly-actionable accounting after the live event.
return db.transaction(async transaction => {
const tx = transaction as unknown as Db;
if (previous && previous.status !== status) await notifyUnsafeWorkspaceExportRunChange(tx, previous);
if (previous && (previous.status === "scheduled_retry" || status === "scheduled_retry"))
await notifyScheduledRetryCancellation(tx, { companyId: previous.companyId, runId: previous.id });
if (previous) await notifyInstructionCollection(tx, { companyId: previous.companyId, runId: previous.id });
+142 -119
View File
@@ -1,3 +1,4 @@
import { notifyUnsafeWorkspaceExportRecovery } from "./unsafe-workspace-export-notifications.js";
import { writeExecutionControl } from "./execution-control-notifications.js";
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
import { and, desc, eq, inArray, isNull, isNotNull, or, sql } from "drizzle-orm";
@@ -322,6 +323,7 @@ export function issueRecoveryActionService(db: DbOrTransaction) {
const created = await db.transaction(async (tx) => {
await notifyDeliveryWork(tx, DELIVERY_QUEUES.executionDisposition);
await notifyDeliveryWork(tx, DELIVERY_QUEUES.executionContinuation);
await notifyUnsafeWorkspaceExportRecovery(tx, { companyId: input.companyId, issueId: input.sourceIssueId });
const [superseded] = await tx
.update(issueRecoveryActions)
.set({
@@ -408,120 +410,132 @@ export function issueRecoveryActionService(db: DbOrTransaction) {
existing.attemptCount,
Math.min(nextAttemptCount, effectiveMaxAttempts),
);
const [exhausted] = await writeExecutionControl(db, [DELIVERY_QUEUES.executionDisposition, DELIVERY_QUEUES.executionContinuation], tx => tx
const [exhausted] = await writeExecutionControl(db, [DELIVERY_QUEUES.executionDisposition, DELIVERY_QUEUES.executionContinuation], async tx => {
if ((!input.preserveExistingOwner && existing.cause !== input.cause)
|| (input.evidence?.runId !== undefined && input.evidence.runId !== existing.evidence?.runId)) {
await notifyUnsafeWorkspaceExportRecovery(tx, { companyId: input.companyId, issueId: input.sourceIssueId });
}
return tx
.update(issueRecoveryActions)
.set({
status: "escalated",
ownerType: existing.ownerType === "system" || input.ownerType === "system" ? "system" : "board",
ownerAgentId: null,
ownerUserId: null,
previousOwnerAgentId:
existing.ownerAgentId ?? existing.previousOwnerAgentId,
returnOwnerAgentId:
input.returnOwnerAgentId ??
existing.returnOwnerAgentId ??
existing.ownerAgentId,
evidence: {
...(existing.evidence ?? {}),
...(input.evidence ?? {}),
recoveryBudget: {
state: "exhausted",
attemptsUsed,
maxAttempts: effectiveMaxAttempts,
exhaustedAt: now.toISOString(),
cause: existing.cause,
fingerprint: existing.fingerprint,
},
},
nextAction: existing.cause === "native_workspace_finalization_owner_unverified"
? input.nextAction
: `Automatic recovery exhausted after ${attemptsUsed}/${effectiveMaxAttempts} attempts. ` +
"Review the infrastructure failure and explicitly choose a replacement run or provider configuration.",
wakePolicy: null,
monitorPolicy: null,
attemptCount: attemptsUsed,
maxAttempts: effectiveMaxAttempts,
timeoutAt: null,
lastAttemptAt: input.lastAttemptAt ?? now,
outcome: "escalated",
resolutionNote: null,
resolvedAt: null,
updatedAt: now,
})
.where(
and(
eq(issueRecoveryActions.id, existing.id),
inArray(issueRecoveryActions.status, [
...ACTIVE_RECOVERY_ACTION_STATUSES,
]),
),
)
.returning();
});
if (!exhausted) {
return retryUpsertSourceScoped(input, retryCount);
}
return toReadModel(exhausted);
}
const [updated] = await writeExecutionControl(db, [DELIVERY_QUEUES.executionDisposition, DELIVERY_QUEUES.executionContinuation], async tx => {
if ((!input.preserveExistingOwner && existing.cause !== input.cause)
|| (input.evidence?.runId !== undefined && input.evidence.runId !== existing.evidence?.runId)) {
await notifyUnsafeWorkspaceExportRecovery(tx, { companyId: input.companyId, issueId: input.sourceIssueId });
}
return tx
.update(issueRecoveryActions)
.set({
status: "escalated",
ownerType: existing.ownerType === "system" || input.ownerType === "system" ? "system" : "board",
ownerAgentId: null,
ownerUserId: null,
previousOwnerAgentId:
existing.ownerAgentId ?? existing.previousOwnerAgentId,
returnOwnerAgentId:
input.returnOwnerAgentId ??
existing.returnOwnerAgentId ??
existing.ownerAgentId,
evidence: {
...(existing.evidence ?? {}),
...(input.evidence ?? {}),
recoveryBudget: {
state: "exhausted",
attemptsUsed,
maxAttempts: effectiveMaxAttempts,
exhaustedAt: now.toISOString(),
cause: existing.cause,
fingerprint: existing.fingerprint,
},
},
nextAction: existing.cause === "native_workspace_finalization_owner_unverified"
? input.nextAction
: `Automatic recovery exhausted after ${attemptsUsed}/${effectiveMaxAttempts} attempts. ` +
"Review the infrastructure failure and explicitly choose a replacement run or provider configuration.",
wakePolicy: null,
monitorPolicy: null,
attemptCount: attemptsUsed,
maxAttempts: effectiveMaxAttempts,
timeoutAt: null,
lastAttemptAt: input.lastAttemptAt ?? now,
outcome: "escalated",
resolutionNote: null,
recoveryIssueId: input.preserveExistingOwner
? existing.recoveryIssueId
: input.recoveryIssueId ?? null,
kind: input.preserveExistingOwner ? existing.kind : input.kind,
status: input.preserveExistingOwner ? existing.status : "active",
ownerType: input.preserveExistingOwner ? existing.ownerType : ownerType,
ownerAgentId: input.preserveExistingOwner
? existing.ownerAgentId
: input.ownerAgentId ?? null,
ownerUserId: input.preserveExistingOwner
? existing.ownerUserId
: input.ownerUserId ?? null,
previousOwnerAgentId: input.preserveExistingOwner
? existing.previousOwnerAgentId
: input.previousOwnerAgentId ?? existing.previousOwnerAgentId,
returnOwnerAgentId: input.preserveExistingOwner
? existing.returnOwnerAgentId
: input.returnOwnerAgentId ?? existing.returnOwnerAgentId,
cause: input.preserveExistingOwner ? existing.cause : input.cause,
fingerprint: input.preserveExistingOwner ? existing.fingerprint : input.fingerprint,
evidence: input.preserveExistingOwner
? {
...(existing.evidence ?? {}),
...(input.evidence ?? {}),
}
: input.evidence ?? existing.evidence,
nextAction: input.preserveExistingOwner ? existing.nextAction : input.nextAction,
wakePolicy: input.preserveExistingOwner
? existing.wakePolicy
: input.wakePolicy ?? null,
monitorPolicy: input.preserveExistingOwner
? existing.monitorPolicy
: input.monitorPolicy ?? null,
attemptCount: nextAttemptCount,
maxAttempts: input.preserveExistingOwner
? existing.maxAttempts
: input.maxAttempts === undefined
? existing.maxAttempts
: input.maxAttempts,
timeoutAt: input.preserveExistingOwner
? asDatabaseDate(existing.timeoutAt)
: input.timeoutAt ?? null,
lastAttemptAt: input.preserveExistingOwner
? asDatabaseDate(existing.lastAttemptAt)
: input.lastAttemptAt ?? now,
outcome: input.preserveExistingOwner ? existing.outcome : null,
resolutionNote: input.preserveExistingOwner ? existing.resolutionNote : null,
resolvedAt: null,
updatedAt: now,
})
.where(
and(
eq(issueRecoveryActions.id, existing.id),
inArray(issueRecoveryActions.status, [
...ACTIVE_RECOVERY_ACTION_STATUSES,
]),
inArray(issueRecoveryActions.status, [...ACTIVE_RECOVERY_ACTION_STATUSES]),
),
)
.returning());
if (!exhausted) {
return retryUpsertSourceScoped(input, retryCount);
}
return toReadModel(exhausted);
}
const [updated] = await writeExecutionControl(db, [DELIVERY_QUEUES.executionDisposition, DELIVERY_QUEUES.executionContinuation], tx => tx
.update(issueRecoveryActions)
.set({
recoveryIssueId: input.preserveExistingOwner
? existing.recoveryIssueId
: input.recoveryIssueId ?? null,
kind: input.preserveExistingOwner ? existing.kind : input.kind,
status: input.preserveExistingOwner ? existing.status : "active",
ownerType: input.preserveExistingOwner ? existing.ownerType : ownerType,
ownerAgentId: input.preserveExistingOwner
? existing.ownerAgentId
: input.ownerAgentId ?? null,
ownerUserId: input.preserveExistingOwner
? existing.ownerUserId
: input.ownerUserId ?? null,
previousOwnerAgentId: input.preserveExistingOwner
? existing.previousOwnerAgentId
: input.previousOwnerAgentId ?? existing.previousOwnerAgentId,
returnOwnerAgentId: input.preserveExistingOwner
? existing.returnOwnerAgentId
: input.returnOwnerAgentId ?? existing.returnOwnerAgentId,
cause: input.preserveExistingOwner ? existing.cause : input.cause,
fingerprint: input.preserveExistingOwner ? existing.fingerprint : input.fingerprint,
evidence: input.preserveExistingOwner
? {
...(existing.evidence ?? {}),
...(input.evidence ?? {}),
}
: input.evidence ?? existing.evidence,
nextAction: input.preserveExistingOwner ? existing.nextAction : input.nextAction,
wakePolicy: input.preserveExistingOwner
? existing.wakePolicy
: input.wakePolicy ?? null,
monitorPolicy: input.preserveExistingOwner
? existing.monitorPolicy
: input.monitorPolicy ?? null,
attemptCount: nextAttemptCount,
maxAttempts: input.preserveExistingOwner
? existing.maxAttempts
: input.maxAttempts === undefined
? existing.maxAttempts
: input.maxAttempts,
timeoutAt: input.preserveExistingOwner
? asDatabaseDate(existing.timeoutAt)
: input.timeoutAt ?? null,
lastAttemptAt: input.preserveExistingOwner
? asDatabaseDate(existing.lastAttemptAt)
: input.lastAttemptAt ?? now,
outcome: input.preserveExistingOwner ? existing.outcome : null,
resolutionNote: input.preserveExistingOwner ? existing.resolutionNote : null,
resolvedAt: null,
updatedAt: now,
})
.where(
and(
eq(issueRecoveryActions.id, existing.id),
inArray(issueRecoveryActions.status, [...ACTIVE_RECOVERY_ACTION_STATUSES]),
),
)
.returning());
.returning();
});
if (!updated) {
return retryUpsertSourceScoped(input, retryCount);
}
@@ -529,10 +543,13 @@ export function issueRecoveryActionService(db: DbOrTransaction) {
}
try {
const [created] = await writeExecutionControl(db, [DELIVERY_QUEUES.executionDisposition, DELIVERY_QUEUES.executionContinuation], tx => tx
.insert(issueRecoveryActions)
.values(buildInsertValues(input, ownerType, now))
.returning());
const [created] = await writeExecutionControl(db, [DELIVERY_QUEUES.executionDisposition, DELIVERY_QUEUES.executionContinuation], async tx => {
await notifyUnsafeWorkspaceExportRecovery(tx, { companyId: input.companyId, issueId: input.sourceIssueId });
return tx
.insert(issueRecoveryActions)
.values(buildInsertValues(input, ownerType, now))
.returning();
});
return toReadModel(created!);
} catch (error) {
if (!isUniqueRecoveryActionConflict(error)) throw error;
@@ -569,17 +586,23 @@ export function issueRecoveryActionService(db: DbOrTransaction) {
predicates.push(eq(issueRecoveryActions.fingerprint, input.fingerprint));
}
const [updated] = await writeExecutionControl(dbOrTx, [DELIVERY_QUEUES.executionDisposition, DELIVERY_QUEUES.executionContinuation], tx => tx
.update(issueRecoveryActions)
.set({
status: input.status,
outcome: input.outcome,
resolutionNote: input.resolutionNote ?? null,
resolvedAt: now,
updatedAt: now,
})
.where(and(...predicates))
.returning());
const [updated] = await writeExecutionControl(dbOrTx, [DELIVERY_QUEUES.executionDisposition, DELIVERY_QUEUES.executionContinuation], async tx => {
const pending = await tx.select({ id: issueRecoveryActions.id }).from(issueRecoveryActions)
.where(and(...predicates)).limit(1);
if (!pending.length) return [];
await notifyUnsafeWorkspaceExportRecovery(tx, { companyId: input.companyId, issueId: input.sourceIssueId });
return tx
.update(issueRecoveryActions)
.set({
status: input.status,
outcome: input.outcome,
resolutionNote: input.resolutionNote ?? null,
resolvedAt: now,
updatedAt: now,
})
.where(and(...predicates))
.returning();
});
return updated ? toReadModel(updated) : null;
}
@@ -1,3 +1,4 @@
import { notifyUnsafeWorkspaceExportRecovery } from "./unsafe-workspace-export-notifications.js";
import { notifyChatResponseAvailability } from "./chat-response-availability.js";
import { notifyChatInboundWakeNotices } from "./chat-inbound-wake-notice-notifications.js";
import { and, asc, eq, inArray, isNull, notInArray, or, sql } from "drizzle-orm";
@@ -1003,6 +1004,7 @@ export function issueTreeControlService(db: Db) {
for (const issueId of issueIdsForStatus) {
await notifyTerminalWorkspaceWork(tx, `issue:${issueId}`);
await notifyChatResponseAvailability(tx, { companyId, issueId });
if (status === "blocked" || status === "in_review") await notifyUnsafeWorkspaceExportRecovery(tx, { companyId, issueId });
}
const rows = await tx
.update(issues)
+13
View File
@@ -1,3 +1,4 @@
import { notifyUnsafeWorkspaceExportRecovery } from "./unsafe-workspace-export-notifications.js";
import { notifyChatResponseAvailability } from "./chat-response-availability.js";
import { notifyChatInboundWakeNotices } from "./chat-inbound-wake-notice-notifications.js";
import { notifyChatRunMilestones } from "./chat-run-milestone-notifications.js";
@@ -7821,6 +7822,7 @@ export function issueService(db: Db) {
if (run && !TERMINAL_HEARTBEAT_RUN_STATUSES.has(run.status)) return false;
if (isExplicitContinuationRetryClaim(issue, run)) return false;
await notifyUnsafeWorkspaceExportRecovery(tx, { companyId: issue.companyId, issueId });
const updated = await tx
.update(issues)
.set({
@@ -7894,6 +7896,7 @@ export function issueService(db: Db) {
if (isExplicitContinuationRetryClaim(issue, executionRun)) return false;
}
await notifyUnsafeWorkspaceExportRecovery(tx, { companyId: issue.companyId, issueId });
const updated = await tx
.update(issues)
.set({
@@ -11462,6 +11465,12 @@ export function issueService(db: Db) {
if (patch.hiddenAt !== undefined || patch.harnessKind !== undefined || patch.conversationAgentId !== undefined) {
await notifyApprovalWork(tx, receiptExisting.companyId);
}
if ((patch.status !== undefined && patch.status !== receiptExisting.status)
|| (patch.assigneeAgentId !== undefined && patch.assigneeAgentId !== receiptExisting.assigneeAgentId)
|| (patch.executionRunId !== undefined && patch.executionRunId !== receiptExisting.executionRunId)
|| (patch.checkoutRunId !== undefined && patch.checkoutRunId !== receiptExisting.checkoutRunId)) {
await notifyUnsafeWorkspaceExportRecovery(tx, { companyId: receiptExisting.companyId, issueId: id });
}
const updated = await tx
.update(issues)
.set(patch)
@@ -12402,6 +12411,7 @@ export function issueService(db: Db) {
const existing = await tx
.select({
id: issues.id,
companyId: issues.companyId,
checkoutRunId: issues.checkoutRunId,
executionRunId: issues.executionRunId,
})
@@ -12422,6 +12432,9 @@ export function issueService(db: Db) {
}
await notifyTerminalWorkspaceWork(tx, `issue:${id}`);
if (existing.checkoutRunId || existing.executionRunId) {
await notifyUnsafeWorkspaceExportRecovery(tx, { companyId: existing.companyId, issueId: id });
}
const updated = await tx
.update(issues)
.set(patch)
@@ -1,3 +1,4 @@
import { notifyUnsafeWorkspaceExportRecovery } from "./unsafe-workspace-export-notifications.js";
import { notifyInstructionCollection } from "./instruction-collection-notifications.js";
import { notifyExecutionRunWork } from "./execution-control-notifications.js";
import { hasRequiredWorkspaceRecovery, preserveWorkspaceRestoreRecoveryMetadata, LEGACY_WORKSPACE_RECOVERY_SCHEMA } from "./workspace-restore-recovery-state.js";
@@ -131,6 +132,7 @@ export async function terminalizeLegacyExecution(input: {
resultJson: preserveWorkspaceRestoreRecoveryMetadata(current.resultJson,
input.recordRestoreFailureOnly ? { ...current.resultJson, ...patch.resultJson } : patch.resultJson),
};
if (issueId) await notifyUnsafeWorkspaceExportRecovery(tx, { companyId: current.companyId, issueId });
await notifyInstructionCollection(tx, { companyId: current.companyId, runId: current.id });
await notifyExecutionRunWork(tx);
let [updated] = await tx
@@ -347,6 +349,7 @@ export async function settleInterruptedNativeBootstrap(
const [settled] = await tx.update(heartbeatRuns).set({ resultJson: { ...run.resultJson,
executionRecovery: { kind: "bootstrap", providerWorkStarted: false, preparationSettledAt: now.toISOString() },
}, updatedAt: now }).where(eq(heartbeatRuns.id, run.id)).returning();
if (holds.length) await notifyUnsafeWorkspaceExportRecovery(tx, { companyId: task.companyId, issueId: task.id });
const resolved = holds.length ? await tx.update(issueRecoveryActions).set({ status: "resolved", outcome: "false_positive",
resolutionNote: "The interrupted executor finished preparation and cleanup without starting a provider.",
resolvedAt: now, updatedAt: now,
@@ -1,3 +1,4 @@
import { notifyUnsafeWorkspaceExportRunChange } from "../unsafe-workspace-export-notifications.js";
import { notifyCancelledWorkspacePreservation } from "../cancelled-workspace-preservation-notifications.js";
import { notifyInstructionCollection } from "../instruction-collection-notifications.js";
import { readRequiredQualityReview } from "../required-quality-review.js";
@@ -519,6 +520,7 @@ export async function claimNativeSessionResumptions(input: {
}).where(eq(nativeRunFinalizations.runId, row.run.id));
await notifyInstructionCollection(tx, { companyId: row.run.companyId, runId: row.run.id });
await notifyCancelledWorkspacePreservation(tx, { companyId: row.run.companyId, runId: row.run.id });
await notifyUnsafeWorkspaceExportRunChange(tx, row.run);
const [updatedRun] = await tx.update(heartbeatRuns).set({
status: "failed",
finishedAt: now,
@@ -639,6 +641,7 @@ export async function claimNativeSessionResumptions(input: {
type NativeReconciliationOptions = {
skipObsoletePolicyReviews?: boolean;
skipAutomaticCompletionReviews?: boolean;
skipUnsafeWorkspaceExports?: boolean;
pluginWorkerManager?: WorkspaceWorkers;
environmentRuntime?: EnvironmentRuntimeService;
onWorkspaceSettled?: (input: {
@@ -721,10 +724,12 @@ async function reconcileNativeFinalizationsPass(
options: NativeReconciliationOptions,
timing: NativeReconciliationPassTiming,
) {
timing.phase("legacy_workspace_exports");
await recoverLegacyUnsafeWorkspaceExports(db, runIds).catch((err) => {
logger.warn({ err }, "Historical unsafe export recovery remains pending");
});
if (!options.skipUnsafeWorkspaceExports) {
timing.phase("legacy_workspace_exports");
await recoverLegacyUnsafeWorkspaceExports(db, runIds).catch((err) => {
logger.warn({ err }, "Historical unsafe export recovery remains pending");
});
}
if (!options.skipObsoletePolicyReviews) {
timing.phase("obsolete_policy_reviews");
await dismissObsoleteNativePolicyReviews(db, runIds).catch((err) => {
@@ -1,3 +1,4 @@
import { notifyUnsafeWorkspaceExportRecovery } from "../unsafe-workspace-export-notifications.js";
import type { Db } from "@paperclipai/db";
import type { NativeInteractionResponseEnvelope } from "../../vendor/paperclip-runner/index.js";
import { and, asc, eq, inArray } from "drizzle-orm";
@@ -120,6 +121,9 @@ export async function applyNativeAttentionStatusDecision(input: {
continue;
}
if (effect.kind === "record_finalization_error") {
if (effect.cause === "native_workspace_sync_out_unsafe_archive") {
await notifyUnsafeWorkspaceExportRecovery(tx, input);
}
const [coordinator] = await tx.update(nativeRunFinalizations).set({
phase: "terminal_failure",
failureCode: effect.cause,
@@ -1,3 +1,4 @@
import { notifyUnsafeWorkspaceExportRunChange } from "../unsafe-workspace-export-notifications.js";
import { notifyCancelledWorkspacePreservation } from "../cancelled-workspace-preservation-notifications.js";
import { notifyInstructionCollection } from "../instruction-collection-notifications.js";
import { notifyExecutionRunWork } from "../execution-control-notifications.js";
@@ -923,6 +924,7 @@ export async function claimNativeRestartRecoveries(input: {
);
await notifyInstructionCollection(tx, { companyId: row.run.companyId, runId: row.run.id });
await notifyCancelledWorkspacePreservation(tx, { companyId: row.run.companyId, runId: row.run.id });
await notifyUnsafeWorkspaceExportRunChange(tx, row.run);
const [updatedRun] = await tx
.update(heartbeatRuns)
.set({
@@ -1,3 +1,4 @@
import { notifyUnsafeWorkspaceExportRecovery } from "../unsafe-workspace-export-notifications.js";
import { isSafeNativeChatProgressEventType } from "../safe-native-chat-progress.js";
import { notifyChatNativeProgress } from "../chat-native-progress-notifications.js";
import { createHash } from "node:crypto";
@@ -562,7 +563,11 @@ export class NativeRunCoordinatorStore {
) {
throw new Error("native_finalization_retry_invalid");
}
const [updated] = await this.#db
const [updated] = await this.#db.transaction(async tx => {
if (input.failureCode === "native_workspace_sync_out_unsafe_archive") {
await notifyUnsafeWorkspaceExportRecovery(tx, this.#binding);
}
return tx
.update(nativeRunFinalizations)
.set({
leaseOwner: null,
@@ -580,6 +585,7 @@ export class NativeRunCoordinatorStore {
),
)
.returning({ runId: nativeRunFinalizations.runId });
});
if (!updated) throw new Error("native_finalization_lease_lost");
}
}
@@ -1,3 +1,5 @@
import { notifyUnsafeWorkspaceExportRunChange } from "../unsafe-workspace-export-notifications.js";
import { notifyUnsafeWorkspaceExportRecovery } from "../unsafe-workspace-export-notifications.js";
import { notifyCancelledWorkspacePreservation } from "../cancelled-workspace-preservation-notifications.js";
import { notifyInstructionCollection } from "../instruction-collection-notifications.js";
import { readRequiredQualityReview } from "../required-quality-review.js";
@@ -380,6 +382,9 @@ async function recordRetryableFailure(input: {
: "native_finalization_retry_exhausted"
: input.failureCode;
if (phase === "terminal_failure") await notifyDeliveryWork(tx, DELIVERY_QUEUES.accounting, input.run.companyId);
if (phase === "terminal_failure" && failureCode === "native_workspace_sync_out_unsafe_archive") {
await notifyUnsafeWorkspaceExportRecovery(tx, { companyId: input.run.companyId, issueId: input.coordinator.issueId });
}
await notifyExecutionRunWork(tx);
await notifyChatRunMilestones(tx, { companyId: input.run.companyId, issueId: input.coordinator.issueId });
await tx
@@ -414,6 +419,7 @@ async function recordRetryableFailure(input: {
}
if (projectsTerminalStatus) await notifyInstructionCollection(tx, { companyId: input.run.companyId, runId: input.run.id });
if (projectsTerminalStatus) await notifyCancelledWorkspacePreservation(tx, { companyId: input.run.companyId, runId: input.run.id });
if (projectsTerminalStatus) await notifyUnsafeWorkspaceExportRunChange(tx, input.run);
const [updatedRun] = await tx
.update(heartbeatRuns)
.set({
@@ -604,7 +610,7 @@ function recoveredExecutionFailureMetadata() {
async function projectCommittedRun(input: {
db: Db;
run: Pick<typeof heartbeatRuns.$inferSelect, "id" | "companyId" | "status" | "finishedAt" | "runtimeMode" | "nativePhase">;
run: Pick<typeof heartbeatRuns.$inferSelect, "id" | "companyId" | "status" | "finishedAt" | "runtimeMode" | "nativePhase" | "nativeIssueId" | "contextSnapshot">;
coordinator: typeof nativeRunFinalizations.$inferSelect;
}) {
if (!input.coordinator.resultId) return;
@@ -673,6 +679,7 @@ async function projectCommittedRun(input: {
const tx = transaction as unknown as Db;
await notifyInstructionCollection(tx, { companyId: input.run.companyId, runId: input.run.id });
await notifyCancelledWorkspacePreservation(tx, { companyId: input.run.companyId, runId: input.run.id });
await notifyUnsafeWorkspaceExportRunChange(tx, input.run);
await notifyDeliveryWork(tx, DELIVERY_QUEUES.accounting, input.run.companyId);
await notifyExecutionRunWork(tx);
await notifyChatRunMilestones(tx, { companyId: input.run.companyId, issueId: input.coordinator.issueId });
@@ -1164,6 +1171,8 @@ export async function finalizeNativeRun(input: {
const admittedRun = await input.db
.select({ id: heartbeatRuns.id, companyId: heartbeatRuns.companyId, agentId: heartbeatRuns.agentId,
runtimeMode: heartbeatRuns.runtimeMode, status: heartbeatRuns.status, errorCode: heartbeatRuns.errorCode,
nativeIssueId: heartbeatRuns.nativeIssueId,
contextSnapshot: sql<Record<string, unknown>>`jsonb_build_object('issueId', ${heartbeatRuns.contextSnapshot}->>'issueId')`,
nativePhase: heartbeatRuns.nativePhase, finishedAt: heartbeatRuns.finishedAt })
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, input.runId))
@@ -1693,6 +1702,7 @@ export async function finalizeNativeRun(input: {
const tx = transaction as unknown as Db;
if (input.projectRunStatus) await notifyInstructionCollection(tx, { companyId: run.companyId, runId: run.id });
if (input.projectRunStatus) await notifyCancelledWorkspacePreservation(tx, { companyId: run.companyId, runId: run.id });
if (input.projectRunStatus) await notifyUnsafeWorkspaceExportRunChange(tx, run);
if (input.projectRunStatus) await notifyDeliveryWork(tx, DELIVERY_QUEUES.accounting, run.companyId);
await notifyExecutionRunWork(tx);
await notifyChatRunMilestones(tx, { companyId: run.companyId, issueId: coordinator.issueId });
@@ -1,3 +1,4 @@
import { notifyUnsafeWorkspaceExportRecovery } from "../unsafe-workspace-export-notifications.js";
import { notifyExecutionRunWork } from "../execution-control-notifications.js";
import { logger } from "../../middleware/logger.js";
import { createHash, randomUUID } from "node:crypto";
@@ -466,6 +467,9 @@ export async function reconcileSafeNativeReplacements(
eq(issueRecoveryActions.cause, current.failureCode!),
sql`${issueRecoveryActions.evidence}->>'runId' = ${run.id}`,
)).for("update");
if (holds.some(hold => hold.status === "active" || hold.status === "escalated")) {
await notifyUnsafeWorkspaceExportRecovery(tx, { companyId: run.companyId, issueId: task.id });
}
for (const hold of holds) {
await tx.update(issueRecoveryActions).set({
status: "resolved", outcome: "handed_back", resolvedAt: now, updatedAt: now,
@@ -1,3 +1,4 @@
import { notifyUnsafeWorkspaceExportRecovery } from "../unsafe-workspace-export-notifications.js";
import { notifyCancelledWorkspacePreservation } from "../cancelled-workspace-preservation-notifications.js";
import { notifyInstructionCollection } from "../instruction-collection-notifications.js";
import { providerPackWorkspaceId, recordAdmittedRemoteProviderPack, resolveRemoteProviderPackAuthority } from "./remote-provider-pack-authority.js";
@@ -9822,6 +9823,7 @@ async function executePaperclipNativeSessionWithinScope(
.for("update");
if (!ownershipUnverified) await notifyInstructionCollection(tx, { companyId: input.execution.binding.companyId, runId: input.execution.binding.runId });
if (!ownershipUnverified) await notifyCancelledWorkspacePreservation(tx, { companyId: input.execution.binding.companyId, runId: input.execution.binding.runId });
if (!ownershipUnverified) await notifyUnsafeWorkspaceExportRecovery(tx, { companyId: input.execution.binding.companyId, issueId: input.execution.binding.issueId });
let [updatedRun] = await tx
.update(heartbeatRuns)
.set({
@@ -1,4 +1,9 @@
import { and, asc, desc, eq, gt, inArray, ne, or, sql } from "drizzle-orm";
import { alias } from "drizzle-orm/pg-core";
import { notifyUnsafeWorkspaceExportRecovery, type UnsafeWorkspaceExportScope } from "../unsafe-workspace-export-notifications.js";
import type { createDeliveryWorkCoordinator } from "../delivery-work-coordinator.js";
import { DELIVERY_QUEUES } from "../delivery-work-notifications.js";
import { registerScopedDeadlineWork } from "../scoped-deadline-work.js";
import { and, asc, desc, eq, exists, gt, inArray, isNull, ne, notExists, or, sql } from "drizzle-orm";
import { completionContracts, heartbeatRuns, issues, issueRecoveryActions, nativeRunFinalizations, nativeRunResults, workspaceOperations, type Db } from "@paperclipai/db";
import { publishLiveEvent } from "../live-events.js";
import { buildHeartbeatRunStatusLiveEventPayload } from "../heartbeat-run-status-payload.js";
@@ -25,87 +30,174 @@ export async function recoverLegacyUnsafeWorkspaceExports(db: Db, runIds?: strin
}
for (const candidate of candidates) {
if (!runIds?.length) scanCursors.set(db, candidate.runId);
const owned = await withNativeWorkspaceFinalizationOwnership({ db, ...candidate }, async ownership => {
await ownership.assertHeld();
return db.transaction(async tx => {
const [issue] = await tx.select().from(issues).where(and(eq(issues.companyId, candidate.companyId), eq(issues.id, candidate.issueId))).for("update").limit(1);
const [coordinator] = await tx.select().from(nativeRunFinalizations).where(and(
eq(nativeRunFinalizations.companyId, candidate.companyId), eq(nativeRunFinalizations.runId, candidate.runId),
)).for("update").limit(1);
if (!coordinator || coordinator.phase !== "terminal_failure" || coordinator.failureCode !== UNSAFE_EXPORT
|| !coordinator.resultId || coordinator.leaseOwner || coordinator.issueId !== issue?.id) return null;
const [run] = await tx.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, candidate.companyId), eq(heartbeatRuns.id, candidate.runId))).for("update").limit(1);
if (!issue || !run || run.runtimeMode !== "native" || run.nativeIssueId !== issue.id) return null;
const [result] = await tx.select().from(nativeRunResults).where(and(
eq(nativeRunResults.id, coordinator.resultId), eq(nativeRunResults.companyId, run.companyId),
eq(nativeRunResults.runId, run.id), eq(nativeRunResults.issueId, issue.id),
eq(nativeRunResults.completionContractId, run.completionContractId ?? "00000000-0000-0000-0000-000000000000"),
eq(nativeRunResults.schemaStatus, "accepted"),
)).limit(1);
if (!result) return null;
const priorActions = await tx.select().from(issueRecoveryActions).where(and(
eq(issueRecoveryActions.companyId, run.companyId), eq(issueRecoveryActions.sourceIssueId, issue.id),
eq(issueRecoveryActions.cause, UNSAFE_EXPORT), sql`${issueRecoveryActions.evidence}->>'runId' = ${run.id}`,
));
const explicitlySettled = priorActions.some(action => action.status === "cancelled"
|| (action.status === "resolved" && !["new_source_execution_path", "unsafe_export_automatic_recovery"].includes(action.resolutionNote ?? "")));
const now = new Date();
// A stale unsafe notice is never an instruction to repair or start a
// provider turn, including when newer work prevents replay of this run.
await tx.update(issueRecoveryActions).set({ status: "resolved", outcome: "restored", resolvedAt: now,
resolutionNote: "unsafe_export_automatic_recovery", nextAction: "", wakePolicy: null, updatedAt: now,
}).where(and(eq(issueRecoveryActions.companyId, run.companyId), eq(issueRecoveryActions.sourceIssueId, issue.id),
eq(issueRecoveryActions.cause, UNSAFE_EXPORT), sql`${issueRecoveryActions.evidence}->>'runId' = ${run.id}`,
or(inArray(issueRecoveryActions.status, ["active", "escalated"]),
and(eq(issueRecoveryActions.status, "resolved"), eq(issueRecoveryActions.resolutionNote, "new_source_execution_path"))),
));
const [newerRun] = await tx.select({ id: heartbeatRuns.id }).from(heartbeatRuns).where(and(
eq(heartbeatRuns.companyId, run.companyId), ne(heartbeatRuns.id, run.id),
or(eq(heartbeatRuns.nativeIssueId, issue.id), sql`${heartbeatRuns.contextSnapshot}->>'issueId' = ${issue.id}`),
or(gt(heartbeatRuns.createdAt, run.createdAt), inArray(heartbeatRuns.status, ["queued", "running"])),
)).limit(1);
const [otherAction] = await tx.select({ id: issueRecoveryActions.id }).from(issueRecoveryActions).where(and(
eq(issueRecoveryActions.companyId, run.companyId), eq(issueRecoveryActions.sourceIssueId, issue.id),
inArray(issueRecoveryActions.status, ["active", "escalated"]),
)).limit(1);
const [latestContract] = await tx.select({ id: completionContracts.id }).from(completionContracts).where(and(
eq(completionContracts.companyId, run.companyId), eq(completionContracts.issueId, issue.id),
)).orderBy(desc(completionContracts.revision)).limit(1);
const eligible = !explicitlySettled && !newerRun && !otherAction && latestContract?.id === result.completionContractId
&& ["blocked", "in_review"].includes(issue.status) && issue.assigneeAgentId === run.agentId
&& ["failed", "succeeded"].includes(run.status) && run.nativePhase === "terminal_failure"
&& (!issue.executionRunId || issue.executionRunId === run.id)
&& (!issue.checkoutRunId || issue.checkoutRunId === run.id);
let updatedRun: typeof heartbeatRuns.$inferSelect | undefined;
if (eligible) {
await ownership.assertHeld();
// Publish the omission barrier atomically with re-admission. This is
// explicitly not a receipt claiming that files were copied to the host.
await tx.insert(workspaceOperations).values({ companyId: run.companyId, heartbeatRunId: run.id, issueId: issue.id,
phase: "workspace_finalize", status: "succeeded", exitCode: 0, finishedAt: now,
metadata: { owningService: "native_workspace_finalizer", workspaceSync: { omitted: true, reason: "restore_unsafe_archive", legacy: true } },
});
await tx.update(nativeRunFinalizations).set({ phase: "result_accepted", failureCode: null, nextAttemptAt: null,
failureDetail: { workspaceFinalizeAttempt: 0, legacyUnsafeExportOmitted: { resultId: result.id, recoveredAt: now.toISOString() } }, updatedAt: now,
}).where(eq(nativeRunFinalizations.runId, run.id));
[updatedRun] = await tx.update(heartbeatRuns).set({ status: "running", finishedAt: null, error: null, errorCode: null,
nativePhase: "result_accepted", nativePhaseUpdatedAt: now,
resultJson: { ...run.resultJson, finalizationPhase: "result_accepted", failureCode: null, originalFailureCode: null, nextAttemptAt: null }, updatedAt: now,
}).where(eq(heartbeatRuns.id, run.id)).returning();
await tx.update(issues).set({ executionRunId: run.id, updatedAt: now }).where(eq(issues.id, issue.id));
await appendHeartbeatRunEvent(tx as unknown as Db, { companyId: run.companyId, agentId: run.agentId, runId: run.id,
eventType: "workspace_export_omitted", stream: "system", level: "info",
message: "Historical unsafe workspace export omitted; continuing with the accepted result.",
payload: { reason: "restore_unsafe_archive", legacy: true },
});
}
// Normal finalization publishes task status. Unsafe-export diagnostics
// belong only in run logs, not activity history or task notifications.
return updatedRun ?? null;
});
});
if (owned.acquired && owned.value) publishLiveEvent({ companyId: owned.value.companyId,
type: "heartbeat.run.status", payload: buildHeartbeatRunStatusLiveEventPayload(owned.value),
});
await recoverCandidate(db, candidate);
}
}
async function recoverCandidate(db: Db, candidate: { runId: string; companyId: string; issueId: string }) {
const owned = await withNativeWorkspaceFinalizationOwnership({ db, ...candidate }, async ownership => {
await ownership.assertHeld();
return db.transaction(async tx => {
const [issue] = await tx.select().from(issues).where(and(eq(issues.companyId, candidate.companyId), eq(issues.id, candidate.issueId))).for("update").limit(1);
const [coordinator] = await tx.select().from(nativeRunFinalizations).where(and(
eq(nativeRunFinalizations.companyId, candidate.companyId), eq(nativeRunFinalizations.runId, candidate.runId),
)).for("update").limit(1);
if (!coordinator || coordinator.phase !== "terminal_failure" || coordinator.failureCode !== UNSAFE_EXPORT
|| !coordinator.resultId || coordinator.leaseOwner || coordinator.issueId !== issue?.id) return null;
const [run] = await tx.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, candidate.companyId), eq(heartbeatRuns.id, candidate.runId))).for("update").limit(1);
if (!issue || !run || run.runtimeMode !== "native" || run.nativeIssueId !== issue.id) return null;
const [result] = await tx.select().from(nativeRunResults).where(and(
eq(nativeRunResults.id, coordinator.resultId), eq(nativeRunResults.companyId, run.companyId),
eq(nativeRunResults.runId, run.id), eq(nativeRunResults.issueId, issue.id),
eq(nativeRunResults.completionContractId, run.completionContractId ?? "00000000-0000-0000-0000-000000000000"),
eq(nativeRunResults.schemaStatus, "accepted"),
)).limit(1);
if (!result) return null;
const priorActions = await tx.select().from(issueRecoveryActions).where(and(
eq(issueRecoveryActions.companyId, run.companyId), eq(issueRecoveryActions.sourceIssueId, issue.id),
eq(issueRecoveryActions.cause, UNSAFE_EXPORT), sql`${issueRecoveryActions.evidence}->>'runId' = ${run.id}`,
));
const explicitlySettled = priorActions.some(action => action.status === "cancelled"
|| (action.status === "resolved" && !["new_source_execution_path", "unsafe_export_automatic_recovery"].includes(action.resolutionNote ?? "")));
const now = new Date();
if (priorActions.some(action => ["active", "escalated"].includes(action.status)
|| (action.status === "resolved" && action.resolutionNote === "new_source_execution_path"))) {
await notifyUnsafeWorkspaceExportRecovery(tx, { companyId: run.companyId, issueId: issue.id });
}
await tx.update(issueRecoveryActions).set({ status: "resolved", outcome: "restored", resolvedAt: now,
resolutionNote: "unsafe_export_automatic_recovery", nextAction: "", wakePolicy: null, updatedAt: now,
}).where(and(eq(issueRecoveryActions.companyId, run.companyId), eq(issueRecoveryActions.sourceIssueId, issue.id),
eq(issueRecoveryActions.cause, UNSAFE_EXPORT), sql`${issueRecoveryActions.evidence}->>'runId' = ${run.id}`,
or(inArray(issueRecoveryActions.status, ["active", "escalated"]),
and(eq(issueRecoveryActions.status, "resolved"), eq(issueRecoveryActions.resolutionNote, "new_source_execution_path"))),
));
const [newerRun] = await tx.select({ id: heartbeatRuns.id }).from(heartbeatRuns).where(and(
eq(heartbeatRuns.companyId, run.companyId), ne(heartbeatRuns.id, run.id),
or(eq(heartbeatRuns.nativeIssueId, issue.id), sql`${heartbeatRuns.contextSnapshot}->>'issueId' = ${issue.id}`),
or(gt(heartbeatRuns.createdAt, run.createdAt), inArray(heartbeatRuns.status, ["queued", "running"])),
)).limit(1);
const [otherAction] = await tx.select({ id: issueRecoveryActions.id }).from(issueRecoveryActions).where(and(
eq(issueRecoveryActions.companyId, run.companyId), eq(issueRecoveryActions.sourceIssueId, issue.id),
inArray(issueRecoveryActions.status, ["active", "escalated"]),
)).limit(1);
const [latestContract] = await tx.select({ id: completionContracts.id }).from(completionContracts).where(and(
eq(completionContracts.companyId, run.companyId), eq(completionContracts.issueId, issue.id),
)).orderBy(desc(completionContracts.revision)).limit(1);
const eligible = !explicitlySettled && !newerRun && !otherAction && latestContract?.id === result.completionContractId
&& ["blocked", "in_review"].includes(issue.status) && issue.assigneeAgentId === run.agentId
&& ["failed", "succeeded"].includes(run.status) && run.nativePhase === "terminal_failure"
&& (!issue.executionRunId || issue.executionRunId === run.id)
&& (!issue.checkoutRunId || issue.checkoutRunId === run.id);
let updatedRun: typeof heartbeatRuns.$inferSelect | undefined;
if (eligible) {
await ownership.assertHeld();
await tx.insert(workspaceOperations).values({ companyId: run.companyId, heartbeatRunId: run.id, issueId: issue.id,
phase: "workspace_finalize", status: "succeeded", exitCode: 0, finishedAt: now,
metadata: { owningService: "native_workspace_finalizer", workspaceSync: { omitted: true, reason: "restore_unsafe_archive", legacy: true } },
});
await tx.update(nativeRunFinalizations).set({ phase: "result_accepted", failureCode: null, nextAttemptAt: null,
failureDetail: { workspaceFinalizeAttempt: 0, legacyUnsafeExportOmitted: { resultId: result.id, recoveredAt: now.toISOString() } }, updatedAt: now,
}).where(eq(nativeRunFinalizations.runId, run.id));
[updatedRun] = await tx.update(heartbeatRuns).set({ status: "running", finishedAt: null, error: null, errorCode: null,
nativePhase: "result_accepted", nativePhaseUpdatedAt: now,
resultJson: { ...run.resultJson, finalizationPhase: "result_accepted", failureCode: null, originalFailureCode: null, nextAttemptAt: null }, updatedAt: now,
}).where(eq(heartbeatRuns.id, run.id)).returning();
await tx.update(issues).set({ executionRunId: run.id, updatedAt: now }).where(eq(issues.id, issue.id));
await appendHeartbeatRunEvent(tx as unknown as Db, { companyId: run.companyId, agentId: run.agentId, runId: run.id,
eventType: "workspace_export_omitted", stream: "system", level: "info",
message: "Historical unsafe workspace export omitted; continuing with the accepted result.",
payload: { reason: "restore_unsafe_archive", legacy: true },
});
}
return updatedRun ?? null;
});
});
if (owned.acquired && owned.value) publishLiveEvent({ companyId: owned.value.companyId,
type: "heartbeat.run.status", payload: buildHeartbeatRunStatusLiveEventPayload(owned.value),
});
}
function findOwedUnsafeExports(db: Db, scope?: UnsafeWorkspaceExportScope, after?: string, runId?: string) {
const action = alias(issueRecoveryActions, "unsafe_export_action");
const otherRun = alias(heartbeatRuns, "unsafe_export_other_run");
const contract = alias(completionContracts, "unsafe_export_contract");
const sameActionRun = and(eq(action.companyId, nativeRunFinalizations.companyId),
eq(action.sourceIssueId, nativeRunFinalizations.issueId), eq(action.cause, UNSAFE_EXPORT),
sql`${action.evidence}->>'runId' = ${nativeRunFinalizations.runId}::text`);
const staleAction = db.select({ id: action.id }).from(action).where(and(sameActionRun,
or(inArray(action.status, ["active", "escalated"]),
and(eq(action.status, "resolved"), eq(action.resolutionNote, "new_source_execution_path")))));
const explicitlySettled = db.select({ id: action.id }).from(action).where(and(sameActionRun,
or(eq(action.status, "cancelled"), and(eq(action.status, "resolved"),
sql`coalesce(${action.resolutionNote}, '') not in ('new_source_execution_path', 'unsafe_export_automatic_recovery')`))));
const competingAction = db.select({ id: action.id }).from(action).where(and(
eq(action.companyId, nativeRunFinalizations.companyId), eq(action.sourceIssueId, nativeRunFinalizations.issueId),
inArray(action.status, ["active", "escalated"]),
or(ne(action.cause, UNSAFE_EXPORT), sql`${action.evidence}->>'runId' is distinct from ${nativeRunFinalizations.runId}::text`)));
const newerRun = db.select({ id: otherRun.id }).from(otherRun).where(and(
eq(otherRun.companyId, heartbeatRuns.companyId), ne(otherRun.id, heartbeatRuns.id),
or(eq(otherRun.nativeIssueId, issues.id), sql`${otherRun.contextSnapshot}->>'issueId' = ${issues.id}::text`),
or(gt(otherRun.createdAt, heartbeatRuns.createdAt), inArray(otherRun.status, ["queued", "running"]))));
const latestContract = db.select({ id: contract.id }).from(contract).where(and(
eq(contract.companyId, nativeRunFinalizations.companyId), eq(contract.issueId, nativeRunFinalizations.issueId),
)).orderBy(desc(contract.revision)).limit(1);
return db.select({ runId: nativeRunFinalizations.runId, companyId: nativeRunFinalizations.companyId, issueId: nativeRunFinalizations.issueId })
.from(nativeRunFinalizations)
.innerJoin(heartbeatRuns, and(eq(heartbeatRuns.companyId, nativeRunFinalizations.companyId),
eq(heartbeatRuns.id, nativeRunFinalizations.runId), eq(heartbeatRuns.nativeIssueId, nativeRunFinalizations.issueId)))
.innerJoin(issues, and(eq(issues.companyId, nativeRunFinalizations.companyId), eq(issues.id, nativeRunFinalizations.issueId)))
.innerJoin(nativeRunResults, and(eq(nativeRunResults.id, nativeRunFinalizations.resultId),
eq(nativeRunResults.companyId, heartbeatRuns.companyId), eq(nativeRunResults.runId, heartbeatRuns.id),
eq(nativeRunResults.issueId, issues.id), eq(nativeRunResults.completionContractId, heartbeatRuns.completionContractId),
eq(nativeRunResults.schemaStatus, "accepted")))
.where(and(eq(nativeRunFinalizations.phase, "terminal_failure"), eq(nativeRunFinalizations.failureCode, UNSAFE_EXPORT),
eq(heartbeatRuns.runtimeMode, "native"),
scope ? and(eq(nativeRunFinalizations.companyId, scope.companyId), scope.issueId ? eq(nativeRunFinalizations.issueId, scope.issueId) : undefined) : undefined,
after ? gt(nativeRunFinalizations.runId, after) : undefined,
runId ? eq(nativeRunFinalizations.runId, runId) : undefined,
or(exists(staleAction), and(
notExists(explicitlySettled), notExists(competingAction), notExists(newerRun),
eq(nativeRunResults.completionContractId, latestContract),
inArray(issues.status, ["blocked", "in_review"]), eq(issues.assigneeAgentId, heartbeatRuns.agentId),
inArray(heartbeatRuns.status, ["failed", "succeeded"]), eq(heartbeatRuns.nativePhase, "terminal_failure"),
or(isNull(issues.executionRunId), eq(issues.executionRunId, heartbeatRuns.id)),
or(isNull(issues.checkoutRunId), eq(issues.checkoutRunId, heartbeatRuns.id)),
)),
)).orderBy(asc(nativeRunFinalizations.runId)).limit(100);
}
export function registerUnsafeWorkspaceExportWork(db: Db, coordinator: ReturnType<typeof createDeliveryWorkCoordinator>, canRun: () => boolean) {
type Entry = UnsafeWorkspaceExportScope & { id: string; dueAt: number };
async function load(scope?: UnsafeWorkspaceExportScope, signal?: AbortSignal): Promise<Entry[]> {
const entries: Entry[] = [];
let after: string | undefined;
while (canRun() && !signal?.aborted) {
const rows = await findOwedUnsafeExports(db, scope, after);
for (const row of rows) entries.push({ id: row.runId, companyId: row.companyId, issueId: row.issueId, dueAt: 0 });
if (rows.length < 100) return entries;
after = rows[rows.length - 1].runId;
}
throw new Error("Unsafe export recovery discovery stopped");
}
return registerScopedDeadlineWork(coordinator, {
queue: DELIVERY_QUEUES.unsafeWorkspaceExportRecovery, batchSize: 5,
decodeScope: (hint: string): UnsafeWorkspaceExportScope | undefined => hint === "*" ? undefined : JSON.parse(hint),
matches: (entry: Entry, scope?: UnsafeWorkspaceExportScope) => !scope || entry.companyId === scope.companyId && (!scope.issueId || entry.issueId === scope.issueId),
load,
check: async (entry: Entry, signal?: AbortSignal) => {
const [candidate] = await findOwedUnsafeExports(db, entry, undefined, entry.id);
if (!canRun() || signal?.aborted) throw new Error("Unsafe export recovery stopped");
if (!candidate) return null;
await recoverCandidate(db, candidate);
const pending = await findOwedUnsafeExports(db, entry, undefined, entry.id);
return pending.length ? { ...entry, dueAt: Date.now() + 30_000 } : null;
},
}, canRun);
}
@@ -1,29 +1,37 @@
import { heartbeatService } from "../heartbeat.js";
import { issueService } from "../issues.js";
import { issueRecoveryActionService } from "../issue-recovery-actions.js";
import { createDeliveryWorkCoordinator } from "../delivery-work-coordinator.js";
import { idleWorkSnapshot } from "../task-admission.js";
import { resolveExternalTestDatabaseUrl } from "../../__tests__/helpers/external-test-database.js";
import { randomUUID } from "node:crypto";
import { beforeAll, afterAll, describe, expect, it, vi } from "vitest";
import { eq } from "drizzle-orm";
import { beforeAll, afterAll, beforeEach, afterEach, describe, expect, it, vi } from "vitest";
import { and, eq } from "drizzle-orm";
import { agents, companies, completionContracts, createDb, environmentLeases, environments, heartbeatRuns, issues, issueRecoveryActions, nativeRunFinalizations, nativeRunResults, agentWakeupRequests, activityLog } from "@paperclipai/db";
import { startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js";
import { remoteTerminationReceipt } from "../remote-execution-termination.js";
import { withNativeWorkspaceFinalizationOwnership } from "./native-workspace-finalization-ownership.js";
const probe = vi.hoisted(() => vi.fn());
vi.mock("../environment-execution-target.js", () => ({ resolveEnvironmentExecutionTarget: async () => ({ kind: "remote", transport: "sandbox", remoteCwd: "/work", runner: { execute: probe } }) }));
import { recoverLegacyUnsafeWorkspaceExports } from "./native-workspace-export-recovery.js";
import { recoverLegacyUnsafeWorkspaceExports, registerUnsafeWorkspaceExportWork } from "./native-workspace-export-recovery.js";
import { recordNativeFinalizationFailure } from "./native-run-finalizer.js";
import { recoveryService } from "../recovery/service.js";
import { retryNativeWorkspaceExport } from "./native-workspace-export-retry.js";
import { releaseCompletedNativeWorkspaceExportRetention } from "./native-workspace-export-resume.js";
describe("board retry of accepted workspace export", () => {
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | undefined;
let db: ReturnType<typeof createDb>;
const companyId = randomUUID(), agentId = randomUUID(), environmentId = randomUUID();
beforeAll(async () => {
temporary = await startEmbeddedPostgresTestDatabase("workspace-export-retry-"); db = createDb(temporary.connectionString);
const external = resolveExternalTestDatabaseUrl("PAPERCLIP_EXPORT_RETRY_TEST_DATABASE_URL");
if (!external) temporary = await startEmbeddedPostgresTestDatabase("workspace-export-retry-");
db = createDb(external ?? temporary!.connectionString);
await db.insert(companies).values({ id: companyId, name: "Export repair", issuePrefix: "EXP" });
await db.insert(agents).values({ id: agentId, companyId, name: "Exporter", adapterType: "paperclip_runner" });
await db.insert(environments).values({ id: environmentId, name: `Retained ${environmentId}`, driver: "sandbox" });
}, 30_000);
afterAll(async () => { await temporary.cleanup(); });
afterAll(async () => { await db?.$client.end({ timeout: 1 }); await temporary?.cleanup(); });
async function seed(cause = "native_workspace_sync_out_retry_exhausted") {
probe.mockReset().mockResolvedValue({ exitCode: 0, timedOut: false });
const issueId = randomUUID(), runId = randomUUID(), resultId = randomUUID(), contractId = randomUUID(), leaseId = randomUUID(), actionId = randomUUID(), providerLeaseId = randomUUID();
@@ -288,4 +296,263 @@ describe("board retry of accepted workspace export", () => {
expect((f.request.environmentRuntime as { resumeRunLease: ReturnType<typeof vi.fn> }).resumeRunLease).not.toHaveBeenCalled();
});
describe("unsafe-export scoped recovery", () => {
const unsafe = "native_workspace_sync_out_unsafe_archive";
let coordinator: ReturnType<typeof createDeliveryWorkCoordinator> | undefined;
let admitted = true;
const errors: unknown[] = [];
beforeEach(async () => {
await db.update(nativeRunFinalizations).set({ failureCode: "unrelated_test_history" })
.where(and(eq(nativeRunFinalizations.companyId, companyId), eq(nativeRunFinalizations.failureCode, unsafe)));
admitted = true; errors.length = 0;
vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"], shouldAdvanceTime: true });
});
afterEach(async () => {
await coordinator?.stop(); coordinator = undefined;
vi.useRealTimers(); vi.restoreAllMocks();
expect(errors).toEqual([]);
});
async function start() {
coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => admitted, canReconcile: () => admitted,
onError: error => errors.push(error) });
await registerUnsafeWorkspaceExportWork(db, coordinator, () => admitted).ready;
}
async function flush() {
await vi.waitFor(async () => {
await vi.advanceTimersByTimeAsync(1000);
expect(idleWorkSnapshot().active).toBe(0);
expect(coordinator!.nextWakeAt()).toBeNull();
}, { timeout: 15_000 });
}
async function quiet() {
const select = vi.spyOn(db, "select"), transaction = vi.spyOn(db, "transaction");
try { await vi.advanceTimersByTimeAsync(120_000); expect(select).not.toHaveBeenCalled(); expect(transaction).not.toHaveBeenCalled(); }
finally { select.mockRestore(); transaction.mockRestore(); }
}
const phase = async (runId: string) => (await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, runId)))[0].phase;
async function seedBeforeFailure() {
const f = await seed(unsafe);
await db.delete(issueRecoveryActions).where(eq(issueRecoveryActions.id, f.actionId));
await db.update(nativeRunFinalizations).set({ phase: "result_accepted", failureCode: null }).where(eq(nativeRunFinalizations.runId, f.runId));
await db.update(heartbeatRuns).set({ status: "running", nativePhase: "result_accepted" }).where(eq(heartbeatRuns.id, f.runId));
return f;
}
function failExport(f: Awaited<ReturnType<typeof seed>>, owner = db) {
return recordNativeFinalizationFailure({ db: owner, runId: f.runId, error: new Error(unsafe),
failureScope: "workspace", projectRunStatus: true, permanent: true });
}
function gate() {
let release!: () => void;
const promise = new Promise<void>(resolve => { release = resolve; });
return { promise, release };
}
it("stays quiet with no matching work", async () => {
await seed(); await start(); await flush(); await quiet(); expect(probe).not.toHaveBeenCalled();
});
it.each(["newer_run", "older_active_run", "competing_action", "cancelled", "changed_contract", "explicit_settlement", "cancelled_action", "null_settlement", "rejected"])("does not poll dormant history: %s", async kind => {
const f = await seed(unsafe);
await db.update(issueRecoveryActions).set({ status: "resolved", resolutionNote: "unsafe_export_automatic_recovery" }).where(eq(issueRecoveryActions.id, f.actionId));
if (["newer_run", "older_active_run"].includes(kind)) await db.insert(heartbeatRuns).values({ companyId, agentId, nativeIssueId: f.issueId,
status: kind === "older_active_run" ? "running" : "failed", createdAt: new Date(Date.now() + (kind === "older_active_run" ? -60_000 : 60_000)) });
if (kind === "competing_action") await db.insert(issueRecoveryActions).values({ companyId, sourceIssueId: f.issueId, kind: "active_run_watchdog", ownerType: "board", cause: "other_repair", fingerprint: randomUUID(), nextAction: "Keep this repair", evidence: {} });
if (kind === "cancelled") await db.update(issues).set({ status: "cancelled" }).where(eq(issues.id, f.issueId));
if (kind === "changed_contract") {
const [contract] = await db.select().from(completionContracts).where(eq(completionContracts.issueId, f.issueId));
await db.insert(completionContracts).values({ ...contract, id: randomUUID(), revision: contract.revision + 1, canonicalSha256: randomUUID() });
}
if (["explicit_settlement", "null_settlement"].includes(kind)) await db.update(issueRecoveryActions).set({ resolutionNote: kind === "null_settlement" ? null : "operator_resolved" }).where(eq(issueRecoveryActions.id, f.actionId));
if (kind === "cancelled_action") await db.update(issueRecoveryActions).set({ status: "cancelled" }).where(eq(issueRecoveryActions.id, f.actionId));
if (kind === "rejected") await db.update(nativeRunResults).set({ schemaStatus: "rejected" }).where(eq(nativeRunResults.id, f.resultId));
await start(); await flush(); expect(await phase(f.runId)).toBe("terminal_failure"); await quiet();
expect(probe).not.toHaveBeenCalled();
});
it("retires a stale action even when a newer run prevents result readmission", async () => {
const f = await seed(unsafe);
await db.insert(heartbeatRuns).values({ companyId, agentId, nativeIssueId: f.issueId, status: "failed", createdAt: new Date(Date.now() + 60_000) });
await start(); await flush();
expect((await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.id, f.actionId)))[0]).toMatchObject({ status: "resolved", resolutionNote: "unsafe_export_automatic_recovery" });
expect(await phase(f.runId)).toBe("terminal_failure"); await quiet();
});
it("discovers a newer accepted result after retiring an older unsafe notice", async () => {
const older = await seed(unsafe);
const [oldRun] = await db.update(heartbeatRuns).set({ createdAt: new Date(Date.now() - 60_000) })
.where(eq(heartbeatRuns.id, older.runId)).returning();
const [oldResult] = await db.select().from(nativeRunResults).where(eq(nativeRunResults.id, older.resultId));
const runId = randomUUID(), resultId = randomUUID();
await db.insert(heartbeatRuns).values({ ...oldRun, id: runId, createdAt: new Date() });
await db.insert(nativeRunResults).values({ ...oldResult, id: resultId, runId,
serverFingerprint: resultId, canonicalSha256: resultId });
await db.insert(nativeRunFinalizations).values({ runId, companyId, issueId: older.issueId,
phase: "terminal_failure", resultId, failureCode: unsafe });
const accepted = await db.select().from(nativeRunResults).where(eq(nativeRunResults.issueId, older.issueId));
await start(); await flush();
expect((await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.id, older.actionId)))[0])
.toMatchObject({ status: "resolved", resolutionNote: "unsafe_export_automatic_recovery" });
expect(await phase(older.runId)).toBe("terminal_failure");
expect(await phase(runId)).toBe("result_accepted");
expect(await db.select().from(nativeRunResults).where(eq(nativeRunResults.issueId, older.issueId))).toEqual(accepted);
expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.nativeIssueId, older.issueId))).toHaveLength(2);
expect(probe).not.toHaveBeenCalled();
await quiet();
});
it("wakes from a real finalization failure and readmits only the accepted result", async () => {
const f = await seedBeforeFailure(); await start(); await flush(); await quiet();
await failExport(f); await flush(); expect(await phase(f.runId)).toBe("result_accepted");
expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.nativeIssueId, f.issueId))).toHaveLength(1);
expect(await db.select().from(nativeRunResults).where(eq(nativeRunResults.runId, f.runId))).toHaveLength(1);
expect(probe).not.toHaveBeenCalled(); await quiet();
});
it.each(["commit", "rollback"] as const)("waits for the outer failure transaction to %s", async outcome => {
const f = await seedBeforeFailure(); await start(); await flush();
const entered = gate(), release = gate();
const writing = db.transaction(async tx => {
await failExport(f, tx as unknown as typeof db); entered.release(); await release.promise;
if (outcome === "rollback") throw new Error("rollback unsafe export");
}).then(() => null, error => error);
await entered.promise; await vi.advanceTimersByTimeAsync(2000);
expect(await phase(f.runId)).toBe("result_accepted"); release.release();
const error = await writing;
if (outcome === "rollback") expect(error).toMatchObject({ message: "rollback unsafe export" });
else expect(error).toBeNull();
await flush(); expect(await phase(f.runId)).toBe("result_accepted");
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId));
expect(run.status).toBe("running"); await quiet();
});
it.each(["restart", "drain"])("recovers saved work after %s", async mode => {
const f = await seedBeforeFailure();
if (mode === "drain") { await start(); await flush(); admitted = false; }
await failExport(f);
if (mode === "restart") await start();
else { await quiet(); expect(await phase(f.runId)).toBe("terminal_failure"); admitted = true; await vi.advanceTimersByTimeAsync(30_000); }
await flush(); expect(await phase(f.runId)).toBe("result_accepted"); await quiet();
});
it("retains a concrete coordinator lease until its owner releases it", async () => {
const f = await seed(unsafe);
await db.update(nativeRunFinalizations).set({ leaseOwner: "still-owned", leaseExpiresAt: new Date(Date.now() + 300_000) }).where(eq(nativeRunFinalizations.runId, f.runId));
await start(); expect(await phase(f.runId)).toBe("terminal_failure"); expect(coordinator!.nextWakeAt()).not.toBeNull();
await db.update(nativeRunFinalizations).set({ leaseOwner: null, leaseExpiresAt: null }).where(eq(nativeRunFinalizations.runId, f.runId));
await vi.advanceTimersByTimeAsync(30_000); await flush(); expect(await phase(f.runId)).toBe("result_accepted"); await quiet();
});
it("waits for physical workspace ownership without stealing or discarding the obligation", async () => {
const f = await seed(unsafe);
const owned = await withNativeWorkspaceFinalizationOwnership(f, async () => {
await start();
await vi.advanceTimersByTimeAsync(60_000);
expect(await phase(f.runId)).toBe("terminal_failure");
await vi.waitFor(() => {
expect(idleWorkSnapshot().active).toBe(0);
expect(coordinator!.nextWakeAt()).not.toBeNull();
});
expect((await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.id, f.actionId)))[0].status).toBe("active");
});
expect(owned.acquired).toBe(true);
await vi.advanceTimersByTimeAsync(30_000); await flush();
expect(await phase(f.runId)).toBe("result_accepted");
expect(probe).not.toHaveBeenCalled(); await quiet();
});
it.each(["admin", "stale_lock_recovery"])("reactivates dormant history after %s releases an older run's locks", async mode => {
const f = await seed(unsafe);
await db.update(issueRecoveryActions).set({ status: "resolved", resolutionNote: "unsafe_export_automatic_recovery" }).where(eq(issueRecoveryActions.id, f.actionId));
const [older] = await db.insert(heartbeatRuns).values({ companyId, agentId, nativeIssueId: f.issueId,
status: "failed", createdAt: new Date(Date.now() - 60_000) }).returning();
await db.update(issues).set({ checkoutRunId: older.id, executionRunId: older.id }).where(eq(issues.id, f.issueId));
await start(); await flush();
expect(await phase(f.runId)).toBe("terminal_failure"); await quiet();
if (mode === "admin") await issueService(db).adminForceRelease(f.issueId);
else expect((await recoveryService(db, { enqueueWakeup: vi.fn() }).sweepStaleIssueLocks()).issueIds).toContain(f.issueId);
await flush(); expect(await phase(f.runId)).toBe("result_accepted");
expect(probe).not.toHaveBeenCalled(); await quiet();
});
it("stops pending retries and reconstructs them on restart", async () => {
const f = await seed(unsafe);
await db.update(nativeRunFinalizations).set({ leaseOwner: "still-owned", leaseExpiresAt: new Date(Date.now() + 300_000) }).where(eq(nativeRunFinalizations.runId, f.runId));
await start(); expect(coordinator!.nextWakeAt()).not.toBeNull();
await coordinator!.stop(); coordinator = undefined;
await db.update(nativeRunFinalizations).set({ leaseOwner: null, leaseExpiresAt: null }).where(eq(nativeRunFinalizations.runId, f.runId));
await quiet(); expect(await phase(f.runId)).toBe("terminal_failure");
await start(); await flush(); expect(await phase(f.runId)).toBe("result_accepted"); await quiet();
});
it("revalidates task cancellation while a physical owner delays recovery", async () => {
const f = await seed(unsafe);
await withNativeWorkspaceFinalizationOwnership(f, async () => {
await start();
await issueService(db).update(f.issueId, { status: "cancelled" });
});
await vi.advanceTimersByTimeAsync(30_000); await flush();
expect(await phase(f.runId)).toBe("terminal_failure");
expect((await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.id, f.actionId)))[0].status).toBe("resolved");
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId)))[0].status).toBe("failed");
expect(probe).not.toHaveBeenCalled(); await quiet();
});
it("discovers and drains more than one page of stale notices", async () => {
const fixtures = [];
for (let index = 0; index < 103; index++) {
const f = await seed(unsafe);
await db.update(issues).set({ status: "cancelled" }).where(eq(issues.id, f.issueId));
fixtures.push(f);
}
await start(); await flush();
for (const f of fixtures) {
expect(await phase(f.runId)).toBe("terminal_failure");
expect((await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.id, f.actionId)))[0]).toMatchObject({ status: "resolved", resolutionNote: "unsafe_export_automatic_recovery" });
}
expect(probe).not.toHaveBeenCalled(); await quiet();
}, 30_000);
it("reactivates dormant history when the real cancellation path settles an older queued run", async () => {
const f = await seed(unsafe);
await db.update(issueRecoveryActions).set({ status: "resolved", resolutionNote: "unsafe_export_automatic_recovery" }).where(eq(issueRecoveryActions.id, f.actionId));
const [older] = await db.insert(heartbeatRuns).values({ companyId, agentId, contextSnapshot: { issueId: f.issueId },
status: "queued", runtimeMode: "legacy", createdAt: new Date(Date.now() - 60_000) }).returning();
await start(); await flush(); expect(await phase(f.runId)).toBe("terminal_failure"); await quiet();
await heartbeatService(db).cancelRun(older.id);
await flush(); expect(await phase(f.runId)).toBe("result_accepted");
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, older.id)))[0].status).toBe("cancelled");
expect(probe).not.toHaveBeenCalled(); await quiet();
});
it("retries failed discovery after admission resumes", async () => {
const f = await seed(unsafe);
const read = vi.spyOn(db, "select").mockImplementationOnce(() => { throw new Error("injected unsafe discovery failure"); });
await start(); await vi.waitFor(() => expect(errors.length).toBeGreaterThan(0)); read.mockRestore();
errors.length = 0; admitted = false; await quiet(); admitted = true;
await vi.advanceTimersByTimeAsync(30_000); await flush(); expect(await phase(f.runId)).toBe("result_accepted"); await quiet();
});
it("does not wake dormant history for unchanged action metadata or repeated resolution", async () => {
const f = await seed(unsafe);
await db.update(issueRecoveryActions).set({ status: "resolved", resolutionNote: "unsafe_export_automatic_recovery" }).where(eq(issueRecoveryActions.id, f.actionId));
const actionInput = { companyId, sourceIssueId: f.issueId, kind: "active_run_watchdog" as const,
ownerType: "board" as const, cause: "other_repair", fingerprint: randomUUID(), nextAction: "Wait for repair" };
const service = issueRecoveryActionService(db);
const other = await service.upsertSourceScoped(actionInput);
await start(); await flush(); await quiet();
await service.upsertSourceScoped({ ...actionInput, nextAction: "Updated operator guidance" });
expect(coordinator!.nextWakeAt()).toBeNull(); await quiet();
const resolution = { companyId, sourceIssueId: f.issueId, actionId: other.id,
status: "resolved" as const, outcome: "restored" as const, resolutionNote: "Other repair finished" };
await service.resolveActiveForIssue(resolution); await flush();
expect(await phase(f.runId)).toBe("result_accepted");
await service.resolveActiveForIssue(resolution);
expect(coordinator!.nextWakeAt()).toBeNull(); await quiet();
});
it("reactivates dormant history from the real recovery-action resolution", async () => {
const f = await seed(unsafe);
await db.update(issueRecoveryActions).set({ status: "resolved", resolutionNote: "unsafe_export_automatic_recovery" }).where(eq(issueRecoveryActions.id, f.actionId));
const [other] = await db.insert(issueRecoveryActions).values({ companyId, sourceIssueId: f.issueId, kind: "active_run_watchdog", ownerType: "board", cause: "other_repair", fingerprint: randomUUID(), nextAction: "Wait for repair" }).returning();
await start(); await flush(); await quiet();
await issueRecoveryActionService(db).resolveActiveForIssue({ companyId, sourceIssueId: f.issueId, actionId: other.id,
status: "resolved", outcome: "restored", resolutionNote: "Other repair finished" });
await flush(); expect(await phase(f.runId)).toBe("result_accepted"); await quiet();
});
it("reactivates dormant history from a real task update", async () => {
const f = await seed(unsafe);
await db.update(issues).set({ status: "cancelled" }).where(eq(issues.id, f.issueId));
await start(); await flush(); await quiet();
await issueService(db).update(f.issueId, { status: "blocked" });
await flush(); expect(await phase(f.runId)).toBe("result_accepted"); await quiet();
});
});
});
@@ -1,3 +1,4 @@
import { notifyUnsafeWorkspaceExportRecovery } from "../unsafe-workspace-export-notifications.js";
import { and, eq, gt, inArray, ne, notInArray, or, sql } from "drizzle-orm";
import { agentWakeupRequests, assets, environmentLeases, heartbeatRuns, issueAttachments, issueRecoveryActions, issues, nativeRunFinalizations, nativeRunResults, type Db } from "@paperclipai/db";
import { reconstructWorkspaceSchema, type ExecutionReconciliation } from "@paperclipai/shared";
@@ -177,6 +178,7 @@ export async function reconstructNativeWorkspace(input: {
...(source.interveningExecutions.length ? { interveningExecutions: source.interveningExecutions } : {}) } };
await markExecutionReconciliation(db, source.action, decision, input.actorId);
const now = new Date();
await notifyUnsafeWorkspaceExportRecovery(tx, { companyId: input.companyId, issueId: input.issueId });
await tx.update(issueRecoveryActions).set({ status: "resolved", outcome: "handed_back", resolvedAt: now, updatedAt: now,
resolutionNote: "workspace_reconstruction_authorized", nextAction: "Continue from saved sources. The original unexported edits are lost; verify the selected attachments before rebuilding. Do not repeat completed external actions or reset the original work limits.",
evidence: sql`${issueRecoveryActions.evidence} || ${JSON.stringify({ workspaceReconstructionRequest: requestSha256 })}::jsonb`,
@@ -1,3 +1,4 @@
import { notifyUnsafeWorkspaceExportRecovery } from "../unsafe-workspace-export-notifications.js";
import { notifyAutomaticCompletionReviews } from "../automatic-completion-review-notifications.js";
import { notifyObsoletePolicyReviews } from "../obsolete-policy-review-notifications.js";
import { notifyCancelledWorkspacePreservation } from "../cancelled-workspace-preservation-notifications.js";
@@ -1072,6 +1073,7 @@ async function materializeDecisionEffect(input: {
}
await notifyInstructionCollection(input.tx, { companyId: input.companyId, runId: input.runId });
await notifyCancelledWorkspacePreservation(input.tx, { companyId: input.companyId, runId: input.runId });
await notifyUnsafeWorkspaceExportRecovery(input.tx, { companyId: input.companyId, issueId: input.issue.id });
await notifyDeliveryWork(input.tx, DELIVERY_QUEUES.accounting, input.companyId);
const [run] = await input.tx
.update(heartbeatRuns)
@@ -1503,6 +1505,7 @@ async function materializeDecisionEffect(input: {
};
}
if (effect.kind === "release_checkout") {
await notifyUnsafeWorkspaceExportRecovery(input.tx, { companyId: input.companyId, issueId: input.issue.id });
const [released] = await input.tx
.update(issues)
.set({
+6 -1
View File
@@ -1,3 +1,4 @@
import { notifyUnsafeWorkspaceExportRecovery } from "../unsafe-workspace-export-notifications.js";
import { notifyCancelledWorkspacePreservation } from "../cancelled-workspace-preservation-notifications.js";
import { notifyInstructionCollection } from "../instruction-collection-notifications.js";
import { notifyChatRunMilestones } from "../chat-run-milestone-notifications.js";
@@ -6253,6 +6254,7 @@ export function recoveryService(
await notifyCancelledWorkspacePreservation(tx, { companyId: candidate.companyId, runId: candidate.id });
const boundIssueId = boundRunIssueId(candidate);
if (boundIssueId) {
await notifyUnsafeWorkspaceExportRecovery(tx, { companyId: run.companyId, issueId: boundIssueId });
await notifyChatRunMilestones(tx, { companyId: run.companyId, issueId: boundIssueId });
}
return tx
@@ -6449,7 +6451,9 @@ export function recoveryService(
continue;
}
const updated = await db
const updated = await db.transaction(async tx => {
await notifyUnsafeWorkspaceExportRecovery(tx, { companyId: issue.companyId, issueId: issue.id });
return tx
.update(issues)
.set({
checkoutRunId: null,
@@ -6471,6 +6475,7 @@ export function recoveryService(
)
.returning({ id: issues.id })
.then((rows) => rows[0] ?? null);
});
if (!updated) continue;
@@ -1,3 +1,4 @@
import { notifyUnsafeWorkspaceExportRecovery } from "./unsafe-workspace-export-notifications.js";
import { and, desc, eq, inArray, sql } from "drizzle-orm";
import { chatConversations, chatEndpoints, heartbeatRuns, issueComments, issues, type Db } from "@paperclipai/db";
import { persistActivity, publishActivity, type ActivityPublication } from "./activity-log.js";
@@ -90,6 +91,7 @@ export async function settleExternalConversation(db: Db, companyId: string, issu
.where(and(eq(chatConversations.id, binding.conversation.id), eq(chatConversations.companyId, companyId), eq(chatConversations.state, "active")))
.returning({ id: chatConversations.id });
if (!changed.length) return false;
await notifyUnsafeWorkspaceExportRecovery(tx, { companyId, issueId });
await tx.update(issues).set({ status: "in_review", updatedAt: new Date() })
.where(and(eq(issues.id, issueId), eq(issues.companyId, companyId)));
activity = (await persistActivity(tx as unknown as Db, {
@@ -0,0 +1,16 @@
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
export type UnsafeWorkspaceExportScope = { companyId: string; issueId?: string };
export async function notifyUnsafeWorkspaceExportRecovery(transaction: object, scope: UnsafeWorkspaceExportScope) {
await notifyDeliveryWork(transaction, DELIVERY_QUEUES.unsafeWorkspaceExportRecovery,
JSON.stringify({ companyId: scope.companyId, issueId: scope.issueId }));
}
export async function notifyUnsafeWorkspaceExportRunChange(transaction: object, run: {
companyId: string; nativeIssueId: string | null; contextSnapshot: Record<string, unknown> | null;
}) {
const contextIssueId = run.contextSnapshot?.issueId;
const issueIds = new Set([run.nativeIssueId, typeof contextIssueId === "string" ? contextIssueId : null]);
for (const issueId of issueIds) if (issueId) await notifyUnsafeWorkspaceExportRecovery(transaction, { companyId: run.companyId, issueId });
}