From 3964ee1fcac4cdfb9981f1ddb82b3f4777189ebc Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 9 Nov 2026 00:42:25 -0600 Subject: [PATCH 01/21] fix(native): a governance pause Stop with no turn result stays a cancellation (#20186) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - A budget hard stop pauses the company; the pause's lifecycle Stop cancels the agents' running native turns > - When a Codex turn is interrupted this way, it ends without a semantic result > - The native executor treated that missing result as a retryable interruption, so it rewrote the cancelled run to `failed` and scheduled an exact resume > - The pause held that recovery, and Raise budget and resume continues only runs that are still cancelled, so the task stayed blocked with no new run > - This pull request makes the executor treat an acknowledged governance pause Stop like a person's Stop: the run stays cancelled > - The benefit is that Raise budget and resume continues the task (#20144), with no manual Retry ## Linked Issues or Issue Description Refs #20144 (resume continues budget-stopped work), #20143 (governance pause stops are not failures), #20113 **What happened?** Restart-chaos wave w-20261109T051242-s9802 (H1, seed 9802), task t03 (Codex, fault K2). The company budget crossed its hard limit at 05:19:39.9. The pause's lifecycle Stop ("Agent lifecycle stop requested") cancelled run 59e976bc at 05:19:40.05, and Codex acknowledged the interrupt. The turn ended without a semantic result, and at 05:19:41.27 the executor rewrote the run to `failed` / `native_session_interrupted`, with an agent-owned `active_run_watchdog` recovery (exact checkpoint resume). The task went to `blocked`. After `raise_budget_and_resume` at 05:20:20, no run started for 14 minutes. **Expected behavior** A pause's Stop is intentional. The run stays cancelled with its pause record, and resuming continues the task once. **Steps to reproduce** Pause a company for budget while a Codex native turn runs, so the Stop interrupts the turn before it records a result. Before this change, the run becomes `failed` with a scheduled exact resume instead of staying `cancelled`. ## What Changed - `native-session-executor.ts`: the no-semantic-result branch now also accepts an acknowledged governance pause Stop (`isGovernancePauseStop` with an acknowledged native Stop receipt) as an intentional stop. It settles the coordinator as `native_retry_cancelled` and raises `NativeCancellationPendingRecoveryError`, as it already does for a person's Stop and for a reassignment. The run is not rewritten to `failed`, and no recovery action is created. - The Stop's cancellation record is written by its fence before dispatch, so the check does not depend on whether the terminal write has landed. ## Verification - `native-session-executor.test.ts`: new cases for "Agent lifecycle stop requested" with a `pauseStop`, and for "Cancelled due to budget pause". Each was red before the fix (it reached the retryable-failure write) and is green now. A control case shows that a system lifecycle Stop without `pauseStop` (termination) still takes the retry path. - Full `native-session-executor.test.ts`: 1309 passed. Server typecheck (`typecheck-layers`, low-memory): no errors. - Not run: the H1 chaos rerun. Phase 3 runs it after merge. ## Risks - A governance pause Stop that lands during a turn no longer schedules an exact resume of that turn. The task continues on resume through a new Retry (#20144) instead. - Termination and cleanup use the same lifecycle Stop reason. They are excluded because only the pausing path records `pauseStop`. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, via Claude Code with tool use (shell, file edits, test execution). Extended reasoning enabled. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- .../native-session-executor.test.ts | 95 +++++++++++++++++++ .../native-runtime/native-session-executor.ts | 13 +++ 2 files changed, 108 insertions(+) diff --git a/server/src/services/native-runtime/native-session-executor.test.ts b/server/src/services/native-runtime/native-session-executor.test.ts index c1b67f1737f..d96451efb43 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -13556,6 +13556,101 @@ describe("native session bounded recovery", () => { expect(state.upsertRecoveryAction).not.toHaveBeenCalled(); }); + // H1 (restart chaos): a budget pause's lifecycle Stop interrupted a Codex + // turn, which ended without a semantic result. That Stop must stay a + // cancellation (resume continues it), never an interrupted-run retry the + // pause then strands. + it.each(["Agent lifecycle stop requested", "Cancelled due to budget pause"])("does not turn an acknowledged governance pause Stop (%s) into a retry", async (reason) => { + const updates: Array<{ table: unknown; values: Record }> = []; + const stop: Record = {}; + state.execute.mockReset().mockImplementationOnce(async () => { + Object.assign(stop, { + cancellation: { reason, source: "control_plane", expected: true, initiator: { type: "system" }, + recordedAt: new Date().toISOString() }, + ...(reason === "Agent lifecycle stop requested" ? { pauseStop: { holds: ["company_paused"], companyPauseReason: "budget" } } : {}), + nativeCancellation: { + schema: "paperclip.native-cancellation.v1", ...execution.binding, scope: "run", reasonCode: "cancellation_run_only", + dispatched: true, dispatchState: "acknowledged", intentAuditId: "intent", acknowledgementAuditId: "ack", + } }); + throw new Error("native_finalization_missing: session returned no semantic result"); + }); + state.upsertRecoveryAction.mockClear(); + await expect(executePaperclipNativeSession({ + db: leaseDb(execution, {}, stop, updates), execution, runnerInstanceId: "governance-stop", + })).rejects.toThrow("native_cancellation_pending_recovery"); + expect(updates.some(update => update.table === heartbeatRuns && update.values.status === "failed")).toBe(false); + expect(updates.some(update => update.table === nativeRunFinalizations && update.values.failureCode === "native_retry_cancelled")).toBe(true); + expect(state.upsertRecoveryAction).not.toHaveBeenCalled(); + }); + + it("waits for an in-flight governance pause Stop to save its acknowledgement before judging a missing result", async () => { + let releaseCleanup!: () => void, endTurn!: () => void, opened!: () => void; + const turnEnded = new Promise(resolve => { endTurn = resolve; }); + const sessionOpen = new Promise(resolve => { opened = resolve; }); + state.cancel.mockReset().mockReturnValue({ cleanup: new Promise(resolve => { releaseCleanup = resolve; }) }); + state.execute.mockReset().mockImplementationOnce(async (options) => { + await options.onSession({ cancel: state.cancel }); + opened(); + await turnEnded; + throw new Error("native_finalization_missing: session returned no semantic result"); + }); + state.upsertRecoveryAction.mockClear(); + const runResultJson: Record = {}; + const updates: Array<{ table: unknown; values: Record }> = []; + const outcome = executePaperclipNativeSession({ db: leaseDb(execution, {}, runResultJson, updates), execution, runnerInstanceId: "pause-stop-in-flight" }) + .catch(error => error); + // The pause's fence recorded its cancellation before dispatch. + const persistence = cancellationDb({ resultJson: { + cancellation: { reason: "Agent lifecycle stop requested", source: "control_plane", expected: true, + initiator: { type: "system" }, recordedAt: new Date().toISOString() }, + pauseStop: { holds: ["company_paused"], companyPauseReason: "budget" } } }); + const transaction = persistence.db.transaction.bind(persistence.db); + // The executor reads the same durable run row the Stop writes. + (persistence.db as unknown as { transaction: typeof transaction }).transaction = (async (operation: Parameters[0]) => { + const result = await transaction(operation); + Object.assign(runResultJson, persistence.getResultJson()); + return result; + }) as typeof transaction; + await sessionOpen; + const stopping = cancelNativeSession(execution.binding.runId, "Agent lifecycle stop requested", { db: persistence.db, scope: "run" }); + try { + await vi.waitFor(() => expect(state.cancel).toHaveBeenCalledOnce()); + // The interrupted turn ends before the Stop saves its acknowledgement. + endTurn(); + await new Promise(resolve => setTimeout(resolve, 20)); + expect(updates.some(update => update.table === heartbeatRuns && update.values.status === "failed")).toBe(false); + releaseCleanup(); + await stopping; + expect(await outcome).toMatchObject({ name: "NativeCancellationPendingRecoveryError" }); + expect(updates.some(update => update.table === heartbeatRuns && update.values.status === "failed")).toBe(false); + expect(updates.some(update => update.table === nativeRunFinalizations && update.values.failureCode === "native_retry_cancelled")).toBe(true); + expect(state.upsertRecoveryAction).not.toHaveBeenCalled(); + } finally { releaseCleanup(); endTurn(); await outcome; } + }); + + it("still retries a missing result after a system Stop that is not a governance pause", async () => { + const updates: Array<{ table: unknown; values: Record }> = []; + const stop: Record = {}; + state.execute.mockReset().mockImplementationOnce(async () => { + Object.assign(stop, { + cancellation: { reason: "Agent lifecycle stop requested", source: "control_plane", expected: true, + initiator: { type: "system" }, recordedAt: new Date().toISOString() }, + nativeCancellation: { + schema: "paperclip.native-cancellation.v1", ...execution.binding, scope: "run", reasonCode: "cancellation_run_only", + dispatched: true, dispatchState: "acknowledged", intentAuditId: "intent", acknowledgementAuditId: "ack", + } }); + throw new Error("native_finalization_missing: session returned no semantic result"); + }); + state.upsertRecoveryAction.mockReset().mockResolvedValue({}); + const service = vi.spyOn(issueServiceModule, "issueService").mockReturnValue({ update: vi.fn(async () => null) } as unknown as ReturnType); + try { + await expect(executePaperclipNativeSession({ + db: leaseDb(execution, {}, stop, updates), execution, runnerInstanceId: "termination-stop", + })).rejects.not.toThrow("native_cancellation_pending_recovery"); + expect(updates.some(update => update.table === nativeRunFinalizations && update.values.failureCode === "native_retry_cancelled")).toBe(false); + } finally { service.mockRestore(); } + }); + it.each(["pending", "acknowledged"])("preserves a %s Stop when cancellation wins before the first turn", async (dispatchState) => { const updates: Array<{ table: unknown; values: Record }> = []; const stop: Record = {}; diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index ed4ffb7cae8..a908267d1c0 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -1,4 +1,5 @@ import { remoteRecoveryLeaseMismatch } from "./remote-recovery-lease-mismatch.js"; +import { isGovernancePauseStop } from "../governance-pause-stop.js"; import { nativeLeaseHolder, notifyNativeTokenLeaseClaimed } from "./native-token-lease-settlement.js"; import { captureRemoteNativeProviderArtifacts } from "./native-provider-artifact-capture.js"; import { NativeSessionDiagnostics, nativeSessionDiagnostics, type NativeDiagnosticLease } from "./native-session-diagnostics.js"; @@ -11581,9 +11582,14 @@ async function executePaperclipNativeSessionWithinScope( } trace.activate(taskSettleScope); liveQuestions.close(); + const activeAtFailure = activeNativeSessions.get(input.execution.binding.runId); activeNativeSessions.delete(input.execution.binding.runId); clearSteeringDeliveries(input.execution.binding.runId); clearNativeRuntimeRequestResolutions(input.execution.binding.runId); + // A Stop interrupts the provider before it saves its acknowledgement. + // When the interrupted turn ends first, wait for that Stop to settle, so + // the checks below judge its saved receipt, not a failure. + if (activeAtFailure?.cancelRequested) await activeAtFailure.cancellationSettled?.catch(() => undefined); // Stop before paperclip_finish is normal. Bounded provider teardown has // finished; a missing result must not overwrite cancellation or trigger // another turn to perform completion bookkeeping. @@ -11608,7 +11614,14 @@ async function executePaperclipNativeSessionWithinScope( stopIntent.scope === "run" && typeof stopIntent.intentAuditId === "string" && stopIntent.intentAuditId.length > 0 && ["pending", "acknowledged"].includes(String(stopIntent.dispatchState)); + // A governance pause (budget, company or agent pause) Stop is the same + // kind of intentional stop. The fence records its cancellation before + // dispatch, so judge it whether or not the terminal write has landed; + // resume continues it (budget-resume-continuation.ts). + const governancePauseStop = !!stoppedRun && hasAcknowledgedNativeRunStopReceipt(stoppedRun) + && isGovernancePauseStop({ status: "cancelled", resultJson: stoppedRun.resultJson }); if (stoppedRun && (pendingStartupStop || hasAcknowledgedNativeStopIntent(stoppedRun) || hasAcknowledgedNativeReassignmentStopIntent(stoppedRun) + || governancePauseStop || (error instanceof NativeCancellationPendingRecoveryError && hasAcknowledgedNativeRunStopReceipt(stoppedRun)))) { const [settled] = await writeExecutionControl(input.db, [DELIVERY_QUEUES.executionFinalization, DELIVERY_QUEUES.accounting], tx => tx.update(nativeRunFinalizations).set({ phase: "terminal_failure", failureCode: "native_retry_cancelled", nextAttemptAt: null, From b4a77b8915c80a39995dd390d0fd49baf8b72b0e Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Sun, 8 Nov 2026 22:49:43 -0800 Subject: [PATCH 02/21] perf(plugins): read runtime identity in one statement without a row lock (#20192) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Lifecycle plugins read the agent runtime identity through the plugin host > - The identity read used the full-state path. That path locks the agent row and runs 11 statements > - Plugins call the identity read many times as a freshness fence, so each round trip adds to the run latency > - This pull request makes the identity read one statement on the request pool, without a row lock, and loads instance secrets with their versions in one statement > - The benefit is fewer round trips per read (11 to 4) and no wait behind writers that hold the agent row ## Linked Issues or Issue Description **What existing behavior does this improve?** The plugin host read of the agent runtime identity (`pluginAgentRuntimeIdentity`). **Subsystem affected** Server: plugin agent runtime state and instance secrets (`server/src/services/plugin-agent-runtime-state.ts`, `server/src/services/secrets.ts`). **Current behavior** The identity read shares the full-state transaction. It opens a transaction on the file-storage pool and locks the agent row with `FOR UPDATE`. It reads the plugin, the company settings and the latest lifecycle event in separate statements. Then it reloads each instance secret and its version one at a time. One read costs 11 statements. It also waits behind any writer that holds the agent row, for example managed file publication. **Proposed behavior** The identity read is one statement on the request pool. The statement joins the plugin and the company settings, and reads the latest lifecycle event id in a subquery. The agent fields, the authorization inputs and the revision come from one snapshot. Instance secrets load together with their latest versions in one statement. One read costs 4 statements. **Reason and benefit** A resumed agent run makes about 7 identity reads on its critical path in a hosted deployment. With a 4 ms round trip, the modeled saving is about 330 ms per run at P50. ## What Changed - `pluginAgentRuntimeIdentity` reads the agent, plugin, company settings and latest lifecycle event id in one statement on the request pool. It does not take a row lock. - The authorization check moves into `assertOwningLifecyclePlugin`. The identity path and the full-state path both use it, so both fail with the same forbidden error. - A missing agent still returns `null` before authorization runs. - The full-state path (`readState`) keeps its transaction and its `FOR UPDATE` lock. It no longer has an identity-only branch. - `instanceSecrets.read` loads each secret with its latest version through a left join in one statement. `resolveSecretValueInternal` accepts these preloaded rows and skips the reload when the secret and the version match. It still reloads when they do not match. - The two audit writes per resolved secret run concurrently, as before. - Tests: - `agent-runtime-hooks.test.ts` compares the identity read with the full read across revisions, lifecycle changes and secret changes, and across every forbidden case. It confirms that forbidden reads record no secret access. It confirms that the identity read does not wait for a held agent row lock. - `agent-instance-secrets.test.ts` confirms that a read of rotated secrets uses one select and returns the latest values. It confirms that each read records an access event. It confirms that a revoked version fails with `version_inactive` and a missing version fails with `version_missing`. ### Why dropping the lock is safe The lock exists for the full-state path. That path projects instruction files under the agent lock. The identity path does no projection. It also released the lock before it returned, so no caller could rely on the lock. ### Unchanged behavior - Authorization: the same plugin, settings, capability and required-plugin checks, with the same forbidden error. - Decryption: the same provider resolution from the version material. - Scope checks and version status checks: the same checks on the same rows. - Audit: the same `lastResolvedAt` update and access event for each resolved secret. ## Verification - `pnpm --filter @paperclipai/shared --filter @paperclipai/plugin-sdk build` - `pnpm --dir server run typecheck`: pass. - Targeted tests: pass (6 files, 174 tests). ``` pnpm --dir server exec vitest run \ src/__tests__/agent-runtime-hooks.test.ts \ src/__tests__/agent-instance-secrets.test.ts \ src/__tests__/project-instance-secrets.test.ts \ src/__tests__/secrets-service.test.ts \ src/__tests__/agent-runtime-assignments.test.ts \ src/__tests__/agent-runtime-skills.test.ts ``` - CI runs the full suite. ### Benchmark A local benchmark ran against the embedded test Postgres. A delay proxy simulated network round trips. | Condition | Before | After | |---|---|---| | Statements per read | 11 | 4 | | P50, no added delay | 2.38 ms | 1.47 ms | | P50, 4 ms round trip | 104.0 ms | 32.2 ms | | P50, 4 ms round trip, prepared statements | 51.9 ms | 16.3 ms | The new read costs about 0.3× the old read in round trips. In a hosted deployment, a resumed agent run makes about 7 of these reads on its critical path. The modeled saving is about 330 ms per run at P50. ## Risks - Low risk. - The identity read no longer runs in the same snapshot as a concurrent writer that holds the agent row. It can return the state from before that write commits. The `revision` field still comes from the same snapshot as the agent fields, so callers that use it as a freshness fence see a consistent pair. - The identity read now runs on the request pool, not the file-storage pool. It adds one short query to the request pool and removes one transaction from the file-storage pool. - The preloaded secret path applies only when the secret and the version match the rows from the join. Any other case falls back to the existing reload. - No migration. No API change. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, through Claude Code with tool use and code execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- .../__tests__/agent-instance-secrets.test.ts | 29 ++++++- .../src/__tests__/agent-runtime-hooks.test.ts | 81 ++++++++++++++++++- .../services/plugin-agent-runtime-state.ts | 46 +++++++---- server/src/services/secrets.ts | 20 +++-- 4 files changed, 147 insertions(+), 29 deletions(-) diff --git a/server/src/__tests__/agent-instance-secrets.test.ts b/server/src/__tests__/agent-instance-secrets.test.ts index 2ccadbac7d2..03d2a181d4e 100644 --- a/server/src/__tests__/agent-instance-secrets.test.ts +++ b/server/src/__tests__/agent-instance-secrets.test.ts @@ -1,7 +1,7 @@ import { randomBytes, randomUUID } from "node:crypto"; -import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; -import { eq, sql } from "drizzle-orm"; -import { agents, companies, companySecrets, companySecretVersions, activityLog, plugins, toolApplications, toolConnections, createDb, type Db } from "@paperclipai/db"; +import { afterAll, beforeAll, describe, expect, it, onTestFinished, vi } from "vitest"; +import { and, eq, sql } from "drizzle-orm"; +import { agents, companies, companySecrets, companySecretVersions, activityLog, plugins, secretAccessEvents, toolApplications, toolConnections, createDb, type Db } from "@paperclipai/db"; import type { AgentLifecycleRequest } from "@paperclipai/shared"; import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; import { pluginAgentRuntimeIdentity } from "../services/plugin-agent-runtime-state.js"; @@ -118,6 +118,29 @@ const support = await getEmbeddedPostgresTestSupport(); await expect(secrets.createBinding({ companyId, secretId: stored.id, targetType: "agent", targetId: f.agent.id, configPath: "env.RUNTIME" })).rejects.toThrow("company-scoped"); }); + it("reads each secret with its latest version in one statement, auditing every read and keeping version failures", async () => { + const f = await fixture(); onTestFinished(async () => { await db.delete(agents).where(eq(agents.id, f.agent.id)); }); + await f.service.apply(f.request, pluginId, { runtime, launcher: "launcher value" }); + await f.service.apply(f.request, pluginId, { launcher: "rotated launcher" }); + const [stored] = await db.select().from(companySecrets).where(and(eq(companySecrets.ownerAgentId, f.agent.id), eq(companySecrets.name, "launcher"))); + expect(stored.latestVersion).toBe(2); + const audit = () => db.select().from(secretAccessEvents).where(eq(secretAccessEvents.secretId, stored.id)).orderBy(secretAccessEvents.createdAt); + const selects = vi.spyOn(db, "select"); + expect(await f.service.read(f.scope)).toEqual({ runtime, launcher: "rotated launcher" }); + expect(selects).toHaveBeenCalledTimes(1); + selects.mockRestore(); + expect((await audit()).at(-1)).toMatchObject({ outcome: "success", version: 2, consumerType: "plugin_worker", consumerId: pluginId, pluginId, actorType: "plugin" }); + const audited = (await audit()).length; + expect(await f.service.read(f.scope)).toEqual({ runtime, launcher: "rotated launcher" }); + expect(await audit()).toHaveLength(audited + 1); + await db.update(companySecretVersions).set({ revokedAt: new Date() }).where(and(eq(companySecretVersions.secretId, stored.id), eq(companySecretVersions.version, 2))); + await expect(f.service.read(f.scope)).rejects.toThrow("Agent instance secret unavailable"); + expect((await audit()).at(-1)).toMatchObject({ outcome: "failure", errorCode: "version_inactive", version: 2 }); + await db.delete(companySecretVersions).where(and(eq(companySecretVersions.secretId, stored.id), eq(companySecretVersions.version, 2))); + await expect(f.service.read(f.scope)).rejects.toThrow("Agent instance secret unavailable"); + expect((await audit()).at(-1)).toMatchObject({ outcome: "failure", errorCode: "version_missing", version: 2 }); + }); + it("fences stale writes and removals, and cascades credentials when the owning agent is deleted", async () => { const f = await fixture(); await f.service.apply(f.request, pluginId, { runtime }); for (const patch of [{ version: 2 }, { operationId: randomUUID() }, { phase: "cleaning_up" }, { companyId: randomUUID() }, { agentId: randomUUID() }]) { diff --git a/server/src/__tests__/agent-runtime-hooks.test.ts b/server/src/__tests__/agent-runtime-hooks.test.ts index 9ed76d192ab..0ec53c4f061 100644 --- a/server/src/__tests__/agent-runtime-hooks.test.ts +++ b/server/src/__tests__/agent-runtime-hooks.test.ts @@ -1,12 +1,12 @@ import * as databaseModule from "@paperclipai/db"; -import { randomUUID } from "node:crypto"; +import { randomBytes, randomUUID } from "node:crypto"; import { createServer, connect, type Socket } from "node:net"; import { EventEmitter } from "node:events"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { and, eq, sql } from "drizzle-orm"; -import { activityLog, agents, companies, applyPendingMigrations, closeFileStorageDb, createDb, pluginCompanySettings, pluginConfig, plugins, resourceLifecycleEvents, type Db } from "@paperclipai/db"; +import { activityLog, agents, companies, applyPendingMigrations, closeFileStorageDb, createDb, pluginCompanySettings, pluginConfig, plugins, resourceLifecycleEvents, secretAccessEvents, type Db } from "@paperclipai/db"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; import * as instructionModule from "../services/agent-instructions.js"; @@ -18,6 +18,7 @@ import { idleWorkSnapshot } from "../services/task-admission.js"; import { resolveManagedInstructionsRoot } from "../services/agent-instructions.js"; import { agentFileStore } from "../services/agent-file-store.js"; import { agentInstructionRevisionService } from "../services/agent-instruction-revisions.js"; +import { secretService } from "../services/secrets.js"; const externalUrl = process.env.PAPERCLIP_AGENT_RUNTIME_HOOKS_TEST_DATABASE_URL; const expectedDatabase = process.env.PAPERCLIP_AGENT_RUNTIME_HOOKS_EXPECTED_DATABASE; @@ -221,6 +222,82 @@ describeDatabase("agent runtime synchronization hooks", () => { await expect(pluginAgentRuntimeIdentity(db, pluginId, companyId, agentId)).rejects.toMatchObject({ status: 403 }); }); + it("returns the same identity, revision, secrets and failures as the full runtime read", async () => { + vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", randomBytes(32).toString("base64")); + const scope = { companyId, agentId, pluginId }; + const outcome = (read: Promise) => read.then(value => value, (error: { status?: number }) => ({ status: error.status })); + const same = async (company = companyId, agent = agentId, plugin = pluginId) => { + const state = await outcome(pluginAgentRuntimeState(db, plugin, company, agent)) as Awaited> | { status: number }; + const identity = await outcome(pluginAgentRuntimeIdentity(db, plugin, company, agent)); + expect(identity).toEqual(state && "revision" in state ? { revision: state.revision, instanceSecrets: state.instanceSecrets, agent: { + id: state.agent.id, companyId: state.agent.companyId, status: state.agent.status, lifecycleState: state.agent.lifecycleState, lifecycleVersion: state.agent.lifecycleVersion } } : state); + return identity as Awaited>; + }; + await secretService(db).agentInstance.set(scope, "launcher", "first launcher"); + const first = await same(); + expect(first).toEqual({ revision: String((await events()).at(-1)!.id), instanceSecrets: { launcher: "first launcher" }, + agent: { id: agentId, companyId, status: "idle", lifecycleState: "ready", lifecycleVersion: 0 } }); + await db.insert(resourceLifecycleEvents).values([{ companyId, resourceType: "agent", resourceId: agentId, action: "pause" }, + { companyId, resourceType: "agent_runtime", resourceId: randomUUID(), action: "update" }]); + expect((await same())!.revision).toBe(first!.revision); + const [latest] = await db.insert(resourceLifecycleEvents).values({ companyId, resourceType: "agent_runtime", resourceId: agentId, action: "update" }).returning(); + expect((await same())!.revision).toBe(String(latest.id)); + await db.update(agents).set({ lifecycleState: "preparing", lifecycleVersion: 3 }).where(eq(agents.id, agentId)); + expect((await same())!.agent).toMatchObject({ lifecycleState: "preparing", lifecycleVersion: 3 }); + await secretService(db).agentInstance.set(scope, "launcher", "second launcher"); + expect((await same())!.instanceSecrets).toEqual({ launcher: "second launcher" }); + await secretService(db).agentInstance.set(scope, "launcher", null); + expect((await same())!.instanceSecrets).toEqual({}); + await secretService(db).agentInstance.set(scope, "launcher", "third launcher"); + const accessEvents = async () => (await db.select().from(secretAccessEvents).where(eq(secretAccessEvents.companyId, companyId))).length; + const audited = await accessEvents(); + expect(await same(randomUUID())).toBeNull(); + expect(await same(companyId, randomUUID())).toBeNull(); + expect(await same(companyId, agentId, randomUUID())).toEqual({ status: 403 }); + const manifest = { resourceLifecycle: ["agent_runtime"], capabilities: ["events.subscribe", "agents.lifecycle.manage"] }; + const denials: Array<[() => Promise, () => Promise]> = [ + [() => db.update(plugins).set({ status: "error" }).where(eq(plugins.id, pluginId)), () => db.update(plugins).set({ status: "ready" }).where(eq(plugins.id, pluginId))], + [() => db.update(plugins).set({ manifestJson: { ...manifest, capabilities: ["events.subscribe"] } as never }).where(eq(plugins.id, pluginId)), + () => db.update(plugins).set({ manifestJson: manifest as never }).where(eq(plugins.id, pluginId))], + [() => db.update(plugins).set({ manifestJson: { ...manifest, resourceLifecycle: [] } as never }).where(eq(plugins.id, pluginId)), + () => db.update(plugins).set({ manifestJson: manifest as never }).where(eq(plugins.id, pluginId))], + [() => db.insert(pluginCompanySettings).values({ companyId, pluginId, enabled: false }), () => db.delete(pluginCompanySettings).where(eq(pluginCompanySettings.pluginId, pluginId))], + [() => db.update(agents).set({ lifecycleRequiredPluginIds: [randomUUID()] }).where(eq(agents.id, agentId)), + () => db.update(agents).set({ lifecycleRequiredPluginIds: [pluginId] }).where(eq(agents.id, agentId))], + [() => db.update(agents).set({ lifecycleRequiredPluginIds: null }).where(eq(agents.id, agentId)), + () => db.update(agents).set({ lifecycleRequiredPluginIds: [pluginId] }).where(eq(agents.id, agentId))], + ]; + for (const [deny, restore] of denials) { + await deny(); + expect(await same()).toEqual({ status: 403 }); + await restore(); + } + expect(await accessEvents()).toBe(audited); + await db.insert(pluginCompanySettings).values({ companyId, pluginId, enabled: true }); + expect((await same())!.instanceSecrets).toEqual({ launcher: "third launcher" }); + }); + + it("reads runtime identity from a snapshot without waiting for an agent row lock", async () => { + let locked!: () => void; + let release!: () => void; + const held = new Promise(resolve => { locked = resolve; }); + const writer = db.transaction(async tx => { + await tx.select().from(agents).where(eq(agents.id, agentId)).for("update"); + await tx.update(agents).set({ lifecycleState: "pausing", lifecycleVersion: 5 }).where(eq(agents.id, agentId)); + await new Promise(resolve => { release = resolve; locked(); }); + }); + await held; + try { + const read = pluginAgentRuntimeIdentity(db, pluginId, companyId, agentId); + const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error("identity read waited for the agent lock")), 5_000)); + expect((await Promise.race([read, timeout]))!.agent).toMatchObject({ lifecycleState: "ready", lifecycleVersion: 0 }); + } finally { + release(); + await writer; + } + expect((await pluginAgentRuntimeIdentity(db, pluginId, companyId, agentId))!.agent).toMatchObject({ lifecycleState: "pausing", lifecycleVersion: 5 }); + }); + it("uses the company reporting graph for runtime health data", async () => { const [manager] = await db.insert(agents).values({ companyId, name: "Manager" }).returning(); await db.update(agents).set({ reportsTo: manager.id }).where(eq(agents.id, agentId)); diff --git a/server/src/services/plugin-agent-runtime-state.ts b/server/src/services/plugin-agent-runtime-state.ts index 1478ba98399..57ee68cea44 100644 --- a/server/src/services/plugin-agent-runtime-state.ts +++ b/server/src/services/plugin-agent-runtime-state.ts @@ -1,4 +1,4 @@ -import { and, desc, eq } from "drizzle-orm"; +import { and, desc, eq, sql } from "drizzle-orm"; import { agents, environments, plugins, pluginCompanySettings, resourceLifecycleEvents, fileStorageDb, type Db } from "@paperclipai/db"; import type { PluginAgentRuntimeState, PluginAgentRuntimeIdentity } from "@paperclipai/plugin-sdk"; import { agentRecordQueries } from "../lib/agent-records.js"; @@ -13,28 +13,25 @@ import { instanceSettingsService } from "./instance-settings.js"; import { agentInstanceSecretService } from "./agent-instance-secrets.js"; import { environmentSkillConfig } from "./environment-skills.js"; -async function readState(db: Db, pluginId: string, companyId: string, agentId: string, identityOnly: true): Promise; -async function readState(db: Db, pluginId: string, companyId: string, agentId: string, identityOnly?: false): Promise | null>; -async function readState(db: Db, pluginId: string, companyId: string, agentId: string, identityOnly = false): Promise | null> { +function assertOwningLifecyclePlugin(plugin: Pick | null | undefined, + settings: Pick | null | undefined, requiredPluginIds: string[] | null | undefined, pluginId: string) { + if (plugin?.status !== "ready" || settings?.enabled === false || !plugin?.manifestJson.capabilities?.includes("agents.lifecycle.manage") + || !plugin.manifestJson.resourceLifecycle?.includes("agent_runtime") || !requiredPluginIds?.includes(pluginId)) { + throw forbidden("Agent runtime state requires the owning lifecycle plugin"); + } +} + +async function readState(db: Db, pluginId: string, companyId: string, agentId: string): Promise | null> { return fileStorageDb(db).transaction(async tx => { const [agent] = await tx.select().from(agents) .where(and(eq(agents.companyId, companyId), eq(agents.id, agentId))).for("update"); if (!agent) return null; const [plugin] = await tx.select().from(plugins).where(eq(plugins.id, pluginId)); const [settings] = await tx.select().from(pluginCompanySettings).where(and(eq(pluginCompanySettings.companyId, companyId), eq(pluginCompanySettings.pluginId, pluginId))); - if (plugin?.status !== "ready" || settings?.enabled === false || !plugin?.manifestJson.capabilities?.includes("agents.lifecycle.manage") - || !plugin.manifestJson.resourceLifecycle?.includes("agent_runtime") || !agent.lifecycleRequiredPluginIds?.includes(pluginId)) { - throw forbidden("Agent runtime state requires the owning lifecycle plugin"); - } + assertOwningLifecyclePlugin(plugin, settings, agent.lifecycleRequiredPluginIds, pluginId); const [latest] = await tx.select({ id: resourceLifecycleEvents.id }).from(resourceLifecycleEvents) .where(and(eq(resourceLifecycleEvents.companyId, companyId), eq(resourceLifecycleEvents.resourceType, "agent_runtime"), eq(resourceLifecycleEvents.resourceId, agentId))) .orderBy(desc(resourceLifecycleEvents.id)).limit(1); - if (identityOnly) return { - revision: String(latest?.id ?? 0), - agent: { id: agent.id, companyId: agent.companyId, status: agent.status as PluginAgentRuntimeIdentity["agent"]["status"], - lifecycleState: agent.lifecycleState, lifecycleVersion: agent.lifecycleVersion }, - instanceSecrets: await agentInstanceSecretService(tx as unknown as Db).read({ companyId, agentId, pluginId }), - }; const instructions = await agentInstructionsService(tx as unknown as Db).getBundleLocation(agent); const records = agentRecordQueries(tx as unknown as Db); const companyAgents = await records.listCompanyAgentEligibilityRows(companyId); @@ -46,9 +43,26 @@ async function readState(db: Db, pluginId: string, companyId: string, agentId: s }); } - export async function pluginAgentRuntimeIdentity(db: Db, pluginId: string, companyId: string, agentId: string): Promise { - return readState(db, pluginId, companyId, agentId, true); + const [row] = await db.select({ + id: agents.id, companyId: agents.companyId, status: agents.status, lifecycleState: agents.lifecycleState, + lifecycleVersion: agents.lifecycleVersion, requiredPluginIds: agents.lifecycleRequiredPluginIds, + plugin: { status: plugins.status, manifestJson: plugins.manifestJson }, settings: { enabled: pluginCompanySettings.enabled }, + revision: sql`(${db.select({ id: resourceLifecycleEvents.id }).from(resourceLifecycleEvents) + .where(and(eq(resourceLifecycleEvents.companyId, companyId), eq(resourceLifecycleEvents.resourceType, "agent_runtime"), eq(resourceLifecycleEvents.resourceId, agentId))) + .orderBy(desc(resourceLifecycleEvents.id)).limit(1)})`.mapWith(resourceLifecycleEvents.id), + }).from(agents) + .leftJoin(plugins, eq(plugins.id, pluginId)) + .leftJoin(pluginCompanySettings, and(eq(pluginCompanySettings.companyId, companyId), eq(pluginCompanySettings.pluginId, pluginId))) + .where(and(eq(agents.companyId, companyId), eq(agents.id, agentId))); + if (!row) return null; + assertOwningLifecyclePlugin(row.plugin, row.settings, row.requiredPluginIds, pluginId); + return { + revision: String(row.revision ?? 0), + agent: { id: row.id, companyId: row.companyId, status: row.status as PluginAgentRuntimeIdentity["agent"]["status"], + lifecycleState: row.lifecycleState, lifecycleVersion: row.lifecycleVersion }, + instanceSecrets: await agentInstanceSecretService(db).read({ companyId, agentId, pluginId }), + }; } export async function pluginAgentRuntimeState(db: Db, pluginId: string, companyId: string, agentId: string, environmentId?: string, installEnvironmentSkills = false): Promise { diff --git a/server/src/services/secrets.ts b/server/src/services/secrets.ts index 2730f1bc7c9..730c15c9059 100644 --- a/server/src/services/secrets.ts +++ b/server/src/services/secrets.ts @@ -1359,10 +1359,11 @@ export function secretService(db: Db | DbTransaction) { version: number | "latest", options?: SecretResolutionOptions, readOnlyEnvironmentInspection = false, + preloaded?: { secret: typeof companySecrets.$inferSelect; version: typeof companySecretVersions.$inferSelect | null }, ): Promise { const bindingContext = options?.bindingContext; const accessContext = options?.accessContext ?? bindingContext; - const secret = await getById(secretId); + const secret = preloaded && preloaded.secret.id === secretId ? preloaded.secret : await getById(secretId); if (!secret) throw notFound("Secret not found"); if (secret.companyId !== companyId) throw unprocessable("Secret must belong to same company"); const instanceOwned = secret.scope === "instance" && options?.instanceOwner && @@ -1384,7 +1385,8 @@ export function secretService(db: Db | DbTransaction) { throw unprocessable("Secret is not active", { code: "secret_inactive" }); } const binding = await assertBindingContext(companyId, secret.id, bindingContext); - const versionRow = await getSecretVersion(secret.id, resolvedVersion); + const versionRow = preloaded?.secret === secret && preloaded.version?.version === resolvedVersion + ? preloaded.version : await getSecretVersion(secret.id, resolvedVersion); if (!versionRow) throw new HttpError(404, "Secret version not found", { code: "version_missing" }); if (versionRow.status === "disabled" || versionRow.status === "destroyed" || versionRow.revokedAt) { throw unprocessable("Secret version is not active", { code: "version_inactive" }); @@ -3513,16 +3515,18 @@ export function secretService(db: Db | DbTransaction) { const instanceSecrets = { async read(scope: InstanceSecretScope): Promise> { - const rows = await db.select().from(companySecrets).where(and(eq(companySecrets.companyId, scope.companyId), - eq(companySecrets.scope, "instance"), scope.agentId !== undefined ? eq(companySecrets.ownerAgentId, scope.agentId) : eq(companySecrets.ownerProjectId, scope.projectId), - eq(companySecrets.ownerPluginId, scope.pluginId), ne(companySecrets.status, "deleted"))); + const rows = await db.select({ secret: companySecrets, version: companySecretVersions }).from(companySecrets) + .leftJoin(companySecretVersions, and(eq(companySecretVersions.secretId, companySecrets.id), eq(companySecretVersions.version, companySecrets.latestVersion))) + .where(and(eq(companySecrets.companyId, scope.companyId), + eq(companySecrets.scope, "instance"), scope.agentId !== undefined ? eq(companySecrets.ownerAgentId, scope.agentId) : eq(companySecrets.ownerProjectId, scope.projectId), + eq(companySecrets.ownerPluginId, scope.pluginId), ne(companySecrets.status, "deleted"))); const values: Record = Object.create(null); for (const row of rows) { - const resolved = await resolveSecretValueInternal(scope.companyId, row.id, "latest", { + const resolved = await resolveSecretValueInternal(scope.companyId, row.secret.id, "latest", { instanceOwner: scope, accessContext: { consumerType: "plugin_worker", consumerId: scope.pluginId, actorType: "plugin", actorId: scope.pluginId, pluginId: scope.pluginId }, - }); - values[row.name] = resolved.value; + }, false, row); + values[row.secret.name] = resolved.value; } return values; }, From 9046884811a7b06131ec60ce9c9f0255c8e84465 Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Sun, 8 Nov 2026 22:56:08 -0800 Subject: [PATCH 03/21] perf(native-runtime): claim a parked Runner while the follow-up run prepares (#20195) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Paperclip Runner executes native agent runs. A host can park an idle Runner (release it and freeze it) between runs, and resume it for the next run on the same task. > - A follow-up run that reuses a parked Runner must claim it first. The claim is a bootstrap ticket, a checkpoint flush, and the plugin resume that thaws the Runner. > - Core made the claim only inside `attachRun`, after all of its per-run preparation. The claim does not depend on that preparation, so the two ran in series. > - This pull request starts the claim while Core still prepares the run. `attachRun` then waits for the claim that is already in progress. > - The benefit is a shorter start for every follow-up run on a parked Runner. The model below gives about 285 ms per run at P50. ## Linked Issues or Issue Description No public issue exists. The issue is described inline. **What happened?** A follow-up run that reuses a parked Runner claims the Runner only in `attachRun`. Core calls `attachRun` after backend setup: runner environment, assigned MCP tools, tool authority and the plugin task owner. The claim waits for all of this, but it uses none of it. **Expected behavior** The claim runs at the same time as backend setup. Everything that needs the new run's tool authority stays at attach. **Steps to reproduce** 1. Run a Codex or Claude task on a host that parks Runners between runs. 2. Let the run finish and the Runner park. 3. Send a follow-up. Before this change, the plugin resume starts only after backend setup ends. **Paperclip version or commit** `master` at 7086019d4b. **Deployment mode** Hosted, with parked Runner tasks. ## What Changed - **Transport** (`runnerd-codex-transport.ts`): - New `beginRunAttachment({ runId })`. It runs the attach admission checks, then starts the existing `#claimReleasedRunner` as the held attachment operation. It returns `false` and does nothing when the Runner is not released and confirmed, a claim already exists, or admission fails. - The admission checks move into `#assertRunAttachmentAdmission`. `beginRunAttachment` runs them first, and `attachRun` runs them again. - The next `attachRun` for the same run waits for the early claim. An `attachRun` for a different run, `release` and `turn/start` still fail with `native_runner_run_attachment_in_progress` while the claim is held. - After an early claim, `attachRun` fails with `native_runner_resume_reconciliation_expired` before it registers if the claim deadline has passed. - **Pass-throughs:** `HarnessNativeSession`, `CodexHarnessSession` and the session, harness and transport contracts get an optional `beginRunAttachment`. Each layer does the same identity, protocol-integrity and busy checks that its attach does. - **Core** (`native-session-executor.ts`): - The executor records when the in-memory warm entry it reuses was parked (`released`). - `createRunnerdBackend` gets `beginRetainedRunAttachment`. Core gives it only for that parked in-memory warm entry. - The plugin task owner preparation moves into `preparePluginTaskOwner`. On the early path, Core prepares and rebinds the retained owner at the same time as the runner environment and the assigned MCP tools, then starts the claim. - Cold, adopted, remote warm recovery and restart paths keep the old order. ### What stays at attach, and why - Registration, the quiescence probe, `run.attach` and the authority rotation stay in `attachRun`. Checkpoint registration needs the new run's tool authority epoch, and Core creates that epoch during backend setup. - The admission checks run again at attach, so a state change during preparation still blocks the attachment. ## Verification - `vitest run src/live/runnerd-codex-transport-release-resume.test.ts` (runner): 21 passed. Five new modes: - `early claim`: `attachRun` waits for the held claim and does not route the new run before the claim ends; - `early claim abandoned`: a close after the claim ends fails the waiting `attachRun` with `closed`, and the new run is never routed; - `early claim cancellation`: a close during the claim cancels it, and the next `attachRun` fails with `closed`; - `early claim expired`: the deadline passes during the claim, and attach fails before it registers; - `early claim stale ticket`: the deadline passes after the claim, before attach. - `vitest run plugin-runner-release-resume.integration.test.ts plugin-runner-eviction.integration.test.ts` (server, with real Runner processes from `cargo build --workspace --bins`): 2 files, 51 passed, none skipped. New modes: - `cached held claim` (Codex and Claude): the plugin resume is held, and the test proves Core's tool preparation runs while the claim is in progress; - `cached cancellation` (Codex): a cancellation after an early claim retires the Runner and the run stops cleanly. - `pnpm run typecheck:typescript` (runner) and `pnpm typecheck` (server): pass. - Evidence (modeled, not measured live): traces of 8 resumed runs on parked Runners in a hosted test deployment, on current `master`. The claim takes about 300 ms, and it fits inside backend setup in 7 of 8 runs. The modeled saving is about 285 ms per follow-up on a parked Runner at P50 (range 208–331 ms). I did not run this change on a hosted deployment. - Not run: the full server suite (CI runs it), smoke tests, and the hosted park, claim and attach loop with this change. ## Risks - **New failure mode:** Core now claims the parked Runner before backend setup ends. If that run then fails preparation, or is cancelled before attach, the existing retire path closes the claimed session. The Runner is retired, so that agent's next follow-up starts cold. Before this change, a preparation failure happened before the claim, while the Runner was still parked. - **Ordering:** the retained plugin task owner is rebound to the new run earlier, before Core creates tool authority. Nothing in the owner preparation reads that authority. A failure in tool authority setup now happens after the rebind, and the retire path handles it. - **Scope:** only an in-memory warm entry that was parked takes the early path. Cold launches, adopted Runners, remote warm recovery and restart recovery are unchanged. - **Deadline:** the claim deadline now starts earlier. A very slow backend setup can make the claim expire before attach. Attach then fails with `native_runner_resume_reconciliation_expired` before it registers, the same failure as a slow claim today. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`) in Claude Code, with tool use (shell, git, GitHub CLI) and subagents. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- .../src/backends/harness-driver-backend.ts | 8 +++ .../src/contracts/harness-driver.ts | 1 + .../src/contracts/native-session-backend.ts | 1 + .../src/drivers/codex/app-server-transport.ts | 1 + .../drivers/codex/codex-harness-session.ts | 5 ++ ...erd-codex-transport-release-resume.test.ts | 54 ++++++++++++++++--- .../src/live/runnerd-codex-transport.ts | 53 +++++++++++++----- .../native-runtime/native-session-executor.ts | 46 ++++++++++++---- ...-runner-release-resume.integration.test.ts | 34 ++++++++++-- 9 files changed, 171 insertions(+), 32 deletions(-) diff --git a/packages/paperclip-runner/src/backends/harness-driver-backend.ts b/packages/paperclip-runner/src/backends/harness-driver-backend.ts index 51f03ff31ac..a8caaa4a643 100644 --- a/packages/paperclip-runner/src/backends/harness-driver-backend.ts +++ b/packages/paperclip-runner/src/backends/harness-driver-backend.ts @@ -480,6 +480,14 @@ class HarnessNativeSession implements NativeSession { }; } + beginRunAttachment(input: { identity: OpenNativeSessionInput["identity"] }): boolean { + const currentIdentity = this.#input.identity; + if (this.#protocolIntegrityFailure !== null || this.#session.beginRunAttachment === undefined + || input.identity.sessionId !== currentIdentity.sessionId || input.identity.companyId !== currentIdentity.companyId + || input.identity.issueId !== currentIdentity.issueId || input.identity.agentId !== currentIdentity.agentId) return false; + return this.#session.beginRunAttachment({ runId: input.identity.runId }); + } + async attachRun(input: { identity: OpenNativeSessionInput["identity"]; currentRunGrant?: NativeRunRuntimeGrant; diff --git a/packages/paperclip-runner/src/contracts/harness-driver.ts b/packages/paperclip-runner/src/contracts/harness-driver.ts index de5e9c8a29f..d4ccd77bc67 100644 --- a/packages/paperclip-runner/src/contracts/harness-driver.ts +++ b/packages/paperclip-runner/src/contracts/harness-driver.ts @@ -564,6 +564,7 @@ export interface HarnessSession { displayId?: string | null; }; events(): AsyncIterable; + beginRunAttachment?(input: { runId: string }): boolean; attachRun?(input: { runId: string; currentRunGrant?: NativeRunRuntimeGrant; completionContract?: { revision: string; criterionIds: readonly string[] } }): Promise | void; startTurn(input: { message: NativeUserMessage; diff --git a/packages/paperclip-runner/src/contracts/native-session-backend.ts b/packages/paperclip-runner/src/contracts/native-session-backend.ts index 3ac90be6d7d..d4e3a4effea 100644 --- a/packages/paperclip-runner/src/contracts/native-session-backend.ts +++ b/packages/paperclip-runner/src/contracts/native-session-backend.ts @@ -217,6 +217,7 @@ export interface NativeSession { capabilities(): Promise; /** completionContract is the attaching run's own contract; a retained session must not keep the prior run's. */ attachRun?(input: { identity: NativeRunIdentity; currentRunGrant?: NativeRunRuntimeGrant; completionContract?: { revision: string; criterionIds: readonly string[] } }): Promise; + beginRunAttachment?(input: { identity: NativeRunIdentity }): boolean; /** Return the confirmed released source, or false when active use was not released. */ release?(): Promise; /** Relinquish controller authority without suspending provider execution. */ diff --git a/packages/paperclip-runner/src/drivers/codex/app-server-transport.ts b/packages/paperclip-runner/src/drivers/codex/app-server-transport.ts index fb7cdbf5958..cbb6186a008 100644 --- a/packages/paperclip-runner/src/drivers/codex/app-server-transport.ts +++ b/packages/paperclip-runner/src/drivers/codex/app-server-transport.ts @@ -80,6 +80,7 @@ export interface CodexAppServerTransport { /** Relinquish controller authority while leaving durable runner work alive. */ detachControllerForRestart?(): Promise; processInfo?(): CodexTransportProcessInfo; + beginRunAttachment?(input: { runId: string }): boolean; attachRun?(input: { runId: string; turnId: string; diff --git a/packages/paperclip-runner/src/drivers/codex/codex-harness-session.ts b/packages/paperclip-runner/src/drivers/codex/codex-harness-session.ts index 2c7c2ab0ad4..a3e0a3c0e04 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-harness-session.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-harness-session.ts @@ -110,6 +110,11 @@ export class CodexHarnessSession }; } + beginRunAttachment(input: { runId: string }): boolean { + if (this.protocolIntegrityFailure !== null || this.turnStartPending || this.pendingRunAttachment !== null || !input.runId) return false; + return this.transport.beginRunAttachment?.(input) ?? false; + } + async attachRun(input: { runId: string; currentRunGrant?: NativeRunRuntimeGrant; completionContract?: { revision: string; criterionIds: readonly string[] } }): Promise { this.assertProtocolIntegrity(); const transportOwnsQuiescence = this.transport.attachRun !== undefined; diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport-release-resume.test.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport-release-resume.test.ts index fc5232c61d6..36b8843de43 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport-release-resume.test.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport-release-resume.test.ts @@ -78,7 +78,7 @@ async function nativeWire(port: number, path: string, closeGate?: Promise) }); } -it.skipIf(process.platform === "win32").each(["cached", "lost claim reply", "cache adoption", "acpx cache adoption", "unavailable", "claim cancellation", "expired ticket", "late claim reply", "held past deadline", "release admission", "no cleanup callback", "adoption no cleanup callback", "resume no cleanup callback", "deferred turn after close", "late wire close", "empty goal snapshot"] as const)( +it.skipIf(process.platform === "win32").each(["cached", "lost claim reply", "cache adoption", "acpx cache adoption", "unavailable", "claim cancellation", "expired ticket", "late claim reply", "held past deadline", "release admission", "no cleanup callback", "adoption no cleanup callback", "resume no cleanup callback", "deferred turn after close", "late wire close", "empty goal snapshot", "early claim", "early claim abandoned", "early claim cancellation", "early claim expired", "early claim stale ticket"] as const)( "releases and resumes the native Codex source before attachment (%s)", async mode => { const root = await mkdtemp(join(tmpdir(), "runner-release-resume-")); const acpx = mode === "acpx cache adoption"; @@ -119,7 +119,7 @@ it.skipIf(process.platform === "win32").each(["cached", "lost claim reply", "cac const heldMeasuredTurn = new Promise(resolve => { finishMeasuredTurn = resolve; }); const sockets = new Set(); const registration = async (core: DurablePrpControlPlane, source = core.store.state.identity) => { - if ((mode === "late claim reply" || mode === "held past deadline") && claimAccepted) throw new Error("Fixture source authentication started after expiry"); + if ((mode === "late claim reply" || mode === "held past deadline" || mode === "early claim expired") && claimAccepted) throw new Error("Fixture source authentication started after expiry"); authority = core; let socket: PrpWireConnection | undefined; return { @@ -165,8 +165,8 @@ it.skipIf(process.platform === "win32").each(["cached", "lost claim reply", "cac parkId: parkId(source), bootstrapTicket: ticket }); if (result.status !== "claimed") throw new Error("Fixture claim receipt unavailable"); claimAccepted = true; acceptClaim(); - if (mode === "claim cancellation" || mode === "held past deadline") await claimReceipt; - if (mode === "expired ticket" || mode === "late claim reply") { + if (mode === "claim cancellation" || mode === "held past deadline" || mode === "early claim" || mode === "early claim cancellation") await claimReceipt; + if (mode === "expired ticket" || mode === "late claim reply" || mode === "early claim expired") { expireClock = vi.spyOn(Date, "now").mockReturnValue(Date.now() + 61_000); if (mode === "expired ticket") throw new Error("Fixture lost claim reply past ticket lifetime"); } @@ -347,12 +347,51 @@ it.skipIf(process.platform === "win32").each(["cached", "lost claim reply", "cac await expect(bundle.transport.request("thread/goal/set", { threadId, objective: "Must stay fenced" })).rejects.toThrow("release_resume_pending"); } if (mode === "unavailable") await fixture.close(); + if (mode.startsWith("early claim")) { + expect(bundle.transport.beginRunAttachment!({ runId: claimantRunId })).toBe(true); + expect(bundle.transport.beginRunAttachment!({ runId: claimantRunId })).toBe(false); + await vi.waitFor(() => expect(pair.resume).toHaveBeenCalledOnce()); + await expect(bundle.transport.release!()).rejects.toThrow("run_attachment_in_progress"); + await expect(bundle.transport.attachRun!({ runId: "other-run", turnId: "other-turn", itemId: "other-item" })).rejects.toThrow("run_attachment_in_progress"); + await expect(bundle.transport.request("turn/start", { input: [] })).rejects.toThrow("run_attachment_in_progress"); + expect(routeRuns).toEqual([identity.runId]); + } + if (mode === "early claim cancellation") { + await vi.waitFor(() => expect(claimAccepted).toBe(true)); + const closing = bundle.transport.close(); + releaseClaim(); + await closing; + await expect(bundle.transport.attachRun!({ runId: claimantRunId, turnId: "next-turn", itemId: "next-item" })).rejects.toThrow("closed"); + } + if (mode === "early claim abandoned" || mode === "early claim stale ticket") { + await vi.waitFor(async () => expect(await pair.resume.mock.results[0]?.value).toBe("resumed")); + if (mode === "early claim abandoned") await bundle.transport.close(); + else { + expireClock = vi.spyOn(Date, "now").mockReturnValue(Date.now() + 61_000); + await expect(bundle.transport.attachRun!({ runId: claimantRunId, turnId: "next-turn", itemId: "next-item" })).rejects.toThrow("reconciliation_expired"); + expireClock.mockRestore(); expireClock = undefined; + await bundle.transport.close(); + } + expect(routeRuns).toEqual([identity.runId]); + expect(pair.release).toHaveBeenCalledOnce(); + } if (mode === "held past deadline") vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"] }); const attaching = Promise.resolve(recoveredSession ? recoveredSession.attachRun!({ runId: claimantRunId }) : bundle.transport.attachRun!({ runId: claimantRunId, turnId: "next-turn", itemId: "next-item" })); void attaching.catch(() => undefined); - if (mode === "held past deadline") { + if (mode === "early claim cancellation" || mode === "early claim abandoned" || mode === "early claim stale ticket") { + await expect(attaching).rejects.toThrow("closed"); + } else if (mode === "early claim") { + await vi.waitFor(() => expect(claimAccepted).toBe(true)); + let attached = false; + void attaching.then(() => { attached = true; }, () => undefined); + await new Promise(resolve => setTimeout(resolve, 100)); + expect(attached).toBe(false); + expect(routeRuns).toEqual([identity.runId]); + releaseClaim(); + await attaching; + } else if (mode === "held past deadline") { await acceptedClaim; let failure: unknown; const settled = attaching.catch(error => { failure = error; }); @@ -372,13 +411,14 @@ it.skipIf(process.platform === "win32").each(["cached", "lost claim reply", "cac releaseClaim(); await expect(attaching).rejects.toThrow("closed"); await closing; - } else if (mode === "unavailable" || mode === "expired ticket" || mode === "late claim reply" || mode === "resume no cleanup callback") { + } else if (mode === "unavailable" || mode === "expired ticket" || mode === "late claim reply" || mode === "resume no cleanup callback" || mode === "early claim expired") { await expect(attaching).rejects.toThrow(mode === "unavailable" ? "reuse_unavailable" : mode === "resume no cleanup callback" ? "released_cleanup_unavailable" : "reconciliation_expired"); expireClock?.mockRestore(); expireClock = undefined; await bundle.transport.close(); } else await attaching; expect(new Set(claimTicketDigests).size).toBe(1); - if (["unavailable", "claim cancellation", "expired ticket", "late claim reply", "held past deadline", "resume no cleanup callback"].includes(mode)) { + if (["unavailable", "claim cancellation", "expired ticket", "late claim reply", "held past deadline", "resume no cleanup callback", + "early claim abandoned", "early claim cancellation", "early claim expired", "early claim stale ticket"].includes(mode)) { expect(pair.resume).toHaveBeenCalledOnce(); expect(authority!.store.state.identity.runId).toBe(identity.runId); expect(launch).toHaveBeenCalledOnce(); diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts index 493b5e8cf91..62b46db025e 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts @@ -4000,6 +4000,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { #detachPromise: Promise | null = null; #startupOperation: Promise | null = null; #attachmentOperation: Promise | null = null; + #earlyClaim: { runId: string; operation: Promise } | null = null; #releaseEpisode: { source: DurableRecoveryIdentity; confirmed: boolean; claimed?: boolean; claimantRunId?: string; ticket?: string; ticketFlushed?: boolean; deadline?: number; @@ -4721,6 +4722,21 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { if (this.options.adoptExistingRunner && this.#adoptedRunnerMonitor === null) this.#watchAdoptedRunner(this.options.adoptExistingRunner); } + beginRunAttachment(input: { runId: string }): boolean { + if (this.#attachmentOperation !== null || !this.#releaseEpisode?.confirmed || this.#releaseEpisode.claimed || !input.runId) return false; + try { + this.#assertStartupOpen(); + this.#assertRunAttachmentAdmission(); + } catch { + return false; + } + const operation = this.#claimReleasedRunner(input.runId); + void operation.catch(() => undefined); + this.#attachmentOperation = operation; + this.#earlyClaim = { runId: input.runId, operation }; + return true; + } + attachRun(input: { runId: string; turnId: string; @@ -4728,12 +4744,14 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { currentRunGrant?: NativeRunRuntimeGrant; completionContract?: { revision: string; criterionIds: readonly string[] }; }): Promise { - if (this.#attachmentOperation !== null) { + const earlyClaim = this.#earlyClaim?.runId === input.runId ? this.#earlyClaim.operation : undefined; + if (this.#attachmentOperation !== null && earlyClaim === undefined) { return Promise.reject(new Error("native_runner_run_attachment_in_progress")); } + this.#earlyClaim = null; const operation = Promise.resolve().then(() => { this.#assertStartupOpen(); - return this.#attachRun(input); + return this.#attachRun(input, earlyClaim); }).finally(() => { this.#attachmentOperation = null; }); this.#attachmentOperation = operation; return operation; @@ -4745,20 +4763,14 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { itemId: string; currentRunGrant?: NativeRunRuntimeGrant; completionContract?: { revision: string; criterionIds: readonly string[] }; - }): Promise { + }, earlyClaim?: Promise): Promise { const previous = record(this.#runAttachTemplate?.provider).runtimeContext; const current = input.currentRunGrant?.runtimeContext; - return this.options.measureOperation ? this.options.measureOperation("runner.transport.attach", () => this.#attachRunWork(input), - previous === undefined ? undefined : {contextReused:current === undefined || commandDigest(current) === commandDigest(previous)}) : this.#attachRunWork(input); + return this.options.measureOperation ? this.options.measureOperation("runner.transport.attach", () => this.#attachRunWork(input, earlyClaim), + previous === undefined ? undefined : {contextReused:current === undefined || commandDigest(current) === commandDigest(previous)}) : this.#attachRunWork(input, earlyClaim); } - async #attachRunWork(input: { - runId: string; - turnId: string; - itemId: string; - currentRunGrant?: NativeRunRuntimeGrant; - completionContract?: { revision: string; criterionIds: readonly string[] }; - }): Promise { + #assertRunAttachmentAdmission(): { core: DurablePrpControlPlane; retainingSource: boolean } { if (this.#pendingWarmRecoveryCompletion !== null) { throw new Error("native_runner_warm_transition_completion_pending"); } @@ -4770,6 +4782,23 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { if (retainingSource && this.#bridgedRuntimeInputs.size > 0) { throw new NativeRunnerSourceReconciliationRequiredError("runtime"); } + return { core, retainingSource }; + } + + async #attachRunWork(input: { + runId: string; + turnId: string; + itemId: string; + currentRunGrant?: NativeRunRuntimeGrant; + completionContract?: { revision: string; criterionIds: readonly string[] }; + }, earlyClaim?: Promise): Promise { + const { core, retainingSource } = this.#assertRunAttachmentAdmission(); + if (earlyClaim) { + await earlyClaim; + if (this.#releaseEpisode && Date.now() >= this.#releaseEpisode.deadline!) { + throw new Error("native_runner_resume_reconciliation_expired"); + } + } if (this.#releaseEpisode) await this.#resumeReleasedRunner(input.runId); await this.#awaitWarmRunAttachmentReady(); if (retainingSource && !core.semanticToolResultsSettled()) { diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index a908267d1c0..3e46964c3f9 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -10707,6 +10707,7 @@ async function executePaperclipNativeSessionWithinScope( `native-warm-session:${input.execution.binding.runId}`, ); let existingWarmSession: NativeSession | undefined; + let reusingReleasedRunner = false; let pluginTaskDescription: Extract,{kind:"description"}> | undefined; let managedCredentialSession: NativeSession | undefined; let githubAccess: NativeGitHubAccess | undefined; @@ -10798,6 +10799,7 @@ async function executePaperclipNativeSessionWithinScope( entry.instructionWorkingCopy = input.instructionWorkingCopy?.checkpointWarm ? undefined : input.instructionWorkingCopy; entry.busy = true; entry.ownerToken = warmSessionOwnerToken; + reusingReleasedRunner = entry.released === true; entry.released = false; entry.environmentId = input.runnerExecutionTarget?.environmentId ?? null; @@ -10960,6 +10962,10 @@ async function executePaperclipNativeSessionWithinScope( const resolveWake = () => timeController("wake_binding", () => resolveCurrentWakeCommentsBinding(input.db, input.execution.binding)); const expectedCurrentWakeComments = await (executionContext ? executionContext.wait(resolveWake) : resolveWake()); let remoteWarmRecoveryPending = remoteWarmRecovery !== undefined; + const beginRetainedRunAttachment = (session: NativeSession) => () => { + session.beginRunAttachment?.({ identity: { runId: input.execution.binding.runId, sessionId: session.identity().sessionId, + companyId: input.execution.binding.companyId, issueId: input.execution.binding.issueId, agentId: input.execution.binding.agentId } }); + }; const createExecutionBackend = () => createRunnerdBackend({ ...input, onDiagnosticLease: ownDiagnosticLease, @@ -10979,6 +10985,7 @@ async function executePaperclipNativeSessionWithinScope( runnerInstanceId: effectiveRunnerInstanceId, pluginTaskDescription, retainedPluginTaskOwner: existingWarmSession ? pluginTaskSessionOwners.get(existingWarmSession) : undefined, + beginRetainedRunAttachment: existingWarmSession && reusingReleasedRunner ? beginRetainedRunAttachment(existingWarmSession) : undefined, durableEnvironmentLeaseId: durableRunnerBinding?.environmentLeaseId, remoteWarmRecovery: remoteWarmRecovery ? { ...remoteWarmRecovery, @@ -14193,6 +14200,7 @@ export async function createRunnerdBackend(input: { durableEnvironmentLeaseId?: string; remoteWarmRecovery?: Omit & { revalidate: () => Promise }; retainedPluginTaskOwner?: ReturnType; + beginRetainedRunAttachment?: () => void; pluginTaskDescription?: Extract,{kind:"description"}>; onSpawn?: (meta: { pid: number; @@ -14487,7 +14495,13 @@ async function createRunnerdBackendWithinSessionClaim( if (relayAssignedMcp && hasAssignedMcp && !assignedGatewayPublicId) { throw new Error("assigned native MCP gateway path is invalid"); } - const [preparedEnvironment, preparedAssignedMcp] = await Promise.allSettled([ + let durableState: ReturnType | undefined; + let pluginTaskOwner: ReturnType | undefined; + const earlyPluginTaskOwner = pluginTask && remoteTarget?.transport === "sandbox" && input.beginRetainedRunAttachment + && input.retainedPluginTaskOwner && !input.remoteWarmRecovery && !input.restartRecovery + ? pluginTaskOwner = preparePluginTaskOwner().then(prepared => { input.beginRetainedRunAttachment!(); return prepared; }) + : undefined; + const [preparedEnvironment, preparedAssignedMcp, preparedPluginTaskOwner] = await Promise.allSettled([ Promise.resolve().then(() => input.prepareRunnerEnvironment?.() ?? input.runnerEnvironment), Promise.resolve().then(() => relayAssignedMcp && !nativeReview && assignedMcpUrl && assignedMcpToken ? timeController("assigned_mcp", () => createAssignedMcpTools({ @@ -14498,9 +14512,11 @@ async function createRunnerdBackendWithinSessionClaim( ...(input.execution.provider.kind === "opencode" ? { delivery: "on_demand" as const } : {}), })) : undefined), + earlyPluginTaskOwner, ]); if (preparedEnvironment.status === "rejected") throw preparedEnvironment.reason; if (preparedAssignedMcp.status === "rejected") throw preparedAssignedMcp.reason; + if (preparedPluginTaskOwner.status === "rejected") throw preparedPluginTaskOwner.reason; const runnerEnvironment = preparedEnvironment.value; const assignedMcpTools = preparedAssignedMcp.value; const dotAttachmentActor = input.execution.schema === "paperclip.native-execution-input.v6" @@ -14565,13 +14581,7 @@ async function createRunnerdBackendWithinSessionClaim( authorityEpoch.revoke(); throw error; } - const root = runnerdStateRoot(input.execution); - const durableIdentity = readRunnerdDurableIdentity(root); - const durableBinding = retainedTransition - ? loadRunnerdDurableBinding(input.execution, retainedTransition) - : durableIdentityMatchesSession(durableIdentity, input.execution) - ? durableIdentity - : null; + const { root, durableIdentity, durableBinding } = durableState ??= readDurableState(); const stagedRecovery = input.restartRecovery?.kind === "reattach_remote_runner" ? await verifyStagedAttachmentRestart(input) : null; const retainedSystemInstructions = input.restartRecovery?.kind === "reattach_remote_runner" @@ -14632,7 +14642,21 @@ async function createRunnerdBackendWithinSessionClaim( input.durableEnvironmentLeaseId ?? input.execution.binding.executionWorkspaceId; mkdirSync(root, { recursive: true, mode: 0o700 }); - if (pluginTask && remoteTarget?.transport === "sandbox" && input.execution.schema !== "paperclip.native-execution-input.v6" && input.execution.schema !== "paperclip.native-execution-input.v8") { + function readDurableState() { + const root = runnerdStateRoot(input.execution); + const durableIdentity = readRunnerdDurableIdentity(root); + const durableBinding = retainedTransition + ? loadRunnerdDurableBinding(input.execution, retainedTransition) + : durableIdentityMatchesSession(durableIdentity, input.execution) + ? durableIdentity + : null; + return { root, durableIdentity, durableBinding }; + } + async function preparePluginTaskOwner() { + if (!pluginTask || remoteTarget?.transport !== "sandbox" || input.execution.schema === "paperclip.native-execution-input.v6" || input.execution.schema === "paperclip.native-execution-input.v8") { + throw new Error("Plugin Runner tasks require a workspace session"); + } + const { root, durableIdentity, durableBinding } = durableState ??= readDurableState(); const provider = input.execution.provider; if (provider.kind !== "codex" && provider.kind !== "opencode" && provider.kind !== "acpx") throw new Error("Plugin Runner tasks require a workspace harness"); if (remoteTarget.sandboxLeaseAcquisition?.outcome === "resumed" && !durableBinding && !input.retainedPluginTaskOwner && !input.remoteWarmRecovery) { @@ -14719,6 +14743,10 @@ async function createRunnerdBackendWithinSessionClaim( if (input.execution.session.lifecyclePolicy.mode === "warm" && (!credentials.accountIdentity || description.warmTaskVersion !== 1)) throw new Error("Plugin warm task capability or account unavailable"); const owner = retainedOwner ?? createPluginRunnerTaskOwner(ownerInput); if (retainedOwner) retainedOwner.rebind(ownerInput); + return { provider, description, prior, adoptionIdentity, adopting, owner }; + } + if (pluginTask && remoteTarget?.transport === "sandbox" && input.execution.schema !== "paperclip.native-execution-input.v6" && input.execution.schema !== "paperclip.native-execution-input.v8") { + const { provider, description, prior, adoptionIdentity, adopting, owner } = await (pluginTaskOwner ??= preparePluginTaskOwner()); if (description.workspaceFileReadVersion === 1) { authority.binding.readRemoteApiFile = owner.readWorkspaceFile; authority.binding.readRemoteTaskFile = file => owner.readWorkspaceFile(file.contentRef, file.byteSize); diff --git a/server/src/services/native-runtime/plugin-runner-release-resume.integration.test.ts b/server/src/services/native-runtime/plugin-runner-release-resume.integration.test.ts index fed25e693ab..79d0a44655b 100644 --- a/server/src/services/native-runtime/plugin-runner-release-resume.integration.test.ts +++ b/server/src/services/native-runtime/plugin-runner-release-resume.integration.test.ts @@ -6,10 +6,10 @@ import https, { createServer } from "node:https"; import { createServer as createTcpServer } from "node:net"; import { join, resolve } from "node:path"; import { getCACertificates, setDefaultCACertificates } from "node:tls"; -import { eq } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import WebSocket, { WebSocketServer, type RawData } from "ws"; import { expect, it, vi } from "vitest"; -import { completionContracts, environmentLeases, environments, executionWorkspaces, heartbeatRuns, issues, nativeRunFinalizations, nativeRunResults } from "@paperclipai/db"; +import { completionContracts, environmentLeases, environments, executionWorkspaces, heartbeatRunEvents, heartbeatRuns, issues, nativeRunFinalizations, nativeRunResults } from "@paperclipai/db"; import { environmentTaskOperationSchema, type PluginEnvironmentTaskOperation } from "@paperclipai/plugin-sdk"; import { readRunnerdArtifactBinding, defaultCapabilityRunnerdBinary, parseNativeExecutionInput, parseNativeRuntimeContext, QUALIFIED_ACPX_PROFILES, type DurableRecoveryIdentity, type RunnerProcessHandle, type RunnerProcessLaunchSpec } from "../../vendor/paperclip-runner/index.js"; @@ -23,6 +23,8 @@ import * as remoteWarmRecovery from "./remote-warm-session-recovery.js"; import { reconcileNativeFinalizations } from "./native-finalization-reconciler.js"; import { buildNativeCompletionContract } from "./completion-contracts.js"; import { nativeRuntimeContextFixture } from "./runtime-context.test-fixture.js"; +import { PROCESS_START_REQUESTED } from "../native-local-process-stop.js"; +import { PaperclipRunnerToolAuthority } from "./paperclip-runner-tool-authority.js"; const { createPrivateHostControlFixture } = await import(new URL("../../../../packages/paperclip-runner/test/support/private-host-control.ts", import.meta.url).href) as { createPrivateHostControlFixture(): { @@ -42,9 +44,11 @@ describeEmbeddedPostgres("plugin Runner release/resume through the Core executio const environment = nativeRestartFixture(); const nativeIt = process.platform === "win32" ? it.skip : realProcessIt; nativeIt.for([...(["codex", "claude"] as const).flatMap(harness => (["cached", "adopted"] as const).map(mode => [harness, mode] as const)), ["codex", "adopted cancellation"] as const, ["codex", "adopted legacy cancellation"] as const, + ["codex", "cached cancellation"] as const, ["codex", "cached held claim"] as const, ["claude", "cached held claim"] as const, ["codex", "cached managed GitHub"] as const, ["codex", "adopted managed GitHub"] as const])("completes %s runs through a retained %s process", { timeout: 60_000 }, async ([harness, mode], context) => { if (harness === "claude" && !existsSync(fakeAcpxSidecar)) context.skip(); - const adopted = mode.startsWith("adopted"), cancellation = mode.includes("cancellation"), qualifiedCancellation = mode === "adopted cancellation"; + const adopted = mode.startsWith("adopted"), cancellation = mode.includes("cancellation"), heldClaimMode = mode === "cached held claim"; + const qualifiedCancellation = mode === "adopted cancellation" || mode === "cached cancellation"; const managedGitHub = mode.endsWith("managed GitHub"), brokerUrl = "https://core.example.test/api", brokerToken = (runId: string) => `fixture-broker-${runId}`; const preparedBrokers: Array<{ runId: string; token?: string }> = []; const first = await environment.seedRun(`RELEASE-ENTRY-${harness}-${mode}`); @@ -121,6 +125,18 @@ describeEmbeddedPostgres("plugin Runner release/resume through the Core executio const host = createPrivateHostControlFixture(), runnerRoot = join(root, "native"), binary = defaultCapabilityRunnerdBinary(); const artifact = readRunnerdArtifactBinding(binary), preparations = new Map; launch: { configurationId: string } }>(); const releases: DurableRecoveryIdentity[] = [], claims: DurableRecoveryIdentity[] = []; + const operationsDuringClaim: string[] = []; + let claimHeld = false, holdPreparationFor: string | undefined, claimedDuringPreparation = false; + const toolDefinitions = PaperclipRunnerToolAuthority.prototype.definitions; + const preparation = vi.spyOn(PaperclipRunnerToolAuthority.prototype, "definitions").mockImplementation(function (this: PaperclipRunnerToolAuthority) { + const definitions = toolDefinitions.call(this); + if (holdPreparationFor === undefined || this.binding.runId !== holdPreparationFor) return definitions; + holdPreparationFor = undefined; + return waitForCondition("claim during preparation", () => claims.length === 1) + .then(() => { claimedDuringPreparation = true; return definitions; }) as unknown as typeof definitions; + }); + const processStartRequested = async (id: string) => (await db.select({ id: heartbeatRunEvents.id }).from(heartbeatRunEvents) + .where(and(eq(heartbeatRunEvents.runId, id), eq(heartbeatRunEvents.eventType, PROCESS_START_REQUESTED)))).length > 0; let handle: RunnerProcessHandle | undefined, submissions = 0, stops = 0, completes = 0; let releaseClaimReply!: () => void, observeHeldClaim!: () => void; const claimReply = new Promise(done => { releaseClaimReply = done; }); @@ -132,6 +148,7 @@ describeEmbeddedPostgres("plugin Runner release/resume through the Core executio const sameSource = (a: DurableRecoveryIdentity, b: DurableRecoveryIdentity) => Object.entries(a).every(([key, value]) => b[key as keyof DurableRecoveryIdentity] === value); const task = async (raw: unknown, authorityRunId: string): Promise => { const operation = environmentTaskOperationSchema.parse(raw); + if (claimHeld) operationsDuringClaim.push(operation.kind); if (operation.kind === "describe") return { kind: "description", taskId, launchPreparationVersion: 1, projectAuthCheckVersion: 1, warmTaskVersion: 1, releaseResumeVersion: 1, runner: artifact, ...(qualifiedCancellation ? { executionContextVersion: 1 } : {}), ...(managedGitHub ? { githubCredentialBrokerVersion: 1 } : {}) }; @@ -190,6 +207,13 @@ describeEmbeddedPostgres("plugin Runner release/resume through the Core executio if (reply.status !== "claimed") throw new Error("Fixture native claim refused"); expect(await providerStarts()).toBe(retainedProviderCount); claims.push(operation.identity); + if (heldClaimMode) { + expect(await processStartRequested(authorityRunId)).toBe(false); + claimHeld = true; + await waitForCondition("preparation completes while the claim is held", () => processStartRequested(authorityRunId)); + await new Promise(done => setTimeout(done, 200)); + claimHeld = false; + } if (cancellation) { observeHeldClaim(); await claimReply; } return { kind: "resume", taskId, outcome: "resumed" }; } @@ -247,6 +271,7 @@ describeEmbeddedPostgres("plugin Runner release/resume through the Core executio await db.insert(environmentLeases).values({ id: leaseB, companyId, environmentId, heartbeatRunId: runB, issueId, executionWorkspaceId: workspaceId, provider: "plugin:fixture:tasks", providerLeaseId: taskId, status: "active", leasePolicy: "reuse_by_environment", metadata: { remoteCwd: workspace } }); const next = { ...execution, binding: { ...execution.binding, runId: runB } }; + if (heldClaimMode) holdPreparationFor = runB; pendingFollowup = execute(next, leaseB); if (cancellation) { void pendingFollowup.catch(() => undefined); @@ -290,6 +315,7 @@ describeEmbeddedPostgres("plugin Runner release/resume through the Core executio expect(adoption).toHaveBeenCalledTimes(1); } expect(claims).toEqual(releases.slice(0, -1)); expect(releases[1]!.runId).toBe(runB); + if (heldClaimMode) expect({ claimedDuringPreparation, operationsDuringClaim }).toEqual({ claimedDuringPreparation: true, operationsDuringClaim: [] }); expect(releases[1]!.environmentLeaseId).toBe(leaseA); expect(handle!.child.pid).toBe(pid); expect(submissions).toBe(1); expect([...new Set(preparedBrokers.map(prepared => prepared.runId))]).toEqual(releases.map(release => release.runId)); const methods = (await readFile(calls, "utf8")).trim().split(/\r?\n/); @@ -316,7 +342,7 @@ describeEmbeddedPostgres("plugin Runner release/resume through the Core executio const failures = cleanup.filter((result): result is PromiseRejectedResult => result.status === "rejected"); if (failures.length) throw new AggregateError(failures.map(result => result.reason), "Fixture cleanup failed"); } finally { - adoption.mockRestore(); + adoption.mockRestore(); preparation.mockRestore(); https.globalAgent.destroy(); https.globalAgent = oldAgent; setDefaultCACertificates(oldCAs); } } From 990de1e0bfb7c7c9b74c143b188a944b86da28e6 Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Sun, 8 Nov 2026 23:00:49 -0800 Subject: [PATCH 04/21] perf(environments): release a stopped plugin task once (#20203) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Plugin environment drivers with `supportsTasks` run agents as provider tasks, and warm runs keep a parked task for the next run > - A follow-up run can find that its parked task already stopped. Core then retires that task before it acquires a new one > - Core sent `stop`, `status`, and `complete` in the settle step. Then the plugin release hook sent `stop`, `status`, and `complete` again for the same task. Every call was on the critical path of the new run > - This pull request removes the repeated calls and keeps every call that does work no other call does > - The benefit is a faster start for a run whose parked task already stopped ## Linked Issues or Issue Description No issue exists. This follows the enhancement template. **What existing behavior does this improve?** The release of a plugin task lease that has already stopped. This occurs when a follow-up run finds that its parked warm task stopped. **Subsystem affected** Server: plugin environment drivers and the plugin Runner task lifecycle. **Current behavior** Core sends `stop`, `status`, and `complete` in the settle step. Then the plugin release hook sends `stop`, `status`, and `complete` again for the same task. Core also sends `stop` after resume already saw that the task stopped. On a hosted test deployment, 8 of 40 follow-up runs took this path. From the resume to the new acquire, these runs spent P50 1014 ms and P90 1080 ms. These 8 runs are the full overall P90 (1275 ms). **Proposed behavior** Core does not call the release hook when settle confirmed the stop and completed the same task. After a resume with no lease, Core sends `status` first, and sends `stop` only when `status` does not confirm the stop. **Reason and benefit** The repeated calls do no new work. Removing them saves about 355 ms, plus the `stop` share of about 187 ms, for each such run. These numbers are modeled from trace timings. **Breaking changes** None for the plugin API. A task driver must release task-scoped resources in its `status` or `complete` handling. The doc now says this. **Additional context** The settle `status` stays. Providers release task storage there, and it records the stop that gates the credential writeback and `complete`. ## What Changed - `settleDispatchedEnvironmentTask` now returns `"completed"` when it confirmed the stop and completed the task in this call, `"stopped"` when the lease was already settled, and `false` otherwise. - The plugin driver release does not call `environmentReleaseLease` when settle returned `"completed"`. In all other cases it calls the hook as before. - When `environmentResumeLease` returns no provider lease, the release sends `status` first. It sends `stop` (and a second `status`) only when that `status` does not confirm `executionStopped: true`. Startup and startup-cleanup leases keep the old `stop` first order. - The cleanup sweep (`retryPendingSandboxTeardown`) and `destroyRunLease` do not change. A settle failure still leaves the lease in `pending_cleanup`, and the sweep runs `stop`, `status`, `complete`, and the release hook. - `doc/plugins/PLUGIN_RUNNER_EXECUTION.md` tells drivers about the new order and that task-scoped resources must be released in `status` or `complete`. ### Dispatch count for a stopped parked task (resume to new acquire) | | Calls | | --- | --- | | Before | resume, stop, status, (credentials), complete, release hook (stop, status, complete), acquire: 9 provider calls (10 with Codex writeback) | | After | resume, status, (credentials), complete, acquire: 4 provider calls (5 with Codex writeback) | The modeled saving is about 540 ms for each such run: about 355 ms for the repeated release, and about 187 ms for the settle `stop` and `status`. This pull request keeps the settle `status`, so the real saving is about 355 ms plus the `stop` share of the 187 ms. These numbers are modeled from trace timings. They are not measured on this branch. ### What covers each removed call - Release hook `stop`, `status`, `complete`: settle already sent `stop` and `status` and checked `executionStopped: true` for the same task id (`lease.providerLeaseId`), then sent `complete`. The skip uses settle's success as its condition, not the code path. - Settle `stop` after a resume with no lease: the next `status` must report `executionStopped: true`. If it does not, Core sends `stop` and `status` as before. ### What each kept call protects - Settle `status`: providers release task storage and cancel preparations here. It also writes `runnerTaskExecutionStopped`, which the credential writeback and the `complete` bookkeeping need. - Credential writeback: Codex can rotate its token. The writeback must land before the new task installs credentials. It still runs inside `complete`, before the new acquire. - Settle `complete`: it retires every run lease that owned the task and clears the dispatch marker. - Release hook when settle did not complete a dispatched task in this call: the provider may still hold the lease. ## Verification - `pnpm --filter @paperclipai/shared --filter @paperclipai/plugin-sdk build` - `pnpm --filter @paperclipai/server typecheck` - `npx vitest run src/__tests__/plugin-warm-tasks.integration.test.ts src/__tests__/plugin-task-credential-writeback.test.ts src/__tests__/environment-task-runtime.test.ts` - Related suites: `environment-runtime`, `plugin-runner-task.integration`, `task-creation-recovery.integration`, `heartbeat-plugin-environment`, `native-startup-cleanup-recovery`, `plugin-environment-driver-ready-recovery`, `environment-task-authority.integration`, `native-workspace-warm-reuse`, `remote-workspaces.integration`, `legacy-workspace-restore-recovery`, `plugin-task-source-recovery.integration`, `managed-plugin-environments`. Locally, all of these pass except `heartbeat-plugin-environment` "inherits the instance default environment across companies…". That test also fails locally with the `origin/master` service files, so this change does not cause it. The existing `plugin-runner-task.integration` same-run replacement test now expects no release hook after a completed settle. New tests: - The stopped parked path sends exactly resume, `status`, `complete`, acquire. The `status` goes to the old task. - A resume with no lease for a task that still runs sends `status`, `stop`, `status`, `complete`. - A failure at `status`, `complete`, or `stop` leaves the lease in `pending_cleanup`, and the sweep then sends `stop`, `status`, `complete`, and the release hook. - With an observed stop, the Codex writeback runs before `complete`. A writeback failure leaves the lease for cleanup, and the retry writes back and completes. - A startup-cleanup lease still sends `stop` first. ## Risks - A third-party task driver that releases resources only in `onEnvironmentReleaseLease` would not get that call after a completed settle. The doc now states the requirement. The cleanup sweep still calls the hook after any failure. - A driver that returns no lease from resume while the task still runs costs one extra `status`. The behavior stays correct. - No migration. No API change for plugins. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), via Claude Code with tool use and code execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- doc/plugins/PLUGIN_RUNNER_EXECUTION.md | 9 ++++ .../environment-task-runtime.test.ts | 18 ++++++- .../plugin-runner-task.integration.test.ts | 2 +- .../plugin-task-credential-writeback.test.ts | 30 +++++++++--- .../plugin-warm-tasks.integration.test.ts | 41 ++++++++++++++-- server/src/services/environment-runtime.ts | 47 +++++++++++-------- .../src/services/environment-task-runtime.ts | 19 +++++--- 7 files changed, 128 insertions(+), 38 deletions(-) diff --git a/doc/plugins/PLUGIN_RUNNER_EXECUTION.md b/doc/plugins/PLUGIN_RUNNER_EXECUTION.md index cd05568a2be..22400cd5ddb 100644 --- a/doc/plugins/PLUGIN_RUNNER_EXECUTION.md +++ b/doc/plugins/PLUGIN_RUNNER_EXECUTION.md @@ -344,6 +344,15 @@ unsettled Core tool result, other close error, or continuation of the same session still blocks replacement. A controller restart is not required for this stopped-owner reset path. +Core ends a dispatched task lease with `stop`, `status`, and `complete`. When +that sequence confirms the stop and completes the same task, Core does not also +call `onEnvironmentReleaseLease`. Core still calls it when the release did not +complete a dispatched task, and the cleanup retry calls it after any failure. +When `onEnvironmentResumeLease` returns no provider lease, Core sends `status` +first. It sends `stop` only when that `status` does not report +`executionStopped: true`. A driver must release task-scoped resources in its +`status` or `complete` handling, not only in `onEnvironmentReleaseLease`. + Termination receipts cover all run leases that owned the retired physical task. A stopped task gets a new provider task ID for checkpoint resumption. Inspection precedes in-memory session selection so a replacement cannot inherit the stopped diff --git a/server/src/__tests__/environment-task-runtime.test.ts b/server/src/__tests__/environment-task-runtime.test.ts index 71c830b821c..8007d130478 100644 --- a/server/src/__tests__/environment-task-runtime.test.ts +++ b/server/src/__tests__/environment-task-runtime.test.ts @@ -85,9 +85,25 @@ describe("plugin-provided Runner execution", () => { expect(operations).not.toContain("complete"); release(); const result = await checked; - if (preparationStopped) { expect(result).toEqual({ value: true, error: null }); expect(operations).toEqual(["stop", "status", "complete"]); } + if (preparationStopped) { expect(result).toEqual({ value: "completed", error: null }); expect(operations).toEqual(["stop", "status", "complete"]); } else { expect(result.error?.message).toContain("termination is unconfirmed"); expect(fixture.sets).toContainEqual(expect.objectContaining({ status: "pending_cleanup", cleanupStatus: "failed" })); } }); + it("still stops a startup task first when resume already observed the stop", async () => { + const value = row(); + Object.assign(value.lease.metadata, { runnerTaskDispatched: false, + runnerTaskStartupCleanup: { contextId: "startup", requiresPreparationStop: true } }); + const fixture = database(value), workers = worker(); + fixture.db.select = ((...args: unknown[]) => (database(value).db.select as (...values: unknown[]) => unknown)(...args)) as Db["select"]; + Object.assign(workers, { drainStartup: vi.fn(async () => undefined) }); + const operations: string[] = []; + workers.call.mockImplementation(async (...args: any[]) => { + const operation = args[2].operation.kind; operations.push(operation); + return operation === "status" ? { kind: "status", taskId: "attempt-1", phase: "cancelled", executionStopped: true, preparationStopped: true } + : { kind: "accepted", taskId: "attempt-1" }; + }); + await expect(settleDispatchedEnvironmentTask(fixture.db, workers as never, { companyId: "company", id: leaseId }, { executionStopObserved: true })).resolves.toBe("completed"); + expect(operations).toEqual(["stop", "status", "complete"]); + }); it("negotiates the transient context without reading transport for an older plugin", async () => { const context = new NativeExecutionContext({ binding: { companyId: "company", agentId: "agent", runId: "run" } } as never, 1_000); const workers = worker(); diff --git a/server/src/__tests__/plugin-runner-task.integration.test.ts b/server/src/__tests__/plugin-runner-task.integration.test.ts index 403eba17f2e..fa7cebf6cdd 100644 --- a/server/src/__tests__/plugin-runner-task.integration.test.ts +++ b/server/src/__tests__/plugin-runner-task.integration.test.ts @@ -416,7 +416,7 @@ databaseSuite("Core plugin Runner task lifecycle", () => { return; } const next = await acquire; - expect(operations).toEqual(["stop", "status", "complete", "environmentReleaseLease", "environmentAcquireLease"]); + expect(operations).toEqual(["stop", "status", "complete", "environmentAcquireLease"]); expect(next.lease.id).not.toBe(oldLeaseId); await expect(resolvePluginTaskResume(db, { companyId, leaseId: next.lease.id, priorIdentity })).resolves.toBe("old-task"); await expect(runtime.task({ companyId, leaseId: next.lease.id, operation: { kind: "submit", projectIds: [], bootstrapTicket: "fixture", runner } })).rejects.toThrow("identity mismatch"); diff --git a/server/src/__tests__/plugin-task-credential-writeback.test.ts b/server/src/__tests__/plugin-task-credential-writeback.test.ts index 203e2b35e83..1ae26bb1fff 100644 --- a/server/src/__tests__/plugin-task-credential-writeback.test.ts +++ b/server/src/__tests__/plugin-task-credential-writeback.test.ts @@ -65,11 +65,11 @@ suite("durable plugin task credential handoff", () => { const lease = () => db.select().from(environmentLeases).where(and(eq(environmentLeases.id, leaseId), eq(environmentLeases.companyId, companyId))).then(rows => rows[0]!); return { companyId, agentId, runId, environmentId, leaseId, owner, selection: selection!, managed, workers, calls, lease, failExport(value: boolean) { failExport = value; }, credential: () => service.credential(selection!), - settle: () => settleDispatchedEnvironmentTask(db, workers, { companyId, id: leaseId }) }; + settle: (executionStopObserved = false) => settleDispatchedEnvironmentTask(db, workers, { companyId, id: leaseId }, { executionStopObserved }) }; } it("persists refreshed credentials before completing a stopped task, after controller files disappear", async () => { const f = await fixture(); - expect(await f.settle()).toBe(true); + expect(await f.settle()).toBe("completed"); expect(await f.credential()).toBe(auth("fixture-refreshed", 2021)); expect(f.calls.indexOf("credentials")).toBeLessThan(f.calls.indexOf("complete")); const saved = await f.lease(); @@ -77,6 +77,24 @@ suite("durable plugin task credential handoff", () => { expect(JSON.stringify(saved.metadata)).not.toContain("fixture-refreshed"); expect(JSON.stringify(saved.metadata)).not.toContain(f.managed.home!); }); + it("writes back credentials before completing a task whose stop was already observed, without stopping it again", async () => { + const f = await fixture(); + expect(await f.settle(true)).toBe("completed"); + expect(f.calls).toEqual(["status", "credentials", "complete"]); + expect(await f.credential()).toBe(auth("fixture-refreshed", 2021)); + expect((await f.lease()).metadata?.runnerTaskCredentialHandoff).toMatchObject({ state: "complete" }); + }); + it("leaves an observed-stop task for cleanup when credential writeback fails", async () => { + const f = await fixture(); f.failExport(true); + await expect(f.settle(true)).rejects.toThrow("termination is unconfirmed"); + expect(f.calls).toEqual(["status", "credentials"]); + expect(await f.lease()).toMatchObject({ status: "pending_cleanup", cleanupStatus: "failed" }); + expect((await f.lease()).metadata?.runnerTaskCredentialHandoff).toMatchObject({ state: "pending" }); + f.failExport(false); + expect(await f.settle()).toBe("completed"); + expect(f.calls.slice(2)).toEqual(["stop", "status", "credentials", "complete"]); + expect(await f.credential()).toBe(auth("fixture-refreshed", 2021)); + }); it("keeps a failed transfer pending and recovers through a fresh cleanup after run and environment deletion", async () => { const f = await fixture(); f.failExport(true); await expect(f.settle()).rejects.toThrow("termination is unconfirmed"); @@ -86,7 +104,7 @@ suite("durable plugin task credential handoff", () => { await db.delete(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId)); await db.delete(environments).where(eq(environments.id, f.environmentId)); f.failExport(false); - expect(await f.settle()).toBe(true); + expect(await f.settle()).toBe("completed"); expect(await f.credential()).toBe(auth("fixture-refreshed", 2021)); expect(f.calls.at(-1)).toBe("complete"); }); @@ -104,7 +122,7 @@ suite("durable plugin task credential handoff", () => { expect((await f.lease()).metadata?.runnerTaskCredentialHandoff).toMatchObject({ state: "pending" }); } finally { await db.execute(sql`drop trigger reject_runner_credential_save on company_secret_versions; drop function reject_runner_credential_save()`); } const receiver = createDb(database.connectionString); - expect(await settleDispatchedEnvironmentTask(receiver, f.workers, { companyId: f.companyId, id: f.leaseId })).toBe(true); + expect(await settleDispatchedEnvironmentTask(receiver, f.workers, { companyId: f.companyId, id: f.leaseId })).toBe("completed"); expect(await f.credential()).toBe(auth("fixture-refreshed", 2021)); expect(f.calls.filter(call => call === "credentials")).toHaveLength(2); expect(f.calls.at(-1)).toBe("complete"); @@ -113,13 +131,13 @@ suite("durable plugin task credential handoff", () => { it("does not overwrite an explicit account replacement", async () => { const f = await fixture(); await secretService(db).rotate(f.managed.credentialWriteback!.secretId, { value: auth("fixture-reconnected", 2022) }, { userId: f.owner }); - expect(await f.settle()).toBe(true); + expect(await f.settle()).toBe("completed"); expect(f.calls).not.toContain("credentials"); expect(await f.credential()).toBe(auth("fixture-reconnected", 2022)); }); it("allows concurrent cleanup to complete the same durable handoff", async () => { const f = await fixture(); - expect(await Promise.all([f.settle(), f.settle()])).toEqual([true, true]); + expect(await Promise.all([f.settle(), f.settle()])).not.toContain(false); expect(await f.credential()).toBe(auth("fixture-refreshed", 2021)); expect((await f.lease()).metadata?.runnerTaskCredentialHandoff).toMatchObject({ state: "complete" }); }); diff --git a/server/src/__tests__/plugin-warm-tasks.integration.test.ts b/server/src/__tests__/plugin-warm-tasks.integration.test.ts index 4bc77c5afdb..da9cc6e3ae5 100644 --- a/server/src/__tests__/plugin-warm-tasks.integration.test.ts +++ b/server/src/__tests__/plugin-warm-tasks.integration.test.ts @@ -7,6 +7,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { eq, sql } from "drizzle-orm"; import { agents, companies, createDb, applyPendingMigrations, environmentLeases, environments, heartbeatRuns, issues, plugins, projects, sandboxCreationOperations } from "@paperclipai/db"; import { environmentRuntimeService } from "../services/environment-runtime.js"; +import { environmentService } from "../services/environments.js"; import { recordSandboxCreationResource } from "../services/sandbox-creation-operations.js"; import { resolvePluginTaskResume } from "../services/environment-task-runtime.js"; import type { PluginWorkerManager } from "../services/plugin-worker-manager.js"; @@ -20,8 +21,8 @@ const support=external ? {supported:true} : await getEmbeddedPostgresTestSupport afterAll(async()=>{await stop?.();}); async function fixture(initialProjects:string[]=[],initialExpiry?:string,deadline?:Date){ const companyId=randomUUID(),agentId=randomUUID(),issueId=randomUUID(),environmentId=randomUUID(),pluginId=randomUUID(); - let runId=randomUUID(),allocation=0,stopped=false,outage=false,releaseFails=false; - const calls:string[]=[]; + let runId=randomUUID(),allocation=0,stopped=false,outage=false,releaseFails=false,unavailable=false; + const calls:string[]=[],dispatched:Array<[string,string]>=[],failures=new Set(); let projectIds=[...initialProjects],providerExpiry=initialExpiry; const taskExpiries=new Map(),acquireRequests:any[]=[]; let resumeGate:Promise|undefined,releaseResume:(()=>void)|undefined,resumes=0; @@ -38,12 +39,14 @@ const support=external ? {supported:true} : await getEmbeddedPostgresTestSupport const workers={isRunning:()=>true,getWorker:()=>({supportedMethods:["environmentTask","environmentAcquireLease","environmentResumeLease","environmentReleaseLease"]}), call:async(_id:string,method:string,p:any)=>{ calls.push(method==="environmentTask" ? p.operation.kind : method); + if(method==="environmentTask") dispatched.push([p.operation.kind,p.taskId]); + if(method==="environmentTask" && failures.delete(p.operation.kind)) throw Error(`${p.operation.kind} unavailable`); if(method==="environmentAcquireLease") { const id=`task-${++allocation}`;taskExpiries.set(id,providerExpiry);acquireRequests.push(p); return {providerLeaseId:id,...(providerExpiry ? {expiresAt:providerExpiry}:{}),metadata:{runId:p.runId}}; } if(method==="environmentResumeLease") {if(outage) throw Error("unreachable"); - if(resumeGate){if(++resumes===2)releaseResume!();await resumeGate;}return {providerLeaseId:stopped?null:p.providerLeaseId,expiresAt:taskExpiries.get(p.providerLeaseId),metadata:{runId:p.runId}};} + if(resumeGate){if(++resumes===2)releaseResume!();await resumeGate;}return {providerLeaseId:stopped||unavailable?null:p.providerLeaseId,expiresAt:taskExpiries.get(p.providerLeaseId),metadata:{runId:p.runId}};} if(method==="environmentReleaseLease") {if(releaseFails) throw Error("release unavailable");return {providerLeaseId:p.providerLeaseId,state:"stopped"};} if(p.operation.kind==="status") return {kind:"status",taskId:p.taskId,phase:stopped?"completed":"running",executionStopped:stopped,sessionStateAvailable:stopped}; if(p.operation.kind==="read_state") return {kind:"state",taskId:p.taskId,state:{...p.operation.identity,lifecycle:"ready"}}; @@ -63,7 +66,8 @@ const support=external ? {supported:true} : await getEmbeddedPostgresTestSupport launch:{runnerVersion:"fixture",runnerDigest:`sha256:${"a".repeat(64)}`,maxOutboxBytes:262144,p0ReserveBytes:65536,lifecyclePolicy:{mode:"warm",idleTimeoutMs:300000}}}}); await runtime().releaseRunLeases(runId,"released",undefined,"keep_running"); await db.update(heartbeatRuns).set({status:"succeeded"}).where(eq(heartbeatRuns.id,runId)); - return {companyId,first,identity,calls,runtime,acquire,acquireRequests, + return {companyId,first,identity,calls,dispatched,runtime,acquire,acquireRequests, + fail(kind:string){failures.add(kind);},unavailable(){unavailable=true;}, setExpiry(next?:string){providerExpiry=next;}, async nextRun(){runId=randomUUID();await db.insert(heartbeatRuns).values({id:runId,companyId,agentId,nativeIssueId:issueId,invocationSource:"manual",status:"running"});return runId;}, setProjects(next:string[]){projectIds=next;},stopped(){stopped=true;},failRelease(){releaseFails=true;},outage(){outage=true;}, @@ -109,6 +113,35 @@ const support=external ? {supported:true} : await getEmbeddedPostgresTestSupport expect(f.calls).toContain("complete"); await expect(resolvePluginTaskResume(db,{companyId:f.companyId,leaseId:next.lease.id,priorIdentity:f.identity})).resolves.toBe(f.first.lease.providerLeaseId); }); + it("retires a stopped retained task with one status and complete before allocating its replacement",async()=>{ + const f=await fixture();f.stopped();await f.nextRun(); + const calls=f.calls.length,dispatched=f.dispatched.length; + const next=await f.acquire(); + expect(f.calls.slice(calls)).toEqual(["environmentResumeLease","status","complete","environmentAcquireLease"]); + expect(f.dispatched.slice(dispatched)).toEqual([["status",f.first.lease.providerLeaseId],["complete",f.first.lease.providerLeaseId]]); + expect(next.lease.providerLeaseId).not.toBe(f.first.lease.providerLeaseId); + const [old]=await db.select().from(environmentLeases).where(eq(environmentLeases.id,f.first.lease.id)); + expect(old.status).toBe("released"); + expect(old.metadata).toMatchObject({runnerTaskExecutionStopped:true,runnerTaskDispatched:false,remoteExecutionTermination:{providerLeaseId:f.first.lease.providerLeaseId,state:"stopped"}}); + }); + it("stops a retained task that resume could not attach but that is still running",async()=>{ + const f=await fixture();f.unavailable();await f.nextRun(); + const calls=f.calls.length; + await f.acquire(); + expect(f.calls.slice(calls)).toEqual(["environmentResumeLease","status","stop","status","complete","environmentAcquireLease"]); + }); + it.each([["status",false],["complete",false],["stop",true]] as const)("keeps a stopped retained task for cleanup retry when %s fails",async(kind,running)=>{ + const f=await fixture();if(running) f.unavailable(); else f.stopped();await f.nextRun(); + f.fail(kind); + await expect(f.acquire()).rejects.toThrow("termination is unconfirmed"); + expect(f.calls.filter(call=>call==="environmentAcquireLease")).toHaveLength(1); + expect(f.calls).not.toContain("environmentReleaseLease"); + const pending=(await environmentService(db).getLeaseById(f.first.lease.id))!; + expect(pending).toMatchObject({status:"pending_cleanup",cleanupStatus:"failed"}); + const calls=f.calls.length; + await expect(f.runtime().retryPendingSandboxTeardown({environment:null,lease:pending})).resolves.toEqual({providerLeaseId:f.first.lease.providerLeaseId,state:"stopped"}); + expect(f.calls.slice(calls)).toEqual(["stop","status","complete","environmentReleaseLease"]); + }); it("does not allocate a replacement when the retained computer cannot be inspected",async()=>{ const f=await fixture();f.outage();await f.nextRun(); await expect(f.acquire()).rejects.toThrow("unreachable"); diff --git a/server/src/services/environment-runtime.ts b/server/src/services/environment-runtime.ts index 1e1a5d03de4..76b590c0d8f 100644 --- a/server/src/services/environment-runtime.ts +++ b/server/src/services/environment-runtime.ts @@ -3322,6 +3322,30 @@ function createPluginEnvironmentDriver( }; } + async function releasePluginRunLease(input: EnvironmentDriverReleaseInput, executionStopObserved = false) { + await db.update(environmentLeases).set({ metadata: sql`coalesce(${environmentLeases.metadata}, '{}'::jsonb) || '{"pluginLeaseCleanupMethod":"release"}'::jsonb` }).where(and(eq(environmentLeases.id, input.lease.id), eq(environmentLeases.companyId, input.lease.companyId))); + const settled = await settleDispatchedEnvironmentTask(db, workerManager, input.lease, { executionStopObserved }); + const taskStopped = settled !== false || + (input.lease.metadata?.runnerTaskLease === true && input.lease.metadata?.runnerTaskDispatched !== true && !input.lease.metadata?.runnerTaskStartup && !input.lease.metadata?.runnerTaskStartupCleanup); + if (settled !== "completed") { + const { plugin, driverKey, driverConfig } = await resolvePluginDriverForRelease(input); + await workerManager.call(plugin.id, "environmentReleaseLease", { + driverKey, + companyId: input.lease.companyId, + environmentId: input.environment.id, + issueId: input.lease.issueId, + config: driverConfig, + providerLeaseId: input.lease.providerLeaseId, + leaseMetadata: input.lease.metadata ?? undefined, + }); + } + return await environmentsSvc.releaseLease(input.lease.id, input.status, { + cleanupStatus: "success", + ...(taskStopped ? { remoteExecutionTermination: remoteTerminationReceipt(input.lease, + { providerLeaseId: input.lease.providerLeaseId, state: "stopped" }) } : {}), + }); + } + return { driver: "plugin", @@ -3363,7 +3387,7 @@ function createPluginEnvironmentDriver( providerLeaseId: previous.providerLeaseId, leaseMetadata: previous.metadata ?? undefined, }); if (!resumed?.providerLeaseId) { - await this.releaseRunLease({ environment: input.environment, lease: toEnvironmentLeaseSnapshot(previous), status: "released" }); + await releasePluginRunLease({ environment: input.environment, lease: toEnvironmentLeaseSnapshot(previous), status: "released" }, true); resumed = undefined; } } @@ -3423,25 +3447,8 @@ function createPluginEnvironmentDriver( }); }, - async releaseRunLease(input) { - await db.update(environmentLeases).set({ metadata: sql`coalesce(${environmentLeases.metadata}, '{}'::jsonb) || '{"pluginLeaseCleanupMethod":"release"}'::jsonb` }).where(and(eq(environmentLeases.id, input.lease.id), eq(environmentLeases.companyId, input.lease.companyId))); - const taskStopped = await settleDispatchedEnvironmentTask(db, workerManager, input.lease) || - (input.lease.metadata?.runnerTaskLease === true && input.lease.metadata?.runnerTaskDispatched !== true && !input.lease.metadata?.runnerTaskStartup && !input.lease.metadata?.runnerTaskStartupCleanup); - const { plugin, driverKey, driverConfig } = await resolvePluginDriverForRelease(input); - await workerManager.call(plugin.id, "environmentReleaseLease", { - driverKey, - companyId: input.lease.companyId, - environmentId: input.environment.id, - issueId: input.lease.issueId, - config: driverConfig, - providerLeaseId: input.lease.providerLeaseId, - leaseMetadata: input.lease.metadata ?? undefined, - }); - return await environmentsSvc.releaseLease(input.lease.id, input.status, { - cleanupStatus: "success", - ...(taskStopped ? { remoteExecutionTermination: remoteTerminationReceipt(input.lease, - { providerLeaseId: input.lease.providerLeaseId, state: "stopped" }) } : {}), - }); + releaseRunLease(input) { + return releasePluginRunLease(input); }, async resumeRunLease(input) { diff --git a/server/src/services/environment-task-runtime.ts b/server/src/services/environment-task-runtime.ts index b8b8a449aad..5ad92fad86b 100644 --- a/server/src/services/environment-task-runtime.ts +++ b/server/src/services/environment-task-runtime.ts @@ -493,7 +493,8 @@ export async function executeEnvironmentTask(db: Db, workers: PluginWorkerManage } /** Failure leaves the dispatch marker and provider handle available to cleanup retries. */ -export async function settleDispatchedEnvironmentTask(db: Db, workers: PluginWorkerManager, lease: { id: string; companyId: string }) { +export async function settleDispatchedEnvironmentTask(db: Db, workers: PluginWorkerManager, lease: { id: string; companyId: string }, + options: { executionStopObserved?: boolean } = {}): Promise { const [current] = await db.update(environmentLeases).set({ metadata: sql`coalesce(${environmentLeases.metadata}, '{}'::jsonb) || '{"runnerTaskRetiring":true}'::jsonb`, }).where(and(eq(environmentLeases.id, lease.id), eq(environmentLeases.companyId, lease.companyId))) @@ -503,19 +504,25 @@ export async function settleDispatchedEnvironmentTask(db: Db, workers: PluginWor if (current?.metadata?.runnerTaskDispatched !== true && !startup && !startupCleanup) { if (current?.metadata) await settlePluginTaskCredentialHandoff(db, { companyId: lease.companyId, leaseId: lease.id }, current.metadata, async () => { throw new Error("Undispatched task must not request credentials"); }); - return current?.metadata?.runnerTaskExecutionStopped === true; + return current?.metadata?.runnerTaskExecutionStopped === true ? "stopped" : false; } const scope = { companyId: lease.companyId, leaseId: lease.id }; try { const contextId = (startupCleanup as { contextId?: unknown } | undefined)?.contextId ?? (startup as { contextId?: unknown } | undefined)?.contextId; const draining = typeof contextId === "string" ? workers.drainStartup?.(contextId) : undefined; void draining?.catch(() => undefined); - await executeEnvironmentTask(db, workers, { ...scope, operation: { kind: "stop" } }); - const state = await executeEnvironmentTask(db, workers, { ...scope, operation: { kind: "status" } }); - if (state.kind !== "status" || state.executionStopped !== true || ((startup || startupCleanup?.requiresPreparationStop) && state.preparationStopped !== true)) throw new Error("Plugin Runner termination is unconfirmed"); + const confirmed = (state?: Awaited>) => state?.kind === "status" && state.executionStopped === true && + (!(startup || startupCleanup?.requiresPreparationStop) || state.preparationStopped === true); + let state = options.executionStopObserved === true && !startup && !startupCleanup + ? await executeEnvironmentTask(db, workers, { ...scope, operation: { kind: "status" } }) : undefined; + if (!confirmed(state)) { + await executeEnvironmentTask(db, workers, { ...scope, operation: { kind: "stop" } }); + state = await executeEnvironmentTask(db, workers, { ...scope, operation: { kind: "status" } }); + } + if (!confirmed(state)) throw new Error("Plugin Runner termination is unconfirmed"); await draining; await executeEnvironmentTask(db, workers, { ...scope, operation: { kind: "complete" } }); - return true; + return "completed"; } catch { await writeEnvironmentCleanup(db, tx => tx.update(environmentLeases).set({ status: "pending_cleanup", cleanupStatus: "failed", failureReason: "plugin_runner_task_cleanup_unconfirmed", releasedAt: new Date(), updatedAt: new Date(), From bf67e2fbd909fc62e9efbfe70147fd83935d3e6f Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 9 Nov 2026 01:06:41 -0600 Subject: [PATCH 05/21] test(chaos): honor 429 retry delays on harness reads (#20194) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The restart chaos harness collects each wave's speed from the board API after the tasks rest, including the M1 subtasks' runs > - The server rate-limits concurrent issue-list requests per client and answers 429 with `retryAfterSeconds` > - After a 95-task storm, the harness requested every M1 parent's subtask list at once. The server returned 429, speed collection failed, and the wave exited 1 with no speed grade > - This pull request makes harness GET requests honor a 429's retry delay with a bounded number of attempts, and collects subtask speed one parent at a time > - The benefit is that a busy wave keeps its speed grade, and the harness behaves like a polite API client ## Linked Issues or Issue Description Refs #20071, #20147 **What happened?** Wave `w-20261109T055902-s9402` (K13 storm, A1/C1/M1, 95 tasks) finished its tasks. Speed collection then failed with: `GET /api/companies/…/issues?view=index&parentIds=…&limit=20 returned 429: {"error":"Too many concurrent issue-list requests for this actor/client","retryAfterSeconds":1}`. `main.ts` ran `childSpeedTasks()` for every observer in parallel. **Expected behavior** - The harness waits and retries a rate-limited read. - It does not flood the issue-list endpoint while collecting speed. - A busy wave still gets graded. ## What Changed - `api.ts`: `ChaosApi` retries a GET that returns 429. - It waits for the delay from `Retry-After` or the body's `retryAfterSeconds` (default 1 s, capped at 10 s), scaled by the attempt number. - It makes at most 5 attempts by default. - Writes (POST/PUT/PATCH/DELETE) are never retried, so a user action is never repeated. - `rateLimitDelayMs` is exported for tests. - `main.ts`: subtask speed is collected one observer at a time instead of with `Promise.all`. - New `api.test.ts` against a real local HTTP server: - a GET that gets 429 twice, then 200, succeeds; - a POST that gets 429 is sent once; - retries stop at the bound; - delay parsing (header, body, cap, default). ## Verification - `npx vitest run` in `tests/restart-chaos`: 216 passed. - `node scripts/typecheck-test-projects.mjs tests/restart-chaos`: clean. ## Risks - A persistently rate-limited read now takes up to about 15 s before it fails, instead of failing at once. - Collecting subtask speed one parent at a time adds a few seconds to the end of large M1 waves. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, with tool use and code execution in Claude Code. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- tests/restart-chaos/api.test.ts | 58 +++++++++++++++++++++++++++++++++ tests/restart-chaos/api.ts | 30 +++++++++++++++-- tests/restart-chaos/main.ts | 3 +- 3 files changed, 87 insertions(+), 4 deletions(-) create mode 100644 tests/restart-chaos/api.test.ts diff --git a/tests/restart-chaos/api.test.ts b/tests/restart-chaos/api.test.ts new file mode 100644 index 00000000000..35854cbea8a --- /dev/null +++ b/tests/restart-chaos/api.test.ts @@ -0,0 +1,58 @@ +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { afterEach, describe, expect, it } from "vitest"; +import { ChaosApi, ChaosApiError, rateLimitDelayMs } from "./api.js"; + +let server: Server | null = null; +afterEach(async () => { + await new Promise(resolve => server ? server.close(() => resolve()) : resolve()); + server = null; +}); + +async function serve(handler: (count: number) => { status: number; body: unknown }) { + let count = 0; + const arrivals: number[] = []; + server = createServer((_request, response) => { + count += 1; + arrivals.push(performance.now()); + const reply = handler(count); + response.writeHead(reply.status, { "content-type": "application/json" }); + response.end(JSON.stringify(reply.body)); + }); + await new Promise(resolve => server!.listen(0, "127.0.0.1", () => resolve())); + return { url: `http://127.0.0.1:${(server!.address() as AddressInfo).port}`, count: () => count, arrivals }; +} + +describe("ChaosApi rate limits", () => { + it("retries a GET the server rate-limited, after the delay it asked for", async () => { + const target = await serve(count => count <= 2 + ? { status: 429, body: { error: "Too many concurrent issue-list requests", retryAfterSeconds: 0.05 } } + : { status: 200, body: [{ id: "c1" }] }); + await expect(new ChaosApi(target.url).get("/api/issues")).resolves.toEqual([{ id: "c1" }]); + expect(target.count()).toBe(3); + const [first, second, third] = target.arrivals; + expect(second! - first!).toBeGreaterThanOrEqual(45); + expect(third! - second!).toBeGreaterThanOrEqual(95); + }); + + it("does not repeat a write the server rate-limited", async () => { + const target = await serve(() => ({ status: 429, body: { retryAfterSeconds: 0.01 } })); + await expect(new ChaosApi(target.url).post("/api/issues/i1/comments", { body: "x" })).rejects.toBeInstanceOf(ChaosApiError); + expect(target.count()).toBe(1); + }); + + it("gives up after a bounded number of attempts", async () => { + const target = await serve(() => ({ status: 429, body: { retryAfterSeconds: 0.01 } })); + await expect(new ChaosApi(target.url, 10_000, 3).get("/api/issues")).rejects.toThrow(/returned 429/); + expect(target.count()).toBe(3); + }); + + it("reads the delay from Retry-After or the body, capped, and defaults to one second", () => { + const response = (status: number, retryAfter: string | null = null) => ({ status, headers: { get: () => retryAfter } }); + expect(rateLimitDelayMs(response(200), "")).toBeNull(); + expect(rateLimitDelayMs(response(429, "2"), "")).toBe(2_000); + expect(rateLimitDelayMs(response(429), JSON.stringify({ retryAfterSeconds: 3 }))).toBe(3_000); + expect(rateLimitDelayMs(response(429, "600"), "")).toBe(10_000); + expect(rateLimitDelayMs(response(429), "not json")).toBe(1_000); + }); +}); diff --git a/tests/restart-chaos/api.ts b/tests/restart-chaos/api.ts index 249e59cef15..b5e2fb8619d 100644 --- a/tests/restart-chaos/api.ts +++ b/tests/restart-chaos/api.ts @@ -8,10 +8,29 @@ export class ChaosApiError extends Error { constructor(readonly status: number | null, message: string) { super(message); this.name = "ChaosApiError"; } } +export function rateLimitDelayMs(response: { status: number; headers: { get(name: string): string | null } }, text: string): number | null { + if (response.status !== 429) return null; + let seconds = Number(response.headers.get("retry-after")); + if (!Number.isFinite(seconds) || seconds <= 0) { + try { seconds = Number((JSON.parse(text) as { retryAfterSeconds?: unknown }).retryAfterSeconds); } catch { seconds = NaN; } + } + return Number.isFinite(seconds) && seconds > 0 ? Math.min(10_000, seconds * 1_000) : 1_000; +} + export class ChaosApi { - constructor(readonly baseUrl: string, private readonly timeoutMs = 10_000) {} + constructor(readonly baseUrl: string, private readonly timeoutMs = 10_000, private readonly rateLimitAttempts = 5) {} private async request(method: string, route: string, body?: unknown, timeoutMs = this.timeoutMs): Promise { + for (let attempt = 1; ; attempt += 1) { + const outcome = await this.attempt(method, route, body, timeoutMs); + if (!("retryAfterMs" in outcome)) return outcome.value; + if (method !== "GET" || attempt >= this.rateLimitAttempts) throw outcome.error; + await new Promise(resolve => setTimeout(resolve, Math.min(10_000, outcome.retryAfterMs * attempt))); + } + } + + private async attempt(method: string, route: string, body: unknown, timeoutMs: number): + Promise<{ value: T } | { retryAfterMs: number; error: ChaosApiError }> { let response: Response; try { response = await fetch(`${this.baseUrl}${route}`, { @@ -25,8 +44,13 @@ export class ChaosApi { throw new ChaosApiError(null, `${method} ${route} unreachable: ${error instanceof Error ? error.message : String(error)}`); } const text = await response.text(); - if (!response.ok) throw new ChaosApiError(response.status, `${method} ${route} returned ${response.status}: ${text.slice(0, 500)}`); - return (text ? JSON.parse(text) : null) as T; + if (!response.ok) { + const error = new ChaosApiError(response.status, `${method} ${route} returned ${response.status}: ${text.slice(0, 500)}`); + const retryAfterMs = rateLimitDelayMs(response, text); + if (retryAfterMs !== null) return { retryAfterMs, error }; + throw error; + } + return { value: (text ? JSON.parse(text) : null) as T }; } get(route: string, timeoutMs?: number) { return this.request("GET", route, undefined, timeoutMs); } diff --git a/tests/restart-chaos/main.ts b/tests/restart-chaos/main.ts index 2f584a3e888..7c3ac613c10 100644 --- a/tests/restart-chaos/main.ts +++ b/tests/restart-chaos/main.ts @@ -469,7 +469,8 @@ async function main(): Promise { } await writeFile(path.join(waveDir, "faults.json"), `${JSON.stringify(faults, null, 2)}\n`, { mode: 0o600 }); const speed = await (async () => { - const childTasks = (await Promise.all(launched.map(observer => observer.childSpeedTasks()))).flat(); + const childTasks: Awaited> = []; + for (const observer of launched) childTasks.push(...await observer.childSpeedTasks()); return collectWaveSpeed(api, plan.options.env, [...launched.flatMap(observer => observer.record.issueId ? [{ issueId: observer.record.issueId, adapter: observer.plan.adapter, samples: observer.record.samples }] : []), ...childTasks], faults); })().catch(error => { timeline.add("wave_speed_failed", { error: String(error) }); anyFailure = true; return null; }); From ad2304dc881db2bb46bc7002b2e88612cdec40c8 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 9 Nov 2026 01:08:10 -0600 Subject: [PATCH 06/21] fix(chat): recover a held conversation turn's answer after a restart (#20204) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - People talk to agents in Agent Chat. The agent's final message becomes the chat reply. > - A chat turn can end while its task stays held: a pending approval, an execution stage or a dependency blocker keeps the status. > - #20179 made live run completion post that held turn's answer. It added `conversationTurnAnswered()`. > - Background presentation recovery still required a `conversation_turn_finished` decision and a `done` result. > - If the server restarts between the status commit and the live presentation, recovery skips the held turn. The person never gets an answer. > - Restarts are product failures. A committed answer must not be lost. > - This pull request makes recovery use the same answered rule as the live path, through one shared helper. > - The benefit is that a held turn's answer reaches the chat exactly once, with or without a restart. ## Linked Issues or Issue Description Refs #20179 (live fix; this PR closes the "Known gap" in its Risks section). **What happened** In an Agent Chat, the agent resubmitted a hire approval and ended its turn with a yielded result. The finalizer committed `governed_response_waiting` and moved the task to `in_review`. The server restarted before run completion posted the reply. Background presentation recovery found the run as a candidate, but declined it, because it accepted only `conversation_turn_finished` with a `done` result. No reply was posted. The run stayed a pending presentation candidate. **Expected behavior** After a restart, recovery posts the held turn's final answer exactly once. It never posts a second copy when the live path already presented the answer. When the turn's own question or confirmation card presents it, recovery posts nothing and records that decision, as the live path does. **Steps to reproduce** 1. Open an Agent Chat. Link a pending hire approval to the chat task. 2. Let the agent end the turn with a yielded result and a `response_wake` continuation. The status decision commits as `governed_response_waiting`. 3. Stop the server after the status commit and before run completion presents the reply. 4. Start the server. Presentation recovery runs. 5. See that the chat shows no reply. ## What Changed - `server/src/services/native-runtime/native-governance.ts`: add `conversationTurnPresentsAnswer()`. It is the one rule for whether a successful conversation turn's prose is its answer: `conversation_turn_finished`, or a committed decision with no pending card of the turn (`conversationTurnAnswered()`). - `server/src/services/heartbeat/run-completion.ts`: the live path calls the shared helper. No change in behavior. - `server/src/services/native-runtime/native-run-finalizer.ts` (`recoverCommittedNativeChatResponse`): - A current conversation turn no longer needs `conversation_turn_finished`. The turn must still be the unsettled current turn: same agent, user, session generation, `conversationState: active`, and no other execution run. A `conversation_turn_finished` turn still needs `in_progress`. - A conversation turn with a `yielded` result is accepted next to `done`. - The resolver gets `conversationTurnFinished` from the shared helper. - When the turn's own card presents it, recovery records `presentationDecision: none` (reason `committed_conversation_interaction_presented`) and settles the conversation turn, like the live path. The run then leaves the pending presentation set. Before, such a run stayed a candidate. - `server/src/services/native-runtime/native-response-presentation-query.ts`: the candidate prefilter matches the same turns: any committed reason for an active conversation turn, and `yielded` conversation results. - `server/src/services/native-runtime/agent-chat-response-recovery.test.ts`: restart regressions with the real finalizer and a real database. - `doc/execution-semantics.md`: state that recovery applies the same rule. ## Verification - Failing first. With only the new tests applied, 5 of 7 new cases failed: pending approval, revision-requested approval, execution stage (all yielded), pending approval with `done`, and the own-card case. The run was a candidate, but recovery posted nothing and did not settle it. - After the fix: `cd server && npx vitest run src/services/native-runtime/agent-chat-response-recovery.test.ts src/services/heartbeat/run-completion.test.ts src/__tests__/idle-native-finalization.test.ts src/services/presentation-ownership-read.test.ts src/services/chat-monitor-continuation.test.ts src/services/native-runtime/native-preserved-monitor-wait.test.ts src/services/native-runtime/native-response-publication-retry.test.ts src/services/heartbeat/run-lifecycle.test.ts src/__tests__/scoped-deadline-work.test.ts src/services/native-runtime/paperclip-control-plane-port.test.ts src/__tests__/heartbeat-run-summary.test.ts src/__tests__/agent-conversations.test.ts`: 12 files, 627 tests passed. - The new cases check: - Held turns: the run is a candidate, the check posts the exact final answer once, the presentation decision is recorded, the run leaves the candidate set, and a second recovery returns false with one comment. - Live already presented: no candidate, no second comment. - Own question card: no comment, `presentationDecision: none`, the card stays pending, the conversation settles to `waiting`, and the run leaves the candidate set. - Previous-release live decision `none` (`yielded_control_plane_wait`): kept, not re-presented. - `tsc --noEmit -p server`: no errors in changed files. Four unrelated errors remain in this local worktree (`esbuild` not linked in `native-harness-prestage.test.ts`, and a linked-package type mismatch in `run-dispatch/adapters/postgres.ts`). - Turn start: no change. The helper runs only in run completion (after the provider turn ends) and in background presentation recovery. Nothing new runs before `turn.start`, and no files move. The live path keeps the same queries: it short-circuits on `conversation_turn_finished`, and otherwise runs the same card read as before. ## Risks - Low to medium. Recovery can now post the answer of a conversation turn whose task is held. This matches the live path since #20179. - A card-presented turn found by recovery is now settled with `presentationDecision: none`. Before, it stayed a pending candidate and kept the presentation worker (and idle-sleep checks) busy. - A `conversation_turn_finished` turn keeps the `in_progress` check. Held turns accept any status their gate committed. The unsettled-turn checks (active conversation state, session generation, execution run) still block stale or superseded turns. - Previous-release state: no persisted shape changes. A run committed by the previous release with no saved presentation decision (the restart window) is recovered by the new code. The test fixture uses the unchanged finalizer, so it saves exactly the previous release's shape. A run whose live path already recorded a decision keeps it. A held turn that the previous release recorded as `yielded_control_plane_wait` is not re-presented, so no late duplicate or stale reply appears. - Not changed: generic stranded-work recovery in `recovery/service.ts` still skips only `conversation_turn_finished` runs during the publication window. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - Anthropic Claude Opus 5.5 (`claude-opus-5-5`), with tool use (shell, file edits, test runs) in Claude Code. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- doc/execution-semantics.md | 4 +- .../src/services/heartbeat/run-completion.ts | 15 ++- .../agent-chat-response-recovery.test.ts | 93 ++++++++++++++++++- .../native-runtime/native-governance.ts | 20 ++++ .../native-response-presentation-query.ts | 11 ++- .../native-runtime/native-run-finalizer.ts | 34 ++++++- 6 files changed, 157 insertions(+), 20 deletions(-) diff --git a/doc/execution-semantics.md b/doc/execution-semantics.md index 67b0b47f82c..172130221c4 100644 --- a/doc/execution-semantics.md +++ b/doc/execution-semantics.md @@ -2268,7 +2268,9 @@ posts its final answer to the conversation even when the task stays held: a pending or revision-requested approval, a pending execution stage, or a dependency blocker. A held task does not present the turn. Only a pending thread interaction (a question or confirmation card) presents the turn in place -of its prose. +of its prose. Background presentation recovery applies the same rule, so a +restart between the status commit and the live presentation posts the held +turn's answer once, or records that its card presented the turn. A delegated-task completion callback can finish native execution before its accepted final reply is saved. The completion outbox must retain that callback's diff --git a/server/src/services/heartbeat/run-completion.ts b/server/src/services/heartbeat/run-completion.ts index 425e681f025..fccf30ccc9e 100644 --- a/server/src/services/heartbeat/run-completion.ts +++ b/server/src/services/heartbeat/run-completion.ts @@ -45,7 +45,7 @@ import { isUnresolvedWorkspaceBaseRefError, readUnresolvedWorkspaceBaseRefDiagno import { runnerGoalService } from "../runner-goals.js"; import { resolveChatRunPresentationAuthorizationReason } from "../chat-run-publications.js"; import { hasChatRunOwnedProviderInteraction } from "../chat-interaction-arbitration.js"; -import { conversationTurnAnswered } from "../native-runtime/native-governance.js"; +import { conversationTurnPresentsAnswer } from "../native-runtime/native-governance.js"; import { readAcceptedTaskContinuationSummary } from "../heartbeat-run-summary.js"; import { SANDBOX_PROVIDER_PLUGIN_NOT_READY_REASON } from "../recovery/stranded-notice.js"; import { MAX_TURN_CONTINUATION_RETRY_REASON, isResolvedInteractionContinuationWakeContext } from "../../modules/run-dispatch/index.js"; @@ -847,14 +847,11 @@ export function createHeartbeatRunCompletion(db: Db, dependencies: HeartbeatRunC interactionOwnsResponse: Boolean(issueId && readAcceptedTaskContinuationSummary(persistedResultJson) && await hasChatRunOwnedProviderInteraction(db, { companyId: livenessRun.companyId, issueId, runId: livenessRun.id })), conversationTurnFinished: Boolean(issueId) && isConversation(issueContext) && livenessRun.status === "succeeded" - && (persistedResultJson?.finalizationReasonCode === "conversation_turn_finished" - // A turn left waiting on an approval, an execution stage or a - // blocker still answered the person. Only its own question card - // presents it instead. - || (persistedResultJson?.finalizationPhase === "committed" - && await measureRunPhase("response.resolve", () => conversationTurnAnswered({ - db, companyId: livenessRun.companyId, issueId: issueId!, runId: livenessRun.id, - })))), + && await measureRunPhase("response.resolve", () => conversationTurnPresentsAnswer({ + db, companyId: livenessRun.companyId, issueId: issueId!, runId: livenessRun.id, + finalizationPhase: persistedResultJson?.finalizationPhase, + finalizationReasonCode: persistedResultJson?.finalizationReasonCode, + })), existingComment: existingRunComment, finalAgentMessage, preferFinalResponseOverExistingComment: diff --git a/server/src/services/native-runtime/agent-chat-response-recovery.test.ts b/server/src/services/native-runtime/agent-chat-response-recovery.test.ts index b0d4ce5481a..4d048122992 100644 --- a/server/src/services/native-runtime/agent-chat-response-recovery.test.ts +++ b/server/src/services/native-runtime/agent-chat-response-recovery.test.ts @@ -1,7 +1,7 @@ import { randomUUID } from "node:crypto"; import { and, eq, sql } from "drizzle-orm"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; -import { agents, authUsers, companies, completionContracts, createDb, heartbeatRunEvents, heartbeatRuns, issueComments, issues, +import { agents, approvals, issueApprovals, statusDecisions, authUsers, companies, completionContracts, createDb, heartbeatRunEvents, heartbeatRuns, issueComments, issues, nativeRunFinalizations, nativeRunResults, issueThreadInteractions, chatTaskHandoffs, chatCompletionDeliveries, agentWakeupRequests, type Db } from "@paperclipai/db"; import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js"; import { resolveExternalTestDatabaseUrl } from "../../__tests__/helpers/external-test-database.js"; @@ -26,7 +26,8 @@ describe.skipIf(!support.supported)("committed Agent Chat reply recovery", () => }, 60_000); afterAll(async () => { await db?.$client.end(); await database?.cleanup(); }); - async function fixture(conversation = true, options: { succeed?: boolean; disposition?: "done" | "needs_review" } = {}) { + async function fixture(conversation = true, options: { succeed?: boolean; disposition?: "done" | "needs_review" | "yielded"; + beforeFinalize?: (ids: { companyId: string; issueId: string; runId: string; agentId: string }) => Promise } = {}) { const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(), runId = randomUUID(); const sessionId = randomUUID(), runnerId = randomUUID(), userId = `fixture-${randomUUID()}`, wakeId = randomUUID(); await db.insert(authUsers).values({ id: userId, name: "Conversation owner", email: `${userId}@example.test`, @@ -54,8 +55,11 @@ describe.skipIf(!support.supported)("committed Agent Chat reply recovery", () => payload: { kind: "agentMessage", channel: "final", providerPhase: "final", text: reply } }; await db.insert(heartbeatRunEvents).values({ companyId, runId, agentId, seq: 170, eventType: "item.completed", payload: { prpEvent } }); const disposition = options.disposition ?? "done"; + await options.beforeFinalize?.({ companyId, issueId, runId, agentId }); await port.completeRun({ result: { schema: "paperclip.run_result.v1", reportedWorkDisposition: disposition, - summary: "A completed hire, with its final message preserved separately.", completionClaim: { contractRevision: "test", + summary: "A completed hire, with its final message preserved separately.", + ...(disposition === "yielded" ? { continuation: { kind: "response_wake", summary: "Wait for the hire decision.", idempotencyKey: "hire-wait" } } : {}), + completionClaim: { contractRevision: "test", objectiveSatisfied: true, criteria: [], remainingWork: [] }, evidence: [], verification: [], attentionRequests: disposition === "needs_review" ? [{ kind: "review", ownerClass: "human", summary: "Review the completed work." }] : [], artifacts: [] }, terminal: { schema: "paperclip.prp.terminal.v1", runTerminalState: "succeeded", turnTerminalState: "completed", reportedWorkDisposition: disposition }, turnId: "hire-turn" }); @@ -706,4 +710,87 @@ describe.skipIf(!support.supported)("committed Agent Chat reply recovery", () => await expect(recoverCommittedNativeChatResponse(db, f.input)).resolves.toBe(true); expect(await comments(f.runId)).toHaveLength(1); }); + + // A chat turn can end while its task stays held: a resubmitted approval or + // an execution stage keeps the status, but the turn still answered the + // person. A restart between the status commit and the live presentation + // must not lose that answer. The run is saved exactly as the previous + // release's finalizer saves it, with no presentation recorded yet. + type Hold = "pending approval" | "approval sent back for revision" | "execution stage" | "own question card"; + async function heldTurn(hold: Hold, disposition: "yielded" | "done" = "yielded") { + return fixture(true, { disposition, beforeFinalize: async ({ companyId, issueId, runId, agentId }) => { + if (hold.includes("approval")) { + const [approval] = await db.insert(approvals).values({ companyId, type: "hire_agent", + status: hold === "pending approval" ? "pending" : "revision_requested", payload: { name: "Researcher" } }).returning(); + await db.insert(issueApprovals).values({ companyId, issueId, approvalId: approval.id }); + } + if (hold === "execution stage") await db.update(issues).set({ executionState: { status: "pending" } }).where(eq(issues.id, issueId)); + if (hold === "own question card") { + await db.insert(issueThreadInteractions).values({ companyId, issueId, kind: "ask_user_questions", status: "pending", + sourceRunId: runId, createdByAgentId: agentId, payload: { version: 1, questions: [{ id: "model", prompt: "Which model?", + selectionMode: "single", required: true, options: [{ id: "flash", label: "Flash" }] }] } }); + } + } }); + } + it.each([ + ["pending approval", "yielded"], ["approval sent back for revision", "yielded"], ["execution stage", "yielded"], + ["pending approval", "done"], + ] as const)( + "recovers a held conversation turn's answer exactly once after a restart: %s (%s)", async (hold, disposition) => { + const f = await heldTurn(hold, disposition); + const [decision] = await db.select().from(statusDecisions).where(eq(statusDecisions.runId, f.runId)); + expect(decision.reasonCode).not.toBe("conversation_turn_finished"); + expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId)))[0].resultJson) + .not.toHaveProperty("presentationDecision"); + expect(await comments(f.runId)).toEqual([]); + const work = nativeResponsePresentationWork(db), entries = await work.load(f.input); + expect(entries).toEqual([expect.objectContaining({ id: f.runId })]); + await expect(work.check(entries[0])).resolves.toBeNull(); + expect(await comments(f.runId)).toEqual([expect.objectContaining({ body: f.reply, authorAgentId: f.agentId, createdByRunId: f.runId })]); + expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId)))[0].resultJson).toMatchObject({ + presentationDecision: { chosenSource: "final_agent_message", commentAction: "create", commentId: expect.any(String) } }); + expect(await work.load(f.input)).toEqual([]); + await expect(recoverCommittedNativeChatResponse(db, f.input)).resolves.toBe(false); + expect(await comments(f.runId)).toHaveLength(1); + }, + ); + it("does not repost a held conversation turn's answer that the live path already presented", async () => { + const f = await heldTurn("pending approval"); + const [saved] = await db.insert(issueComments).values({ companyId: f.companyId, issueId: f.issueId, authorAgentId: f.agentId, + createdByRunId: f.runId, body: f.reply }).returning(); + await db.update(heartbeatRuns).set({ resultJson: sql`coalesce(${heartbeatRuns.resultJson}, '{}'::jsonb) || ${JSON.stringify({ + presentationDecision: { chosenSource: "final_agent_message", commentAction: "create", commentId: saved.id, reasonCodes: [] } })}::jsonb` }) + .where(eq(heartbeatRuns.id, f.runId)); + expect(await nativeResponsePresentationWork(db).load(f.input)).toEqual([]); + await expect(recoverCommittedNativeChatResponse(db, f.input)).resolves.toBe(false); + expect(await comments(f.runId)).toEqual([saved]); + }); + it("keeps a held turn's presentation that the previous release's live path already recorded", async () => { + // Before the live answered rule, a held yielded turn was recorded as a + // control-plane wait. That decision stands; recovery never re-presents it. + const f = await heldTurn("pending approval"); + await db.update(heartbeatRuns).set({ resultJson: sql`coalesce(${heartbeatRuns.resultJson}, '{}'::jsonb) || ${JSON.stringify({ + presentationDecision: { chosenSource: "none", commentAction: "none", reasonCodes: ["yielded_control_plane_wait"] } })}::jsonb` }) + .where(eq(heartbeatRuns.id, f.runId)); + expect(await nativeResponsePresentationWork(db).load(f.input)).toEqual([]); + await expect(recoverCommittedNativeChatResponse(db, f.input)).resolves.toBe(false); + expect(await comments(f.runId)).toEqual([]); + }); + it("settles a held conversation turn presented by its own question card without posting its prose", async () => { + const f = await heldTurn("own question card"); + const cards = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.issueId, f.issueId)); + expect(cards).toEqual([expect.objectContaining({ kind: "ask_user_questions", status: "pending", sourceRunId: f.runId })]); + const work = nativeResponsePresentationWork(db), entries = await work.load(f.input); + expect(entries).toEqual([expect.objectContaining({ id: f.runId })]); + await expect(work.check(entries[0])).resolves.toBeNull(); + expect(await comments(f.runId)).toEqual([]); + expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId)))[0].resultJson).toMatchObject({ + presentationDecision: { chosenSource: "none", commentAction: "none" } }); + // Settled like the live path: the card is the turn's response. + expect(await issue(f.issueId)).toMatchObject({ conversationState: "waiting" }); + expect(await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.issueId, f.issueId))).toEqual(cards); + expect(await work.load(f.input)).toEqual([]); + await expect(recoverCommittedNativeChatResponse(db, f.input)).resolves.toBe(false); + expect(await comments(f.runId)).toEqual([]); + }); }); diff --git a/server/src/services/native-runtime/native-governance.ts b/server/src/services/native-runtime/native-governance.ts index 84b7c5c5b80..9be92563bf6 100644 --- a/server/src/services/native-runtime/native-governance.ts +++ b/server/src/services/native-runtime/native-governance.ts @@ -103,3 +103,23 @@ export async function conversationTurnAnswered(input: { // status gate, but it does not present the turn. return !(await pendingNativeInteraction(input)); } + +/** + * The one rule for whether a successful conversation turn's prose is its + * answer. Live run completion and background presentation recovery both use + * it, so a restart between the status commit and the live presentation + * presents the same reply the live path would have. + */ +export async function conversationTurnPresentsAnswer(input: { + db: Db; + companyId: string; + issueId: string; + runId: string; + finalizationPhase: unknown; + finalizationReasonCode: unknown; +}): Promise { + if (input.finalizationReasonCode === "conversation_turn_finished") return true; + // A turn left waiting on an approval, an execution stage or a blocker still + // answered the person. Only its own question card presents it instead. + return input.finalizationPhase === "committed" && await conversationTurnAnswered(input); +} diff --git a/server/src/services/native-runtime/native-response-presentation-query.ts b/server/src/services/native-runtime/native-response-presentation-query.ts index 87da12c4ff9..5ea7b2a3e2c 100644 --- a/server/src/services/native-runtime/native-response-presentation-query.ts +++ b/server/src/services/native-runtime/native-response-presentation-query.ts @@ -1,5 +1,5 @@ import { QueryBuilder } from "drizzle-orm/pg-core"; -import { and, asc, eq, exists, gt, inArray, isNotNull, isNull, notExists, or, sql } from "drizzle-orm"; +import { and, asc, eq, exists, gt, inArray, isNotNull, isNull, ne, notExists, or, sql } from "drizzle-orm"; import { chatActions, heartbeatRunEvents, heartbeatRuns, issues, nativeRunFinalizations, nativeRunResults, statusDecisions, statusDecisionEffects } from "@paperclipai/db"; import type { NativeResponsePresentationScope } from "../native-response-presentation-notifications.js"; @@ -42,9 +42,11 @@ export function nativeResponsePresentationCandidates(scope?: NativeResponsePrese or( and(eq(heartbeatRuns.status, "succeeded"), or(sql`(coalesce(${issues.conversationAgentId}::text, '') = '' or coalesce(${issues.conversationUserId}, '') = '')`, - and(eq(statusDecisions.reasonCode, "conversation_turn_finished"), + // An unsettled conversation turn. A held turn keeps the status its + // gate committed; recovery applies the live path's answered rule. + and(or(ne(statusDecisions.reasonCode, "conversation_turn_finished"), eq(issues.status, "in_progress")), eq(issues.conversationAgentId, heartbeatRuns.agentId), eq(issues.conversationUserId, heartbeatRuns.responsibleUserId), - eq(issues.status, "in_progress"), eq(issues.conversationState, "active"), + eq(issues.conversationState, "active"), or(isNull(issues.executionRunId), eq(issues.executionRunId, heartbeatRuns.id)), sql`${heartbeatRuns.contextSnapshot}->>'issueId' = ${issues.id}::text`, sql`jsonb_typeof(${heartbeatRuns.contextSnapshot}->'conversationSessionGeneration') = 'number'`, @@ -65,6 +67,9 @@ export function nativeResponsePresentationCandidates(scope?: NativeResponsePrese and(sql`(coalesce(${issues.conversationAgentId}::text, '') = '' or coalesce(${issues.conversationUserId}, '') = '')`, sql`${nativeRunResults.resultJson} #>> '{result,reportedWorkDisposition}' = 'needs_review'`, sql`${nativeRunResults.resultJson} #>> '{terminal,reportedWorkDisposition}' = 'needs_review'`), + and(sql`(coalesce(${issues.conversationAgentId}::text, '') <> '' and coalesce(${issues.conversationUserId}, '') <> '')`, + sql`${nativeRunResults.resultJson} #>> '{result,reportedWorkDisposition}' = 'yielded'`, + sql`${nativeRunResults.resultJson} #>> '{terminal,reportedWorkDisposition}' = 'yielded'`), )), and(sql`${nativeRunResults.resultJson} #>> '{result,reportedWorkDisposition}' = 'yielded'`, sql`${nativeRunResults.resultJson} #>> '{terminal,reportedWorkDisposition}' = 'yielded'`, diff --git a/server/src/services/native-runtime/native-run-finalizer.ts b/server/src/services/native-runtime/native-run-finalizer.ts index 1fae0f2768f..069a415d072 100644 --- a/server/src/services/native-runtime/native-run-finalizer.ts +++ b/server/src/services/native-runtime/native-run-finalizer.ts @@ -15,7 +15,7 @@ import { notifyCancelledWorkspacePreservation } from "../cancelled-workspace-pre import { notifyInstructionCollection } from "../instruction-collection-notifications.js"; import { readRequiredQualityReview } from "../required-quality-review.js"; import { NativeDeliverableEvidenceError, validateNativeDeliverableEvidence } from "./native-deliverable-feedback.js"; -import { pendingNativeGovernance } from "./native-governance.js"; +import { conversationTurnPresentsAnswer, pendingNativeGovernance } from "./native-governance.js"; import { notifyChatRunMilestones } from "../chat-run-milestone-notifications.js"; import { onboardingFirstTaskCompletionAllowed } from "../onboarding-first-task-admission.js"; import { notifyExecutionRunWork } from "../execution-control-notifications.js"; @@ -830,6 +830,7 @@ async function recoverCommittedNativeChatResponseInScope( let agentId: string | null = null; let materialized = false; let conversationReply = false; + let settledWithoutReply = false; const entered = beginRunPhase("response.transaction.entry"); try { materialized = await measureRunPhase("response.transaction", () => db.transaction(async (transaction) => { @@ -996,9 +997,13 @@ async function recoverCommittedNativeChatResponseInScope( return true; } const conversation = isConversation(issue); - const currentConversation = conversation && decision.reasonCode === "conversation_turn_finished" + // The turn is still the conversation's unsettled current turn. A held + // turn keeps whatever status its gate committed; a finished turn + // preserves in_progress. + const currentConversation = conversation && issue.conversationAgentId === run.agentId && issue.conversationUserId === run.responsibleUserId - && issue.status === "in_progress" && issue.conversationState === "active" + && (decision.reasonCode !== "conversation_turn_finished" || issue.status === "in_progress") + && issue.conversationState === "active" && (!issue.executionRunId || issue.executionRunId === run.id) && run.contextSnapshot?.issueId === issue.id && typeof run.contextSnapshot.conversationSessionGeneration === "number" @@ -1013,7 +1018,10 @@ async function recoverCommittedNativeChatResponseInScope( terminal.runTerminalState === "succeeded" && terminal.turnTerminalState === "completed" && ((result.reportedWorkDisposition === "done" && terminal.reportedWorkDisposition === "done") || - (!conversation && result.reportedWorkDisposition === "needs_review" && terminal.reportedWorkDisposition === "needs_review")) && + (!conversation && result.reportedWorkDisposition === "needs_review" && terminal.reportedWorkDisposition === "needs_review") || + // A conversation turn can yield while an approval, an execution + // stage or a blocker holds its task; it still answered the person. + (conversation && result.reportedWorkDisposition === "yielded" && terminal.reportedWorkDisposition === "yielded")) && (await resolveChatRunPresentationAuthorizationReason(tx, input)) === "internal_agent_write" ) { const rows = await tx.select({ seq: heartbeatRunEvents.seq, payload: heartbeatRunEvents.payload }) @@ -1049,6 +1057,10 @@ async function recoverCommittedNativeChatResponseInScope( )).orderBy(desc(issueComments.createdAt), desc(issueComments.id)); const resolved = resolveHeartbeatRunResponse({ resultJson: { ...record(run.resultJson), nativeResult: result }, + // The same answered rule as live run completion. + conversationTurnFinished: conversation && await conversationTurnPresentsAnswer({ + db: tx, ...input, finalizationPhase: coordinator.phase, finalizationReasonCode: decision.reasonCode, + }), existingComment: findHeartbeatRunCompletionComment(comments, run.resultJson), finalAgentMessage, }); @@ -1063,6 +1075,19 @@ async function recoverCommittedNativeChatResponseInScope( }).where(eq(heartbeatRuns.id, run.id)); return false; } + if (conversation && resolved.decision.commentAction === "none") { + // The turn's own question or confirmation card presents it, as on + // the live path. Record that decision once so the turn settles and + // leaves the pending presentation set. + await tx.update(heartbeatRuns).set({ + resultJson: sql`coalesce(${heartbeatRuns.resultJson}, '{}'::jsonb) || ${JSON.stringify({ + presentationDecision: { ...resolved.decision, + reasonCodes: [...resolved.decision.reasonCodes, "committed_conversation_interaction_presented"] }, + })}::jsonb`, updatedAt: new Date(), + }).where(eq(heartbeatRuns.id, run.id)); + settledWithoutReply = true; + return false; + } if (!resolved.text || resolved.decision.commentAction !== "create") return false; await notifyChatRunMilestones(tx, input); const comment = await issueService(db).addComment(input.issueId, resolved.text, @@ -1152,6 +1177,7 @@ async function recoverCommittedNativeChatResponseInScope( } finally { entered(); } + if (settledWithoutReply) await settleConversationTurnForRun(db, input.companyId, input.runId); if (materialized && agentId) { if (conversationReply) await settleConversationTurnForRun(db, input.companyId, input.runId); publishChatPublicationCommitSignal({ From 42bc238aa5a1208cabecba76773e0500fcd33959 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 9 Nov 2026 01:08:19 -0600 Subject: [PATCH 07/21] fix(runner): keep a restart continuation when the lost turn's terminal is delivered again (#20196) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - When a restart loses a native run's active turn, the run records it as `provider_turn_lost_on_restore` and continues in a same-conversation continuation turn > - #20118 taught the event consumer to skip a stale restart-lost terminal of another turn once a continuation is running. It applied only when the checkpoint already recorded the interruption > - When the checkpoint instead names the lost turn as active (the drain hit right after the first turn was submitted), the consumer starts the continuation on the first lost terminal. If the same lost terminal is then delivered again, it was taken as the run's terminal and failed the run > - This pull request applies the skip to any recovered run once its continuation has started. For a checkpointed interruption it still waits for the startup decision first > - The benefit is that a turn lost on restore always continues, however often its lost terminal is delivered ## Linked Issues or Issue Description Refs #20118 **What happened?** Local restart-chaos K13 regression storm w-20261109T055902-s9402 (master 9b71d03215): 94 of 95 passed. In t90 (claude M1 parent), run `e5eab3cb`: 1. The run submitted its first turn at 06:28:25, and a graceful drain followed at 06:28:28. 2. After restore at 06:28:41, the ACPX runner replayed the turn's accept and start, then reported `turn_lab_1abbeca2…` as `provider_turn_lost_on_restore`. 3. The controller started continuation `turn_lab_a3d009c6…`; the saved checkpoint names it as the active turn. 4. The run failed at 06:28:45 with `native_provider_terminal_failed: The runner restored the conversation after process loss, but the previous turn is no longer active.` and needed a Retry. **Expected behavior** Once the continuation is running, a later delivery of the lost turn's terminal is history, not the run's terminal. The run finishes on the continuation. **Steps to reproduce** 1. Recover a run whose checkpoint names the interrupted turn as active (not yet recorded as terminal). 2. Deliver that turn's restart-lost terminal, which starts the continuation, then deliver it again before the continuation completes. 3. Before this change, the second delivery ends the run as failed. **Paperclip version or commit** 9b71d03215 (wave). Branch rebased on current master. ## What Changed - `native-session-runtime.ts`: `supersededByRestartContinuation` applies to every recovered run. - Once a continuation has started, a restart-lost `turn.failed` for any turn other than that continuation keeps the consumer going. - With a checkpointed interruption, it still waits for the startup decision first. Before this change it excluded the checkpointed interrupted turn itself, which could also end a run if that turn's replayed terminal arrived before the startup continuation began. - `native-session-runtime.test.ts`: - a lost terminal delivered again after the continuation starts; - the checkpointed interrupted turn's own terminal arriving before the continuation starts. ## Verification - Red/green: both new cases fail on master (the run ends failed or on the stale terminal) and pass with the change. The #20118 cases still pass. - `vitest run` over the runner restart, recovery, continuation and terminal test files: 9 files, 389 tests passed. - `PAPERCLIP_TYPECHECK_LOW_MEMORY=1 pnpm run typecheck:typescript` (`packages/paperclip-runner`): passed. ## Risks - Low risk. The skip still requires a recovered run, a recoverable `provider_turn_lost_on_restore` failure, a continuation that actually started, and a terminal for a different turn than that continuation. A loss of the continuation itself, real provider failures, and runs that started no continuation keep their current handling. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), via Claude Code with tool use (shell, file edits, test execution). ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- .../src/native-session-runtime.test.ts | 61 +++++++++++++++++-- .../src/native-session-runtime.ts | 15 +++-- 2 files changed, 65 insertions(+), 11 deletions(-) diff --git a/packages/paperclip-runner/src/native-session-runtime.test.ts b/packages/paperclip-runner/src/native-session-runtime.test.ts index fd5d7939cab..e09f3a35dad 100644 --- a/packages/paperclip-runner/src/native-session-runtime.test.ts +++ b/packages/paperclip-runner/src/native-session-runtime.test.ts @@ -587,9 +587,10 @@ describe("executeNativeSession recovery", () => { }); it.each([ - { label: "after the continuation starts", staleFirst: false }, - { label: "before the continuation starts", staleFirst: true }, - ])("keeps the restart continuation when the restored runner reports an earlier unrecorded continuation lost $label", async ({ staleFirst }) => { + { label: "after the continuation starts", staleFirst: false, staleTurnId: "turn-unrecorded-continuation" }, + { label: "before the continuation starts", staleFirst: true, staleTurnId: "turn-unrecorded-continuation" }, + { label: "as the checkpointed interrupted turn before the continuation starts", staleFirst: true, staleTurnId: "turn-recovery" }, + ])("keeps the restart continuation when the restored runner reports an earlier unrecorded continuation lost $label", async ({ staleFirst, staleTurnId }) => { let staleAppended!: () => void; const staleTerminal = new Promise(resolve => { staleAppended = resolve; }); const failure = { code: "provider_turn_lost_on_restore", recoverable: true }; @@ -607,7 +608,7 @@ describe("executeNativeSession recovery", () => { identity: () => identity, async capabilities() { return { resume: true, typedEvents: true, steering: false, interruption: true, structuredResult: true }; }, async *events() { - yield { ...runnerEvent(1, "turn.failed", { status: "failed", error: failure }), turnId: "turn-unrecorded-continuation" }; + yield { ...runnerEvent(1, "turn.failed", { status: "failed", error: failure }), turnId: staleTurnId }; yield { ...runnerEvent(2, "turn.completed", { status: "completed" }), turnId: "turn-continuation" }; }, startTurn, @@ -639,7 +640,7 @@ describe("executeNativeSession recovery", () => { })).resolves.toMatchObject({ result, turnId: "turn-continuation", terminal: { runTerminalState: "succeeded" } }); expect(startTurn).toHaveBeenCalledOnce(); expect((startTurn.mock.calls[0]![0] as Parameters[0]).continuation).toBe(true); - expect(events.filter(event => event.eventType === "turn.failed").map(event => event.turnId)).toEqual(["turn-unrecorded-continuation"]); + expect(events.filter(event => event.eventType === "turn.failed").map(event => event.turnId)).toEqual([staleTurnId]); }); it("recovers a restart continuation whose checkpoint still carries the lost turn's failed terminal", async () => { @@ -689,6 +690,56 @@ describe("executeNativeSession recovery", () => { .toBe(false); }); + it.each([ + { label: "after it starts", proposedFirst: false }, + { label: "after the continuation proposed its result", proposedFirst: true }, + ])("keeps the restart continuation when the lost turn's terminal is delivered again $label", async ({ proposedFirst }) => { + const failure = { code: "provider_turn_lost_on_restore", recoverable: true }; + const checkpoint: PersistedNativeSession = { + backendKind: "runner", driverKind: "acpx_runtime", sessionId: "record-recovery", identity, + providerSessionId: "provider-recovery", cursor: "0", activeTurnId: "turn-recovery", + terminalTurns: [], semanticResult: null, pendingRuntimeRequests: [], lineage: [], + }; + const interrupted: PersistedNativeSession = { + ...checkpoint, activeTurnId: null, + terminalTurns: [{ turnId: "turn-recovery", fingerprint: JSON.stringify({ terminalState: "failed", error: failure, result: null }) }], + }; + const startTurn = vi.fn(async () => ({ turnId: "turn-continuation" })); + const session: NativeSession = { + identity: () => identity, + async capabilities() { return { resume: true, typedEvents: true, steering: false, interruption: true, structuredResult: true }; }, + async *events() { + yield runnerEvent(1, "turn.failed", { status: "failed", error: failure }); + if (!startTurn.mock.calls.length) return; + if (proposedFirst) yield { ...runnerEvent(2, "run.result.proposed", result), turnId: "turn-continuation" }; + yield runnerEvent(3, "turn.failed", { status: "failed", error: failure }); + yield { ...runnerEvent(4, "turn.completed", { status: "completed" }), turnId: "turn-continuation" }; + }, + startTurn, + async result() { return startTurn.mock.calls.length ? { result, terminal, turnId: "turn-continuation" } : null; }, + async snapshot() { return structuredClone(interrupted); }, + async close() {}, + }; + const backend: NativeSessionBackend = { + async descriptor() { return { kind: "runner", name: "restart", version: "1", capabilities: await session.capabilities() }; }, + async openSession() { throw new Error("must preserve the provider session"); }, + async recoverSession() { return { recovered: true, session }; }, + }; + const port: ControlPlanePort = { + async openRun() {}, + async checkpointSession() {}, + async appendEvent(event: PrpEvent) { return { cursor: event.sourceSeq, highestContiguousSourceSeq: event.sourceSeq, disposition: "committed" }; }, + async replayEvents() { return { events: [], highestContiguousSourceSeq: 0 }; }, + async completeRun() {}, + }; + await expect(executeNativeSession({ + input, backend, controlPlane: port, persistedSession: checkpoint, + runnerInstanceId: "runner-recovery", controlPlaneInstanceId: "control-recovery", + resumeInterruptedTurn: true, + })).resolves.toMatchObject({ result, turnId: "turn-continuation", terminal: { runTerminalState: "succeeded" } }); + expect(startTurn).toHaveBeenCalledOnce(); + }); + it.each(["acpx_runtime", "opencode_server"] as const)("continues a restart-lost %s turn once in the same conversation", async (driverKind) => { const qualified = resolveQualifiedAcpxProfile("claude", "claude-sonnet-5"); const acpxInput: NativeExecutionInputV1 = (driverKind === "acpx_runtime" ? { diff --git a/packages/paperclip-runner/src/native-session-runtime.ts b/packages/paperclip-runner/src/native-session-runtime.ts index 6246376bee9..995b143d4f2 100644 --- a/packages/paperclip-runner/src/native-session-runtime.ts +++ b/packages/paperclip-runner/src/native-session-runtime.ts @@ -1111,6 +1111,7 @@ async function consumeTurn( initialGovernedWait?: PersistedNativeSession["governedWait"], /** Settles a compaction-only turn, which reports no semantic result. */ compactionResult?: PrpStructuredRunResult, + supersededTerminal?: (event: PrpEvent, signal: AbortSignal) => Promise, ) { let timer: ReturnType | undefined; const appendAbort = new AbortController(); @@ -1504,6 +1505,7 @@ async function consumeTurn( } } if (isTurnTerminal(event)) { + if (await supersededTerminal?.(event, appendAbort.signal)) continue; if (providerFailure) throw providerFailure; if (compactionResult && event.eventType === "turn.completed") { return { @@ -2752,11 +2754,12 @@ export async function executeNativeSession( return true; }; const supersededByRestartContinuation = async (event: PrpEvent, signal: AbortSignal) => { - if (!recovered || !checkpointedInterruption || event.eventType !== "turn.failed" || - !isNativeRestartInterruption(event.payload.error) || !event.turnId || - event.turnId === checkpointedInterruption) return false; - await startupDecision; - signal.throwIfAborted(); + if (!recovered || event.eventType !== "turn.failed" || + !isNativeRestartInterruption(event.payload.error) || !event.turnId) return false; + if (checkpointedInterruption) { + await startupDecision; + signal.throwIfAborted(); + } if (!restartContinuationStarted) return false; await continuationSetup?.catch(() => undefined); signal.throwIfAborted(); @@ -2764,7 +2767,6 @@ export async function executeNativeSession( }; const resumeInterruptedTurn = async (event: PrpEvent, signal: AbortSignal) => { if (await requestContinuationDisposition(event, signal)) return true; - if (await supersededByRestartContinuation(event, signal)) return true; if (restartContinuationStarted || !recovered || !options.resumeInterruptedTurn || // A compaction-only run never continues task work after a restart. // Runnerd records a restart-lost turn only for these providers, after @@ -2948,6 +2950,7 @@ export async function executeNativeSession( options.sessionCompaction ? sessionCompactionResult(input, options.sessionCompaction.requestId) : undefined, + supersededByRestartContinuation, ) : Promise.resolve({ event: recoveryTerminal, From 6fd46c0025c9c1cc6236c1bb186bac76c4a7d2ad Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 9 Nov 2026 01:22:28 -0600 Subject: [PATCH 08/21] perf(budgets): judge Paperclip's share of restart recovery; report the provider's session load (#20183) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path - Speed budgets protect the time to first visible agent activity, and restart recovery is one of the judged metrics (`native-speed-budgets.ts`, wave grader). - In the B1 concurrency-6 chaos wave (`w-20261109T045320-s9807`), Codex `restart_recovery` was p50 8.6 s and p95 11.2 s, against a budget of 6.5 s and 8.0 s. At concurrency 4 (K13) it was 3.8 s and 5.0 s. - The preparation spans on a copy of the kept database break it down: - Paperclip's share (ready → claim → prepare → runtime stage → runner launch) is a steady 1.2–2.0 s. - `provider.session.load`, the Codex app-server boot plus `thread/resume` of long saved sessions, takes 3.0–6.3 s, 65–75% of each recovery. - Resumes run concurrently, and the worktree lock and the startup reconciler are not on the path. - Judging the full latency therefore mixes our code with provider and session-size effects. Budget the part we control, so a regression in our code shows up regardless of session size or concurrency, and report the rest. ## Linked Issues or Issue Description **What happened?** `restart_recovery` breached its budget whenever many long provider sessions resumed at once, although Paperclip's own recovery work stayed within about 2 s. The breach did not point at anything Paperclip could fix, and a real regression in our code could hide inside the provider-load variance. **Steps to reproduce** 1. Run a local chaos wave at concurrency 6 with long-running Codex tasks and K1/K2/K4 faults (for example B1, seed 9807). 2. Compare `restart_recovery` in `speed.json` with `provider.session.load` in the recovered runs' run logs. **Expected behavior** The budget judges Paperclip's share of a recovery. The provider's session load and the full latency are reported but not judged. ## What Changed - `server/src/services/native-runtime/native-speed-budgets.ts`: - New budgeted metric `restart_recovery_control_plane`: from `ready` to the start of `provider.session.load`, or to the next provider event when nothing reloads (for example a reattached runner). - Its budget is 2.5 s / 4.0 s for claude, codex and opencode. Measured: 1.2–2.0 s per run at concurrency 6. - The full `restart_recovery` stays in the table for reporting. - Breach payloads carry `restartTotalMs` and `providerLoadMs`. - `server/src/services/native-runtime/native-run-trace.ts`: - The per-run check records when `provider.session.load` starts, keeps the recovery measurement, and judges it when the run finishes. - It judges after the deferred diagnostics drain, so a provider span committed after the provider event still counts. - It emits `restart_recovery_control_plane` breaches only. - `tests/restart-chaos/ttft-baseline-stats.ts`: each recovery sample gains `controlPlaneMs` and `providerLoadMs`. The provider-load start is the span event's write time minus its duration. - `tests/restart-chaos/wave-speed.ts`: - Three restart rows per adapter: `restart_recovery` (report-only), `restart_recovery_control_plane` (budgeted) and `restart_provider_load` (report-only). - All three have outage time removed. - `tests/restart-chaos/ttft-baseline.ts`: the recovery table adds a "Paperclip share" column. - Docs: `doc/run-log-events.md` (metric and payload) and the speed-budget table in `doc/plans/2026-11-07-durable-ownership-and-reconciliation.md`. ## Verification - `server`: `npx vitest run src/services/native-runtime/native-speed-budgets.test.ts`, 18 passed. Two new tests: - a recovery dominated by provider load does not breach; - a slow Paperclip share breaches with `restartTotalMs` and `providerLoadMs`, even when the provider span is committed after the provider event. The existing recovery tests moved to the new metric. All six were red before the trace change. - `tests/restart-chaos`: `npx vitest run --config tests/restart-chaos/vitest.config.ts wave-speed.test.ts ttft-baseline-stats.test.ts`, 29 passed. New tests cover the sample split and the grader rows (a judged share, a report-only provider load, zero breaches). - Server typecheck: no errors in the touched files. ## Risks - **Lower sensitivity to provider-side slowdowns.** A provider regression in session load no longer breaches. It stays visible in the report-only rows and in breach payloads. - **Runs with no `provider.session.load` span** (reattach, or an adapter that doesn't emit it) are judged on the full latency against the control-plane budget. That's conservative. - **Run-log consumers that filter on `metric: "restart_recovery"`** see `restart_recovery_control_plane` from now on. The doc notes the change date. ## Model Used Claude Opus 5.5 (`claude-opus-5-5`, Anthropic), 1M context window, tool use and code execution, via Claude Code. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- ...07-durable-ownership-and-reconciliation.md | 6 ++ doc/run-log-events.md | 18 ++++- .../native-runtime/native-run-trace.ts | 55 ++++++++++++-- .../native-runtime/native-session-executor.ts | 33 ++++++++- .../native-speed-budgets.test.ts | 73 +++++++++++++++++-- .../native-runtime/native-speed-budgets.ts | 27 ++++++- .../restart-chaos/ttft-baseline-stats.test.ts | 36 ++++++++- tests/restart-chaos/ttft-baseline-stats.ts | 47 +++++++++++- tests/restart-chaos/ttft-baseline.ts | 5 +- tests/restart-chaos/wave-speed.test.ts | 20 ++++- tests/restart-chaos/wave-speed.ts | 36 ++++++--- 11 files changed, 320 insertions(+), 36 deletions(-) diff --git a/doc/plans/2026-11-07-durable-ownership-and-reconciliation.md b/doc/plans/2026-11-07-durable-ownership-and-reconciliation.md index f20302066b8..46b73a402a2 100644 --- a/doc/plans/2026-11-07-durable-ownership-and-reconciliation.md +++ b/doc/plans/2026-11-07-durable-ownership-and-reconciliation.md @@ -99,6 +99,12 @@ Restart recovery latency, from `ready` to the recovered run's next provider even | codex | 6.5 / 8.0 | | opencode | 25.5 / 38.5 | +Since 2026-11-09 this full latency is reported, not judged: the provider's saved-session load (`provider.session.load`) grows with session size and with how many sessions resume at once (Codex at concurrency 6: 3.0–6.3 s of a 5.5–8.5 s recovery). The judged row is Paperclip's own share, `restart_recovery_control_plane`: from `ready` to the start of the provider's session load, or to the next provider event when nothing reloads. It covers claim, prepare, runtime stage and runner launch, which measured 1.2–2.0 s per run with six Codex runs resuming at once (wave `w-20261109T045320-s9807`). + +| Adapter | Paperclip share budget (s, p50 / p95) | +|---|---| +| claude, codex, opencode | 2.5 / 4.0 | + These budgets measure the product default `lifecycleMode: per_turn`. Phase 1's TTFT work (runner and provider-session reuse, baseline finding 1) should lower them. Lower budgets go in the same table. ### Phase 1 report-only data: local chaos waves (2026-11-07) diff --git a/doc/run-log-events.md b/doc/run-log-events.md index e6f63526d73..fa2a6c92db9 100644 --- a/doc/run-log-events.md +++ b/doc/run-log-events.md @@ -64,6 +64,11 @@ instance run log and add no first-party Telemetry or OpenTelemetry export. ## Provider-pack preparation timing +Every `run.performance.span` payload also carries `wallStartedAtMs`, the span's own +start time in epoch milliseconds (since 2026-11-09). Span rows can be written late +through deferred diagnostics, so readers take a span's start from this field (or +from the trace start plus `startOffsetMs`), never from the row's write time. + `run.performance.span` also records `task.time_to_first_token` once per native dispatch at the first newly committed deliverable text: assistant or reasoning `item.delta`/`item.completed`, a redaction tail released at completion, or an @@ -97,8 +102,15 @@ budgets come from the Phase 0 local baseline and are listed in the first text delta, tool-call start or reasoning item; named `ttft` and judged on `task.time_to_first_token` before 2026-11-08), `agent_boot` (end of `heartbeat.queue` to the end of `runner.turn.submit` or `runner.session.resume`) -or `restart_recovery` (startup recovery `ready` to the recovered run's first -provider event). The payload names `adapter` (`codex`, `claude` or `opencode`), +or `restart_recovery_control_plane` (startup recovery `ready` to the start of the +provider's saved-session load, `provider.session.load`, or to the recovered run's +first provider event when nothing reloads). That is Paperclip's own share of a +recovery: claim, prepare, runtime stage and runner launch. The full recovery +(`ready` to the first provider event) and the provider's session load grow with +session size and with how many sessions resume at once, so they are reported in +the payload as `restartTotalMs` and `providerLoadMs` and are not judged; events +written before 2026-11-09 judged the full latency as `restart_recovery`. +The payload names `adapter` (`codex`, `claude` or `opencode`), `environment` (`local`), `startKind` (`cold`, `warm` or `unknown`; an unknown start uses the looser budget), `measuredMs`, `budgetP50Ms`, `budgetP95Ms`, `percentile` (`p95`), `baseline`, and, for restart recovery, `disposition`. @@ -106,7 +118,7 @@ For `time_to_first_activity`, `measuredMs` counts from run creation: when the in run to exist (for example a comment posted while the previous turn was still running), that wait is excluded from the judgment and reported as `inputWaitMs`. Each metric is checked at most once per dispatch. A restart-recovery dispatch is -judged only on `restart_recovery`, because its first activity and boot span the outage. Time to first text (`task.time_to_first_token`) is reported without a budget. Remote runs and other adapters have +judged only on `restart_recovery_control_plane`, because its first activity and boot span the outage. Time to first text (`task.time_to_first_token`) is reported without a budget. Remote runs and other adapters have no budget yet. The event is written in detached diagnostic work after the measurement, so it adds no wait before `turn.start`. It is a run-log row only and never goes to Telemetry. diff --git a/server/src/services/native-runtime/native-run-trace.ts b/server/src/services/native-runtime/native-run-trace.ts index 137ca901612..9224fd9dfee 100644 --- a/server/src/services/native-runtime/native-run-trace.ts +++ b/server/src/services/native-runtime/native-run-trace.ts @@ -262,11 +262,16 @@ function safeAttributes( * seams inherit that context. The persisted run-event copy retains the same * semantic parent name for developer installs without an OTLP collector. */ +/** The runner span for the provider loading its saved session on resume. */ +const PROVIDER_SESSION_LOAD_SPAN = "provider.session.load"; + export function createNativeRunTrace(input: { runId: string; startedAtMs?: number; onEvent?: NativeRunTraceSink; traceContext?: StartupTraceContextHandle; + /** Called once when the trace finishes. */ + onFinish?: () => void; /** Speed-budget subject (invariant 8). Absent or remote means no check. */ budget?: { adapter: SpeedBudgetAdapter | null; @@ -279,6 +284,8 @@ export function createNativeRunTrace(input: { const tracing = input.traceContext ?? getStartupTraceContext("paperclip.native-runner"); const traceStartedAtMs = finiteMs(input.startedAtMs ?? Date.now()); + /** Start times of the provider's saved-session loads (restart recovery split). */ + const providerLoadStartsMs: number[] = []; const runIdHash = hashedId(input.runId); let rootSpan: SpanHandle; try { @@ -429,6 +436,9 @@ export function createNativeRunTrace(input: { durationMs, outcome, ...attributes, + // The span's own start time. The row can be written late (deferred + // diagnostics), so readers must not infer the start from its write time. + wallStartedAtMs: startedAtMs, }, }); } catch { @@ -447,6 +457,11 @@ export function createNativeRunTrace(input: { } = {}, ): NativeRunSpanScope => { const startedAtMs = finiteMs(options.startedAtMs ?? Date.now()); + if (name === PROVIDER_SESSION_LOAD_SPAN) { + providerLoadStartsMs.push(startedAtMs); + // The measurement can arrive before its provider span is recorded; judge now. + if (pendingRecovery) judgeRestartRecovery(); + } const attributes = safeAttributes(options.attributes); const parent = parentFor(options.parentName); let span: SpanHandle; @@ -635,8 +650,13 @@ export function createNativeRunTrace(input: { const finish = async (outcome: NativeRunSpanOutcome): Promise => { if (ended) return; + try { input.onFinish?.(); } catch { /* Diagnostics cannot change runner control flow. */ } // Settled diagnostics can defer more (a budget breach after a deferred - // measurement), so drain until none remain. + // measurement), so drain until none remain. Judge restart recovery after + // the first drain, so a deferred provider span is counted, then drain its + // breach report. + while (pendingDiagnostics.size > 0) await Promise.all([...pendingDiagnostics]); + judgeRestartRecovery(); while (pendingDiagnostics.size > 0) await Promise.all([...pendingDiagnostics]); if (ended) return; ended = true; @@ -666,8 +686,28 @@ export function createNativeRunTrace(input: { }; /** Restart recovery latency: control plane ready to the recovered run's - * next provider event, checked once per run. */ + * next provider event, checked once per run. Only Paperclip's share is + * judged: ready to the start of the provider's session load (or to the + * event when nothing reloads). It is judged as soon as both are known: at + * the check when the load span is already recorded or nothing reloads (a + * reattached runner, whose dispositions launch nothing), when the load span is recorded, and otherwise when + * the run finishes. A run that detaches for another restart therefore keeps + * its warning unless the detach lands between the event and a deferred span. */ let recoveryChecked = false; + let pendingRecovery: RestartRecoveryMeasurement | null = null; + const judgeRestartRecovery = (): void => { + const measurement = pendingRecovery; + pendingRecovery = null; + if (!input.budget || !measurement || measurement.readyAtMs === null) return; + const readyAtMs = measurement.readyAtMs; + const loads = providerLoadStartsMs.filter(at => at >= readyAtMs && at <= measurement.committedAtMs); + const loadStartedAtMs = loads.length ? Math.min(...loads) : null; + const totalMs = measurement.committedAtMs - readyAtMs; + reportBudgetBreach(evaluateSpeedBudget({ adapter: input.budget.adapter, environment: input.budget.environment, + metric: "restart_recovery_control_plane", start: null, measuredMs: (loadStartedAtMs ?? measurement.committedAtMs) - readyAtMs, + disposition: measurement.disposition, restartTotalMs: totalMs, + providerLoadMs: loadStartedAtMs === null ? 0 : measurement.committedAtMs - loadStartedAtMs })); + }; const checkRestartRecovery = (measurement: RestartRecoveryMeasurement): void => { if (!input.budget || recoveryChecked || measurement.readyAtMs === null) return; // An event emitted before ready (buffered by a surviving runner and @@ -676,15 +716,20 @@ export function createNativeRunTrace(input: { if (measurement.emittedAtMs !== null && measurement.emittedAtMs < measurement.readyAtMs) return; if (measurement.committedAtMs < measurement.readyAtMs) return; recoveryChecked = true; - reportBudgetBreach(evaluateSpeedBudget({ adapter: input.budget.adapter, environment: input.budget.environment, - metric: "restart_recovery", start: null, measuredMs: measurement.committedAtMs - measurement.readyAtMs, - disposition: measurement.disposition })); + pendingRecovery = measurement; + const readyAtMs = measurement.readyAtMs; + // Only a launched runner reloads a provider session (native-restart-recovery). + if (!["resume_dead_runner", "bootstrap_incomplete"].includes(measurement.disposition) + || providerLoadStartsMs.some(at => at >= readyAtMs && at <= measurement.committedAtMs)) judgeRestartRecovery(); }; return { traceStartedAtMs, setBudgetStart, checkRestartRecovery, + /** Judge a pending restart-recovery check now (the controller is detaching + * this run for another restart and will not finish the trace). */ + flushRestartRecovery: () => judgeRestartRecovery(), start, annotate, activate, diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index 3e46964c3f9..8512171085d 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -380,6 +380,9 @@ export class NativeControllerDetachedForRestartError extends Error { const activeNativeSessions = new Map(); // Shutdown can race provider startup before onSession publishes its handle. const nativeRunsDetachingForRestart = new Map(); +/** Flushes a run's pending restart-recovery budget check when the controller + * detaches it for another restart (its trace never finishes). */ +const nativeRunRecoveryFlushers = new Map void>(); // A restart must not cut authority rotation between archiving the previous // runner state and authenticating its replacement. Keep the startup owner @@ -465,6 +468,7 @@ export async function detachNativeSessionsForRestart( continue; } nativeRunsDetachingForRestart.set(runId, Symbol("native controller detach")); + try { nativeRunRecoveryFlushers.get(runId)?.(); } catch { /* Diagnostics cannot change detach. */ } let active = activeNativeSessions.get(runId); if (!active) { // Only a startup registered before this fence may complete read-only @@ -9592,6 +9596,24 @@ async function executePaperclipNativeSessionWithinScope( prepareAfterClaim?: () => Promise[0]>, cleanupOnly = false, ownDiagnosticLease?: (lease: NativeDiagnosticLease) => void, +): Promise { + // Drop this attempt's restart-recovery flush however it ends (a failure that + // never finishes its trace included), without touching a newer attempt's. + const runId = input.execution.binding.runId; + const priorFlush = nativeRunRecoveryFlushers.get(runId); + try { + return await executePaperclipNativeSessionAttempt(input, prepareAfterClaim, cleanupOnly, ownDiagnosticLease); + } finally { + const current = nativeRunRecoveryFlushers.get(runId); + if (current !== undefined && current !== priorFlush) nativeRunRecoveryFlushers.delete(runId); + } +} + +async function executePaperclipNativeSessionAttempt( + input: Parameters[0], + prepareAfterClaim?: () => Promise[0]>, + cleanupOnly = false, + ownDiagnosticLease?: (lease: NativeDiagnosticLease) => void, ): Promise { if (input.execution.provider.kind === "acpx" && input.execution.provider.agent === "antigravity") { const candidate = resolveAntigravityCandidateAdmission({ @@ -9649,16 +9671,25 @@ async function executePaperclipNativeSessionWithinScope( preparationSpans, Date.now(), ); + const traceRunId = input.execution.binding.runId; + const flushRecovery = () => { + if (nativeRunRecoveryFlushers.get(traceRunId) === flushRecovery) nativeRunRecoveryFlushers.delete(traceRunId); + trace.flushRestartRecovery(); + }; const trace = createNativeRunTrace({ - runId: input.execution.binding.runId, + runId: traceRunId, startedAtMs: preparationStarts.runStartedAtMs, onEvent: input.onEvent, + onFinish: () => { + if (nativeRunRecoveryFlushers.get(traceRunId) === flushRecovery) nativeRunRecoveryFlushers.delete(traceRunId); + }, budget: { adapter: speedBudgetAdapter(input.execution.provider), environment: !input.runnerExecutionTarget || input.runnerExecutionTarget.kind === "local" ? "local" : "remote", restartRecovery: Boolean(input.restartRecovery), }, }); + nativeRunRecoveryFlushers.set(traceRunId, flushRecovery); await emitStartupReuse(input.onEvent, "environment", environmentStartupReuse(input.runnerExecutionTarget)); if (!input.runnerExecutionTarget || input.runnerExecutionTarget.kind === "local") { await emitStartupReuse(input.onEvent, "workspace", { state: "not_applicable", reason: "not_applicable" }); diff --git a/server/src/services/native-runtime/native-speed-budgets.test.ts b/server/src/services/native-runtime/native-speed-budgets.test.ts index 1b54906f324..523d377fd73 100644 --- a/server/src/services/native-runtime/native-speed-budgets.test.ts +++ b/server/src/services/native-runtime/native-speed-budgets.test.ts @@ -134,12 +134,12 @@ describe("speed budget checks in the native run trace", () => { it("checks restart recovery latency once, from ready to the first provider event", async () => { const { trace, breaches } = traced({ adapter: "opencode", environment: "local" }); - const late = LOCAL_SPEED_BUDGETS.opencode.restart_recovery.p95Ms + 1_000; + const late = LOCAL_SPEED_BUDGETS.opencode.restart_recovery_control_plane.p95Ms + 1_000; trace.checkRestartRecovery({ readyAtMs: 10_000, emittedAtMs: 10_000 + late, committedAtMs: 10_000 + late, disposition: "resume_dead_runner" }); trace.checkRestartRecovery({ readyAtMs: 10_000, emittedAtMs: 10_000 + late * 2, committedAtMs: 10_000 + late * 2, disposition: "resume_dead_runner" }); await trace.finish("ok"); - expect(breaches()).toEqual([expect.objectContaining({ metric: "restart_recovery", measuredMs: late, + expect(breaches()).toEqual([expect.objectContaining({ metric: "restart_recovery_control_plane", measuredMs: late, disposition: "resume_dead_runner", startKind: "unknown" })]); }); @@ -148,22 +148,79 @@ describe("speed budget checks in the native run trace", () => { await trace.record({ name: "heartbeat.queue", startedAtMs: 0, endedAtMs: 1 }); await trace.record({ name: "runner.session.resume", startedAtMs: 1, endedAtMs: 120_000 }); await trace.record({ name: "task.time_to_first_agent_activity", startedAtMs: 0, endedAtMs: 120_000 }); - const at = LOCAL_SPEED_BUDGETS.codex.restart_recovery.p95Ms + 1; + const at = LOCAL_SPEED_BUDGETS.codex.restart_recovery_control_plane.p95Ms + 1; trace.checkRestartRecovery({ readyAtMs: 0, emittedAtMs: at, committedAtMs: at, disposition: "resume_dead_runner" }); await trace.finish("ok"); - expect(breaches().map(breach => breach.metric)).toEqual(["restart_recovery"]); + expect(breaches().map(breach => breach.metric)).toEqual(["restart_recovery_control_plane"]); + }); + + it("judges only Paperclip's share of a recovery, not the provider's session load", async () => { + const { trace, breaches } = traced({ adapter: "codex", environment: "local", restartRecovery: true }); + const total = LOCAL_SPEED_BUDGETS.codex.restart_recovery.p95Ms + 3_000; + // Ready → claim, prepare and launch in 1.5 s; the provider reloads its long session. + await trace.record({ name: "provider.session.load", startedAtMs: 1_500, endedAtMs: total - 500 }); + trace.checkRestartRecovery({ readyAtMs: 0, emittedAtMs: total, committedAtMs: total, disposition: "resume_dead_runner" }); + await trace.finish("ok"); + expect(breaches()).toEqual([]); + }); + + it("reports a slow control-plane share with the full latency and the provider's share", async () => { + const { trace, breaches } = traced({ adapter: "codex", environment: "local", restartRecovery: true }); + const control = LOCAL_SPEED_BUDGETS.codex.restart_recovery_control_plane.p95Ms + 2_000; + trace.checkRestartRecovery({ readyAtMs: 0, emittedAtMs: control + 3_000, committedAtMs: control + 3_000, disposition: "resume_dead_runner" }); + // The provider span is committed after the provider event that ended the recovery. + await trace.record({ name: "provider.session.load", startedAtMs: control, endedAtMs: control + 2_800 }); + await trace.finish("ok"); + expect(breaches()).toEqual([expect.objectContaining({ metric: "restart_recovery_control_plane", measuredMs: control, + restartTotalMs: control + 3_000, providerLoadMs: 3_000 })]); + }); + + it("flushes a pending recovery check when the controller detaches the run", async () => { + const { trace, breaches } = traced({ adapter: "codex", environment: "local", restartRecovery: true }); + const late = LOCAL_SPEED_BUDGETS.codex.restart_recovery_control_plane.p95Ms + 1_000; + // A launched runner whose load span has not been written yet. + trace.checkRestartRecovery({ readyAtMs: 0, emittedAtMs: late, committedAtMs: late, disposition: "resume_dead_runner" }); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(breaches()).toEqual([]); + trace.flushRestartRecovery(); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(breaches()).toEqual([expect.objectContaining({ metric: "restart_recovery_control_plane", measuredMs: late })]); + }); + + it("logs each span's own start time in its run-log row", async () => { + const events: AdapterRuntimeEvent[] = []; + const trace = createNativeRunTrace({ runId: "run-1", startedAtMs: 1_000, onEvent: async event => { events.push(event); } }); + await trace.record({ name: "provider.session.load", startedAtMs: 4_000, endedAtMs: 9_000 }); + await trace.finish("ok"); + expect(events.find(event => (event.payload as Record)?.span === "provider.session.load")?.payload) + .toMatchObject({ startOffsetMs: 3_000, durationMs: 5_000, wallStartedAtMs: 4_000 }); + }); + + it("judges a recovery before the run finishes, so a later detach for restart keeps the warning", async () => { + const control = LOCAL_SPEED_BUDGETS.codex.restart_recovery_control_plane.p95Ms + 1_000; + // The load span arrives after the provider event: judged when it is recorded. + const resumed = traced({ adapter: "codex", environment: "local", restartRecovery: true }); + resumed.trace.checkRestartRecovery({ readyAtMs: 0, emittedAtMs: control + 2_000, committedAtMs: control + 2_000, disposition: "resume_dead_runner" }); + await resumed.trace.record({ name: "provider.session.load", startedAtMs: control, endedAtMs: control + 1_900 }); + // A reattached runner reloads nothing: judged at the check. + const reattached = traced({ adapter: "codex", environment: "local", restartRecovery: true }); + reattached.trace.checkRestartRecovery({ readyAtMs: 0, emittedAtMs: control, committedAtMs: control, disposition: "reattach_existing_runner" }); + // Neither trace finishes (the controller detached for another restart). + await new Promise(resolve => setTimeout(resolve, 0)); + expect(resumed.breaches()).toEqual([expect.objectContaining({ metric: "restart_recovery_control_plane", measuredMs: control })]); + expect(reattached.breaches()).toEqual([expect.objectContaining({ metric: "restart_recovery_control_plane", measuredMs: control, providerLoadMs: 0 })]); }); it("does not spend the restart-recovery check on an event emitted before ready", async () => { const { trace, breaches } = traced({ adapter: "codex", environment: "local", restartRecovery: true }); - const late = LOCAL_SPEED_BUDGETS.codex.restart_recovery.p95Ms + 1_000; + const late = LOCAL_SPEED_BUDGETS.codex.restart_recovery_control_plane.p95Ms + 1_000; // Buffered during the outage, committed after ready. trace.checkRestartRecovery(restartRecoveryMeasurementFor({ event: { eventType: "item.delta", emittedAt: new Date(9_000).toISOString() }, startup: { phase: "ready", updatedAt: new Date(10_000).toISOString() }, committedAtMs: 10_500, disposition: "reattach_existing_runner" })!); trace.checkRestartRecovery(restartRecoveryMeasurementFor({ event: { eventType: "item.delta", emittedAt: new Date(10_000 + late - 200).toISOString() }, startup: { phase: "ready", updatedAt: new Date(10_000).toISOString() }, committedAtMs: 10_000 + late, disposition: "reattach_existing_runner" })!); await trace.finish("ok"); - expect(breaches()).toEqual([expect.objectContaining({ metric: "restart_recovery", measuredMs: late })]); + expect(breaches()).toEqual([expect.objectContaining({ metric: "restart_recovery_control_plane", measuredMs: late })]); }); it("measures a resumed attempt's boot from its own claim, not the run's first queue span", async () => { @@ -189,11 +246,11 @@ describe("speed budget checks in the native run trace", () => { it("measures to the commit time, so delivery delays after emission count", async () => { const { trace, breaches } = traced({ adapter: "codex", environment: "local", restartRecovery: true }); - const budget = LOCAL_SPEED_BUDGETS.codex.restart_recovery.p95Ms; + const budget = LOCAL_SPEED_BUDGETS.codex.restart_recovery_control_plane.p95Ms; // Emitted 1 s after ready, committed 20 s after ready. trace.checkRestartRecovery({ readyAtMs: 0, emittedAtMs: 1_000, committedAtMs: budget + 12_000, disposition: "resume_dead_runner" }); await trace.finish("ok"); - expect(breaches()).toEqual([expect.objectContaining({ metric: "restart_recovery", measuredMs: budget + 12_000 })]); + expect(breaches()).toEqual([expect.objectContaining({ metric: "restart_recovery_control_plane", measuredMs: budget + 12_000 })]); }); it("writes nothing for remote runs or without a budget subject", async () => { diff --git a/server/src/services/native-runtime/native-speed-budgets.ts b/server/src/services/native-runtime/native-speed-budgets.ts index cc6302b88bb..93234dc64c8 100644 --- a/server/src/services/native-runtime/native-speed-budgets.ts +++ b/server/src/services/native-runtime/native-speed-budgets.ts @@ -22,7 +22,16 @@ export type SpeedBudgetAdapter = "claude" | "codex" | "opencode"; * a reported metric without a budget: tool-first adapters such as Claude often * call tools before they write text. The budget numbers are the TTFT baseline's. */ -export type SpeedBudgetMetric = "time_to_first_activity" | "agent_boot" | "restart_recovery"; +/** + * `restart_recovery_control_plane`: control plane ready to the moment the + * recovered run's provider starts loading its saved session + * (`provider.session.load`), or to the run's next provider event when nothing + * reloads (a reattached runner). That is Paperclip's own share of a recovery: + * claim, prepare, runtime stage and runner launch. The provider's session load + * grows with session size and with how many sessions resume at once, so the + * full `restart_recovery` latency is reported without a budget. + */ +export type SpeedBudgetMetric = "time_to_first_activity" | "agent_boot" | "restart_recovery" | "restart_recovery_control_plane"; export type SpeedBudgetStart = "cold" | "warm"; export type SpeedBudget = { p50Ms: number; p95Ms: number }; @@ -43,22 +52,29 @@ const s = (seconds: number) => Math.round(seconds * 1000); export const LOCAL_SPEED_BUDGETS: Readonly; agent_boot: Record; + /** Full recovery (ready → next provider event). Reported, not judged. */ restart_recovery: SpeedBudget; + /** Paperclip's share of a recovery; judged. Measured 1.2–2.0 s per run with six + * Codex runs resuming at once (wave w-20261109T045320-s9807, local). */ + restart_recovery_control_plane: SpeedBudget; }>> = { claude: { time_to_first_activity: { cold: { p50Ms: s(16.5), p95Ms: s(30) }, warm: { p50Ms: s(9.5), p95Ms: s(28.5) } }, agent_boot: { cold: { p50Ms: s(9), p95Ms: s(20.5) }, warm: { p50Ms: s(3.5), p95Ms: s(16.5) } }, restart_recovery: { p50Ms: s(15), p95Ms: s(22.5) }, + restart_recovery_control_plane: { p50Ms: s(2.5), p95Ms: s(4) }, }, codex: { time_to_first_activity: { cold: { p50Ms: s(6), p95Ms: s(17) }, warm: { p50Ms: s(10), p95Ms: s(28.5) } }, agent_boot: { cold: { p50Ms: s(2.5), p95Ms: s(7) }, warm: { p50Ms: s(5), p95Ms: s(16.5) } }, restart_recovery: { p50Ms: s(6.5), p95Ms: s(8) }, + restart_recovery_control_plane: { p50Ms: s(2.5), p95Ms: s(4) }, }, opencode: { time_to_first_activity: { cold: { p50Ms: s(17.5), p95Ms: s(49) }, warm: { p50Ms: s(26.5), p95Ms: s(88) } }, agent_boot: { cold: { p50Ms: s(12), p95Ms: s(37) }, warm: { p50Ms: s(22), p95Ms: s(65.5) } }, restart_recovery: { p50Ms: s(25.5), p95Ms: s(38.5) }, + restart_recovery_control_plane: { p50Ms: s(2.5), p95Ms: s(4) }, }, }; @@ -77,6 +93,7 @@ export function speedBudgetFor(adapter: SpeedBudgetAdapter, metric: SpeedBudgetM start: SpeedBudgetStart | null): SpeedBudget { const budgets = LOCAL_SPEED_BUDGETS[adapter]; if (metric === "restart_recovery") return budgets.restart_recovery; + if (metric === "restart_recovery_control_plane") return budgets.restart_recovery_control_plane; const byStart = budgets[metric]; if (start) return byStart[start]; return { p50Ms: Math.max(byStart.cold.p50Ms, byStart.warm.p50Ms), p95Ms: Math.max(byStart.cold.p95Ms, byStart.warm.p95Ms) }; @@ -98,6 +115,10 @@ export type SpeedBudgetBreach = { /** First activity only: time the input waited for its run to exist (for example a * comment posted while the previous turn was running). Not judged. */ inputWaitMs?: number; + /** Control-plane recovery only: the full ready → next provider event latency + * and the provider's share of it (session load onwards). Not judged. */ + restartTotalMs?: number; + providerLoadMs?: number; }; /** A breach when one measured run exceeds its p95 budget, else null. */ @@ -109,6 +130,8 @@ export function evaluateSpeedBudget(input: { measuredMs: number; disposition?: string; inputWaitMs?: number; + restartTotalMs?: number; + providerLoadMs?: number; }): SpeedBudgetBreach | null { if (!input.adapter || input.environment !== "local" || !Number.isFinite(input.measuredMs) || input.measuredMs < 0) return null; const budget = speedBudgetFor(input.adapter, input.metric, input.start); @@ -118,6 +141,8 @@ export function evaluateSpeedBudget(input: { startKind: input.start ?? "unknown", measuredMs: Math.round(input.measuredMs), budgetP50Ms: budget.p50Ms, budgetP95Ms: budget.p95Ms, percentile: "p95", baseline: SPEED_BUDGET_BASELINE, ...(input.disposition ? { disposition: input.disposition } : {}), + ...(input.restartTotalMs !== undefined ? { restartTotalMs: Math.round(input.restartTotalMs) } : {}), + ...(input.providerLoadMs !== undefined ? { providerLoadMs: Math.round(input.providerLoadMs) } : {}), ...(input.inputWaitMs ? { inputWaitMs: Math.round(input.inputWaitMs) } : {}), }; } diff --git a/tests/restart-chaos/ttft-baseline-stats.test.ts b/tests/restart-chaos/ttft-baseline-stats.test.ts index 39a5490fc9b..52b90bc79cf 100644 --- a/tests/restart-chaos/ttft-baseline-stats.test.ts +++ b/tests/restart-chaos/ttft-baseline-stats.test.ts @@ -119,12 +119,44 @@ describe("ttft baseline statistics", () => { ]); const silent = run("r2", "restart", []); expect(recoverySamples([recovered, silent], [{ kind: "K1", readyAt, runIds: ["r1", "r2", "missing"] }])).toEqual([ - { runId: "r1", adapter: "codex", kind: "K1", disposition: "resume_dead_runner", readyAt, latencyMs: 3_250 }, - { runId: "r2", adapter: "codex", kind: "K1", disposition: "none", readyAt, latencyMs: null }, + { runId: "r1", adapter: "codex", kind: "K1", disposition: "resume_dead_runner", readyAt, latencyMs: 3_250, controlPlaneMs: 3_250, providerLoadMs: 0 }, + { runId: "r2", adapter: "codex", kind: "K1", disposition: "none", readyAt, latencyMs: null, controlPlaneMs: null, providerLoadMs: null }, ]); }); }); +describe("restart recovery split", () => { + // The trace starts at the epoch; the queue span is written promptly and anchors it. + const queue = { eventType: "run.performance.span", createdAt: at(100), payload: { span: "heartbeat.queue", startOffsetMs: 0, durationMs: 100 } }; + const load = (writtenAt: number) => ({ eventType: "run.performance.span", createdAt: at(writtenAt), + payload: { span: "provider.session.load", startOffsetMs: 11_500, durationMs: 4_500 } }); + + it("splits a recovery at the start of the provider's session load", () => { + const readyAt = Date.parse(at(10_000)); + const recovered = run("r1", "restart", [queue, load(16_000), { eventType: "session.resumed", createdAt: at(16_400) }]); + expect(recoverySamples([recovered], [{ kind: "K2", readyAt, runIds: ["r1"] }])).toEqual([ + { runId: "r1", adapter: "codex", kind: "K2", disposition: "none", readyAt, latencyMs: 6_400, controlPlaneMs: 1_500, providerLoadMs: 4_900 }, + ]); + }); + + it("uses the start the server logged even when every span row was written late", () => { + const readyAt = Date.parse(at(10_000)); + // All rows deferred by 8 s, so no write time can anchor the trace. + const late = (span: string, offset: number, duration: number) => ({ eventType: "run.performance.span", createdAt: at(offset + duration + 8_000), + payload: { span, startOffsetMs: offset, durationMs: duration, wallStartedAtMs: Date.parse(at(offset)) } }); + const recovered = run("r1", "restart", [late("heartbeat.queue", 0, 100), { eventType: "session.resumed", createdAt: at(16_400) }, + late("provider.session.load", 11_500, 4_500)]); + expect(recoverySamples([recovered], [{ kind: "K2", readyAt, runIds: ["r1"] }])[0]).toMatchObject({ controlPlaneMs: 1_500, providerLoadMs: 4_900 }); + }); + + it("takes the load start from the span's offset, not its (possibly deferred) write time", () => { + const readyAt = Date.parse(at(10_000)); + // The load span's run-log row is written 5 s late, after the provider event. + const recovered = run("r1", "restart", [queue, { eventType: "session.resumed", createdAt: at(16_400) }, load(21_000)]); + expect(recoverySamples([recovered], [{ kind: "K2", readyAt, runIds: ["r1"] }])[0]).toMatchObject({ controlPlaneMs: 1_500, providerLoadMs: 4_900 }); + }); +}); + describe("restart sample guard", () => { const tool = (eventType: string, executionId: string, extra: Record = {}): BaselineEvent => ({ eventType, createdAt: at(0), payload: { prpEvent: { payload: { executionId, ...extra } } }, diff --git a/tests/restart-chaos/ttft-baseline-stats.ts b/tests/restart-chaos/ttft-baseline-stats.ts index 9b1cdfd690f..ce407120494 100644 --- a/tests/restart-chaos/ttft-baseline-stats.ts +++ b/tests/restart-chaos/ttft-baseline-stats.ts @@ -132,9 +132,42 @@ export function runMetrics(run: BaselineRun): RunMetrics { const PROVIDER_EVENT_TYPES = new Set(["turn.submitted", "turn.started", "turn.accepted", "item.started", "item.delta", "item.completed", "tool.execution.started", "tool.execution.progressed", "tool.execution.completed", "session.resumed"]); -export interface RecoverySample { runId: string; adapter: string; kind: string; disposition: string; readyAt: number; latencyMs: number | null } +export interface RecoverySample { runId: string; adapter: string; kind: string; disposition: string; readyAt: number; latencyMs: number | null; + /** Paperclip's share: ready to the start of the provider's session load (`provider.session.load`), or the whole + * latency when nothing reloads. The judged row. */ + controlPlaneMs: number | null; + /** The provider's share: session load start to the next provider event. Reported. */ + providerLoadMs: number | null } /** Control plane ready → the recovered run's next provider event, keyed by runner disposition. */ +/** The run's trace start: every span records its offset from it. A span event is + * written when the span ends, possibly through deferred work, so each one gives + * an upper bound (write time minus offset and duration); the earliest is used. */ +export function spanTraceStartMs(run: BaselineRun): number | null { + let earliest: number | null = null; + for (const event of run.events) { + if (event.eventType !== "run.performance.span" || !event.payload) continue; + const candidate = Date.parse(event.createdAt) - (Number(event.payload.startOffsetMs) + Number(event.payload.durationMs)); + if (Number.isFinite(candidate) && (earliest === null || candidate < earliest)) earliest = candidate; + } + return earliest; +} + +/** Stamps each provider session load with its wall-clock start (`wallStartMs`). + * The server writes the span's own start as `wallStartedAtMs`; runs logged before + * that field existed fall back to the trace start plus the span's offset, never to + * its write time. Call on the raw run, before outage removal rewrites offsets. */ +export function stampProviderLoadStarts(run: BaselineRun): BaselineRun { + const anchor = spanTraceStartMs(run); + return { ...run, events: run.events.map(event => { + if (event.eventType !== "run.performance.span" || event.payload?.span !== "provider.session.load" || event.payload.wallStartMs !== undefined) return event; + const logged = Number(event.payload.wallStartedAtMs); + const offset = Number(event.payload.startOffsetMs); + const start = Number.isFinite(logged) ? logged : anchor !== null && Number.isFinite(offset) ? anchor + offset : null; + return start === null ? event : { ...event, payload: { ...event.payload, wallStartMs: start } }; + }) }; +} + export function recoverySamples(runs: readonly BaselineRun[], faults: readonly BaselineFault[]): RecoverySample[] { const byId = new Map(runs.map(run => [run.runId, run])); const samples: RecoverySample[] = []; @@ -146,9 +179,19 @@ export function recoverySamples(runs: readonly BaselineRun[], faults: readonly B const transition = after.find(event => event.eventType === "native.recovery.transition" && Date.parse(event.createdAt) >= fault.readyAt - 60_000); const next = run.events.find(event => PROVIDER_EVENT_TYPES.has(event.eventType) && Date.parse(event.createdAt) >= fault.readyAt); + const nextAt = next ? Date.parse(next.createdAt) : null; + const stamped = stampProviderLoadStarts(run); + const loadStarts = nextAt === null ? [] : stamped.events.flatMap(event => { + if (event.eventType !== "run.performance.span" || event.payload?.span !== "provider.session.load") return []; + const start = Number(event.payload.wallStartMs); + return Number.isFinite(start) && start >= fault.readyAt && start <= nextAt ? [start] : []; + }); + const loadStart = loadStarts.length ? Math.min(...loadStarts) : null; samples.push({ runId, adapter: run.adapter, kind: fault.kind, readyAt: fault.readyAt, disposition: String(transition?.payload?.runnerDisposition ?? "none"), - latencyMs: next ? Date.parse(next.createdAt) - fault.readyAt : null }); + latencyMs: nextAt === null ? null : nextAt - fault.readyAt, + controlPlaneMs: nextAt === null ? null : (loadStart ?? nextAt) - fault.readyAt, + providerLoadMs: nextAt === null ? null : loadStart === null ? 0 : nextAt - loadStart }); } } return samples; diff --git a/tests/restart-chaos/ttft-baseline.ts b/tests/restart-chaos/ttft-baseline.ts index 2f1fe6d633d..893f18616c4 100644 --- a/tests/restart-chaos/ttft-baseline.ts +++ b/tests/restart-chaos/ttft-baseline.ts @@ -204,7 +204,7 @@ function report(runs: BaselineRun[], faults: BaselineFault[]): string { lines.push("", "| Pre-turn step | n | p50 | p95 | Adapters |", "|---|---|---|---|---|"); for (const step of rankPreTurnSteps(runs).slice(0, 25)) lines.push(`| \`${step.step}\` | ${step.n} | ${ms(step.p50)} | ${ms(step.p95)} | ${step.adapters.join(", ")} |`); const samples = recoverySamples(runs, faults); - lines.push("", "| Adapter | Fault | Disposition | n | Recovery p50 | Recovery p95 | No provider event |", "|---|---|---|---|---|---|---|"); + lines.push("", "| Adapter | Fault | Disposition | n | Recovery p50 | Recovery p95 | Paperclip share p50 / p95 | No provider event |", "|---|---|---|---|---|---|---|---|"); const groups = new Map(); for (const sample of samples) { const key = `${sample.adapter}|${sample.kind}|${sample.disposition}`; @@ -213,7 +213,8 @@ function report(runs: BaselineRun[], faults: BaselineFault[]): string { for (const [key, group] of [...groups.entries()].sort()) { const [adapter, kind, disposition] = key.split("|"); const d = distribution(group.flatMap(sample => sample.latencyMs === null ? [] : [sample.latencyMs])); - lines.push(`| ${adapter} | ${kind} | ${disposition} | ${d.n} | ${ms(d.p50)} | ${ms(d.p95)} | ${group.length - d.n} |`); + const control = distribution(group.flatMap(sample => sample.controlPlaneMs === null ? [] : [sample.controlPlaneMs])); + lines.push(`| ${adapter} | ${kind} | ${disposition} | ${d.n} | ${ms(d.p50)} | ${ms(d.p95)} | ${ms(control.p50)} / ${ms(control.p95)} | ${group.length - d.n} |`); } return `${lines.join("\n")}\n`; } diff --git a/tests/restart-chaos/wave-speed.test.ts b/tests/restart-chaos/wave-speed.test.ts index 293ba5d1756..c376cd83ee2 100644 --- a/tests/restart-chaos/wave-speed.test.ts +++ b/tests/restart-chaos/wave-speed.test.ts @@ -45,7 +45,23 @@ describe("wave speed grade", () => { const recovered: BaselineRun = { runId: "r", adapter: "claude", environment: "local", phase: "cold", events: [{ eventType: "turn.started", createdAt: new Date(10_000 + 30_000).toISOString() }] }; const speed = gradeWaveSpeed("local", [recovered], [{ kind: "K1", readyAt: 10_000, runIds: ["r"] }], 0); - expect(speed.rows.find(row => row.metric === "restart_recovery")).toMatchObject({ adapter: "claude", breaches: ["p50", "p95"] }); + // Nothing reloaded, so the whole latency is Paperclip's share and is judged. + expect(speed.rows.find(row => row.metric === "restart_recovery_control_plane")).toMatchObject({ adapter: "claude", breaches: ["p50", "p95"] }); + expect(speed.rows.find(row => row.metric === "restart_recovery")).toMatchObject({ budget: null, breaches: [], measured: { p50: 30_000 } }); + }); + + it("judges Paperclip's share of a recovery and only reports the provider's session load", () => { + const loadEnd = 10_000 + 1_500 + 9_000; + const recovered: BaselineRun = { runId: "r", adapter: "codex", environment: "local", phase: "cold", events: [ + { eventType: "run.performance.span", createdAt: new Date(100).toISOString(), payload: { span: "heartbeat.queue", startOffsetMs: 0, durationMs: 100 } }, + { eventType: "run.performance.span", createdAt: new Date(loadEnd).toISOString(), + payload: { span: "provider.session.load", startOffsetMs: 11_500, durationMs: 9_000 } }, + { eventType: "session.resumed", createdAt: new Date(loadEnd + 300).toISOString() }] }; + const speed = gradeWaveSpeed("local", [recovered], [{ kind: "K2", readyAt: 10_000, runIds: ["r"] }], 0); + expect(speed.rows.find(row => row.metric === "restart_recovery_control_plane")).toMatchObject({ measured: { p50: 1_500 }, breaches: [] }); + expect(speed.rows.find(row => row.metric === "restart_provider_load")).toMatchObject({ measured: { p50: 9_300 }, budget: null, breaches: [] }); + expect(speed.rows.find(row => row.metric === "restart_recovery")).toMatchObject({ measured: { p50: 10_800 }, budget: null }); + expect(speed.breaches).toBe(0); }); it("removes a later outage from restart recovery", () => { @@ -54,7 +70,7 @@ describe("wave speed grade", () => { const windows = outageWindows([{ startedAt: 12_000, readyAt: 312_000 } as FaultReceipt]); const speed = gradeWaveSpeed("local", [recovered], [{ kind: "K1", readyAt: 10_000, runIds: ["r"] }], 0, [], { adjusted: 0, removedMs: 0, windows }); - expect(speed.rows.find(row => row.metric === "restart_recovery")).toMatchObject({ measured: { p50: 2_000 }, breaches: [] }); + expect(speed.rows.find(row => row.metric === "restart_recovery_control_plane")).toMatchObject({ measured: { p50: 2_000 }, breaches: [] }); }); it("has no budget for remote environments or unbudgeted adapters", () => { diff --git a/tests/restart-chaos/wave-speed.ts b/tests/restart-chaos/wave-speed.ts index bbadc7d7128..c57973fa00b 100644 --- a/tests/restart-chaos/wave-speed.ts +++ b/tests/restart-chaos/wave-speed.ts @@ -1,7 +1,7 @@ import { LOCAL_SPEED_BUDGETS, type SpeedBudget, type SpeedBudgetAdapter } from "../../server/src/services/native-runtime/native-speed-budgets.js"; import type { ChaosApi } from "./api.js"; import { - baselineRows, distribution, recoverySamples, runMetrics, runSpans, runWarmth, + baselineRows, distribution, recoverySamples, runMetrics, runSpans, runWarmth, stampProviderLoadStarts, type BaselineEvent, type BaselineFault, type BaselineRun, type Distribution, } from "./ttft-baseline-stats.js"; import type { AdapterId, EnvironmentKind, FaultReceipt, RunSample } from "./types.js"; @@ -11,7 +11,8 @@ type Json = Record; export interface WaveSpeedTask { issueId: string | null; adapter: AdapterId; samples: Array<{ runs?: RunSample[] }> } export interface SpeedRow { - adapter: string; metric: "time_to_first_activity" | "ttft" | "agent_boot" | "workspace_provision" | "restart_recovery"; start: "cold" | "warm" | "restart"; + adapter: string; metric: "time_to_first_activity" | "ttft" | "agent_boot" | "workspace_provision" | "restart_recovery" + | "restart_recovery_control_plane" | "restart_provider_load"; start: "cold" | "warm" | "restart"; measured: Distribution; budget: SpeedBudget | null; breaches: Array<"p50" | "p95">; } @@ -33,9 +34,11 @@ export function budgetFor(environment: EnvironmentKind, adapter: string, metric: start: SpeedRow["start"]): SpeedBudget | null { const key = BUDGET_ADAPTER[adapter as AdapterId]; if (environment !== "local" || !key) return null; - if (metric === "ttft" || metric === "workspace_provision") return null; + // Reported without a budget: the full recovery and the provider's session load + // grow with session size and simultaneous resumes; Paperclip's share is judged. + if (metric === "ttft" || metric === "workspace_provision" || metric === "restart_recovery" || metric === "restart_provider_load") return null; const budgets = LOCAL_SPEED_BUDGETS[key] as unknown as Record | SpeedBudget>; - if (metric === "restart_recovery") return budgets.restart_recovery as SpeedBudget; + if (metric === "restart_recovery_control_plane") return budgets.restart_recovery_control_plane as SpeedBudget; const byStart = (metric === "time_to_first_activity" ? budgets.time_to_first_activity ?? budgets.ttft : budgets[metric]) as Record<"cold" | "warm", SpeedBudget>; return byStart[start === "warm" ? "warm" : "cold"]; @@ -180,12 +183,24 @@ export function gradeWaveSpeed(environment: EnvironmentKind, runs: readonly Base } } const clock = outageFreeClock(outage.windows ?? []); - const samples = recoverySamples(runs, faults).map(sample => sample.latencyMs === null ? sample - : { ...sample, latencyMs: Math.max(0, clock(sample.readyAt + sample.latencyMs) - clock(sample.readyAt)) }); + const outageFree = (readyAt: number, ms: number | null) => ms === null ? null : Math.max(0, clock(readyAt + ms) - clock(readyAt)); + const samples = recoverySamples(runs, faults).map(sample => sample.latencyMs === null ? sample : { + ...sample, latencyMs: outageFree(sample.readyAt, sample.latencyMs), + controlPlaneMs: outageFree(sample.readyAt, sample.controlPlaneMs), + providerLoadMs: sample.providerLoadMs === null || sample.controlPlaneMs === null ? sample.providerLoadMs + : Math.max(0, clock(sample.readyAt + sample.controlPlaneMs + sample.providerLoadMs) - clock(sample.readyAt + sample.controlPlaneMs)), + }); for (const adapter of [...new Set(samples.map(sample => sample.adapter))].sort()) { - const measured = distribution(samples.flatMap(sample => sample.adapter === adapter && sample.latencyMs !== null ? [sample.latencyMs] : [])); - const budget = budgetFor(environment, adapter, "restart_recovery", "restart"); - rows.push({ adapter, metric: "restart_recovery", start: "restart", measured, budget, breaches: breachesOf(measured, budget) }); + for (const [metric, pick] of [["restart_recovery", (sample: typeof samples[number]) => sample.latencyMs], + ["restart_recovery_control_plane", (sample: typeof samples[number]) => sample.controlPlaneMs], + ["restart_provider_load", (sample: typeof samples[number]) => sample.providerLoadMs]] as const) { + const measured = distribution(samples.flatMap(sample => { + const value = sample.adapter === adapter ? pick(sample) : null; + return value === null ? [] : [value]; + })); + const budget = budgetFor(environment, adapter, metric, "restart"); + rows.push({ adapter, metric, start: "restart", measured, budget, breaches: breachesOf(measured, budget) }); + } } const singleRunBreaches = runs.filter(run => run.phase !== "restart" && run.phase !== "verify").filter(run => { const start = runWarmth(run); @@ -248,7 +263,8 @@ export async function collectWaveSpeed(api: ChaosApi, environment: EnvironmentKi } if (run.retryOfRunId && faultHit.has(run.retryOfRunId)) raw.phase = "restart"; if (faultBeforeFirstActivity(raw, windows, Date.parse(run.createdAt!))) raw.phase = "restart"; - const trimmed = withoutOutage(raw, windows); + // Stamp provider load starts from raw offsets before outage removal rewrites them. + const trimmed = withoutOutage(stampProviderLoadStarts(raw), windows); outage.adjusted += trimmed.adjusted; outage.removedMs += trimmed.removedMs; runs.push(trimmed.run); timing.push({ id: run.id, createdAt: Date.parse(run.createdAt!), finishedAt: run.finishedAt ? Date.parse(run.finishedAt) : null }); From 09e24dd12c36251dd12af2d73dbc715ce990f922 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 9 Nov 2026 01:41:57 -0600 Subject: [PATCH 09/21] fix(authz): say every plugin management step is board-only in plugin denials (#20207) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - People ask a chat agent to do work, and some of that work is board-only, such as plugin management > - When an agent gets a board-only denial, the denial tells it to stop and reply with a link to the board page > - The denial only spoke about the one route that was denied. An agent that read the plugin list first was not told that plugin configuration and secret binding are also board-only > - The agent then searched the API catalog for an agent route to configure the plugin. It searched for 110 seconds, and its reply came after the chat window closed > - This pull request adds one fact to every board-only denial on a plugin route: all plugin management steps have no agent route > - The benefit is that the agent replies at once with the plugin settings link, and does not search for a route that does not exist ## Linked Issues or Issue Description Refs #20182 (the plugin denial link and "reply now" text that this builds on). **What happened** In the chat eval `plugins.configure-plugin`, the person asked the agent to finish configuring the preview plugin. The agent called `GET /api/plugins` and got the board-only denial. Its reasoning said: "Denial final. But ... perhaps the preview plugin is configured through a different route accessible to agents". It then made 14 more catalog searches for plugin config, credential and secret-binding routes. The run did not reply before the 150 second chat window closed. **Expected behavior** After a board-only denial on any plugin route, the agent replies now. It says that a board user must configure the plugin, and it links the plugin settings page. **Steps to reproduce** 1. Install a plugin that has a required secret setting, and do not configure it. 2. In chat, ask an agent: "Finish configuring the preview plugin." 3. The agent calls `GET /api/plugins` and gets 403 "Board access required". 4. Before this change, the denial does not say that configuration and secret binding are also board-only, and the agent searches for another route. ## What Changed - `server/src/routes/authz.ts`: a board-only denial for an agent on any `/api/plugins` route adds one sentence. The sentence says that listing, installing, configuring (including choosing a secret for a plugin setting), enabling, disabling, upgrading and removing plugins have no agent route. - Other board-only denials do not change. Plugin tools that are assigned to an agent are not mentioned, so tool use is not discouraged. - `server/src/__tests__/board-access-denial.test.ts`: a regression test for the plugin list and plugin config denials, and a negative check for a non-plugin denial. ## Verification - `cd server && npx vitest run src/__tests__/board-access-denial.test.ts` passes (26 tests). - Without the `authz.ts` change, the new test fails. - The chat eval `plugins.configure-plugin` is rerun on a case branch that includes this commit. The result is in the PR comments. ## Risks - Low risk. The change only adds text to the 403 body that agents get. People and board actors get the same body as before. - The sentence is a fact about the current routes. Every plugin management route uses `assertBoard`, `assertBoardOrgAccess` or `assertInstanceAdmin`. If a plugin management route becomes agent-accessible later, this sentence must change too. ## Model Used - Anthropic Claude Opus 5.5 (`claude-opus-5-5`), with tool use and code execution in Claude Code. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- server/src/__tests__/board-access-denial.test.ts | 13 +++++++++++++ server/src/routes/authz.ts | 14 ++++++++++++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/server/src/__tests__/board-access-denial.test.ts b/server/src/__tests__/board-access-denial.test.ts index 9518032f254..4729c740d6e 100644 --- a/server/src/__tests__/board-access-denial.test.ts +++ b/server/src/__tests__/board-access-denial.test.ts @@ -58,6 +58,19 @@ describe("board-only denial", () => { expect(res.body.remediation).toContain(link); }); + it("tells an agent denied any plugin route that every plugin management step is board-only", async () => { + // An agent asked to finish configuring a plugin usually reads the plugin list first. + // That denial must also settle configuration and secret binding, or the agent keeps + // searching the catalog for an agent route to them. + for (const path of ["/api/plugins", `/api/plugins/${AGENT_ID}/config`]) { + const res = await request(createApp(agentActor)).get(path).expect(403); + expect(res.body.remediation).toContain("Plugin management is board-only throughout"); + expect(res.body.remediation).toContain("configuring (including choosing a secret for a plugin setting)"); + } + const other = await request(createApp(agentActor)).post(`/api/companies/${COMPANY_ID}/secrets`).expect(403); + expect(other.body.remediation).not.toContain("Plugin management"); + }); + it("links the resolved project when an agent names the project by its short reference", async () => { const app = express(); app.use((req, _res, next) => { diff --git a/server/src/routes/authz.ts b/server/src/routes/authz.ts index 8b39815fc45..444dfc7ed58 100644 --- a/server/src/routes/authz.ts +++ b/server/src/routes/authz.ts @@ -81,8 +81,17 @@ function pluginControlPage(path: string): [string, string] | null { : ["plugin settings", "//company/settings/instance/plugins"]; } +// Every plugin management route is board-only, so a denial on any one of them +// (often the list an agent reads first) also answers the plugin's other management steps. +const PLUGIN_ROUTE = /^\/plugins(?:\/|$)/; +const PLUGIN_ADMINISTRATION_SCOPE = "Plugin management is board-only throughout: listing, installing, configuring (including choosing a secret for a plugin setting), enabling, disabling, upgrading and removing plugins have no agent route either."; + +function deniedApiPath(req: Request) { + return (req.originalUrl ?? "").split("?")[0]!.replace(/^\/api(?=\/)/, ""); +} + function boardControlPage(req: Request) { - const path = (req.originalUrl ?? "").split("?")[0]!.replace(/^\/api(?=\/)/, ""); + const path = deniedApiPath(req); const pluginPage = pluginControlPage(path); if (pluginPage) return pluginPage; for (const [pattern, page] of BOARD_CONTROL_PAGES) { @@ -99,12 +108,13 @@ function boardControlPage(req: Request) { export function boardAccessRequired(req: Request) { if (req.actor.type !== "agent") return forbidden("Board access required"); const page = boardControlPage(req); + const scope = PLUGIN_ROUTE.test(deniedApiPath(req)) ? `${PLUGIN_ADMINISTRATION_SCOPE} ` : ""; const where = page ? `They can do it in the Paperclip web app: link [Open ${page[0]}](${page[1]}), with this conversation's company prefix as .` : "They can do it in the Paperclip web app or with their own board credentials. Link a page only if you already know its route; do not search for one."; return forbidden("Board access required", { code: BOARD_ACCESS_REQUIRED_CODE, - remediation: `This action is board-only by design and this denial is final: no agent credential, tool or other API route can do it, so do not search for one. Stop working on this step and reply now: tell the person that a board user must do it. ${where}`, + remediation: `This action is board-only by design and this denial is final: no agent credential, tool or other API route can do it, so do not search for one. Stop working on this step and reply now: tell the person that a board user must do it. ${scope}${where}`, }); } From 9c18cf61daeaa630afa9825503e6ed55520fef39 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 9 Nov 2026 02:23:44 -0600 Subject: [PATCH 10/21] fix(runner): keep discarded OpenCode token deltas from delaying tool calls (#20209) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Native OpenCode runs use a TypeScript app-server proxy between OpenCode and runnerd > - runnerd reads the proxy's frames in order, only after the controller acknowledges earlier events > - For each frame, runnerd rewrites its whole durable provider state with fsync, including every tool result that the turn retains > - The proxy sent one `item/delta` frame for each reasoning or tool-output token, and runnerd discards `item/delta` > - A Paperclip tool call request therefore waited behind hundreds of discarded frames, and the wait grew with each tool result in the turn > - This pull request stops the proxy from sending the token deltas that runnerd does not consume > - The benefit is that tool calls reach runnerd without that queue, so agent turns finish faster ## Linked Issues or Issue Description **What happened** In the chat eval `plugins.configure-plugin` (runner-opencode, deepseek-v4.1-flash, GitHub Actions run 42574640038), `paperclip_search_api` calls in one turn slowed from about 1.4 s to 9-15 s each. The server handled every call in 50-250 ms (`tool.execution` spans). For one `call_api` call, 6.3 s passed between the server's 403 and the next agent activity. Time to first token was about 4.4 s. The run events show the cause. Events that OpenCode streams reach runnerd in bursts. In one burst, the usage events for three different model requests arrived within 0.5 s, which no model can do. Each burst ends with the next tool call request. 143 of 203 recorded runner-opencode runs that we checked show such bursts, and 108 show a gap of 8 s or more. Many chat eval cases that time out use this runner. The mechanism: 1. OpenCode streams reasoning one token at a time. The proxy forwarded each token as its own `item/delta` frame. 2. runnerd reads proxy frames in order and only after the controller acknowledges earlier events. For each frame it calls `save_state`. That serializes the whole provider state, with the tool results retained in the turn, and fsyncs it. 3. runnerd's Codex facade has no mapping for `item/delta`, so it discards the frame after the save. 4. A tool call request is one more frame in the same ordered stream. It waits until runnerd works through every earlier delta frame. The wait is about (reasoning tokens) x (save cost), and the save cost grows with each retained tool result. **Expected behavior** A Paperclip tool call reaches runnerd soon after OpenCode makes it. Its delay does not grow with the reasoning length or with the number of earlier tool calls in the turn. **Steps to reproduce** 1. Run a native OpenCode chat turn with a reasoning model that makes many sequential Paperclip tool calls, for example `search_api`. 2. Compare each tool's `item.completed` tool_result time with the `tool.execution.completed` time and with the next `item.started` tool_use time in the run events. 3. See that the gap grows during the turn, and that events for several model requests arrive within less than one second. 4. For a local model of the mechanism: run the pinned OpenCode binary with a controlled OpenAI-compatible provider that streams 300 reasoning tokens before each tool call. Pass the driver events through the proxy's mapping and batching into a reader that consumes one frame at a time with a save cost that grows by 4 ms per completed tool result. Before this change, 8 sequential calls waited 1.7, 3.1, 4.2, 5.4, 6.7, 7.9, 9.2 and 10.5 s behind 142-261 queued frames. After this change, they waited 64-93 ms behind 0 frames. ## What Changed - `shouldForwardOpenCodeProxyItem` now takes the event type and payload. For `item.delta`, it forwards only assistant text (`agentMessage` or `text`), which runnerd maps to `item/agentMessage/delta`. - The proxy keeps reasoning and tool-output token deltas. Completed reasoning and tool items still go to runnerd as `item/completed`. Tool progress still goes as `item/updated`. - New regression test: a step with 1,000 streamed reasoning and tool-output tokens puts the same number of frames before the tool call request as a step with 1 token, and no `item/delta` frame is written. - New test: assistant text deltas are still forwarded. Completed reasoning items are still forwarded. ## Verification - `pnpm exec vitest run src/cli/opencode-proxy-events.test.ts src/cli/opencode-proxy-output.test.ts` in `packages/paperclip-runner`: 15 passed. - Red/green: with the predicate change reverted, the two new tests fail. With the change, they pass. - `tsc --noEmit -p tsconfig.test.json` in `packages/paperclip-runner` has no errors in the changed files. Local errors exist only in `src/eval/workflow-harness.ts`, which needs a built `@paperclipai/paperclip-eval-kernel`. - Local before/after measurement with the real pinned OpenCode binary (`opencode-ai@1.18.34`), described in Steps to reproduce, step 4. - Not run locally: the suites that need the `paperclip-runnerd` debug binary or the bundled provider entrypoints (`runnerd-opencode-streaming-tools`, `runnerd-codex-transport`, `opencode-proxy-thread-read`). They fail the same way on the unchanged base in this worktree because those build outputs are missing. CI covers them. ## Risks - Low risk. The changed frames were discarded by runnerd. No persisted state shape changes, so no previous-release story is needed. - If runnerd later maps a reasoning delta method, the proxy must forward it with batching, as it does for assistant text. The test names this boundary. - This change does not change the per-frame durable save in runnerd. Mapped frames (assistant text, tool items) still pay it. A runnerd change that skips the save when a notification changes no state would protect other proxies too. That is a separate change. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - Anthropic Claude Opus 5.5 (`claude-opus-5-5`), used through Claude Code with tool use (shell, file edits, local benchmarks). ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- .../src/cli/opencode-app-server-proxy.ts | 3 +- .../src/cli/opencode-proxy-events.test.ts | 50 +++++++++++++++++-- .../src/cli/opencode-proxy-events.ts | 6 ++- 3 files changed, 52 insertions(+), 7 deletions(-) diff --git a/packages/paperclip-runner/src/cli/opencode-app-server-proxy.ts b/packages/paperclip-runner/src/cli/opencode-app-server-proxy.ts index 3782d09e129..9a9e43c707c 100644 --- a/packages/paperclip-runner/src/cli/opencode-app-server-proxy.ts +++ b/packages/paperclip-runner/src/cli/opencode-app-server-proxy.ts @@ -220,8 +220,9 @@ async function pumpEvents(opened: HarnessSession): Promise { // correctly trip the facade's strict turn-binding validator. if ( !shouldForwardOpenCodeProxyItem({ + eventType: event.eventType, turnId: event.turnId, - kind: payload.kind, + payload, }) ) continue; diff --git a/packages/paperclip-runner/src/cli/opencode-proxy-events.test.ts b/packages/paperclip-runner/src/cli/opencode-proxy-events.test.ts index 9d484ba314f..ee2de334279 100644 --- a/packages/paperclip-runner/src/cli/opencode-proxy-events.test.ts +++ b/packages/paperclip-runner/src/cli/opencode-proxy-events.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { boundedOpenCodeTerminalError, @@ -8,17 +8,59 @@ import { shouldAnnounceOpenCodeProxyTurn, shouldForwardOpenCodeProxyItem, } from "./opencode-proxy-events.js"; +import { createOpenCodeProxyOutput } from "./opencode-proxy-output.js"; describe("OpenCode runnerd proxy event boundary", () => { it("drops only the duplicate session-scoped model item", () => { - expect(shouldForwardOpenCodeProxyItem({ kind: "model" })).toBe(false); + expect(shouldForwardOpenCodeProxyItem({ payload: { kind: "model" } })).toBe(false); expect( shouldForwardOpenCodeProxyItem({ turnId: "turn-1", - kind: "model", + payload: { kind: "model" }, }), ).toBe(true); - expect(shouldForwardOpenCodeProxyItem({ kind: "agentMessage" })).toBe(true); + expect(shouldForwardOpenCodeProxyItem({ payload: { kind: "agentMessage" } })).toBe(true); + }); + + it("keeps per-token deltas runnerd discards from queueing ahead of a tool call", () => { + const framesBeforeToolCall = (tokens: number) => { + vi.useFakeTimers(); + try { + const written: Array<{ method?: string }> = []; + const output = createOpenCodeProxyOutput(frame => written.push(frame as { method?: string })); + const forward = (eventType: "item.started" | "item.delta" | "item.completed", itemId: string, payload: Record) => { + if (shouldForwardOpenCodeProxyItem({ eventType, turnId: "turn-1", payload })) + output.send(openCodeProxyItemNotification({ eventType, threadId: "thread-1", turnId: "turn-1", itemId, payload })); + }; + for (let index = 0; index < tokens; index += 1) { + forward("item.delta", "reasoning-1", { kind: "reasoning", channel: "detail", providerPhase: "reasoning", text: `token ${index} `, + item: { id: "reasoning-1", type: "reasoning", text: `token ${index} ` } }); + forward("item.delta", "tool-1", { kind: "tool", text: `line ${index}\n`, item: { id: "tool-1", type: "tool", state: { status: "running" } } }); + } + forward("item.completed", "reasoning-1", { kind: "reasoning", text: "Reasoning summary.", + item: { id: "reasoning-1", type: "reasoning", text: "Reasoning summary." } }); + output.send({ id: "opencode-1", method: "item/tool/call", params: { tool: "search_api" } }); + const position = written.findIndex(frame => frame.method === "item/tool/call"); + expect(position).toBeGreaterThanOrEqual(0); + expect(written.some(frame => frame.method === "item/delta")).toBe(false); + expect(written.at(position - 1)).toMatchObject({ method: "item/completed", params: { kind: "reasoning" } }); + return position; + } finally { + vi.useRealTimers(); + } + }; + expect(framesBeforeToolCall(1_000)).toBe(framesBeforeToolCall(1)); + }); + + it("still forwards streamed assistant text for runnerd's live reply", () => { + const payload = { kind: "agentMessage", channel: "progress", text: "Checking the plugin.", item: { type: "agentMessage" } }; + expect(shouldForwardOpenCodeProxyItem({ eventType: "item.delta", turnId: "turn-1", payload })).toBe(true); + expect(shouldForwardOpenCodeProxyItem({ eventType: "item.delta", turnId: "turn-1", + payload: { kind: "text", text: "Legacy text part." } })).toBe(true); + expect(shouldForwardOpenCodeProxyItem({ eventType: "item.delta", turnId: "turn-1", + payload: { kind: "reasoning", text: "Private reasoning" } })).toBe(false); + expect(shouldForwardOpenCodeProxyItem({ eventType: "item.completed", turnId: "turn-1", + payload: { kind: "reasoning", text: "Private reasoning" } })).toBe(true); }); it("announces one outer turn when SSE wins the response race", () => { diff --git a/packages/paperclip-runner/src/cli/opencode-proxy-events.ts b/packages/paperclip-runner/src/cli/opencode-proxy-events.ts index 1dfe90af890..04de0faf752 100644 --- a/packages/paperclip-runner/src/cli/opencode-proxy-events.ts +++ b/packages/paperclip-runner/src/cli/opencode-proxy-events.ts @@ -20,10 +20,12 @@ export function openCodeProxyToolNotification(input: { } export function shouldForwardOpenCodeProxyItem(input: { + eventType?: string; turnId?: string; - kind?: unknown; + payload: Record; }): boolean { - return !(input.turnId === undefined && input.kind === "model"); + if (input.eventType === "item.delta") return openCodeProxyAssistantText(input.payload) !== null; + return !(input.turnId === undefined && input.payload.kind === "model"); } export function openCodeProxyAssistantText( From fc5c79a3270395f1b6b3f33833a7ccab002966ae Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 9 Nov 2026 02:36:57 -0600 Subject: [PATCH 11/21] fix(guidance): tell agents who acts and which page to link for person-only steps (#20206) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Agents in chat read the API catalog (`search_api`) and `connections_search` to learn what they can do and who must act > - Some requests need a step that only a person can take: connect an assistant, allow a quarantined app action, turn on an experiment, enter an API key, or finish their own connection request > - For these steps, no catalog entry or search result said who acts or which page to link, and none of them returns a board-only 403, so the denial guidance from #20151 and #20182 does not reach them > - The agent knew a person had to act, searched for the page until its turn ran out, and never replied > - This pull request puts the fact and the exact page link where the agent already looks, following #20152 and #20170 > - The benefit is that the agent replies at once with the right page instead of timing out ## Linked Issues or Issue Description Refs #20151, #20152, #20170, #20182 **What happened** In chat, five requests ended with no reply. The agent searched the API until the turn deadline: - "Connect my assistant so it can see our tasks." `GET /api/mcp/setup` is refused for agents. Its catalog entry had no description, so the refusal text "Its catalog description says who can use it" pointed at nothing. - "Use the app's new export feature." The new action was quarantined. No catalog entry said where a person reviews it. - "Let me chat with Morgan over iMessage." `connections_search` hides chat channels while the Chat connectors experiment is off, so the agent could not find iMessage or the setting that hides it. `POST /api/companies/{companyId}/chat-endpoints` also accepted a draft endpoint for the turned-off provider. - "Connect my account using an API key." The service was the person's own MCP server. `connections_search` returned no result and did not name the secure "Connect your own MCP server" page. - "I will finish Alex's connection request for them." No catalog entry said that only the addressee can resolve a connection request card. **Expected behavior** The agent says who must act, links the exact page, and replies in the same turn. It does not search further, does not ask for a key in chat, and does not create another request. **Steps to reproduce** 1. Ask a chat agent one of the five requests above, in the matching state (for example, Chat connectors off, or a quarantined catalog entry). 2. Watch the run events. The agent calls `search_api` and `call_api` until the turn deadline and posts no reply. ## What Changed - `server/src/routes/openapi.ts`: add catalog descriptions that say who acts, link the page, and say not to search further: - `GET /api/mcp/setup` and `POST /api/mcp/requests/{id}/consent` link [Assistant connections](//apps/assistant-connection) and say the consent screen offers only access within the person's own role. - `GET /api/tool-connections/{connectionId}/catalog`, `.../review-counts` and `GET /api/companies/{companyId}/tools/apps/attention` say a quarantined action waits for a person on `//apps//permissions`. - The four `/api/connection-intents/{interactionId}/...` routes say only the addressee can resolve the card, and link the task that holds it. - `server/src/services/connection-intents.ts`: - While Chat connectors is off, a search that names a chat-only channel (for example iMessage) returns it as `unavailable`, with a reason that links `//company/settings/instance/experimental`. Channels that also offer tools, such as Slack, keep their current result. - A single `unavailable` result now always gets the "explain the reason, do not bypass" instruction. - When no listed service matches and the person gave the service's own server URL, the instruction links `//apps/byo`, the secure "Connect your own MCP server" page, and says never to ask for a key in chat. - `server/src/routes/chat-channels.ts`: `POST /api/companies/{companyId}/chat-endpoints` returns 403 with the Experimental settings page while the provider's experiment is off (Chat connectors, or GitHub review bots for GitHub). AgentMail is unchanged. The catalog entry says the same. The UI already hides the setup pages in this state. - `GET /api/issues/{id}/interactions` says only the addressee resolves a connection request card. - A hidden channel that an aggregator can serve (for example Discord) keeps its aggregator routes instead of the unavailable result. - Tests: `person-only-page-guidance.test.ts` checks the catalog text and that every named page is a route that `ui/src/App.tsx` mounts. `connection-intents-service.test.ts` covers the hidden channel (off and on) and the own-server page. ## Verification - `cd server && npx vitest run src/__tests__/person-only-page-guidance.test.ts src/__tests__/connection-intents-service.test.ts` passes. `chat-channels.integration.test.ts` passes with `-t` for every test that posts to the create route. - These related suites pass: `openapi-routes`, `connection-aggregator-fallback`, `generic-mcp-connection`, `assistant-connections-guidance`, `company-model-service-settings-guidance`, `board-access-denial`, `email-channels.integration`, and the eight suites that import `runner-api-catalog`. - `npx tsc --noEmit -p server` shows no errors in the changed files. - End to end: the full-stack chat evals for the five requests ran on a branch with this change. The run results are in the comments below. ## Risks - Low risk. The change is text in the API catalog and in `connections_search` results, plus one result for a channel that was hidden before. - A hidden channel now shows in search as `unavailable` with no methods. It cannot be set up from the result, so the gate stays in place. - Chat endpoint creation is now refused while the experiment is off. Callers that created endpoints with the setting off now get 403. Three integration tests that relied on that now turn the setting on first, and a new test covers the refusal. - The single-`unavailable` instruction now also applies in suggestion mode. Before, that mode told the agent to "respect unavailable states" with no reason. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - Anthropic Claude Opus 5.5 (`claude-opus-5-5`), used through Claude Code with tool use (shell, file edits, test runs). ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- .../chat-channels.integration.test.ts | 22 +++++ .../connection-intents-service.test.ts | 35 ++++++++ .../person-only-page-guidance.test.ts | 84 +++++++++++++++++++ server/src/routes/chat-channels.ts | 11 +++ server/src/routes/openapi.ts | 35 +++++++- server/src/services/connection-intents.ts | 28 +++++-- 6 files changed, 208 insertions(+), 7 deletions(-) create mode 100644 server/src/__tests__/person-only-page-guidance.test.ts diff --git a/server/src/__tests__/chat-channels.integration.test.ts b/server/src/__tests__/chat-channels.integration.test.ts index aa8aa1a61fb..e5af378da22 100644 --- a/server/src/__tests__/chat-channels.integration.test.ts +++ b/server/src/__tests__/chat-channels.integration.test.ts @@ -9607,7 +9607,27 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { }; } + it("refuses to create a chat endpoint while its provider experiment is off", async () => { + const settings = instanceSettingsService(db); + const previous = await settings.getExperimental(); + try { + await settings.updateExperimental({ enableChatConnectors: false, enableGitHubReviewBots: false }); + const fixture = await seedCompany(); + const { service } = createService(); + const app = routesApp(db, fixture.companyId, service); + for (const provider of ["imessage-photon", "slack", "github"]) { + const response = await request(app).post(`/api/companies/${fixture.companyId}/chat-endpoints`) + .send({ provider, assignedAgentId: fixture.assignedAgentId }).expect(403); + expect(response.body.error).toContain("//company/settings/instance/experimental"); + } + expect(await service.list(fixture.companyId)).toEqual([]); + } finally { + await settings.updateExperimental({ enableChatConnectors: previous.enableChatConnectors, enableGitHubReviewBots: previous.enableGitHubReviewBots }); + } + }); + it("creates a channel-purpose connection and rejects attempts to change its assigned agent", async () => { + await instanceSettingsService(db).updateExperimental({ enableChatConnectors: true }); const fixture = await seedCompany(); const { service } = createService(); const app = routesApp(db, fixture.companyId, service); @@ -9662,6 +9682,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { const signingSecret = "signing-secret-canary"; const clientSecret = "client-secret-canary"; it("saves advanced app details with the initial draft and rejects them for other providers", async () => { + await instanceSettingsService(db).updateExperimental({ enableChatConnectors: true }); const company = await seedCompany(); const { service } = createService(); const app = routesApp(db, company.companyId, service); @@ -11808,6 +11829,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { }); it("rejects chat endpoints for non-invokable agents", async () => { + await instanceSettingsService(db).updateExperimental({ enableChatConnectors: true }); const fixture = await seedCompany(); const { service } = createService(); const app = routesApp(db, fixture.companyId, service); diff --git a/server/src/__tests__/connection-intents-service.test.ts b/server/src/__tests__/connection-intents-service.test.ts index e64d5d93035..43dcc815fc7 100644 --- a/server/src/__tests__/connection-intents-service.test.ts +++ b/server/src/__tests__/connection-intents-service.test.ts @@ -226,6 +226,41 @@ describeEmbeddedPostgres("connectionIntentService", () => { } }); + it("names the instance setting and its page when a requested chat channel is turned off", async () => { + const settings = instanceSettingsService(db); + const previous = await settings.getExperimental(); + const service = connectionIntentService(db); + try { + await settings.updateExperimental({ enableChatConnectors: false }); + expect((await service.search(claims, "iMessage chat messaging with a contact")).results.find(item => item.service === "imessage-photon")?.state).toBe("unavailable"); + const off = await service.search(claims, "iMessage"); + const imessage = off.results.find(item => item.service === "imessage-photon"); + expect(imessage).toMatchObject({ state: "unavailable", methods: [] }); + expect(imessage?.reason).toMatch(/\[Experimental settings\]\(\/[A-Z0-9]+\/company\/settings\/instance\/experimental\)/); + expect(off.instruction).toContain("Explain the reason"); + expect((await service.search(claims, "slack")).results.some(item => item.state === "unavailable" && item.methods.length === 0)).toBe(false); + expect((await service.search(claims, "discord")).results.some(item => item.service === "discord" && item.state === "unavailable")).toBe(false); + const partial = await service.search(claims, "imess"); + expect(partial.results.map(item => item.service)).toEqual(["imessage-photon"]); + expect(partial.instruction).toContain("Explain the reason"); + await settings.updateExperimental({ enableChatConnectors: true }); + const on = (await service.search(claims, "iMessage")).results.find(item => item.service === "imessage-photon"); + expect(on?.state).not.toBe("unavailable"); + expect(on?.methods.some(method => method.purpose === "channel")).toBe(true); + } finally { + await settings.updateExperimental({ enableChatConnectors: previous.enableChatConnectors }); + } + }); + + it("points a service the catalog does not list at the secure own-server setup page", async () => { + const ranked = await connectionIntentService(db).search(claims, "Synthetic notes service connect my account with an API key"); + expect(ranked.results.length).toBeGreaterThan(1); + expect(ranked.instruction).toMatch(/^These are ranked connection matches[\s\S]*\[Connect your own MCP server\]\(\/[A-Z0-9]+\/apps\/byo\)/); + const result = await connectionIntentService(db).search(claims, "Synthetic notes service"); + expect(result.instruction).toMatch(/\[Connect your own MCP server\]\(\/[A-Z0-9]+\/apps\/byo\)/); + expect(result.instruction).toContain("Do not create the connection yourself, ask for the key in chat"); + }); + it("creates one addressed request, resolves after install, and then reports ready for the responsible user", async () => { const service = connectionIntentService(db); const first = await service.request(claims, "notion"); diff --git a/server/src/__tests__/person-only-page-guidance.test.ts b/server/src/__tests__/person-only-page-guidance.test.ts new file mode 100644 index 00000000000..c02e6b586e6 --- /dev/null +++ b/server/src/__tests__/person-only-page-guidance.test.ts @@ -0,0 +1,84 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { expect, it } from "vitest"; +import { runnerApiCatalog, searchRunnerApi } from "../services/native-runtime/runner-api-catalog.js"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); + +it.each([ + ["connect my assistant mcp setup", "GET /api/mcp/setup"], + ["assistant connection consent approve access", "POST /api/mcp/requests/{id}/consent"], +])("search %j says only the person connects an assistant, and links the page", (query, operationId) => { + const operation = searchRunnerApi({ query, limit: 10 }).results.find(row => row.operationId === operationId); + expect(operation, query).toBeDefined(); + expect(operation!.description).toContain("[Assistant connections](//apps/assistant-connection)"); + expect(operation!.description).toContain("a read-only member can grant read access but not writes"); + expect(operation!.description).toContain("Do not search for another route"); +}); + +it.each([ + ["tool connection catalog quarantined", "GET /api/tool-connections/{connectionId}/catalog"], + ["quarantined catalog entry counts review", "GET /api/tool-connections/{connectionId}/review-counts"], + ["tool apps needing attention", "GET /api/companies/{companyId}/tools/apps/attention"], +])("search %j says where a person reviews a quarantined action", (query, operationId) => { + const operation = searchRunnerApi({ query, limit: 10 }).results.find(row => row.operationId === operationId); + expect(operation, query).toBeDefined(); + expect(operation!.description).toContain("[Permissions](//apps//permissions)"); + expect(operation!.description).toContain("No agent can call a quarantined action"); +}); + +it("says chat endpoint creation needs the experiment, and links where it is turned on", () => { + const create = searchRunnerApi({ query: "create chat endpoint imessage", limit: 10 }).results.find(row => row.operationId === "POST /api/companies/{companyId}/chat-endpoints"); + expect(create?.description).toContain("[Experimental settings](//company/settings/instance/experimental)"); +}); + +it.each([ + "complete connection request for someone else", + "connection intent resolve decline", +])("search %j says only the addressee can resolve a connection request card", (query) => { + const found = searchRunnerApi({ query, limit: 10 }).results.filter(row => row.path.startsWith("/api/connection-intents/")); + expect(found.length, query).toBeGreaterThan(0); + for (const operation of found) { + expect(operation.description).toContain("Only that person, signed in to their own browser"); + expect(operation.description).toContain("[](//issues/)"); + } +}); + +it("says on the interaction listing that only the addressee resolves a connection request card", () => { + const list = searchRunnerApi({ query: "GET /api/issues/{id}/interactions" }).results.find(row => row.operationId === "GET /api/issues/{id}/interactions"); + expect(list?.description).toContain("Only that person can connect or decline it"); +}); + +it("names only pages that the board UI mounts", () => { + const appRoutes = readFileSync(path.join(repoRoot, "ui/src/App.tsx"), "utf8"); + const pages = new Set( + runnerApiCatalog() + .filter(row => /^\/api\/(mcp\/(setup|requests\/\{id\}\/consent)$|connection-intents\/|tool-connections\/\{connectionId\}\/(catalog|review-counts)$|companies\/\{companyId\}\/(tools\/apps\/attention|tools\/apps\/connect|tools\/connections|chat-endpoints)$)/.test(row.path)) + .flatMap(row => [...(row.description ?? "").matchAll(/\/\/([a-z0-9/<>-]+)/gi)].map(match => match[1]!)), + ); + expect([...pages].sort()).toEqual(["apps//permissions", "apps/assistant-connection", "apps/byo", "company/settings/instance/experimental", "issues/"]); + const mounted = (page: string) => page + .replace("apps//permissions", "apps/:connectionId/:tab") + .replace("issues/", "issues/:issueId"); + for (const page of pages) expect(appRoutes, page).toContain(` { + const found = searchRunnerApi({ query, limit: 10 }).results.filter(row => ["POST /api/companies/{companyId}/tools/apps/connect", "POST /api/companies/{companyId}/tools/connections"].includes(row.operationId)); + expect(found.length, query).toBeGreaterThan(0); + for (const operation of found) expect(operation.description).toContain("[Connect your own MCP server](//apps/byo)"); +}); + +it("links only mounted pages from connections_search", () => { + const appRoutes = readFileSync(path.join(repoRoot, "ui/src/App.tsx"), "utf8"); + const search = readFileSync(path.join(repoRoot, "server/src/services/connection-intents.ts"), "utf8"); + for (const page of ["apps/byo", "company/settings/instance/experimental"]) { + expect(search, page).toContain(`}/${page})`); + expect(appRoutes, page).toContain(`/company/settings/instance/experimental. Agents cannot change instance settings.`); + } + async function assertConnectionManager( req: ExpressRequest, companyId: string, @@ -195,6 +205,7 @@ export function chatChannelRoutes(db: Db, options: ChatChannelRouteOptions) { async (req, res) => { const companyId = req.params.companyId as string; await assertConnectionManager(req, companyId); + assertChatProviderEnabledForCreate(req.body.provider, await instanceSettingsService(db).getExperimental()); if (req.body.provider === "slack" && !req.body.slackSetupMethod) { req.body.slackSetupMethod = service.slackRegistration.managerGrants.defaultMethod(); } diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index a158743c1e7..16376e8b8df 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -2359,7 +2359,9 @@ registry.registerPath({ tags: ["chat-channels"], summary: "Create a chat endpoint", description: - "Creates one provider bot endpoint bound permanently to one Paperclip agent. Provider setup and verification happen in later calls.", + "Creates one provider bot endpoint bound permanently to one Paperclip agent. Provider setup and verification happen in later calls. " + + "Chat providers need Chat connectors, and GitHub needs GitHub review bots, turned on by an instance admin in Experimental settings at //company/settings/instance/experimental. " + + "While that setting is off, connections_search shows the provider as unavailable and this call is refused; agents cannot change it. Link that page as [Experimental settings](//company/settings/instance/experimental) and reply.", request: { params: z.object({ companyId: z.string().uuid() }), body: jsonBody(createChatEndpointSchema), @@ -8327,6 +8329,7 @@ registry.registerPath({ path: "/api/issues/{id}/interactions", tags: ["issues"], summary: "List issue thread interactions", + description: "A connection_intent interaction is a connection request card addressed to one person, its addresseeUserId. Only that person can connect or decline it; no other member, board user or agent can resolve it for them.", request: { params: z.object({ id: z.string() }) }, responses: { 200: r.ok(), 401: r.unauthorized }, }); @@ -11329,11 +11332,17 @@ registerCurrentRoute({ body: connectionRequestInputSchema, }); +const ADDRESSED_CONNECTION_REQUEST = "Person-only: a connection request card is addressed to one person, its addresseeUserId. " + + "Only that person, signed in to their own browser, can open its setup, connect their account or decline it. The server refuses every other member, board user and agent, so nobody can finish it for them. " + + "When someone else offers to finish it, tell them the addressee must connect their own account from the card on its task, linked as [](//issues/). " + + "Do not create another request for the addressee and do not search for another route."; + registerCurrentRoute({ method: "get", path: "/api/connection-intents/{interactionId}/setup-options", tags: ["connection-intents"], summary: "Get setup options for an addressed connection request", + description: ADDRESSED_CONNECTION_REQUEST, }); registerCurrentRoute({ @@ -11341,6 +11350,7 @@ registerCurrentRoute({ path: "/api/connection-intents/{interactionId}/phase", tags: ["connection-intents"], summary: "Update the setup phase for an addressed connection request", + description: ADDRESSED_CONNECTION_REQUEST, body: z .object({ phase: z.enum(["requested", "authorizing", "needs_retry"]), @@ -11362,6 +11372,7 @@ registerCurrentRoute({ path: "/api/connection-intents/{interactionId}/complete", tags: ["connection-intents"], summary: "Complete an addressed connection request", + description: ADDRESSED_CONNECTION_REQUEST, body: completeConnectionIntentSchema, }); @@ -11387,6 +11398,7 @@ registerCurrentRoute({ path: "/api/connection-intents/{interactionId}/decline", tags: ["connection-intents"], summary: "Decline an addressed connection request", + description: ADDRESSED_CONNECTION_REQUEST, body: declineConnectionIntentSchema, }); @@ -11634,6 +11646,9 @@ registerCurrentRoute({ "Inspect a curated app's public MCP and OAuth metadata without credentials or registration", }); +const OWN_MCP_SERVER_KEY = "When a person's own MCP server needs an API key, the person adds it themselves on [Connect your own MCP server](//apps/byo): they enter its URL and type the key into that page's secure field. " + + "An agent cannot enter the key and must not ask for it in chat. Link that page and reply instead of creating the connection here."; + registerCurrentRoute({ "x-paperclip-transport": "rest", "x-paperclip-connection-management": "connections:configure", @@ -11641,6 +11656,7 @@ registerCurrentRoute({ path: "/api/companies/{companyId}/tools/apps/connect", tags: ["tool-access"], summary: "Create a draft app connection from gallery input", + description: OWN_MCP_SERVER_KEY, body: connectToolAppSchema, responses: { 200: r.ok(), @@ -11688,6 +11704,11 @@ registerCurrentRoute({ }, }); +const QUARANTINED_ACTION_REVIEW = "Catalog entries with status quarantined are new actions that a catalog refresh found and holds for a person's review. " + + "No agent can call a quarantined action, through this connection or any other transport, until a person who manages the connection allows it on the app's Permissions page, " + + "[Permissions](//apps//permissions), with this conversation's company prefix and the connection's id. Allowing it is that person's decision, not an agent's. " + + "When asked to use a quarantined action, say it is waiting for review, link that page and reply; do not search for another route."; + registerCurrentRoute({ "x-paperclip-transport": "rest", "x-paperclip-connection-management": "connections:read", @@ -11695,6 +11716,7 @@ registerCurrentRoute({ path: "/api/companies/{companyId}/tools/apps/attention", tags: ["tool-access"], summary: "List tool apps needing attention", + description: QUARANTINED_ACTION_REVIEW, }); registerCurrentRoute({ @@ -11806,6 +11828,7 @@ registerCurrentRoute({ path: "/api/companies/{companyId}/tools/connections", tags: ["tool-access"], summary: "Create a tool connection", + description: OWN_MCP_SERVER_KEY, body: createToolConnectionSchema, responses: { 201: r.ok(), @@ -11910,6 +11933,7 @@ registerCurrentRoute({ path: "/api/tool-connections/{connectionId}/review-counts", tags: ["tool-access"], summary: "Get pending action request and quarantined catalog entry counts for one connection", + description: QUARANTINED_ACTION_REVIEW, }); registerCurrentRoute({ @@ -12029,6 +12053,7 @@ registerCurrentRoute({ path: "/api/tool-connections/{connectionId}/catalog", tags: ["tool-access"], summary: "List a tool connection catalog", + description: QUARANTINED_ACTION_REVIEW, }); registerCurrentRoute({ @@ -12901,9 +12926,16 @@ registerCurrentRoute({ 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 409: r.conflict }, }); +const ASSISTANT_CONNECTION_SETUP_PERSON_ONLY = "Person-only: a person connects their own assistant, such as Claude, ChatGPT or another MCP client, in their browser on " + + "[Assistant connections](//apps/assistant-connection), with this conversation's company prefix as . " + + "That page gives the invitation and server URL to paste into the assistant; the assistant then opens a Paperclip consent screen where the person picks the company and approves access. " + + "The consent screen offers only access within that person's own role: a read-only member can grant read access but not writes. " + + "An agent cannot read this setup or approve consent for anyone: these routes accept only the person's own signed-in browser session. " + + "Do not search for another route; link that page, say the person approves access there themselves, and reply."; registerCurrentRoute({ method: "get", path: "/api/mcp/setup", tags: ["tool-gateway"], summary: "Read assistant connection setup using a human browser session", + description: ASSISTANT_CONNECTION_SETUP_PERSON_ONLY, // Available while disabled; returns metadata only and never grants access. responses: { 200: r.ok(z.object({ enabled: z.boolean(), serverUrl: z.string().url(), invitationUrl: z.string().url(), invitation: z.string() })), @@ -12944,6 +12976,7 @@ for (const suffix of ["/dot-pairing/preview", "/dot-pairing"]) registerCurrentRo registerCurrentRoute({ method: "post", path: "/api/mcp/requests/{id}/consent", tags: ["tool-gateway"], summary: "Approve or deny assistant access using a same-origin browser session", + description: ASSISTANT_CONNECTION_SETUP_PERSON_ONLY, body: mcpConsentSchema, responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden }, }); diff --git a/server/src/services/connection-intents.ts b/server/src/services/connection-intents.ts index 9386e0eb0ee..683de9b6ce5 100644 --- a/server/src/services/connection-intents.ts +++ b/server/src/services/connection-intents.ts @@ -543,6 +543,21 @@ export function connectionIntentService(db: Db) { connectionId: ready?.id ?? null, }}); } + if (!settings.enableChatConnectors) for (const app of APP_STORE_DEFINITIONS) { + if (!isConnectorFeatureEnabled(app, settings) || discoveryMethods(app).length || findAggregatorService(app.slug) || findAggregatorService(app.name) + || !getAvailableConnectionMethods(app).some(method => method.purpose === "channel" && method.provider !== "github")) continue; + const channelMethods = getAvailableConnectionMethods(app).filter(method => method.purpose === "channel"); + const { score, nameScore } = scoreConnectionSearch(preparedQuery, [app.slug, app.name], + `${app.description ?? ""} ${channelMethods.map(method => `${method.whenToUse} ${method.label ?? ""}`).join(" ")}`); + if (!score) continue; + candidates.push({ score, nameScore, item: { + service: app.slug, name: app.name, description: app.description ?? null, logoUrl: app.branding.logoUrl ?? null, + methods: [], source: "catalog", state: "unavailable", connectionId: null, + reason: `${app.name} chat is turned off on this instance. An instance admin turns it on with Chat connectors on [Experimental settings](/${company.prefix}/company/settings/instance/experimental); ` + + "then a person who manages connections can set it up. Agents cannot change instance settings. " + + "Tell the person this, link that page and reply; do not create a chat endpoint, substitute another channel or search for another route.", + }}); + } // A yielded native service is not offered for new setup when an aggregator // route can serve it; its routes take its place in ranked results, so broad // and capability-only searches still find the app. Without a route the @@ -588,7 +603,7 @@ export function connectionIntentService(db: Db) { // Aggregator routes need a recorded provider choice before connection_request. const rankedResult = (suggestions = false) => { const results = ranked(); - return results.some(item => item.source === "aggregator") ? discoverySuggestions(results) : directSearchResult(query, results, suggestions); + return results.some(item => item.source === "aggregator") ? discoverySuggestions(results) : directSearchResult(query, results, company.prefix, suggestions); }; const targetService = publicService?.slug ?? (indexedMatches.length === 1 ? indexedMatches[0]!.slug : normalizeConnectionQuery(serviceQuery).replaceAll(" ", "-")); // Indexed-only app labels must be identical when requests re-search the slug. @@ -696,13 +711,15 @@ export function connectionIntentService(db: Db) { } } - function directSearchResult(query: string, results: ConnectionSearchResultItem[], suggestions = false): ConnectionsSearchResult { + function directSearchResult(query: string, results: ConnectionSearchResultItem[], prefix: string, suggestions = false): ConnectionsSearchResult { + const ownServer = `the person adds it as their own MCP server on [Connect your own MCP server](/${prefix}/apps/byo): they enter the URL and type any API key into that page's secure field themselves. ` + + "An agent cannot enter the key. Do not create the connection yourself, ask for the key in chat or search for another route; link that page and reply."; return { version: 1, query, results, instruction: !results.length - ? "No verified connection route was found. Explain that support could not be verified; do not invent a provider route or request unsupported services." + ? `No verified connection route was found. If the person gave the service's own server URL, ${ownServer} Otherwise explain that support could not be verified; do not invent a provider route or request unsupported services.` : !suggestions && isRemoteMcpConnectorId(results[0]!.service) && results[0]!.state !== "unavailable" ? `${results[0]!.name} is an external service. When the user explicitly names this provider, disclose that it handles the connection and requests to the requested app; no additional provider-choice question is necessary. ${results[0]!.state === "ready" ? aggregatorContinuationInstruction(results[0]!.service, "The requested app") : "Call connection_request with the returned service identifier and follow its instruction. Provider setup does not yet verify underlying app access."}` - : !suggestions && results[0]!.state === "unavailable" ? "This connection is unavailable or administratively restricted. Explain the reason. Do not bypass it using another provider." - : suggestions || results.length > 1 ? "These are ranked connection matches. Choose the relevant service and method using their descriptions and purposes; extra query words need not match. For user-requested configuration, inspect method.management and use search_api/call_api with nextApiOperation when allowed. Hand off only required authentication, consent, credentials, or decisions at the saved setupPath destination. Use connection_request when human setup or access is required. Use ready tools as installed; ready AI authentication is available for the agent's next execution and does not need reconnection. Respect unavailable states and recorded user choices. Clarify only if the intended service is still ambiguous; unrelated matches are not evidence of support." + : (!suggestions || results.length === 1) && results[0]!.state === "unavailable" ? "This connection is unavailable or administratively restricted. Explain the reason. Do not bypass it using another provider." + : suggestions || results.length > 1 ? `These are ranked connection matches. Choose the relevant service and method using their descriptions and purposes; extra query words need not match. For user-requested configuration, inspect method.management and use search_api/call_api with nextApiOperation when allowed. Hand off only required authentication, consent, credentials, or decisions at the saved setupPath destination. Use connection_request when human setup or access is required. Use ready tools as installed; ready AI authentication is available for the agent's next execution and does not need reconnection. Respect unavailable states and recorded user choices. Clarify only if the intended service is still ambiguous; unrelated matches are not evidence of support. If none of these is the service the person named and they gave that service's own server URL, ${ownServer}` : results[0]!.state === "ready" && results[0]!.methods.every(method => method.purpose === "ai") ? "AI authentication is available for this agent's next execution. Do not create another connection request or ask the user to reconnect." : results[0]!.service === "agentmail" @@ -712,7 +729,6 @@ export function connectionIntentService(db: Db) { : results[0]!.methods.every(method => method.purpose && method.purpose !== "tool") ? "Choose the method relevant to the task using its purpose and label. If method.management.allowed, configure through search_api/call_api using nextApiOperation. Hand off only required human authentication or consent at the saved setupPath. These methods do not use the tool connection_request card. Do not claim tools or an inbox are ready before setup finishes." : results[0]!.state === "ready" ? "Use the installed connection. Do not create another connection request." - : results[0]!.state === "unavailable" ? "This connection is unavailable or administratively restricted. Explain the reason. Do not bypass it using another provider." : "The user asked to connect. If method.management.allowed, use search_api/call_api with nextApiOperation to configure it. Reuse a saved draft. Use connection_request only for required human input or access. Do not ask a generic confirmation or silently change policy to bypass a failed tool call." }; } From b1049b174e00eba29d9368a84f80128e9b7d91ef Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 9 Nov 2026 02:40:13 -0600 Subject: [PATCH 12/21] fix(native): retire a finished remote owner whose suspend never finished once its sandbox leases ended (#20211) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Before a per-turn follow-up starts a new runner, it retires the previous turn's finished owner. Retirement requires the owner's controller journal to be settled > - A local runner that is proven stopped can no longer act on its own lifecycle commands, so an unfinished `runner.suspend` from it does not hold the follow-up > - On a remote per-turn sandbox the same thing happens when a restart or drain lands while the runner is suspending. But the remote path had no equivalent rule, so the unfinished `runner.suspend` held the follow-up even after the previous run's sandbox lease had ended > - This pull request treats a remote owner's lifecycle commands as settled once every sandbox lease of that run has been released or expired, and none of them is the sandbox the follow-up now uses > - The benefit is that a C1 follow-up after a restart on Daytona continues on its own ## Linked Issues or Issue Description Refs #20140 **What happened?** Daytona per-turn restart-chaos K13 storm w-20261109T074128-s9503 (revision 9046884811, image 01246d43): 60 of 65 passed. Three C1 follow-ups failed with `runner_state_identity_mismatch: prior_owner_terminal_state_indeterminate; retirement_refused=journal_commands_unsettled(runner.suspend.pending)`: - t04 codex, run `c7446d0c`; - t16 claude, run `4b2c770f`; - t39 claude, run `3ef7d934`. For t04, the previous owner `fc585314` committed its `run.terminal`, and its journal kept only `runner.suspend` pending. Its per-turn lease (`5576f27d…`) expired at 07:43:52, and the follow-up ran on a new sandbox. Each Retry succeeded. **Expected behavior** Once the previous run's sandbox leases have all ended, and the follow-up runs on another sandbox, the previous runner cannot act on its suspend. The finished owner is retired and the follow-up continues. The owner stays held while any of these holds: - a lease is active, retained or pending cleanup; - the follow-up runs on the same sandbox; - no lease is recorded. **Steps to reproduce** 1. Finish a remote per-turn run whose controller journal keeps `runner.suspend` pending after its committed run terminal, and let its sandbox lease end. 2. Start a follow-up on a new sandbox. Before this change, retirement refuses with `journal_commands_unsettled(runner.suspend.pending)`. **Paperclip version or commit** 9046884811 (wave). Branch rebased on current master. ## What Changed - `native-session-executor.ts`: - the new `priorRemoteSandboxLeasesEnded` checks the previous run's `environment_leases`: at least one lease, every one `released` or `expired` with `releasedAt`, and none on the current target's provider lease; - `inspectFinishedPerTurnRunnerdOwner` passes the result as `remoteRunnerGone` to `readSettledRunnerdProjection`, for the live root and for quarantined copies. That function then ignores unfinished runner lifecycle commands (`runner.suspend`, `runner.drain`, `runner.shutdown`), exactly as it already does for a stopped local runner. - `native-recovered-owner-followup.test.ts`: a red/green test with lease variants. A lease that ended or was released on another sandbox is admitted. A retained lease, an ended lease on the same sandbox, or no lease record stays held. ## Verification - Red/green: on master, the two admitted cases fail with exactly the production error; with the change they are admitted. The three held cases hold on both. - `vitest run` of the recovered-owner, remote-warm and restart-recovery test files and `native-session-executor.test.ts`: 7 files, 1634 tests passed (14 skipped). - `PAPERCLIP_TYPECHECK_LOW_MEMORY=1 pnpm typecheck` (`server`): passed. ## Risks - Low risk. Only runner lifecycle commands become settled, and only when the database proves every sandbox of the previous run has ended and the follow-up runs elsewhere. Unfinished turn, tool or result commands, a missing run terminal and unanswered tool input still hold. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), via Claude Code with tool use (shell, file edits, test execution). ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- .../native-recovered-owner-followup.test.ts | 21 ++++++++++++++ .../native-runtime/native-session-executor.ts | 29 +++++++++++++++---- 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/server/src/services/native-runtime/native-recovered-owner-followup.test.ts b/server/src/services/native-runtime/native-recovered-owner-followup.test.ts index e1887754d71..3e6af4c6106 100644 --- a/server/src/services/native-runtime/native-recovered-owner-followup.test.ts +++ b/server/src/services/native-runtime/native-recovered-owner-followup.test.ts @@ -670,6 +670,27 @@ describe.skipIf(!recoveryPostgresSupport.supported)("follow-up after a recovered expect(await archivedOwners(root)).toEqual([prior.runId]); }, 60_000); + it.each([ + { label: "ended on another sandbox", status: "expired", sameSandbox: false, recorded: true, outcome: ADMITTED }, + { label: "released on another sandbox", status: "released", sameSandbox: false, recorded: true, outcome: ADMITTED }, + { label: "still retained", status: "retained", sameSandbox: false, recorded: true, + outcome: stranded("journal_commands_unsettled(runner.suspend.pending)") }, + { label: "ended on this sandbox", status: "expired", sameSandbox: true, recorded: true, + outcome: stranded("journal_commands_unsettled(runner.suspend.pending)") }, + { label: "not recorded", status: "expired", sameSandbox: false, recorded: false, + outcome: stranded("journal_commands_unsettled(runner.suspend.pending)") }, + ])("retires a finished remote owner whose suspend never finished when its sandbox lease is $label", async ({ status, sameSandbox, recorded, outcome }) => { + const prior = await seedRecoveredPriorRun("pending-command"); + const followUp = await admitFollowUp(prior); + if (recorded) { + await db.insert(environments).values({ id: followUp.environmentId, name: `ended-${followUp.environmentId}`, driver: "sandbox" }); + await db.insert(environmentLeases).values({ companyId, environmentId: followUp.environmentId, issueId: prior.issueId, + heartbeatRunId: prior.runId, provider: "daytona", providerLeaseId: sameSandbox ? followUp.providerLeaseId : `sandbox-${prior.runId}`, + status, releasedAt: status === "retained" ? null : new Date(), metadata: { remoteCwd: "/workspace" } }); + } + expect(await followUp.start()).toBe(outcome); + }, 60_000); + it("admits again when a retry rebuilds the same projection from the database", async () => { const prior = await seedRecoveredPriorRun(); const followUp = await admitFollowUp(prior); diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index 8512171085d..7a4bc5ed81f 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -5429,7 +5429,7 @@ type SettledRunnerdProjection = { bytes: string; refused?: never } | { bytes?: n const LOCAL_RUNNER_LIFECYCLE_COMMANDS = new Set(["runner.suspend", "runner.drain", "runner.shutdown"]); function readSettledRunnerdProjection(root: string, identity: Record, - options: { localRunner?: boolean; controlBytes?: string } = {}): SettledRunnerdProjection { + options: { localRunner?: boolean; controlBytes?: string; remoteRunnerGone?: boolean } = {}): SettledRunnerdProjection { const refuse = (refused: string): SettledRunnerdProjection => ({ refused }); try { if (!isSafeNativeStateDirectory(root)) return refuse("root_unsafe"); @@ -5449,7 +5449,7 @@ function readSettledRunnerdProjection(root: string, identity: Record !LOCAL_RUNNER_LIFECYCLE_COMMANDS.has(String(command.type)) || command.status === "completed") : commands; const settledControl = settledCommands === commands ? control : { ...control, commands: settledCommands }; @@ -5553,11 +5553,16 @@ async function inspectFinishedPerTurnRunnerdOwner(input: { let originalBytes: string; try { originalBytes = readBoundedNativeFile(statePath, NATIVE_CONTROL_PLANE_STATE_MAX_BYTES, "runner_durable_identity_too_large").toString("utf8"); } catch { return refuse("journal_unreadable"); } - const projectionOptions = { localRunner, controlBytes: input.projectedControlBytes }; + const { binding } = input.execution; + const remoteRunnerGone = !localRunner && await priorRemoteSandboxLeasesEnded(input.db, { + companyId: binding.companyId, runId: identity.runId, + currentProviderLeaseId: target?.kind === "remote" && target.transport === "sandbox" + ? target.sandboxLeaseAcquisition?.providerLeaseId : undefined, + }); + const projectionOptions = { localRunner, controlBytes: input.projectedControlBytes, remoteRunnerGone }; const projection = readSettledRunnerdProjection(input.root, identity, projectionOptions); if (projection.refused !== undefined) return refuse(projection.refused); const controlBytes = projection.bytes; - const { binding } = input.execution; let prior: { run: typeof heartbeatRuns.$inferSelect; owner: typeof nativeRunFinalizations.$inferSelect } | undefined; try { [prior] = await input.db.select({ run: heartbeatRuns, owner: nativeRunFinalizations }).from(heartbeatRuns) @@ -5619,7 +5624,7 @@ async function inspectFinishedPerTurnRunnerdOwner(input: { for (const name of readdirSync(quarantine)) { if (!name.startsWith(`${basename(input.root)}.`)) continue; if (quarantinedBootstrapOfOwnerHeldNoProviderWork(resolve(quarantine, name), identity, localRunner)) continue; - const copy = readSettledRunnerdProjection(resolve(quarantine, name), identity, { localRunner }); + const copy = readSettledRunnerdProjection(resolve(quarantine, name), identity, { localRunner, remoteRunnerGone }); if (copy.refused !== undefined) return refuse(`quarantine_${copy.refused}`); } } @@ -5643,6 +5648,20 @@ export function quarantinedBootstrapOfOwnerHeldNoProviderWork(root: string, iden || (!localRunner && runnerdStateProvesNoRemoteProviderTurn(root)); } +async function priorRemoteSandboxLeasesEnded(db: Db, input: { + companyId: string; runId: string; currentProviderLeaseId?: string; +}): Promise { + try { + const leases = await db.select({ status: environmentLeases.status, releasedAt: environmentLeases.releasedAt, + providerLeaseId: environmentLeases.providerLeaseId }).from(environmentLeases) + .where(and(eq(environmentLeases.companyId, input.companyId), eq(environmentLeases.heartbeatRunId, input.runId))); + return leases.length > 0 && leases.every(lease => (lease.status === "released" || lease.status === "expired") + && lease.releasedAt !== null && lease.providerLeaseId !== input.currentProviderLeaseId); + } catch { + return false; + } +} + async function retireFinishedPerTurnRunnerdOwner(input: Parameters[0]) : Promise<{ retired: true } | { retired: false; refused: string }> { const proof = await inspectFinishedPerTurnRunnerdOwner(input); From 6383eb80331ef1721ff8d3caa9173f3cce784129 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 9 Nov 2026 02:50:16 -0600 Subject: [PATCH 13/21] fix(authz): require board authority for agent budget changes on every route (#20213) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Budgets stop agent spending. The budget hard stop is a control-plane invariant > - The budget routes (`PATCH /api/agents/:id/budgets`, `PATCH /api/companies/:id/budgets`) are board-only > - `PATCH /api/agents/:id` also accepts `budgetMonthlyCents`, and the configuration service writes the agent budget policy from it. That route had no board check for the field > - So an agent, such as a CEO, a manager or the agent itself, can change a monthly budget without a board user. This is a second route around a board-only control, like the pause bypass that #20164 fixed > - This pull request applies the budget route authority to every route that writes an agent budget, and gives agents the final board-only denial with a link to the budget page (#20151) > - The benefit is that only the board changes budgets, and an agent that asks gets a final answer that it can pass to the person ## Linked Issues or Issue Description Refs #17857 (earlier fix, now in conflict with master; this PR rebases it and adds the budget page link). Refs #20164 and #20151 (same pattern for PATCH status and board-only denials). **What happened** An agent can send `PATCH /api/agents/{id}` with `budgetMonthlyCents`. The route accepts it, and the configuration service writes the agent budget policy. This works for the agent's own budget and, with update permission, for other agents. The same field also reaches budget policy through a configuration rollback, direct agent creation, an immediate hire and a built-in agent provision. A chat eval (`budgets-costs.resolve-budget`) found the route. **Expected behavior** Only a board user can change an active budget, as on the dedicated budget routes. An agent gets `403` with code `board_access_required`. The denial says it is final and links the agent's budget tab (`//agents//budgets`) or the company budgets page (`//activity/budgets`). An agent can still propose a budget in a hire that waits for board approval. The approving board user activates it. **Steps to reproduce** 1. On master, authenticate as an agent of a company (for example the CEO). 2. Send `PATCH /api/agents/{agentId}` with body `{"budgetMonthlyCents": 5000}`, for its own id or for another agent. 3. See `200`. The agent budget policy changes with no board action. 4. The new route tests reproduce this: on master source they fail with `expected 200 to be 403`. ## What Changed - `PATCH /api/agents/:id`: a changed `budgetMonthlyCents` requires a board actor. An unchanged amount with other edits still works. - Configuration rollback: a rollback that changes the budget requires a board actor. - `POST /api/companies/:id/agents` and an immediate `agent-hires`: a positive budget requires a board actor. A hire that needs board approval keeps the proposed budget and does not activate it before approval. - Built-in agent provision: a budget override requires a board actor. - `prepareAgentConfiguration` rejects an agent-originated budget change again (defense in depth). An agent rollback with an unchanged amount does not rewrite the policy. - Board writes keep the actual operator identity. The initial hire budget is saved in the hire transaction. - `assertBoard` and `boardAccessRequired` take an optional board page. Budget denials link the agent's budget tab or the company budgets page, not the general agent profile. - Agent access map updated. - This includes the four commits of #17857 (f970f6b01f, 8c06aba41c, e5682d22fa, b6cd49d4b6), rebased onto master with the conflicts resolved, then squashed. ## Verification - Red on master source, same tests: 12 budget tests fail. The PATCH tests fail with `expected 200 to be 403` for the agent's own budget and for a CEO on another agent. Built-in provision fails with `expected 200 to be 403`. Creation and immediate hire fail with `expected 201 to be 403`. - Green with the fix: `cd server && npx vitest run src/__tests__/agent-permissions-routes.test.ts src/__tests__/built-in-agent-routes.test.ts src/__tests__/built-in-agents.test.ts src/__tests__/agent-hire-idempotency-routes.test.ts src/__tests__/board-access-denial.test.ts src/__tests__/agent-budget-configuration.test.ts`: 222 passed. - `src/modules/agent-lifecycle/__tests__/lifecycle.test.ts` (embedded Postgres): 76 passed. - `agent-skills-routes`, `agent-adapter-validation-routes`, `openapi-routes`, `agent-cross-tenant-authz-routes`: 275 passed. - `node scripts/check-maps.mjs`: passed. `git diff --check`: passed. - Server `tsc --noEmit`: no errors in changed files. One error in `native-session-executor.ts` comes from a linked runner build in the local worktree, not from this change. CI runs the full typecheck. ## Risks - Agent clients that set an active budget directly now get `403`. They must propose the budget in an approval-gated hire, or ask a board user. - Agent creation with no budget or a zero budget is not changed. Pending hire budgets do not activate before approval. - Trusted internal plugin provisioning keeps its prior behavior and gets no board identity. - No schema or migration change. ## Model Used - Anthropic Claude Opus 5.5 (`claude-opus-5-5`), with tool use and code execution, in Claude Code. The rebased commits from #17857 were first written with OpenAI `gpt-6.1-sol` through Codex. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- maps/agent-access-map/agents.md | 4 +- .../agent-budget-configuration.test.ts | 21 ++++++ .../agent-hire-idempotency-routes.test.ts | 4 +- .../agent-permissions-routes.test.ts | 74 +++++++++++++++++++ .../__tests__/built-in-agent-routes.test.ts | 12 +++ server/src/__tests__/built-in-agents.test.ts | 8 ++ server/src/lib/agent-records.ts | 1 + .../__tests__/lifecycle.test.ts | 43 +++++++++++ .../agent-lifecycle/adapters/records.ts | 6 +- server/src/routes/agents.ts | 49 ++++++------ server/src/routes/authz.ts | 23 ++++-- server/src/routes/built-in-agents.ts | 3 +- server/src/services/agent-configuration.ts | 6 +- server/src/services/built-in-agents.ts | 13 ++-- 14 files changed, 226 insertions(+), 41 deletions(-) create mode 100644 server/src/__tests__/agent-budget-configuration.test.ts diff --git a/maps/agent-access-map/agents.md b/maps/agent-access-map/agents.md index 8b8c24511bb..96f9550ca37 100644 --- a/maps/agent-access-map/agents.md +++ b/maps/agent-access-map/agents.md @@ -83,7 +83,7 @@ An unfinished record keeps compatibility status `paused` with a `setup` hold and cannot receive API keys or authenticate with keys or JWTs. Its standard grants apply at hire. Authorized completion retries recover the same record and approval without changing the submitted configuration. -Normal agent-authored hiring keeps its existing approval policy. +Normal agent-authored hiring keeps its existing approval policy. A pending hire may propose a budget, but only board approval activates that policy. Direct creation or an immediate hire with a positive budget requires a board actor. An agent-owned plugin may prepare the draft and leave its lifecycle in `verifying` while that setup hold remains. Only bounded Core-issued setup probes @@ -212,6 +212,8 @@ CLI. Policy: Agent creation/configuration uses action grants, resource policies and change consent; some controls require a board actor. +Changes to the legacy `budgetMonthlyCents` field and built-in provision budget overrides require a board actor, including budget-changing rollbacks. + The roster intent preserves unrelated settings, rejects stale model/connection previews and harness changes, and checks the full execution configuration under the lifecycle write lock. It uses the same configuration, company, credential, diff --git a/server/src/__tests__/agent-budget-configuration.test.ts b/server/src/__tests__/agent-budget-configuration.test.ts new file mode 100644 index 00000000000..4f2ff10c8d1 --- /dev/null +++ b/server/src/__tests__/agent-budget-configuration.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import type { Db } from "@paperclipai/db"; +import type { AgentConfigurationRecord } from "../lib/agent-records.js"; +import { prepareAgentConfiguration } from "../services/agent-configuration.js"; + +const existing = { id: "agent", companyId: "company", status: "idle", budgetMonthlyCents: 500 } as AgentConfigurationRecord; +// Preparation queues retention work through the database before reading the patch. +const fakeDb = () => ({ execute: async () => [] }) as unknown as Db; +describe("budget authority in configuration preparation", () => { + it("rejects agent-originated budget changes before persistence or policy audit", async () => { + const db = fakeDb(); + await expect(prepareAgentConfiguration(db, existing, { budgetMonthlyCents: 5000 }, { recordRevision: { createdByAgentId: "ceo" } })).rejects.toMatchObject({ status: 403 }); + await expect(prepareAgentConfiguration(db, existing, { budgetMonthlyCents: 0 }, { recordRevision: { createdByAgentId: "ceo" } })).rejects.toMatchObject({ status: 403 }); + }); + it("removes an unchanged legacy budget from agent rollback so it cannot rewrite policy or invent a board actor", async () => { + expect(await prepareAgentConfiguration(fakeDb(), existing, { budgetMonthlyCents: 500 }, { recordRevision: { createdByAgentId: "ceo", source: "rollback" } })).toEqual({}); + }); + it("preserves the actual board-authorized amount for the existing policy writer", async () => { + expect(await prepareAgentConfiguration(fakeDb(), existing, { budgetMonthlyCents: 5000 }, { recordRevision: { createdByUserId: "operator-user" } })).toEqual({ budgetMonthlyCents: 5000 }); + }); +}); diff --git a/server/src/__tests__/agent-hire-idempotency-routes.test.ts b/server/src/__tests__/agent-hire-idempotency-routes.test.ts index 8d2740f40ff..282cae1da5d 100644 --- a/server/src/__tests__/agent-hire-idempotency-routes.test.ts +++ b/server/src/__tests__/agent-hire-idempotency-routes.test.ts @@ -185,11 +185,11 @@ describeEmbeddedPostgres("agent hire idempotency within a run", () => { // Same identity, corrected configuration: the agent meant a different hire. const corrected = await request(app) .post(`/api/companies/${company.id}/agent-hires`) - .send({ name: "Sam", role: "engineer", adapterType: "process", budgetMonthlyCents: 5000 }); + .send({ name: "Sam", role: "engineer", adapterType: "process", title: "Store Builder" }); expect(corrected.status, JSON.stringify(corrected.body)).toBe(201); expect(corrected.body.idempotent).toBeUndefined(); expect(corrected.body.agent?.id).not.toBe(first.body.agent?.id); - expect(corrected.body.agent?.budgetMonthlyCents).toBe(5000); + expect(corrected.body.agent?.title).toBe("Store Builder"); }); it("still creates a distinct agent for a different hire in the same run", async () => { diff --git a/server/src/__tests__/agent-permissions-routes.test.ts b/server/src/__tests__/agent-permissions-routes.test.ts index 555bc23ccd1..132e8dee1af 100644 --- a/server/src/__tests__/agent-permissions-routes.test.ts +++ b/server/src/__tests__/agent-permissions-routes.test.ts @@ -2642,6 +2642,80 @@ describe("agent permission routes", () => { }); }); + it.each([ + ["its own budget", agentId, 0], + ["its own budget", agentId, 5000], + ["another agent's budget as a CEO", "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", 5000], + ])("budget authority: PATCH /agents/:id never lets an agent change %s (%s, %i)", async (_case, actorAgentId, amount) => { + mockAgentService.getById.mockImplementation(async (id: string) => id === agentId + ? { ...baseAgent, budgetMonthlyCents: 500 } + : { ...baseAgent, id, role: "ceo", permissions: { canCreateAgents: true } }); + const app = createApp({ type: "agent", agentId: actorAgentId, companyId, source: "agent_key", runId: "run-1" }); + const response = await requestApp(app, url => request(url).patch(`/api/agents/${agentId}`).send({ budgetMonthlyCents: amount })); + expect(response.status, JSON.stringify(response.body)).toBe(403); + expect(response.body.code).toBe("board_access_required"); + expect(response.body.remediation).toContain("this denial is final"); + expect(response.body.remediation).toContain(`[Open the agent's budget](//agents/${agentId}/budgets)`); + expect(mockAgentService.update).not.toHaveBeenCalled(); + expect(mockBudgetService.upsertPolicy).not.toHaveBeenCalled(); + expect(mockLogActivity).not.toHaveBeenCalled(); + }); + it("budget authority: a configuration rollback never lets an agent restore a different budget", async () => { + mockAgentService.getById.mockResolvedValue({ ...baseAgent, budgetMonthlyCents: 500 }); + mockAgentService.getConfigRevision.mockResolvedValue({ + id: "33333333-3333-4333-8333-333333333333", + afterConfig: { adapterType: "process", adapterConfig: {}, runtimeConfig: {}, budgetMonthlyCents: 50000 }, + }); + const app = createApp({ type: "agent", agentId, companyId, source: "agent_key", runId: "run-1" }); + const response = await requestApp(app, url => request(url).post(`/api/agents/${agentId}/config-revisions/33333333-3333-4333-8333-333333333333/rollback`)); + expect(response.status, JSON.stringify(response.body)).toBe(403); + expect(response.body.code).toBe("board_access_required"); + expect(response.body.remediation).toContain(`[Open the agent's budget](//agents/${agentId}/budgets)`); + expect(mockAgentService.rollbackConfigRevision).not.toHaveBeenCalled(); + }); + it("budget authority: accepts an unchanged legacy amount with an unrelated title edit", async () => { + const app = createApp({ type: "agent", agentId, companyId, source: "agent_key" }); + const response = await requestApp(app, url => request(url).patch(`/api/agents/${agentId}`).send({ budgetMonthlyCents: 0, title: "New title" })); + expect(response.status).toBe(200); + expect(mockAgentService.update).toHaveBeenCalled(); + }); + it.each(["agents", "agent-hires"])("budget authority: rejects agent immediate creation budgets through %s", async route => { + mockAccessService.hasPermission.mockResolvedValue(true); + const app = createApp({ type: "agent", agentId, companyId, source: "agent_key" }); + const response = await requestApp(app, url => request(url).post(`/api/companies/${companyId}/${route}`).send({ name: "New employee", role: "engineer", adapterType: "process", budgetMonthlyCents: 5000 })); + expect(response.status, JSON.stringify(response.body)).toBe(403); + expect(response.body.code).toBe("board_access_required"); + expect(response.body.remediation).toContain("[Open budgets](//activity/budgets)"); + expect(mockAgentService.create).not.toHaveBeenCalled(); + expect(mockBudgetService.upsertPolicy).not.toHaveBeenCalled(); + expect(mockLogActivity).not.toHaveBeenCalled(); + }); + it("budget authority: preserves a proposed budget without applying a policy before approval", async () => { + mockAccessService.hasPermission.mockResolvedValue(true); + mockAgentService.create.mockResolvedValue({ ...baseAgent, status: "pending_approval", budgetMonthlyCents: 5000 }); + mockApprovalService.create.mockResolvedValue({ id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", status: "pending", type: "hire_agent", companyId }); + const app = createApp({ type: "agent", agentId, companyId, source: "agent_key" }, { requireBoardApprovalForNewAgents: true }); + const response = await requestApp(app, url => request(url).post(`/api/companies/${companyId}/agent-hires`).send({ name: "Proposed employee", role: "engineer", adapterType: "process", budgetMonthlyCents: 5000 })); + expect(response.status).toBe(201); + expect(response.body.approval.status).toBe("pending"); + expect(mockApprovalService.create).toHaveBeenCalledWith(companyId, expect.objectContaining({ payload: expect.objectContaining({ budgetMonthlyCents: 5000 }) })); + expect(mockBudgetService.upsertPolicy).not.toHaveBeenCalled(); + }); + it("budget authority: forwards the actual board identity on legacy updates", async () => { + const app = createApp({ type: "board", userId: "operator-user", source: "session", companyIds: [companyId] }); + const response = await requestApp(app, url => request(url).patch(`/api/agents/${agentId}`).send({ budgetMonthlyCents: 5000 })); + expect(response.status).toBe(200); + expect(mockAgentService.update).toHaveBeenCalledWith(agentId, expect.objectContaining({ budgetMonthlyCents: 5000 }), expect.objectContaining({ recordRevision: expect.objectContaining({ createdByUserId: "operator-user", createdByAgentId: null }) })); + expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ actorType: "user", actorId: "operator-user", agentId: null })); + }); + it.each(["agents", "agent-hires"])("budget authority: records the real board actor for immediate %s budgets", async route => { + mockAgentService.create.mockResolvedValue({ ...baseAgent, budgetMonthlyCents: 5000 }); + const app = createApp({ type: "board", userId: "operator-user", source: "local_implicit", companyIds: [companyId] }); + const response = await requestApp(app, url => request(url).post(`/api/companies/${companyId}/${route}`).send({ name: "New employee", role: "engineer", adapterType: "process", budgetMonthlyCents: 5000 })); + expect(response.status).toBe(201); + expect(mockAgentService.create).toHaveBeenCalledWith(companyId, expect.objectContaining({ budgetMonthlyCents: 5000 }), expect.objectContaining({ createdByUserId: "operator-user" })); + expect(mockBudgetService.upsertPolicy).not.toHaveBeenCalled(); + }); it("rejects heartbeat cancellation outside the caller company scope", async () => { mockHeartbeatService.getRun.mockResolvedValue({ id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", diff --git a/server/src/__tests__/built-in-agent-routes.test.ts b/server/src/__tests__/built-in-agent-routes.test.ts index e51166f2ba4..b6d985b4170 100644 --- a/server/src/__tests__/built-in-agent-routes.test.ts +++ b/server/src/__tests__/built-in-agent-routes.test.ts @@ -204,6 +204,16 @@ describe("built-in agent routes", () => { })); }); + it.each([0, 5000])("rejects an agent budget override through built-in provision (%s)", async budgetMonthlyCents => { + const app = await createApp({ type: "agent", agentId: "agent-1", companyId, source: "agent_key" }); + const response = await request(app).post(`/api/companies/${companyId}/built-in-agents/briefs/provision`).send({ budgetMonthlyCents }); + expect(response.status).toBe(403); + expect(response.body.code).toBe("board_access_required"); + expect(response.body.remediation).toContain("[Open budgets](//activity/budgets)"); + expect(mockBuiltInAgentService.provision).not.toHaveBeenCalled(); + expect(mockLogActivity).not.toHaveBeenCalled(); + }); + it("denies provision when agents:create is not allowed", async () => { mockAccessService.decide.mockResolvedValue(denyDecision()); const app = await createApp({ @@ -372,6 +382,7 @@ describe("built-in agent routes", () => { const app = await createApp({ type: "agent", agentId: "manager-agent", + runId: "native-request-run", companyId, source: "agent_key", }); @@ -395,6 +406,7 @@ describe("built-in agent routes", () => { actorType: "agent", actorId: "manager-agent", action: "approval.created", + runId: "native-request-run", entityId: "approval-1", })); }); diff --git a/server/src/__tests__/built-in-agents.test.ts b/server/src/__tests__/built-in-agents.test.ts index c43f911d6e7..e6989b1798b 100644 --- a/server/src/__tests__/built-in-agents.test.ts +++ b/server/src/__tests__/built-in-agents.test.ts @@ -266,6 +266,14 @@ describeEmbeddedPostgres("built-in agents", () => { ])).toThrow("defaultAdapterType must be allowed"); }); + it("retains the actual operator on a provisioned existing agent budget", async () => { + const companyId = await seedCompany({ requireApproval: false }); + const svc = builtInAgentService(db); + const original = await svc.ensure(companyId, "briefs"); + await svc.provision(companyId, "briefs", { budgetMonthlyCents: 5000 }, { requestedByUserId: "budget-operator" }); + expect(await db.select().from(budgetPolicies).where(eq(budgetPolicies.scopeId, original.agentId!))).toMatchObject([{ amount: 5000, createdByUserId: "budget-operator" }]); + }); + it.each([{ platform: "linux", architecture: "x64" }, { platform: "linux", architecture: "arm64" }, { platform: "darwin", architecture: "arm64" }, { platform: "darwin", architecture: "x64" }])("lazily provisions one agent per company/key and updates the same row on setup ($platform/$architecture)", async ({ platform, architecture }) => { runnerTarget.platform = platform; runnerTarget.architecture = architecture; diff --git a/server/src/lib/agent-records.ts b/server/src/lib/agent-records.ts index b9a7bb702f7..9a075a860a9 100644 --- a/server/src/lib/agent-records.ts +++ b/server/src/lib/agent-records.ts @@ -89,6 +89,7 @@ export type AgentConfigurationPatch = Partial; export interface CreateAgentOptions { + initialBudgetPolicyUserId?: string; setupOnly?: boolean; setupAgentId?: string; runnerResolved?: boolean; diff --git a/server/src/modules/agent-lifecycle/__tests__/lifecycle.test.ts b/server/src/modules/agent-lifecycle/__tests__/lifecycle.test.ts index 2502fae80cf..42b2e7c0bdf 100644 --- a/server/src/modules/agent-lifecycle/__tests__/lifecycle.test.ts +++ b/server/src/modules/agent-lifecycle/__tests__/lifecycle.test.ts @@ -357,6 +357,49 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu expect((await current(setupId)).lifecycleHolds).toContain("setup"); }); + it("saves the immediate hire budget and actual operator audit in its transaction", async () => { + const agent = await createAgentLifecycle(db).requestHire(companyId, { name: "Capped hire", adapterType: "process", budgetMonthlyCents: 5000 }, { createdByUserId: "operator-user", initialBudgetPolicyUserId: "operator-user" }); + expect(await db.select().from(budgetPolicies).where(eq(budgetPolicies.scopeId, agent.id))).toMatchObject([{ amount: 5000 }]); + const audit = await db.select().from(activityLog).where(and(eq(activityLog.companyId, companyId), eq(activityLog.action, "budget.policy_upserted"))); + expect(audit).toMatchObject([{ actorType: "user", actorId: "operator-user", agentId: null }]); + }); + + it("preserves trusted no-user provisioning without inventing initial board authority", async () => { + const agent = await createAgentLifecycle(db).requestHire(companyId, { name: "Internal declared budget", budgetMonthlyCents: 5000 }); + expect(agent.budgetMonthlyCents).toBe(5000); + expect(await db.select().from(budgetPolicies).where(eq(budgetPolicies.scopeId, agent.id))).toHaveLength(0); + expect(await db.select().from(activityLog).where(and(eq(activityLog.companyId, companyId), eq(activityLog.action, "budget.policy_upserted")))).toHaveLength(0); + }); + + it("credits only the approving operator for a positive-budget approval without a precreated agent", async () => { + const approval = await approvalService(db).create(companyId, { type: "hire_agent", requestedByUserId: "requester", payload: { name: "Approved cap", role: "engineer", budgetMonthlyCents: 5000 } }); + await approvalService(db).approve(approval.id, "approver"); + expect(await db.select().from(budgetPolicies).where(eq(budgetPolicies.companyId, companyId))).toMatchObject([{ amount: 5000, createdByUserId: "approver" }]); + expect(await db.select().from(activityLog).where(and(eq(activityLog.companyId, companyId), eq(activityLog.action, "budget.policy_upserted")))).toMatchObject([{ actorType: "user", actorId: "approver" }]); + }); + + it("does not activate a proposed hire budget before human approval", async () => { + const agent = await createAgentLifecycle(db).requestHire(companyId, { name: "Proposed cap", status: "pending_approval", budgetMonthlyCents: 5000 }, { createdByUserId: "operator-user", initialBudgetPolicyUserId: "operator-user" }); + expect(agent.budgetMonthlyCents).toBe(5000); + expect(await db.select().from(budgetPolicies).where(eq(budgetPolicies.scopeId, agent.id))).toHaveLength(0); + }); + + it("rolls back the hire, grants, policy and audit together when its initial budget write fails", async () => { + const effects = createAgentLifecycleEffects(); + const setAgentBudget = vi.fn(async (...args: Parameters) => { + await effects.setAgentBudget(...args); + throw new Error("Initial budget failed"); + }); + const lifecycle = createLifecycleCommands(db, { ...effects, setAgentBudget }); + await expect(lifecycle.requestHire(companyId, { name: "Budget rollback", adapterType: "process", budgetMonthlyCents: 5000 }, { createdByUserId: "operator-user", initialBudgetPolicyUserId: "operator-user" })) + .rejects.toThrow("Initial budget failed"); + expect(setAgentBudget).toHaveBeenCalledWith(expect.anything(), expect.any(Array), companyId, expect.any(String), 5000, "operator-user"); + expect(await db.select().from(agents).where(eq(agents.companyId, companyId))).toHaveLength(0); + expect(await db.select().from(principalPermissionGrants).where(eq(principalPermissionGrants.companyId, companyId))).toHaveLength(0); + expect(await db.select().from(budgetPolicies).where(eq(budgetPolicies.companyId, companyId))).toHaveLength(0); + expect(await db.select().from(activityLog).where(eq(activityLog.companyId, companyId))).toHaveLength(0); + }); + it("rolls back a hire and its grants when hire initialization fails", async () => { const effects = createAgentLifecycleEffects(); const lifecycle = createLifecycleCommands(db, { ...effects, diff --git a/server/src/modules/agent-lifecycle/adapters/records.ts b/server/src/modules/agent-lifecycle/adapters/records.ts index cdc85e7297a..1cce960c54c 100644 --- a/server/src/modules/agent-lifecycle/adapters/records.ts +++ b/server/src/modules/agent-lifecycle/adapters/records.ts @@ -13,7 +13,7 @@ export function agentRecords(db: Db, effects: LifecycleEffects) { getById, create: async (companyId: string, data: CreateAgentData, options?: CreateAgentOptions) => { if (Object.keys(data).some(key => key.startsWith("lifecycle"))) throw conflict("Lifecycle fields belong to the lifecycle module"); - return effects.transaction(db, companyId, async (txDb) => { + return effects.transaction(db, companyId, async (txDb, publications) => { const tx = txDb; const setupId = options?.setupAgentId ?? (options?.setupOnly ? data.id : undefined); const [setup] = setupId ? await tx.select().from(agents).where(eq(agents.id, setupId)).for("update") : []; @@ -60,6 +60,10 @@ export function agentRecords(db: Db, effects: LifecycleEffects) { .returning() .then((rows) => rows[0]); await effects.initializeHire(txDb, created, options); + if (!options?.setupOnly && status !== "pending_approval" && status !== "terminated" + && created.budgetMonthlyCents > 0 && options?.initialBudgetPolicyUserId) { + await effects.setAgentBudget(txDb, publications, companyId, created.id, created.budgetMonthlyCents, options.initialBudgetPolicyUserId); + } if (!options?.setupOnly && created.status !== "pending_approval" && created.status !== "terminated") { await effects.recordCreation(txDb, companyId, created.id); } diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index e95253f8cbd..59fe094d2ed 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -140,7 +140,7 @@ import { import { badRequest, conflict, forbidden, HttpError, notFound, unprocessable } from "../errors.js"; import { ONBOARDING_FIRST_TASK_SKILL_KEY, PAPERCLIP_CORE_SKILL_KEYS } from "../services/company-skills.js"; import { createRunSecretRedactionRegistry } from "../services/run-secret-redaction.js"; -import { assertAuthenticated, assertBoard, assertCompanyAccess, assertInstanceAdmin, buildActorSecretContext, getAccessibleResource, getActorInfo, hasCompanyAccess } from "./authz.js"; +import { agentBudgetPage, assertAuthenticated, assertBoard, assertCompanyAccess, COMPANY_BUDGETS_PAGE, assertInstanceAdmin, buildActorSecretContext, getAccessibleResource, getActorInfo, hasCompanyAccess } from "./authz.js"; import { runAdapterLoginStartSpine } from "./adapter-login-route-spine.js"; import { isLoginCommandSupportedAdapterType } from "../services/login-command.js"; import { @@ -4315,6 +4315,9 @@ export function agentRoutes( if (!rollbackConfig) { throw unprocessable("Invalid revision snapshot"); } + if (typeof rollbackConfig.budgetMonthlyCents === "number" && rollbackConfig.budgetMonthlyCents !== existing.budgetMonthlyCents) { + assertBoard(req, agentBudgetPage(existing.id)); + } assertProviderTraceSettingTransition( req, rollbackConfig.runtimeConfig, @@ -4447,6 +4450,19 @@ export function agentRoutes( const handleAgentHire = async (req: Request, res: Response) => { const companyId = req.params.companyId as string; await assertCanCreateAgentsForCompany(req, companyId, { hiring: true }); + const company = await db + .select() + .from(companies) + .where(eq(companies.id, companyId)) + .then((rows) => rows[0] ?? null); + if (!company) { + res.status(404).json({ error: "Company not found" }); + return; + } + + // Budgets are board-only. An agent may propose one in a hire that waits for + // board approval; the approving operator then activates it. + if (!company.requireBoardApprovalForNewAgents && req.body.budgetMonthlyCents > 0) assertBoard(req, COMPANY_BUDGETS_PAGE); const sourceIssueIds = parseSourceIssueIds(req.body); const { desiredSkills: requestedDesiredSkills, @@ -4610,16 +4626,6 @@ export function agentRoutes( allowedSandboxProviders: allowedSandboxProvidersForAgent(hireInput.adapterType), }); - const company = await db - .select() - .from(companies) - .where(eq(companies.id, companyId)) - .then((rows) => rows[0] ?? null); - if (!company) { - res.status(404).json({ error: "Company not found" }); - return; - } - // Idempotency within a run: if this run already created a hire from this // exact request, return that hire instead of creating a duplicate. The // creating agent cannot pause or delete its own hire (board-only), so a @@ -4671,6 +4677,7 @@ export function agentRoutes( { ...(setupAgentId ? { setupAgentId } : {}), createdByUserId: req.actor.type === "board" ? req.actor.userId : null, + ...(req.actor.type === "board" && req.body.budgetMonthlyCents > 0 ? { initialBudgetPolicyUserId: getActorInfo(req).actorId } : {}), runnerResolved: true, responsibleUserId: responsibleUserForAiRequest(req), aiConnectionInstall: managedHireConnection ? { ...managedHireConnection, createdByUserId: responsibleUserForAiRequest(req) } : undefined, @@ -4831,6 +4838,7 @@ export function agentRoutes( router.post("/companies/:companyId/agents", validate(createAgentSchema), async (req, res) => { const companyId = req.params.companyId as string; await assertCanCreateAgentsForCompany(req, companyId, { hiring: true }); + if (req.body.budgetMonthlyCents > 0) assertBoard(req, COMPANY_BUDGETS_PAGE); const company = await db .select() @@ -4952,6 +4960,7 @@ export function agentRoutes( { ...(setupId ? { setupOnly: true } : {}), createdByUserId: req.actor.type === "board" ? req.actor.userId : null, + ...(req.actor.type === "board" && req.body.budgetMonthlyCents > 0 ? { initialBudgetPolicyUserId: getActorInfo(req).actorId } : {}), runnerResolved: true, responsibleUserId: responsibleUserForAiRequest(req), aiConnectionInstall: managedConnection ? { ...managedConnection, createdByUserId: responsibleUserForAiRequest(req) } : undefined, @@ -5014,19 +5023,6 @@ export function agentRoutes( ); await builtInAgentService(db).ensureCompanyDefaultAgentGrants(companyId); - if (agent.budgetMonthlyCents > 0) { - await budgets.upsertPolicy( - companyId, - { - scopeType: "agent", - scopeId: agent.id, - amount: agent.budgetMonthlyCents, - windowKind: "calendar_month_utc", - }, - actor.actorType === "user" ? actor.actorId : null, - ); - } - res.status(201).json({ ...redactAgentRowForResponse(agent), ...(configurationWarnings.length ? { configurationWarnings } : {}) }); }); @@ -5523,6 +5519,11 @@ export function agentRoutes( const id = req.params.id as string; const existing = await getAccessibleResource(req, res, svc.getById(id), "Agent not found"); if (!existing) return; + // A budget change is the board-only budget control (PATCH /agents/:id/budgets), + // so PATCH is never a way around it. + if (hasOwn(req.body as object, "budgetMonthlyCents") && req.body.budgetMonthlyCents !== existing.budgetMonthlyCents) { + assertBoard(req, agentBudgetPage(existing.id)); + } if (hasOwn(req.body as object, "permissions")) { res.status(422).json({ error: "Use /api/agents/:id/permissions for permission changes" }); diff --git a/server/src/routes/authz.ts b/server/src/routes/authz.ts index 444dfc7ed58..b8d4dcd5fb4 100644 --- a/server/src/routes/authz.ts +++ b/server/src/routes/authz.ts @@ -44,13 +44,22 @@ const AGENT_PROFILE_TABS: Record = { "runtime-state": ["harness settings", "runtime"], }; +/** A board page as [label, path], where the path keeps for the agent to fill. */ +export type BoardControlPage = [label: string, path: string]; + +/** Budgets are board-only. These pages hold the budget controls behind any route that writes one. */ +export const COMPANY_BUDGETS_PAGE: BoardControlPage = ["budgets", "//activity/budgets"]; +export function agentBudgetPage(agentId: string): BoardControlPage { + return [`the agent's ${AGENT_PROFILE_TABS.budgets![0]}`, `//agents/${agentId}/${AGENT_PROFILE_TABS.budgets![1]}`]; +} + // The board page that holds the control behind a board-only API route, when one // is known. Paths keep for the agent to fill from its own task identifier. const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const BOARD_CONTROL_PAGES: Array<[RegExp, (match: RegExpExecArray, req: Request) => [string, string] | null]> = [ [/^\/(?:companies\/[^/]+\/)?secrets?(?:[-/]|$)/, () => ["company secrets", "//company/settings/secrets"]], - [/^\/companies\/[^/]+\/(?:budgets|budget-incidents|costs)(?:\/|$)/, () => ["budgets", "//activity/budgets"]], + [/^\/companies\/[^/]+\/(?:budgets|budget-incidents|costs)(?:\/|$)/, () => COMPANY_BUDGETS_PAGE], [/^\/companies\/[^/]+\/(?:prompt-injection-prevention|decision-model)(?:\/|$)/, () => ["connection settings", "//company/settings/connections"]], [/^\/companies\/[^/]+\/(?:members|invites|join-requests)(?:\/|$)/, () => ["company members", "//company/settings/members"]], [/^\/companies\/[^/]+\/(?:tools\/)?gateways(?:\/|$)|^\/tool-gateway\//, () => ["tool gateways", "//apps/gateways"]], @@ -105,9 +114,9 @@ function boardControlPage(req: Request) { * A board-only denial. Agents get a final, actionable remediation so they hand the * action to the person instead of searching other routes for one that does not exist. */ -export function boardAccessRequired(req: Request) { +export function boardAccessRequired(req: Request, controlPage?: BoardControlPage) { if (req.actor.type !== "agent") return forbidden("Board access required"); - const page = boardControlPage(req); + const page = controlPage ?? boardControlPage(req); const scope = PLUGIN_ROUTE.test(deniedApiPath(req)) ? `${PLUGIN_ADMINISTRATION_SCOPE} ` : ""; const where = page ? `They can do it in the Paperclip web app: link [Open ${page[0]}](${page[1]}), with this conversation's company prefix as .` @@ -118,9 +127,13 @@ export function boardAccessRequired(req: Request) { }); } -export function assertBoard(req: Request) { +/** + * Board-only check. Pass `controlPage` when the board control lives on a different + * page from the one the route path implies, e.g. a budget field on a general route. + */ +export function assertBoard(req: Request, controlPage?: BoardControlPage) { if (req.actor.type !== "board") { - throw boardAccessRequired(req); + throw boardAccessRequired(req, controlPage); } } diff --git a/server/src/routes/built-in-agents.ts b/server/src/routes/built-in-agents.ts index c881ae9c93f..ee1529197bf 100644 --- a/server/src/routes/built-in-agents.ts +++ b/server/src/routes/built-in-agents.ts @@ -6,7 +6,7 @@ import { forbidden, notFound } from "../errors.js"; import { accessService, instanceSettingsService, logActivity } from "../services/index.js"; import { builtInAgentService } from "../services/built-in-agents.js"; import { authorizationDeniedDetails } from "../services/authorization.js"; -import { assertCompanyAccess, getActorInfo } from "./authz.js"; +import { assertBoard, assertCompanyAccess, COMPANY_BUDGETS_PAGE, getActorInfo } from "./authz.js"; import type { BuiltInAgentState } from "../services/built-in-agents.js"; const WEEKDAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; @@ -186,6 +186,7 @@ export function builtInAgentRoutes(db: Db) { await assertBuiltInAgentsEnabled(); await assertCanProvisionBuiltInAgents(req, companyId); const actor = getActorInfo(req); + if (req.body.budgetMonthlyCents !== undefined) assertBoard(req, COMPANY_BUDGETS_PAGE); const result = await svc.provision(companyId, key, req.body, { requestedByAgentId: actor.actorType === "agent" ? actor.actorId : null, requestedByUserId: actor.actorType === "user" ? actor.actorId : null, diff --git a/server/src/services/agent-configuration.ts b/server/src/services/agent-configuration.ts index 3dfc40e4048..0a2a0185bd4 100644 --- a/server/src/services/agent-configuration.ts +++ b/server/src/services/agent-configuration.ts @@ -11,7 +11,7 @@ import { and, eq } from "drizzle-orm"; import { assets, agentConfigRevisions, agentTaskSessions, agentRuntimeState, type Db } from "@paperclipai/db"; import { normalizeAgentUrlKey } from "@paperclipai/shared"; import { normalizePaperclipRunnerAdapterConfig } from "@paperclipai/adapter-utils/server-utils"; -import { conflict, notFound, unprocessable } from "../errors.js"; +import { conflict, forbidden, notFound, unprocessable } from "../errors.js"; import { agentRecordQueries, type UpdateAgentOptions, type AgentConfigurationPatch, type AgentConfigurationRecord, isPlainRecord, buildConfigSnapshot, hasConfigPatchFields, changedPendingApprovalConfigFields, diffConfigSnapshot, jsonEqual } from "../lib/agent-records.js"; import { CEO_HIRING_DENIAL_KEY, ceoHiringDenial, normalizeAgentPermissions } from "../lib/agent-permissions.js"; @@ -22,6 +22,9 @@ import { clearPrimaryAgent } from "./primary-agent.js"; import { budgetServiceInTransaction } from "./budgets.js"; export async function prepareAgentConfiguration(db: Db, existing: AgentConfigurationRecord, data: AgentConfigurationPatch, options?: UpdateAgentOptions): Promise { + if (options?.recordRevision?.createdByAgentId && data.budgetMonthlyCents !== undefined && data.budgetMonthlyCents !== existing.budgetMonthlyCents) { + throw forbidden("Only board operators can change agent budgets"); + } await notifyAttentionRetentionWork(db, { companyId: existing.companyId }); const id = existing.id; const { ensureManager, assertNoCycle, assertCompanyShortnameAvailable, assertBuiltInAgentMetadataMutationAllowed } = agentRecordQueries(db); @@ -65,6 +68,7 @@ export async function prepareAgentConfiguration(db: Db, existing: AgentConfigura } } const normalizedPatch = { ...data } as AgentConfigurationPatch; + if (options?.recordRevision?.createdByAgentId) delete normalizedPatch.budgetMonthlyCents; if (data.permissions !== undefined) { // Only the permissions control records a CEO hiring restriction. Keep an // existing one unless this write turns hiring on, and never take one from input. diff --git a/server/src/services/built-in-agents.ts b/server/src/services/built-in-agents.ts index ecd6a1dd111..fac7ac6faf5 100644 --- a/server/src/services/built-in-agents.ts +++ b/server/src/services/built-in-agents.ts @@ -1732,7 +1732,7 @@ export function builtInAgentService(db: Db) { companyId: string, key: string, input: BuiltInAgentProvisionInput = {}, - options: { isRaceRetry?: boolean; createdByUserId?: string | null } = {}, + options: { isRaceRetry?: boolean; createdByUserId?: string | null; createdByAgentId?: string | null } = {}, ) { const definition = requireBuiltInAgentDefinition(key); await ensureCompany(companyId); @@ -1800,7 +1800,7 @@ export function builtInAgentService(db: Db) { } const updated = await agentSvc.update(existing.id, patch, { allowBuiltInAgentMetadata: true, - recordRevision: { source: "built-in-agent:ensure" }, + recordRevision: { source: "built-in-agent:ensure", createdByUserId: options.createdByUserId, createdByAgentId: options.createdByAgentId }, }); if (!updated) throw notFound("Built-in agent not found"); if (existingPendingApproval) { @@ -1835,13 +1835,14 @@ export function builtInAgentService(db: Db) { permissions: definition.defaultPermissions ?? {}, spentMonthlyCents: 0, lastHeartbeatAt: null, - }, { allowBuiltInAgentMetadata: true, createdByUserId: options.createdByUserId ?? null }) as Agent; + }, { allowBuiltInAgentMetadata: true, createdByUserId: options.createdByUserId ?? null, + ...(options.createdByUserId && resolvedInput.budgetMonthlyCents ? { initialBudgetPolicyUserId: options.createdByUserId } : {}) }) as Agent; } catch (error) { // Lost the provisioning race: a concurrent writer inserted the row first // and the partial unique index rejected ours. Re-run once; the winning // row now exists, so we take the update path instead of inserting again. if (!options.isRaceRetry && isBuiltInAgentMarkerConflict(error)) { - return ensure(companyId, key, input, { isRaceRetry: true, createdByUserId: options.createdByUserId }); + return ensure(companyId, key, input, { isRaceRetry: true, createdByUserId: options.createdByUserId, createdByAgentId: options.createdByAgentId }); } throw error; } @@ -1874,7 +1875,7 @@ export function builtInAgentService(db: Db) { const definition = requireBuiltInAgentDefinition(key); const company = await ensureCompany(companyId); if (!company.requireBoardApprovalForNewAgents) { - return { state: await ensure(companyId, key, input, { createdByUserId: actor.requestedByUserId ?? null }), approval: null }; + return { state: await ensure(companyId, key, input, { createdByUserId: actor.requestedByUserId ?? null, createdByAgentId: actor.requestedByAgentId ?? null }), approval: null }; } await assertKnownBuiltInAgentModel(definition, input); @@ -1906,7 +1907,7 @@ export function builtInAgentService(db: Db) { // it does when board approval is not required, instead of dead-ending on a // fresh board-approval requirement the operator can never satisfy. if (providesAdapterSetup && (existing.metadata?.paperclipBuiltInSetupRequired === true || !hasCompleteAdapterConfig(existing.adapterType, existing.adapterConfig))) { - return { state: await ensure(companyId, key, input), approval: null }; + return { state: await ensure(companyId, key, input, { createdByUserId: actor.requestedByUserId ?? null, createdByAgentId: actor.requestedByAgentId ?? null }), approval: null }; } // Changing the adapter of an already-configured (`ready`/`paused`) From 088bdf7cfa78eda23bdfb4c25dbae90e1861c0dc Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 9 Nov 2026 03:00:14 -0600 Subject: [PATCH 14/21] fix(daytona): wait out a sandbox state change before starting it for workspace realize (#20212) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - On Daytona, every run realizes its workspace in the leased sandbox. Realizing it first makes sure the sandbox is started > - Right after a control-plane restart, the sandbox can still be mid-transition, for example stopping or starting from the previous lifecycle step > - The plugin called `sandbox.start` straight away. Daytona rejects that with "Sandbox state change in progress", so the run failed `setup_failed` and needed a Retry > - This pull request waits out a sandbox's state change before starting it, and retries a start the provider rejected for that reason, within the existing start deadline > - The benefit is that a run whose sandbox is briefly transitioning starts on its own ## Linked Issues or Issue Description Refs #20211 **What happened?** Daytona per-turn restart-chaos K13 storm w-20261109T074128-s9503 (revision 9046884811), t07 (claude A1): run `5aabeb55` failed at 07:45:27 with `setup_failed` and "Failed to realize workspace for environment … (sandbox): Sandbox state change in progress". The sandbox was mid-transition after the restart, and realize neither waited nor retried. A Retry succeeded. **Expected behavior** Realize waits for a transitional sandbox state to settle, then starts the sandbox. If the provider still reports the state change, it retries, all within the existing start deadline. Any other start error fails at once, and a transition that outlasts the deadline still fails. **Steps to reproduce** 1. Realize a workspace while the Daytona sandbox is `stopping` or `starting`, or while `start` returns "Sandbox state change in progress". 2. Before this change, realize fails at once with that error. **Paperclip version or commit** 9046884811 (wave). Branch rebased on current master. ## What Changed - `packages/plugins/sandbox-providers/daytona/src/plugin.ts`: `ensureSandboxStarted` now polls `refreshData` while the sandbox is in a transitional state. - The transitional states are `starting`, `stopping`, `restoring`, `archiving`, `creating`, `pending_build`, `pulling_snapshot`, `building_snapshot` and `resizing`. - It polls every second, until the start deadline from `timeoutMs`. Each read keeps the existing liveness bound. - If the start fails with "state change in progress", it refreshes and tries again, within the same deadline. - The wait, every retry and the start share one deadline. Each read is bounded by the time left, and the start receives only the remaining seconds. - As soon as any state other than `started` is observed, the lease's stored session is dropped before waiting, so a restart that happened outside the plugin never reuses a dead session. - Starting a settled sandbox (`startSettledSandbox`) is unchanged. - `plugin.test.ts`: - realize waits for a stopping sandbox, then starts it; - realize retries a start that was rejected for a state change; - realize gives up once the transition outlasts the deadline; - another start error fails at once; - the start receives only the budget the wait left; - a restart outside the plugin that the wait sees through opens a fresh session. - The tests stub the API key with `vi.stubEnv`. ## Verification - Red/green: on master, the wait and the retry cases fail with "Sandbox state change in progress". With the change they pass, and the deadline and other-error cases hold. - `vitest run` in `packages/plugins/sandbox-providers/daytona`: 14 files, 614 tests passed (8 skipped). - `tsc --noEmit` for the Daytona plugin package: passed. ## Risks - Low risk. The wait is bounded by the start deadline the plugin already used. Only transitional states and the provider's state-change rejection are waited out, and every other error behaves as before. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), via Claude Code with tool use (shell, file edits, test execution). ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- .../daytona/src/plugin.test.ts | 81 +++++++++++++++++++ .../sandbox-providers/daytona/src/plugin.ts | 40 +++++++++ 2 files changed, 121 insertions(+) diff --git a/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts b/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts index bfa76665615..c1bf861a4d8 100644 --- a/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts +++ b/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts @@ -278,6 +278,67 @@ describe("Daytona idle eligibility", () => { expect(await plugin.definition.onIdleDrain!(signal)).toBe("none"); }); + describe("a sandbox in a state change during workspace realize", () => { + beforeEach(() => { vi.stubEnv("DAYTONA_API_KEY", "host-key"); }); + const realize = (timeoutMs = 300_000) => plugin.definition.onEnvironmentRealizeWorkspace!({ + driverKey: "daytona", companyId: "company-1", environmentId: "env-1", + config: { timeoutMs, reuseLease: false }, + lease: { providerLeaseId: "sandbox-transition", metadata: { remoteCwd: "/home/daytona/paperclip-workspace" } }, + workspace: { localPath: "/tmp/workspace" }, + } as never); + const stateChangeInProgress = () => Object.assign(new Error("Sandbox state change in progress"), { statusCode: 409 }); + + it("waits for a stopping sandbox to settle, then starts it", async () => { + const sandbox = createMockSandbox({ id: "sandbox-transition", state: "stopping" }); + mockGet.mockResolvedValue(sandbox); + sandbox.refreshData.mockImplementation(async () => { sandbox.state = "stopped"; }); + sandbox.start.mockImplementation(async () => { + if (sandbox.state !== "stopped") throw stateChangeInProgress(); + sandbox.state = "started"; + }); + await expect(realize()).resolves.toMatchObject({ cwd: "/home/daytona/paperclip-workspace" }); + expect(sandbox.start).toHaveBeenCalledTimes(1); + expect(sandbox.fs.createFolder).toHaveBeenCalledWith("/home/daytona/paperclip-workspace", "755"); + }); + + it("retries a start the provider rejected while its state was changing", async () => { + const sandbox = createMockSandbox({ id: "sandbox-transition", state: "stopped" }); + mockGet.mockResolvedValue(sandbox); + sandbox.start.mockRejectedValueOnce(stateChangeInProgress()) + .mockImplementationOnce(async () => { sandbox.state = "started"; }); + await expect(realize()).resolves.toMatchObject({ cwd: "/home/daytona/paperclip-workspace" }); + expect(sandbox.start).toHaveBeenCalledTimes(2); + }); + + it("gives up when the state change outlasts the start deadline", async () => { + const sandbox = createMockSandbox({ id: "sandbox-transition", state: "stopping" }); + mockGet.mockResolvedValue(sandbox); + sandbox.start.mockRejectedValue(stateChangeInProgress()); + await expect(realize(1_000)).rejects.toThrow("is still stopping at its start deadline"); + expect(sandbox.start).not.toHaveBeenCalled(); + expect(sandbox.fs.createFolder).not.toHaveBeenCalled(); + }); + + it("gives the start only the budget the wait left", async () => { + const sandbox = createMockSandbox({ id: "sandbox-transition", state: "stopping" }); + mockGet.mockResolvedValue(sandbox); + let reads = 0; + sandbox.refreshData.mockImplementation(async () => { if (++reads === 2) sandbox.state = "stopped"; }); + sandbox.start.mockImplementation(async () => { sandbox.state = "started"; }); + await expect(realize(4_000)).resolves.toMatchObject({ cwd: "/home/daytona/paperclip-workspace" }); + expect(reads).toBe(2); + expect(sandbox.start.mock.calls[0]![0]).toBeLessThanOrEqual(2); + }); + + it("fails other start errors at once", async () => { + const sandbox = createMockSandbox({ id: "sandbox-transition", state: "stopped" }); + mockGet.mockResolvedValue(sandbox); + sandbox.start.mockRejectedValue(new Error("quota exceeded")); + await expect(realize()).rejects.toThrow("quota exceeded"); + expect(sandbox.start).toHaveBeenCalledTimes(1); + }); + }); + it("keeps a stale stopped reply from clearing a start that finished during the read", async () => { const sandbox = createMockSandbox({ state: "stopped" }); mockGet.mockResolvedValue(sandbox); @@ -3389,6 +3450,26 @@ describe("Daytona sandbox provider plugin", () => { expect(lastExec[0]).toBe(freshSessionId); }); + it("opens a fresh session after a restart outside the plugin that realize waited out", async () => { + vi.stubEnv("DAYTONA_API_KEY", "host-key"); + const sandbox = autoStoppedSandbox(); + mockGet.mockResolvedValue(sandbox); + await plugin.definition.onEnvironmentExecute?.(sessionExecParams()); + const staleSessionId = sandbox.process.createSession.mock.calls[0]![0] as string; + sandbox.state = "starting"; + sandbox.refreshData.mockImplementationOnce(async () => { sandbox.state = "started"; }); + await plugin.definition.onEnvironmentRealizeWorkspace?.({ + driverKey: "daytona", companyId: "company-1", environmentId: "env-1", + config: { timeoutMs: 300000, reuseLease: false }, + lease: { providerLeaseId: "sandbox-123", metadata: { remoteCwd: "/home/daytona/paperclip-workspace" } }, + workspace: { localPath: "/tmp/workspace" }, + } as never); + expect(sandbox.start).not.toHaveBeenCalled(); + await plugin.definition.onEnvironmentExecute?.(sessionExecParams()); + expect(sandbox.process.createSession).toHaveBeenCalledTimes(2); + expect(sandbox.process.createSession.mock.calls[1]![0]).not.toBe(staleSessionId); + }); + it("opens a fresh session after sync-in restarts an auto-stopped sandbox", async () => { process.env.DAYTONA_API_KEY = "host-key"; const sandbox = autoStoppedSandbox(); diff --git a/packages/plugins/sandbox-providers/daytona/src/plugin.ts b/packages/plugins/sandbox-providers/daytona/src/plugin.ts index efd2f96292a..15839c36ad0 100644 --- a/packages/plugins/sandbox-providers/daytona/src/plugin.ts +++ b/packages/plugins/sandbox-providers/daytona/src/plugin.ts @@ -637,7 +637,47 @@ async function terminateAtProvider(scope: SandboxScope, operation: string, ac // restart, so the next command opens a fresh session instead of sending a dead // id and failing with "session not found". A running sandbox keeps its live // session, so the id is left in place when no restart happens. +const SANDBOX_TRANSITION_STATES: ReadonlySet = new Set([ + "starting", "stopping", "restoring", "archiving", "creating", "pending_build", "pulling_snapshot", "building_snapshot", "resizing", +]); +const SANDBOX_TRANSITION_POLL_MS = 1_000; + +function isSandboxStateChangeInProgress(error: unknown): boolean { + return /state change in progress/i.test(error instanceof Error ? error.message : String(error)); +} + +async function waitOutSandboxTransition(sandbox: Sandbox, deadline: number, readTimeoutMs: number): Promise { + while (SANDBOX_TRANSITION_STATES.has(sandbox.state ?? "") && Date.now() + SANDBOX_TRANSITION_POLL_MS < deadline) { + await new Promise(resolve => setTimeout(resolve, SANDBOX_TRANSITION_POLL_MS)); + await withLivenessTimeout("sandbox.refreshData", Math.min(readTimeoutMs, Math.max(1, deadline - Date.now())), + () => sandbox.refreshData()); + } +} + async function ensureSandboxStarted(sandbox: Sandbox, timeoutSeconds: number, scope: SandboxScope): Promise { + if (sandbox.state === "started") return; + sandboxHandleSessionStore.clear(scope); + const deadline = Date.now() + timeoutSeconds * 1_000; + const readTimeoutMs = Math.min(scope.config.livenessTimeoutMs, 30_000); + for (;;) { + await waitOutSandboxTransition(sandbox, deadline, readTimeoutMs); + if (SANDBOX_TRANSITION_STATES.has(sandbox.state ?? "") || Date.now() >= deadline) { + throw new Error(`Daytona sandbox ${sandbox.id} is still ${sandbox.state ?? "changing state"} at its start deadline`); + } + const remainingSeconds = Math.max(1, Math.ceil((deadline - Date.now()) / 1_000)); + try { + await startSettledSandbox(sandbox, remainingSeconds, scope); + return; + } catch (error) { + if (!isSandboxStateChangeInProgress(error) || Date.now() + SANDBOX_TRANSITION_POLL_MS >= deadline) throw error; + await new Promise(resolve => setTimeout(resolve, SANDBOX_TRANSITION_POLL_MS)); + await withLivenessTimeout("sandbox.refreshData", Math.min(readTimeoutMs, Math.max(1, deadline - Date.now())), + () => sandbox.refreshData()); + } + } +} + +async function startSettledSandbox(sandbox: Sandbox, timeoutSeconds: number, scope: SandboxScope): Promise { if (sandbox.state === "started") return; sandboxHandleSessionStore.clear(scope); // Bound the lifecycle call just past its own SDK deadline. A normal slow start From 98e0f1fc0d972113cb14e299d54cd8ac6c2e7780 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 9 Nov 2026 03:03:15 -0600 Subject: [PATCH 15/21] fix(native-runtime): a stopped run cannot close a task another run continues; deliver a pre-restart comment at once (#20216) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - When the board stops a run and then posts a comment asking the agent to continue, that comment starts a new run, and only that new run should decide when the task is done > - In restart chaos wave `w-20261109T081456-s9901`, t16 (claude S1, K1) closed the task as done without the line the user asked for, and t10 (codex S1, K6) never woke a run for the continue comment, so it needed a Retry > - In t16, the stopped run's saved "done" result was accepted 10 s after the continue run started. In t10, a restart killed the server 21 ms after the comment committed, before its wake was written, and the comment backstop then waited out its 30 s grace > - This pull request stops a run from closing a task that another run now holds, and makes the backstop deliver at once a comment that predates the server process > - The benefit is that a user's latest direction after a Stop is neither overridden by the stopped run nor lost to a restart ## Linked Issues or Issue Description Refs #19831. Restart chaos wave `w-20261109T081456-s9901` (master `09e24dd12c`), tasks t16 and t10. **t16, from the kept database (CHA-20):** - 08:29:19: the board stopped run 1 (`6e33d123`). Its saved result reported done, but finalization failed on the stop (`finalization_failed_claim_preserved`, a retryable failure). - 08:29:41: the user's continue comment started run 2 (`2cb71c5b`). Run 2 held the task's execution and saw the comment. - 08:29:51: the finalization reconciler retried run 1's saved result and committed `completion_claim_policy_accepted` for run 1. The task closed as done. - From then on, every Paperclip tool call from run 2 was refused (`paperclip_runner_tool_binding_not_authorized`), so it could not add the `CONTINUE-…` line. Its result (blocked) was then preserved as `terminal_status_preserved`. - Run 2 ending `succeeded` with `native_session_lease_lost` follows from this: its result was accepted before the lease was lost. The comment was delivered; the model saw it. **t10 (CHA-14):** - The Stop moved the task to blocked. The user's comment reopened it to todo at 08:26:28.633, and K6 killed the server at 08:26:28.654, before the route wrote the comment's wake. No wake request exists for that comment. - This is not caused by the Stop, #20066 or #20180: the wake is written after the comment commits, and the kill landed in between. - The I4 backstop ("a user comment without a wake") matched the comment, but treated it as possibly still getting its normal wake for 30 s. The pass interval then pushed the check to 02:27:16, and the harness clicked Retry at 02:26:59. ## What Changed - `status-arbiter.ts`: - New `heldByAnotherRun(issue, runId)`. - `supersedeStaleOwnerCompletion` also keeps status, with reason `completion_run_superseded`, when the issue's `executionRunId` is a different run. A run that was reassigned away still gets `completion_owner_superseded`. - `native-run-finalizer.ts` and `native-status-reassessment.ts` pass the run id to that guard. - `status-decision-committer.ts` rechecks the same rule under the issue lock: a done decision for a non-conversation issue held by another run raises `NativeStatusRaceError`. The finalizer then re-arbitrates and keeps status. - `ownership-invariant-report.ts`: the comment-without-wake grace now only applies to comments created after this server process started (`getServerProcessStartedAt`). Separate live server replicas are not supported, and the wake is written by the process that accepted the comment, so an older comment is owed at once. The startup pass now delivers it. `runOwnershipReconciliationPass` takes an optional `processStartedAt`. ## Verification Red/green: - `native-stale-owner-completion.test.ts`: three new tests. - The unit policy: another run holding the task keeps status; the same run, or none, still closes it. - "does not let a stopped run's saved done result close the task a newer run is continuing" (the t16 shape). - "does not close the task when a newer run takes it after arbitration but before the status commit" (only the committer recheck covers this). - On master: 3 failed. With this PR: 13 passed. - `ownership-reconciliation-report.test.ts`: "finds at once a young user comment whose wake a restart lost after the board stopped the run" (the t10 shape). It fails on master and passes with this PR; the same comment in a process that accepted it still waits for its grace deadline. The file: 27 passed. Regression and static checks: - 16 nearby native finalization, status projection, workspace finalizer and arbiter suites: 439 passed. - `tsc --noEmit` passes for `server`, and for the two test files. ## Risks - A done result now waits when a different run holds the task. That run reaches its own decision; the earlier result stays as history. If the holder is stale, the existing execution-lock recovery clears it, and a later reassessment can then close the task. - After a restart, a user comment newer than the task's last run, with no wake, is delivered at the startup pass rather than 30 s later. A replica that is still live and writing the same wake is not a supported topology. - No schema change and no change to persisted run state. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, via Claude Code with tool use (shell, file edits). ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- .../ownership-reconciliation-report.test.ts | 23 +++++- .../native-runtime/native-run-finalizer.ts | 2 +- .../native-stale-owner-completion.test.ts | 70 +++++++++++++++++++ .../native-status-reassessment.ts | 2 +- .../services/native-runtime/status-arbiter.ts | 14 +++- .../status-decision-committer.ts | 4 ++ .../recovery/ownership-invariant-report.ts | 13 ++-- 7 files changed, 117 insertions(+), 11 deletions(-) diff --git a/server/src/__tests__/ownership-reconciliation-report.test.ts b/server/src/__tests__/ownership-reconciliation-report.test.ts index 309f0ab683f..f6ce47f45d5 100644 --- a/server/src/__tests__/ownership-reconciliation-report.test.ts +++ b/server/src/__tests__/ownership-reconciliation-report.test.ts @@ -457,17 +457,36 @@ describe("report-only ownership reconciliation", () => { body: "self", createdAt: minutesAgo(2) }); await db.insert(agentWakeupRequests).values({ companyId, agentId, source: "automation", status: "queued", payload: { issueId, commentId: carried.id } }); - const report = await runOwnershipReconciliationPass(db, { state: createOwnershipReportState(), record: false }); + const report = await runOwnershipReconciliationPass(db, { state: createOwnershipReportState(), record: false, + processStartedAt: minutesAgo(10) }); expect(report.violations.filter(row => row.reason === "user_comment_without_wake").map(row => row.recordId)).toEqual([lost.id]); // The young comment may still get its normal wake: a deadline, not a report. expect(report.nextDueAt?.getTime()).toBe(young.createdAt.getTime() + 30_000); // A run that started after the comment read it. await db.insert(heartbeatRuns).values({ companyId, agentId, status: "succeeded", invocationSource: "manual", contextSnapshot: { issueId }, startedAt: minutesAgo(1), createdAt: minutesAgo(1), finishedAt: new Date() }); - const later = await runOwnershipReconciliationPass(db, { state: createOwnershipReportState(), record: false }); + const later = await runOwnershipReconciliationPass(db, { state: createOwnershipReportState(), record: false, + processStartedAt: minutesAgo(10) }); expect(later.violations.filter(row => row.reason === "user_comment_without_wake")).toEqual([]); }); + it("finds at once a young user comment whose wake a restart lost after the board stopped the run", async () => { + const issueId = randomUUID(); + await db.insert(issues).values({ id: issueId, companyId, title: "Stopped then continued", status: "todo", assigneeAgentId: agentId }); + await db.insert(heartbeatRuns).values({ companyId, agentId, status: "cancelled", invocationSource: "assignment", + runtimeMode: "native", contextSnapshot: { issueId }, startedAt: minutesAgo(4), createdAt: minutesAgo(4), finishedAt: minutesAgo(1) }); + const [comment] = await db.insert(issueComments).values({ companyId, issueId, authorUserId: "user-1", + body: "I stopped your previous run on purpose. Please continue.", createdAt: new Date(Date.now() - 12_000) }).returning(); + const report = await runOwnershipReconciliationPass(db, { state: createOwnershipReportState(), record: false, + processStartedAt: new Date(Date.now() - 2_000) }); + expect(report.violations.filter(row => row.reason === "user_comment_without_wake").map(row => row.recordId)) + .toEqual([comment!.id]); + const sameProcess = await runOwnershipReconciliationPass(db, { state: createOwnershipReportState(), record: false, + processStartedAt: minutesAgo(10) }); + expect(sameProcess.violations.filter(row => row.reason === "user_comment_without_wake")).toEqual([]); + expect(sameProcess.nextDueAt?.getTime()).toBe(comment!.createdAt.getTime() + 30_000); + }); + it("finds a user comment that a restart left without a wake on a task its agent then closed", async () => { const closedTask = async (actorType: "agent" | "user") => { const issueId = randomUUID(); diff --git a/server/src/services/native-runtime/native-run-finalizer.ts b/server/src/services/native-runtime/native-run-finalizer.ts index 069a415d072..52d3aa178fc 100644 --- a/server/src/services/native-runtime/native-run-finalizer.ts +++ b/server/src/services/native-runtime/native-run-finalizer.ts @@ -1604,7 +1604,7 @@ export async function finalizeNativeRun(input: { // A reassigned-away run keeps its accepted result, but cannot close the // new owner's task. The status committer rechecks this under the issue lock. if (!isConversation(authoritativeIssue)) { - proposedDecision = supersedeStaleOwnerCompletion(proposedDecision, authoritativeIssue, run.agentId); + proposedDecision = supersedeStaleOwnerCompletion(proposedDecision, authoritativeIssue, run.agentId, run.id); } if (proposedDecision.statusAction === "done") { const continuation = record(run.contextSnapshot?.executionContinuation); diff --git a/server/src/services/native-runtime/native-stale-owner-completion.test.ts b/server/src/services/native-runtime/native-stale-owner-completion.test.ts index 3541caa00b3..3c1bb94b1a7 100644 --- a/server/src/services/native-runtime/native-stale-owner-completion.test.ts +++ b/server/src/services/native-runtime/native-stale-owner-completion.test.ts @@ -27,6 +27,12 @@ describe("stale owner completion policy", () => { expect(supersedeStaleOwnerCompletion(done, issue, "agent-a")).toEqual({ ...done, statusAction: "preserve", toStatus: "in_progress", reasonCode: "completion_owner_superseded", effects: [] }); }); + it("preserves status when another run of the same owner now holds the task", () => { + expect(supersedeStaleOwnerCompletion(done, { ...owner, executionRunId: "run-2" }, "agent-a", "run-1")).toEqual({ ...done, + statusAction: "preserve", toStatus: "in_progress", reasonCode: "completion_run_superseded", effects: [] }); + expect(supersedeStaleOwnerCompletion(done, { ...owner, executionRunId: "run-1" }, "agent-a", "run-1")).toBe(done); + expect(supersedeStaleOwnerCompletion(done, { ...owner, executionRunId: null }, "agent-a", "run-1")).toBe(done); + }); it("leaves non-terminal decisions to their existing owner checks", () => { const blocked: NativeStatusDecision = { ...done, statusAction: "blocked", toStatus: "blocked", reasonCode: "x" }; expect(supersedeStaleOwnerCompletion(blocked, { ...owner, assigneeAgentId: "agent-b" }, "agent-a")).toBe(blocked); @@ -92,6 +98,46 @@ describe.skipIf(!support.supported)("native completion after reassignment", () = .where(eq(heartbeatRuns.id, f.runId)); } + /** The board's Stop, then the user's continue comment starts a new run that holds the task. */ + async function stopAndContinue(f: Awaited>) { + await db.update(heartbeatRuns).set({ status: "cancelled", errorCode: "cancelled", finishedAt: new Date() }) + .where(eq(heartbeatRuns.id, f.runId)); + await db.update(issues).set({ executionRunId: null, checkoutRunId: null }).where(eq(issues.id, f.issueId)); + const continuationRunId = randomUUID(); + await db.insert(heartbeatRuns).values({ id: continuationRunId, companyId: f.companyId, agentId: f.agentId, + nativeIssueId: f.issueId, status: "running", runtimeMode: "native", + contextSnapshot: { issueId: f.issueId, source: "issue.comment" } }); + await db.update(issues).set({ executionRunId: continuationRunId, checkoutRunId: continuationRunId }) + .where(eq(issues.id, f.issueId)); + return continuationRunId; + } + + function actBeforeStatusCommit(act: () => Promise) { + let acted = false; + const racing = new Proxy(db, { + get(target, property, receiver) { + const value = Reflect.get(target, property, receiver); + if (property !== "update") return typeof value === "function" ? value.bind(target) : value; + return (table: unknown) => { + const builder = target.update(table as typeof nativeRunFinalizations); + if (table !== nativeRunFinalizations || acted) return builder; + const set = builder.set.bind(builder); + return Object.assign(builder, { set: (values: Record) => { + const next = set(values as never); + if (values.phase !== "arbitrating" || !("assessmentId" in values)) return next; + const where = next.where.bind(next); + return Object.assign(next, { where: (...args: Parameters) => { + const query = where(...args); + return { then: (resolve: (value: unknown) => unknown, reject: (error: unknown) => unknown) => + query.then(async (result) => { acted = true; await act(); return result; }).then(resolve, reject) }; + } }); + } }); + }; + }, + }) as Db; + return { racing, acted: () => acted }; + } + const issue = async (issueId: string) => (await db.select().from(issues).where(eq(issues.id, issueId)))[0]; const decision = async (runId: string) => { const [coordinator] = await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, runId)); @@ -154,6 +200,30 @@ describe.skipIf(!support.supported)("native completion after reassignment", () = expect(await issue(f.issueId)).toMatchObject({ status: "in_progress", assigneeAgentId: f.successorId }); expect((await decision(f.runId)).row).toMatchObject({ reasonCode: "completion_owner_superseded" }); }); + it("does not let a stopped run's saved done result close the task a newer run is continuing", async () => { + const f = await acceptedDoneResult(); + const continuationRunId = await stopAndContinue(f); + + await finalizeNativeRun({ db, runId: f.runId, workspaceFinalizeStatus: "succeeded", projectRunStatus: false }); + + expect(await issue(f.issueId)).toMatchObject({ status: "in_progress", executionRunId: continuationRunId }); + const { coordinator, row } = await decision(f.runId); + expect(coordinator.phase).toBe("committed"); + expect(row).toMatchObject({ toStatus: "in_progress", reasonCode: "completion_run_superseded" }); + }); + + it("does not close the task when a newer run takes it after arbitration but before the status commit", async () => { + const f = await acceptedDoneResult(); + let continuationRunId: string | null = null; + const { racing, acted } = actBeforeStatusCommit(async () => { continuationRunId = await stopAndContinue(f); }); + + await finalizeNativeRun({ db: racing, runId: f.runId, workspaceFinalizeStatus: "succeeded", projectRunStatus: false }); + + expect(acted()).toBe(true); + expect(await issue(f.issueId)).toMatchObject({ status: "in_progress", executionRunId: continuationRunId }); + expect((await decision(f.runId)).row).toMatchObject({ reasonCode: "completion_run_superseded" }); + }); + async function approveEvidenceLater(f: Awaited>) { await db.update(issueWorkProducts).set({ reviewState: "approved", updatedAt: new Date(Date.now() + 1_000) }) .where(eq(issueWorkProducts.id, f.workProductId)); diff --git a/server/src/services/native-runtime/native-status-reassessment.ts b/server/src/services/native-runtime/native-status-reassessment.ts index 1bc389852a5..225d979dc35 100644 --- a/server/src/services/native-runtime/native-status-reassessment.ts +++ b/server/src/services/native-runtime/native-status-reassessment.ts @@ -236,7 +236,7 @@ export async function reassessNativeStatusFromSource( } // New evidence from a reassigned-away run cannot close the new owner's task. if (currentIssue && !currentIssue.conversationAgentId) { - decision = supersedeStaleOwnerCompletion(decision, { ...currentIssue, status: row.issueStatus }, row.agentId); + decision = supersedeStaleOwnerCompletion(decision, { ...currentIssue, status: row.issueStatus }, row.agentId, row.runId); } let committed: Awaited>; try { diff --git a/server/src/services/native-runtime/status-arbiter.ts b/server/src/services/native-runtime/status-arbiter.ts index 97eaf9079b0..ba5bf640d92 100644 --- a/server/src/services/native-runtime/status-arbiter.ts +++ b/server/src/services/native-runtime/status-arbiter.ts @@ -103,17 +103,25 @@ export function ownsNativeTaskCompletion( return issue.assigneeAgentId === agentId && !issue.assigneeUserId; } +export function heldByAnotherRun(issue: { executionRunId?: string | null }, runId: string) { + return Boolean(issue.executionRunId && issue.executionRunId !== runId); +} + export function supersedeStaleOwnerCompletion( decision: NativeStatusDecision, - issue: { status: string; assigneeAgentId: string | null; assigneeUserId: string | null }, + issue: { status: string; assigneeAgentId: string | null; assigneeUserId: string | null; + executionRunId?: string | null }, agentId: string, + runId?: string, ): NativeStatusDecision { - if (decision.statusAction !== "done" || ownsNativeTaskCompletion(issue, agentId)) return decision; + if (decision.statusAction !== "done") return decision; + const ownerSuperseded = !ownsNativeTaskCompletion(issue, agentId); + if (!ownerSuperseded && !(runId && heldByAnotherRun(issue, runId))) return decision; return { policyVersion: NATIVE_STATUS_ARBITER_POLICY_VERSION, statusAction: "preserve", toStatus: issue.status as NativeAuthoritativeIssueStatus, - reasonCode: "completion_owner_superseded", + reasonCode: ownerSuperseded ? "completion_owner_superseded" : "completion_run_superseded", unblockDescriptor: null, effects: [], }; diff --git a/server/src/services/native-runtime/status-decision-committer.ts b/server/src/services/native-runtime/status-decision-committer.ts index b192e3f670c..29089f8ddf4 100644 --- a/server/src/services/native-runtime/status-decision-committer.ts +++ b/server/src/services/native-runtime/status-decision-committer.ts @@ -46,6 +46,7 @@ import { workspaceOperations, } from "@paperclipai/db"; import { + heldByAnotherRun, NATIVE_STATUS_ARBITER_POLICY_VERSION, type NativeGovernanceGate, type NativeStatusDecision, @@ -1793,6 +1794,9 @@ export async function commitNativeStatusDecision(input: { if (input.decision.statusAction === "done" && !await onboardingFirstTaskCompletionAllowed(tx as unknown as Db, { companyId: input.companyId, issueId: input.issueId, runId: input.runId, })) throw new NativeStatusRaceError(); + if (input.decision.statusAction === "done" && !isConversation(issue) && heldByAnotherRun(issue, input.runId)) { + throw new NativeStatusRaceError(); + } if (input.decision.statusAction === "done") { try { const [saved] = await tx.select({ context: heartbeatRuns.contextSnapshot, receipts: heartbeatRuns.resultJson, diff --git a/server/src/services/recovery/ownership-invariant-report.ts b/server/src/services/recovery/ownership-invariant-report.ts index 0bc8aecc468..c00cb3b4517 100644 --- a/server/src/services/recovery/ownership-invariant-report.ts +++ b/server/src/services/recovery/ownership-invariant-report.ts @@ -9,6 +9,7 @@ import { import { taskClosedByAgentSql, TEMPORARY_REFUSAL_TRIGGERS } from "../work-intent-outbox.js"; import { deliveredQueuedCommentIds } from "../queued-comment-delivery.js"; import { logger } from "../../middleware/logger.js"; +import { getServerProcessStartedAt } from "../../server-info.js"; import { persistActivity } from "../activity-log.js"; import { currentProcessProbe, proveOwnerGone, type OwnerProof, type ProcessProbe } from "../owner-proof.js"; import { LEGACY_OWNER_REASONS, recordVersionAttestations } from "./owner-gone-attestation.js"; @@ -466,7 +467,7 @@ async function checkStaleDroppedComments(db: Db, now: Date, limit: number): Prom * finds a user comment on an assigned, non-conversation task with no live run * or lock, newer than every run of the task, that no wake request carries. * The reconciliation worker saves it as a deferred receipt and promotes it. */ -async function checkUserCommentsWithoutWake(db: Db, now: Date, limit: number): Promise<{ violations: InvariantViolation[]; nextDueAt: Date | null }> { +async function checkUserCommentsWithoutWake(db: Db, now: Date, limit: number, processStartedAt: Date): Promise<{ violations: InvariantViolation[]; nextDueAt: Date | null }> { const since = new Date(now.getTime() - INTENT_LOOKBACK_MS).toISOString(); const rows = await db.execute<{ id: string; company_id: string; issue_id: string; created_at: string }>(sql` select c.id, c.company_id, c.issue_id, c.created_at::text as created_at from issue_comments c @@ -498,8 +499,10 @@ async function checkUserCommentsWithoutWake(db: Db, now: Date, limit: number): P or coalesce(w.payload#>'{_paperclipWakeContext,wakeCommentIds}', '[]'::jsonb) ? c.id::text)) order by c.created_at limit ${Math.min(limit, MAX_RECORDS_PER_KIND)}`); // A comment inside the grace may still get its normal wake: check it again - // when the grace ends (a saved deadline), and report only older ones. - const graceEnd = now.getTime() - COMMENT_WAKE_GRACE_MS; + // when the grace ends (a saved deadline), and report only older ones. This + // process writes that wake after the comment commits, so a comment from + // before it started has no writer left and is owed now. + const graceEnd = Math.max(now.getTime() - COMMENT_WAKE_GRACE_MS, processStartedAt.getTime()); const young = [...rows].map(row => new Date(row.created_at).getTime()).filter(at => at >= graceEnd); return { violations: [...rows].filter(row => new Date(row.created_at).getTime() < graceEnd) @@ -594,6 +597,7 @@ export async function runOwnershipReconciliationPass(db: Db, options: { limitPerKind?: number; probe?: ProcessProbe; now?: Date; + processStartedAt?: Date; /** Write new violations to the activity log. Defaults to true. */ record?: boolean; /** Settle proven-gone records of kinds that register a `settle`, and surface @@ -686,7 +690,8 @@ export async function runOwnershipReconciliationPass(db: Db, options: { () => checkExactlyOnceIntents(db, state, now, limit), () => checkStaleDroppedComments(db, now, limit), async () => { - const result = await checkUserCommentsWithoutWake(db, now, limit); + const result = await checkUserCommentsWithoutWake(db, now, limit, + options.processStartedAt ?? new Date(getServerProcessStartedAt())); if (result.nextDueAt && (!nextDueAt || result.nextDueAt < nextDueAt)) nextDueAt = result.nextDueAt; return result.violations; }, From ad57d5cc12e1c88c981ab868fb6dd8e613d87461 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 9 Nov 2026 03:10:23 -0600 Subject: [PATCH 16/21] fix(native): restart an unclaimed remote follow-up that carries only its inherited checkpoint (#20214) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - A remote per-turn follow-up inherits the previous run's session checkpoint at admission, rebound to its own identity. Its session root still holds the previous owner's remote marker until the follow-up rotates authority > - If a restart lands before the follow-up's controller ever claims execution, recovery should restart its bootstrap: nothing of this run reached any runner > - Recovery's unclaimed-bootstrap check required no checkpoint and no provider evidence. The inherited checkpoint failed both, so recovery claimed `reattach_remote_runner` > - Even under a bootstrap claim, the executor skipped the per-turn projection restore for every restart claim. It therefore never rehydrated the previous owner's external marker, and failed with `runner_state_identity_mismatch: durable_identity_unreadable` > - This pull request does not count the inherited checkpoint as evidence for an unclaimed remote run, and lets a bootstrap claim restore the previous owner's projection, as a fresh follow-up start does > - The benefit is that a follow-up drained in its first second on Daytona restarts on its own ## Linked Issues or Issue Description Refs #20153 **What happened?** Daytona per-turn restart-chaos K13 storm w-20261109T074128-s9503 (revision 9046884811), t26 (codex C1): follow-up run `f1ddc43d` started at 07:54:18 and was drained by SIGTERM at 07:54:19, during workspace staging. - It had not requested a runner process, and its coordinator was never claimed (provider attempt 0). - Recovery claimed `reattach_remote_runner` (`hasCheckpoint: true`, `hasProviderEvidence: true`, both from the inherited checkpoint). - The executor failed with `runner_state_identity_mismatch: durable_identity_unreadable`, because the session root held the previous run's external marker. A Retry succeeded. **Expected behavior** An unclaimed remote run whose only checkpoint is the admission-rebound one, with no durable controller record and no provider events, restarts its bootstrap (`remote_execution_never_claimed`). The executor rehydrates the previous owner's projection and starts the follow-up from it. **Steps to reproduce** 1. Start a remote per-turn follow-up whose session root holds the previous owner's external marker. Drain the control plane before the follow-up's controller claims execution. 2. Restart. Before this change, recovery claims `reattach_remote_runner` and the executor fails with `durable_identity_unreadable`. **Paperclip version or commit** 9046884811 (wave). Branch rebased on current master. ## What Changed - `native-restart-recovery.ts`: - #20153's admission-rebound checkpoint test moves earlier. A new `inheritedCheckpointOnly` holds for a remote sandbox run (not a plugin task) with only that checkpoint, no durable controller record, no staged attachment and no provider events. - The unclaimed-bootstrap path and its classification then treat that checkpoint as neither a checkpoint nor provider evidence. - `native-inherited-runner-retry.ts` / `native-restart-recovery.ts`: a warm continuation carries its settled predecessor's runner process identity until it launches its own runner, and t26's row still held pid 596 at the restart. - The new `carriesInheritedRunnerIdentity` reuses the same-run retry path's proof (the exact pid, group and start time a settled predecessor recorded). - The restart classifier then ignores that inherited identity on the unclaimed path, for a run that carries only its inherited checkpoint. - A process identity that is the run's own still holds the run. - `native-session-executor.ts`: `restorePerTurnRunnerdController` now also runs for a `bootstrap_incomplete` claim, but only when the root's reference identity belongs to a previous run. A claim on the run's own root returns early, as before. - Tests: - `native-runner-restart-recovery.integration.test.ts`: an unclaimed remote follow-up with only its inherited checkpoint restarts, with no process identity and with its predecessor's runner identity. It stays held with its own runner identity, or when a provider event was projected; - `native-recovered-owner-followup.test.ts`: a bootstrap claim over the previous owner's external marker is admitted, rehydrating the marker, keeping the backup and quarantining nothing. ## Verification - Red/green: both new tests fail on master. The classifier test claims `reattach_remote_runner`, and the executor test fails with exactly `durable_identity_unreadable`. Both pass with the change, and the held case holds. - `vitest run` over the restart-recovery, bootstrap, recovered-owner and failed-workspace-recovery test files and `native-session-executor.test.ts`: 9 files, 1715 tests passed (14 skipped). After the inherited-identity change, the restart-recovery, bootstrap, recovered-owner, failed-workspace-recovery and inherited-runner test files: 8 files, 407 tests passed (14 skipped). Without the identity check, the inherited case is not restarted. - `PAPERCLIP_TYPECHECK_LOW_MEMORY=1 pnpm typecheck` (`server`): passed. ## Risks - Low risk: - The inherited checkpoint stops counting only when the database shows no controller record and no provider event for the run, and the existing unclaimed-bootstrap conditions all still hold. - The executor change runs the existing fresh-follow-up projection restore, with all of its own checks, only for a root that still belongs to the previous run. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), via Claude Code with tool use (shell, file edits, test execution). ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- .../native-inherited-runner-retry.ts | 34 +++++++++----- .../native-recovered-owner-followup.test.ts | 10 +++++ .../native-runtime/native-restart-recovery.ts | 23 +++++----- ...unner-restart-recovery.integration.test.ts | 44 +++++++++++++++++++ .../native-runtime/native-session-executor.ts | 7 ++- 5 files changed, 97 insertions(+), 21 deletions(-) diff --git a/server/src/services/native-runtime/native-inherited-runner-retry.ts b/server/src/services/native-runtime/native-inherited-runner-retry.ts index 63c686304b8..0568842db20 100644 --- a/server/src/services/native-runtime/native-inherited-runner-retry.ts +++ b/server/src/services/native-runtime/native-inherited-runner-retry.ts @@ -89,6 +89,28 @@ export function readInheritedRunnerRetainedSandbox(profile: Record; + +/** The settled predecessor whose exact runner process identity this run carries. */ +async function settledPredecessorWithRunnerIdentity(tx: Pick, run: RunnerIdentityRun) { + if (!run.nativeSessionId || (run.processPid === null && run.processGroupId === null)) return null; + const [predecessor] = await tx.select({ id: heartbeatRuns.id, processStartedAt: heartbeatRuns.processStartedAt }) + .from(heartbeatRuns).where(and( + eq(heartbeatRuns.companyId, run.companyId), eq(heartbeatRuns.agentId, run.agentId), + eq(heartbeatRuns.nativeSessionId, run.nativeSessionId), ne(heartbeatRuns.id, run.id), + lt(heartbeatRuns.createdAt, run.createdAt), inArray(heartbeatRuns.status, TERMINAL_RUN_STATUSES), + run.processPid === null ? isNull(heartbeatRuns.processPid) : eq(heartbeatRuns.processPid, run.processPid), + run.processGroupId === null ? isNull(heartbeatRuns.processGroupId) : eq(heartbeatRuns.processGroupId, run.processGroupId), + )).orderBy(desc(heartbeatRuns.createdAt)).limit(1); + return predecessor && sameTime(predecessor.processStartedAt, run.processStartedAt) ? predecessor : null; +} + +/** The run's recorded runner process identity is exactly a settled predecessor's, not its own. */ +export async function carriesInheritedRunnerIdentity(tx: Pick, run: RunnerIdentityRun): Promise { + return (await settledPredecessorWithRunnerIdentity(tx, run)) !== null; +} + export async function retireInheritedRemoteRunnerIdentity(db: Db, runId: string): Promise { return db.transaction(async transaction => { const tx = transaction as unknown as Db; @@ -127,16 +149,8 @@ export async function retireInheritedRemoteRunnerIdentity(db: Db, runId: string) if ((await nativeSessionProviderEvidence(tx, [run.id])).has(run.id)) return "not_applicable"; if (await hasNativeRunnerCheckpoint(tx, { companyId: run.companyId, issueId: issue.id, runId: run.id })) return "not_applicable"; - // The identity must be exactly the one a settled predecessor recorded. - const [predecessor] = await tx.select({ id: heartbeatRuns.id, processStartedAt: heartbeatRuns.processStartedAt }) - .from(heartbeatRuns).where(and( - eq(heartbeatRuns.companyId, run.companyId), eq(heartbeatRuns.agentId, run.agentId), - eq(heartbeatRuns.nativeSessionId, run.nativeSessionId), ne(heartbeatRuns.id, run.id), - lt(heartbeatRuns.createdAt, run.createdAt), inArray(heartbeatRuns.status, TERMINAL_RUN_STATUSES), - run.processPid === null ? isNull(heartbeatRuns.processPid) : eq(heartbeatRuns.processPid, run.processPid), - run.processGroupId === null ? isNull(heartbeatRuns.processGroupId) : eq(heartbeatRuns.processGroupId, run.processGroupId), - )).orderBy(desc(heartbeatRuns.createdAt)).limit(1); - if (!predecessor || !sameTime(predecessor.processStartedAt, run.processStartedAt)) return "not_applicable"; + const predecessor = await settledPredecessorWithRunnerIdentity(tx, run); + if (!predecessor) return "not_applicable"; // Keep the run's own sandbox when its lease is still live. const [lease] = await tx.select({ id: environmentLeases.id }).from(environmentLeases).where(and( diff --git a/server/src/services/native-runtime/native-recovered-owner-followup.test.ts b/server/src/services/native-runtime/native-recovered-owner-followup.test.ts index 3e6af4c6106..af4aeaf8ddd 100644 --- a/server/src/services/native-runtime/native-recovered-owner-followup.test.ts +++ b/server/src/services/native-runtime/native-recovered-owner-followup.test.ts @@ -337,6 +337,16 @@ describe.skipIf(!recoveryPostgresSupport.supported)("follow-up after a recovered expect(followUp.logs.join("")).not.toContain("starting a new runner for this conversation"); }, 60_000); + it("starts an unclaimed remote follow-up from the previous owner's external marker under a bootstrap claim", async () => { + const fixture = await staleProjection("codex", true); + expect(await fixture.followUp.start("created", { kind: "bootstrap_incomplete", runId: fixture.followUp.runId, + leaseOwner: "successor", controllerGeneration: 1, providerAttempt: 0, restartKind: "hard", recoveryRequestId: null } as never)) + .toBe(ADMITTED); + expect(JSON.parse(await readFile(fixture.path, "utf8")).identity).toEqual(fixture.latest.identity); + await fixture.assertBackup(); + expect(await quarantined()).toEqual([]); + }, 60_000); + it("rehydrates a same-source external marker before reading the suspended resumed sandbox", async () => { const fixture = await staleProjection("codex", true), { latest, followUp, root, path } = fixture; await writeFile(path, JSON.stringify({ schema: "paperclip.runner.external-control-plane-state.v1", identity: latest.identity })); diff --git a/server/src/services/native-runtime/native-restart-recovery.ts b/server/src/services/native-runtime/native-restart-recovery.ts index 14d3954a745..b9d19c33f7d 100644 --- a/server/src/services/native-runtime/native-restart-recovery.ts +++ b/server/src/services/native-runtime/native-restart-recovery.ts @@ -1,3 +1,4 @@ +import { carriesInheritedRunnerIdentity } from "./native-inherited-runner-retry.js"; import { readPendingNativeRunStop } from "./native-cancellation-request.js"; import { CONTROLLER_LEASE_RENEW_MS } from "../controller-instance-lease.js"; import { currentControllerProcessStamp, UNKNOWN_PROCESS_SPACE } from "../controller-process-identity.js"; @@ -1324,6 +1325,13 @@ export async function claimNativeRestartRecoveries(input: { const hasProviderEvidence = hasCheckpointProviderIdentity || providerEvents.length > 0; + const admissionCheckpointOnly = !hasCheckpoint || (checkpointIdentityMatches && checkpointRecord.cursor == null + && (!Array.isArray(checkpointRecord.terminalTurns) || checkpointRecord.terminalTurns.length === 0) + && checkpointRecord.activeTurnId == null && checkpointRecord.semanticResult == null && checkpointRecord.terminal == null); + const inheritedCheckpointOnly = hasCheckpoint && admissionCheckpointOnly && remoteSandbox && !pluginTask + && !durableAuthority && stagedAttachment === null && providerEvents.length === 0; + const inheritedRunnerIdentity = inheritedCheckpointOnly && Boolean(row.run.processPid || row.run.processGroupId) + && await carriesInheritedRunnerIdentity(tx, row.run); let unclaimedBootstrap = remoteWorkspace?.state === "prepared" && row.run.runnerProfileJson?.nativePreparationFencingVersion === NATIVE_PREPARATION_FENCING_VERSION && @@ -1338,14 +1346,12 @@ export async function claimNativeRestartRecoveries(input: { // suspends. Without an owner, an elapsed expiry grants no authority. (!row.coordinator.leaseExpiresAt || row.coordinator.leaseExpiresAt <= now) && !row.coordinator.failureDetail?.successorRunId && - !row.run.processPid && - !row.run.processGroupId && - !row.run.processStartedAt && + (inheritedRunnerIdentity || (!row.run.processPid && !row.run.processGroupId && !row.run.processStartedAt)) && !row.run.sessionIdBefore && !row.run.sessionIdAfter && Boolean(row.run.nativeSessionId && row.run.runnerInstanceId) && - !hasCheckpoint && - !hasProviderEvidence; + (!hasCheckpoint || inheritedCheckpointOnly) && + (!hasProviderEvidence || inheritedCheckpointOnly); if (unclaimedBootstrap) { if (activeNativeExecutionRunIds.has(row.run.id) && controller.bootId === (await currentNativeControllerIdentity()).bootId) { @@ -1430,9 +1436,6 @@ export async function claimNativeRestartRecoveries(input: { const remoteBootstrapProvenIncomplete = remoteBootstrapProof !== null && (!hasProviderEvidence || remoteBootstrapProof === "session_opened_without_turn"); - const admissionCheckpointOnly = !hasCheckpoint || (checkpointIdentityMatches && checkpointRecord.cursor == null - && (!Array.isArray(checkpointRecord.terminalTurns) || checkpointRecord.terminalTurns.length === 0) - && checkpointRecord.activeTurnId == null && checkpointRecord.semanticResult == null && checkpointRecord.terminal == null); const remoteRunnerNeverReceivedCommands = !unclaimedBootstrap && !remoteRunnerNeverLaunched && !remoteRunnerExitedBeforeProviderWork && !remoteBootstrapProvenIncomplete && remoteSandbox && remoteWorkspace !== null && !pluginTask && !durableAuthority && stagedAttachment === null @@ -1468,12 +1471,12 @@ export async function claimNativeRestartRecoveries(input: { knownProviderProcessAlive: providerProcesses.livePids.length > 0, knownProviderProcessIdentityAmbiguous: providerProcesses.ambiguousLivePids.length > 0, - hasCheckpoint: hasCheckpoint && !remoteRunnerNeverReceivedCommands, + hasCheckpoint: hasCheckpoint && !remoteRunnerNeverReceivedCommands && !(unclaimedBootstrap && inheritedCheckpointOnly), stagedAttachmentVerified: stagedAttachment !== null, checkpointIdentityMatches: checkpointIdentityMatches && (!remoteSandbox || remoteWorkspace !== null), - hasProviderEvidence, + hasProviderEvidence: hasProviderEvidence && !(unclaimedBootstrap && inheritedCheckpointOnly), checkpointFailed: (checkpointRecord.terminal as Record | undefined) ?.runTerminalState === "failed" && diff --git a/server/src/services/native-runtime/native-runner-restart-recovery.integration.test.ts b/server/src/services/native-runtime/native-runner-restart-recovery.integration.test.ts index 8b8ebc17c71..866f51003e3 100644 --- a/server/src/services/native-runtime/native-runner-restart-recovery.integration.test.ts +++ b/server/src/services/native-runtime/native-runner-restart-recovery.integration.test.ts @@ -2757,6 +2757,50 @@ process.on("SIGTERM", () => child.kill("SIGTERM")); expect(coordinator.recoveryHistory.at(-1)).toMatchObject({ reason: "remote_runner_never_received_commands" }); }); + it.each([ + { label: "restarts", providerEvent: false, process: "none" }, + { label: "restarts with its predecessor's runner identity", providerEvent: false, process: "inherited" }, + { label: "keeps its own runner identity held instead of restarting", providerEvent: false, process: "own" }, + { label: "keeps a projected provider event held instead of restarting", providerEvent: true, process: "none" }, + ])("$label an unclaimed remote follow-up that carries only its inherited checkpoint", async ({ providerEvent, process }) => { + const fixture = await seedUnlaunchedRemoteClaim(`UNCLAIMED-INHERITED-${providerEvent}-${process}`); + if (process !== "none") { + const processStartedAt = new Date(Date.now() - 120_000); + await fixture.db.update(heartbeatRuns).set({ processPid: 596, processGroupId: null, processStartedAt, processHostHash: "remote" }) + .where(eq(heartbeatRuns.id, fixture.runId)); + if (process === "inherited") { + const [current] = await fixture.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, fixture.runId)); + await fixture.db.insert(heartbeatRuns).values({ companyId, agentId, status: "succeeded", runtimeMode: "native", + nativeIssueId: fixture.issueId, nativeSessionId: current.nativeSessionId, runnerInstanceId: current.runnerInstanceId, + createdAt: new Date(current.createdAt.getTime() - 60_000), finishedAt: new Date(current.createdAt.getTime() - 5_000), + processPid: 596, processGroupId: null, processStartedAt, processHostHash: "remote", + contextSnapshot: { issueId: fixture.issueId } }); + } + } + await fixture.db.update(nativeRunFinalizations).set({ attempt: 0, controllerGeneration: 0, controllerBootId: null, + controllerPid: null, controllerProcessStartedAt: null, leaseOwner: null, leaseExpiresAt: null }) + .where(eq(nativeRunFinalizations.runId, fixture.runId)); + const [seeded] = await fixture.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, fixture.runId)); + await fixture.db.update(heartbeatRuns).set({ runnerProfileJson: { ...seeded.runnerProfileJson, sessionCheckpoint: { + driverKind: "codex_app_server", backendKind: "runner", sessionId: "inherited-thread", cursor: null, + identity: { companyId, issueId: fixture.issueId, runId: fixture.runId, agentId, sessionId: fixture.native.normalizedSessionId }, + providerSessionId: "inherited-thread", activeTurnId: null, terminal: null, terminalTurns: [], semanticResult: null, + pendingRuntimeRequests: [], lineage: [], + } } }).where(eq(heartbeatRuns.id, fixture.runId)); + if (providerEvent) await fixture.db.insert(heartbeatRunEvents).values({ companyId, runId: fixture.runId, agentId, seq: 901, + stream: "system", level: "info", eventType: "session.started" }); + const [claim] = await claimNativeRestartRecoveries({ db: fixture.db, controller: successor, + restartKind: "hard", runIds: [fixture.runId] }); + if (providerEvent || process === "own") { + expect(claim).not.toMatchObject({ kind: "bootstrap_incomplete" }); + return; + } + expect(claim).toMatchObject({ kind: "bootstrap_incomplete", runId: fixture.runId }); + const [coordinator] = await fixture.db.select().from(nativeRunFinalizations) + .where(eq(nativeRunFinalizations.runId, fixture.runId)); + expect(coordinator.recoveryHistory.at(-1)).toMatchObject({ reason: "remote_execution_never_claimed" }); + }); + it("restarts a remote bootstrap retry that shutdown cut before any runner was requested", async () => { const fixture = await seedUnlaunchedRemoteClaim("BOOTSTRAP-RETRY", { bootstrapRetry: true }); expect(await claimNativeRestartRecoveries({ db: fixture.db, controller: successor, diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index 7a4bc5ed81f..15aacf4dbd1 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -6744,9 +6744,14 @@ async function restorePerTurnRunnerdController(input: { runnerExecutionTarget?: AdapterExecutionTarget | null; restartRecovery?: NativeRestartRecoveryClaim; }): Promise { - if (input.restartRecovery || input.runnerExecutionTarget?.kind !== "remote" || + if ((input.restartRecovery && input.restartRecovery.kind !== "bootstrap_incomplete") || + input.runnerExecutionTarget?.kind !== "remote" || input.execution.session.lifecyclePolicy.mode !== "per_turn") return; const root = scopedRunnerdStateRoot(input.execution); + if (input.restartRecovery) { + const prior = readRunnerdDurableIdentity(root) ?? readRunnerdDurableIdentity(root, true); + if (!prior || prior.runId === input.execution.binding.runId) return; + } await restoreDurableHarnessForExecution(input.db, input.execution, root); const identity = readRunnerdDurableIdentity(root); const reference = identity ?? readRunnerdDurableIdentity(root, true); From fe72bc5f81dd3bf2722daf5bf714aa8d451b8374 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 9 Nov 2026 03:20:23 -0600 Subject: [PATCH 17/21] fix(native): roll back a warm rotation cut off before its new control plane once its runner is proven dead (#20218) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - A warm follow-up rotates authority before it attaches. It archives the previous epoch (suspended runner state and settled controller) under `authority-epochs/`, then writes its own control plane > - #20146 taught recovery to roll back a rotation whose new control plane held only an undelivered `run.attach`, once its runner was proven dead > - A hard kill can land one step earlier, after the archive and before the new control plane exists. The root then holds only the archive, with no active control plane and no runner state. The executor read "no durable identity" and failed with `runner_state_identity_mismatch: durable_identity_unreadable` > - This pull request applies the same proven rollback to that earlier point of the rotation > - The benefit is that a hard kill anywhere in the rotation window resumes the follow-up from the previous turn ## Linked Issues or Issue Description Refs #20146 **What happened?** Local mixed restart-chaos wave w-20261109T081456-s9901 (master 09e24dd12c), t15 (codex M1): - Run `4b85497f` started at 08:29:32 as a warm follow-up and requested its process, then K1 killed the server. - Recovery stopped the previous run's warm runner (pid 60249) and claimed `resume_dead_runner`. - The executor failed with `runner_state_identity_mismatch: durable_identity_unreadable`. The quarantined root held: - `authority-epochs/epoch-c2842347…` with the previous owner `dc7d447b`: runner `suspended`, every command completed; - no `control-plane/` state and no `runner/runner-state.json`, only provider state. A Retry ran fresh. Phase 3 also saw the same signature on Daytona; #20214 covers that remote shape. **Expected behavior** For a local runner proven dead (`resume_dead_runner`), a root that holds only the archive of a suspended previous owner of this session restores that epoch, and the follow-up continues from the previous turn. Without a dead-runner claim it still fails closed. **Steps to reproduce** 1. Start a warm follow-up and hard-kill the server after the rotation archives the previous epoch, before it writes the new control plane. 2. Restart. Before this change, the follow-up fails with `durable_identity_unreadable`. **Paperclip version or commit** 09e24dd12c (wave). Branch rebased on current master. ## What Changed - `native-session-executor.ts`: - `undeliveredAttachRotationSource` becomes `interruptedRotationSource`, and the new `latestSuspendedArchivedEpoch` shares the epoch checks: named by its identity digest, a matching suspended runner state. - Besides #20146's undelivered-attach shape, it accepts a root with no `runner-state.json` and no active control plane (absent or empty). In that case the epoch must belong to this session (`normalizedSessionId`) and to a different run. - The rollback moves an active control plane aside only when one exists. - `native-recovered-owner-followup.test.ts`: #20146's rotation test adds the "cut off before its new control plane" shape. With a dead-runner claim it is admitted from the restored previous owner; without the claim it still fails closed. ## Verification - Red/green: on master, the new admitted case fails with exactly `durable_identity_unreadable`. With the change it is admitted. The fail-closed cases are unchanged. - `vitest run` of the recovered-owner, restart-recovery and bootstrap test files and `native-session-executor.test.ts`: 8 files, 1627 tests passed (14 skipped). - `PAPERCLIP_TYPECHECK_LOW_MEMORY=1 pnpm typecheck` (`server`): passed. ## Risks - Low risk: - The rollback still requires a `resume_dead_runner` claim on a local runner, an absent runner state, and an archived epoch of this session whose runner is suspended and whose name matches its identity. - Restoring only moves files back within the root, and nothing is deleted. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), via Claude Code with tool use (shell, file edits, test execution). ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- .../native-recovered-owner-followup.test.ts | 23 ++++--- .../native-runtime/native-session-executor.ts | 61 +++++++++++-------- 2 files changed, 52 insertions(+), 32 deletions(-) diff --git a/server/src/services/native-runtime/native-recovered-owner-followup.test.ts b/server/src/services/native-runtime/native-recovered-owner-followup.test.ts index af4aeaf8ddd..2c34ce01ae1 100644 --- a/server/src/services/native-runtime/native-recovered-owner-followup.test.ts +++ b/server/src/services/native-runtime/native-recovered-owner-followup.test.ts @@ -904,10 +904,13 @@ describe.skipIf(!recoveryPostgresSupport.supported)("follow-up after a recovered } }, 60_000); it.each([ - { label: "never delivered to a dead runner", connectionCount: 0, claim: true, admitted: true }, - { label: "possibly delivered", connectionCount: 1, claim: true, admitted: false }, - { label: "without a dead-runner claim", connectionCount: 0, claim: false, admitted: false }, - ])("resumes from the previous turn when a warm attach rotation was $label", async ({ connectionCount, claim, admitted }) => { + { label: "never delivered to a dead runner", connectionCount: 0, claim: true, admitted: true, archiveOnly: false }, + { label: "possibly delivered", connectionCount: 1, claim: true, admitted: false, archiveOnly: false }, + { label: "without a dead-runner claim", connectionCount: 0, claim: false, admitted: false, archiveOnly: false }, + { label: "cut off before its new control plane was written", connectionCount: 0, claim: true, admitted: true, archiveOnly: true }, + { label: "cut off before its new control plane, without a dead-runner claim", connectionCount: 0, claim: false, admitted: false, + archiveOnly: true }, + ])("resumes from the previous turn when a warm attach rotation was $label", async ({ connectionCount, claim, admitted, archiveOnly }) => { const prior = await seedRecoveredPriorRun(); const followUp = await admitFollowUp(prior); const root = await restoreHeldRoot(prior, followUp); @@ -921,22 +924,28 @@ describe.skipIf(!recoveryPostgresSupport.supported)("follow-up after a recovered await mkdir(join(root, "runner"), { recursive: true, mode: 0o700 }); await writeFile(join(root, "runner", "codex-provider-state.json"), "{}"); const identity = { ...priorIdentity, runId: followUp.runId, turnId: `turn-${followUp.runId}`, itemId: `item-${followUp.runId}` }; - await mkdir(join(root, "control-plane"), { mode: 0o700 }); + if (!archiveOnly) await mkdir(join(root, "control-plane"), { mode: 0o700 }); const undelivered = JSON.stringify({ schema: "paperclip.runner.durable.control-plane-state.v2", identity, bootstrapHistory: { schema: "paperclip.runner.bootstrap-history.v1", originIdentity: identity, credentialsIssued: 0, adopted: false }, runAttachTemplate: {}, tickets: {}, leases: {}, commands: [{ commandId: "attach", controllerSeq: 1, type: "run.attach", status: "pending", payload: {} }], committedEvents: [], ackedSourceSeq: 0, connectionCount, commandDeliveryCounts: {} }); - await writeFile(join(root, "control-plane", "control-plane-state.json"), undelivered); + if (!archiveOnly) await writeFile(join(root, "control-plane", "control-plane-state.json"), undelivered); const outcome = await followUp.startLocal(claim ? { kind: "resume_dead_runner", runId: followUp.runId, leaseOwner: "successor", controllerGeneration: 2, providerAttempt: 1, restartKind: "hard", recoveryRequestId: null } as never : undefined); if (!admitted) { - expect(outcome).toBe("runner_state_identity_mismatch: authority_indeterminate"); + expect(outcome).toBe(archiveOnly ? "runner_state_identity_mismatch: durable_identity_unreadable" + : "runner_state_identity_mismatch: authority_indeterminate"); return; } expect(outcome).toBe(ADMITTED); expect(JSON.parse(await readFile(join(root, "control-plane", "control-plane-state.json"), "utf8")).identity).toEqual(priorIdentity); expect(existsSync(join(root, "authority-epochs"))).toBe(false); + if (archiveOnly) { + expect(existsSync(join(root, "continuity-breaks"))).toBe(false); + expect(await quarantined()).toEqual([]); + return; + } const [archive] = await readdir(join(root, "continuity-breaks")); expect(await readFile(join(root, "continuity-breaks", archive!, "control-plane", "control-plane-state.json"), "utf8")).toBe(undelivered); expect(await quarantined()).toEqual([]); diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index 15aacf4dbd1..d3573e54916 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -5680,8 +5680,34 @@ async function retireFinishedPerTurnRunnerdOwner(input: Parameters) => boolean): string | null { + const archives = resolve(root, "authority-epochs"); + if (!isSafeNativeStateDirectory(archives)) return null; + const candidates = readdirSync(archives).map(name => resolve(archives, name)).filter(epoch => { + const state = resolve(epoch, "control-plane", "control-plane-state.json"); + return isSafeNativeStateDirectory(epoch) && existsSync(state) && existsSync(resolve(epoch, "runner-state.json")); + }).sort((left, right) => lstatSync(resolve(right, "control-plane", "control-plane-state.json")).mtimeMs + - lstatSync(resolve(left, "control-plane", "control-plane-state.json")).mtimeMs); + const latest = candidates[0]; + if (!latest) return null; + const prior = record(readRunnerdControlPlaneState(latest).identity); + const runner = record(JSON.parse(readBoundedNativeFile(resolve(latest, "runner-state.json"), + NATIVE_RUNNER_STATE_MAX_BYTES, "runner_state_too_large").toString("utf8"))); + const digest = createHash("sha256").update(JSON.stringify(prior)).digest("hex").slice(0, 24); + if (basename(latest) !== `epoch-${digest}` || !matches(prior) || + runner.schema !== RUNNERD_STATE_SCHEMA || runner.lifecycle !== "suspended" || + ["runnerInstanceId", "environmentLeaseId", "runId", "normalizedSessionId"].some(key => runner[key] !== prior[key])) return null; + return latest; +} + +function interruptedRotationSource(root: string, execution: NativeExecutionInput): string | null { try { + if (existsSync(resolve(root, "runner", "runner-state.json"))) return null; + if (!existsSync(resolve(root, "control-plane", "control-plane-state.json"))) { + if (existsSync(resolve(root, "control-plane")) && readdirSync(resolve(root, "control-plane")).length > 0) return null; + return latestSuspendedArchivedEpoch(root, prior => typeof prior.runId === "string" && + prior.runId !== execution.binding.runId && prior.normalizedSessionId === nativeSessionKey(execution)); + } const control = readRunnerdControlPlaneState(root); const identity = record(control.identity); const commands = Array.isArray(control.commands) ? control.commands.map(record) : []; @@ -5693,26 +5719,9 @@ function undeliveredAttachRotationSource(root: string, execution: NativeExecutio Object.keys(record(control.commandDeliveryCounts)).length !== 0 || control.warmTransition != null || control.completedWarmTransition != null || control.stagedRunAttachment != null || history.adopted === true || history.credentialsIssued !== 0 || - commands.length !== 1 || commands[0]!.type !== "run.attach" || commands[0]!.status !== "pending" || - existsSync(resolve(root, "runner", "runner-state.json"))) return null; - const archives = resolve(root, "authority-epochs"); - if (!isSafeNativeStateDirectory(archives)) return null; - const candidates = readdirSync(archives).map(name => resolve(archives, name)).filter(epoch => { - const state = resolve(epoch, "control-plane", "control-plane-state.json"); - return isSafeNativeStateDirectory(epoch) && existsSync(state) && existsSync(resolve(epoch, "runner-state.json")); - }).sort((left, right) => lstatSync(resolve(right, "control-plane", "control-plane-state.json")).mtimeMs - - lstatSync(resolve(left, "control-plane", "control-plane-state.json")).mtimeMs); - const latest = candidates[0]; - if (!latest) return null; - const prior = record(readRunnerdControlPlaneState(latest).identity); - const runner = record(JSON.parse(readBoundedNativeFile(resolve(latest, "runner-state.json"), - NATIVE_RUNNER_STATE_MAX_BYTES, "runner_state_too_large").toString("utf8"))); - const digest = createHash("sha256").update(JSON.stringify(prior)).digest("hex").slice(0, 24); - if (basename(latest) !== `epoch-${digest}` || prior.runId === identity.runId || - ["runnerInstanceId", "environmentLeaseId", "normalizedSessionId"].some(key => prior[key] !== identity[key]) || - runner.schema !== RUNNERD_STATE_SCHEMA || runner.lifecycle !== "suspended" || - ["runnerInstanceId", "environmentLeaseId", "runId", "normalizedSessionId"].some(key => runner[key] !== prior[key])) return null; - return latest; + commands.length !== 1 || commands[0]!.type !== "run.attach" || commands[0]!.status !== "pending") return null; + return latestSuspendedArchivedEpoch(root, prior => prior.runId !== identity.runId && + ["runnerInstanceId", "environmentLeaseId", "normalizedSessionId"].every(key => prior[key] === identity[key])); } catch { return null; } @@ -5721,13 +5730,15 @@ function undeliveredAttachRotationSource(root: string, execution: NativeExecutio async function rollBackUndeliveredAttachRotation(root: string, execution: NativeExecutionInput, onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise): Promise { if (!existsSync(root) || !isSafeNativeStateDirectory(root)) return; - const epoch = undeliveredAttachRotationSource(root, execution); + const epoch = interruptedRotationSource(root, execution); if (!epoch) return; const archivedAt = Date.now(); - const breakRoot = resolve(root, "continuity-breaks", `${archivedAt}-${randomUUID()}`); - mkdirSync(breakRoot, { recursive: true, mode: 0o700 }); noteNativeRunnerEvidence(archivedAt); - renameSync(resolve(root, "control-plane"), resolve(breakRoot, "control-plane")); + if (existsSync(resolve(root, "control-plane"))) { + const breakRoot = resolve(root, "continuity-breaks", `${archivedAt}-${randomUUID()}`); + mkdirSync(breakRoot, { recursive: true, mode: 0o700 }); + renameSync(resolve(root, "control-plane"), resolve(breakRoot, "control-plane")); + } mkdirSync(resolve(root, "runner"), { recursive: true, mode: 0o700 }); renameSync(resolve(epoch, "runner-state.json"), resolve(root, "runner", "runner-state.json")); renameSync(resolve(epoch, "control-plane"), resolve(root, "control-plane")); From 8525bb534c211ba39d0e29e2b4c1a6fb4f4c4d61 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 9 Nov 2026 03:22:01 -0600 Subject: [PATCH 18/21] fix(chat): post a turn's answer when a card it did not create is pending (#20220) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - People talk to agents in Agent Chat. The agent's final message becomes the chat reply. > - A chat thread can hold a pending card that the board addressed to another person, for example a human-only confirmation for Riley. > - When Alex asks the agent to act on that card, the agent correctly gets `403 interaction_human_only` and writes a final answer that explains the denial. > - The shared answered rule (`conversationTurnAnswered()`, from #20179 and #20204) counted any pending human-only card in the thread as the turn's own response. So the answer was suppressed, and the chat stayed `active` with no reply. > - This pull request limits that rule to cards that the turn's own run created. Other cards still hold the task status. > - The benefit is that the person always gets the turn's answer, unless the turn itself asked a question with a card. Live completion and restart recovery stay consistent. ## Linked Issues or Issue Description Refs #20179, #20204 (the shared answered rule this PR corrects). **What happened** In an Agent Chat, the board created a pending `request_confirmation` card with `resolverPolicy: human_only`, addressed to another user (Riley). The card has no source run. Alex wrote "I approve Riley's request." The agent tried to accept the card and got `403 interaction_human_only`. It then wrote a correct final answer: the card needs Riley. The run succeeded with a `yielded` result and a `response_wake` continuation. Run completion recorded `presentationDecision: none`. No comment was created. The conversation stayed `active`, so Alex never got a reply. Cause: `conversationTurnAnswered()` reused the status-gate query `pendingNativeInteraction()`. In conversation mode, that query excludes another run's card only when it is an ordinary question or an `anyone` confirmation. A human-only card addressed to someone else is kept. The rule then treated that card as the card that "presents the turn", although this run did not create it. **Expected behavior** A successful chat turn posts its final answer exactly once, and the conversation moves to `waiting`. Only a pending question or confirmation card that the turn's own run created presents the turn in place of its prose. A card from an earlier turn, or one the board addressed to another person, can hold the task status, but it does not suppress the answer. **Steps to reproduce** 1. Open an Agent Chat as Alex. 2. As the board, create a pending `request_confirmation` card in the chat with `resolverPolicy: human_only` and `addresseeUserId` set to another person. 3. Send "I approve Riley's request." 4. Let the agent try to accept the card, get `403 interaction_human_only`, and end its turn with a final answer. 5. See that no reply appears, `presentationDecision.commentAction` is `none`, and `conversationState` stays `active`. ## What Changed - `server/src/services/native-runtime/native-governance.ts`: `conversationTurnAnswered()` now checks only pending cards whose `sourceRunId` is the turn's run. `pendingNativeInteraction()` gets an optional `createdByRun` filter for this. The status gate (`pendingNativeGovernance()`) does not change, so another person's card still holds the task. - Both callers get the fix through the one shared rule `conversationTurnPresentsAnswer()`: live run completion (`run-completion.ts`) and background presentation recovery (`recoverCommittedNativeChatResponse`). - `server/src/services/heartbeat/run-completion.test.ts`: new live cases "someone else's confirmation card" (board-created, human-only, addressed to another user, no source run) and "earlier turn's question card" (created by an earlier run of the same agent). Each case now also checks that the conversation moves to `waiting`. - `server/src/services/native-runtime/agent-chat-response-recovery.test.ts`: new restart case for the board-created card, plus a `waiting` check after recovery. - `doc/execution-semantics.md`: state that only the turn's own card presents the turn. ## Verification - Thesis: the reply is suppressed because the answered rule counts any pending human-only card in the thread, not only this run's card. If this is true, a turn with only a board-created card addressed to someone else posts no comment on master, and restricting the rule to `sourceRunId = runId` posts exactly one comment and settles the conversation to `waiting`, with the own-card cases unchanged. - Failing first: without the fix, the two new live cases failed (`expected [] to deeply equal [answer]`). The new recovery case also failed (no comment). - After the fix: - `cd server && npx vitest run src/services/heartbeat/run-completion.test.ts -t "posts a waiting conversation turn"`: 9 passed (includes the two own-card cases, which still post nothing). - `cd server && npx vitest run src/services/native-runtime/agent-chat-response-recovery.test.ts`: 62 passed. - Both suites use a real embedded PostgreSQL database. - Turn start: no change. The rule runs only after the provider turn ends. ## Risks - Low. Only the presentation rule changes. The status gate, the conversation settle rule (it already required `sourceRunId = run.id`) and the external-chat path do not change. - Behavior shift: a turn now posts its answer while another run's or the board's card is pending. Before, the answer was lost. A card that the turn itself created still presents the turn, as before. - Previous-release state: no persisted shape change. A run that the previous release already recorded as `none` keeps that decision. Recovery does not re-present it. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - Anthropic Claude Opus 5.5 (`claude-opus-5-5`), with tool use (shell, file edits, test runs) in Claude Code. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- doc/execution-semantics.md | 6 +++-- .../services/heartbeat/run-completion.test.ts | 25 ++++++++++++++++++- .../agent-chat-response-recovery.test.ts | 13 ++++++++-- .../native-runtime/native-governance.ts | 12 ++++++--- 4 files changed, 47 insertions(+), 9 deletions(-) diff --git a/doc/execution-semantics.md b/doc/execution-semantics.md index 172130221c4..2adc1c2198d 100644 --- a/doc/execution-semantics.md +++ b/doc/execution-semantics.md @@ -2267,8 +2267,10 @@ In an Agent Chat conversation, a successful turn whose status decision committed posts its final answer to the conversation even when the task stays held: a pending or revision-requested approval, a pending execution stage, or a dependency blocker. A held task does not present the turn. Only a pending -thread interaction (a question or confirmation card) presents the turn in place -of its prose. Background presentation recovery applies the same rule, so a +thread interaction (a question or confirmation card) that the turn's own run +created presents the turn in place of its prose. A card from an earlier turn, +or one the board addressed to another person, can hold the task but does not +present the turn, so its answer is still posted. Background presentation recovery applies the same rule, so a restart between the status commit and the live presentation posts the held turn's answer once, or records that its card presented the turn. diff --git a/server/src/services/heartbeat/run-completion.test.ts b/server/src/services/heartbeat/run-completion.test.ts index 42c34facc34..10f480c9760 100644 --- a/server/src/services/heartbeat/run-completion.test.ts +++ b/server/src/services/heartbeat/run-completion.test.ts @@ -16,7 +16,7 @@ import { createHash, randomUUID } from "node:crypto"; import { appendHeartbeatRunEvent } from "../heartbeat-run-events.js"; import { createNativeRunLogCoverageVerifier, formatNativeRunLogEvent } from "../native-runtime/native-run-log-export.js"; import type { Db } from "@paperclipai/db"; -import { activityLog, agents, agentWakeupRequests, approvals, issueApprovals, budgetReservations, caseHandoffs, cases, companies, completionContracts, costEvents, createDb, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS, heartbeatRuns, heartbeatRunEvents, issueComments, issueQuestionResponseDeliveries, issueRecoveryActions, issues, nativeRunFinalizations } from "@paperclipai/db"; +import { activityLog, agents, agentWakeupRequests, approvals, issueApprovals, budgetReservations, caseHandoffs, cases, companies, completionContracts, costEvents, createDb, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS, heartbeatRuns, heartbeatRunEvents, issueComments, issueQuestionResponseDeliveries, issueRecoveryActions, issues, issueThreadInteractions, nativeRunFinalizations } from "@paperclipai/db"; import { and, eq, sql } from "drizzle-orm"; import { afterAll, beforeAll, beforeEach, describe, expect, it, onTestFinished, vi } from "vitest"; import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js"; @@ -481,6 +481,10 @@ describe.skipIf(!support.supported && !process.env.PAPERCLIP_TEST_DATABASE_URL)( { hold: "finished turn", reasonCode: "conversation_turn_finished", replies: true }, { hold: "own question card", reasonCode: "governed_response_waiting", replies: false }, { hold: "execution stage and own question card", reasonCode: "governed_response_waiting", replies: false }, + // A card the board addressed to someone else holds the task, but this run + // did not create it, so it cannot be the turn's response. + { hold: "someone else's confirmation card", reasonCode: "governed_response_waiting", replies: true }, + { hold: "earlier turn's question card", reasonCode: "governed_response_waiting", replies: true }, ] as const)("posts a waiting conversation turn's accepted answer: $hold", async ({ hold, reasonCode, replies }) => { const f = await fixture({ runtimeMode: "native" }, `CNV${randomUUID().slice(0, 6)}`); const [issue] = await db.insert(issues).values({ companyId: f.run.companyId, title: "Chat with CEO", @@ -501,6 +505,22 @@ describe.skipIf(!support.supported && !process.env.PAPERCLIP_TEST_DATABASE_URL)( selectionMode: "single", required: true, options: [{ id: "flash", label: "Flash" }] }] } }, { agentId: f.agent.id, runId: f.run.id }); } + if (hold === "someone else's confirmation card") { + // Saved as the board's create route saves it: no source run. + await db.insert(issueThreadInteractions).values({ companyId: f.run.companyId, issueId: issue.id, + kind: "request_confirmation", status: "pending", continuationPolicy: "none", title: "Riley's website proposal", + requestedResolverPolicy: "human_only", effectiveResolverPolicy: "human_only", resolverPolicyProvenance: "explicit", + addresseeUserId: "riley", createdByUserId: "local-board", + payload: { version: 1, prompt: "Riley: approve creating the website implementation task?" } }); + } + if (hold === "earlier turn's question card") { + const [earlier] = await db.insert(heartbeatRuns).values({ companyId: f.run.companyId, agentId: f.agent.id, + status: "succeeded", runtimeMode: "native", responsibleUserId: "local-board" }).returning(); + await issueThreadInteractionService(db).create(issue, { kind: "request_confirmation", sourceRunId: earlier.id, + resolverPolicy: "human_only", continuationPolicy: "wake_assignee", + payload: { version: 1, prompt: "Create the website implementation task?" } }, + { agentId: f.agent.id, runId: earlier.id }); + } const answer = "Resubmitted the hire with the cheaper model. [Review the pending approval](/CNV/approvals/a1)."; f.input.issueId = issue.id; f.input.issueRef = issue; @@ -528,6 +548,9 @@ describe.skipIf(!support.supported && !process.env.PAPERCLIP_TEST_DATABASE_URL)( expect((await read(f.run.id)).resultJson).toMatchObject({ presentationDecision: replies ? { chosenSource: "final_agent_message", commentAction: "create", commentId: posted[0]?.id } : { chosenSource: "none", commentAction: "none" } }); + // The posted answer (or the run's own card) is the turn's response: the + // conversation waits for the person. + expect((await db.select().from(issues).where(eq(issues.id, issue.id)))[0].conversationState).toBe("waiting"); }, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS); it("settles the accepted native exact rate-card price without replacing it with zero", async () => { diff --git a/server/src/services/native-runtime/agent-chat-response-recovery.test.ts b/server/src/services/native-runtime/agent-chat-response-recovery.test.ts index 4d048122992..2b569acced4 100644 --- a/server/src/services/native-runtime/agent-chat-response-recovery.test.ts +++ b/server/src/services/native-runtime/agent-chat-response-recovery.test.ts @@ -716,7 +716,8 @@ describe.skipIf(!support.supported)("committed Agent Chat reply recovery", () => // person. A restart between the status commit and the live presentation // must not lose that answer. The run is saved exactly as the previous // release's finalizer saves it, with no presentation recorded yet. - type Hold = "pending approval" | "approval sent back for revision" | "execution stage" | "own question card"; + type Hold = "pending approval" | "approval sent back for revision" | "execution stage" | "own question card" + | "someone else's confirmation card"; async function heldTurn(hold: Hold, disposition: "yielded" | "done" = "yielded") { return fixture(true, { disposition, beforeFinalize: async ({ companyId, issueId, runId, agentId }) => { if (hold.includes("approval")) { @@ -730,11 +731,18 @@ describe.skipIf(!support.supported)("committed Agent Chat reply recovery", () => sourceRunId: runId, createdByAgentId: agentId, payload: { version: 1, questions: [{ id: "model", prompt: "Which model?", selectionMode: "single", required: true, options: [{ id: "flash", label: "Flash" }] }] } }); } + // The board addressed this card to another person; the run did not create it. + if (hold === "someone else's confirmation card") { + await db.insert(issueThreadInteractions).values({ companyId, issueId, kind: "request_confirmation", status: "pending", + continuationPolicy: "none", title: "Riley's website proposal", requestedResolverPolicy: "human_only", + effectiveResolverPolicy: "human_only", resolverPolicyProvenance: "explicit", addresseeUserId: "riley", + createdByUserId: "local-board", payload: { version: 1, prompt: "Riley: approve creating the website implementation task?" } }); + } } }); } it.each([ ["pending approval", "yielded"], ["approval sent back for revision", "yielded"], ["execution stage", "yielded"], - ["pending approval", "done"], + ["pending approval", "done"], ["someone else's confirmation card", "yielded"], ] as const)( "recovers a held conversation turn's answer exactly once after a restart: %s (%s)", async (hold, disposition) => { const f = await heldTurn(hold, disposition); @@ -749,6 +757,7 @@ describe.skipIf(!support.supported)("committed Agent Chat reply recovery", () => expect(await comments(f.runId)).toEqual([expect.objectContaining({ body: f.reply, authorAgentId: f.agentId, createdByRunId: f.runId })]); expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId)))[0].resultJson).toMatchObject({ presentationDecision: { chosenSource: "final_agent_message", commentAction: "create", commentId: expect.any(String) } }); + expect(await issue(f.issueId)).toMatchObject({ conversationState: "waiting" }); expect(await work.load(f.input)).toEqual([]); await expect(recoverCommittedNativeChatResponse(db, f.input)).resolves.toBe(false); expect(await comments(f.runId)).toHaveLength(1); diff --git a/server/src/services/native-runtime/native-governance.ts b/server/src/services/native-runtime/native-governance.ts index 9be92563bf6..5009a2730ec 100644 --- a/server/src/services/native-runtime/native-governance.ts +++ b/server/src/services/native-runtime/native-governance.ts @@ -31,6 +31,8 @@ async function pendingNativeInteraction(input: { issueId: string; runId: string; ignoredInteractionIds?: string[]; + /** Only a card this run created; any other card can hold the task but not present the turn. */ + createdByRun?: boolean; }): Promise<{ id: string } | null> { const [issue] = await input.db.select({ conversationAgentId: issues.conversationAgentId, conversationUserId: issues.conversationUserId }) .from(issues).where(and(eq(issues.id, input.issueId), eq(issues.companyId, input.companyId))); @@ -43,6 +45,7 @@ async function pendingNativeInteraction(input: { eq(issueThreadInteractions.issueId, input.issueId), eq(issueThreadInteractions.status, "pending"), ...(input.ignoredInteractionIds?.length ? [notInArray(issueThreadInteractions.id, input.ignoredInteractionIds)] : []), + ...(input.createdByRun ? [eq(issueThreadInteractions.sourceRunId, input.runId)] : []), activeIssueInteractionCondition({ runId: input.runId, conversationMode: isConversation(issue) }), ), ) @@ -89,9 +92,10 @@ export async function pendingNativeGovernance(input: { /** * A committed conversation turn has answered the person, whatever status its - * task keeps. An approval or an execution stage can hold the task, but neither - * presents that answer in the conversation. Only a pending thread interaction - * (a question or confirmation card) is the turn's visible response. + * task keeps. An approval, an execution stage or a card the run did not create + * (an earlier turn's, or one the board addressed to someone else) can hold the + * task, but none presents that answer in the conversation. Only a pending + * question or confirmation card this run created is the turn's visible response. */ export async function conversationTurnAnswered(input: { db: Db; @@ -101,7 +105,7 @@ export async function conversationTurnAnswered(input: { }): Promise { // Check cards on their own: an execution stage outranks a card as the // status gate, but it does not present the turn. - return !(await pendingNativeInteraction(input)); + return !(await pendingNativeInteraction({ ...input, createdByRun: true })); } /** From df4562ab381f8bea5f216fb42a12794ed378a457 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 9 Nov 2026 03:31:35 -0600 Subject: [PATCH 19/21] fix(native): an inherited checkpoint with a pending runtime request is evidence (#20226) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - #20153 and #20214 let restart recovery set aside a remote follow-up's admission-rebound checkpoint when the checkpoint is blank and nothing of the run reached a runner. Recovery then restarts the bootstrap instead of blocking > - "Blank" checked the cursor, terminal turns, active turn, semantic result and terminal, but not pending runtime requests > - A rebound checkpoint that still carried an outstanding runtime request would therefore be treated as no evidence. The run would restart as a fresh bootstrap and drop that request > - This pull request also requires no pending runtime requests before the checkpoint counts as a blank admission checkpoint > - The benefit is that an outstanding request always keeps the run on its reconciling path ## Linked Issues or Issue Description Refs #20214 **What happened?** After #20214 merged, ponytail review noted that `admissionCheckpointOnly` (shared by #20153's `remoteRunnerNeverReceivedCommands` and #20214's `inheritedCheckpointOnly`) did not inspect `pendingRuntimeRequests`. A rebound checkpoint with a pending request would be demoted to "no evidence", and the run restarted as `remote_execution_never_claimed`, dropping the request. **Expected behavior** A checkpoint with any pending runtime request is evidence. The run is not restarted as a fresh bootstrap. **Steps to reproduce** 1. Claim restart recovery for an unclaimed remote follow-up whose rebound checkpoint carries a pending runtime request and no other state. 2. Before this change, recovery claims `bootstrap_incomplete`. **Paperclip version or commit** Current master after #20214. ## What Changed - `native-restart-recovery.ts`: `admissionCheckpointOnly` additionally requires `pendingRuntimeRequests` to be absent or empty. - `native-runner-restart-recovery.integration.test.ts`: a held case, an unclaimed remote follow-up whose inherited checkpoint carries a pending runtime request. ## Verification - Red/green: on master the held case is restarted as `bootstrap_incomplete`. With the change it is not, and the other cases are unchanged. - `vitest run` of `native-runner-restart-recovery.integration.test.ts` and `native-restart-recovery.test.ts`: 2 files, 201 tests passed (14 skipped). - `PAPERCLIP_TYPECHECK_LOW_MEMORY=1 pnpm typecheck` (`server`): passed. ## Risks - Minimal. The change only narrows when a checkpoint counts as blank. Every run it affects keeps the existing, more conservative classification. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), via Claude Code with tool use (shell, file edits, test execution). ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- .../services/native-runtime/native-restart-recovery.ts | 4 +++- .../native-runner-restart-recovery.integration.test.ts | 9 +++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/server/src/services/native-runtime/native-restart-recovery.ts b/server/src/services/native-runtime/native-restart-recovery.ts index b9d19c33f7d..657c2a96b8a 100644 --- a/server/src/services/native-runtime/native-restart-recovery.ts +++ b/server/src/services/native-runtime/native-restart-recovery.ts @@ -1327,7 +1327,9 @@ export async function claimNativeRestartRecoveries(input: { const admissionCheckpointOnly = !hasCheckpoint || (checkpointIdentityMatches && checkpointRecord.cursor == null && (!Array.isArray(checkpointRecord.terminalTurns) || checkpointRecord.terminalTurns.length === 0) - && checkpointRecord.activeTurnId == null && checkpointRecord.semanticResult == null && checkpointRecord.terminal == null); + && checkpointRecord.activeTurnId == null && checkpointRecord.semanticResult == null && checkpointRecord.terminal == null + && (checkpointRecord.pendingRuntimeRequests == null + || (Array.isArray(checkpointRecord.pendingRuntimeRequests) && checkpointRecord.pendingRuntimeRequests.length === 0))); const inheritedCheckpointOnly = hasCheckpoint && admissionCheckpointOnly && remoteSandbox && !pluginTask && !durableAuthority && stagedAttachment === null && providerEvents.length === 0; const inheritedRunnerIdentity = inheritedCheckpointOnly && Boolean(row.run.processPid || row.run.processGroupId) diff --git a/server/src/services/native-runtime/native-runner-restart-recovery.integration.test.ts b/server/src/services/native-runtime/native-runner-restart-recovery.integration.test.ts index 866f51003e3..a56d5456ae1 100644 --- a/server/src/services/native-runtime/native-runner-restart-recovery.integration.test.ts +++ b/server/src/services/native-runtime/native-runner-restart-recovery.integration.test.ts @@ -2762,8 +2762,9 @@ process.on("SIGTERM", () => child.kill("SIGTERM")); { label: "restarts with its predecessor's runner identity", providerEvent: false, process: "inherited" }, { label: "keeps its own runner identity held instead of restarting", providerEvent: false, process: "own" }, { label: "keeps a projected provider event held instead of restarting", providerEvent: true, process: "none" }, - ])("$label an unclaimed remote follow-up that carries only its inherited checkpoint", async ({ providerEvent, process }) => { - const fixture = await seedUnlaunchedRemoteClaim(`UNCLAIMED-INHERITED-${providerEvent}-${process}`); + { label: "keeps a pending runtime request held instead of restarting", providerEvent: false, process: "none", pendingRequest: true }, + ])("$label an unclaimed remote follow-up that carries only its inherited checkpoint", async ({ providerEvent, process, pendingRequest }) => { + const fixture = await seedUnlaunchedRemoteClaim(`UNCLAIMED-INHERITED-${providerEvent}-${process}-${Boolean(pendingRequest)}`); if (process !== "none") { const processStartedAt = new Date(Date.now() - 120_000); await fixture.db.update(heartbeatRuns).set({ processPid: 596, processGroupId: null, processStartedAt, processHostHash: "remote" }) @@ -2785,13 +2786,13 @@ process.on("SIGTERM", () => child.kill("SIGTERM")); driverKind: "codex_app_server", backendKind: "runner", sessionId: "inherited-thread", cursor: null, identity: { companyId, issueId: fixture.issueId, runId: fixture.runId, agentId, sessionId: fixture.native.normalizedSessionId }, providerSessionId: "inherited-thread", activeTurnId: null, terminal: null, terminalTurns: [], semanticResult: null, - pendingRuntimeRequests: [], lineage: [], + pendingRuntimeRequests: pendingRequest ? [{ requestId: "input-1", kind: "user_input" }] : [], lineage: [], } } }).where(eq(heartbeatRuns.id, fixture.runId)); if (providerEvent) await fixture.db.insert(heartbeatRunEvents).values({ companyId, runId: fixture.runId, agentId, seq: 901, stream: "system", level: "info", eventType: "session.started" }); const [claim] = await claimNativeRestartRecoveries({ db: fixture.db, controller: successor, restartKind: "hard", runIds: [fixture.runId] }); - if (providerEvent || process === "own") { + if (providerEvent || process === "own" || pendingRequest) { expect(claim).not.toMatchObject({ kind: "bootstrap_incomplete" }); return; } From d7d0f96f3f500a0e7a2028a323c42ace2aa2fa77 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 9 Nov 2026 03:43:27 -0600 Subject: [PATCH 20/21] perf(runner): save provider state once per poll instead of once per frame (#20217) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Native OpenCode, Codex and Cursor Cloud chat turns run through runnerd. runnerd reads provider frames in order and keeps the run's provider state in a durable file > - For every frame that it reads, runnerd rewrote the whole provider state file and called `fsync` on the file and on its directory. The state holds every tool result kept in the turn, so each save got more expensive as the turn went on > - A tool call request is just the next frame. It waited behind one full save for every frame before it. #20209 measured tool-call turnaround that grew from about 1.4 s to about 15 s in one turn > - #20209 stopped the OpenCode proxy from sending frames that runnerd discards. Every frame that runnerd still reads still paid a full save, and other proxies (for example Cursor Cloud) still send such frames > - This pull request makes runnerd save the frames of one poll once, before anything derived from them leaves runnerd. A save that would write the bytes already on disk is skipped > - The benefit is that the number of saves before a tool call no longer grows with the number of streamed frames. In a local measurement, a tool call after 400 frames with 830 KB of kept state took 0.57 s instead of more than 30 s ## Linked Issues or Issue Description Refs #20209 Related: #17514 (the same per-event full-state rewrite, on the server control-plane side) **What happened** During a native OpenCode chat turn, tool-call turnaround grew from about 1.4 s to about 15 s as the turn went on. runnerd saved its complete durable provider state, with `fsync`, after every provider frame. That state includes the tool results kept so far in the turn. A tool call request waited behind one full save for each earlier frame. **Expected behavior** The time to deliver a tool call must not grow with the number of assistant-text, reasoning or item frames that came before it. A crash or restart must still never lose state that a resumed run needs. **Steps to reproduce** 1. Start a native OpenCode (or Codex app-server or Cursor Cloud) turn that keeps a few tool results and then streams many text or reasoning frames before its next tool call. 2. Measure the time from the tool call frame to the `semantic_tool.input` event. 3. See that the time grows with the number of earlier frames and with the size of the kept state. Locally, `cargo test -p paperclip-runner-core --lib measure_streamed_frame -- --ignored --nocapture` on `master` reports 102 saves and 9.3 s for 100 frames with 830 KB of kept state, and more than 30 s for 400 frames. ## What Changed - `packages/paperclip-runner/runner/crates/runner-core/src/provider_backend.rs` and `codex_provider.rs`: - The provider frame loop moves into `poll_provider_frames`. An ordinary notification frame and a descendant notification frame no longer save the state. They mark it as unsaved. - After the loop, `poll_current_provider_with_control` saves the unsaved state once. It also saves when a frame fails, before it returns the error. - Frames that already saved at once still do so: turn terminals, Cursor Cloud terminal deliveries, `reconciliation_required`, protocol failures, MCP permission and runtime requests, process exit, and new tool calls. - A tool call that runnerd answers at once (an exact completed replay or the receipt-limit stop) saves unsaved state before it writes to the provider. A rejected tool call already saved first. - `retained_events`, `execute` and `host_park_ready` save unsaved state first, so no event, command result or park decision can expose state that is not on disk. - Courtesy rejections that `CodexProvider::poll` writes by itself (unsupported requests, descendant requests, delayed requests after a terminal, full runtime-request capacity) are held while runnerd reads a batch (`hold_courtesy_responses`). runnerd sends them only after it saves the state that covers every earlier frame (`save_before_provider_writes`). A terminal frame also saves and releases them before runnerd asks the provider for goal state or drains the accounting tail. Released courtesy replies are best-effort, as courtesy replies were before: a provider that already closed stdin cannot turn a received terminal into a polling failure. The receipt-limit stop sends held cancellations before its `turn/interrupt` request, so their order to the provider does not change. - The trace records for runnerd's normalization (`rust_durable_normalization`), which name emitted event ids, are kept until the state that holds those ids is saved. The trace therefore only names saved events. Saved mappings are written before an exited or failed provider is dropped, so they are not lost. Raw frame records still go to the trace when a frame is read; they name no event ids. - `save_unsaved_frame_state` does not write when the serialized state is byte-for-byte the same as the saved file. All other saves write as before. - Serialization moves to `serialize_provider_state`, and the file write moves to `write_state_bytes`. The write path (private temporary file, `fsync`, rename, directory `fsync`, `0600`) does not change. - New tests in `packages/paperclip-runner/runner/crates/runner-core/src/tests/provider_frame_save_batching.rs`: - `streamed_frames_do_not_multiply_durable_saves_before_a_tool_call`: 8 and 48 streamed frame pairs before a tool call need the same number of saves (1). On `master` they need 18 and 98. - `batched_frame_state_is_saved_before_events_leave_and_survives_a_restart`: at every poll, the saved file is equal to the in-memory state and holds exactly the delivered events. A new executor loads the file after the old one stops and gets the same state, the same unacknowledged events and the pending tool call. - `batched_frame_state_is_saved_when_a_later_frame_fails`: a frame that fails after 20 batched frames still leaves those 20 frames on disk. - `previous_release_state_is_rewritten_by_the_first_batched_save`: a state file in an older shape (compact JSON without newer default fields) loads, and the first batched save rewrites it. Unchanged state is not rewritten. Changed state is. - `courtesy_rejections_reach_the_provider_only_after_earlier_frames_are_saved`: when the courtesy rejection reaches the provider, the saved file (what a restarted runnerd would load) already holds the 20 earlier frames and equals the in-memory state. It fails if the hold is removed. - `trace_interpretations_name_only_saved_events`: every event id that a trace record names is in saved state at the moment the record is written. It fails if records are written before the save. - `a_crash_inside_an_unsaved_batch_leaves_saved_state_and_provider_output_unchanged`: after 122 frames are read and not yet saved, the saved file is byte-for-byte unchanged and runnerd has written nothing to the provider. A reload gets the pre-batch event sequence and no pending events. - `a_held_cancellation_that_cannot_reach_the_provider_keeps_the_completed_turn`: the provider closes stdin and then completes the turn with a tool call still pending. The held cancellation cannot be written, and the turn still completes and is saved. - `trace_interpretations_survive_a_provider_exit_in_the_same_poll`: the trace keeps the mappings for frames that arrived in the same poll as the provider exit. - `measure_streamed_frame_cost_before_a_tool_call` (ignored): prints saves and tool-call latency for different frame counts and kept-state sizes. ## Verification - `cargo test --locked -p paperclip-runner-core` (debug, macOS arm64): library 595 passed, 1 failed; all 35 integration test binaries passed (`--no-fail-fast`). The one library failure is `task_memory::tests::monitor_bounds_notices`. It also fails when run alone, takes about 9 minutes, and waits on file-watch timing in `task_memory.rs`, which this PR does not touch. In one earlier run, two ACPX park tests in `acpx_park_preparation` failed. The same binary also failed on unchanged `master` (with a different test), and it passed in the final run. - `cargo test -p paperclip-runner-core --lib measure_streamed_frame -- --ignored --nocapture`, debug build, macOS APFS (`sync_all` is `F_FULLFSYNC`). Each row is one turn: frames streamed, then one tool call. | Kept state | Frames | Saves (master) | Tool-call latency (master) | Saves (this PR) | Tool-call latency (this PR) | | --- | --- | --- | --- | --- | --- | | 4 KB | 100 | 102 | 946 ms | 1 | 18 ms | | 4 KB | 400 | 405 | 3,791 ms | 7 | 70 ms | | 211 KB | 100 | 102 | 3,207 ms | 1 | 37 ms | | 211 KB | 400 | 405 | 12,803 ms | 7 | 193 ms | | 833 KB | 100 | 102 | 9,251 ms | 1 | 93 ms | | 833 KB | 400 | about 405 | more than 30 s (test deadline) | 7 | 567 ms | With 400 frames, the remaining saves are one per poll of up to 128 frames and one per controller acknowledgement. - With the debug `paperclip-runnerd` and `fake-codex-app-server` binaries from this branch: `npx vitest run` on `src/live/runnerd-opencode-streaming-tools`, `runnerd-opencode-assigned-tools`, `runnerd-codex-transport`, `runnerd-codex-transport-settlement`, `runnerd-codex-transport-cold-restore`, `runnerd-restart-continuation`, `runnerd-saved-continuation`, `runnerd-detach-durability`, and `src/cli/opencode-proxy-output`, `opencode-proxy-events`, `opencode-app-server-proxy`: 11 files, 353 tests passed. ## Risks - Durability. The rule is: nothing derived from a frame leaves runnerd before the state that holds that frame is on disk. That covers delivered events, command results, park decisions, tool results and rejections, courtesy rejections, Cursor Cloud terminal acknowledgements, and trace records that name event ids. The tests above check each of these. - The unsaved window is at most one poll (128 frames). Every frame in it is undelivered: no event from it reached the controller, and runnerd wrote nothing to the provider while it was unsaved. `a_crash_inside_an_unsaved_batch_leaves_saved_state_and_provider_output_unchanged` proves this. A crash in the window therefore leaves the saved state and everything the controller and provider have seen exactly as a crash just before those frames were read. The resumed run starts from the same event sequence, so it cannot deliver an event id twice. Its normal recovery (thread resume and `thread/read` reconciliation) handles those frames in the same way as frames still in the provider pipe. On `master`, a crash could already lose the one frame being processed. - One provider write can still happen in the window: `thread/read`, which `CodexProvider::poll` sends to check the lineage of an unknown helper thread. It is a read-only query that waits for its answer, and it changes nothing on the provider side. - Courtesy rejections now reach the provider at the end of the poll instead of during it. The delay is one batch. The provider gets the same replies. - Previous-release story: the saved file shape does not change, and no new field is saved. A file from the previous release loads as before. The skip check compares exact bytes, so an older file is never treated as already saved. The first save after an upgrade rewrites it in the current shape. A deploy that lands mid-turn sees a file that the previous release saved after each frame. That file is valid input for this code. - The ACPX backend has its own frame loop and is not changed here. ## Model Used - Anthropic Claude Opus 5.5 (`claude-opus-5-5`), used through Claude Code with tool use (shell, file editing) and extended thinking. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- .../paperclip-runner/runner/CODEX_PROVIDER.md | 5 +- .../crates/runner-core/src/codex_provider.rs | 45 ++ .../runner-core/src/provider_backend.rs | 190 ++++-- .../src/tests/provider_frame_save_batching.rs | 579 ++++++++++++++++++ 4 files changed, 777 insertions(+), 42 deletions(-) create mode 100644 packages/paperclip-runner/runner/crates/runner-core/src/tests/provider_frame_save_batching.rs diff --git a/packages/paperclip-runner/runner/CODEX_PROVIDER.md b/packages/paperclip-runner/runner/CODEX_PROVIDER.md index 7a100aa7f32..38c36126c29 100644 --- a/packages/paperclip-runner/runner/CODEX_PROVIDER.md +++ b/packages/paperclip-runner/runner/CODEX_PROVIDER.md @@ -29,7 +29,10 @@ execution and finalization path. The provider descriptor, Codex thread ID, account session ID, active Codex turn ID, and unacknowledged normalized-event prefix are written to a private, -bounded, atomically replaced sidecar in the runner state directory. On restart, +bounded, atomically replaced sidecar in the runner state directory. runnerd +writes the sidecar once for the provider frames read in one poll, not once per +frame. The write always comes before an event, a command result, or a reply to +the provider that depends on those frames leaves runnerd. On restart, runnerd resumes that exact thread and reconciles the active turn from `thread/read`. An unexpected provider exit retains the last active turn until that reconciliation proves whether it is still running, so cancellation and a diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs b/packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs index 033e4fb8dfe..e67c96c0f2f 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs @@ -869,6 +869,11 @@ pub struct CodexProvider { quarantined: bool, trace: Option, last_trace_frame_id: Option, + held_courtesy_responses: Option>, + #[cfg(test)] + pub(crate) sent_frame_observer: Option>, + #[cfg(test)] + pub(crate) interpretation_observer: Option>, opencode_launch_profile: Option, cursor_cloud_launch_profile: Option, provider_termination_receipt: Option, @@ -1300,6 +1305,11 @@ impl CodexProvider { quarantined: false, trace: ProviderTraceSink::from_environment(), last_trace_frame_id: None, + held_courtesy_responses: None, + #[cfg(test)] + sent_frame_observer: None, + #[cfg(test)] + interpretation_observer: None, opencode_launch_profile: opencode_launch_profile.cloned(), cursor_cloud_launch_profile: cursor_cloud_launch_profile.cloned(), provider_termination_receipt: None, @@ -1467,6 +1477,11 @@ impl CodexProvider { self.last_trace_frame_id.take() } + #[cfg(test)] + pub(crate) fn enable_trace_for_test(&mut self, trace_path: PathBuf) { + self.trace = Some(ProviderTraceSink::at_path(trace_path, 64 * 1024 * 1024)); + } + pub(crate) fn record_provider_trace_interpretation( &mut self, frame_id: u64, @@ -1475,6 +1490,10 @@ impl CodexProvider { emitted_event_ids: Vec, reason: &str, ) { + #[cfg(test)] + if let Some(observer) = self.interpretation_observer.as_mut() { + observer(&emitted_event_ids); + } if let Some(trace) = self.trace.as_mut() { trace.interpretation( frame_id, @@ -3831,10 +3850,36 @@ impl CodexProvider { { return Ok(()); } + if let Some(held) = self.held_courtesy_responses.as_mut() { + held.push(value.clone()); + return Ok(()); + } self.send_frame(value) } + pub(crate) fn hold_courtesy_responses(&mut self) { + self.held_courtesy_responses.get_or_insert_with(Vec::new); + } + + pub(crate) fn release_courtesy_responses(&mut self, keep_holding: bool) { + let held = if keep_holding { + self.held_courtesy_responses + .as_mut() + .map(std::mem::take) + .unwrap_or_default() + } else { + self.held_courtesy_responses.take().unwrap_or_default() + }; + for value in held { + let _ = self.send_frame(&value); + } + } + fn send_frame(&mut self, value: &Value) -> Result<(), LocalRunnerError> { + #[cfg(test)] + if let Some(observer) = self.sent_frame_observer.as_mut() { + observer(value); + } if let Some(trace) = self.trace.as_mut() { let raw = serde_json::to_vec(value).unwrap_or_default(); if let Some(frame_id) = trace.frame("client_to_provider", &raw) { diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/src/provider_backend.rs index af7e43def90..fd0ea148c9b 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/provider_backend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/provider_backend.rs @@ -79,6 +79,18 @@ fn invalid_tool_call_result( } } +fn serialize_provider_state(state: &CodexProviderState) -> Result, DurableRunnerError> { + let bytes = serde_json::to_vec_pretty(state).map_err(|error| { + DurableRunnerError::invalid(format!("failed to serialize Codex provider state: {error}")) + })?; + if bytes.len() as u64 > MAX_PROVIDER_STATE_BYTES { + return Err(DurableRunnerError::invalid( + "Codex provider state exceeds the 16 MiB limit", + )); + } + Ok(bytes) +} + fn receipt_limit_deadline_after(timeout_ms: u64) -> Result { current_unix_ms()?.checked_add(timeout_ms).ok_or_else(|| { DurableRunnerError::invalid("Codex receipt-limit interruption deadline overflowed") @@ -1973,6 +1985,13 @@ impl CodexProviderState { } } +struct NormalizationTrace { + frame_id: u64, + rule_id: String, + mapped: bool, + emitted_event_ids: Vec, +} + pub struct CodexCommandExecutor { state_dir: PathBuf, state: Option, @@ -1985,6 +2004,10 @@ pub struct CodexCommandExecutor { startup_command: Option, startup_evidence_error: Option, recovery_pending_effects: Option, + unsaved_frame_state: std::cell::Cell, + unsaved_trace_interpretations: Vec, + #[cfg(test)] + persist_count: std::cell::Cell, } impl CodexCommandExecutor { @@ -2001,6 +2024,10 @@ impl CodexCommandExecutor { startup_command: None, startup_evidence_error: None, recovery_pending_effects: None, + unsaved_frame_state: std::cell::Cell::new(false), + unsaved_trace_interpretations: Vec::new(), + #[cfg(test)] + persist_count: std::cell::Cell::new(0), } } @@ -2960,11 +2987,69 @@ impl CodexCommandExecutor { .state .as_ref() .ok_or_else(|| DurableRunnerError::invalid("Codex provider state is unavailable"))?; - self.persist_state(state) + self.persist_state(state)?; + self.unsaved_frame_state.set(false); + Ok(()) + } + + fn save_unsaved_frame_state(&self) -> Result<(), DurableRunnerError> { + if !self.unsaved_frame_state.get() { + return Ok(()); + } + let state = self + .state + .as_ref() + .ok_or_else(|| DurableRunnerError::invalid("Codex provider state is unavailable"))?; + let bytes = serialize_provider_state(state)?; + if fs::read(self.state_path()).is_ok_and(|saved| saved == bytes) { + self.unsaved_frame_state.set(false); + return Ok(()); + } + state.validate()?; + self.write_state_bytes(&bytes)?; + self.unsaved_frame_state.set(false); + Ok(()) + } + + fn record_saved_trace_interpretations(&mut self) { + let traces = std::mem::take(&mut self.unsaved_trace_interpretations); + let Some(provider) = self.provider.as_mut() else { + return; + }; + for trace in traces { + provider.record_provider_trace_interpretation( + trace.frame_id, + &trace.rule_id, + if trace.mapped { "mapped" } else { "ignored" }, + trace.emitted_event_ids, + if trace.mapped { + "Provider notification normalized into durable PRP events" + } else { + "Provider notification did not produce a durable PRP event" + }, + ); + } + } + + fn save_before_provider_writes( + &mut self, + still_polling: bool, + ) -> Result<(), DurableRunnerError> { + self.save_unsaved_frame_state()?; + self.record_saved_trace_interpretations(); + if let Some(provider) = self.provider.as_mut() { + provider.release_courtesy_responses(still_polling); + } + Ok(()) } fn persist_state(&self, state: &CodexProviderState) -> Result<(), DurableRunnerError> { state.validate()?; + let bytes = serialize_provider_state(state)?; + self.write_state_bytes(&bytes) + } + + fn write_state_bytes(&self, bytes: &[u8]) -> Result<(), DurableRunnerError> { fs::create_dir_all(&self.state_dir).map_err(|error| { DurableRunnerError::invalid(format!( "failed to create provider state directory: {error}" @@ -2980,19 +3065,9 @@ impl CodexCommandExecutor { )?; verify_private_directory(&self.state_dir)?; let path = self.state_path(); - let bytes = serde_json::to_vec_pretty(state).map_err(|error| { - DurableRunnerError::invalid(format!( - "failed to serialize Codex provider state: {error}" - )) - })?; - if bytes.len() as u64 > MAX_PROVIDER_STATE_BYTES { - return Err(DurableRunnerError::invalid( - "Codex provider state exceeds the 16 MiB limit", - )); - } let (temporary, mut file) = create_private_temporary_file(&path)?; let result = (|| -> std::io::Result<()> { - file.write_all(&bytes)?; + file.write_all(bytes)?; file.sync_all()?; drop(file); fs::rename(&temporary, &path)?; @@ -3010,6 +3085,8 @@ impl CodexCommandExecutor { fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).map_err(|error| { DurableRunnerError::invalid(format!("failed to protect provider state: {error}")) })?; + #[cfg(test)] + self.persist_count.set(self.persist_count.get() + 1); Ok(()) } @@ -4515,6 +4592,7 @@ impl CodexCommandExecutor { call_id: String, operation_id: String, ) -> Result<(), DurableRunnerError> { + self.save_before_provider_writes(true)?; let deadline_unix_ms = receipt_limit_deadline_after(RECEIPT_LIMIT_INTERRUPT_TERMINAL_DEADLINE_MS)?; let state = self @@ -4562,7 +4640,12 @@ impl CodexCommandExecutor { .expect("Codex state remains available before receipt-limit interruption") .record_receipt_limit_interrupt_attempt()?; self.save_state()?; - match self.interrupt_turn("semantic_tool_turn_receipt_limit") { + self.save_before_provider_writes(false)?; + let interrupted = self.interrupt_turn("semantic_tool_turn_receipt_limit"); + if let Some(provider) = self.provider.as_mut() { + provider.hold_courtesy_responses(); + } + match interrupted { Ok(_) => { let accepted_deadline_unix_ms = receipt_limit_deadline_after(RECEIPT_LIMIT_ACCEPTED_TERMINAL_DEADLINE_MS)?; @@ -4782,6 +4865,7 @@ impl CodexCommandExecutor { .admit_tool_call(&call_id, &operation_id, &input); match admission { Ok(ToolCallAdmission::CompletedReplay(result)) => { + self.save_before_provider_writes(true)?; self.provider .as_mut() .expect("provider remains present while handling its tool call") @@ -5113,6 +5197,25 @@ impl CodexCommandExecutor { if self.provider.is_none() { return self.settle_receipt_limit_interrupt_if_deadline_elapsed(); } + if let Some(provider) = self.provider.as_mut() { + provider.hold_courtesy_responses(); + } + let polled = self.poll_provider_frames(allow_control, receipt_limit_terminal_poll); + let saved = self.save_before_provider_writes(false); + polled?; + saved?; + // Check the durable deadline only after a complete provider poll. A + // terminal that arrived after interruption acceptance but before this + // observation remains authoritative even when several fast controller + // polls have already exhausted the interruption-attempt budget. + self.settle_receipt_limit_interrupt_if_deadline_elapsed() + } + + fn poll_provider_frames( + &mut self, + allow_control: bool, + receipt_limit_terminal_poll: bool, + ) -> Result<(), DurableRunnerError> { for _ in 0..MAX_EVENTS_PER_POLL { let provider = self .provider @@ -5175,6 +5278,7 @@ impl CodexCommandExecutor { ))?; // Commit the authoritative failure before best-effort provider cleanup. self.save_state()?; + self.record_saved_trace_interpretations(); if let Some(mut provider) = self.provider.take() { if let Some(frame_id) = trace_frame_id { provider.record_provider_trace_interpretation( @@ -5225,7 +5329,7 @@ impl CodexCommandExecutor { "method": method.chars().take(128).collect::(), "receivedThreadId": child, "expectedThreadId": root_thread, "receivedTurnId": child_turn, "expectedTurnId": root_turn }), }])?; - self.save_state()?; + self.unsaved_frame_state.set(true); if let (Some(frame_id), Some(provider)) = (trace_frame_id, self.provider.as_mut()) { @@ -5245,6 +5349,9 @@ impl CodexCommandExecutor { }; let normalized_terminal_type = normalized_codex_terminal_event_type(&method, ¶ms); + if normalized_terminal_type.is_some() { + self.save_before_provider_writes(true)?; + } let cursor_cloud_delivery = if self .state .as_ref() @@ -5486,12 +5593,16 @@ impl CodexCommandExecutor { .collect::>() }) .unwrap_or_default(); - self.save_state()?; - if self - .state - .as_ref() - .is_some_and(|state| state.lifecycle == "reconciliation_required") + let reconciliation_required = state.lifecycle == "reconciliation_required"; + if terminal_event_type.is_some() + || cursor_cloud_delivery.is_some() + || reconciliation_required { + self.save_state()?; + } else { + self.unsaved_frame_state.set(true); + } + if reconciliation_required { break; } if let (Some(delivery), Some(provider)) = @@ -5499,24 +5610,16 @@ impl CodexCommandExecutor { { let _ = provider.acknowledge_cursor_cloud_terminal(delivery); } - if let (Some(frame_id), Some(provider)) = - (trace_frame_id, self.provider.as_mut()) - { - provider.record_provider_trace_interpretation( + if let Some(frame_id) = trace_frame_id { + self.unsaved_trace_interpretations.push(NormalizationTrace { frame_id, - &format!("codex.normalize.{}", method.replace('/', ".")), - if normalized_event_count > 0 { - "mapped" - } else { - "ignored" - }, - trace_emitted_event_ids, - if normalized_event_count > 0 { - "Provider notification normalized into durable PRP events" - } else { - "Provider notification did not produce a durable PRP event" - }, - ); + rule_id: format!("codex.normalize.{}", method.replace('/', ".")), + mapped: normalized_event_count > 0, + emitted_event_ids: trace_emitted_event_ids, + }); + } + if !self.unsaved_frame_state.get() { + self.record_saved_trace_interpretations(); } } CodexProviderEvent::McpPermissionRequest { request_id, params } => { @@ -5582,6 +5685,8 @@ impl CodexCommandExecutor { process_generation, completed_turn_process_generation, } => { + self.save_unsaved_frame_state()?; + self.record_saved_trace_interpretations(); self.provider = None; if !success { let state = self @@ -5624,16 +5729,13 @@ impl CodexCommandExecutor { } } } - // Check the durable deadline only after a complete provider poll. A - // terminal that arrived after interruption acceptance but before this - // observation remains authoritative even when several fast controller - // polls have already exhausted the interruption-attempt budget. - self.settle_receipt_limit_interrupt_if_deadline_elapsed() + Ok(()) } } impl CommandExecutor for CodexCommandExecutor { fn host_park_ready(&mut self) -> Result { + self.save_before_provider_writes(false)?; let Some(state) = self.state.as_ref() else { return Ok(false); }; @@ -5701,6 +5803,7 @@ impl CommandExecutor for CodexCommandExecutor { command_type: command.command_type.clone(), }); let outcome = (|| { + self.save_before_provider_writes(false)?; self.restore()?; if self .state @@ -5805,6 +5908,7 @@ impl CommandExecutor for CodexCommandExecutor { if let Some(error) = &self.startup_evidence_error { return Err(error.clone()); } + self.save_before_provider_writes(false)?; Ok(self .state .as_ref() @@ -9312,6 +9416,10 @@ done #[path = "tests/provider_api_validation.rs"] mod api_validation_tests; +#[cfg(test)] +#[path = "tests/provider_frame_save_batching.rs"] +mod frame_save_batching_tests; + #[cfg(test)] #[path = "tests/provider_assigned_tool_validation.rs"] mod assigned_tool_validation_tests; diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/tests/provider_frame_save_batching.rs b/packages/paperclip-runner/runner/crates/runner-core/src/tests/provider_frame_save_batching.rs new file mode 100644 index 00000000000..a41aa1047ca --- /dev/null +++ b/packages/paperclip-runner/runner/crates/runner-core/src/tests/provider_frame_save_batching.rs @@ -0,0 +1,579 @@ +use super::*; + +fn frame_test_state() -> CodexProviderState { + let mut state = CodexProviderState::new( + CodexProviderConfig { + provider: "opencode".to_owned(), + driver: "opencode_server".to_owned(), + provider_version: "1.18.34".to_owned(), + command: PathBuf::from("node"), + args: Vec::new(), + cwd: std::env::current_dir() + .unwrap() + .to_string_lossy() + .into_owned(), + model: Some("openrouter/model".to_owned()), + provider_session_id: None, + instructions: String::new(), + approval_policy: "never".to_owned(), + externally_sandboxed: false, + read_only: false, + include_skill_instructions: None, + conversation_mode: None, + }, + Some(CompletionContractBinding { + revision: "revision-1".to_owned(), + criterion_ids: vec!["criterion-1".to_owned()], + }), + ProviderToolBridge::default(), + ); + state.thread_id = Some("thread-1".to_owned()); + state.active_provider_turn_id = Some("turn-1".to_owned()); + state.lifecycle = "turn_active".to_owned(); + state +} + +fn streamed_task_context_operation() -> crate::provider_bridge::AuthorizedTool { + crate::provider_bridge::AuthorizedTool { + operation_id: "get_task_context".to_owned(), + version: 1, + description: "Read the active task context.".to_owned(), + input_schema: json!({"type": "object"}), + response_schema: json!({"type": "object"}), + } +} + +#[cfg(unix)] +fn streamed_turn_before_tool_call( + frames: usize, + retained_results: usize, + tool: &str, + wait_for_stream: bool, +) -> (PathBuf, CodexCommandExecutor) { + let tail = format!( + r#"{{"id":"tool-1","method":"item/tool/call","params":{{"threadId":"thread-1","turnId":"turn-1","callId":"call-streamed","tool":"{tool}","arguments":{{}}}}}}"# + ); + streamed_turn(frames, retained_results, &tail, wait_for_stream, |_| {}) +} + +#[cfg(unix)] +fn streamed_turn( + frames: usize, + retained_results: usize, + tail: &str, + wait_for_stream: bool, + prepare_provider: impl FnOnce(&mut CodexProvider), +) -> (PathBuf, CodexCommandExecutor) { + streamed_turn_then( + frames, + retained_results, + tail, + ":", + wait_for_stream, + prepare_provider, + ) +} + +#[cfg(unix)] +fn streamed_turn_then( + frames: usize, + retained_results: usize, + tail: &str, + after: &str, + wait_for_stream: bool, + prepare_provider: impl FnOnce(&mut CodexProvider), +) -> (PathBuf, CodexCommandExecutor) { + let directory = std::env::temp_dir().join(format!( + "paperclip-streamed-frames-{}", + uuid::Uuid::new_v4() + )); + fs::create_dir_all(&directory).unwrap(); + let streamed = directory.join("streamed"); + let script = r#" +while IFS= read -r line; do + case "$line" in + *'"method":"initialize"'*) printf '%s\n' '{"id":1,"result":{}}' ;; + *'"method":"thread/start"'*) printf '%s\n' '{"id":2,"result":{"thread":{"id":"thread-1"}}}' ;; + *'"method":"turn/start"'*) + printf '%s\n' '{"id":3,"result":{"turn":{"id":"turn-1"}}}' + printf '%s\n' '{"method":"turn/started","params":{"threadId":"thread-1","turn":{"id":"turn-1","status":"inProgress"}}}' + i=0 + while [ "$i" -lt FRAMES ]; do + printf '%s\n' '{"method":"item/agentMessage/delta","params":{"threadId":"thread-1","turnId":"turn-1","itemId":"message-1","delta":"x"}}' + printf '%s\n' '{"method":"item/updated","params":{"threadId":"thread-1","turnId":"turn-1","item":{"id":"reasoning-1","type":"reasoning","summary":[],"content":[]}}}' + i=$((i+1)) + done + printf '%s\n' 'TAIL' + touch 'STREAMED' + AFTER ;; + esac +done +"# + .replace("FRAMES", &frames.to_string()) + .replace("TAIL", tail) + .replace("AFTER", after) + .replace("STREAMED", &streamed.display().to_string()); + let operation = streamed_task_context_operation(); + let mut bridge = ProviderToolBridge::default(); + bridge + .prepare(AuthorizedToolSet { + schema: TOOL_SET_SCHEMA.to_owned(), + schema_version: 1, + catalog_digest: authorized_tool_catalog_digest(std::slice::from_ref(&operation)) + .unwrap(), + operations: vec![operation.clone()], + }) + .unwrap(); + for index in 0..retained_results { + let call_id = format!("retained-{index}"); + bridge + .begin_call(call_id.clone(), "get_task_context".to_owned(), json!({})) + .unwrap(); + bridge + .apply_result(ToolResult { + call_id, + operation_id: "get_task_context".to_owned(), + result: json!({"body": "x".repeat(10_000)}), + is_error: false, + }) + .unwrap(); + } + let mut state = frame_test_state(); + state.config.provider = "codex".to_owned(); + state.config.driver = "codex_app_server".to_owned(); + state.config.command = PathBuf::from("/bin/sh"); + state.config.args = vec!["-c".to_owned(), script]; + state.config.model = None; + state.tool_bridge = bridge; + let mut provider = CodexProvider::start_with_tools(&state.config, [operation], None).unwrap(); + prepare_provider(&mut provider); + provider.start_turn("Test", &state.config.cwd).unwrap(); + state.provider_process_generation = provider.process_generation(); + let mut executor = CodexCommandExecutor::new(&directory); + executor.event_identity = Some(ProviderEventIdentity { + runner_instance_id: "runner-1".to_owned(), + run_id: "run-1".to_owned(), + normalized_session_id: "session-1".to_owned(), + turn_id: "turn-1".to_owned(), + item_id: "item-1".to_owned(), + }); + executor.state = Some(state); + executor.provider = Some(provider); + executor.restore_checked = true; + executor.save_state().unwrap(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while wait_for_stream && !streamed.exists() { + assert!(std::time::Instant::now() < deadline); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + (directory, executor) +} + +fn poll_until_semantic_tool_call(executor: &mut CodexCommandExecutor) -> Vec { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + let mut observed = Vec::new(); + loop { + assert!(std::time::Instant::now() < deadline); + let events = executor.poll_events().unwrap(); + let count = events.len(); + let delivered = events + .iter() + .any(|event| event.event_type == "semantic_tool.input"); + observed.extend(events); + if delivered { + return observed; + } + executor.acknowledge_events(count).unwrap(); + } +} + +#[cfg(unix)] +#[test] +fn streamed_frames_do_not_multiply_durable_saves_before_a_tool_call() { + let mut saves = Vec::new(); + for frames in [8, 48] { + let (directory, mut executor) = + streamed_turn_before_tool_call(frames, 4, "get_task_context", true); + let before = executor.persist_count.get(); + let observed = poll_until_semantic_tool_call(&mut executor); + saves.push(executor.persist_count.get() - before); + assert_eq!( + observed + .iter() + .filter(|event| event.event_type == "item.delta") + .count(), + frames + ); + executor.provider.as_mut().unwrap().shutdown().unwrap(); + fs::remove_dir_all(directory).unwrap(); + } + eprintln!("durable saves before the tool call: {saves:?}"); + assert_eq!( + saves[0], saves[1], + "saves grew with streamed frames: {saves:?}" + ); +} + +fn saved_provider_state(executor: &CodexCommandExecutor) -> CodexProviderState { + serde_json::from_slice(&fs::read(executor.state_path()).unwrap()).unwrap() +} + +#[cfg(unix)] +#[test] +fn batched_frame_state_is_saved_before_events_leave_and_survives_a_restart() { + let (directory, mut executor) = + streamed_turn_before_tool_call(300, 4, "get_task_context", false); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + loop { + assert!(std::time::Instant::now() < deadline); + let events = executor.poll_events().unwrap(); + assert!(!executor.unsaved_frame_state.get()); + let saved = saved_provider_state(&executor); + assert_eq!( + serde_json::to_value(&saved).unwrap(), + serde_json::to_value(executor.state.as_ref().unwrap()).unwrap() + ); + assert_eq!(saved.pending_events, events); + if events + .iter() + .any(|event| event.event_type == "semantic_tool.input") + { + break; + } + let count = events.len(); + executor.acknowledge_events(count).unwrap(); + } + let live = executor.state.clone().unwrap(); + executor.provider.as_mut().unwrap().shutdown().unwrap(); + drop(executor); + + let mut resumed = CodexCommandExecutor::new(&directory); + resumed.load_state_without_provider().unwrap(); + let resumed_state = resumed.state.as_ref().unwrap(); + assert_eq!( + serde_json::to_value(resumed_state).unwrap(), + serde_json::to_value(&live).unwrap() + ); + assert!(resumed_state + .tool_bridge + .pending_calls() + .any(|call| call.call_id == "call-streamed")); + assert_eq!( + resumed.retained_events().unwrap(), + live.pending_events.iter().cloned().collect::>() + ); + fs::remove_dir_all(directory).unwrap(); +} + +#[cfg(unix)] +#[test] +fn batched_frame_state_is_saved_when_a_later_frame_fails() { + let (directory, mut executor) = + streamed_turn_before_tool_call(20, 0, "unauthorized_tool", true); + assert!(executor.poll_events().is_err()); + assert!(!executor.unsaved_frame_state.get()); + let saved = saved_provider_state(&executor); + assert_eq!( + serde_json::to_value(&saved).unwrap(), + serde_json::to_value(executor.state.as_ref().unwrap()).unwrap() + ); + assert_eq!( + saved + .pending_events + .iter() + .filter(|event| event.event_type == "item.delta") + .count(), + 20 + ); + let _ = executor.provider.as_mut().unwrap().shutdown(); + fs::remove_dir_all(directory).unwrap(); +} + +fn previous_release_state() -> CodexProviderState { + let mut state = frame_test_state(); + state.config.provider = "codex".to_owned(); + state.config.driver = "codex_app_server".to_owned(); + state.config.model = None; + state +} + +#[test] +fn previous_release_state_is_rewritten_by_the_first_batched_save() { + let directory = std::env::temp_dir().join(format!( + "paperclip-batched-save-upgrade-{}", + uuid::Uuid::new_v4() + )); + fs::create_dir_all(&directory).unwrap(); + #[cfg(unix)] + fs::set_permissions(&directory, fs::Permissions::from_mode(0o700)).unwrap(); + let mut previous = serde_json::to_value(previous_release_state()).unwrap(); + let object = previous.as_object_mut().unwrap(); + assert!(object.remove("activeTerminalToolRejected").is_some()); + assert!(object.remove("descendantThreadIds").is_some()); + let previous_bytes = serde_json::to_vec(&previous).unwrap(); + let path = directory.join(CODEX_PROVIDER_STATE_FILE); + fs::write(&path, &previous_bytes).unwrap(); + #[cfg(unix)] + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap(); + + let mut resumed = CodexCommandExecutor::new(&directory); + resumed.load_state_without_provider().unwrap(); + assert_eq!(resumed.state.as_ref().unwrap(), &previous_release_state()); + resumed.unsaved_frame_state.set(true); + resumed.save_unsaved_frame_state().unwrap(); + assert_eq!(resumed.persist_count.get(), 1); + assert_ne!(fs::read(&path).unwrap(), previous_bytes); + assert_eq!( + serde_json::to_value(saved_provider_state(&resumed)).unwrap(), + serde_json::to_value(resumed.state.as_ref().unwrap()).unwrap() + ); + + resumed.unsaved_frame_state.set(true); + resumed.save_unsaved_frame_state().unwrap(); + assert_eq!(resumed.persist_count.get(), 1); + + resumed.state.as_mut().unwrap().last_agent_message = Some("streamed".to_owned()); + resumed.unsaved_frame_state.set(true); + resumed.save_unsaved_frame_state().unwrap(); + assert_eq!(resumed.persist_count.get(), 2); + assert_eq!( + saved_provider_state(&resumed).last_agent_message.as_deref(), + Some("streamed") + ); + fs::remove_dir_all(directory).unwrap(); +} + +#[cfg(unix)] +#[test] +#[ignore = "timing measurement; run with --ignored --nocapture"] +fn measure_streamed_frame_cost_before_a_tool_call() { + for retained in [0, 20, 80] { + for frames in [50, 200] { + let (directory, mut executor) = + streamed_turn_before_tool_call(frames, retained, "get_task_context", false); + let state_bytes = fs::metadata(executor.state_path()).unwrap().len(); + let before = executor.persist_count.get(); + let started = std::time::Instant::now(); + poll_until_semantic_tool_call(&mut executor); + let elapsed = started.elapsed(); + eprintln!( + "retained={retained} stateBytes={state_bytes} frames={} saves={} toolCallLatencyMs={:.1}", + frames * 2, + executor.persist_count.get() - before, + elapsed.as_secs_f64() * 1000.0 + ); + executor.provider.as_mut().unwrap().shutdown().unwrap(); + fs::remove_dir_all(directory).unwrap(); + } + } +} + +#[cfg(unix)] +fn event_identity_for_tests() -> ProviderEventIdentity { + ProviderEventIdentity { + runner_instance_id: "runner-1".to_owned(), + run_id: "run-1".to_owned(), + normalized_session_id: "session-1".to_owned(), + turn_id: "turn-1".to_owned(), + item_id: "item-1".to_owned(), + } +} + +#[cfg(unix)] +fn read_saved_state(path: &std::path::Path) -> CodexProviderState { + serde_json::from_slice(&fs::read(path).unwrap()).unwrap() +} + +#[cfg(unix)] +#[test] +fn courtesy_rejections_reach_the_provider_only_after_earlier_frames_are_saved() { + let directory_slot = std::sync::Arc::new(std::sync::Mutex::new(None::)); + let saved_at_rejection = std::sync::Arc::new(std::sync::Mutex::new(None::)); + let observer_directory = std::sync::Arc::clone(&directory_slot); + let observer_saved = std::sync::Arc::clone(&saved_at_rejection); + let tail = r#"{"id":"courtesy-1","method":"paperclip/test/unsupported","params":{"threadId":"thread-1","turnId":"turn-1"}}"#; + let (directory, mut executor) = streamed_turn(20, 0, tail, true, move |provider| { + provider.sent_frame_observer = Some(Box::new(move |frame: &Value| { + if frame["id"] != "courtesy-1" { + return; + } + let directory = observer_directory.lock().unwrap().clone().unwrap(); + *observer_saved.lock().unwrap() = + Some(read_saved_state(&directory.join(CODEX_PROVIDER_STATE_FILE))); + })); + }); + *directory_slot.lock().unwrap() = Some(directory.clone()); + let events = executor.poll_events().unwrap(); + assert!(events.iter().any(|event| event.event_type == "item.delta")); + + let resumed = saved_at_rejection + .lock() + .unwrap() + .take() + .expect("runnerd sent the courtesy rejection"); + assert_eq!( + resumed + .pending_events + .iter() + .filter(|event| event.event_type == "item.delta") + .count(), + 20 + ); + assert_eq!( + serde_json::to_value(&resumed).unwrap(), + serde_json::to_value(executor.state.as_ref().unwrap()).unwrap() + ); + executor.provider.as_mut().unwrap().shutdown().unwrap(); + fs::remove_dir_all(directory).unwrap(); +} + +#[cfg(unix)] +#[test] +fn trace_interpretations_name_only_saved_events() { + let directory_slot = std::sync::Arc::new(std::sync::Mutex::new(None::)); + let checked = std::sync::Arc::new(std::sync::Mutex::new((0usize, Vec::::new()))); + let observer_directory = std::sync::Arc::clone(&directory_slot); + let observer_checked = std::sync::Arc::clone(&checked); + let trace_path = std::env::temp_dir().join(format!( + "paperclip-frame-trace-{}.jsonl", + uuid::Uuid::new_v4() + )); + let provider_trace_path = trace_path.clone(); + let (directory, mut executor) = streamed_turn_before_tool_call_with(40, move |provider| { + provider.enable_trace_for_test(provider_trace_path); + provider.interpretation_observer = Some(Box::new(move |ids: &[String]| { + if ids.is_empty() { + return; + } + let directory = observer_directory.lock().unwrap().clone().unwrap(); + let saved = read_saved_state(&directory.join(CODEX_PROVIDER_STATE_FILE)); + let identity = event_identity_for_tests(); + let saved_ids = (0..saved.next_provider_event_seq) + .map(|sequence| identity.source_event_id(&provider_event_id(sequence))) + .collect::>(); + let mut checked = observer_checked.lock().unwrap(); + checked.0 += ids.len(); + checked + .1 + .extend(ids.iter().filter(|id| !saved_ids.contains(*id)).cloned()); + })); + }); + *directory_slot.lock().unwrap() = Some(directory.clone()); + poll_until_semantic_tool_call(&mut executor); + let (count, unsaved) = checked.lock().unwrap().clone(); + assert!(count >= 40, "trace named {count} events"); + assert!( + unsaved.is_empty(), + "trace named unsaved events: {unsaved:?}" + ); + executor.provider.as_mut().unwrap().shutdown().unwrap(); + fs::remove_dir_all(directory).unwrap(); + let _ = fs::remove_file(trace_path); +} + +#[cfg(unix)] +#[test] +fn a_crash_inside_an_unsaved_batch_leaves_saved_state_and_provider_output_unchanged() { + let sent = std::sync::Arc::new(std::sync::Mutex::new(Vec::::new())); + let observer_sent = std::sync::Arc::clone(&sent); + let tail = r#"{"method":"item/agentMessage/delta","params":{"threadId":"thread-1","turnId":"turn-1","itemId":"message-1","delta":"last"}}"#; + let (directory, mut executor) = streamed_turn(60, 0, tail, true, move |provider| { + provider.sent_frame_observer = Some(Box::new(move |frame: &Value| { + observer_sent.lock().unwrap().push(frame.clone()); + })); + }); + let path = executor.state_path(); + let saved_before = fs::read(&path).unwrap(); + let sent_before = sent.lock().unwrap().len(); + executor + .provider + .as_mut() + .unwrap() + .hold_courtesy_responses(); + executor.poll_provider_frames(true, false).unwrap(); + assert!(executor.unsaved_frame_state.get()); + let live_next = executor.state.as_ref().unwrap().next_provider_event_seq; + let saved_next = read_saved_state(&path).next_provider_event_seq; + assert!(live_next >= saved_next + 121); + assert_eq!(fs::read(&path).unwrap(), saved_before); + assert_eq!(sent.lock().unwrap().len(), sent_before); + executor.provider.as_mut().unwrap().shutdown().unwrap(); + drop(executor); + + let mut resumed = CodexCommandExecutor::new(&directory); + resumed.load_state_without_provider().unwrap(); + let resumed_state = resumed.state.as_ref().unwrap(); + assert_eq!(resumed_state.next_provider_event_seq, saved_next); + assert!(resumed_state.pending_events.is_empty()); + fs::remove_dir_all(directory).unwrap(); +} + +#[cfg(unix)] +fn streamed_turn_before_tool_call_with( + frames: usize, + prepare_provider: impl FnOnce(&mut CodexProvider), +) -> (PathBuf, CodexCommandExecutor) { + let tail = r#"{"id":"tool-1","method":"item/tool/call","params":{"threadId":"thread-1","turnId":"turn-1","callId":"call-streamed","tool":"get_task_context","arguments":{}}}"#; + streamed_turn(frames, 0, tail, true, prepare_provider) +} + +#[cfg(unix)] +#[test] +fn a_held_cancellation_that_cannot_reach_the_provider_keeps_the_completed_turn() { + let tail = r#"{"id":"tool-1","method":"item/tool/call","params":{"threadId":"thread-1","turnId":"turn-1","callId":"call-streamed","tool":"get_task_context","arguments":{}}}"#; + let after = r#"exec 0<&-; printf '%s\n' '{"method":"turn/completed","params":{"threadId":"thread-1","turn":{"id":"turn-1","status":"completed"}}}'; sleep 2; exit 0"#; + let (directory, mut executor) = streamed_turn_then(10, 0, tail, after, true, |_| {}); + std::thread::sleep(std::time::Duration::from_millis(200)); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + let mut observed = Vec::new(); + while !observed + .iter() + .any(|event: &PolledEvent| event.event_type == "turn.completed") + { + assert!(std::time::Instant::now() < deadline); + let events = executor.poll_events().unwrap(); + let count = events.len(); + observed.extend(events); + executor.acknowledge_events(count).unwrap(); + } + assert!(observed + .iter() + .any(|event| event.event_type == "semantic_tool.input")); + let saved = read_saved_state(&executor.state_path()); + assert!(saved.completed_turn_authoritative); + if let Some(provider) = executor.provider.as_mut() { + let _ = provider.shutdown(); + } + fs::remove_dir_all(directory).unwrap(); +} + +#[cfg(unix)] +#[test] +fn trace_interpretations_survive_a_provider_exit_in_the_same_poll() { + let named = std::sync::Arc::new(std::sync::Mutex::new(0usize)); + let observer_named = std::sync::Arc::clone(&named); + let tail = r#"{"method":"item/agentMessage/delta","params":{"threadId":"thread-1","turnId":"turn-1","itemId":"message-1","delta":"last"}}"#; + let trace_path = std::env::temp_dir().join(format!( + "paperclip-frame-trace-exit-{}.jsonl", + uuid::Uuid::new_v4() + )); + let provider_trace_path = trace_path.clone(); + let (directory, mut executor) = + streamed_turn_then(20, 0, tail, "exit 0", true, move |provider| { + provider.enable_trace_for_test(provider_trace_path); + provider.interpretation_observer = Some(Box::new(move |ids: &[String]| { + *observer_named.lock().unwrap() += ids.len(); + })); + }); + std::thread::sleep(std::time::Duration::from_millis(200)); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + while executor.provider.is_some() { + assert!(std::time::Instant::now() < deadline); + let events = executor.poll_events().unwrap(); + let count = events.len(); + executor.acknowledge_events(count).unwrap(); + } + assert!(*named.lock().unwrap() >= 41); + fs::remove_dir_all(directory).unwrap(); + let _ = fs::remove_file(trace_path); +} From d2fe85e57c2fb805e9d08a8cf132621733e77715 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 9 Nov 2026 03:54:07 -0600 Subject: [PATCH 21/21] fix(secrets): delete a secret that a binding proposal referenced (#20193) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Agents can propose a secret binding. A person approves or rejects it on a review card. > - Each binding proposal row refers to one source: a secret or a pending secret proposal. A check constraint enforces this. > - The secret reference used `ON DELETE SET NULL`. When a person deletes the secret, Postgres sets the reference to null. The check constraint then rejects the row. > - So a person cannot delete any secret that a binding proposal ever referenced. The API returns 500. > - This pull request deletes the proposal rows together with the secret, as the other source reference already does. Before the delete, it rejects pending proposals so their cards close with a reason. > - The benefit is that secret deletion works again and no review card points at a removed proposal. ## Linked Issues or Issue Description Refs #15644 (related: block secret deletion while bindings still exist). **What happened** `DELETE /api/secrets/:id` returned 500 for a secret that a binding proposal referenced. The server log showed `new row for relation "company_secret_proposals" violates check constraint "company_secret_proposals_shape_check"`. This happened for pending, rejected and approved proposals. It also happened after the person removed the agent's binding. **Expected behavior** The person can delete the secret. Proposals that referenced it go with it. A pending review card closes as rejected and tells why. **Steps to reproduce** 1. An agent proposes a binding for an existing company secret (`POST /api/agents/me/secret-proposals` with `secretId`). 2. Approve or reject the proposal, or leave it pending. 3. Delete the secret with `DELETE /api/secrets/:id`. 4. The response is 500 and the secret stays. ## What Changed - `packages/db`: `company_secret_proposals.secret_id` now uses `ON DELETE CASCADE`, the same as `secret_proposal_id`. Migration `0452_secret_proposal_secret_cascade.sql` replaces the foreign key (`DROP CONSTRAINT IF EXISTS`, then `ADD CONSTRAINT`). - `server/src/services/secrets.ts`: `remove` accepts an optional `beforeDelete` hook. The hook runs after the secret is marked deleted and before its row is removed. - `server/src/services/secret-proposals.ts`: new `rejectPendingBindingsForSecret`. It rejects pending binding proposals for a secret with a reason and returns them. This closes their review cards and writes the normal activity entries. `createBinding` now holds the secret row `FOR SHARE` in its insert transaction and refuses a deleted secret. So no new pending proposal can start after the delete marks the secret deleted. - `server/src/routes/secrets.ts`: the delete route rejects pending proposals in `beforeDelete`. For each one it sends the normal rejection notice and wake (`notifySecretProposalResolution`), so the proposing agent continues. - `server/src/__tests__/secret-proposals-routes.test.ts`: a regression test deletes a secret with a pending, rejected and approved binding proposal through the route. It checks the secret and proposal are gone, the card status is correct, and only the pending case sends one rejection notice and one wake. A second test checks that a binding proposal for a secret that the delete already marked deleted fails with 404. ## Verification - Red first: the new test failed on `origin/master` with the shape-check violation for all three cases. - `cd server && vitest run --config vitest.config.ts src/__tests__/secret-proposals-routes.test.ts`: 47 passed. - `vitest run src/__tests__/chat-run-milestone-governed-work.test.ts src/__tests__/agent-secrets-routes.test.ts src/__tests__/secrets-service.test.ts`: 112 passed. - `tsc --noEmit` for `server` and `packages/db`: pass. `pnpm --filter @paperclipai/db run check:migrations`: pass. - Found by the `secrets.secret-proposal` chat evaluation. Its cleanup deletes the fixture secret and failed on this error in runs 42563093640 and 42571127911. ## Risks - The migration only replaces one foreign key. It does not rewrite rows. Existing rows already have a non-null `secret_id` or `secret_proposal_id`, so nothing else changes. - Deleting a secret now also deletes its binding proposal rows. The activity log keeps the proposal history (`secret.proposal.*` and `secret.deleted`). Secret-kind proposal rows that created the secret keep `created_secret_id ON DELETE SET NULL`, so their history stays. - Other paths that delete secrets (company deletion, user secret cleanup) now cascade instead of failing on the same constraint. They do not reject pending cards first. ## Model Used Anthropic Claude Opus 5.5 (`claude-opus-5-5`), used with tool use and code execution in Claude Code. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- .../0452_secret_proposal_secret_cascade.sql | 3 + ...{0447_snapshot.json => 0452_snapshot.json} | 290 +++++++++++++++++- packages/db/src/migrations/meta/_journal.json | 7 + .../db/src/schema/company_secret_proposals.ts | 4 +- .../__tests__/secret-proposals-routes.test.ts | 67 ++++ server/src/__tests__/secrets-routes.test.ts | 1 + server/src/routes/secrets.ts | 12 +- server/src/services/secret-proposals.ts | 39 ++- server/src/services/secrets.ts | 14 +- 9 files changed, 428 insertions(+), 9 deletions(-) create mode 100644 packages/db/src/migrations/0452_secret_proposal_secret_cascade.sql rename packages/db/src/migrations/meta/{0447_snapshot.json => 0452_snapshot.json} (99%) diff --git a/packages/db/src/migrations/0452_secret_proposal_secret_cascade.sql b/packages/db/src/migrations/0452_secret_proposal_secret_cascade.sql new file mode 100644 index 00000000000..2d85e90ed77 --- /dev/null +++ b/packages/db/src/migrations/0452_secret_proposal_secret_cascade.sql @@ -0,0 +1,3 @@ +ALTER TABLE "company_secret_proposals" DROP CONSTRAINT IF EXISTS "company_secret_proposals_secret_id_company_secrets_id_fk"; +--> statement-breakpoint +ALTER TABLE "company_secret_proposals" ADD CONSTRAINT "company_secret_proposals_secret_id_company_secrets_id_fk" FOREIGN KEY ("secret_id") REFERENCES "public"."company_secrets"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/packages/db/src/migrations/meta/0447_snapshot.json b/packages/db/src/migrations/meta/0452_snapshot.json similarity index 99% rename from packages/db/src/migrations/meta/0447_snapshot.json rename to packages/db/src/migrations/meta/0452_snapshot.json index 4e82c37d4bb..082e8419e84 100644 --- a/packages/db/src/migrations/meta/0447_snapshot.json +++ b/packages/db/src/migrations/meta/0452_snapshot.json @@ -1,6 +1,6 @@ { - "id": "76f06e29-53a8-4ecc-809a-d52b6bbb6520", - "prevId": "782688f1-7843-4a93-8ea3-2cec362d269e", + "id": "64e8be1e-e0cb-4cd8-85e0-0fdcce5eafde", + "prevId": "b5699366-77c2-48c8-b7dc-658a75de7658", "version": "7", "dialect": "postgresql", "tables": { @@ -4102,6 +4102,28 @@ "method": "btree", "with": {} }, + "agent_wakeup_requests_issue_blocker_stalled_uq": { + "name": "agent_wakeup_requests_issue_blocker_stalled_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"agent_wakeup_requests\".\"idempotency_key\" LIKE 'issue_blocker_stalled:%' AND \"agent_wakeup_requests\".\"status\" IN ('queued', 'deferred_issue_execution', 'claimed', 'coalesced', 'completed')", + "concurrently": true, + "method": "btree", + "with": {} + }, "agent_wakeup_requests_company_payload_issue_idx": { "name": "agent_wakeup_requests_company_payload_issue_idx", "columns": [ @@ -4252,6 +4274,18 @@ "primaryKey": false, "notNull": false }, + "harness_verified_digest": { + "name": "harness_verified_digest", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "harness_verified_at": { + "name": "harness_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, "reports_to": { "name": "reports_to", "type": "uuid", @@ -7848,6 +7882,150 @@ }, "isRLSEnabled": false }, + "public.budget_resume_claims": { + "name": "budget_resume_claims", + "schema": "", + "columns": { + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "incident_id": { + "name": "incident_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_source": { + "name": "actor_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "settled_at": { + "name": "settled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_id": { + "name": "request_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "budget_resume_claims_pending_idx": { + "name": "budget_resume_claims_pending_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"budget_resume_claims\".\"state\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "budget_resume_claims_run_id_heartbeat_runs_id_fk": { + "name": "budget_resume_claims_run_id_heartbeat_runs_id_fk", + "tableFrom": "budget_resume_claims", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "budget_resume_claims_company_id_companies_id_fk": { + "name": "budget_resume_claims_company_id_companies_id_fk", + "tableFrom": "budget_resume_claims", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "budget_resume_claims_incident_id_budget_incidents_id_fk": { + "name": "budget_resume_claims_incident_id_budget_incidents_id_fk", + "tableFrom": "budget_resume_claims", + "tableTo": "budget_incidents", + "columnsFrom": [ + "incident_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.built_in_managed_resources": { "name": "built_in_managed_resources", "schema": "", @@ -17221,7 +17399,7 @@ "columnsTo": [ "id" ], - "onDelete": "set null", + "onDelete": "cascade", "onUpdate": "no action" }, "company_secret_proposals_secret_proposal_id_company_secret_proposals_id_fk": { @@ -21965,6 +22143,112 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.controller_instances": { + "name": "controller_instances", + "schema": "", + "columns": { + "boot_id": { + "name": "boot_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "epoch": { + "name": "epoch", + "type": "bigserial", + "primaryKey": false, + "notNull": true + }, + "process_space": { + "name": "process_space", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pid": { + "name": "pid", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "renewed_at": { + "name": "renewed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "fenced_at": { + "name": "fenced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "fenced_by_epoch": { + "name": "fenced_by_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "stopped_at": { + "name": "stopped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "controller_instances_epoch_idx": { + "name": "controller_instances_epoch_idx", + "columns": [ + { + "expression": "epoch", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "controller_instances_lease_idx": { + "name": "controller_instances_lease_idx", + "columns": [ + { + "expression": "lease_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.cost_events": { "name": "cost_events", "schema": "", diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index cbd48bace5c..8d913de1669 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -3151,6 +3151,13 @@ "when": 1794185816322, "tag": "0451_budget_resume_claims", "breakpoints": true + }, + { + "idx": 452, + "version": "7", + "when": 1794205579544, + "tag": "0452_secret_proposal_secret_cascade", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/company_secret_proposals.ts b/packages/db/src/schema/company_secret_proposals.ts index 7bcfd3c1e48..476179a1e1e 100644 --- a/packages/db/src/schema/company_secret_proposals.ts +++ b/packages/db/src/schema/company_secret_proposals.ts @@ -21,7 +21,9 @@ export const companySecretProposals = pgTable( valueCiphertext: jsonb("value_ciphertext").$type | null>(), valueFingerprintSha256: text("value_fingerprint_sha256"), valueLength: integer("value_length"), - secretId: uuid("secret_id").references(() => companySecrets.id, { onDelete: "set null" }), + // Binding proposals require exactly one source reference (shape check), so + // they go with the secret they reference, like secretProposalId below. + secretId: uuid("secret_id").references(() => companySecrets.id, { onDelete: "cascade" }), secretProposalId: uuid("secret_proposal_id").references((): AnyPgColumn => companySecretProposals.id, { onDelete: "cascade" }), targetType: text("target_type"), targetId: uuid("target_id").references(() => agents.id, { onDelete: "cascade" }), diff --git a/server/src/__tests__/secret-proposals-routes.test.ts b/server/src/__tests__/secret-proposals-routes.test.ts index 940b1c09a12..250132f6a46 100644 --- a/server/src/__tests__/secret-proposals-routes.test.ts +++ b/server/src/__tests__/secret-proposals-routes.test.ts @@ -865,6 +865,73 @@ describeEmbeddedPostgres("secret proposal routes", () => { ]); }); + it.each(["rejected", "approved", "pending"] as const)("deletes a secret whose %s binding proposal referenced it", async (outcome) => { + const fixture = await seedRun(); + const secret = await secretService(db).create(fixture.companyId, { + name: "dev/deploy/publishing", + key: "DEPLOY_PUBLISHING", + provider: "local_encrypted", + value: "publishing-secret", + }); + const proposals = createSecretProposalsService(db); + const proposal = await proposals.createBinding({ companyId: fixture.companyId, heartbeatRunId: fixture.heartbeatRunId }, { + secretId: secret.id, + configPath: "env.PUBLISHING_API_KEY", + justification: "Publish the site", + bindingTargetPolicy: "self_and_reports", + }); + if (outcome === "rejected") await proposals.transition(fixture.companyId, proposal.id, "rejected", { resolvedByUserId: "board-user" }); + if (outcome === "approved") { + await proposals.approve(fixture.companyId, proposal.id, { resolvedByUserId: "board-user" }); + await db.delete(companySecretBindings).where(eq(companySecretBindings.secretId, secret.id)); + } + + // The proposal's secret reference used ON DELETE SET NULL, which the binding + // shape check rejects, so deleting the secret failed with a 500. + await db.update(issues).set({ assigneeAgentId: fixture.agentId }).where(eq(issues.id, fixture.issueId)); + const wakeup = vi.fn().mockResolvedValue(null); + const persistedIssues = issueService(db); + const addComment = vi.fn().mockResolvedValue(undefined); + const deleted = await request(createBoardApp(fixture, { + heartbeat: { wakeup }, + issues: { getById: persistedIssues.getById, addComment }, + })).delete(`/api/secrets/${secret.id}`); + expect(deleted.status).toBe(200); + // Only a pending proposal is rejected by the delete; it gets the normal rejection signal. + expect(addComment).toHaveBeenCalledTimes(outcome === "pending" ? 1 : 0); + if (outcome === "pending") { + expect(addComment.mock.calls[0]?.[1]).toContain("Status: **rejected**"); + expect(addComment.mock.calls[0]?.[1]).toContain("The referenced secret was deleted."); + } + expect(wakeup).toHaveBeenCalledTimes(outcome === "pending" ? 1 : 0); + expect(await db.select().from(companySecrets).where(eq(companySecrets.id, secret.id))).toEqual([]); + expect(await db.select().from(companySecretProposals).where(eq(companySecretProposals.id, proposal.id))).toEqual([]); + // A pending review card closes as rejected instead of pointing at a deleted proposal. + const [card] = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, proposal.interactionId!)); + expect(card?.status).toBe(outcome === "approved" ? "accepted" : "rejected"); + }); + + it("refuses a new binding proposal for a secret that a delete already marked deleted", async () => { + const fixture = await seedRun(); + const secret = await secretService(db).create(fixture.companyId, { + name: "dev/deploy/deleting", + key: "DEPLOY_DELETING", + provider: "local_encrypted", + value: "deleting-secret", + }); + let proposalDuringDelete: unknown; + await secretService(db).remove(secret.id, { + beforeDelete: async () => { + proposalDuringDelete = await createSecretProposalsService(db).createBinding( + { companyId: fixture.companyId, heartbeatRunId: fixture.heartbeatRunId }, + { secretId: secret.id, configPath: "env.LATE_KEY", justification: "Late request", bindingTargetPolicy: "self_and_reports" }, + ).catch((error: unknown) => error); + }, + }); + expect(proposalDuringDelete).toMatchObject({ status: 404 }); + expect(await db.select().from(companySecretProposals)).toEqual([]); + }); + it("proposes and approves a binding by resolving the proposer's own sourceConfigPath", async () => { const fixture = await seedRun(); const sourceSecret = await secretService(db).create(fixture.companyId, { diff --git a/server/src/__tests__/secrets-routes.test.ts b/server/src/__tests__/secrets-routes.test.ts index 73d37996bdf..d684c7c16d6 100644 --- a/server/src/__tests__/secrets-routes.test.ts +++ b/server/src/__tests__/secrets-routes.test.ts @@ -1039,6 +1039,7 @@ describe("secret routes", () => { expect(res.body).toEqual({ ok: true }); expect(mockSecretService.remove).toHaveBeenCalledWith( "33333333-3333-4333-8333-333333333333", + { beforeDelete: expect.any(Function) }, ); expect(mockLogActivity).toHaveBeenCalledWith( expect.anything(), diff --git a/server/src/routes/secrets.ts b/server/src/routes/secrets.ts index 3d3ca863f22..0747738e2b4 100644 --- a/server/src/routes/secrets.ts +++ b/server/src/routes/secrets.ts @@ -1115,7 +1115,17 @@ export function secretRoutes(db: Db, deps: SecretRoutesDeps = {}) { ); if (!existing) return; - const removed = await svc.remove(id); + const resolvedByUserId = req.actor.userId ?? "board"; + const reason = "The referenced secret was deleted."; + const removed = await svc.remove(id, { + beforeDelete: async (secret) => { + const rejected = await proposals.rejectPendingBindingsForSecret(secret.companyId, secret.id, { resolvedByUserId, reason }); + // Same signal as an ordinary rejection, so the proposing agent continues. + for (const proposal of rejected) { + await notifySecretProposalResolution({ proposal, status: "rejected", userId: resolvedByUserId, reason, issues, heartbeat }); + } + }, + }); if (!removed) { res.status(404).json({ error: "Secret not found" }); return; diff --git a/server/src/services/secret-proposals.ts b/server/src/services/secret-proposals.ts index 870749d1513..7de0ad8302e 100644 --- a/server/src/services/secret-proposals.ts +++ b/server/src/services/secret-proposals.ts @@ -446,6 +446,13 @@ export function createSecretProposalsService(db: Db) { return createWithinQuota( { companyId: context.companyId, agentId: run.agentId, runId: run.id, issueId: originIssueId }, async (txDb) => { + if (resolvedSecretId) { + // Hold the secret against a concurrent delete: the delete marks it + // deleted first, then settles the proposals that reference it. + const live = await txDb.select({ status: companySecrets.status }).from(companySecrets) + .where(eq(companySecrets.id, resolvedSecretId)).for("share").then((rows) => rows[0] ?? null); + if (!live || live.status === "deleted") throw notFound("Secret not found"); + } if (input.secretProposalId) { const dependency = await txDb.select().from(companySecretProposals).where(and( eq(companySecretProposals.id, input.secretProposalId), @@ -915,5 +922,35 @@ export function createSecretProposalsService(db: Db) { return row?.at == null ? null : new Date(row.at).getTime(); } - return { getById, view: enrich, createSecret, createBinding, listForAgent, listForBoard, assertBindingSnapshotCurrent, approve, transition, sweepExpired, sweepExpiredForCompany, expiryDeadlines, nextExpiryAt }; + /** + * Deleting a secret also deletes the binding proposals that reference it. + * Settle pending ones first, so their review cards close as rejected with + * the reason instead of pointing at a proposal that no longer exists. Call + * it after the secret is marked deleted: createBinding holds the secret row + * and refuses a deleted one, so no new pending proposal can follow. + */ + async function rejectPendingBindingsForSecret(companyId: string, secretId: string, input: { resolvedByUserId?: string | null; reason: string }) { + const pending = await db.select().from(companySecretProposals).where(and( + eq(companySecretProposals.companyId, companyId), + eq(companySecretProposals.secretId, secretId), + eq(companySecretProposals.kind, "binding"), + eq(companySecretProposals.status, "pending"), + )); + const rejected: Proposal[] = []; + for (const row of pending) { + try { + await transition(companyId, row.id, "rejected", { + resolvedByUserId: input.resolvedByUserId ?? null, + reason: input.reason, + }); + rejected.push(row); + } catch (error) { + // Resolved concurrently; the secret deletion still removes the row. + if (!(error instanceof HttpError) || error.status !== 409) throw error; + } + } + return rejected; + } + + return { getById, view: enrich, createSecret, createBinding, listForAgent, listForBoard, assertBindingSnapshotCurrent, approve, transition, rejectPendingBindingsForSecret, sweepExpired, sweepExpiredForCompany, expiryDeadlines, nextExpiryAt }; } diff --git a/server/src/services/secrets.ts b/server/src/services/secrets.ts index 730c15c9059..deb48d52673 100644 --- a/server/src/services/secrets.ts +++ b/server/src/services/secrets.ts @@ -970,6 +970,11 @@ function assertSelectableProviderConfig(config: { } } +export type RemoveSecretOptions = { + /** Runs after the secret is marked deleted and before its row is removed. */ + beforeDelete?: (secret: typeof companySecrets.$inferSelect) => Promise; +}; + export function secretService(db: Db | DbTransaction) { const authorization = authorizationService(db); @@ -3401,18 +3406,18 @@ export function secretService(db: Db | DbTransaction) { // `removeSecretUnlocked` re-reads the secret under the lock, so a delete // that lost a race between the pre-check and the lock still sees current // state, not the pre-check's stale read. - async function removeSecretInternal(secretId: string) { + async function removeSecretInternal(secretId: string, options: RemoveSecretOptions = {}) { const preCheckSecret = await getById(secretId); if (!preCheckSecret) return null; return withAccountHomeSecretMutationLock(undefined, preCheckSecret.companyId, () => - removeSecretUnlocked(secretId), + removeSecretUnlocked(secretId, options), ); } // The body of `removeSecretInternal` above, unchanged. Extracted to a named // function so that wrapper can hold the lock across this whole delete // sequence without duplicating it. - async function removeSecretUnlocked(secretId: string) { + async function removeSecretUnlocked(secretId: string, options: RemoveSecretOptions = {}) { const secret = await getById(secretId); if (!secret) return null; const versionRow = await getSecretVersion(secret.id, secret.latestVersion); @@ -3430,6 +3435,9 @@ export function secretService(db: Db | DbTransaction) { }) .where(eq(companySecrets.id, secretId)); } + // New references now see the deleted status, so the caller can settle + // every reference that exists before the row and its cascades go. + await options.beforeDelete?.(secret); const providerConfig = secret.providerConfigId ? await getProviderConfigById(secret.providerConfigId) : null;