fix(runner): preserve bounded recovery fixture synchronization

Merge current master and reuse the durable-resume test envelope correction. Wait for actual callback entry under the existing recovery deadline and preserve original early errors.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-12 22:29:13 -05:00
commit dae808fe02
45 files changed
+1338 -757

No files matched your search

+8
View File
@@ -2034,6 +2034,14 @@ pnpm secrets:migrate-inline-env --apply # apply migration
## Internal Connection Token Brokers
Paperclip Cloud connector identities support `development`, `test`, `staging`,
and `production`. To enroll a self-hosted instance against the test broker, set
`PAPERCLIP_CLOUD_CONNECTOR_BASE_URL=https://test.paperclip.app`; its environment
is inferred as `test`. Managed instances also receive
`PAPERCLIP_CLOUD_CONNECTOR_ENVIRONMENT=test`. Standard Cloud broker hosts reject
identities for another environment, and enrollment keys remain local to the
selected broker.
Connection token exchanges carry a resolved parent credential, so their URLs
are public-only by default even in local/private deployments. To intentionally
use an internal broker, configure an exact hostname allowlist:
@@ -9,6 +9,15 @@ A completed workspace barrier is reread
under ownership before export, and a committed coordinator cannot be overwritten
by a late failure receipt.
An unverified workspace owner keeps one board recovery action for its exact run
and owner fingerprint. Repeated recovery checks do not consume attempts or
rewrite that action. Task completion or cancellation does not prove the old
physical writer stopped, so reads and recovery sweeps retain this action while
the matching run still carries that owner receipt. After the receipt is cleared
or replaced, ordinary stale-action cleanup applies. Cleanup is attributed to
the system; historical cleanup from page reads does not add tasks to a person's
inbox or refresh issue activity timestamps.
The lock transaction holds no row locks. Ordinary progress and finalization
receipts remain visible through the normal database pool. A dedicated connection outside the application pool is
reserved for the duration of copyback and closed when it settles; even a one-connection application pool remains available for progress writes. Its run profile carries
@@ -2,6 +2,68 @@
Updated 2026-10-12.
## Final CI fixture corrections and current master
Published prerequisite `16a53254c495f3e49a28cc911f5dcd60c41779fc`
has fresh Greptile 5/5 and all four review threads resolved. Full CI records
52 successful checks, four conditional skips and two failures: the runner
Vitest first shard and its aggregate. The actual failing shard passes 1,671
cases and skips three; its two failures remain recorded in
`/private/tmp/pr2-16a532-runner-vitest-failure.log`.
The durable-resume case exceeded its outer five-second test envelope. Reuse
Claude owner's existing correction from `c60bf4b6`, changing only that case's
outer limit to 15 seconds. On canonical Node 24, controlled 3,800 ms fake
latency reproduces the old outer timeout, while corrected and ordinary cases
pass. Internal 500 ms/5 second turn deadlines, distinct attempts, same thread,
effect deduplication and the complete usage assertions remain unchanged.
Receipt: `/private/tmp/pr2-16a532-durable-resume-correction-receipt.json`.
The warm-recovery callback fixture polled callback installation with Vitest's
implicit one-second wait, despite its existing five-second request bound.
Replace polling with actual callback-entry notification raced against request
settlement under that same bound. Controlled 1,250 ms scheduling delay fails
the original assertion and passes the correction; an early recovery error now
surfaces unchanged. All fence, snapshot, retry and side-effect assertions remain.
The original hosted request outcome was hidden, so its underlying cause remains
inconclusive until fresh hosted CI. These controls use recorded historical mock
binaries and do not qualify a final native package. Receipt:
`/private/tmp/ci-final-16a532-warm-callback-signal-receipt.json`.
Independent matching-source review of both test-only changes is clear:
`/private/tmp/pr2-16a532-fixture-corrections-independent-review.json`.
Current master `e168512f7bdbb6af6ea01954c6d7ed80b91e903e` merges cleanly.
Runner/surfaces/server/E2E no-emit checks pass on the composed source. Maps
remain aligned at 35 areas/171 IDs and token gates pass. Master changed three
activity/inbox/recovery recipes without synchronizing 25 catalog fingerprints.
Independent inspection verifies those entries are exact inherited master drift;
all 92 declared non-map story/component/wrapper/fixture inputs are unchanged.
Only their guarded fingerprints and a source-only review note are synchronized.
Existing 24 unreviewed/one current classifications, IDs, fixtures and INDEX stay
unchanged. The catalog check passes 223 files/2,210 stories. No new live or
visual coverage is claimed for the inherited backend requirements.
The exact `16a532` remote package campaign is
https://github.com/paperclipai/paperclip/actions/runs/38338806076.
The campaign completed successfully. All four native producers, public Linux
package assembly and the Apple Silicon, Intel Mac and Linux ARM64 installed
consumers pass. The earlier Intel timeout remains a separate failed receipt;
this success does not establish its cause or a diagnostic-code fix.
This remains literal source evidence after the current master/test changes.
No queued job is rerun or obsolete package result relabeled.
Next action: preserve the completed package result, publish this ordinary master
reconciliation with both scoped fixture corrections, require fresh full CI and
review, and qualify the final package source. The prerequisite remains draft,
auto-merge off, and actual code-owner review is pending. Codex stays `1a0ca662`
until actual prerequisite merge and source reconciliation; the Claude owner
retains its branches and separate $50 allowance. Final immutable managed QA,
actual private Cloud distribution, warm/cold signup and backward-image or
forward-recovery evidence remain required. Production Admin sign-in is pending.
Nothing is merged, deployed or production-ready. The idle root test-drive and
its data are preserved. Paid holds remain $248.179254104/$250; no provider turn,
new paid host, local native/Docker build, rollout or default promotion occurred.
## Master reconciliation and installed-package checkpoint
The published prerequisite `64c074fb8da1b31f8d6042c4fef48dc4164e759b`
@@ -444,8 +444,13 @@ Longer polling intervals alone do not complete an item.
running sessions and pauses without rescheduling until an endpoint change.
Tests exercise real producers with deferred fast paths disabled, retry and
startup recovery, stale-send quarantine, and zero scans after settlement.
Remaining **1s** maintenance: provider runtimes, Telegram maintenance, failed
chat-run retries, run milestone/wake-notice projection, GitHub operation
Failed chat-run retries now also use commit wakes and durable deadlines from
existing action rows. Issued retries honor `retryAt`; processing claims wait
for both stale-claim expiry and any saved retry time. Startup recovers unfinished
requests. Claim and settlement writes signal in their own short transactions,
with agent wakeup outside those transactions. Empty and terminal queues disarm.
Remaining **1s** maintenance: provider runtimes, Telegram maintenance,
run milestone/wake-notice projection, GitHub operation
recovery, and Teams protocol maintenance. GitHub pre-ingress recovery
must still inspect provider history: a lost callback has no local queue row.
This slice does **not** yet make the whole chat subsystem quiet. Independent
+21 -11
View File
@@ -40,22 +40,32 @@ Review: `baseline`.
API: `present` — representative operation IDs:
- `GET /api/companies/{companyId}/activity`.
- `GET /api/issues/{id}/activity` (UUID or issue identifier).
Agent access: `available` — REST / native call_api / CLI activity.
Agent access: `partial` — REST / native call_api / CLI activity for company
activity. Sandbox agents can use GET issue activity through the default
[callback bridge](../../packages/adapter-utils/src/sandbox-callback-bridge.ts).
The bridge still rejects company activity and company audit routes.
Policy: Same company plus visibility/audit/cost/run permissions where the route requires
them.
Policy: Company activity requires same-company access and `company_scope:read`,
with issue visibility filtering. Issue activity requires same-company resource
access and `issue:read` for the resolved issue. The sandbox transport does not
replace those controller checks or permit activity writes.
Evidence: `source-only` — [implementation](../../server/src/routes/activity.ts#L224) at
the reviewed commit above.
Evidence: `source-only` — [activity routes](../../server/src/routes/activity.ts),
[agent authorization tests](../../server/src/__tests__/activity-routes.test.ts),
and [bridge tests](../../packages/adapter-utils/src/sandbox-callback-bridge.test.ts).
Issue activity transport reviewed at `89770ba707fb1ebd4cabd184eec2e7fee226e0c3`;
other operations retain the baseline source review above.
Verification: `not-run` — exercise a listed permitted operation with actual agent
credentials/run context; inspect its result and applicable persistence/attribution.
Assert the relevant denied actor, owner, company or mode from Policy. Review the
remaining operations before qualifying the whole capability.
Verification: `not-run` — no live agent-credential proof. Local route and bridge
regressions pass: permitted agent issue read, denied company/resource scope,
denied writes, extra path segments, and company audit routes. With a real
sandbox agent, read an authorized issue's activity, then verify denial for an
inaccessible issue, another company, and company-wide audit.
Gaps: Audit export and raw activity creation have extra restrictions; do not equate them
with activity reading.
Gaps: Audit export and raw activity creation have extra restrictions; do not
equate them with issue activity reading. Live sandbox access remains unverified.
## `runs`
+12 -3
View File
@@ -22,15 +22,24 @@ API: `present` — representative operation IDs:
Agent access: `partial` — REST / native call_api; MCP inbox/task search.
Policy: Own agent context; responsible-user inbox policy and explicit opt-in/grants for
cross-user archive; decision policy separately.
cross-user archive; decision policy separately. Automatic read cleanup does not
count as user participation, including historical reader-attributed rows.
Explicit user recovery decisions still count.
Evidence: `source-only` — [implementation](../../server/src/routes/agents.ts#L3952) at
the reviewed commit above.
the reviewed commit above. The cleanup exclusion in
[issue queries](../../server/src/services/issues.ts) was reviewed at
`89770ba707fb1ebd4cabd184eec2e7fee226e0c3`.
[Issue service regressions](../../server/src/__tests__/issues-service.test.ts)
cover membership, unread counts, timestamps, and retained audit rows.
Verification: `not-run` — exercise a listed permitted operation with actual agent
credentials/run context; inspect its result and applicable persistence/attribution.
Assert the relevant denied actor, owner, company or mode from Policy. Review the
remaining operations before qualifying the whole capability.
remaining operations before qualifying the whole capability. Seed a completed
issue with only automatic read cleanup under a user identity: Mine and Unread
must exclude it while activity history retains the row. An explicit recovery
decision must still establish participation. No live user-inbox proof is claimed.
Gaps: Agent inbox and Mine support exist; board attention/unread/dismissal aggregation
is a separate user-only surface.
+13 -3
View File
@@ -64,20 +64,30 @@ Review: `baseline`.
API: `present` — representative operation IDs:
- `GET /api/issues/{id}/recovery-actions`.
- `POST /api/issues/{id}/recovery-actions/resolve`.
Agent access: `partial` — Eligible agent REST / CLI; source-bound recovery state.
Policy: Task/run visibility, run ownership and source/action policy; workspace export
retry and some operator controls require board.
retry and some operator controls require board. Completion or cancellation of a
task does not clear its exact unresolved native workspace owner hold. Physical
owner release still requires the [operator stop-verification procedure](../../doc/native-workspace-finalization-recovery.md).
Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts#L9478) at
the reviewed commit above.
the reviewed commit above. Exact owner hold retention in the
[recovery state helper](../../server/src/services/native-runtime/native-workspace-finalization-recovery-state.ts)
was reviewed at `89770ba707fb1ebd4cabd184eec2e7fee226e0c3`.
Verification: `not-run` — exercise a listed permitted operation with actual agent
credentials/run context; inspect its result and applicable persistence/attribution.
Assert the relevant denied actor, owner, company or mode from Policy. Review the
remaining operations before qualifying the whole capability.
remaining operations before qualifying the whole capability. Local
[recovery route regressions](../../server/src/__tests__/issue-recovery-actions.test.ts)
verify one unchanged hold across reads and sweeps on done/cancelled tasks, no
physical copyback, and cleanup after the receipt is cleared. Exercise this
sequence in a disposable instance; automatic cleanup must use the system actor
and must not create reader participation. No live recovery proof is claimed.
Gaps: Need operation-level eligibility and a real native binding for permitted recovery
actions; do not route around lifecycle authority.
+6
View File
@@ -40,6 +40,12 @@ Automated: [activity service](../../server/src/__tests__/activity-service.test.t
Manual: Find a known mutation by actor/resource and a known run by status. Change filters, load more, and follow the source links. Reload the chosen view and verify no company leakage or duplicate events. Inspect a routine run from its audit entry.
Issue activity is also available through `GET /api/issues/{id}/activity`.
[Agent authorization](../../server/src/__tests__/activity-routes.test.ts) and
[sandbox bridge](../../packages/adapter-utils/src/sandbox-callback-bridge.test.ts)
regressions cover this route separately from company-wide audit. Live sandbox
agent access remains unverified; use the companion recipe.
Audit-event Load more and run listings differ: Runs currently loads at most 200
entries without pagination. The tested RoutineAuditActivity component has no
registered activity-route host; inspect execution links actually offered by
+11
View File
@@ -35,8 +35,19 @@ Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Seed
Automated: [archive routes](../../server/src/__tests__/inbox-archive-routes.test.ts) cover server mutations. [Task-list helpers](../../ui/src/pages/Issues.test.tsx) cover search URLs, pagination, and presentation constants; they do not render the combined Inbox/Tasks page. Reading, archiving, and navigating that page remain manual checks.
Automated: [Issue service regressions](../../server/src/__tests__/issues-service.test.ts)
cover exclusion of historical automatic read cleanup from user participation,
unread counts, and activity timestamps while retaining explicit user decisions
and audit history.
Manual: Read an unread task, return to Unread, archive a handled item, and reload. Verify personal triage state changes while task status stays correct. Switch users and companies to check isolation.
Seed a completed task whose only activity under the viewing user is automatic
read cleanup. Refresh Mine and Unread: the task must not appear through that
participation, while the cleanup remains in activity history. Record an explicit
user recovery decision on a separate task and verify it still counts. This
browser journey remains manual.
### `decisions`
Automated: [decision queues](../../server/src/__tests__/decision-queues-routes.test.ts) covers route behavior. End-to-end queue-to-source navigation is manual.
+8
View File
@@ -90,6 +90,14 @@ the supported recovery until the source task produces a useful result or a
specific escalation. Reload and verify that resolved notices retire and attempt
history remains. A fixture pass does not qualify every live harness/provider.
For a native workspace owner hold, repeat issue reads and recovery checks after
the task becomes done or cancelled. Verify one unchanged action ID, attempt
count, and timestamps; task completion must not authorize copyback. Clear only
the exact receipt after operator stop verification, then confirm stale action
cleanup. The [route regressions](../../server/src/__tests__/issue-recovery-actions.test.ts)
and [heartbeat regression](../../server/src/__tests__/heartbeat-process-recovery.test.ts)
cover this sequence without a live operator recovery claim.
### `run-detail`
Automated: [run workspace recovery](../../ui/src/components/RunWorkspaceRecoverySurface.test.tsx)
+38
View File
@@ -240,3 +240,41 @@ Reviewed the same twelve workspace-recipe-dependent entries after extending the
## Default-source recipe follow-up — 2026-10-13
Reviewed the twelve unchanged workspace recipe subjects and fixtures after correcting implicit default selection. Task creation and controller classification now share configured-policy, primary, creation-time and ID precedence. Explicit choices and retained denial markers still suppress controller hooks. Project strategy fixtures show configuration, while runtime status and file/browser/artifact/service fixtures do not execute source scripts or prove this backend ordering. Refreshed only their reviewed recipe fingerprints and retained all classifications and stable IDs. No browser render, play, screenshot, Storybook build or live runtime acceptance was performed.
## Inherited activity, inbox and recovery recipes — 2026-10-12
Source comparison: master `e168512f7bdbb6af6ea01954c6d7ed80b91e903e`
against previous master `f329b24ee12496e710ad17377ec7716e0eee22ef`,
while reconciling the native-runtime prerequisite. All 25 affected catalog
entries are identical to e168 master; their stale fingerprints were inherited.
The 101 unique declared inputs have three changed feature recipes. The remaining 98 inputs, including all 25 story files, are unchanged
between those masters and the merged worktree. They include 92 non-map
story, subject, wrapper and fixture sources and six unchanged map files. The reviewed recipe
bytes have these SHA-256 identities:
- `maps/feature-map/activity.md`: `3867cc392c031cc5ca9ec61bfc4211d685da37e39fb36e9b152497b7cf970caf`.
- `maps/feature-map/inbox-search.md`: `8ff5431ddfe54c0c288eecffb785f864d8bfeb38be50c66615999ffbbfba6863`.
- `maps/feature-map/recovery.md`: `46fee5940f23a6d1483697e0b07817a78d993d4748b51121d2e113dd2a96173b`.
The activity addition names issue-scoped activity authorization and sandbox
bridge controls; company audit/display fixtures do not establish live sandbox
agent access. The inbox recipe excludes automatic historical read cleanup from
user participation while retaining explicit decisions; static inbox/decision
examples do not execute that query or the newly required user/company journey.
The recovery recipe retains native workspace-owner holds across task completion
and repeated reads until exact operator retirement; recovery display fixtures
do not establish ownership, copyback denial, action identity or live operator
recovery. These backend acceptance requirements remain separate gaps.
The proposed maintenance changes only those 25 reviewed dependency fingerprints.
Retain the existing 24 unreviewed and one current classification, simulation
boundaries, story exports, titles and stable IDs. No fixture or story correction
is indicated by this declared-source comparison. This does not promote full
state parity or coverage for the added backend requirements.
Source inspection only: no browser rendering, play, visual baseline, provider
run or live production acceptance was performed for this reconciliation. The
existing catalog checker must run on the actual merged tree after synchronization;
its result verifies structure and declared hashes, not these live journeys.
+25 -25
View File
@@ -246,7 +246,7 @@
"ui/src/pages/tools/AuditTab.tsx",
"ui/storybook/stories/activity/activity-feed.stories.tsx"
],
"fingerprint": "e38af3b7963caf0cc3b13f7681da370a579f97ad78a6064559fbb18aeb73b4ed",
"fingerprint": "42f1ebbd11a7b47dd507f43c287c46a9191b8ce323b9c15d83a6d8ec5521eeee",
"stableId": "tools-activity-audit-feed",
"sidebarTitle": "Oversight and operation/Dashboards and audit trails/Tools/Activity (audit feed)"
},
@@ -808,7 +808,7 @@
"ui/src/pages/apps/app-detail/types.ts",
"ui/storybook/stories/activity/app-activity-lifecycle.stories.tsx"
],
"fingerprint": "1642f6a6692ccd3ac058d1841f388607b3379c683711bbe558aad3cc79ffc077",
"fingerprint": "6c813a16621d93fe9f817b1b01d451a35b4b552727976168e938409d100b26e9",
"stableId": "apps-app-detail-·-activity-lifecycle",
"sidebarTitle": "Oversight and operation/Dashboards and audit trails/Apps/App detail · Activity (lifecycle)"
},
@@ -1170,7 +1170,7 @@
"ui/storybook/fixtures/paperclipData.ts",
"ui/storybook/stories/tasks/assigned-backlog-safeguards.stories.tsx"
],
"fingerprint": "3a3610f557ca0b31528e7df0a2faf0885132ba6c947380588bc63b144c14833e",
"fingerprint": "f6d94acdd29ba5ca746a6fcba87a44f4384288c02d736aa496a4d30bd94a922a",
"stableId": "paperclip-assigned-backlog-safeguards",
"sidebarTitle": "Tasks and conversations/Task creation and lifecycle/Paperclip/Assigned Backlog Safeguards"
},
@@ -1194,7 +1194,7 @@
"ui/storybook/fixtures/paperclipData.ts",
"ui/storybook/stories/inbox-search/blocked-inbox.stories.tsx"
],
"fingerprint": "d7406369152782fe626f9be02cecde54a7abc58753fc9e5a5b0d167f2a5df3f2",
"fingerprint": "b6f5f4b7c00e48da7320bef20e5904527f83665e5fdac4fbb44c866e15547502",
"stableId": "product-inbox-blocked-tab",
"sidebarTitle": "Tasks and conversations/Inbox, decisions, and search/Product/Inbox/Blocked tab"
},
@@ -1476,7 +1476,7 @@
"ui/src/components/CloudAccessGate.tsx",
"ui/storybook/stories/recovery/cloud-access-recovery.stories.tsx"
],
"fingerprint": "ce4f356d3f8296a2f6e3c6da8bb6b3a57866a0ad9c9e74850a76ff7fd9484b54",
"fingerprint": "bbec224b127ec6898cf95dfae922e6747483c0d0d4ec55ff7d1560c0c4a5c041",
"stableId": "app-connection-recovery",
"sidebarTitle": "Projects and execution/Recovery/App/Connection recovery"
},
@@ -1763,7 +1763,7 @@
"ui/storybook/fixtures/paperclipData.ts",
"ui/storybook/stories/tasks/control-plane-surfaces.stories.tsx"
],
"fingerprint": "121469a91e60dbb4e085512d86e3bd8a24b1590469d0f1321ea5e1a869df7da0",
"fingerprint": "55b7a763e93f099aa6085dc7de65474f619aebd1ec9d114ee9c70fd0d2cf42c6",
"stableId": "product-control-plane-surfaces",
"sidebarTitle": "Tasks and conversations/Task creation and lifecycle/Product/Control Plane Surfaces"
},
@@ -1807,7 +1807,7 @@
"ui/src/components/task-chat/TaskChatMarker.tsx",
"ui/storybook/stories/secrets/credential-access/01-notice.stories.tsx"
],
"fingerprint": "d4d4b6098d76fa827e6529a6f071948f1f4e6a15a337da4a85ffe9f0234d8565",
"fingerprint": "2eba045db9127e5cd8d8bf7c9664e4598a5c84be117ae6159ce7171b24f2005d",
"stableId": "task-credential-access-01-notice",
"sidebarTitle": "Apps, channels, and extensions/Secrets and proposals/Task credential access/01 Notice"
},
@@ -1833,7 +1833,7 @@
"ui/storybook/stories/secrets/credential-access/02-task-page.stories.tsx",
"ui/storybook/support/credential-access/CredentialTaskPage.tsx"
],
"fingerprint": "b7f5b355919f9e11c7d079955113f365d3a41e956fd6cc6746e4c3fdddae644e",
"fingerprint": "aadd6c8f82db53b2e910829deb409344199353cb4956313424ce8d1415bead85",
"stableId": "task-credential-access-02-task-page",
"sidebarTitle": "Apps, channels, and extensions/Secrets and proposals/Task credential access/02 Task page"
},
@@ -1886,7 +1886,7 @@
"ui/storybook/fixtures/paperclipData.ts",
"ui/storybook/stories/activity/data-viz-misc.stories.tsx"
],
"fingerprint": "5cc019e34fd9a2adae706143c0af4fd78334a2ffefac62eb82d7ec7f64ee0ea3",
"fingerprint": "2861c5c594bbc3c30682958dd3727488f776e411368bcfdcbde39c14cb64cef6",
"stableId": "product-data-visualization-misc",
"sidebarTitle": "Oversight and operation/Dashboards and audit trails/Product/Data Visualization & Misc"
},
@@ -1906,7 +1906,7 @@
"ui/src/components/DecisionCard.tsx",
"ui/storybook/stories/inbox-search/decision-card.stories.tsx"
],
"fingerprint": "151c678aa5f7befc369d2def8df019b626c865f1c5b7bf91543dd4454395cb66",
"fingerprint": "220a4f037c1dd4a456ac7df75d6ee7fd0d844ff7db3fd30ca1da2fff9477cbc8",
"stableId": "decisions-decisioncard",
"sidebarTitle": "Tasks and conversations/Inbox, decisions, and search/Decisions/DecisionCard"
},
@@ -1999,7 +1999,7 @@
"ui/src/pages/WhatNeedsMe.tsx",
"ui/storybook/stories/inbox-search/decisions-desk.stories.tsx"
],
"fingerprint": "614be5300540b7c3c8942a8a043e005d0f92e0a021819ade6ad291526b923566",
"fingerprint": "1e49e931cddd7ef77de70f25fc4ee991d3de9d933670ae4412c5d17fad6db0aa",
"stableId": "pages-decisions-desk",
"sidebarTitle": "Tasks and conversations/Inbox, decisions, and search/Pages/Decisions Desk"
},
@@ -2184,7 +2184,7 @@
"ui/src/components/ExecutionBlockerNotice.tsx",
"ui/storybook/stories/recovery/execution-blocker-notice.stories.tsx"
],
"fingerprint": "19fa3d95095bbcd67c5306d6ad3c8c00ae526bab9d8cbc49e69ca36c15a873c2",
"fingerprint": "bee366ba1266e6692c8f0ee027c449fdda6b51762369ba431871a5af5ee018c6",
"stableId": "task-chat-recovery-notice",
"sidebarTitle": "Projects and execution/Recovery/Task chat/Recovery notice"
},
@@ -2208,7 +2208,7 @@
"ui/storybook/fixtures/paperclipData.ts",
"ui/storybook/stories/recovery/execution-recovery.stories.tsx"
],
"fingerprint": "003b5d7b2f8d0baea06402776157854f6b55d06bfd4d6223300413c284a22fd2",
"fingerprint": "6f8b245b169d95046bdb52513dfaf4937a96f807b72470c198863471f59c8149",
"stableId": "tasks-execution-recovery",
"sidebarTitle": "Projects and execution/Recovery/Tasks/Execution recovery"
},
@@ -2820,7 +2820,7 @@
"ui/src/components/task-chat/TaskChatBlockerLinks.tsx",
"ui/storybook/stories/recovery/issue-blocked-notice.stories.tsx"
],
"fingerprint": "4eb78b4e772567d657339435dc650e9b59c2347792cf764c7b376d7f631fe4de",
"fingerprint": "6a84d7f60318d33a1835a668873095aaad1c215a83bbbb4735823fdcb3291140",
"stableId": "product-issue-blocked-notice",
"sidebarTitle": "Projects and execution/Recovery/Product/Issue/Blocked notice"
},
@@ -3047,7 +3047,7 @@
"ui/src/components/IssueScheduledRetryCard.tsx",
"ui/storybook/stories/recovery/model-capacity-retry.stories.tsx"
],
"fingerprint": "4f7421c0267fee71b9de5e38916ab86c66a2c69caa950730751c031a86503b4f",
"fingerprint": "5e08f681f3f96cb7be32e316d3f4066e5239dcf0f4393cb9ec09d599734f201e",
"stableId": "tasks-model-capacity-retry",
"sidebarTitle": "Projects and execution/Recovery/Tasks/Model capacity retry"
},
@@ -4384,7 +4384,7 @@
"ui/storybook/prototypes/recovery-notice/RecoveryNoticePreview.tsx",
"ui/storybook/stories/recovery/recovery-notice-preview.stories.tsx"
],
"fingerprint": "4e880f02947c96e751ecca7a0f662106874ac5b58f254915592ecdec699298de",
"fingerprint": "cfd7345545112ea9ecc13035ebd187451117de5ed010f439cfb8c6962461fb3f",
"stableId": "design-previews-recovery-notice",
"sidebarTitle": "Projects and execution/Recovery/Design previews/Recovery notice"
},
@@ -4638,7 +4638,7 @@
"ui/storybook/fixtures/paperclipData.ts",
"ui/storybook/stories/recovery/scheduled-retry.stories.tsx"
],
"fingerprint": "b07bdc368ce48d184b5cd99156ed23c03251ac9ecd90de47812db351261cc5c0",
"fingerprint": "43ef1917079251d558b3f52e5af750dfb69a54d3326d31494dbf037baeefe193",
"stableId": "product-issue-scheduled-retry-surfaces",
"sidebarTitle": "Projects and execution/Recovery/Product/Issue Scheduled retry surfaces"
},
@@ -4665,7 +4665,7 @@
"ui/storybook/fixtures/paperclipData.ts",
"ui/storybook/stories/inbox-search/search.stories.tsx"
],
"fingerprint": "dfd04cb52d810ed016fae1508dea6fa7baeb8a71676e605a4eff269d844957b6",
"fingerprint": "4ebfe41c086ce2e0ae674263dfa50239a0770e5e6f98c3c24f54955f6cbdd3d2",
"stableId": "product-search-command-k",
"sidebarTitle": "Tasks and conversations/Inbox, decisions, and search/Product/Search & Command K"
},
@@ -4950,7 +4950,7 @@
"ui/storybook/fixtures/paperclipData.ts",
"ui/storybook/stories/recovery/source-issue-recovery.stories.tsx"
],
"fingerprint": "ad00308c561a44cfb60a7b359ac88b48ad5ee2f13d4b36af99552c29c0a3df67",
"fingerprint": "1b6e44d4fb4ab7e2e63fb8816aa31fbe3267e10647f238ed82e6591a82d2e918",
"stableId": "paperclip-source-issue-recovery",
"sidebarTitle": "Projects and execution/Recovery/Paperclip/Source Issue Recovery"
},
@@ -4972,7 +4972,7 @@
"ui/src/components/SourceResolvedFoldCallout.tsx",
"ui/storybook/stories/recovery/source-resolved-fold.stories.tsx"
],
"fingerprint": "f6a8c8477c45cbf1c98a0e0a8618373800183aa49a189b00efac62c42fb0b92e",
"fingerprint": "3af3daff618b8c278bcbf10ba3e5d081d87240c680758ccb3078eccdf9283122",
"stableId": "paperclip-source-resolved-fold",
"sidebarTitle": "Projects and execution/Recovery/Paperclip/Source-resolved Fold"
},
@@ -5049,7 +5049,7 @@
"ui/storybook/fixtures/paperclipData.ts",
"ui/storybook/stories/tasks/successful-run-handoff.stories.tsx"
],
"fingerprint": "dee060e56c932b5c076cde939cc8cad36b46452fd3fd85118e1eb6664deb47ad",
"fingerprint": "dd47ab0240dd12f6bdeddeaf155be7afc472e728f69e77579adaf7aeec0eb588",
"stableId": "paperclip-successful-run-handoff",
"sidebarTitle": "Tasks and conversations/Task creation and lifecycle/Paperclip/Successful Run Handoff"
},
@@ -5463,7 +5463,7 @@
"ui/src/components/ToastViewport.tsx",
"ui/storybook/stories/inbox-search/what-needs-me.stories.tsx"
],
"fingerprint": "b84aef9b566d476455d6b441adfb301d92f68148a409eff29f5850c047d04baf",
"fingerprint": "d8b838185489f6b5009e52d9c2ac3a1fea3b2718211a1ddc393c404a0335a466",
"stableId": "pages-decisions",
"sidebarTitle": "Tasks and conversations/Inbox, decisions, and search/Pages/Decisions"
},
@@ -5486,7 +5486,7 @@
"ui/storybook/fixtures/workTimeline.sample.json",
"ui/storybook/stories/activity/work-timeline.stories.tsx"
],
"fingerprint": "ef4f6622a54c742869916b1bded0fd410aaf1faa3bf9e5ff37182eac7ac6aa50",
"fingerprint": "4f6e9f556073d4bc6a812f633bcff32cb84c4c02488746b37797501684ef3899",
"stableId": "pages-work-timeline",
"sidebarTitle": "Oversight and operation/Dashboards and audit trails/Pages/Work Timeline"
},
@@ -5507,7 +5507,7 @@
"ui/storybook/stories/recovery/workspace-branch-recovery/01-component.stories.tsx",
"ui/storybook/support/workspace-branch-recovery/fixtures.ts"
],
"fingerprint": "411a3a9332d5348327cb4c04e508895f9f1989d6da27fe8d1d73ebd34be87a82",
"fingerprint": "77f708cc660f6829cadaffa6139930a102109976c41f7e24e2f662ed687d2412",
"stableId": "workspace-branch-recovery-01-repair-card",
"sidebarTitle": "Projects and execution/Recovery/Workspace branch recovery/01 Repair card"
},
@@ -5532,7 +5532,7 @@
"ui/storybook/stories/recovery/workspace-branch-recovery/02-task-journey.stories.tsx",
"ui/storybook/support/workspace-branch-recovery/fixtures.ts"
],
"fingerprint": "85db6aeb522937db01d8e307eab632d16f887abe88301c5d2f3c2f797fcb7cec",
"fingerprint": "d34d8092f10377b2280f9511205b5eae76b5dbfd9cde002669eefc51aa5d16d6",
"stableId": "workspace-branch-recovery-02-task-journey",
"sidebarTitle": "Projects and execution/Recovery/Workspace branch recovery/02 Task journey"
},
@@ -1500,6 +1500,7 @@ describe("sandbox callback bridge", () => {
{ method: "GET", path: "/api/goals/goal-1" },
{ method: "GET", path: "/api/issues/issue-1" },
{ method: "GET", path: "/api/issues/issue-1/heartbeat-context" },
{ method: "GET", path: "/api/issues/issue-1/activity" },
{ method: "GET", path: "/api/issues/issue-1/comments" },
{ method: "GET", path: "/api/issues/issue-1/comments/c-1" },
{ method: "POST", path: "/api/issues/issue-1/comments" },
@@ -1546,6 +1547,10 @@ describe("sandbox callback bridge", () => {
}
const denied: Array<{ method: string; path: string }> = [
{ method: "POST", path: "/api/issues/issue-1/activity" },
{ method: "GET", path: "/api/issues/issue-1/activity/extra" },
{ method: "GET", path: "/api/companies/co-1/activity" },
{ method: "GET", path: "/api/companies/co-1/audit/agent-actions" },
{ method: "GET", path: "/api/companies/co-1/agent-commentary" },
{ method: "POST", path: "/api/companies/co-1/agent-commentary/other" },
{ method: "POST", path: "/api/companies/co-1/email/inboxes" },
@@ -162,6 +162,8 @@ export const DEFAULT_SANDBOX_CALLBACK_BRIDGE_ROUTE_ALLOWLIST: readonly SandboxCa
// Issue lifecycle: read context, checkout, update, comment, document, release
{ method: "GET", path: /^\/api\/issues\/[^/]+$/ },
{ method: "GET", path: /^\/api\/issues\/[^/]+\/heartbeat-context$/ },
// The controller still checks company membership and issue read authority.
{ method: "GET", path: /^\/api\/issues\/[^/]+\/activity$/ },
{ method: "GET", path: /^\/api\/issues\/[^/]+\/comments(?:\/[^/]+)?$/ },
{ method: "POST", path: /^\/api\/issues\/[^/]+\/comments$/ },
{ method: "GET", path: /^\/api\/issues\/[^/]+\/documents(?:\/[^/]+)?$/ },
@@ -47,104 +47,104 @@ The skill/reference inventory and eval cases are the only normative behavior sou
| skill:skills/paperclip/SKILL.md:conversation-tasks:32 | optional_agent_tool | skills/paperclip/SKILL.md:32 |
| skill:skills/paperclip/SKILL.md:server-verified-external-chat-turns:49 | control_plane_owned | skills/paperclip/SKILL.md:49 |
| skill:skills/paperclip/SKILL.md:the-heartbeat-procedure:89 | optional_agent_tool | skills/paperclip/SKILL.md:89 |
| skill:skills/paperclip/SKILL.md:generated-artifacts-and-work-products:198 | always_agent_tool | skills/paperclip/SKILL.md:198 |
| skill:skills/paperclip/SKILL.md:status-quick-guide:246 | control_plane_owned | skills/paperclip/SKILL.md:246 |
| skill:skills/paperclip/SKILL.md:monitors-and-watchers-say-only-what-you-actually-scheduled:256 | optional_agent_tool | skills/paperclip/SKILL.md:256 |
| skill:skills/paperclip/SKILL.md:delegating-review-tasks:269 | always_agent_tool | skills/paperclip/SKILL.md:269 |
| skill:skills/paperclip/SKILL.md:managing-a-user-s-inbox:280 | control_plane_owned | skills/paperclip/SKILL.md:280 |
| skill:skills/paperclip/SKILL.md:issue-dependencies-blockers:288 | control_plane_owned | skills/paperclip/SKILL.md:288 |
| skill:skills/paperclip/SKILL.md:requesting-board-approval:313 | optional_agent_tool | skills/paperclip/SKILL.md:313 |
| skill:skills/paperclip/SKILL.md:issue-thread-interactions:334 | optional_agent_tool | skills/paperclip/SKILL.md:334 |
| skill:skills/paperclip/SKILL.md:standalone-decisions:364 | optional_agent_tool | skills/paperclip/SKILL.md:364 |
| skill:skills/paperclip/SKILL.md:mcp-tool-approval-gates:468 | optional_agent_tool | skills/paperclip/SKILL.md:468 |
| skill:skills/paperclip/SKILL.md:niche-workflow-pointers:510 | optional_agent_tool | skills/paperclip/SKILL.md:510 |
| skill:skills/paperclip/SKILL.md:cases:520 | optional_agent_tool | skills/paperclip/SKILL.md:520 |
| skill:skills/paperclip/SKILL.md:company-skills-workflow:525 | optional_agent_tool | skills/paperclip/SKILL.md:525 |
| skill:skills/paperclip/SKILL.md:routines:536 | optional_agent_tool | skills/paperclip/SKILL.md:536 |
| skill:skills/paperclip/SKILL.md:task-files-and-workspace-choices:547 | optional_agent_tool | skills/paperclip/SKILL.md:547 |
| skill:skills/paperclip/SKILL.md:issue-workspace-runtime-controls:553 | optional_agent_tool | skills/paperclip/SKILL.md:553 |
| skill:skills/paperclip/SKILL.md:proposing-credentials-safely:560 | optional_agent_tool | skills/paperclip/SKILL.md:560 |
| skill:skills/paperclip/SKILL.md:reading-granted-secrets:567 | optional_agent_tool | skills/paperclip/SKILL.md:567 |
| skill:skills/paperclip/SKILL.md:critical-rules:593 | optional_agent_tool | skills/paperclip/SKILL.md:593 |
| skill:skills/paperclip/SKILL.md:comment-style-required:614 | always_agent_tool | skills/paperclip/SKILL.md:614 |
| skill:skills/paperclip/SKILL.md:update:646 | optional_agent_tool | skills/paperclip/SKILL.md:646 |
| skill:skills/paperclip/SKILL.md:task-documents-and-deliverables:656 | always_agent_tool | skills/paperclip/SKILL.md:656 |
| skill:skills/paperclip/SKILL.md:planning-required-when-planning-requested:670 | optional_agent_tool | skills/paperclip/SKILL.md:670 |
| skill:skills/paperclip/SKILL.md:key-endpoints-hot-routes:701 | optional_agent_tool | skills/paperclip/SKILL.md:701 |
| skill:skills/paperclip/SKILL.md:searching-issues:730 | optional_agent_tool | skills/paperclip/SKILL.md:730 |
| skill:skills/paperclip/SKILL.md:full-reference:740 | optional_agent_tool | skills/paperclip/SKILL.md:740 |
| skill:skills/paperclip/SKILL.md:conversational-confirmation-answers:744 | always_agent_tool | skills/paperclip/SKILL.md:744 |
| skill:skills/paperclip/SKILL.md:incidental-feedback:761 | optional_agent_tool | skills/paperclip/SKILL.md:761 |
| skill:skills/paperclip/SKILL.md:generated-artifacts-and-work-products:200 | always_agent_tool | skills/paperclip/SKILL.md:200 |
| skill:skills/paperclip/SKILL.md:status-quick-guide:248 | control_plane_owned | skills/paperclip/SKILL.md:248 |
| skill:skills/paperclip/SKILL.md:monitors-and-watchers-say-only-what-you-actually-scheduled:258 | optional_agent_tool | skills/paperclip/SKILL.md:258 |
| skill:skills/paperclip/SKILL.md:delegating-review-tasks:271 | always_agent_tool | skills/paperclip/SKILL.md:271 |
| skill:skills/paperclip/SKILL.md:managing-a-user-s-inbox:282 | control_plane_owned | skills/paperclip/SKILL.md:282 |
| skill:skills/paperclip/SKILL.md:issue-dependencies-blockers:290 | control_plane_owned | skills/paperclip/SKILL.md:290 |
| skill:skills/paperclip/SKILL.md:requesting-board-approval:315 | optional_agent_tool | skills/paperclip/SKILL.md:315 |
| skill:skills/paperclip/SKILL.md:issue-thread-interactions:336 | optional_agent_tool | skills/paperclip/SKILL.md:336 |
| skill:skills/paperclip/SKILL.md:standalone-decisions:366 | optional_agent_tool | skills/paperclip/SKILL.md:366 |
| skill:skills/paperclip/SKILL.md:mcp-tool-approval-gates:470 | optional_agent_tool | skills/paperclip/SKILL.md:470 |
| skill:skills/paperclip/SKILL.md:niche-workflow-pointers:512 | optional_agent_tool | skills/paperclip/SKILL.md:512 |
| skill:skills/paperclip/SKILL.md:cases:522 | optional_agent_tool | skills/paperclip/SKILL.md:522 |
| skill:skills/paperclip/SKILL.md:company-skills-workflow:527 | optional_agent_tool | skills/paperclip/SKILL.md:527 |
| skill:skills/paperclip/SKILL.md:routines:538 | optional_agent_tool | skills/paperclip/SKILL.md:538 |
| skill:skills/paperclip/SKILL.md:task-files-and-workspace-choices:549 | optional_agent_tool | skills/paperclip/SKILL.md:549 |
| skill:skills/paperclip/SKILL.md:issue-workspace-runtime-controls:555 | optional_agent_tool | skills/paperclip/SKILL.md:555 |
| skill:skills/paperclip/SKILL.md:proposing-credentials-safely:562 | optional_agent_tool | skills/paperclip/SKILL.md:562 |
| skill:skills/paperclip/SKILL.md:reading-granted-secrets:569 | optional_agent_tool | skills/paperclip/SKILL.md:569 |
| skill:skills/paperclip/SKILL.md:critical-rules:595 | optional_agent_tool | skills/paperclip/SKILL.md:595 |
| skill:skills/paperclip/SKILL.md:comment-style-required:616 | always_agent_tool | skills/paperclip/SKILL.md:616 |
| skill:skills/paperclip/SKILL.md:update:648 | optional_agent_tool | skills/paperclip/SKILL.md:648 |
| skill:skills/paperclip/SKILL.md:task-documents-and-deliverables:658 | always_agent_tool | skills/paperclip/SKILL.md:658 |
| skill:skills/paperclip/SKILL.md:planning-required-when-planning-requested:672 | optional_agent_tool | skills/paperclip/SKILL.md:672 |
| skill:skills/paperclip/SKILL.md:key-endpoints-hot-routes:703 | optional_agent_tool | skills/paperclip/SKILL.md:703 |
| skill:skills/paperclip/SKILL.md:searching-issues:733 | optional_agent_tool | skills/paperclip/SKILL.md:733 |
| skill:skills/paperclip/SKILL.md:full-reference:743 | optional_agent_tool | skills/paperclip/SKILL.md:743 |
| skill:skills/paperclip/SKILL.md:conversational-confirmation-answers:747 | always_agent_tool | skills/paperclip/SKILL.md:747 |
| skill:skills/paperclip/SKILL.md:incidental-feedback:764 | optional_agent_tool | skills/paperclip/SKILL.md:764 |
| skill:skills/paperclip/references/api-reference.md:paperclip-api-reference:1 | optional_agent_tool | skills/paperclip/references/api-reference.md:1 |
| skill:skills/paperclip/references/api-reference.md:response-schemas:9 | optional_agent_tool | skills/paperclip/references/api-reference.md:9 |
| skill:skills/paperclip/references/api-reference.md:agent-record-get-api-agents-me-or-get-api-agents-agentid:11 | optional_agent_tool | skills/paperclip/references/api-reference.md:11 |
| skill:skills/paperclip/references/api-reference.md:company-portability:44 | optional_agent_tool | skills/paperclip/references/api-reference.md:44 |
| skill:skills/paperclip/references/api-reference.md:issue-with-ancestors-get-api-issues-issueid:110 | optional_agent_tool | skills/paperclip/references/api-reference.md:110 |
| skill:skills/paperclip/references/api-reference.md:issue-update-response-patch-api-issues-issueid:196 | optional_agent_tool | skills/paperclip/references/api-reference.md:196 |
| skill:skills/paperclip/references/api-reference.md:blocker-diagnostics-get-api-issues-issueid-diagnostics-blockers:238 | control_plane_owned | skills/paperclip/references/api-reference.md:238 |
| skill:skills/paperclip/references/api-reference.md:wake-diagnostics-get-api-issues-issueid-diagnostics-wakes:277 | control_plane_owned | skills/paperclip/references/api-reference.md:277 |
| skill:skills/paperclip/references/api-reference.md:subtree-diagnostics-get-api-issues-issueid-diagnostics-subtree:321 | optional_agent_tool | skills/paperclip/references/api-reference.md:321 |
| skill:skills/paperclip/references/api-reference.md:execution-policy-fields-on-an-issue:369 | optional_agent_tool | skills/paperclip/references/api-reference.md:369 |
| skill:skills/paperclip/references/api-reference.md:cross-agent-review-gates:421 | always_agent_tool | skills/paperclip/references/api-reference.md:421 |
| skill:skills/paperclip/references/api-reference.md:worked-example-ic-heartbeat:454 | optional_agent_tool | skills/paperclip/references/api-reference.md:454 |
| skill:skills/paperclip/references/api-reference.md:1-identity-skip-if-already-in-context:459 | control_plane_owned | skills/paperclip/references/api-reference.md:459 |
| skill:skills/paperclip/references/api-reference.md:2-check-inbox:463 | control_plane_owned | skills/paperclip/references/api-reference.md:463 |
| skill:skills/paperclip/references/api-reference.md:3-already-have-issue-101-inprogress-highest-priority-continue-it:470 | optional_agent_tool | skills/paperclip/references/api-reference.md:470 |
| skill:skills/paperclip/references/api-reference.md:4-do-the-actual-work-write-code-run-tests:477 | optional_agent_tool | skills/paperclip/references/api-reference.md:477 |
| skill:skills/paperclip/references/api-reference.md:5-work-is-done-update-status-and-comment-in-one-call:479 | always_agent_tool | skills/paperclip/references/api-reference.md:479 |
| skill:skills/paperclip/references/api-reference.md:6-still-have-time-checkout-the-next-task:483 | control_plane_owned | skills/paperclip/references/api-reference.md:483 |
| skill:skills/paperclip/references/api-reference.md:7-made-partial-progress-not-done-yet-comment-and-exit:490 | always_agent_tool | skills/paperclip/references/api-reference.md:490 |
| skill:skills/paperclip/references/api-reference.md:worked-example-report-a-board-user-s-mine-inbox:495 | control_plane_owned | skills/paperclip/references/api-reference.md:495 |
| skill:skills/paperclip/references/api-reference.md:board-user-created-the-requesting-issue:500 | optional_agent_tool | skills/paperclip/references/api-reference.md:500 |
| skill:skills/paperclip/references/api-reference.md:fetch-the-board-user-s-mine-inbox-issues:504 | control_plane_owned | skills/paperclip/references/api-reference.md:504 |
| skill:skills/paperclip/references/api-reference.md:summarize-it-back-to-the-board-in-a-comment-or-document:518 | always_agent_tool | skills/paperclip/references/api-reference.md:518 |
| skill:skills/paperclip/references/api-reference.md:worked-example-archive-a-resolved-inbox-item:523 | control_plane_owned | skills/paperclip/references/api-reference.md:523 |
| skill:skills/paperclip/references/api-reference.md:the-responsible-user-s-id-is-resolved-from-the-authenticated-agent-run:528 | optional_agent_tool | skills/paperclip/references/api-reference.md:528 |
| skill:skills/paperclip/references/api-reference.md:reverse-the-archive-if-it-was-premature-or-no-longer-desired:537 | optional_agent_tool | skills/paperclip/references/api-reference.md:537 |
| skill:skills/paperclip/references/api-reference.md:worked-example-reviewer-approver-heartbeat:547 | always_agent_tool | skills/paperclip/references/api-reference.md:547 |
| skill:skills/paperclip/references/api-reference.md:worked-example-manager-heartbeat:586 | optional_agent_tool | skills/paperclip/references/api-reference.md:586 |
| skill:skills/paperclip/references/api-reference.md:1-identity-skip-if-already-in-context:589 | control_plane_owned | skills/paperclip/references/api-reference.md:589 |
| skill:skills/paperclip/references/api-reference.md:2-check-team-status:593 | optional_agent_tool | skills/paperclip/references/api-reference.md:593 |
| skill:skills/paperclip/references/api-reference.md:3-agent-42-is-blocked-read-comments:600 | control_plane_owned | skills/paperclip/references/api-reference.md:600 |
| skill:skills/paperclip/references/api-reference.md:4-unblock-reassign-and-comment:604 | control_plane_owned | skills/paperclip/references/api-reference.md:604 |
| skill:skills/paperclip/references/api-reference.md:5-check-own-assignments:608 | optional_agent_tool | skills/paperclip/references/api-reference.md:608 |
| skill:skills/paperclip/references/api-reference.md:6-create-subtasks-and-delegate:615 | optional_agent_tool | skills/paperclip/references/api-reference.md:615 |
| skill:skills/paperclip/references/api-reference.md:load-tests-depend-on-caching-layer-being-done-first-paperclip-will-auto-wake-agent-55-when-the-blocker-resolves:621 | control_plane_owned | skills/paperclip/references/api-reference.md:621 |
| skill:skills/paperclip/references/api-reference.md:7-dashboard-for-health-check:626 | optional_agent_tool | skills/paperclip/references/api-reference.md:626 |
| skill:skills/paperclip/references/api-reference.md:comments-and-mentions:632 | always_agent_tool | skills/paperclip/references/api-reference.md:632 |
| skill:skills/paperclip/references/api-reference.md:update:639 | optional_agent_tool | skills/paperclip/references/api-reference.md:639 |
| skill:skills/paperclip/references/api-reference.md:cross-team-work-and-delegation:661 | optional_agent_tool | skills/paperclip/references/api-reference.md:661 |
| skill:skills/paperclip/references/api-reference.md:receiving-cross-team-work:665 | optional_agent_tool | skills/paperclip/references/api-reference.md:665 |
| skill:skills/paperclip/references/api-reference.md:questions-and-dependencies:675 | always_agent_tool | skills/paperclip/references/api-reference.md:675 |
| skill:skills/paperclip/references/api-reference.md:company-context:687 | optional_agent_tool | skills/paperclip/references/api-reference.md:687 |
| skill:skills/paperclip/references/api-reference.md:company-branding-ceo-board:699 | optional_agent_tool | skills/paperclip/references/api-reference.md:699 |
| skill:skills/paperclip/references/api-reference.md:openclaw-invite-prompt-ceo:719 | optional_agent_tool | skills/paperclip/references/api-reference.md:719 |
| skill:skills/paperclip/references/api-reference.md:setting-agent-instructions-path:738 | optional_agent_tool | skills/paperclip/references/api-reference.md:738 |
| skill:skills/paperclip/references/api-reference.md:project-setup-create-workspace:771 | optional_agent_tool | skills/paperclip/references/api-reference.md:771 |
| skill:skills/paperclip/references/api-reference.md:option-a-one-call-create-with-workspace:795 | optional_agent_tool | skills/paperclip/references/api-reference.md:795 |
| skill:skills/paperclip/references/api-reference.md:option-b-two-calls-project-first-then-workspace:814 | optional_agent_tool | skills/paperclip/references/api-reference.md:814 |
| skill:skills/paperclip/references/api-reference.md:governance-and-approvals:843 | optional_agent_tool | skills/paperclip/references/api-reference.md:843 |
| skill:skills/paperclip/references/api-reference.md:requesting-a-hire-management-only:847 | optional_agent_tool | skills/paperclip/references/api-reference.md:847 |
| skill:skills/paperclip/references/api-reference.md:ceo-strategy-approval:912 | optional_agent_tool | skills/paperclip/references/api-reference.md:912 |
| skill:skills/paperclip/references/api-reference.md:questions-and-waiting-for-human-input:921 | always_agent_tool | skills/paperclip/references/api-reference.md:921 |
| skill:skills/paperclip/references/api-reference.md:issue-thread-confirmations:1025 | always_agent_tool | skills/paperclip/references/api-reference.md:1025 |
| skill:skills/paperclip/references/api-reference.md:conversational-confirmation-answers:1083 | always_agent_tool | skills/paperclip/references/api-reference.md:1083 |
| skill:skills/paperclip/references/api-reference.md:checkbox-confirmations:1089 | always_agent_tool | skills/paperclip/references/api-reference.md:1089 |
| skill:skills/paperclip/references/api-reference.md:item-verdict-requests:1204 | optional_agent_tool | skills/paperclip/references/api-reference.md:1204 |
| skill:skills/paperclip/references/api-reference.md:checking-approval-status:1314 | optional_agent_tool | skills/paperclip/references/api-reference.md:1314 |
| skill:skills/paperclip/references/api-reference.md:approval-follow-up-requesting-agent:1320 | always_agent_tool | skills/paperclip/references/api-reference.md:1320 |
| skill:skills/paperclip/references/api-reference.md:issue-lifecycle:1338 | always_agent_tool | skills/paperclip/references/api-reference.md:1338 |
| skill:skills/paperclip/references/api-reference.md:error-handling:1368 | control_plane_owned | skills/paperclip/references/api-reference.md:1368 |
| skill:skills/paperclip/references/api-reference.md:full-api-reference:1382 | optional_agent_tool | skills/paperclip/references/api-reference.md:1382 |
| skill:skills/paperclip/references/api-reference.md:agents:1384 | optional_agent_tool | skills/paperclip/references/api-reference.md:1384 |
| skill:skills/paperclip/references/api-reference.md:issues-tasks:1405 | optional_agent_tool | skills/paperclip/references/api-reference.md:1405 |
| skill:skills/paperclip/references/api-reference.md:companies-projects-goals:1446 | optional_agent_tool | skills/paperclip/references/api-reference.md:1446 |
| skill:skills/paperclip/references/api-reference.md:routines:1470 | optional_agent_tool | skills/paperclip/references/api-reference.md:1470 |
| skill:skills/paperclip/references/api-reference.md:approvals-costs-activity-dashboard:1486 | optional_agent_tool | skills/paperclip/references/api-reference.md:1486 |
| skill:skills/paperclip/references/api-reference.md:secrets:1508 | optional_agent_tool | skills/paperclip/references/api-reference.md:1508 |
| skill:skills/paperclip/references/api-reference.md:agent-secret-proposals:1521 | optional_agent_tool | skills/paperclip/references/api-reference.md:1521 |
| skill:skills/paperclip/references/api-reference.md:agent-secret-access:1621 | optional_agent_tool | skills/paperclip/references/api-reference.md:1621 |
| skill:skills/paperclip/references/api-reference.md:common-mistakes:1661 | optional_agent_tool | skills/paperclip/references/api-reference.md:1661 |
| skill:skills/paperclip/references/api-reference.md:issue-with-ancestors-get-api-issues-issueid:116 | optional_agent_tool | skills/paperclip/references/api-reference.md:116 |
| skill:skills/paperclip/references/api-reference.md:issue-update-response-patch-api-issues-issueid:202 | optional_agent_tool | skills/paperclip/references/api-reference.md:202 |
| skill:skills/paperclip/references/api-reference.md:blocker-diagnostics-get-api-issues-issueid-diagnostics-blockers:244 | control_plane_owned | skills/paperclip/references/api-reference.md:244 |
| skill:skills/paperclip/references/api-reference.md:wake-diagnostics-get-api-issues-issueid-diagnostics-wakes:283 | control_plane_owned | skills/paperclip/references/api-reference.md:283 |
| skill:skills/paperclip/references/api-reference.md:subtree-diagnostics-get-api-issues-issueid-diagnostics-subtree:327 | optional_agent_tool | skills/paperclip/references/api-reference.md:327 |
| skill:skills/paperclip/references/api-reference.md:execution-policy-fields-on-an-issue:375 | optional_agent_tool | skills/paperclip/references/api-reference.md:375 |
| skill:skills/paperclip/references/api-reference.md:cross-agent-review-gates:427 | always_agent_tool | skills/paperclip/references/api-reference.md:427 |
| skill:skills/paperclip/references/api-reference.md:worked-example-ic-heartbeat:460 | optional_agent_tool | skills/paperclip/references/api-reference.md:460 |
| skill:skills/paperclip/references/api-reference.md:1-identity-skip-if-already-in-context:465 | control_plane_owned | skills/paperclip/references/api-reference.md:465 |
| skill:skills/paperclip/references/api-reference.md:2-check-inbox:469 | control_plane_owned | skills/paperclip/references/api-reference.md:469 |
| skill:skills/paperclip/references/api-reference.md:3-already-have-issue-101-inprogress-highest-priority-continue-it:476 | optional_agent_tool | skills/paperclip/references/api-reference.md:476 |
| skill:skills/paperclip/references/api-reference.md:4-do-the-actual-work-write-code-run-tests:483 | optional_agent_tool | skills/paperclip/references/api-reference.md:483 |
| skill:skills/paperclip/references/api-reference.md:5-work-is-done-update-status-and-comment-in-one-call:485 | always_agent_tool | skills/paperclip/references/api-reference.md:485 |
| skill:skills/paperclip/references/api-reference.md:6-still-have-time-checkout-the-next-task:489 | control_plane_owned | skills/paperclip/references/api-reference.md:489 |
| skill:skills/paperclip/references/api-reference.md:7-made-partial-progress-not-done-yet-comment-and-exit:496 | always_agent_tool | skills/paperclip/references/api-reference.md:496 |
| skill:skills/paperclip/references/api-reference.md:worked-example-report-a-board-user-s-mine-inbox:501 | control_plane_owned | skills/paperclip/references/api-reference.md:501 |
| skill:skills/paperclip/references/api-reference.md:board-user-created-the-requesting-issue:506 | optional_agent_tool | skills/paperclip/references/api-reference.md:506 |
| skill:skills/paperclip/references/api-reference.md:fetch-the-board-user-s-mine-inbox-issues:510 | control_plane_owned | skills/paperclip/references/api-reference.md:510 |
| skill:skills/paperclip/references/api-reference.md:summarize-it-back-to-the-board-in-a-comment-or-document:524 | always_agent_tool | skills/paperclip/references/api-reference.md:524 |
| skill:skills/paperclip/references/api-reference.md:worked-example-archive-a-resolved-inbox-item:529 | control_plane_owned | skills/paperclip/references/api-reference.md:529 |
| skill:skills/paperclip/references/api-reference.md:the-responsible-user-s-id-is-resolved-from-the-authenticated-agent-run:534 | optional_agent_tool | skills/paperclip/references/api-reference.md:534 |
| skill:skills/paperclip/references/api-reference.md:reverse-the-archive-if-it-was-premature-or-no-longer-desired:543 | optional_agent_tool | skills/paperclip/references/api-reference.md:543 |
| skill:skills/paperclip/references/api-reference.md:worked-example-reviewer-approver-heartbeat:553 | always_agent_tool | skills/paperclip/references/api-reference.md:553 |
| skill:skills/paperclip/references/api-reference.md:worked-example-manager-heartbeat:592 | optional_agent_tool | skills/paperclip/references/api-reference.md:592 |
| skill:skills/paperclip/references/api-reference.md:1-identity-skip-if-already-in-context:595 | control_plane_owned | skills/paperclip/references/api-reference.md:595 |
| skill:skills/paperclip/references/api-reference.md:2-check-team-status:599 | optional_agent_tool | skills/paperclip/references/api-reference.md:599 |
| skill:skills/paperclip/references/api-reference.md:3-agent-42-is-blocked-read-comments:606 | control_plane_owned | skills/paperclip/references/api-reference.md:606 |
| skill:skills/paperclip/references/api-reference.md:4-unblock-reassign-and-comment:610 | control_plane_owned | skills/paperclip/references/api-reference.md:610 |
| skill:skills/paperclip/references/api-reference.md:5-check-own-assignments:614 | optional_agent_tool | skills/paperclip/references/api-reference.md:614 |
| skill:skills/paperclip/references/api-reference.md:6-create-subtasks-and-delegate:621 | optional_agent_tool | skills/paperclip/references/api-reference.md:621 |
| skill:skills/paperclip/references/api-reference.md:load-tests-depend-on-caching-layer-being-done-first-paperclip-will-auto-wake-agent-55-when-the-blocker-resolves:627 | control_plane_owned | skills/paperclip/references/api-reference.md:627 |
| skill:skills/paperclip/references/api-reference.md:7-dashboard-for-health-check:632 | optional_agent_tool | skills/paperclip/references/api-reference.md:632 |
| skill:skills/paperclip/references/api-reference.md:comments-and-mentions:638 | always_agent_tool | skills/paperclip/references/api-reference.md:638 |
| skill:skills/paperclip/references/api-reference.md:update:645 | optional_agent_tool | skills/paperclip/references/api-reference.md:645 |
| skill:skills/paperclip/references/api-reference.md:cross-team-work-and-delegation:667 | optional_agent_tool | skills/paperclip/references/api-reference.md:667 |
| skill:skills/paperclip/references/api-reference.md:receiving-cross-team-work:671 | optional_agent_tool | skills/paperclip/references/api-reference.md:671 |
| skill:skills/paperclip/references/api-reference.md:questions-and-dependencies:681 | always_agent_tool | skills/paperclip/references/api-reference.md:681 |
| skill:skills/paperclip/references/api-reference.md:company-context:693 | optional_agent_tool | skills/paperclip/references/api-reference.md:693 |
| skill:skills/paperclip/references/api-reference.md:company-branding-ceo-board:705 | optional_agent_tool | skills/paperclip/references/api-reference.md:705 |
| skill:skills/paperclip/references/api-reference.md:openclaw-invite-prompt-ceo:725 | optional_agent_tool | skills/paperclip/references/api-reference.md:725 |
| skill:skills/paperclip/references/api-reference.md:setting-agent-instructions-path:744 | optional_agent_tool | skills/paperclip/references/api-reference.md:744 |
| skill:skills/paperclip/references/api-reference.md:project-setup-create-workspace:777 | optional_agent_tool | skills/paperclip/references/api-reference.md:777 |
| skill:skills/paperclip/references/api-reference.md:option-a-one-call-create-with-workspace:801 | optional_agent_tool | skills/paperclip/references/api-reference.md:801 |
| skill:skills/paperclip/references/api-reference.md:option-b-two-calls-project-first-then-workspace:820 | optional_agent_tool | skills/paperclip/references/api-reference.md:820 |
| skill:skills/paperclip/references/api-reference.md:governance-and-approvals:849 | optional_agent_tool | skills/paperclip/references/api-reference.md:849 |
| skill:skills/paperclip/references/api-reference.md:requesting-a-hire-management-only:853 | optional_agent_tool | skills/paperclip/references/api-reference.md:853 |
| skill:skills/paperclip/references/api-reference.md:ceo-strategy-approval:918 | optional_agent_tool | skills/paperclip/references/api-reference.md:918 |
| skill:skills/paperclip/references/api-reference.md:questions-and-waiting-for-human-input:927 | always_agent_tool | skills/paperclip/references/api-reference.md:927 |
| skill:skills/paperclip/references/api-reference.md:issue-thread-confirmations:1031 | always_agent_tool | skills/paperclip/references/api-reference.md:1031 |
| skill:skills/paperclip/references/api-reference.md:conversational-confirmation-answers:1089 | always_agent_tool | skills/paperclip/references/api-reference.md:1089 |
| skill:skills/paperclip/references/api-reference.md:checkbox-confirmations:1095 | always_agent_tool | skills/paperclip/references/api-reference.md:1095 |
| skill:skills/paperclip/references/api-reference.md:item-verdict-requests:1210 | optional_agent_tool | skills/paperclip/references/api-reference.md:1210 |
| skill:skills/paperclip/references/api-reference.md:checking-approval-status:1320 | optional_agent_tool | skills/paperclip/references/api-reference.md:1320 |
| skill:skills/paperclip/references/api-reference.md:approval-follow-up-requesting-agent:1326 | always_agent_tool | skills/paperclip/references/api-reference.md:1326 |
| skill:skills/paperclip/references/api-reference.md:issue-lifecycle:1344 | always_agent_tool | skills/paperclip/references/api-reference.md:1344 |
| skill:skills/paperclip/references/api-reference.md:error-handling:1374 | control_plane_owned | skills/paperclip/references/api-reference.md:1374 |
| skill:skills/paperclip/references/api-reference.md:full-api-reference:1388 | optional_agent_tool | skills/paperclip/references/api-reference.md:1388 |
| skill:skills/paperclip/references/api-reference.md:agents:1390 | optional_agent_tool | skills/paperclip/references/api-reference.md:1390 |
| skill:skills/paperclip/references/api-reference.md:issues-tasks:1411 | optional_agent_tool | skills/paperclip/references/api-reference.md:1411 |
| skill:skills/paperclip/references/api-reference.md:companies-projects-goals:1452 | optional_agent_tool | skills/paperclip/references/api-reference.md:1452 |
| skill:skills/paperclip/references/api-reference.md:routines:1476 | optional_agent_tool | skills/paperclip/references/api-reference.md:1476 |
| skill:skills/paperclip/references/api-reference.md:approvals-costs-activity-dashboard:1492 | optional_agent_tool | skills/paperclip/references/api-reference.md:1492 |
| skill:skills/paperclip/references/api-reference.md:secrets:1514 | optional_agent_tool | skills/paperclip/references/api-reference.md:1514 |
| skill:skills/paperclip/references/api-reference.md:agent-secret-proposals:1527 | optional_agent_tool | skills/paperclip/references/api-reference.md:1527 |
| skill:skills/paperclip/references/api-reference.md:agent-secret-access:1627 | optional_agent_tool | skills/paperclip/references/api-reference.md:1627 |
| skill:skills/paperclip/references/api-reference.md:common-mistakes:1667 | optional_agent_tool | skills/paperclip/references/api-reference.md:1667 |
| skill:skills/paperclip/references/artifacts.md:generated-artifacts-and-work-products:1 | always_agent_tool | skills/paperclip/references/artifacts.md:1 |
| skill:skills/paperclip/references/artifacts.md:workspace-only-file-references:15 | optional_agent_tool | skills/paperclip/references/artifacts.md:15 |
| skill:skills/paperclip/references/cases.md:cases:1 | optional_agent_tool | skills/paperclip/references/cases.md:1 |
File diff suppressed because it is too large. Load diff
@@ -5,6 +5,6 @@ Generated by `scripts/generate-capability-contract.mjs`; do not edit generated f
- Skill/reference headings: 165
- Legacy MCP tools: 42
- Eval cases: 106 across 16 groups
- Deterministic content SHA-256: `83447a4f9463f9a3d73dd4bea78b580df0356545ba00d0da96763282653bdf0f`
- Deterministic content SHA-256: `74c5fcb9ac01e20178834438536f40edc8b630e15571b447cd783a77b8b69c7b`
Every row has exactly one primary disposition, a source anchor, a semantic operation, and a mock-state expectation.
File diff suppressed because it is too large. Load diff
@@ -1871,7 +1871,7 @@ describe("Capability live runnerd and Codex session", () => {
reasoningTokens: 5,
costNanodollars: 2_500,
});
});
}, 15_000);
it("does not raise an unhandled rejection when interrupt() outlasts the turn timeout during reconcileActiveTurn", async () => {
const state = providerState();
@@ -5893,6 +5893,10 @@ it.each([
let recoveryFenceActive = true;
const completionSnapshotIds: string[] = [];
let rejectHeldCompletion: ((error: Error) => void) | undefined;
let markHeldCompletionEntered!: () => void;
const heldCompletionEntered = new Promise<void>((resolve) => {
markHeldCompletionEntered = resolve;
});
let snapshotObserverSpy: ReturnType<typeof vi.spyOn> | undefined;
const routeServer = createServer((_request, response) =>
response.writeHead(404).end(),
@@ -6471,6 +6475,7 @@ it.each([
) {
return new Promise<void>((_resolve, reject) => {
rejectHeldCompletion = reject;
markHeldCompletionEntered();
});
}
recoveryFenceActive = false;
@@ -6626,9 +6631,13 @@ it.each([
const firstRead = within(resumed.transport.request("thread/read", {}));
void firstRead.catch(() => undefined);
if (ordinaryFollowup === "callback_async_failure") {
await vi.waitFor(() =>
expect(rejectHeldCompletion).toBeTypeOf("function"),
);
await within(Promise.race([
heldCompletionEntered,
firstRead.then(() => {
throw new Error("warm restart completion callback was not entered");
}),
]));
expect(rejectHeldCompletion).toBeTypeOf("function");
try {
expect(recoveryFenceActive).toBe(true);
const queuedBefore = cores[1]!.store.state.commands.map(
+65
View File
@@ -0,0 +1,65 @@
# Paperclip v2026.1015.0
> Released: 2026-10-15
Paperclip v2026.1015.0 carries 200 commits, promoted from `2026.1012.1-beta.0`. The headline is private work: a task or project can now be marked private and shared with named people and agents; everyone else cannot open it, find it in task lists or search, read its run transcripts, or reach it through a plugin. Around that: agents can run through your own model gateway (OpenRouter, Amazon Bedrock, Google Gemini, or any OpenAI Responses, Anthropic Messages, or Chat Completions endpoint), Slack bots set themselves up through Slack's Manifest API, the GitHub review bot gets a two-screen setup and a real **Paperclip Review** status check, Costs becomes an exact ledger that also shows monthly subscription fees, and connected assistants can edit tasks, upload files, and configure agents over MCP. Underneath, OpenAI Dot and Pi 1.0 join the experimental Runner, native OpenCode streams as it types, each agent gets a persistent cryptographic identity, and idle instances stop polling the database every second.
## Breaking Changes
- **Operators can now edit the company.** The Operator role preset grows from one grant (`tasks:assign`) to fourteen: agent creation and configuration, skills, environments, invitations, task assignment, pipelines, connections, tool management and use, and audit reads. Existing Operators pick up the missing grants through the normal startup seeding; custom grant scopes are kept. Join approval and member-permission management stay out of the preset, and inviting an Admin now requires join approval while inviting an Owner also requires member-permission management. Review your member roles if Operators were meant to be read-mostly. ([#15377](https://github.com/paperclipai/paperclip/pull/15377))
- **GitHub review bots move behind their own experimental setting.** The **GitHub Code Review Bot** catalog entry, its setup, and its management screens appear only while **Settings → Experimental → GitHub review bots** (`enableGitHubReviewBots`, off by default) is on; existing bots keep running while it is off. The old setup-method and delegated-agent screens are gone, and per-person automatic-run flags are replaced by author include/exclude filters (legacy `people[].automaticReviews` values are read but ignored). ([#15416](https://github.com/paperclipai/paperclip/pull/15416))
- **Budgets hold on unpriced usage by default.** Each budget policy gains an unpriced-usage setting that defaults to `block`: while a run's usage has no reliable price, the policy counts as at its limit until the charge is resolved through the audited cost adjustment flow. Set the policy to `allow` to keep running on unpriced usage. ([#14997](https://github.com/paperclipai/paperclip/pull/14997))
## Highlights
- **Private tasks and projects** — Mark a task private when you create it, or make a project private with its own member list; a personal private project is created for you on first use. Private work is readable only by its responsible user, creator, current assignee, named grantees, and private-project members, and that rule applies to task lists and task counts, search, activity, run detail, transcripts, events, and logs, documents, attachments, work products, and every plugin read. Two surfaces stay company-wide on purpose: dashboard and sidebar totals may count private work without exposing a title or identifier, and the company run list shows a locked row for a private task's run with only the agent, status, timing, token, and cost fields, so budget oversight keeps working. Privacy flows down to children; sharing a child never exposes its parent or siblings. Owners and admins do not see private tasks by default and must request `?breakGlass=true`, which writes an audit row and a warning notice on the task. Agents' access is intersected with the run's responsible user, so reusing an agent does not carry another person's permissions. ([#14717](https://github.com/paperclipai/paperclip/pull/14717), [#10633](https://github.com/paperclipai/paperclip/pull/10633), [#14718](https://github.com/paperclipai/paperclip/pull/14718))
- **Bring your own model gateway** — Connectors gains rows for OpenRouter, Amazon Bedrock, Google Gemini, and generic Responses API, Messages API, Chat Completions API, and local endpoints. URLs, protocol, region, and credentials live on the connection; the agent's Harness / Runtime page picks a compatible **Connection** from a dropdown and the model picker accepts manual IDs. Codex (legacy and Runner) routes to OpenRouter or custom Responses endpoints, Claude (legacy and Runner) to OpenRouter, custom Messages endpoints, or Bedrock, and OpenCode and Hermes to OpenRouter or custom Chat Completions endpoints. OpenRouter connections without an explicit model list load OpenRouter's public catalog. ([#14970](https://github.com/paperclipai/paperclip/pull/14970), [#15341](https://github.com/paperclipai/paperclip/pull/15341))
- **Slack bots set themselves up** — New Slack bot drafts default to automatic setup: paste a temporary App configuration token, Paperclip creates the app through Slack's Manifest API, installs it through OAuth, links your Slack account, uploads the agent's avatar, and verifies delivery in a four-screen wizard with no Slack CLI involved ([#15413](https://github.com/paperclipai/paperclip/pull/15413)). A new **Require at-mention** setting stops thread replies from waking the agent without an explicit mention ([#15612](https://github.com/paperclipai/paperclip/pull/15612)); a follow-up sent after a Slack Stop starts a fresh turn instead of stalling ([#15737](https://github.com/paperclipai/paperclip/pull/15737)); and Slack agents now know the saved slash command people use to link their accounts, link every task they create, and report results and review requests back in the originating thread ([#15576](https://github.com/paperclipai/paperclip/pull/15576), [#15579](https://github.com/paperclipai/paperclip/pull/15579), [#15611](https://github.com/paperclipai/paperclip/pull/15611)).
- **A two-screen GitHub review bot** — Setup is now Choose agent, then Connect GitHub under a personal account or organization; GitHub asks for creation and installation consent, and Paperclip stores the App secrets locally, imports repository access, and verifies delivery. Each accepted request gets one editable App-owned progress comment, and review assessments publish a **Paperclip Review** check for the reviewed commit with a configurable passing score (1–5) that branch protection can require. A self-hosted instance needs one Paperclip Cloud approval before its first managed GitHub connection, because Cloud hosts the fixed OAuth callback and signed webhook inbox; tokens are sealed to your instance. ([#15416](https://github.com/paperclipai/paperclip/pull/15416))
- **Costs you can reconcile** — Accounting is now a durable, exact ledger: usage receipts are fsynced and replayed after failures, retries settle one charge, amounts keep decimal cents, and every event is marked reported, estimated, or unpriced instead of silently zero ([#14991](https://github.com/paperclipai/paperclip/pull/14991), [#14997](https://github.com/paperclipai/paperclip/pull/14997)). The Costs page reports each connected ChatGPT or Claude subscription's monthly fee once beside API spend, lets you enter the real price, and keeps ambiguous plans unpriced ([#15634](https://github.com/paperclipai/paperclip/pull/15634)); quota reads are scoped to authorized company accounts ([#14994](https://github.com/paperclipai/paperclip/pull/14994)); cost gaps are listed by agent with links to the affected runs, the model the runtime actually selected is recorded, and a retry appears only when saved evidence is ready ([#15948](https://github.com/paperclipai/paperclip/pull/15948)); and historical pricing gaps show as a neutral note under the totals rather than a red error ([#15459](https://github.com/paperclipai/paperclip/pull/15459)).
- **Assistants that can do more over MCP** — A connected Claude Code, Codex, or other MCP assistant can now edit tasks, finish or block them, write documents and revisions, register deliverables, upload files through expiring tickets, and, with a separately consented configuration scope, manage agent settings and instructions, projects and repositories, and skills ([#15380](https://github.com/paperclipai/paperclip/pull/15380)). Connections are named by owner and client ("Dotta's Codex connection") with the authorizing person's avatar, revoked rows disappear, and the copied invitation is shorter ([#15411](https://github.com/paperclipai/paperclip/pull/15411)).
- **Sixteen more apps in the catalog** — Gauge ([#15596](https://github.com/paperclipai/paperclip/pull/15596)), Superagent ([#15394](https://github.com/paperclipai/paperclip/pull/15394)), Telem.AI web search ([#15379](https://github.com/paperclipai/paperclip/pull/15379)), Enterpret ([#13906](https://github.com/paperclipai/paperclip/pull/13906)), Calendly, Exa, Firecrawl, GSC Wizard, Parallel Search, Tavily, and Windsor.ai ([#15621](https://github.com/paperclipai/paperclip/pull/15621)), and Bitly, Context7, Hugging Face, and Resend ([#15686](https://github.com/paperclipai/paperclip/pull/15686)) join as verified hosted MCP connections. Sentry sign-in discovers OAuth through its MCP resource metadata, so setup no longer asks for a client ID and secret ([#15837](https://github.com/paperclipai/paperclip/pull/15837)), and OAuth grants now record whether each scope came from the provider or from Paperclip's request ([#14059](https://github.com/paperclipai/paperclip/pull/14059)).
- **New Runner providers** — OpenAI Dot joins as a standalone experimental agent choice: pair your Dot with a one-use code, and the Runner delivers assignments over MCP Events while Dot uses its own tools and reports back ([#15402](https://github.com/paperclipai/paperclip/pull/15402), [#15414](https://github.com/paperclipai/paperclip/pull/15414), [#15581](https://github.com/paperclipai/paperclip/pull/15581), [#15734](https://github.com/paperclipai/paperclip/pull/15734)). Pi 1.0 runs through the experimental Runner with explicit provider and model selection ([#14921](https://github.com/paperclipai/paperclip/pull/14921)), and the Cursor Runner integration is consolidated onto mainline recovery behavior with a public install path ([#15075](https://github.com/paperclipai/paperclip/pull/15075)).
- **Codex and OpenCode that just start** — Native Codex uses the installed Codex CLI and accepts usable older or newer versions, so a clean npm install no longer needs a global command ([#15555](https://github.com/paperclipai/paperclip/pull/15555)); a model that the sandbox's Codex CLI is too old for is swapped for a compatible one with a visible warning instead of failing every turn ([#15399](https://github.com/paperclipai/paperclip/pull/15399), [#15519](https://github.com/paperclipai/paperclip/pull/15519)); saved runs survive server upgrades ([#15518](https://github.com/paperclipai/paperclip/pull/15518)); and task environment variables reach later turns and tool commands ([#15451](https://github.com/paperclipai/paperclip/pull/15451)). Native OpenCode streams text as it arrives ([#15708](https://github.com/paperclipai/paperclip/pull/15708)), starts without the postinstall script that package managers skip ([#15702](https://github.com/paperclipai/paperclip/pull/15702)), and selects the bundled Linux assets for remote runs without path overrides ([#15721](https://github.com/paperclipai/paperclip/pull/15721)).
- **Reports you can open on the task** — HTML attachments and workspace `.html` files render in an opaque-origin sandbox with view-source and download controls ([#15447](https://github.com/paperclipai/paperclip/pull/15447)), and CSV files open as tables with row numbers, sticky headers, and record counts ([#15445](https://github.com/paperclipai/paperclip/pull/15445)).
- **Clearer task provenance and waits** — A follow-up task an agent creates while working on a task now defaults to that task as its parent, and the task page shows "Created from <task> by <agent>" ([#15762](https://github.com/paperclipai/paperclip/pull/15762)); native agents can schedule their own one-shot task monitor and yield until it fires ([#15446](https://github.com/paperclipai/paperclip/pull/15446)); the monitor card gets an X with a confirmation ([#15735](https://github.com/paperclipai/paperclip/pull/15735)); and a saved pull request stays visible in Properties with its review request and a **Check status** action even when the provider lookup fails ([#15375](https://github.com/paperclipai/paperclip/pull/15375)).
- **Your primary agent** — Each person gets one primary agent per company, shown with a crown on the roster and used as the fallback for new tasks and Chat when there is no recent choice; the first agent you create becomes primary automatically and can be changed on the agent profile ([#15470](https://github.com/paperclipai/paperclip/pull/15470)).
- **Company decision models and fast responses** — Company Settings can bind one shared OpenAI or OpenRouter API connection for small typed model decisions, with permission checks, budget reservations, and a Costs → Decisions history ([#15473](https://github.com/paperclipai/paperclip/pull/15473)). On top of it, **Experimental fast responses** posts a short acknowledgement comment within three seconds of a human message while the agent starts its normal run, in tasks, Agent Chat, and Slack and GitHub threads ([#15748](https://github.com/paperclipai/paperclip/pull/15748)).
## Fixes
- **Workspaces and sandboxes** — A transient sandbox copy-back failure is retried up to three times before the task is held for repair, and remaining holds explain what happened in plain language ([#15920](https://github.com/paperclipai/paperclip/pull/15920)); a remote sandbox whose required copy-back failed is kept, with its files, behind an explicit repair decision instead of being deleted by cleanup ([#15479](https://github.com/paperclipai/paperclip/pull/15479), [#15790](https://github.com/paperclipai/paperclip/pull/15790)); server shutdown waits for a legacy adapter's workspace export before releasing its sandbox lease ([#15857](https://github.com/paperclipai/paperclip/pull/15857)); a reused saved worktree keeps its original repository when project settings change ([#15528](https://github.com/paperclipai/paperclip/pull/15528)); an accepted native result is no longer mistaken for lost work by the orphan reaper after a restart ([#15718](https://github.com/paperclipai/paperclip/pull/15718)); OpenCode runs in the realized workspace via `--dir` ([#11438](https://github.com/paperclipai/paperclip/pull/11438)); Daytona OpenCode sessions keep their deadlines ([#15905](https://github.com/paperclipai/paperclip/pull/15905)); and OpenCode timeouts no longer claim the configured deadline expired ([#15789](https://github.com/paperclipai/paperclip/pull/15789)).
- **Runs, recovery, and questions** — A retry no longer burns itself while a stopped run's environment is still cleaning up, and settled repair instructions stop reappearing in later wakes ([#15463](https://github.com/paperclipai/paperclip/pull/15463)); a review wait whose automatic repair also leaves no wake path now records a board decision instead of sitting silently ([#15764](https://github.com/paperclipai/paperclip/pull/15764)); a parent is woken again when a child finishes requested revisions ([#15372](https://github.com/paperclipai/paperclip/pull/15372)); native runs resume cleanly after connection approval decisions with final usage retained ([#15471](https://github.com/paperclipai/paperclip/pull/15471), [#15420](https://github.com/paperclipai/paperclip/pull/15420), [#15514](https://github.com/paperclipai/paperclip/pull/15514)); a pending tool request expires on its own when the user has already connected the service in Apps ([#15856](https://github.com/paperclipai/paperclip/pull/15856)); agents stop re-asking questions that moved into feed history ([#15392](https://github.com/paperclipai/paperclip/pull/15392)); archiving a task during its completion no longer deadlocks ([#15615](https://github.com/paperclipai/paperclip/pull/15615)); and a task policy with a monitor but no stages no longer crashes the task page ([#15593](https://github.com/paperclipai/paperclip/pull/15593)).
- **Adapters** — Claude CLI sessions resume across per-run agent file copies ([#15437](https://github.com/paperclipai/paperclip/pull/15437)); Hermes gets managed instructions through its system overlay instead of a copy in every resumed user turn ([#15439](https://github.com/paperclipai/paperclip/pull/15439)); and external agents such as Dot are told to use their own tools when a Paperclip app is missing ([#15734](https://github.com/paperclipai/paperclip/pull/15734)).
- **Connections and MCP** — One oversized or malformed MCP reply fails only that call instead of locking the whole connection in an error state ([#15462](https://github.com/paperclipai/paperclip/pull/15462)); remote MCP callbacks keep an explicitly saved empty agent selection, token exchange sends `Accept: application/json`, and saved hidden-provider drafts resume from Finish setup ([#15686](https://github.com/paperclipai/paperclip/pull/15686)); Airtable, Calendly, and other verified providers get the registration and form fixes found in live testing ([#15621](https://github.com/paperclipai/paperclip/pull/15621)); and the sidebar keeps its last plugin items quiet when the server is unreachable ([#15588](https://github.com/paperclipai/paperclip/pull/15588)).
## Improvements
- Hiring, approval, pause, resume, and termination run through a durable agent lifecycle with explicit states: proposed hires stay pending until approval, approved hires are prepared and have their harness configuration verified before work, required plugins get callbacks at each step, and the agent page shows lifecycle progress with a retry action ([#15631](https://github.com/paperclipai/paperclip/pull/15631), [#15391](https://github.com/paperclipai/paperclip/pull/15391)); plugins can consume a durable resource lifecycle journal with acknowledgments, seeded with a one-time baseline of existing agents and projects, and project archive and restore now write journal events ([#15343](https://github.com/paperclipai/paperclip/pull/15343), [#15371](https://github.com/paperclipai/paperclip/pull/15371)).
- Every agent gets a persistent Ed25519 keypair, created on hire or on its next managed run, encrypted at rest and handed to managed processes as `PAPERCLIP_AGENT_KEY_ID`, `PAPERCLIP_AGENT_PUBLIC_KEY`, and `PAPERCLIP_AGENT_PRIVATE_KEY`; the public key appears on the agent overview ([#15352](https://github.com/paperclipai/paperclip/pull/15352)).
- Agents can file complaints and suggestions about their tooling into the instance's `agent_commentary` table through new `complain` and `suggestion-box` skills or native `submit_complaint` and `submit_suggestion` tools, keeping that feedback out of task threads ([#15367](https://github.com/paperclipai/paperclip/pull/15367)).
- Idle instances go quiet: chat delivery, provider action, Slack sync, email, Browser Use cleanup, and five server delivery queues now wake from committed writes and known deadlines instead of scanning the database every second or three ([#15625](https://github.com/paperclipai/paperclip/pull/15625), [#15750](https://github.com/paperclipai/paperclip/pull/15750), [#15757](https://github.com/paperclipai/paperclip/pull/15757), [#15802](https://github.com/paperclipai/paperclip/pull/15802), [#15808](https://github.com/paperclipai/paperclip/pull/15808), [#15822](https://github.com/paperclipai/paperclip/pull/15822)); hosts that park idle instances get an idle-sleep safety report that fences accepted work, drains plugin workers, and treats finished logins, consumed retries, and verified delivery history as history rather than pending work ([#15522](https://github.com/paperclipai/paperclip/pull/15522), [#15599](https://github.com/paperclipai/paperclip/pull/15599), [#15761](https://github.com/paperclipai/paperclip/pull/15761), [#15851](https://github.com/paperclipai/paperclip/pull/15851), [#15897](https://github.com/paperclipai/paperclip/pull/15897), [#15907](https://github.com/paperclipai/paperclip/pull/15907)), with an opt-in final backup checkpoint before sleep ([#15629](https://github.com/paperclipai/paperclip/pull/15629)).
- Task notifications show the actor's name and avatar and hide your own actions ([#15695](https://github.com/paperclipai/paperclip/pull/15695)); other people's comments in task chat appear on the left with their name and avatar, and only yours are on the right in blue ([#15494](https://github.com/paperclipai/paperclip/pull/15494)); an agent blocked by an AI connection its responsible user cannot use shows an expanded error naming the agent and connection with a link to its AI settings ([#15741](https://github.com/paperclipai/paperclip/pull/15741)); and a teammate without the agent's required personal AI account can connect their own from inside the chat and resume the message ([#15376](https://github.com/paperclipai/paperclip/pull/15376)).
- The task composer keeps its model label steady while the effort slider moves and the slider is larger ([#15444](https://github.com/paperclipai/paperclip/pull/15444)), nested selectors scroll with the wheel and touch ([#15374](https://github.com/paperclipai/paperclip/pull/15374)), the project picker always offers **No project** ([#15382](https://github.com/paperclipai/paperclip/pull/15382)), routine descriptions keep code-block pastes in the code block and gain a copy control ([#15773](https://github.com/paperclipai/paperclip/pull/15773)), confirmation cards stack the prompt above the target pill and hide unlabeled machine keys ([#15895](https://github.com/paperclipai/paperclip/pull/15895)), chat connection and recovery cards share one surface ([#15771](https://github.com/paperclipai/paperclip/pull/15771)), the Slack chat-or-tools chooser gets icons ([#15574](https://github.com/paperclipai/paperclip/pull/15574)), the Workspace filter leaves the task and inbox filter menus ([#15788](https://github.com/paperclipai/paperclip/pull/15788)), and the star and leave buttons leave the agent index ([#15400](https://github.com/paperclipai/paperclip/pull/15400)).
- Tasks whose starting branch does not exist in the repository show a branch repair card that offers the remote's default branch or a typed branch and retries through the normal admission gates ([#15726](https://github.com/paperclipai/paperclip/pull/15726)); a GitHub reference the instance's credentials cannot see is reported as inaccessible and re-checked instead of frozen as "Not found" ([#15906](https://github.com/paperclipai/paperclip/pull/15906)); and the Paperclip skill ships its own issue-update helper and honors a harness checkout instead of demanding a second one ([#15554](https://github.com/paperclipai/paperclip/pull/15554), [#15548](https://github.com/paperclipai/paperclip/pull/15548)).
- Workspace jobs that exit non-zero return `422 workspace_job_failed` with the job's own output instead of a 500 ([#15941](https://github.com/paperclipai/paperclip/pull/15941)); custom source launchers get the native Runner WebSocket transport from the shared HTTP server constructor ([#15682](https://github.com/paperclipai/paperclip/pull/15682)); a refused local Hermes Gateway connection explains which host to check ([#15520](https://github.com/paperclipai/paperclip/pull/15520)); and pi-local's unavailable-model error no longer dumps the model catalog ([#13628](https://github.com/paperclipai/paperclip/pull/13628)).
- Failure reports carry bounded evidence for Daytona acquisition timeouts and provider state, workspace base-ref resolution, sandbox transfer and sync-out errors, orphan process loss, ACP bridge terminal frames, Git worktree inspection, and stackless browser rejections ([#15946](https://github.com/paperclipai/paperclip/pull/15946), [#15736](https://github.com/paperclipai/paperclip/pull/15736), [#15719](https://github.com/paperclipai/paperclip/pull/15719), [#15637](https://github.com/paperclipai/paperclip/pull/15637), [#15481](https://github.com/paperclipai/paperclip/pull/15481), [#15475](https://github.com/paperclipai/paperclip/pull/15475), [#15478](https://github.com/paperclipai/paperclip/pull/15478), [#15525](https://github.com/paperclipai/paperclip/pull/15525), [#15940](https://github.com/paperclipai/paperclip/pull/15940)), while proven configuration blockers such as a missing user secret, an unchosen AI account, a credential not shared with the responsible user, a local-directory project with a worktree policy, or an external service outage stay out of Sentry ([#15472](https://github.com/paperclipai/paperclip/pull/15472), [#15618](https://github.com/paperclipai/paperclip/pull/15618), [#15714](https://github.com/paperclipai/paperclip/pull/15714), [#15521](https://github.com/paperclipai/paperclip/pull/15521), [#15590](https://github.com/paperclipai/paperclip/pull/15590)).
## Upgrade Guide
- **Migrations `0306`–`0333`** run on upgrade: `0306` allows `google` in the AI account-default provider checks; `0307` rewrites the lifecycle-event action constraint; `0308` adds `agent_identity_keys`; `0309` adds `plugin_lifecycle_acknowledgments`, indexes the lifecycle journal, and seeds a one-time baseline event per existing agent and project; `0310` adds `agent_commentary`; `0311` adds MCP attachment upload and file ticket tables; `0312` adds accounting receipt, reservation, adjustment, invoice, and provider snapshot tables, widens cost columns to `numeric`, and adds the budget unpriced-usage policy (default `block`); `0313` adds `issue_access_grants` and `project_access_members`, adds `visibility` to tasks and projects (default `open`), and backfills privacy parent edges and run bindings in 1,000-row batches; `0314` binds historical inline draft images to their first task; `0315` adds `user_company_preferences` and backfills a primary agent from history; `0316` adds decision model tables; `0317` adds OpenAI Dot binding, mailbox, and assignment tables; `0318` adds `chat_slack_registrations`; `0319` makes Dot agent refresh tokens non-expiring; `0320` adds agent lifecycle columns and derives `lifecycle_state` from each agent's current status; `0321` adds `chat_endpoints.require_at_mention`; `0322` adds AI subscription account and price tables; `0323` adds GitHub registration owner columns; `0324`–`0330` add the experimental voice session tables; `0331` adds fast-response tables and `issue_comments.origin`; `0332` adds a question-history index concurrently; `0333` adds cost data-quality indexes. `0313` and `0314` run outside a single transaction and commit each batch, so an interrupted upgrade leaves them pending and safe to rerun; expect them to take longer on instances with large run histories.
- **New environment variables**, none required: `PAPERCLIP_ISSUE_PRIVACY_MODE` (default `enforce`; `shadow` logs would-deny decisions without enforcing, `off` disables the predicate) ([#10633](https://github.com/paperclipai/paperclip/pull/10633)); `PAPERCLIP_DB_BACKUP_IDLE_CHECKPOINT_ENABLED=1` opts an instance with automatic backups into taking a final verified backup before idle sleep (off by default) ([#15629](https://github.com/paperclipai/paperclip/pull/15629)); `PAPERCLIP_RESPONSIBLE_USER_AUTHZ_CACHE_TTL_MS` bounds the responsible-user permission snapshot (default `5000`) ([#10633](https://github.com/paperclipai/paperclip/pull/10633)).
- **New experimental settings**, all off by default: **GitHub review bots** (`enableGitHubReviewBots`) shows GitHub bot setup and management ([#15416](https://github.com/paperclipai/paperclip/pull/15416)); **OpenAI Dot** (`enableOpenAiDot`) adds Dot as an agent choice and requires **Assistant connections (MCP)**, authenticated sign-in, and an HTTPS `PAPERCLIP_PUBLIC_URL` ([#15414](https://github.com/paperclipai/paperclip/pull/15414)); **Experimental fast responses** (`enableFastResponses`) reveals Company Settings → Connections and allows acknowledgement comments ([#15748](https://github.com/paperclipai/paperclip/pull/15748)). Decision model setup lives under Company Settings → General until fast responses are on ([#15473](https://github.com/paperclipai/paperclip/pull/15473)).
- **Review Operator members** ([#15377](https://github.com/paperclipai/paperclip/pull/15377)): Operators now hold company editing, invitation, connection, tool, and audit grants after startup seeding. Demote members who should not configure agents or connect accounts.
- **Budget policies and unpriced usage** ([#14997](https://github.com/paperclipai/paperclip/pull/14997)): existing policies get `unpricedUsagePolicy: block`. If your agents run on providers whose prices Paperclip cannot determine, set the policy to `allow` or resolve unpriced events through the cost adjustment flow so the budget does not hold them.
- **Custom adapters and managed processes** ([#15352](https://github.com/paperclipai/paperclip/pull/15352)): managed runs now receive `PAPERCLIP_AGENT_KEY_ID`, `PAPERCLIP_AGENT_PUBLIC_KEY`, and `PAPERCLIP_AGENT_PRIVATE_KEY`; the key values are multiline PEM strings and the private key must not be logged. The wrapping key is `PAPERCLIP_SECRETS_MASTER_KEY` or the local secrets master-key file; a wrong master key fails the run rather than regenerating the keypair.
- **Private-task callers** ([#10633](https://github.com/paperclipai/paperclip/pull/10633)): direct reads of a private task by a non-member return `404`; owners and admins must pass `?breakGlass=true` for an audited emergency read; plugins are non-member principals and see no private content.
- **Slack and GitHub drafts**: existing Slack bot drafts without a saved setup method stay on manual setup ([#15413](https://github.com/paperclipai/paperclip/pull/15413)); existing GitHub bots, direct webhooks, and manual existing-App recovery keep working, but their settings are hidden until **GitHub review bots** is enabled ([#15416](https://github.com/paperclipai/paperclip/pull/15416)).
- **Harness pins** are unchanged for Codex, Claude, and OpenCode. Native Codex now prefers the Codex CLI installed with the server package and tolerates version drift; a sandbox image whose Codex CLI is too old for the selected model is switched to a compatible model with a warning ([#15555](https://github.com/paperclipai/paperclip/pull/15555), [#15519](https://github.com/paperclipai/paperclip/pull/15519)).
- No other configuration or API changes require action.
## Contributors
This release has 200 commits from 6 contributors. Thank you to everyone who contributed to this release!
@lorenzozanee, @lusoris
@@ -126,6 +126,33 @@ describe("activity routes", () => {
mockAccessService.canUser.mockResolvedValue(false);
});
it("lets an agent read activity for an authorized issue", async () => {
mockIssueService.getByIdentifier.mockResolvedValue({
id: "issue-1", companyId: "company-1", projectId: null, parentId: null,
assigneeAgentId: "agent-1", assigneeUserId: null, status: "done",
});
mockActivityService.forIssue.mockResolvedValue([{ action: "issue.recovery_action_resolved", actorType: "system" }]);
const app = await createApp({ type: "agent", companyId: "company-1", agentId: "agent-1" });
const res = await requestApp(app, baseUrl => request(baseUrl).get("/api/issues/TEST-1/activity"));
expect(res.status).toBe(200);
expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({
action: "issue:read", resource: expect.objectContaining({ companyId: "company-1", issueId: "issue-1" }),
}));
expect(mockActivityService.forIssue).toHaveBeenCalledWith("issue-1");
expect(res.body).toEqual([{ action: "issue.recovery_action_resolved", actorType: "system" }]);
});
it.each(["boundary", "company"])("denies agent activity reads outside the %s scope", async (kind) => {
const issue = { id: "issue-1", companyId: kind === "company" ? "company-2" : "company-1" };
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.getByIdentifier.mockResolvedValue(issue);
mockAccessService.decide.mockResolvedValue({ allowed: false });
const app = await createApp({ type: "agent", companyId: "company-1", agentId: "agent-1" });
const res = await requestApp(app, baseUrl => request(baseUrl).get("/api/issues/issue-1/activity"));
expect(res.status).toBe(kind === "company" ? 404 : 403);
expect(mockActivityService.forIssue).not.toHaveBeenCalled();
});
it("returns redacted all-actors rows to a basic company reader", async () => {
mockAgentActionAuditService.list.mockResolvedValue({
items: [{
@@ -110,6 +110,7 @@ describe("createChatReconciliationCoordinator", () => {
const service = readFileSync(new URL("../services/chat-channels.ts", import.meta.url), "utf8");
const actionMaintenance = service.slice(service.indexOf("async function processPendingChatMaintenance("),
service.indexOf("async function processPendingDeliveries("));
expect(actionMaintenance).not.toContain("processFailedChatRunRetries(");
expect(actionMaintenance).not.toContain("processPendingProviderEffects(");
expect(actionMaintenance).not.toContain("processPendingGitHubWebhookIngress(");
});
@@ -19,6 +19,7 @@ function setup() {
const coordinator = createDeliveryWorkCoordinator({ owner, canRun: enabled, canReconcile: enabled, onError });
coordinators.push(coordinator);
const service = {
processFailedChatRunRetries: vi.fn(async () => {}),
processPendingProviderEffects: vi.fn(async () => {}),
processPendingGitHubWebhookIngress: vi.fn(async () => {}),
processPendingSlackSessionSyncs: vi.fn(async () => {}),
@@ -30,7 +31,7 @@ function setup() {
nextChatActionAt: vi.fn(async (): Promise<number | null> => null),
nextVerificationMessageAt: vi.fn(async (): Promise<number | null> => null),
};
const runs = [service.processPendingProviderEffects, service.processPendingGitHubWebhookIngress, service.processPendingSlackSessionSyncs, service.processPendingSlackBoardMessages, service.processPendingSlackTaskStarts,
const runs = [service.processFailedChatRunRetries, service.processPendingProviderEffects, service.processPendingGitHubWebhookIngress, service.processPendingSlackSessionSyncs, service.processPendingSlackBoardMessages, service.processPendingSlackTaskStarts,
service.processPendingReceiptReactions, service.processPendingSlackSessionStops, service.processPendingVerificationMessages];
return { owner, service, runs, coordinator, enabled, onError, start: () => registerChatActionWork(coordinator, service, enabled) };
}
@@ -40,7 +41,7 @@ describe("chat action scheduling", () => {
await s.start().ready;
await vi.advanceTimersByTimeAsync(3_600_000);
for (const run of s.runs) expect(run).toHaveBeenCalledTimes(1);
expect(s.service.nextChatActionAt).toHaveBeenCalledTimes(7);
expect(s.service.nextChatActionAt).toHaveBeenCalledTimes(8);
expect(s.service.nextVerificationMessageAt).toHaveBeenCalledTimes(1);
expect(s.coordinator.nextWakeAt()).toBeNull();
expect(vi.getTimerCount()).toBe(0);
@@ -1,3 +1,4 @@
import { idleWorkSnapshot } from "../services/task-admission.js";
import * as activityLogModule from "../services/activity-log.js";
import { chatSlackManagerGrants, chatSlackRegistrations, toolOauthStates } from "@paperclipai/db";
import type { PaperclipCloudConnector, SealedConnectorCredentials } from "../services/paperclip-cloud-connector.js";
@@ -33026,6 +33027,26 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
await set("delivery_unknown");
expect(await f.service.nextChatActionAt(kind)).toBeNull();
expect(await due()).toBe(false);
} else if (kind === "failed_run_retry") {
expect(await due()).toBe(false);
await set("issued");
expect(await due()).toBe(true);
await set("issued", { retryAt: later.toISOString() });
expect(await f.service.nextChatActionAt(kind)).toBe(later.getTime());
expect(await due()).toBe(false);
await set("processing");
expect(await f.service.nextChatActionAt(kind)).toBe(at.getTime() + 60_000);
expect(await due()).toBe(false);
// Both the claim lease and retry time must expire before replay.
await set("processing", { retryAt: later.toISOString() });
expect(await f.service.nextChatActionAt(kind)).toBe(later.getTime());
await set("processing", { retryAt: at.toISOString() });
expect(await f.service.nextChatActionAt(kind)).toBe(at.getTime() + 60_000);
for (const status of ["failed", "cancelled", "processed"]) {
await set(status, { retryable: true, retryAt: at.toISOString() });
expect(await f.service.nextChatActionAt(kind)).toBeNull();
expect(await due()).toBe(false);
}
} else if (kind === "slack_session_sync") {
expect(await due()).toBe(true);
await set("received", { retryAt: later.toISOString() });
@@ -59779,6 +59800,81 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
},
);
it.each(["commit", "retry", "startup", "stale_claim", "rollback"] as const)(
"drains failed chat retries from durable work and becomes idle: %s",
async (mode) => {
const context = await failedChatRetryFixture("telegram");
const onError = vi.fn();
const coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => true, onError });
const input = { companyId: context.fixture.companyId, issueId: context.issue.id,
agentId: context.fixture.assignedAgentId, failedRunId: context.runId, initiatedByUserId: "owner-user" };
const stage = () => db.transaction(tx => context.service.prepareFailedChatRunRetry(tx, input));
context.wakeup.mockClear();
try {
let actionId: string;
if (mode === "startup" || mode === "stale_claim") {
({ actionId } = await stage());
if (mode === "stale_claim") await db.update(chatActions).set({ status: "processing",
updatedAt: new Date(Date.now() - 120_000) }).where(eq(chatActions.id, actionId));
await registerChatActionWork(coordinator, context.service, () => true).ready;
} else {
await registerChatActionWork(coordinator, context.service, () => true).ready;
if (mode === "rollback") {
await expect(db.transaction(async tx => {
await context.service.prepareFailedChatRunRetry(tx, input);
throw new Error("rollback retry intent");
})).rejects.toThrow("rollback retry intent");
await vi.waitFor(() => {
expect(coordinator.nextWakeAt()).toBeNull();
expect(idleWorkSnapshot().active).toBe(0);
}, { timeout: 10_000 });
expect(context.wakeup).not.toHaveBeenCalled();
}
if (mode === "retry") {
let failedAt = 0;
context.wakeup.mockImplementationOnce(async () => {
failedAt = Date.now();
throw new Error("temporary admission outage");
}).mockImplementationOnce(async () => {
expect(Date.now() - failedAt).toBeGreaterThanOrEqual(250);
return { accepted: true };
});
}
({ actionId } = await db.transaction(async tx => {
const staged = await tx.transaction(nested => context.service.prepareFailedChatRunRetry(nested, input));
// A nested savepoint must not wake the worker before the outer commit.
await new Promise(resolve => setTimeout(resolve, 25));
expect(context.wakeup).not.toHaveBeenCalled();
return staged;
}));
}
await vi.waitFor(async () => {
const [action] = await db.select().from(chatActions).where(eq(chatActions.id, actionId));
expect(action?.status).toBe("processed");
expect(action?.result.attemptCount).toBe(mode === "retry" ? 2 : 1);
expect(coordinator.nextWakeAt()).toBeNull();
}, { timeout: 10_000 });
expect(context.wakeup).toHaveBeenCalledTimes(mode === "retry" ? 2 : 1);
const receipts = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, actionId));
expect(receipts).toHaveLength(1);
expect(receipts[0]?.payload).toMatchObject({ retryOfRunId: context.runId,
wakeCommentIds: [context.action.payload.commentId] });
// A running worker has no scheduled deadline. Wait for its final scan
// and any commit-driven follow-up before measuring the quiet period.
await vi.waitFor(() => {
expect(coordinator.nextWakeAt()).toBeNull();
expect(idleWorkSnapshot().active).toBe(0);
}, { timeout: 10_000 });
const scans = vi.spyOn(db, "select");
try {
await new Promise(resolve => setTimeout(resolve, 1200));
expect(scans).not.toHaveBeenCalled();
} finally { scans.mockRestore(); }
expect(onError).not.toHaveBeenCalled();
} finally { await coordinator.stop(); await context.service.shutdown(); }
},
);
it.each(["slack", "telegram"] as const)(
"retries the exact failed %s chat input with a new idempotent admission",
async (provider) => {
@@ -2415,7 +2415,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
.toMatchObject({ status: "active", resolvedAt: null });
});
it("keeps existing terminal-task disposition for a native finalization hold", async () => {
it("retains the exact native owner hold without changing a terminal task's disposition", async () => {
const f = await seedPendingNativeFinalization();
await db.update(heartbeatRuns).set({ runnerProfileJson: { nativeWorkspaceFinalizationOwner: { unknown: true } } })
.where(eq(heartbeatRuns.id, f.runId));
@@ -2423,6 +2423,13 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
await heartbeat.recoverNativeRunsAfterRestart();
const [hold] = await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, f.issueId));
await db.update(issues).set({ status: "done" }).where(eq(issues.id, f.issueId));
for (let attempt = 0; attempt < 3; attempt += 1) {
await heartbeat.reconcileStrandedAssignedIssues();
expect((await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.id, hold.id)))[0])
.toEqual(hold);
expect((await db.select().from(issues).where(eq(issues.id, f.issueId)))[0].status).toBe("done");
}
await db.update(heartbeatRuns).set({ runnerProfileJson: {} }).where(eq(heartbeatRuns.id, f.runId));
await heartbeat.reconcileStrandedAssignedIssues();
expect((await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.id, hold.id)))[0])
.toMatchObject({ status: "resolved", resolutionNote: "source_terminal" });
@@ -38,6 +38,8 @@ import { deliverReconciledExecutions } from "../services/execution-recovery-reso
import { workspaceOperationService, isNativeWorkspaceFinalizationOperationActive } from "../services/workspace-operations.js";
import { issueRecoveryActionService } from "../services/issue-recovery-actions.js";
import { recoveryService } from "../services/recovery/service.js";
import { issueService } from "../services/issues.js";
import { withNativeWorkspaceFinalizationOwnership } from "../services/native-runtime/native-workspace-finalization-ownership.js";
import { noticeMetadataReferencesRecoveryAction } from "../services/recovery/successful-run-handoff.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
@@ -2479,6 +2481,51 @@ describeEmbeddedPostgres("issue recovery actions", () => {
trigger: "read_projection",
recoveryActionId: action.id,
});
expect(activityRows.find((row) => row.action === "issue.recovery_action_resolved")).toMatchObject({
actorType: "system", actorId: "recovery", agentId: null,
});
});
it.each(["done", "cancelled"])("keeps one workspace owner hold across reads and sweeps of a %s task", async (status) => {
const { companyId, coderId, sourceIssueId } = await seedCompany();
const runId = randomUUID();
const owner = { token: randomUUID(), hostname: "old-controller.invalid", pid: 42, processStartedAt: new Date(0).toISOString() };
await db.insert(heartbeatRuns).values({
id: runId, companyId, agentId: coderId, nativeIssueId: sourceIssueId,
runtimeMode: "native", status: "failed", finishedAt: new Date(),
runnerProfileJson: { nativeWorkspaceFinalizationOwner: owner },
});
await db.update(issues).set({ status }).where(eq(issues.id, sourceIssueId));
const userId = "board-reader";
const app = createApp({ type: "board", source: "local_implicit", userId });
const copyback = vi.fn();
const recovery = recoveryService(db, { enqueueWakeup: vi.fn(async () => null) });
let firstAction: typeof issueRecoveryActions.$inferSelect | undefined;
for (let tick = 0; tick < 3; tick += 1) {
expect(await withNativeWorkspaceFinalizationOwnership({ db, companyId, runId }, copyback)).toEqual({ acquired: false });
const detail = await request(app).get(`/api/issues/${sourceIssueId}`).expect(200);
expect(detail.body.activeRecoveryAction).toMatchObject({
cause: "native_workspace_finalization_owner_unverified", status: "active", attemptCount: 1,
});
await request(app).get(`/api/companies/${companyId}/issues?view=compact`).expect(200);
await request(app).get(`/api/issues/${sourceIssueId}/recovery-actions`).expect(200);
await recovery.reconcileStrandedAssignedIssues();
const actions = await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, sourceIssueId));
expect(actions).toHaveLength(1);
firstAction ??= actions[0]!;
expect(actions[0]).toEqual(firstAction);
}
expect(copyback).not.toHaveBeenCalled();
expect(await issueService(db).list(companyId, { touchedByUserId: userId })).toEqual([]);
expect(await db.select().from(activityLog).where(eq(activityLog.entityId, sourceIssueId))).toHaveLength(0);
// Only clearing the exact physical owner receipt makes this hold stale.
await db.update(heartbeatRuns).set({ runnerProfileJson: {} }).where(eq(heartbeatRuns.id, runId));
const cleared = await request(app).get(`/api/issues/${sourceIssueId}`).expect(200);
expect(cleared.body.activeRecoveryAction).toBeNull();
const rows = await db.select().from(activityLog).where(eq(activityLog.entityId, sourceIssueId));
expect(rows).toEqual([expect.objectContaining({ actorType: "system", actorId: "recovery", action: "issue.recovery_action_resolved" })]);
expect(await issueService(db).list(companyId, { touchedByUserId: userId })).toEqual([]);
});
it("keeps active recovery visible when a plain comment does not create a live path", async () => {
@@ -668,6 +668,39 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => {
]);
});
it("ignores historical reader-attributed recovery cleanup without hiding explicit recovery decisions", async () => {
const companyId = await seedAssignableAgentCompany();
const userId = "board-user";
const old = new Date("2026-03-26T10:00:00Z");
const recent = new Date("2026-03-26T15:00:00Z");
const variants = [
{ id: randomUUID(), details: { source: "source_revalidation", trigger: "read_projection" }, counts: false },
{ id: randomUUID(), details: null, counts: true },
{ id: randomUUID(), details: { outcome: "restored" }, counts: true },
{ id: randomUUID(), details: { source: "source_revalidation", trigger: "issue_update" }, counts: true },
];
await db.insert(issues).values(variants.map(({ id }) => ({
id, companyId, title: "Recovery audit", status: "done", updatedAt: old,
})));
await db.insert(activityLog).values(variants.map(({ id, details }) => ({
companyId, actorType: "user", actorId: userId, action: "issue.recovery_action_resolved",
entityType: "issue", entityId: id, details, createdAt: recent,
})));
await db.insert(issueComments).values(variants.map(({ id }) => ({
companyId, issueId: id, authorUserId: "other-user", body: "An older update", createdAt: old,
})));
const expectedIds = variants.filter(v => v.counts).map(v => v.id).sort();
expect((await svc.list(companyId, { touchedByUserId: userId })).map(v => v.id).sort()).toEqual(expectedIds);
expect(await svc.countUnreadTouchedByUser(companyId, userId)).toBe(expectedIds.length);
const rows = await svc.list(companyId, {});
for (const variant of variants) {
expect(rows.find(v => v.id === variant.id)?.lastActivityAt).toEqual(variant.counts ? recent : old);
}
// Audit evidence is preserved, including the wrongly attributed old row.
expect(await db.select().from(activityLog).where(eq(activityLog.companyId, companyId))).toHaveLength(variants.length);
});
function agentRow(companyId: string, input: {
id: string;
name: string;
+9 -5
View File
@@ -1,6 +1,7 @@
import { parseObject } from "../adapters/utils.js";
import { enqueueFastResponse } from "../services/fast-responses.js";
import { hasRequiredWorkspaceRecovery, LEGACY_WORKSPACE_RECOVERY_SCHEMA } from "../services/workspace-restore-recovery-state.js";
import { hasUnverifiedNativeWorkspaceFinalizationOwner } from "../services/native-runtime/native-workspace-finalization-recovery-state.js";
import { monitorPoliciesEqual, applyActorMonitorScheduledBy, assertCanManageIssueMonitor, summarizeIssueMonitor } from "../services/issue-monitors.js";
import type { IssuePrivacyConstraints } from "@paperclipai/shared";
import { canActorReadHeartbeatRun } from "../services/heartbeat-run-privacy.js";
@@ -4344,6 +4345,10 @@ export function issueRoutes(
: input.activeRecoveryAction;
if (!activeRecoveryAction) return null;
if (await hasUnverifiedNativeWorkspaceFinalizationOwner(db, activeRecoveryAction)) {
return activeRecoveryAction;
}
const resolutionNote = await classifySourceRecoveryRevalidation(input);
if (!resolutionNote) return activeRecoveryAction;
@@ -4357,13 +4362,12 @@ export function issueRoutes(
});
if (!resolved) return activeRecoveryAction;
const actor = input.actor;
await logActivity(db, {
companyId: input.issue.companyId,
actorType: actor?.actorType ?? "system",
actorId: actor?.actorId ?? "system",
agentId: actor?.agentId ?? null,
runId: actor?.runId ?? null,
actorType: "system",
actorId: "recovery",
agentId: null,
runId: input.actor?.runId ?? null,
action: "issue.recovery_action_resolved",
entityType: "issue",
entityId: input.issue.id,
+16 -8
View File
@@ -8,6 +8,7 @@ import { registerChatQueueWork } from "./chat-delivery-work.js";
export const SLACK_COMMAND_POST_STALE_MS = 60_000;
export const SLACK_COMMAND_EXPLICIT_RETRY_STALE_MS = 5 * 60_000;
export const SLACK_COMMAND_ADMISSION_STALE_MS = 60_000;
export const FAILED_RUN_RETRY_STALE_MS = 60_000;
export const PROVIDER_EFFECT_STALE_MS = 60_000;
/** The dispatch predicate and scheduler use the same eligibility and deadlines.
@@ -15,6 +16,9 @@ export const PROVIDER_EFFECT_STALE_MS = 60_000;
export function chatActionDeadline(kind: ScheduledChatAction) {
const updated = sql`extract(epoch from ${chatActions.updatedAt}) * 1000`;
const retry = sql`coalesce(extract(epoch from (${chatActions.result}->>'retryAt')::timestamptz) * 1000, 0)`;
if (kind === "failed_run_retry") return sql<number>`case
when ${chatActions.status} = 'issued' then ${retry}
when ${chatActions.status} = 'processing' then greatest(${updated} + ${FAILED_RUN_RETRY_STALE_MS}, ${retry}) end`;
if (kind === "slack_board_message") return sql<number>`case
when ${chatActions.status} = 'received' and ${chatEndpoints.status} not in ('paused', 'attention') then 0::numeric end`;
if (kind === "slash_task_start") return sql<number>`case
@@ -36,14 +40,16 @@ export function chatActionDeadline(kind: ScheduledChatAction) {
function pendingChatAction(kind: ScheduledChatAction) {
// Filter history before the endpoint join and aggregate. Failed actions
// only remain live when the saved result permits another attempt.
const status = kind === "slack_board_message"
? eq(chatActions.status, "received")
: kind === "slash_task_start"
? inArray(chatActions.status, ["queued", "provider_confirmed", "admitting", "resolving", "validating"])
: kind === "slack_session_sync"
? inArray(chatActions.status, ["received", "processing"])
: or(inArray(chatActions.status, ["received", "processing"]),
and(eq(chatActions.status, "failed"), sql`${chatActions.result}->>'retryable' = 'true'`));
const status = kind === "failed_run_retry"
? inArray(chatActions.status, ["issued", "processing"])
: kind === "slack_board_message"
? eq(chatActions.status, "received")
: kind === "slash_task_start"
? inArray(chatActions.status, ["queued", "provider_confirmed", "admitting", "resolving", "validating"])
: kind === "slack_session_sync"
? inArray(chatActions.status, ["received", "processing"])
: or(inArray(chatActions.status, ["received", "processing"]),
and(eq(chatActions.status, "failed"), sql`${chatActions.result}->>'retryable' = 'true'`));
return and(eq(chatActions.kind, kind), status);
}
@@ -60,6 +66,7 @@ export async function nextChatActionAt(db: Db, kind: ScheduledChatAction): Promi
}
type ChatActionService = {
processFailedChatRunRetries(): Promise<unknown>;
processPendingProviderEffects(): Promise<unknown>;
processPendingGitHubWebhookIngress(): Promise<unknown>;
processPendingSlackSessionSyncs(): Promise<unknown>;
@@ -78,6 +85,7 @@ export function registerChatActionWork(
canRun: () => boolean,
) {
const actions = [
{ kind: "failed_run_retry", run: service.processFailedChatRunRetries },
{ kind: "provider_effect", run: service.processPendingProviderEffects },
{ kind: "github_webhook_ingress", run: service.processPendingGitHubWebhookIngress },
{ kind: "slack_session_sync", run: service.processPendingSlackSessionSyncs },
+2 -3
View File
@@ -5729,7 +5729,6 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
lifecycleRuntimeFence,
isShuttingDown: () => shuttingDown,
wakeup: (...args) => options.heartbeat.wakeup(...args),
staleProcessingMs: DELIVERY_PROCESSING_STALE_MS,
});
const { stageTeamsBoardFileIntent, deriveTeamsFileTransferAuthority } = createTeamsFileAuthority({ runtimeGeneration, credentialFingerprint, runtime, runtimeContexts, localRuntimeEpoch, runtimeVersions, lockCurrentPrincipalAuthorization, lifecycleRuntimeFence, assertFailedRetryReceipt, inboundWakeScope, failedChatRetrySource, isExplicitOperatorPublication, retryCommentIds });
async function authorizeRetainedChatSourcePublication(
@@ -15429,7 +15428,6 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
async function processPendingChatMaintenance(limit = 25) {
return Promise.allSettled([
processPendingTelegramMaintenance(limit),
processFailedChatRunRetries(limit),
]);
}
@@ -15440,7 +15438,8 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
// so a crashed processing claim is quarantined before the delivery worker
// could otherwise replay it.
const actionRecovery = onlyDeliveryId || !maintenance ? null : Promise.allSettled([
processPendingChatMaintenance(limit), processPendingGitHubWebhookIngress(limit),
processPendingChatMaintenance(limit), processFailedChatRunRetries(limit),
processPendingGitHubWebhookIngress(limit),
processPendingProviderEffects(limit), processPendingSlackBoardMessages(limit),
processPendingSlackTaskStarts(limit), processPendingReceiptReactions(limit),
processPendingSlackSessionStops(limit), slackRegistration.processPendingVerificationMessages(limit),
@@ -1,5 +1,6 @@
import { continuingTeamsFilePublication, type ContinuingTeamsFileTransfer } from "./microsoft-teams/file-transfer-source.js";
import { notifyChatPublicationWork } from "../chat-work-notifications.js";
import { notifyChatActionWork, notifyChatPublicationWork } from "../chat-work-notifications.js";
import { FAILED_RUN_RETRY_STALE_MS, dueChatAction } from "../chat-action-work.js";
import {
createHash,
@@ -13,7 +14,6 @@ import {
isNotNull,
isNull,
like,
lte,
ne,
notInArray,
or,
@@ -104,7 +104,6 @@ type ChatFailedRunRetryOptions = {
lifecycleRuntimeFence: (delivery: DeliveryRow) => RetryRuntimeFence | null;
isShuttingDown: () => boolean;
wakeup: IssueAssignmentWakeupDeps["wakeup"];
staleProcessingMs: number;
};
export function createChatFailedRunRetry(db: Db, options: ChatFailedRunRetryOptions) {
@@ -115,7 +114,6 @@ export function createChatFailedRunRetry(db: Db, options: ChatFailedRunRetryOpti
lifecycleRuntimeFence,
isShuttingDown,
wakeup,
staleProcessingMs: DELIVERY_PROCESSING_STALE_MS,
} = options;
@@ -1265,6 +1263,7 @@ export function createChatFailedRunRetry(db: Db, options: ChatFailedRunRetryOpti
.limit(1);
if (busy || !input.initiatedByUserId) throw failedChatRetryDenied();
const now = new Date();
await notifyChatActionWork(tx, "failed_run_retry");
const [intent] = await tx
.insert(chatActions)
.values({
@@ -1508,36 +1507,42 @@ export function createChatFailedRunRetry(db: Db, options: ChatFailedRunRetryOpti
!["issued", "processing"].includes(candidate.status) ||
(candidate.status === "processing" &&
candidate.updatedAt.getTime() >
now.getTime() - DELIVERY_PROCESSING_STALE_MS) ||
now.getTime() - FAILED_RUN_RETRY_STALE_MS) ||
(typeof candidate.result?.retryAt === "string" &&
Date.parse(candidate.result.retryAt) > now.getTime())
)
return failedChatRetryReceipt(candidate);
const [claimed] = await db
.update(chatActions)
.set({ status: "processing", updatedAt: now })
.where(
and(
eq(chatActions.id, candidate.id),
eq(chatActions.status, candidate.status),
eq(chatActions.updatedAt, candidate.updatedAt),
),
)
.returning();
if (!claimed) return failedChatRetryReceipt(candidate);
const attemptCount = Number(candidate.result?.attemptCount ?? 0) + 1;
const settle = async (status: string, result: Record<string, unknown>) => {
const [current] = await db
const [claimed] = await db.transaction(async (tx) => {
await notifyChatActionWork(tx, "failed_run_retry");
return tx
.update(chatActions)
.set({ status, result, updatedAt: new Date() })
.set({ status: "processing", updatedAt: now })
.where(
and(
eq(chatActions.id, claimed.id),
eq(chatActions.status, "processing"),
eq(chatActions.updatedAt, now),
eq(chatActions.id, candidate.id),
eq(chatActions.status, candidate.status),
eq(chatActions.updatedAt, candidate.updatedAt),
),
)
.returning();
});
if (!claimed) return failedChatRetryReceipt(candidate);
const attemptCount = Number(candidate.result?.attemptCount ?? 0) + 1;
const settle = async (status: string, result: Record<string, unknown>) => {
const [current] = await db.transaction(async (tx) => {
await notifyChatActionWork(tx, "failed_run_retry");
return tx
.update(chatActions)
.set({ status, result, updatedAt: new Date() })
.where(
and(
eq(chatActions.id, claimed.id),
eq(chatActions.status, "processing"),
eq(chatActions.updatedAt, now),
),
)
.returning();
});
return failedChatRetryReceipt(current ?? claimed);
};
try {
@@ -1669,26 +1674,10 @@ export function createChatFailedRunRetry(db: Db, options: ChatFailedRunRetryOpti
async function processFailedChatRunRetries(limit = 20) {
if (isShuttingDown()) return 0;
const now = new Date();
const candidates = await db
.select({ id: chatActions.id })
.from(chatActions)
.where(
and(
eq(chatActions.kind, "failed_run_retry"),
or(
eq(chatActions.status, "issued"),
and(
eq(chatActions.status, "processing"),
lte(
chatActions.updatedAt,
new Date(now.getTime() - DELIVERY_PROCESSING_STALE_MS),
),
),
),
sql`(${chatActions.result}->>'retryAt' is null or (${chatActions.result}->>'retryAt')::timestamptz <= ${now.toISOString()}::timestamptz)`,
),
)
.where(dueChatAction("failed_run_retry"))
.orderBy(asc(chatActions.createdAt), asc(chatActions.id))
.limit(Math.max(1, Math.min(50, limit)));
for (const candidate of candidates)
@@ -13,6 +13,7 @@ export async function notifyChatPublicationWork(tx: object): Promise<void> {
export const CHAT_ACTION_QUEUES = {
provider_effect: DELIVERY_QUEUES.chatProviderEffects,
github_webhook_ingress: DELIVERY_QUEUES.chatGitHubIngress,
failed_run_retry: DELIVERY_QUEUES.chatFailedRunRetries,
slack_session_sync: DELIVERY_QUEUES.chatSessionSyncs,
slack_board_message: DELIVERY_QUEUES.chatBoardMessages,
slash_task_start: DELIVERY_QUEUES.chatTaskStarts,
@@ -8,6 +8,7 @@ export const DELIVERY_QUEUES = {
chatSessionStops: "chat-session-stops",
chatProviderEffects: "chat-provider-effects",
chatGitHubIngress: "chat-github-ingress",
chatFailedRunRetries: "chat-failed-run-retries",
chatSessionSyncs: "chat-session-syncs",
chatVerification: "chat-verification",
chatInbound: "chat-inbound",
+13
View File
@@ -2842,6 +2842,16 @@ const ISSUE_USER_PARTICIPATION_ACTIVITY_ACTIONS = [
"issue.work_product_updated",
] as const;
// Old read projections attributed this automatic cleanup to the reader. Keep
// the audit rows, but do not count them as participation or issue activity.
function automaticReadRecoveryCleanupCondition() {
return sql<boolean>`(
${activityLog.action} = 'issue.recovery_action_resolved'
AND COALESCE(${activityLog.details}->>'source', '') = 'source_revalidation'
AND COALESCE(${activityLog.details}->>'trigger', '') = 'read_projection'
)`;
}
function touchedByUserCondition(companyId: string, userId: string) {
return sql<boolean>`
(
@@ -2855,6 +2865,7 @@ function touchedByUserCondition(companyId: string, userId: string) {
AND ${activityLog.companyId} = ${companyId}
AND ${activityLog.actorType} = 'user'
AND ${activityLog.actorId} = ${userId}
AND NOT ${automaticReadRecoveryCleanupCondition()}
AND ${activityLog.action} IN (${sql.join(
ISSUE_USER_PARTICIPATION_ACTIVITY_ACTIONS.map(
(action) => sql`${action}`,
@@ -2960,6 +2971,7 @@ function issueLatestLogAtExpr(companyId: string) {
WHERE ${activityLog.companyId} = ${companyId}
AND ${activityLog.entityType} = 'issue'
AND ${activityLog.entityId} = ${issues.id}::text
AND NOT ${automaticReadRecoveryCleanupCondition()}
AND ${activityLog.action} NOT IN (${sql.join(
ISSUE_LOCAL_INBOX_ACTIVITY_ACTIONS.map((action) => sql`${action}`),
sql`, `,
@@ -5138,6 +5150,7 @@ async function lastActivityStatsForIssues(
eq(activityLog.companyId, companyId),
eq(activityLog.entityType, "issue"),
inArray(activityLog.entityId, issueIdChunk),
sql`NOT ${automaticReadRecoveryCleanupCondition()}`,
sql`${activityLog.action} NOT IN (${sql.join(
ISSUE_LOCAL_INBOX_ACTIVITY_ACTIONS.map(
(action) => sql`${action}`,
@@ -1,5 +1,6 @@
import { issueRecoveryActionService } from "../issue-recovery-actions.js";
import { nativeSha256 } from "./canonical.js";
import { NATIVE_WORKSPACE_OWNER_UNVERIFIED } from "./native-workspace-finalization-recovery-state.js";
import { randomUUID } from "node:crypto";
import os from "node:os";
import { and, eq, sql } from "drizzle-orm";
@@ -74,11 +75,18 @@ export async function withNativeWorkspaceFinalizationOwnership<T>(
const prior = readOwner(rawPrior);
if (rawPrior != null) {
const requireStopVerification = async () => {
if (run.issueId) await issueRecoveryActionService(input.db).upsertSourceScoped({
const fingerprint = nativeSha256({ runId: input.runId, owner: rawPrior });
const recovery = issueRecoveryActionService(input.db);
const existing = run.issueId ? await recovery.getActiveForIssue(input.companyId, run.issueId) : null;
// Rechecking the same physical owner is not a new recovery attempt.
if (existing?.cause === NATIVE_WORKSPACE_OWNER_UNVERIFIED && existing.fingerprint === fingerprint) {
return { acquired: false } as const;
}
if (run.issueId) await recovery.upsertSourceScoped({
companyId: input.companyId, sourceIssueId: run.issueId,
kind: "active_run_watchdog", ownerType: "board", returnOwnerAgentId: run.agentId,
cause: "native_workspace_finalization_owner_unverified",
fingerprint: nativeSha256({ runId: input.runId, owner: rawPrior }),
cause: NATIVE_WORKSPACE_OWNER_UNVERIFIED,
fingerprint,
evidence: { runId: input.runId, owner: rawPrior },
nextAction: "Verify the previous controller and its workspace-copyback processes have stopped, then release only this exact workspace owner receipt using doc/native-workspace-finalization-recovery.md. Resume workspace finalization without another provider turn.",
wakePolicy: null, maxAttempts: 1, supersedeOnIdentityChange: true,
@@ -0,0 +1,24 @@
import { and, eq } from "drizzle-orm";
import { heartbeatRuns, type Db } from "@paperclipai/db";
import { isUuidLike, type IssueRecoveryAction } from "@paperclipai/shared";
import { nativeSha256 } from "./canonical.js";
export const NATIVE_WORKSPACE_OWNER_UNVERIFIED = "native_workspace_finalization_owner_unverified";
/** A terminal task does not prove that an older controller's copyback stopped. */
export async function hasUnverifiedNativeWorkspaceFinalizationOwner(
db: Db,
action: Pick<IssueRecoveryAction, "companyId" | "sourceIssueId" | "cause" | "fingerprint" | "evidence">,
): Promise<boolean> {
if (action.cause !== NATIVE_WORKSPACE_OWNER_UNVERIFIED) return false;
const runId = action.evidence?.runId;
if (typeof runId !== "string" || !isUuidLike(runId)) return false;
const [run] = await db.select({ profile: heartbeatRuns.runnerProfileJson }).from(heartbeatRuns).where(and(
eq(heartbeatRuns.companyId, action.companyId),
eq(heartbeatRuns.id, runId),
eq(heartbeatRuns.nativeIssueId, action.sourceIssueId),
eq(heartbeatRuns.runtimeMode, "native"),
)).limit(1);
const owner = run?.profile?.nativeWorkspaceFinalizationOwner;
return owner != null && action.fingerprint === nativeSha256({ runId, owner });
}
@@ -193,6 +193,35 @@ describe("Paperclip Cloud self-host enrollment", () => {
expect(paperclipCloudConnectorEnrollmentStatus({
PAPERCLIP_CLOUD_CONNECTOR_BASE_URL: "https://my-staging.paperclip.app",
})).toMatchObject({ environment: "staging" });
expect(paperclipCloudConnectorEnrollmentStatus({
PAPERCLIP_CLOUD_CONNECTOR_BASE_URL: "https://test.paperclip.app",
})).toMatchObject({ environment: "test" });
});
it.each(["development", "staging", "production"])("rejects %s identity at the test broker", (environment) => {
expect(() => paperclipCloudConnectorEnrollmentStatus({
PAPERCLIP_CLOUD_CONNECTOR_BASE_URL: "https://test.paperclip.app",
PAPERCLIP_CLOUD_CONNECTOR_ENVIRONMENT: environment,
})).toThrow(/broker and environment do not match/);
});
it("persists test enrollment identity and signs requests for the test broker", async () => {
const origin = "https://tenant.test.paperclip.app";
const request = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const body = JSON.parse(String(init?.body));
if (String(input).endsWith("/enrollments")) {
expect(body).toMatchObject({ environment: "test", origin });
return Response.json({ enrollmentId: "enroll-test", verificationUrl: "https://test.paperclip.app/connections/enroll?id=enroll-test", expiresAt: new Date(Date.now() + 60_000).toISOString() }, { status: 201 });
}
const claims = JSON.parse(Buffer.from(body.request.split(".")[1], "base64url").toString("utf8"));
expect(claims).toMatchObject({ env: "test", aud: "https://test.paperclip.app/v1/connector/enrollment-claims" });
return Response.json({ id: loadPaperclipCloudConnectorIdentity()!.instanceId, environment: "test", origins: [origin] });
});
const env = { PAPERCLIP_CLOUD_CONNECTOR_BASE_URL: "https://test.paperclip.app" };
await startPaperclipCloudConnectorEnrollment({ origin, env, request: request as typeof fetch });
const pending = loadPaperclipCloudConnectorIdentity()!;
await completePaperclipCloudConnectorEnrollment({ enrollmentId: "enroll-test", approvalCode: "approval-code", state: pending.pending!.returnState, env, request: request as typeof fetch });
expect(paperclipCloudConnectorConfigFromEnv(env)).toMatchObject({ environment: "test", baseUrl: "https://test.paperclip.app", instanceId: pending.instanceId });
});
it("rotates a non-active identity instead of mixing enrollment targets", async () => {
@@ -17,7 +17,7 @@ const IDENTITY_VERSION = 1;
const ENROLLMENT_FILE = "paperclip-cloud-connector.json";
const REQUEST_TYPE = "paperclip-cloud-connector-request+jwt";
export type LocalConnectorEnvironment = "development" | "staging" | "production";
export type LocalConnectorEnvironment = "development" | "test" | "staging" | "production";
type PendingEnrollment = {
enrollmentId: string;
@@ -350,10 +350,13 @@ function connectorEnvironment(
? "production"
: host === "my-staging.paperclip.app"
? "staging"
: "development";
: host === "test.paperclip.app"
? "test"
: "development";
const value = env.PAPERCLIP_CLOUD_CONNECTOR_ENVIRONMENT?.trim() || fallback || inferred;
if (!isEnvironment(value)) throw new Error("Paperclip Cloud connector environment is invalid");
if ((host === "my.paperclip.app" && value !== "production")
|| (host === "test.paperclip.app" && value !== "test")
|| (host === "my-staging.paperclip.app" && value !== "staging")) {
throw new Error("Paperclip Cloud connector broker and environment do not match");
}
@@ -390,7 +393,7 @@ function hasManagedConnectorIdentityOverride(env: NodeJS.ProcessEnv): boolean {
}
function isEnvironment(value: unknown): value is LocalConnectorEnvironment {
return value === "development" || value === "staging" || value === "production";
return value === "development" || value === "test" || value === "staging" || value === "production";
}
function normalizeBrokerOrigin(value: string): string {
@@ -55,6 +55,24 @@ function config() {
}
describe("Paperclip Cloud connector", () => {
it("accepts a managed test identity and rejects other environments at its broker", () => {
const identity = config().config;
const env = {
PAPERCLIP_CLOUD_CONNECTOR_INSTANCE_ID: identity.instanceId,
PAPERCLIP_CLOUD_CONNECTOR_SIGN_PRIVATE_KEY: identity.signPrivateKey,
PAPERCLIP_CLOUD_CONNECTOR_SEAL_PRIVATE_KEY: identity.sealPrivateKey,
PAPERCLIP_CLOUD_CONNECTOR_BASE_URL: "https://test.paperclip.app",
PAPERCLIP_CLOUD_CONNECTOR_ENVIRONMENT: "test",
};
expect(paperclipCloudConnectorConfigFromEnv(env)).toMatchObject({ environment: "test", baseUrl: env.PAPERCLIP_CLOUD_CONNECTOR_BASE_URL });
for (const environment of ["development", "staging", "production"]) {
expect(() => paperclipCloudConnectorConfigFromEnv({ ...env, PAPERCLIP_CLOUD_CONNECTOR_ENVIRONMENT: environment })).toThrow(/broker and environment do not match/);
}
for (const baseUrl of ["https://my.paperclip.app", "https://my-staging.paperclip.app"]) {
expect(() => paperclipCloudConnectorConfigFromEnv({ ...env, PAPERCLIP_CLOUD_CONNECTOR_BASE_URL: baseUrl })).toThrow(/broker and environment do not match/);
}
});
async function rejection(response: Response) {
const connector = createPaperclipCloudConnector({
config: config().config,
@@ -32,7 +32,7 @@ export const GMAIL_CONNECTOR_SCOPES = [
] as const;
export { GOOGLE_WORKSPACE_CONNECTOR_PROFILES };
export type PaperclipCloudConnectorEnvironment = "development" | "staging" | "production";
export type PaperclipCloudConnectorEnvironment = "development" | "test" | "staging" | "production";
export type PaperclipCloudConnectorOperation = "status" | "session" | "claim" | "refresh" | "revoke" | "webhook-bind" | "event-lease" | "event-ack" | "github-app";
export type PaperclipCloudConnectorProfileId = GoogleWorkspaceConnectorProfileId | GitHubConnectorProfileId | AsanaConnectorProfileId | "slack.manager" | "github.bot";
export type PaperclipCloudConnectorProvider = "google" | "github" | "asana" | "slack";
@@ -232,7 +232,7 @@ export function paperclipCloudConnectorConfigFromEnv(
if (values.some((value) => !value)) {
throw new PaperclipCloudConnectorError("Paperclip Cloud connector configuration is incomplete", "CONNECTOR_CONFIG_INCOMPLETE");
}
if (environment !== "development" && environment !== "staging" && environment !== "production") {
if (environment !== "development" && environment !== "test" && environment !== "staging" && environment !== "production") {
throw new PaperclipCloudConnectorError("Paperclip Cloud connector environment is invalid", "CONNECTOR_CONFIG_INVALID");
}
const parsedBaseUrl = new URL(baseUrl);
@@ -244,6 +244,7 @@ export function paperclipCloudConnectorConfigFromEnv(
}
const brokerHost = parsedBaseUrl.hostname.toLowerCase();
if ((brokerHost === "my.paperclip.app" && environment !== "production")
|| (brokerHost === "test.paperclip.app" && environment !== "test")
|| (brokerHost === "my-staging.paperclip.app" && environment !== "staging")) {
throw new PaperclipCloudConnectorError(
"Paperclip Cloud connector broker and environment do not match",
+5
View File
@@ -93,6 +93,7 @@ import { emitAgentTaskRun } from "../agent-task-run-telemetry.js";
import { budgetService } from "../budgets.js";
import { unadmittedChatWakeupCondition } from "../durable-chat-wakeup.js";
import { issueRecoveryActionService } from "../issue-recovery-actions.js";
import { hasUnverifiedNativeWorkspaceFinalizationOwner } from "../native-runtime/native-workspace-finalization-recovery-state.js";
import {
legacyExecutionNeedsReconciliationWithEvidence,
terminalizeLegacyExecution,
@@ -3428,6 +3429,10 @@ export function recoveryService(
issueIds: [] as string[],
};
for (const { action, issue } of rows) {
if (await hasUnverifiedNativeWorkspaceFinalizationOwner(db, action)) {
result.skipped += 1;
continue;
}
const wakePolicy = parseObject(action.wakePolicy);
const wakePolicyType = readNonEmptyString(wakePolicy.type);
if (
+3
View File
@@ -131,6 +131,8 @@ If already checked out by you, returns normally. If owned by another agent: `409
**Step 6 — Understand context.** Prefer `GET /api/issues/{issueId}/heartbeat-context` first. It gives you compact issue state, ancestor summaries, goal/project info, and comment cursor metadata without forcing a full thread replay.
For attribution questions, read `GET /api/issues/{issueId}/activity`. Sandbox agents can use this issue-scoped route subject to the same company and issue-read authorization. Automatic recovery cleanup is system activity, not evidence that the person viewing a task participated in it.
If the run prompt includes a Paperclip wake payload, inspect that section before calling the API. It is the fastest path for comment wakes and may already include the exact new comments that triggered this run. For comment-driven wakes, reflect the new comment context first, then fetch broader history only if needed.
Use comments incrementally:
@@ -708,6 +710,7 @@ If `plan` already exists, first `GET /api/issues/{issueId}/documents/plan` and r
| Checkout task | `POST /api/issues/:issueId/checkout` |
| Get task + ancestors | `GET /api/issues/:issueId` |
| Compact heartbeat context | `GET /api/issues/:issueId/heartbeat-context` |
| Issue activity and attribution | `GET /api/issues/:issueId/activity` |
| Update task | `PATCH /api/issues/:issueId` (optional `comment` field) |
| Get comments / delta / single | `GET /api/issues/:issueId/comments[?after=:commentId&order=asc]` • `/comments/:commentId` |
| Add comment | `POST /api/issues/:issueId/comments` |
@@ -107,6 +107,12 @@ POST /api/companies/company-1/exports
}
```
**Issue Activity (`GET /api/issues/:issueId/activity`)**
Returns the issue's activity history, including actor, action, timestamp, and sanitized details. Sandbox agents can call this read-only route for issues their identity may read. Company boundaries and issue-read authorization still apply; this does not grant company-wide audit access.
An automatic stale-recovery cancellation has `action: "issue.recovery_action_resolved"` and `details.source: "source_revalidation"`. Page reads use `details.trigger: "read_projection"`. These cancellations belong to the system. Historical reader-attributed rows with that source and trigger do not count as inbox participation or recent issue activity.
### Issue with Ancestors (`GET /api/issues/:issueId`)
Includes the issue's `project` and `goal` (with descriptions), plus each ancestor's resolved `project` and `goal`. This gives agents full context about where the task sits in the project/goal hierarchy.
+1 -1
View File
@@ -106,7 +106,7 @@ export type CloudConnectorEnrollmentStatus = {
status: "not_configured" | "unenrolled" | "pending" | "active" | "suspended" | "unverified";
brokerBaseUrl: string;
instanceId: string | null;
environment: "development" | "staging" | "production";
environment: "development" | "test" | "staging" | "production";
origins: string[];
verificationUrl?: string;
expiresAt?: string;