fix(cursor-cloud): support API-only controllers on managed stacks (#18310)

## Thinking Path

> - Paperclip helps people manage AI agents at work.
> - Cursor Cloud hosts execution behind its API.
> - Product setup still expected a Paperclip Computer.
> - Managed-only tenants therefore rejected valid setup.
> - This admits the validated API-owned controller.
> - Authority, Stop proof, budgets and cleanup remain enforced.

## Linked Issues or Issue Description

Refs #18068 and #18069; both dependencies have merged.

**What happened?**

Managed-only setup still requested a Computer. Live tests exposed
missing Cursor Stop proof and duplicate replay.

**Expected behavior**

Use Cursor’s API controller. Verify provider/controller termination and
replay each log identity once.

**Steps to reproduce**

Enable native Runner/managed-only. Configure Cursor model/repository/key
without a Computer, then Test, finish setup, run and Stop.

## What Changed

- Derive API-only placement from validated native Cursor configuration;
reject placement/command/workspace/home overrides and keep
Kubernetes-only execution closed.
- Skip Computers/checkout/workspace realization. Keep scoped tools,
completion, private staging, budgets, leases and recovery. Persist
placement; reject contradictions.
- Align Cloud cards, Test, Save and Finish setup; hold unready drafts.
Legacy keeps its Computer, credential and policy checks.
- Verify Stop from exact API ownership, audited intent,
provider-terminal proof, no Computer/process ownership and controller
retirement. Recheck immediate/deferred retirement; wake existing
recovery on durable evidence.
- Clear warnings when the actual stopped target later has authoritative
confirmation. Keep missing/other-run negatives and the existing
predicate.
- Publish safe status refresh after full-proof ACK. Retain bounded
native/physical cancellation history; add no poller or weaker proof
path.

## Verification

Core merged as `3910352c923a`; Product merged as `27d35314be8e`. Public
branch `5f86eb66b081` has tree `67344151d303`. Its exact-head Greptile
summary is 5/5; the fresh audit found zero unresolved threads and
complete pagination. The controlled workspace retry and required summary
passed. All 59 current-head contexts are terminal and clean. Prior
green-36 evidence keeps its identity.

[Current
CI](https://github.com/paperclipai/paperclip/actions/runs/41680511731)
first failed workspaces-a 1/2: the CLI company-import/export beforeAll
30-second runtime-info/child-PID/HTTP readiness wait expired. All 431 UI
files/4,714 tests and 285 other CLI tests passed (one skip). The cause
remains UNKNOWN. Child output records startup recovery complete, but
buffered CI prefixes do not establish its relation to the waiter
deadline; runtime-file/PID/last-health state was not captured. Both
unchanged local probes were RED. The second observed dependency
preflight still pending at the deadline; observer overhead was not
quantified. Neither proves the CI cause. After all functional jobs
finished, only the queued metadata summary was cancelled; the other 48
records stayed unchanged. Attempt 1 retains 46 successes, the original
failure, one skip and the cancelled summary. One same-head debug retry
was admitted at 18:42 UTC on November 3. Its workspace job passed at
19:23 UTC on the same checkout: UI 431 files/4,714 tests and CLI 33
files/286 tests, including company import/export. Test caching was
disabled. The new `ci / verify` summary passed; workflow attempt 2
completed SUCCESS. Required `ci / e2e` also passed. The final check at
19:53 UTC on November 3 found all 59 current-head contexts terminal and
clean: zero pending and zero failing. The 47 inherited completed/skipped
rows are not new executions. No code, assertion or timeout changed;
success does not explain the original failure or erase the local REDs.

CI checkout is `1ea9d8608ced`/tree `78cb1ec13d59` (parents
`2e37c73c05fe`, 5f86); both workflows use 2e37. Its 20 extra paths add
low-memory build/Rust task-memory. Scripts cohere; later matrix changes
only labels, with no newer debug-cache steps. Static audit found no
blocker. Supported API arguments omit the memory flag: no default
monitor/instruction changes. Flagged external launchers can change
Cursor instructions/digests and remain unqualified.

The merge added 41 master-identical paths, retained the green-36 inbox
hunk and found UI fixtures matching upstream. Startup/backup, recovery
projection, sleep diagnostics and binary backup change production.
Static audit found eight Stop/ACK owner blobs and 37 sleep-work SQL
templates unchanged, with sleep fences/single-flight accounting intact.
Exact precommit tree: 416/416 across eight server files, binary backup
1/1, server types PASS; repeated scopes are not summed.

The deferred-retirement P1 reproduced three real-DB/owned-process
failures; missing proof stayed unverified. Recovery passed 47 process
cases/33 proof negatives. Warning repair binds actual targets to
same-run proof. Validation passed 729 scoped UI cases, a separate
359-case thread run, 111 backend cases (84 CAS, 6 assembled, 21
activity/privacy), types/tokens. Proof validators/predicate are
unchanged; no new poller or increased timeout.

The d8d3 attempt stays FAILED: 45 passes, two workspaces-a failures,
derived verify failure and one skip. Both shards shared three failures:
two successful mocks omitted the account guard’s required userId. The
two-line fixture repair changed no guard, assertion or timeout. Three
failures reproduced; 54 access/auth tests then passed, including
account-change/denial negatives, plus UI types/tokens.

The 69285 attempt stays FAILED: 49 jobs, 46 passed, serialized shard 9
failed, its verify aggregate failed and one selection job skipped. The
shard passed 575/576 cases. Its aborted-snapshot case expected nine
computes but saw eight: a final HTTP 200 coalesced while seven ordinary
reads had not proved the aborted compute settled. Earlier CI and the
unmodified local baseline passed with a miss; no local RED is claimed.
The repair awaits the snapshot, allows exactly one extra GET if
coalesced, then requires a miss and retains nine computes/all HTTP429
assertions. No production code, hook, loop, sleep or timeout changed.
Six inbox and seven cache/admission cases passed locally, plus server
types; 30 unrelated cases were filtered. Green 36 passed 576/576 without
taking the extra-GET branch; local runs also missed that schedule.
Current CI event merge 1ea9 now passed 576/576 and naturally exercised
the former failure: coalesced (count 2, 1ms), then exactly one fresh
miss (count 1, 16ms), with strict nine-compute/HTTP429 assertions
intact. No instrumentation or retry was added.

Earlier compatibility passed scoped remote/inbox, routine, API-placement
and UI tests, plus types/migrations/snapshots/maps/tokens. The 89-path
adoption restored fleet routing without workflow overrides or added
skips. Each result retains its source identity.

Live qualification remains actual `9af1a5de42a2`: unchanged Artifact
grader PASSED 17/17 and active-Stop grader PASSED 21/21. All three
provider test resources were deleted canonically with GET404;
post-delete download returned the exact 27 bytes. Histories stayed
stable (Artifact 121 and Stop 67 canonical identities), with 21 company
runs, zero active work and no accounting/cancellation/reservation debt
or integrity findings. Saved settings reopened; no-prompt Test/Linux
admission passed with `claude-sonnet-4-5`. No new Save is claimed.

The first Stop sampling case stays FAILED on a 10.373-second gap.
Unchanged retry kept all 24 samples: 35.076 seconds quiet, maximum gap
4.584 seconds, plus a separate CANCELLED/artifact-absent read after 180
seconds. Initial grading rejected locator spelling before functional
checks; metadata changed only after same-file/inode/byte proof, with raw
evidence/oracles unchanged. Original Question/Plan/reconnect and
historical deleted-artifact results keep their identities.

Git trees verified 11 history-only conflicts and six earlier mainline
paths. Hosted 9af to the public BRANCH differs in 222 paths; 13,500
other mode/blob leaves match. Runner runtime implementations and six
observer/driver pins match 9af on that branch only; inherited
`drivers/acpx/runtime-host.test.ts` differs. No all-Runner/src,
CI/latest-master runtime or image equality is claimed. Later static
audits through `8ab89f41e499` found no concrete API-only blocker. That
base adds completed-history sleep proofs and an MCP cleanup migration;
protected Stop/ACK owners are unchanged. These later changes are outside
this CI and have no live qualification here. Host duplicate-card and
later recovery/UI repairs retain separate proof, not deployed 9af
identity. Direct-key coverage requires absent aiConnection; API-only
placement skips inherited remote-workspace disk-full paths, and
config-read gates match their parent.

Other retained failures: stale fixture 132/133; E8 48/135 with 87
reset-hook failures; Mac 17 SQL failures/skips;
title/startup/custody/Stop/deletion unknown causes and bounded
recoveries; superseded warning/50 duplicate identities; pre-repair
ACK/Core CI failures. Fixture RED/GREEN passed 7/7 browser and 121/121
shutdown tests with a failing broken-unsubscribe control; stable WSP
wrapper passed 128 after physical teardown failure. No full local-suite
pass is inferred. All paid attempts remain recorded; vendor dollar cost
is UNKNOWN.

## Risks

Experimental: Cursor independently acknowledges neither the effective
model nor instruction digest before the first prompt. Explicit values
are submitted and behavior checked live; unpriced provider dollar cost
stays UNKNOWN.

The placement exception applies only to validated native Cursor API
execution. Other local work remains denied under managed-only policy.
Missing/stale Stop or cleanup proof stays pending; damaged archives
remain failed. Explicit Legacy keeps its existing policy owners.

## Model Used

OpenAI Codex/GPT-6 family: tools, execution, browser checks, delegated
review. API ID/context window unavailable.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used and the limits of available model
metadata
- [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 linked public issues or described the issue in this PR
- [x] I have not referenced internal or instance-local Paperclip issues
or links
- [x] My branch describes the change and contains no internal ticket ID
- [x] I have run the scoped local tests reported above; retained
failures are stated separately
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation
- [x] I have considered and documented the risks
- [x] All required CI gates pass on the current head
- [x] Fresh Greptile is 5/5 with no unresolved findings on the current
head
- [x] I will address all reviewer comments before requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-11-03 13:55:39 -06:00
1 parent 4d31588dea
commit 1f55073f96
60 files changed
+1825 -176

No files matched your search

@@ -15,6 +15,36 @@ and dependent [product PR #18069](https://github.com/paperclipai/paperclip/pull/
Neither PR is merged. Product PR #18069's Verification section owns the latest
candidate, check status and final acceptance decision.
## Hosted placement correction — 2026-10-30
Native Cursor Cloud uses a provider API controller. Cursor hosts the model and
commands; Paperclip runs only its qualified API client and bounded private artifact
stager. This placement has no Paperclip Computer, lease, task checkout or host
provisioning command. It is derived from the validated native Cursor profile and
persisted with the admitted run; recovery retains that backend and placement.
Earlier Cursor profiles retain their admitted workspace binding as identity;
recovery does not reopen that workspace or allocate a Computer. Known backend
and driver contradictions fail closed, while historical missing driver values
remain recoverable.
The generic managed-sandbox-only policy and local Computer denial stay intact.
Kubernetes-only execution remains unsupported in save, Test, inventory and run
admission. An unavailable Computer does not advertise local Legacy execution.
Legacy direct agents retain their existing selection and execution behavior.
Ordinary native setup clears the Computer binding and explains Cursor's hosted
environment. Save/reopen and Test use the same account/configuration contract as
run admission. The Cloud front door must pass the exact authenticated native-run
callback to the tenant; Core still enforces live run authority and per-turn scope.
The ordinary Cloud card is available only with native rollout and a qualified
controller. A server-held new setup draft can use that managed API default before
model selection. It keeps its setup hold and does not inherit another harness's
model or permissions. Unsaved automatic Test uses the advertised native default;
saved Legacy agents still require an explicit conversion.
This correction requires fresh hosted acceptance on its final image. Local and
synthetic checks do not qualify that deployed journey or erase prior failures.
## Review correction checkpoint — 2026-10-29 20:25 UTC
All six original acceptance gates have passed on their recorded candidates;
+19
View File
@@ -19,6 +19,25 @@ approval activation, built-in provisioning and import preservation described
below. The original baseline commit above still governs unrelated entries; this
update does not claim operation-complete review or live agent verification.
Native Cursor Cloud uses a server-derived `provider_api` placement after its
complete native profile is validated. Create/update with `runner: paperclip`
stores `defaultEnvironmentId: null`; the caller cannot supply placement, commands,
working directories or arbitrary environment values. Only the bound
`CURSOR_API_KEY` is passed to the qualified API proxy. Test does not acquire a
Computer lease. Finish setup uses the same validated placement and account check;
it does not inherit a default Computer or its warm-task lifecycle. Incomplete
held drafts stay inactive until the complete profile passes activation validation.
Task/run authority, company boundaries, budgets and per-turn callback scope remain enforced. Local Computer execution stays denied under
managed-sandbox-only policy. Verify the actual saved agent and live run; a board
Test result does not prove agent task-tool access.
On managed-only instances with native Runner enabled, a server-held Cursor setup
draft can resolve an automatic runner to the API controller before it has a
model. Unsaved automatic Test requests use the same advertised default and still
validate the full profile. Existing active Legacy agents and explicit Legacy
choices do not convert automatically. A client-supplied setup flag does not grant
draft status or bypass active model validation.
## `hire`
Capability ID: `agents/hire`.
+16
View File
@@ -37,6 +37,22 @@ Save & exit keeps its configuration; Finish setup on the agent returns to the
same record. Final submission requests approval when required, then follows the
existing preparation and verification lifecycle.
For **Cursor Cloud**, choose its card in Cloud when native Runner is enabled and
the qualified controller is available. Its held setup draft uses the API
controller without a Computer, before model or credential selection. Local
operators can choose **Paperclip Runner** in Advanced. Bind a Cursor Cloud
API key, enter the exact model and select a repository/ref or a named Cursor
cloud environment. Cursor hosts the agent's environment; this native setup has
no Paperclip Computer selection. Save, reopen, and run Test. Test checks the
account catalog and callback authentication gate without a prompt; the effective-model
warning remains visible for this experimental integration. Finish setup repeats
that account check and reaches Ready without preparing or leasing a Computer,
even when the instance has a default plugin Computer. The Cursor lifecycle stays
per-turn. Assign a real task
and verify its output, question continuation, Plan approval and Stop separately.
Managed-sandbox-only policy stays enabled. It continues to deny local agent code.
Kubernetes-only execution is not supported by this API controller.
When the native Runner adapter is available, **Google Antigravity** appears in the ordinary
adapter picker. Before runtime acceptance, its card says **Not available yet**.
Selecting it opens Google account setup and model configuration without saving
@@ -325,6 +325,46 @@ describeEmbeddedPostgres("activity service", () => {
expect(runs[0]).not.toHaveProperty("contextSnapshot");
});
it.each(["requested", "acknowledged", "pending_native", "missing_native", "wrong_run", "wrong_company",
"malformed_execution", "null_execution", "malformed_native"] as const)(
"projects only bounded, owner-matched Stop evidence in issue history: %s", async proof => {
const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(), runId = randomUUID();
await db.insert(companies).values({ id: companyId, name: "Stop history", issuePrefix: `S${companyId.slice(0, 6)}` });
await db.insert(agents).values({ id: agentId, companyId, name: "Stopped runner", adapterType: "process" });
await db.insert(issues).values({ id: issueId, companyId, title: "Stop the current run", assigneeAgentId: agentId });
const native = { schema: "paperclip.native-cancellation.v1", runId, companyId,
dispatchState: proof === "pending_native" ? "pending" : "acknowledged",
intentAuditId: "private-stop-proof", effects: ["private-stop-proof"], secret: "private-stop-proof" };
const result: Record<string, unknown> = {
executionCancellation: { state: proof === "requested" ? "requested" : "acknowledged",
proof: "private-stop-proof", secret: "private-stop-proof" },
nativeCancellation: native, unrelated: "private-stop-proof",
};
if (proof === "missing_native") delete result.nativeCancellation;
if (proof === "wrong_run") native.runId = randomUUID();
if (proof === "wrong_company") native.companyId = randomUUID();
if (proof === "malformed_native") result.nativeCancellation = { ...native, dispatchState: { secret: "private-stop-proof" } };
if (proof === "malformed_execution") result.executionCancellation = { state: { secret: "private-stop-proof" } };
if (proof === "null_execution") result.executionCancellation = null;
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, issueId, scopeKind: "issue",
nativeIssueId: issueId, runtimeMode: "native", status: "cancelled", finishedAt: new Date(),
contextSnapshot: { issueId }, livenessState: "advanced", resultJson: result });
const rows = await activityService(db).runsForIssue(companyId, issueId);
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({ runId, runtimeMode: "native", status: "cancelled" });
expect(rows[0]!.resultJson).toEqual({
executionCancellation: { state: proof === "requested" ? "requested"
: proof === "malformed_execution" || proof === "null_execution" ? "unknown" : "acknowledged" },
...(["missing_native", "wrong_run", "wrong_company", "malformed_native"].includes(proof) ? {} : {
nativeCancellation: { schema: "paperclip.native-cancellation.v1", runId, companyId,
dispatchState: proof === "pending_native" ? "pending" : "acknowledged" },
}),
});
expect(JSON.stringify(rows)).not.toContain("private-stop-proof");
},
);
it("backfills missing liveness for completed issue runs before returning the ledger", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
@@ -1,4 +1,5 @@
import express from "express";
import { unprocessable, forbidden } from "../errors.js";
import request from "supertest";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { vi } from "vitest";
@@ -259,6 +260,30 @@ describe("adapter routes", () => {
expect(resolveRunnerTarget).not.toHaveBeenCalled();
});
it("keeps Cursor API discovery available without granting local support when the implicit managed Computer is unavailable", async () => {
const unavailable = unprocessable("Managed Computer unavailable", { code: "managed_sandbox_unavailable" });
const app = createApp({}, { resolveRunnerTarget: async () => { throw unavailable; } });
const response = await request(app).get("/api/adapters?companyId=company-1");
expect(response.status).toBe(200);
expect(response.body.find((adapter: any) => adapter.type === "cursor_cloud")).toMatchObject({ supportedRunners: ["paperclip"], defaultRunner: "paperclip" });
expect(response.body.find((adapter: any) => adapter.type === "codex_local").supportedRunners).toEqual([]);
const explicit = await request(app).get("/api/adapters?companyId=company-1&environmentId=selected");
expect(explicit.status).toBe(422);
const foreign = await request(createApp({}, { resolveRunnerTarget: async () => { throw forbidden("Foreign Computer", { code: "environment_company_mismatch" }); } })).get("/api/adapters?companyId=company-1");
expect(foreign.status).toBe(403);
});
it("does not offer Cursor execution under Kubernetes-only policy, including the unavailable managed Computer fallback", async () => {
for (const resolveRunnerTarget of [
async () => ({ driver: "sandbox", executionMode: "kubernetes" as const }),
async () => { throw unprocessable("Managed Computer unavailable", { code: "managed_sandbox_unavailable", executionMode: "kubernetes" }); },
]) {
const response = await request(createApp({}, { resolveRunnerTarget })).get("/api/adapters?companyId=company-1");
expect(response.status).toBe(200);
expect(response.body.find((adapter: any) => adapter.type === "cursor_cloud").supportedRunners).toEqual([]);
}
});
it.each(["environmentId=selected", "companyId=", "companyId=one&companyId=two", "companyId=one&environmentId="])("rejects malformed discovery scope before resolution (%s)", async query => {
const resolveRunnerTarget = vi.fn();
const response = await request(createApp({}, { resolveRunnerTarget })).get(`/api/adapters?${query}`);
@@ -68,7 +68,7 @@ const mockApprovalService = vi.hoisted(() => ({
const mockInstanceSettingsService = vi.hoisted(() => ({
get: vi.fn(async () => ({ defaultEnvironmentId: null })),
getGeneral: vi.fn(async () => ({ censorUsernameInLogs: false })),
getExperimental: vi.fn(async (): Promise<{ enableNativeRunner: boolean; enableOpenAiDot?: boolean }> => ({ enableNativeRunner: false })),
getExperimental: vi.fn(async (): Promise<{ enableNativeRunner: boolean; enableOpenAiDot?: boolean; enableManagedSandboxOnly?: boolean }> => ({ enableNativeRunner: false })),
}));
const mockManagedAgentProfileService = vi.hoisted(() => ({
@@ -268,6 +268,7 @@ describe("agent routes adapter validation", () => {
mockAccessService.setPrincipalPermission.mockResolvedValue(undefined);
mockLogActivity.mockResolvedValue(undefined);
mockSecretService.syncEnvBindingsForTarget.mockResolvedValue(undefined);
mockInstanceSettingsService.get.mockResolvedValue({ defaultEnvironmentId: null });
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableNativeRunner: false });
mockManagedAgentProfileService.requireQualified.mockResolvedValue({
id: "managed-primary",
@@ -959,6 +960,46 @@ describe("agent routes adapter validation", () => {
expect(mockAgentService.create).not.toHaveBeenCalled();
});
it("selects the native Cursor API controller from the ordinary held Cloud setup card without a Computer", async () => {
const settings = { defaultEnvironmentId: null, experimental: { enableManagedSandboxOnly: true, enableNativeRunner: true } };
mockInstanceSettingsService.get.mockResolvedValue(settings);
mockInstanceSettingsService.getExperimental.mockResolvedValue(settings.experimental);
const environments = await import("../services/environments.js");
const findManaged = vi.fn(async () => null);
const getEnvironment = vi.fn(async () => null);
vi.spyOn(environments, "environmentService").mockReturnValue({ findManagedSandboxEnvironment: findManaged,
getById: getEnvironment, listBoundCompanyIds: vi.fn(async () => []) } as never);
const existing = { ...(await mockAgentService.getById()), adapterType: "process", adapterConfig: {},
status: "paused", lifecycleState: "paused", pauseReason: "setup", lifecycleHolds: ["setup"] };
mockAgentService.getById.mockResolvedValue(existing);
const app = await createApp();
const response = await requestApp(app, base => request(base).patch(`/api/agents/${existing.id}`).send({
name: "Cursor setup", adapterType: "cursor_cloud", runner: "auto", adapterConfig: {}, metadata: { setupAdapterPending: false },
}));
expect(response.status, JSON.stringify(response.body)).toBe(200);
expect(response.body).toMatchObject({ lifecycleState: "paused", pauseReason: "setup", lifecycleHolds: ["setup"] });
expect(mockAgentService.update).toHaveBeenCalledWith(existing.id, expect.objectContaining({
adapterType: "paperclip_runner", defaultEnvironmentId: null,
adapterConfig: expect.objectContaining({ provider: "cursor_cloud", lifecycleMode: "per_turn" }),
}), expect.anything());
expect(findManaged).not.toHaveBeenCalled();
expect(getEnvironment).not.toHaveBeenCalled();
mockAgentService.update.mockClear();
mockAgentService.getById.mockResolvedValue({ ...existing, status: "idle", lifecycleState: "ready", pauseReason: null, lifecycleHolds: [] });
const active = await requestApp(app, base => request(base).patch(`/api/agents/${existing.id}`).send({
adapterType: "cursor_cloud", runner: "auto", adapterConfig: {}, setupOnly: true,
}));
expect(active.status).toBe(422);
expect(active.body.details?.code).toBe("managed_sandbox_unavailable");
expect(mockAgentService.update).not.toHaveBeenCalled();
const incompleteActive = await requestApp(app, base => request(base).patch(`/api/agents/${existing.id}`).send({
adapterType: "paperclip_runner", runner: "paperclip", adapterConfig: { provider: "cursor_cloud" }, setupOnly: true,
}));
expect(incompleteActive.status).toBe(422);
expect(incompleteActive.body.details?.code).toBe("paperclip_runner_cursor_cloud_model_required");
expect(mockAgentService.update).not.toHaveBeenCalled();
});
it.each(["auto", "legacy"] as const)("creates Pi through the public route with %s selection and the experimental flag off", async runner => {
const selection = await import("../services/agent-runner-selection.js");
vi.spyOn(selection, "resolveNewAgentRunnerForCompany").mockImplementation(async (_db, _companyId, input) =>
@@ -1107,9 +1148,9 @@ describe("agent routes adapter validation", () => {
);
});
it("saves and reopens an explicit native Cursor Cloud setup without Codex defaults", async () => {
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableNativeRunner: true });
let existing = { ...(await mockAgentService.getById()), adapterType: "cursor_cloud", adapterConfig: {},
it("saves and reopens native Cursor API setup under managed-only policy without a Computer", async () => {
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableNativeRunner: true, enableManagedSandboxOnly: true });
let existing = { ...(await mockAgentService.getById()), adapterType: "cursor_cloud", adapterConfig: {}, defaultEnvironmentId: "33333333-3333-4333-8333-333333333333",
status: "paused", lifecycleState: "paused", pauseReason: "setup", lifecycleHolds: ["setup"] };
mockAgentService.getById.mockImplementation(async () => existing);
mockAgentService.update.mockImplementation(async (_id, patch) => { existing = { ...existing, ...patch }; return existing; });
@@ -1123,6 +1164,7 @@ describe("agent routes adapter validation", () => {
provider: "cursor_cloud", model: config.model, repoUrl: config.repoUrl, repoRef: "feature", env: config.env, lifecycleMode: "per_turn",
} });
expect(saved.body.adapterConfig).not.toHaveProperty("codexPermissionMode");
expect(saved.body.defaultEnvironmentId).toBeNull();
const reopened = await requestApp(app, baseUrl => request(baseUrl).get(`/api/agents/${existing.id}/configuration`));
expect(reopened.status, JSON.stringify(reopened.body)).toBe(200);
expect(reopened.body.adapterConfig).toEqual(saved.body.adapterConfig);
@@ -0,0 +1,164 @@
import { randomUUID } from "node:crypto";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { eq, sql } from "drizzle-orm";
import { agents, companies, createDb, applyPendingMigrations, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS,
environments, environmentLeases, executionWorkspaces, heartbeatRuns, plugins, type Db } from "@paperclipai/db";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { requireServerAdapter } from "../adapters/index.js";
import { agentHarnessVerificationService } from "../services/agent-harness-verification.js";
import { createAgentLifecycle, configureAgentLifecycle } from "../services/agent-lifecycle.js";
import { createLifecycleDriver } from "../services/agent-lifecycle-driver.js";
import { instanceSettingsService } from "../services/instance-settings.js";
import { environmentService } from "../services/environments.js";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js";
const externalDatabaseUrl = resolveExternalTestDatabaseUrl("PAPERCLIP_PROVIDER_API_LIFECYCLE_TEST_DATABASE_URL");
const support = externalDatabaseUrl ? { supported: true } : await getEmbeddedPostgresTestSupport();
describe.skipIf(!support.supported)("Cursor Cloud API controller activation", () => {
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: Db;
let home: string;
let companyId: string;
const workers: ReturnType<typeof configureAgentLifecycle>[] = [];
const config = { provider: "cursor_cloud", model: "caller-model", repoUrl: "https://github.com/example/qualification",
lifecycleMode: "per_turn", env: { CURSOR_API_KEY: "synthetic-cursor-key" } };
const fetch = vi.fn(async (input: unknown, init?: RequestInit) => {
const url = String(input);
if (url.startsWith("https://callback.example.test/mcp/native-runs/") && init?.method === "POST") return new Response(null, { status: 401 });
if (url === "https://api.cursor.com/v1/models" && init?.method === "GET") return new Response(JSON.stringify({ items: [{ id: config.model, displayName: "Caller model" }] }), { headers: { "Content-Type": "application/json" } });
throw new Error(`Unexpected provider request: ${init?.method} ${url}`);
});
beforeAll(async () => {
home = await mkdtemp(join(tmpdir(), "paperclip-provider-api-lifecycle-"));
vi.stubEnv("PAPERCLIP_HOME", home);
vi.stubEnv("PAPERCLIP_MANAGED_CONFIG", undefined);
vi.stubEnv("PAPERCLIP_SETTING_DEFAULTS", undefined);
vi.stubEnv("PAPERCLIP_PUBLIC_URL", "https://callback.example.test");
if (externalDatabaseUrl) await applyPendingMigrations(externalDatabaseUrl);
database = externalDatabaseUrl ? { connectionString: externalDatabaseUrl, cleanup: async () => {} }
: await startEmbeddedPostgresTestDatabase("paperclip-provider-api-lifecycle-");
db = createDb(database.connectionString);
await db.execute(sql`set client_min_messages = warning`);
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
beforeEach(async () => {
for (const worker of workers) await worker.stop();
await instanceSettingsService(db).update({ defaultEnvironmentId: null });
await db.execute(sql`truncate table companies, plugins, environments restart identity cascade`);
await instanceSettingsService(db).updateGeneral({ executionMode: "any" });
await instanceSettingsService(db).updateExperimental({ enableManagedSandboxOnly: true, enableNativeRunner: true });
[companyId] = await db.insert(companies).values({ name: "API controller activation", issuePrefix: "API" }).returning({ id: companies.id }).then(rows => rows.map(row => row.id));
fetch.mockClear();
vi.stubGlobal("fetch", fetch);
});
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); });
afterAll(async () => {
for (const worker of workers) await worker.stop();
await db?.$client.end();
await database?.cleanup();
vi.unstubAllEnvs();
if (home) await rm(home, { recursive: true, force: true });
});
const current = (id: string) => db.select().from(agents).where(eq(agents.id, id)).then(rows => rows[0]!);
async function saved(adapterType = "paperclip_runner", adapterConfig = config) {
return (await db.insert(agents).values({ companyId, name: "Saved controller", adapterType, adapterConfig,
status: "paused", lifecycleState: "verifying" }).returning())[0]!;
}
async function noComputerWork() {
expect(await db.select().from(environmentLeases)).toHaveLength(0);
expect(await db.select().from(executionWorkspaces)).toHaveLength(0);
expect(await db.select().from(heartbeatRuns)).toHaveLength(0);
}
it.each([false, true])("finishes the same held draft and reaches Ready with an experimental account WARN, inherited plugin Computer=%s", async defaultComputer => {
if (defaultComputer) {
const pluginId = randomUUID();
await db.insert(plugins).values({ id: pluginId, pluginKey: pluginId, packageName: "unneeded-computer", version: "1.0.0", status: "ready",
manifestJson: { capabilities: ["environment.drivers.register"], environmentDrivers: [{ driverKey: "tasks", requiresAgent: true, supportsTasks: true, warmTaskVersion: 1 }] } as never });
const [computer] = await db.insert(environments).values({ name: "Inherited Computer", driver: "plugin", status: "active",
config: { pluginKey: pluginId, driverKey: "tasks", driverConfig: {} } }).returning();
await instanceSettingsService(db).update({ defaultEnvironmentId: computer.id });
}
expect(await environmentService(db).findManagedSandboxEnvironment(companyId)).toBeNull();
const lifecycle = createAgentLifecycle(db);
const draft = await lifecycle.requestHire(companyId, { name: "Cursor setup", adapterType: "paperclip_runner",
adapterConfig: { provider: "cursor_cloud" } }, { setupOnly: true, runnerResolved: true });
expect(draft).toMatchObject({ status: "paused", lifecycleState: "paused", lifecycleHolds: ["setup"], defaultEnvironmentId: null });
await lifecycle.updateConfiguration(draft.id, { adapterConfig: config, runtimeConfig: { heartbeat: { enabled: false } } });
expect(await current(draft.id)).toMatchObject({ lifecycleState: "paused", lifecycleHolds: ["setup"] });
const finished = await lifecycle.requestHire(companyId, { id: draft.id, name: draft.name, adapterType: "paperclip_runner",
adapterConfig: config, runtimeConfig: { heartbeat: { enabled: false } }, defaultEnvironmentId: null }, { setupAgentId: draft.id, runnerResolved: true });
expect(finished.id).toBe(draft.id);
expect(finished.adapterConfig.lifecycleMode).toBe("per_turn");
const probe = vi.spyOn(requireServerAdapter("paperclip_runner"), "testEnvironment");
const driver = createLifecycleDriver(db, {} as never);
const worker = configureAgentLifecycle(db, driver);
workers.push(worker);
await worker.process(draft.id);
expect(await current(draft.id)).toMatchObject({ status: "idle", lifecycleState: "ready", lifecycleHolds: [], defaultEnvironmentId: null });
expect(probe).toHaveBeenCalledOnce();
expect(await probe.mock.results[0]!.value).toMatchObject({ status: "warn", checks: [
{ code: "cursor_cloud_account_ready", level: "info" },
{ code: "cursor_cloud_model_listed", level: "info" },
{ code: "cursor_cloud_model_identity_unverified", level: "warn" },
] });
expect(fetch.mock.calls.map(([url, input]) => [String(url), input?.method])).toEqual([
["https://callback.example.test/mcp/native-runs/00000000-0000-4000-8000-000000000000", "POST"],
["https://api.cursor.com/v1/models", "GET"],
]);
await noComputerWork();
});
it("keeps native Computer-backed setup preparation and warm-task defaults", async () => {
const pluginId = randomUUID();
await db.insert(plugins).values({ id: pluginId, pluginKey: pluginId, packageName: "required-computer", version: "1.0.0", status: "ready",
manifestJson: { capabilities: ["environment.drivers.register"], environmentDrivers: [{ driverKey: "tasks", requiresAgent: true, supportsTasks: true, warmTaskVersion: 1 }] } as never });
const [computer] = await db.insert(environments).values({ name: "Required Computer", driver: "plugin", status: "active",
config: { pluginKey: pluginId, driverKey: "tasks", driverConfig: {} } }).returning();
await instanceSettingsService(db).update({ defaultEnvironmentId: computer.id });
const draft = await createAgentLifecycle(db).requestHire(companyId, { name: "Codex setup", adapterType: "paperclip_runner",
adapterConfig: { provider: "codex", model: "gpt-5.6-luna", lifecycleMode: "per_turn" } }, { setupOnly: true, runnerResolved: true });
expect(draft).toMatchObject({ status: "paused", lifecycleState: "preparing", lifecycleHolds: ["setup"], adapterConfig: { lifecycleMode: "warm" } });
expect(fetch).not.toHaveBeenCalled();
await noComputerWork();
});
it("verifies a saved native controller without resolving an unavailable managed Computer", async () => {
const agent = await saved();
await expect(agentHarnessVerificationService(db, {} as never).verify(agent)).resolves.toBe("complete");
expect(fetch).toHaveBeenCalledTimes(2);
await noComputerWork();
});
it("rejects Kubernetes-only activation before any provider probe", async () => {
const agent = await saved();
await instanceSettingsService(db).updateGeneral({ executionMode: "kubernetes" });
await expect(agentHarnessVerificationService(db, {} as never).verify(agent)).rejects.toMatchObject({
status: 422, details: { code: "provider_api_kubernetes_execution_unsupported" },
});
await expect(createAgentLifecycle(db).requestHire(companyId, { name: "Forbidden hire", adapterType: "paperclip_runner", adapterConfig: config }, { runnerResolved: true }))
.rejects.toMatchObject({ status: 422, details: { code: "provider_api_kubernetes_execution_unsupported" } });
expect(fetch).not.toHaveBeenCalled();
await noComputerWork();
});
it("does not grant API placement to an incomplete active profile", async () => {
const agent = await saved("paperclip_runner", { ...config, model: "" });
await expect(agentHarnessVerificationService(db, {} as never).verify(agent)).rejects.toMatchObject({ code: "paperclip_runner_cursor_cloud_model_required" });
expect(fetch).not.toHaveBeenCalled();
await noComputerWork();
});
it.each(["opencode_local", "cursor_cloud"])("preserves the managed Computer requirement for %s", async adapterType => {
const agent = await saved(adapterType);
await expect(agentHarnessVerificationService(db, {} as never).verify(agent)).rejects.toMatchObject({ details: { code: "managed_sandbox_unavailable" } });
expect(fetch).not.toHaveBeenCalled();
await noComputerWork();
});
});
@@ -293,6 +293,177 @@ describe("agent test-environment route", () => {
await unregisterTestAdapter("external_test");
});
it("tests the saved Cursor API controller without allocating a Computer under managed-only policy", async () => {
const { registerServerAdapter, unregisterServerAdapter, getServerAdapter } = await import("../adapters/index.js");
const previous = getServerAdapter("paperclip_runner");
const probe = vi.fn(async () => ({ adapterType: "paperclip_runner", status: "warn" as const, testedAt: new Date().toISOString(),
checks: [{ code: "cursor_cloud_account_ready", level: "info" as const, message: "Synthetic account probe; no prompt." }] }));
registerServerAdapter({ ...externalAdapter, type: "paperclip_runner", testEnvironment: probe });
try {
const id = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
const adapterConfig = { provider: "cursor_cloud", model: "caller-model", repoUrl: "https://github.com/example/qualification", env: { CURSOR_API_KEY: "synthetic" } };
mockAgentService.getById.mockResolvedValue({ id, companyId: "company-1", adapterType: "paperclip_runner", adapterConfig, defaultEnvironmentId: "11111111-1111-4111-8111-111111111111" });
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableManagedSandboxOnly: true });
mockInstanceSettingsService.get.mockResolvedValue({ defaultEnvironmentId: "11111111-1111-4111-8111-111111111111" });
const app = await createApp();
const response = await request(app).post("/api/companies/company-1/adapters/paperclip_runner/test-environment").send({ agentId: id, adapterConfig });
expect(response.status, JSON.stringify(response.body)).toBe(200);
expect(response.body).toMatchObject({ status: "warn", checks: [{ code: "cursor_cloud_account_ready" }] });
expect(probe).toHaveBeenCalledWith(expect.objectContaining({ executionTarget: null, environmentName: null, config: adapterConfig }));
expect(mockEnvironmentRuntime.acquireRunLease).not.toHaveBeenCalled();
expect(mockEnvironmentRuntime.realizeWorkspace).not.toHaveBeenCalled();
expect(mockEnvironmentService.findManagedSandboxEnvironment).not.toHaveBeenCalled();
mockInstanceSettingsService.getGeneral.mockResolvedValue({ censorUsernameInLogs: false, executionMode: "kubernetes" });
const denied = await request(app).post("/api/companies/company-1/adapters/paperclip_runner/test-environment").send({ agentId: id, adapterConfig });
expect(denied.status).toBe(422);
expect(denied.body.details?.code ?? denied.body.code).toBe("provider_api_kubernetes_execution_unsupported");
expect(probe).toHaveBeenCalledOnce();
} finally { unregisterServerAdapter("paperclip_runner"); if (previous) registerServerAdapter(previous); }
});
async function mockCursorSwitchProbes() {
const { getServerAdapter } = await import("../adapters/index.js");
const legacy = vi.spyOn(getServerAdapter("cursor_cloud"), "testEnvironment").mockImplementation(async () => ({
adapterType: "cursor_cloud", status: "pass", checks: [], testedAt: new Date().toISOString(),
}));
const native = vi.spyOn(getServerAdapter("paperclip_runner"), "testEnvironment").mockImplementation(async () => ({
adapterType: "paperclip_runner", status: "warn", testedAt: new Date().toISOString(),
checks: [{ code: "cursor_cloud_account_ready", level: "info", message: "Synthetic no-prompt probe" }],
}));
return { legacy, native, restore: () => { legacy.mockRestore(); native.mockRestore(); } };
}
function savedCursorSwitchFixture() {
const id = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
const environmentId = "22222222-2222-4222-8222-222222222222";
const adapterConfig = { provider: "cursor_cloud", model: "caller-model", repoUrl: "https://github.com/example/qualification", env: {} };
mockAgentService.getById.mockResolvedValue({ id, companyId: "company-1", adapterType: "paperclip_runner",
adapterConfig, runtimeConfig: {}, defaultEnvironmentId: "11111111-1111-4111-8111-111111111111" });
const envVars = { CURSOR_API_KEY: { type: "secret_ref", secretId: "computer-key" } };
mockEnvironmentService.getById.mockResolvedValue({ id: environmentId, name: "Chosen Computer",
driver: "local", config: {}, envVars });
mockSecretService.resolveEnvBindings.mockResolvedValue({ env: { CURSOR_API_KEY: "chosen-computer-key" },
secretKeys: new Set(["CURSOR_API_KEY"]), manifest: [] });
return { id, environmentId, adapterConfig, envVars };
}
it.each([false, true])("tests a saved native Cursor's explicit Legacy switch on the selected Computer (legacyPrompt=%s)", async legacyPrompt => {
const probes = await mockCursorSwitchProbes();
const probe = probes.legacy;
try {
const f = savedCursorSwitchFixture();
const config = { ...f.adapterConfig, ...(legacyPrompt ? { promptTemplate: "Legacy prompt" } : {}) };
const response = await request(await createApp()).post("/api/companies/company-1/adapters/paperclip_runner/test-environment")
.send({ agentId: f.id, runner: "legacy", environmentId: f.environmentId, adapterConfig: config });
expect(response.status, JSON.stringify(response.body)).toBe(200);
expect(probe).toHaveBeenCalledWith(expect.objectContaining({ environmentName: "Chosen Computer", executionTarget: null,
config: expect.objectContaining({ model: "caller-model", env: { CURSOR_API_KEY: "chosen-computer-key" },
...(legacyPrompt ? { promptTemplate: "Legacy prompt" } : {}) }) }));
expect(probe.mock.calls[0][0].config).not.toHaveProperty("provider");
expect(mockSecretService.resolveEnvBindings).toHaveBeenCalledWith("company-1", f.envVars,
expect.objectContaining({ consumerType: "environment", consumerId: f.environmentId }));
expect(mockEnvironmentRuntime.acquireRunLease).not.toHaveBeenCalled();
expect(probes.native).not.toHaveBeenCalled();
} finally { probes.restore(); }
});
it("fails closed on a foreign Computer before credentials for an explicit Legacy switch", async () => {
const probes = await mockCursorSwitchProbes();
try {
const f = savedCursorSwitchFixture();
mockEnvironmentService.listBoundCompanyIds.mockResolvedValue(["company-2"]);
const response = await request(await createApp()).post("/api/companies/company-1/adapters/paperclip_runner/test-environment")
.send({ agentId: f.id, runner: "legacy", environmentId: f.environmentId,
adapterConfig: { ...f.adapterConfig, promptTemplate: "Legacy prompt" } });
expect(response.status).toBe(403);
expect(mockSecretService.resolveAdapterConfigForRuntime).not.toHaveBeenCalled();
expect(mockSecretService.resolveEnvBindings).not.toHaveBeenCalled();
expect(mockEnvironmentRuntime.acquireRunLease).not.toHaveBeenCalled();
expect(probes.legacy).not.toHaveBeenCalled();
expect(probes.native).not.toHaveBeenCalled();
} finally { probes.restore(); }
});
it("keeps managed-only policy for a Legacy switch rather than testing its API controller", async () => {
const probes = await mockCursorSwitchProbes();
const probe = probes.legacy;
try {
const f = savedCursorSwitchFixture();
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableManagedSandboxOnly: true });
const response = await request(await createApp()).post("/api/companies/company-1/adapters/paperclip_runner/test-environment")
.send({ agentId: f.id, runner: "legacy", environmentId: f.environmentId, adapterConfig: f.adapterConfig });
expect(response.status, JSON.stringify(response.body)).toBe(200);
expect(response.body).toMatchObject({ status: "fail", checks: expect.arrayContaining([
expect.objectContaining({ code: "managed_sandbox_unavailable", level: "error" }),
]) });
expect(probe).not.toHaveBeenCalled();
expect(mockEnvironmentRuntime.acquireRunLease).not.toHaveBeenCalled();
expect(probes.native).not.toHaveBeenCalled();
} finally { probes.restore(); }
});
it("keeps an explicit Paperclip Cursor test API-only despite a selected Computer", async () => {
const probes = await mockCursorSwitchProbes();
const probe = probes.native;
try {
const f = savedCursorSwitchFixture();
const settings = { defaultEnvironmentId: null, experimental: { enableManagedSandboxOnly: true, enableNativeRunner: true } };
mockInstanceSettingsService.get.mockResolvedValue(settings);
mockInstanceSettingsService.getExperimental.mockResolvedValue(settings.experimental);
const response = await request(await createApp()).post("/api/companies/company-1/adapters/paperclip_runner/test-environment")
.send({ agentId: f.id, runner: "paperclip", environmentId: f.environmentId, adapterConfig: f.adapterConfig });
expect(response.status, JSON.stringify(response.body)).toBe(200);
expect(probe).toHaveBeenCalledWith(expect.objectContaining({ executionTarget: null, environmentName: null,
config: expect.objectContaining({ provider: "cursor_cloud" }) }));
expect(mockEnvironmentService.getById).not.toHaveBeenCalled();
expect(mockSecretService.resolveEnvBindings).not.toHaveBeenCalled();
expect(mockEnvironmentRuntime.acquireRunLease).not.toHaveBeenCalled();
expect(probes.legacy).not.toHaveBeenCalled();
} finally { probes.restore(); }
});
it.each([
{ patch: { model: "" }, code: "paperclip_runner_cursor_cloud_model_required" },
{ patch: { command: "untrusted" }, code: "paperclip_runner_cursor_cloud_config_invalid" },
])("returns a useful validation error before Computer or credential work for invalid native Cursor input ($code)", async ({ patch, code }) => {
const app = await createApp();
const response = await request(app).post("/api/companies/company-1/adapters/paperclip_runner/test-environment").send({
adapterConfig: { provider: "cursor_cloud", model: "caller-model", repoUrl: "https://github.com/example/qualification", ...patch },
});
expect(response.status, JSON.stringify(response.body)).toBe(422);
expect(response.body.details?.code).toBe(code);
expect(mockEnvironmentRuntime.acquireRunLease).not.toHaveBeenCalled();
expect(mockEnvironmentRuntime.realizeWorkspace).not.toHaveBeenCalled();
expect(mockEnvironmentService.findManagedSandboxEnvironment).not.toHaveBeenCalled();
});
it.each([undefined, "auto"] as const)("tests the prospective managed Cursor API default without converting saved Legacy agents (%s)", async runner => {
const { getServerAdapter } = await import("../adapters/index.js");
const native = getServerAdapter("paperclip_runner");
const probe = vi.spyOn(native, "testEnvironment").mockImplementation(async () => ({ adapterType: "paperclip_runner", status: "warn" as const, testedAt: new Date().toISOString(),
checks: [{ code: "cursor_cloud_account_ready", level: "info" as const, message: "Synthetic account probe; no prompt." }] }));
try {
const settings = { defaultEnvironmentId: null, experimental: { enableManagedSandboxOnly: true, enableNativeRunner: true } };
mockInstanceSettingsService.get.mockResolvedValue(settings);
mockInstanceSettingsService.getExperimental.mockResolvedValue(settings.experimental);
const adapterConfig = { model: "caller-model", repoUrl: "https://github.com/example/qualification", env: { CURSOR_API_KEY: "synthetic" } };
const app = await createApp();
const response = await request(app).post("/api/companies/company-1/adapters/cursor_cloud/test-environment").send({ runner, adapterConfig });
expect(response.status, JSON.stringify(response.body)).toBe(200);
expect(response.body).toMatchObject({ adapterType: "paperclip_runner", checks: [{ code: "cursor_cloud_account_ready" }] });
expect(probe).toHaveBeenCalledWith(expect.objectContaining({ executionTarget: null, environmentName: null,
config: expect.objectContaining({ provider: "cursor_cloud", model: "caller-model" }) }));
expect(mockEnvironmentService.findManagedSandboxEnvironment).not.toHaveBeenCalled();
expect(mockEnvironmentRuntime.acquireRunLease).not.toHaveBeenCalled();
const id = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
mockAgentService.getById.mockResolvedValue({ id, companyId: "company-1", adapterType: "cursor_cloud", adapterConfig, defaultEnvironmentId: null });
const legacy = await request(app).post("/api/companies/company-1/adapters/cursor_cloud/test-environment").send({ agentId: id, adapterConfig });
expect(legacy.status).toBe(422);
expect(legacy.body.details?.code).toBe("managed_sandbox_unavailable");
expect(probe).toHaveBeenCalledOnce();
} finally { probe.mockRestore(); }
});
it("accepts a managed Dot environment without allocating an idle sandbox", async () => {
vi.stubEnv("PAPERCLIP_PUBLIC_URL", "https://paperclip.example");
mockInstanceSettingsService.getExperimental.mockResolvedValue({
@@ -285,11 +285,14 @@ describeEmbeddedPostgres("Mine inbox snapshot routes", () => {
const responses = await Promise.all(requests);
expect(responses.every(response => response.status === 200)).toBe(true);
if (coalesced) expect((await coalesced).headers["x-paperclip-request-cache"]).toBe("coalesced");
await request(app).get(snapshotPath).expect(200);
const computesBeforeFreshRead = started.mock.calls.length;
const fresh = await request(app).get(snapshotPath).expect(200);
// The aborted snapshot can outlive the seven ordinary reads. A coalesced
// response waits for that compute to settle before we require a fresh one.
const settled = await request(app).get(snapshotPath).expect(200);
const fresh = settled.headers["x-paperclip-request-cache"] === "coalesced"
? await request(app).get(snapshotPath).expect(200)
: settled;
expect(fresh.headers["x-paperclip-request-cache"]).toBe("miss");
expect(started).toHaveBeenCalledTimes(computesBeforeFreshRead + 1);
expect(started).toHaveBeenCalledTimes(9);
} finally {
release();
await aborted;
+116 -1
View File
@@ -32,8 +32,14 @@ import { terminalizeLegacyExecution } from "../services/legacy-execution-recover
import { issueService } from "../services/issues.js";
import { nativeRetryCancellationEligible, pendingFailedNativeRetryCancellationCondition } from "../services/native-runtime/native-cancellation-request.js";
import { requestNativeQuestionRunCancellation } from "../services/native-runtime/native-question-bridge.js";
import { readNativeLocalProcessStop } from "../services/native-local-process-stop.js";
import { readNativeLocalProcessStop, releaseSettledNativeRunProcess, settleNativeRunProcess } from "../services/native-local-process-stop.js";
import { DELIVERY_QUEUES } from "../services/delivery-work-notifications.js";
import { buildNativeExecutionInput } from "../services/native-runtime/native-execution-input.js";
import { nativeRuntimeContextFixture } from "../services/native-runtime/runtime-context.test-fixture.js";
import * as nativeStopEvidence from "../services/acknowledged-native-stop.js";
import { remoteTerminationReceipt } from "../services/remote-execution-termination.js";
import { subscribeCompanyLiveEvents } from "../services/live-events.js";
import type { LiveEvent } from "@paperclipai/shared";
import { logger } from "../middleware/logger.js";
vi.setConfig({ testTimeout: 90_000 });
@@ -295,6 +301,115 @@ describe("residual recovery production matrix", () => {
}, { timeout: 45_000 });
await competing.stop(); extraStops.pop(); await quiet();
});
it.each(["confirmed", "already_retired", "missing_terminal", "unproven_terminal", "ack_failure", "late_terminal"] as const)(
"settles an API-only Stop after deferred controller retirement without inventing proof: %s", async mode => {
const f = await fixture(), runId = randomUUID(), sessionId = randomUUID(), runnerId = randomUUID();
const statusEvents: LiveEvent[] = [];
const unsubscribe = subscribeCompanyLiveEvents(f.companyId, event => {
if (event.type === "heartbeat.run.status" && event.payload.runId === runId) statusEvents.push(event);
});
extraStops.push(async () => unsubscribe());
const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { detached: true, stdio: "ignore" });
await once(child, "spawn");
const stopChild = async () => {
if (child.exitCode !== null || child.signalCode !== null) return;
const exited = once(child, "exit"); child.kill("SIGTERM"); await exited;
};
extraStops.push(stopChild);
const recordedAt = new Date().toISOString();
const nativeExecutionInput = buildNativeExecutionInput({ companyId: f.companyId, agentId: f.agent.id, runId,
provider: "cursor_cloud", model: "explicit-model", cursorCloud: { schema: "paperclip.cursor-cloud.config.v1",
repos: [{ url: "https://github.com/example/qualification" }] },
issue: { id: f.issue.id, identifier: "API-1", title: f.issue.title, description: null, workMode: "standard" },
taskPrompt: "Stop the foreground command", normalizedSessionId: sessionId,
workspace: { id: randomUUID(), cwd: "/private/controller", repoUrl: null, repoRef: null, branchName: null },
completionContract: { id: randomUUID(), sha256: `sha256:${"a".repeat(64)}`, schemaVersion: "paperclip.run-result.v1",
contract: { revision: "1", objective: "Stop the foreground command", criteria: [{ id: "stop", requirement: "Stop the foreground command" }] } },
runtimeContext: nativeRuntimeContextFixture(),
});
await db.insert(heartbeatRuns).values({ id: runId, companyId: f.companyId, agentId: f.agent.id,
runtimeMode: "native", driverKind: "cursor_cloud_api", nativeIssueId: f.issue.id, nativeSessionId: sessionId,
runnerInstanceId: runnerId, status: "cancelled", finishedAt: new Date(),
runnerProfileJson: { backend: "cursor_cloud_api", nativeExecutionInput, sessionCheckpoint: { providerSessionId: "cloud-agent" },
executionPlacement: { kind: "provider_api", provider: "cursor_cloud", controller: "paperclip" } },
resultJson: { executionCancellation: { state: "requested", requestedAt: recordedAt },
cancelledByActorType: "user", cancelledByUserId: f.actorId, nativeCancellation: {
schema: "paperclip.native-cancellation.v1", companyId: f.companyId, runId, issueId: f.issue.id,
scope: "run", reasonCode: "cancellation_run_only", dispatchState: "acknowledged", dispatched: true,
effects: ["release_run_resources"], recordedAt, acknowledgedAt: recordedAt,
intentAuditId: randomUUID(), acknowledgementAuditId: randomUUID() } } });
await persistHeartbeatRunProcessMetadata(db, runId, { pid: child.pid!, processGroupId: child.pid!, startedAt: recordedAt, targetKind: "local" });
await db.insert(nativeRunFinalizations).values({ companyId: f.companyId, issueId: f.issue.id, runId, phase: "committed" });
const appendTerminal = async (target: typeof db = db) => {
const terminal = { schema: "paperclip.prp.event.v1", schemaVersion: 1, eventType: "turn.interrupted",
sourceKind: "runner", sourceInstanceId: runnerId, sourceEventId: `${runnerId}:${runId}:1`, sourceSeq: 1,
runId, turnId: "stopped-turn", normalizedSessionId: sessionId, emittedAt: new Date().toISOString(), priority: 1,
payload: { status: "interrupted", error: null, ...(mode === "unproven_terminal" ? { providerTerminalObserved: false } : {}) } };
await appendHeartbeatRunEvent(target, { companyId: f.companyId, agentId: f.agent.id, runId,
eventType: terminal.eventType, payload: { prpEvent: terminal }, nativeSource: {
sourceInstanceId: runnerId, sourceEventId: terminal.sourceEventId, sourceSeq: 1,
protocolSchemaVersion: 1, canonicalPayload: terminal } });
};
if (mode !== "missing_terminal" && mode !== "late_terminal") await appendTerminal();
expect(await db.select().from(environmentLeases).where(eq(environmentLeases.heartbeatRunId, runId))).toEqual([]);
expect(await settleNativeRunProcess(db, runId)).toBe("pending");
expect((await getRun(runId)).resultJson?.executionCancellation).toMatchObject({ state: "requested" });
expect(statusEvents).toEqual([]);
if (mode === "already_retired") { await stopChild(); expect(await releaseSettledNativeRunProcess(db, runId)).toBe("released"); }
await start();
if (mode !== "already_retired") {
await vi.waitFor(() => expect(deadlines.get(DELIVERY_QUEUES.settledNativeProcesses)?.()).toBeGreaterThan(Date.now()));
expect((await getRun(runId)).processPid).toBe(child.pid);
await stopChild();
}
const failure = new Error("synthetic Stop proof read failure after controller retirement");
if (mode === "ack_failure") vi.spyOn(nativeStopEvidence, "acknowledgedNativeApiStopExecutionHasStopped").mockRejectedValueOnce(failure);
await advance(31_000);
if (mode === "ack_failure") {
expect((await getRun(runId)).processPid).toBeNull();
expect((await getRun(runId)).resultJson?.executionCancellation).toMatchObject({ state: "requested" });
expect(errors).toHaveLength(1);
expect(errors[0]).toBeInstanceOf(AggregateError);
expect((errors[0] as AggregateError).errors).toEqual([failure]); errors.length = 0;
expect(statusEvents).toEqual([]);
await advance(31_000);
}
const stopped = await getRun(runId);
expect(stopped).toMatchObject({ processPid: null, processGroupId: null, processStartedAt: null });
expect(await readNativeLocalProcessStop(db, f.companyId, runId)).toMatchObject({ processPid: child.pid, processGroupId: child.pid });
expect(stopped.resultJson?.executionCancellation).toMatchObject(["missing_terminal", "unproven_terminal", "late_terminal"].includes(mode)
? { state: "requested" } : { state: "acknowledged", proof: "provider_api_stop_receipt" });
expect(statusEvents).toHaveLength(["missing_terminal", "unproven_terminal", "late_terminal"].includes(mode) ? 0 : 1);
await quiet();
if (mode === "late_terminal") {
const rollback = new Error("rollback the uncommitted terminal receipt");
await expect(db.transaction(async tx => { await appendTerminal(tx as unknown as typeof db); throw rollback; })).rejects.toBe(rollback);
await advance(1_000);
expect((await getRun(runId)).resultJson?.executionCancellation).toMatchObject({ state: "requested" });
expect(statusEvents).toEqual([]);
await quiet();
let commit!: () => void, entered!: () => void;
const gate = new Promise<void>(resolve => { commit = resolve; });
const recorded = new Promise<void>(resolve => { entered = resolve; });
const transaction = db.transaction(async tx => { await appendTerminal(tx as unknown as typeof db); entered(); await gate; });
await recorded;
try {
await vi.advanceTimersByTimeAsync(500); await immediate();
expect((await getRun(runId)).resultJson?.executionCancellation).toMatchObject({ state: "requested" });
expect(statusEvents).toEqual([]);
expect(await db.select().from(heartbeatRunEvents).where(and(eq(heartbeatRunEvents.runId, runId),
eq(heartbeatRunEvents.eventType, "turn.interrupted")))).toEqual([]);
} finally { commit(); await transaction; }
await vi.waitFor(async () => expect((await getRun(runId)).resultJson?.executionCancellation)
.toMatchObject({ state: "acknowledged", proof: "provider_api_stop_receipt" }));
await quiet();
expect(statusEvents).toHaveLength(1);
expect(statusEvents[0]).toMatchObject({ companyId: f.companyId, type: "heartbeat.run.status",
payload: { runId, agentId: f.agent.id, status: "cancelled" } });
expect(statusEvents[0]!.payload).not.toHaveProperty("resultJson");
}
},
);
it("releases a settled native process only after matching host and birth evidence arrives", async () => {
const f = await fixture(), startedAt = new Date();
const child = spawn(process.execPath, ["-e", "console.log('identity-proof')"], { stdio: "ignore" });
@@ -28,7 +28,9 @@ export async function updateAgentConfiguration(tx: Db, effects: LifecycleEffects
const prepareSetup = changed && current.lifecycleHolds.length === 1 && current.lifecycleHolds[0] === "setup"
&& ["paused", "preparing", "verifying"].includes(current.lifecycleState)
&& await effects.setupRequiresPreparation(tx, current.companyId,
Object.prototype.hasOwnProperty.call(patch, "defaultEnvironmentId") ? patch.defaultEnvironmentId : current.defaultEnvironmentId);
Object.prototype.hasOwnProperty.call(patch, "defaultEnvironmentId") ? patch.defaultEnvironmentId : current.defaultEnvironmentId,
{ adapterType: patch.adapterType ?? current.adapterType,
adapterConfig: Object.prototype.hasOwnProperty.call(patch, "adapterConfig") ? patch.adapterConfig : current.adapterConfig });
const invalidate = changed && (["preparing", "verifying", "resuming"].includes(current.lifecycleState) || prepareSetup);
if (invalidate) await effects.notifyWork(tx);
const [updated] = await tx.update(agents).set({ ...patch, updatedAt: new Date(), ...(invalidate ? {
@@ -6,7 +6,8 @@ import type { ActivityPublication } from "../../../types/activity-publication.js
/** Services supply integrations; lifecycle owns the transaction and state writes. */
export interface LifecycleEffects {
setupRequiresPreparation(db: Db, companyId: string, environmentId?: string | null): Promise<boolean>;
setupRequiresPreparation(db: Db, companyId: string, environmentId: string | null | undefined,
profile: Pick<CreateAgentData, "adapterType" | "adapterConfig">): Promise<boolean>;
notifyWork(db: Db): Promise<void>;
beforeStatusChange(db: Db, companyId: string, agentId: string, before: string, after: string): Promise<void>;
reportFailure(context: LifecycleFailure, error: unknown): void;
@@ -64,7 +64,7 @@ async function changeLockedAgent(tx: Db, effects: LifecycleEffects, agent: Lifec
if (command === "complete" && next === "ready" && holds.length) next = "pausing";
if (command === "complete" && next === "paused" && !holds.length) next = "resuming";
if (["complete", "reconcile"].includes(command) && next === "paused" && holds.length === 1 && holds[0] === "setup"
&& await effects.setupRequiresPreparation(tx, agent.companyId, agent.defaultEnvironmentId)) next = "preparing";
&& await effects.setupRequiresPreparation(tx, agent.companyId, agent.defaultEnvironmentId, agent)) next = "preparing";
if (next === agent.lifecycleState) {
if (JSON.stringify(holds) !== JSON.stringify(agent.lifecycleHolds) || agent.pauseReason !== (holds[0] ?? null)) {
const [updated] = await tx.update(agents).set({ lifecycleHolds: holds, pauseReason: holds[0] ?? null }).where(eq(agents.id, agent.id)).returning();
@@ -36,7 +36,7 @@ export function agentRecords(db: Db, effects: LifecycleEffects) {
const [company] = await tx.select({ status: companies.status }).from(companies).where(eq(companies.id, companyId));
const companyHolds = company?.status === "paused" ? ["company_paused"] : company?.status === "archived" ? ["company_archived"] : [];
const holds = [...(status === "paused" ? [pauseReason ?? "manual"] : []), ...companyHolds];
const prepareSetup = options?.setupOnly && !companyHolds.length && await effects.setupRequiresPreparation(txDb, companyId, prepared.defaultEnvironmentId);
const prepareSetup = options?.setupOnly && !companyHolds.length && await effects.setupRequiresPreparation(txDb, companyId, prepared.defaultEnvironmentId, prepared);
const lifecycleState = status === "pending_approval" ? "pending_approval" : status === "terminated" ? "terminated"
: prepareSetup ? "preparing" : status === "paused" ? "paused" : companyHolds.length ? "pausing" : "preparing";
if (lifecycleState === "preparing" || lifecycleState === "pausing") await effects.notifyWork(tx);
+13 -2
View File
@@ -48,7 +48,8 @@ import type {
} from "@paperclipai/adapter-utils";
import { loadExternalAdapterPackage, getUiParserSource, getOrExtractUiParserSource, reloadExternalAdapter } from "../adapters/plugin-loader.js";
import { logger } from "../middleware/logger.js";
import { badRequest, forbidden } from "../errors.js";
import { badRequest, forbidden, HttpError } from "../errors.js";
import { isCloudManagedInstance } from "../services/cloud-instance.js";
import { assertExternalAdapterCodeAllowed } from "../services/adapter-code-policy.js";
import { getHiddenSettings } from "../services/settings-visibility.js";
import { assertBoardOrgAccess, assertCompanyAccess, assertInstanceAdmin, getActorInfo } from "./authz.js";
@@ -281,7 +282,17 @@ export function adapterRoutes(options: {
if (typeof companyId === "string") {
assertCompanyAccess(_req, companyId);
if (!options.resolveRunnerTarget) throw badRequest("Scoped adapter discovery is unavailable.");
target = await options.resolveRunnerTarget(companyId, typeof environmentId === "string" ? environmentId : undefined);
try {
target = await options.resolveRunnerTarget(companyId, typeof environmentId === "string" ? environmentId : undefined);
} catch (error) {
// API controllers do not need a Computer. Keep their catalog visible
// when only the implicit managed Computer is unavailable. This target
// grants no local execution support to any workspace harness.
if (environmentId !== undefined || !(error instanceof HttpError) || error.status !== 422
|| (error.details as { code?: unknown } | undefined)?.code !== "managed_sandbox_unavailable") throw error;
target = { driver: "unavailable", ...((error.details as { executionMode?: unknown }).executionMode === "kubernetes"
? { executionMode: "kubernetes" as const } : {}) };
}
}
await waitForExternalAdapters();
+25 -2
View File
@@ -1,3 +1,4 @@
import { assertProviderApiPlacementPolicy, managedCursorApiDefaultEnabled, resolveProviderApiPlacement } from "../services/native-runtime/provider-api-placement.js";
import { readTaskPanelActivations } from "../services/plugin-task-panels.js";
import { enqueueNativeRuntimeResponse } from "../services/native-runtime/native-runtime-response-delivery.js";
import { latestFailedRunsByAgent } from "@paperclipai/shared";
@@ -1960,6 +1961,9 @@ export function agentRoutes(
return input.nextAdapterConfig;
}
if (["openai_dot", "muse"].includes(String(input.nextAdapterConfig.provider))) return input.nextAdapterConfig;
// A provisional setup harness supplies no Cursor model or permissions.
// Legacy Cursor conversion still retains its provider-specific defaults.
if (input.nextAdapterConfig.provider === "cursor_cloud" && input.previousAdapterType !== "cursor_cloud") return input.nextAdapterConfig;
const defaults = paperclipRunnerTransitionConfig(input.previousAdapterType, input.previousAdapterConfig.model, input.nextAdapterConfig.provider);
if (!["claude_local", "codex_local", "opencode_local", "pi_local", "grok_local"].includes(input.previousAdapterType)
&& !isPaperclipRunnerProvider(input.nextAdapterConfig.provider)) {
@@ -2205,6 +2209,7 @@ export function agentRoutes(
);
const incompleteModelSetup = input.setupOnly && input.adapterType === "paperclip_runner"
&& (normalizedAdapterConfig.provider === "opencode"
|| normalizedAdapterConfig.provider === "cursor_cloud"
|| (normalizedAdapterConfig.provider === "acpx" && ["pi", "kimi"].includes(String(normalizedAdapterConfig.acpxAgent))))
&& (normalizedAdapterConfig.model === undefined || normalizedAdapterConfig.model === "");
// Inactive drafts can precede model selection. Test/hire always validate the complete profile.
@@ -3274,7 +3279,25 @@ export function agentRoutes(
}
const testingSetup = savedAgent ? isAgentSetupDraft(savedAgent) : false;
const poolBinding = requestedBinding ?? (savedAgent?.runtimeConfig?.aiConnection ? aiRuntimeConnectionBindingSchema.parse(savedAgent.runtimeConfig.aiConnection) : undefined);
const requestedEnvironmentId = await resolveAdapterTestEnvironmentId(
const prospectiveCursorDefault = type === "cursor_cloud" && !savedAgent
&& (req.body.runner === undefined || req.body.runner === "auto")
&& managedCursorApiDefaultEnabled((await instanceSettings.get()).experimental);
const testRunner = prospectiveCursorDefault ? "paperclip" : req.body.runner;
let providerApiTest;
try {
// An explicit Legacy switch must resolve its Computer and credentials
// before runner selection converts the saved native adapter.
providerApiTest = testRunner === "legacy" ? null : resolveProviderApiPlacement({ agent: {
adapterType: type === "cursor_cloud" && testRunner === "paperclip" ? "paperclip_runner" : type,
adapterConfig: type === "cursor_cloud" && testRunner === "paperclip"
? { ...inputAdapterConfig, provider: "cursor_cloud" } : inputAdapterConfig,
} });
} catch (error) {
if (error instanceof PaperclipRunnerProviderProfileError) throw unprocessable(error.message, { code: error.code });
throw error;
}
if (providerApiTest) assertProviderApiPlacementPolicy(await instanceSettings.getGeneral());
const requestedEnvironmentId = providerApiTest ? null : await resolveAdapterTestEnvironmentId(
companyId,
// Omission tests the saved selection. Explicit null tests a prospective
// change back to the instance default.
@@ -3320,7 +3343,7 @@ export function agentRoutes(
? { adapterType: type, adapterConfig: adapterConfigForTest }
: await resolveNewAgentRunnerForCompany(db, companyId, {
adapterType: type, adapterConfig: adapterConfigForTest,
runner: req.body.runner,
runner: testRunner,
defaultEnvironmentId: requestedEnvironmentId,
});
type = selection.adapterType;
@@ -1,7 +1,12 @@
import { and, eq } from "drizzle-orm";
import { environmentLeases, heartbeatRuns, type Db } from "@paperclipai/db";
import { and, desc, eq, inArray, isNotNull } from "drizzle-orm";
import { environmentLeases, heartbeatRunEvents, heartbeatRuns, type Db } from "@paperclipai/db";
import { parseObject } from "../adapters/utils.js";
import { resolveProviderApiPlacement } from "./native-runtime/provider-api-placement.js";
import { hasNativeLocalProcessStop } from "./native-local-process-stop.js";
import { hasRemoteTerminationReceipt } from "./remote-execution-termination.js";
import { validatePrpEvent } from "../vendor/paperclip-runner/index.js";
import { nativeSha256 } from "./native-runtime/canonical.js";
import { isConfirmedCursorCloudTerminalObservation } from "./native-runtime/cursor-cloud-runtime.js";
/** A server-recorded run-only Stop must not manufacture a recovery incident. */
export function hasAcknowledgedNativeRunStopReceipt(run: {
@@ -37,6 +42,49 @@ export function isAcknowledgedNativeStop(run: Parameters<typeof hasAcknowledgedN
return run.status === "cancelled" && hasAcknowledgedNativeStopIntent(run);
}
/** API-owned execution has no Computer lease. Its provider terminal and retired
* local controller must both be recorded before publishing shared Stop proof. */
export async function acknowledgedNativeApiStopExecutionHasStopped(db: Db, run: Pick<typeof heartbeatRuns.$inferSelect,
"id" | "companyId" | "agentId" | "nativeIssueId" | "status" | "resultJson" | "processPid" | "processGroupId" |
"runtimeMode" | "driverKind" | "runnerProfileJson" | "nativeSessionId" | "runnerInstanceId">): Promise<boolean> {
if (run.runtimeMode !== "native" || run.driverKind !== "cursor_cloud_api" || !isAcknowledgedNativeStop(run) ||
run.processPid !== null || run.processGroupId !== null || !run.nativeIssueId || !run.nativeSessionId || !run.runnerInstanceId) return false;
const intent = parseObject(run.resultJson?.nativeCancellation);
const requestedAt = typeof intent.recordedAt === "string" ? Date.parse(intent.recordedAt) : NaN;
if (!Number.isFinite(requestedAt) || !Array.isArray(intent.effects) || intent.effects.length !== 1 ||
intent.effects[0] !== "release_run_resources" ||
!String(intent.intentAuditId ?? "").trim() || !String(intent.acknowledgementAuditId ?? "").trim()) return false;
const profile = parseObject(run.runnerProfileJson);
const execution = parseObject(profile.nativeExecutionInput);
const binding = parseObject(execution.binding), session = parseObject(execution.session);
if (profile.executionPlacement === undefined || profile.backend !== "cursor_cloud_api" ||
binding.companyId !== run.companyId || binding.runId !== run.id || binding.issueId !== run.nativeIssueId ||
binding.agentId !== run.agentId || session.normalizedSessionId !== run.nativeSessionId || session.driverKind !== "cursor_cloud_api") return false;
try {
if (!resolveProviderApiPlacement({ run, agent: { adapterType: "process", adapterConfig: {} } })) return false;
} catch { return false; }
const [lease] = await db.select({ id: environmentLeases.id }).from(environmentLeases).where(and(
eq(environmentLeases.companyId, run.companyId), eq(environmentLeases.heartbeatRunId, run.id),
)).limit(1);
if (lease || !await hasNativeLocalProcessStop(db, run.companyId, run.id)) return false;
const [terminal] = await db.select().from(heartbeatRunEvents).where(and(
eq(heartbeatRunEvents.companyId, run.companyId), eq(heartbeatRunEvents.runId, run.id),
isNotNull(heartbeatRunEvents.sourceEventId),
inArray(heartbeatRunEvents.eventType, ["turn.started", "turn.completed", "turn.failed", "turn.cancelled", "turn.interrupted"]),
)).orderBy(desc(heartbeatRunEvents.seq)).limit(1);
const validated = validatePrpEvent(parseObject(terminal?.payload).prpEvent);
if (!terminal || !validated.ok || terminal.sourcePayloadSha256 !== nativeSha256(validated.event)) return false;
const event = validated.event;
return isConfirmedCursorCloudTerminalObservation({ eventType: terminal.eventType, payload: event.payload }) && terminal.agentId === run.agentId &&
event.schema === "paperclip.prp.event.v1" && [1, 2].includes(Number(event.schemaVersion)) &&
terminal.protocolSchemaVersion === event.schemaVersion && event.eventType === terminal.eventType &&
event.sourceKind === "runner" && event.runId === run.id && event.normalizedSessionId === run.nativeSessionId &&
event.sourceInstanceId === run.runnerInstanceId && terminal.sourceInstanceId === event.sourceInstanceId &&
terminal.sourceEventId === event.sourceEventId && terminal.sourceSeq === event.sourceSeq &&
Number.isSafeInteger(event.sourceSeq) && Number(event.sourceSeq) > 0 &&
typeof event.turnId === "string" && Boolean(event.turnId);
}
/** A Stop acknowledgement alone does not prove provider cleanup completed. */
export async function acknowledgedNativeStopExecutionHasStopped(db: Db, run: Pick<typeof heartbeatRuns.$inferSelect,
"id" | "companyId" | "nativeIssueId" | "status" | "resultJson" | "processPid" | "processGroupId">) {
+15
View File
@@ -91,11 +91,26 @@ export function activityService(db: Db) {
`.as("usageJson");
// A denied user needs the selected connection's name to repair their task.
// Other readers need the connection's existing human sharing permission.
// A malformed present Stop receipt must stay unconfirmed, not disappear.
const summarizedResultJson = (viewerUserId: string | null) => sql<Record<string, unknown> | null>`
case
when ${heartbeatRuns.resultJson} is null then null
else jsonb_strip_nulls(jsonb_build_object(
'conversationReset', ${heartbeatRuns.resultJson} -> 'conversationReset',
'executionCancellation', case when ${heartbeatRuns.resultJson} ? 'executionCancellation'
then jsonb_build_object('state', case
when jsonb_typeof(${heartbeatRuns.resultJson} -> 'executionCancellation') = 'object'
and ${heartbeatRuns.resultJson} #>> '{executionCancellation,state}' in ('requested', 'acknowledged')
then ${heartbeatRuns.resultJson} #>> '{executionCancellation,state}' else 'unknown' end) end,
'nativeCancellation', case
when jsonb_typeof(${heartbeatRuns.resultJson} -> 'nativeCancellation') = 'object'
and ${heartbeatRuns.resultJson} #>> '{nativeCancellation,schema}' = 'paperclip.native-cancellation.v1'
and ${heartbeatRuns.resultJson} #>> '{nativeCancellation,runId}' = ${heartbeatRuns.id}::text
and ${heartbeatRuns.resultJson} #>> '{nativeCancellation,companyId}' = ${heartbeatRuns.companyId}::text
and ${heartbeatRuns.resultJson} #>> '{nativeCancellation,dispatchState}' in ('pending', 'acknowledged')
then jsonb_build_object('schema', 'paperclip.native-cancellation.v1',
'runId', ${heartbeatRuns.id}, 'companyId', ${heartbeatRuns.companyId},
'dispatchState', ${heartbeatRuns.resultJson} #>> '{nativeCancellation,dispatchState}') end,
'configurationIncomplete', case
when ${heartbeatRuns.resultJson} -> 'configurationIncomplete' ->> 'reason' = 'workspace_base_ref_unresolved'
then jsonb_build_object('reason', 'workspace_base_ref_unresolved')
@@ -8,7 +8,7 @@ import os from "node:os";
import path from "node:path";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../__tests__/helpers/embedded-postgres.js";
const legacy = vi.hoisted(() => ({ get: vi.fn(() => [] as string[]), set: vi.fn() }));
const legacy = vi.hoisted(() => ({ extraTypes: [] as string[], get: vi.fn(() => [] as string[]), set: vi.fn() }));
vi.mock("./adapter-plugin-store.js", () => ({
getDisabledAdapterTypes: legacy.get,
setAdapterDisabled: legacy.set,
@@ -16,7 +16,7 @@ vi.mock("./adapter-plugin-store.js", () => ({
getAdapterPluginByType: () => undefined,
}));
vi.mock("../adapters/registry.js", () => ({
listServerAdapters: () => [{ type: "codex_local" }, { type: "claude_local" }, { type: "paperclip_runner" }],
listServerAdapters: () => [{ type: "codex_local" }, { type: "claude_local" }, { type: "paperclip_runner" }, ...legacy.extraTypes.map(type => ({ type }))],
findServerAdapter: (type: string) => ({ type }),
findActiveServerAdapter: (type: string) => ({ type }),
isOverridePaused: () => false,
@@ -56,6 +56,7 @@ const support = externalDatabaseUrl ? { supported: true } : await getEmbeddedPos
beforeEach(async () => {
directory = await fs.mkdtemp(path.join(os.tmpdir(), "adapter-settings-import-"));
await db.delete(instanceSettings).where(eq(instanceSettings.singletonKey, "adapter-settings:v1"));
legacy.extraTypes = [];
legacy.get.mockClear();
legacy.set.mockClear();
vi.stubEnv("PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN", "synthetic-test-token");
@@ -188,6 +189,58 @@ const support = externalDatabaseUrl ? { supported: true } : await getEmbeddedPos
expect((await resolveNewAgentRunnerForCompany(db, "company", input)).adapterType).toBe("codex_local");
});
it("refreshes API-only Cursor policy from PostgreSQL and authoritative operator declarations", async () => {
legacy.extraTypes = ["cursor_cloud"];
await service().seedIfAbsent({ disabledTypes: [] });
await db.insert(instanceSettings).values({ singletonKey: "default", general: {},
experimental: { enableManagedSandboxOnly: true, enableNativeRunner: true } })
.onConflictDoUpdate({ target: instanceSettings.singletonKey, set: { general: {},
experimental: { enableManagedSandboxOnly: true, enableNativeRunner: true } } });
const draft = { adapterType: "cursor_cloud", runner: "auto" as const, setupOnly: true, adapterConfig: {}, disabledTypes: [] };
const saved = { adapterType: "paperclip_runner", adapterConfig: { provider: "cursor_cloud", model: "caller-model", repoUrl: "https://github.com/acme/repo", lifecycleMode: "per_turn" } };
const admitted = async (input = draft as typeof draft | typeof saved) => {
expect(await resolveNewAgentRunnerForCompany(db, "company", input))
.toMatchObject({ adapterType: "paperclip_runner", defaultEnvironmentId: null, adapterConfig: { provider: "cursor_cloud" } });
};
const denied = async (input = draft as typeof draft | typeof saved) => {
await expect(resolveNewAgentRunnerForCompany(db, "company", input))
.rejects.toMatchObject({ status: 422, details: { code: "agent_runner_unavailable" } });
};
await admitted(); // The default managed tenant has no Computer or CLI probe.
for (const type of ["cursor_cloud", "paperclip_runner"]) {
await adapterSettingsService(peer, managed).setDisabled(type, true);
await denied();
await denied(saved);
await adapterSettingsService(peer, managed).setDisabled(type, false);
await admitted();
}
await adapterSettingsService(peer, managed).setDisabled("cursor_cloud", true);
const stored = await service().inspectStored();
vi.stubEnv("PAPERCLIP_ADAPTERS", JSON.stringify([
{ adapterType: "cursor_cloud", enabled: true }, { adapterType: "paperclip_runner", enabled: true },
]));
await admitted(); // Operator allowlist supersedes, rather than unions, saved disables.
await admitted(saved);
expect(await service().inspectStored()).toEqual(stored);
for (const declaration of [
[{ adapterType: "cursor_cloud", enabled: true }],
[{ adapterType: "paperclip_runner", enabled: true }],
[{ adapterType: "cursor_cloud", enabled: false }, { adapterType: "paperclip_runner", enabled: true }],
[{ adapterType: "cursor_cloud", enabled: true }, { adapterType: "paperclip_runner", enabled: false }],
]) {
vi.stubEnv("PAPERCLIP_ADAPTERS", JSON.stringify(declaration));
await denied();
await denied(saved);
}
vi.stubEnv("PAPERCLIP_ADAPTERS", "invalid operator declaration");
await expect(resolveNewAgentRunnerForCompany(db, "company", draft)).rejects.toThrow();
vi.stubEnv("PAPERCLIP_ADAPTERS", undefined);
await denied(); // Removing the overlay restores the saved choice.
expect(await service().inspectStored()).toEqual(stored);
expect(legacy.get).not.toHaveBeenCalled();
expect(legacy.set).not.toHaveBeenCalled();
});
it("rolls back the setting when its audit write fails", async () => {
await expect(service().setDisabled("codex_local", true, async () => { throw new Error("audit failed"); })).rejects.toThrow("audit failed");
expect(await service().inspectStored()).toBeNull();
@@ -10,6 +10,8 @@ import { aiConnectionService } from "./ai-connections.js";
import { withManagedAiProbe, stripAiAuthBindings } from "./ai-connection-runtime.js";
import { environmentService } from "./environments.js";
import { secretService } from "./secrets.js";
import { instanceSettingsService } from "./instance-settings.js";
import { assertProviderApiPlacementPolicy, resolveProviderApiPlacement } from "./native-runtime/provider-api-placement.js";
import { museRunnerBroker } from "./muse-runner-broker.js";
import { dotRunnerBroker } from "./dot-runner-broker.js";
import type { PluginWorkerManager } from "./plugin-worker-manager.js";
@@ -51,6 +53,8 @@ export function agentHarnessVerificationService(db: Db, manager: PluginWorkerMan
const binding = await museRunnerBroker(db).bindingForAgent(agent.companyId,agent.id);
return binding?.status === "ready" && binding.paired && binding.receiverDetected && binding.backgroundReplyVerified && binding.id === agent.adapterConfig.museBindingId ? "complete" : "pending";
}
const providerApi = resolveProviderApiPlacement({ agent });
if (providerApi) assertProviderApiPlacementPolicy(await instanceSettingsService(db).getGeneral());
const [company] = await db.select().from(companies).where(eq(companies.id, agent.companyId));
const responsibleUserId = agent.lifecycleOperation?.responsibleUserId ?? company.defaultResponsibleUserId;
const context = { consumerType: "agent" as const, consumerId: agent.id, actorType: "system" as const,
@@ -66,7 +70,7 @@ export function agentHarnessVerificationService(db: Db, manager: PluginWorkerMan
}
if (binding) config = { ...config, env: stripAiAuthBindings(config.env) };
config = (await secrets.resolveAdapterConfigForRuntime(agent.companyId, config, context, { adapterType: agent.adapterType })).config;
const environmentId = await tests.resolveAdapterTestEnvironmentId(agent.companyId, agent.defaultEnvironmentId, agent.adapterType === "paperclip_runner" && config.provider === "acpx" && config.acpxAgent === "copilot");
const environmentId = providerApi ? null : await tests.resolveAdapterTestEnvironmentId(agent.companyId, agent.defaultEnvironmentId, agent.adapterType === "paperclip_runner" && config.provider === "acpx" && config.acpxAgent === "copilot");
if (environmentId) {
await tests.assertAdapterTestEnvironmentForCompany(agent.companyId, environmentId, agent.adapterType);
const environment = await environments.getById(environmentId);
@@ -81,7 +85,9 @@ export function agentHarnessVerificationService(db: Db, manager: PluginWorkerMan
adapterType: agent.adapterType, model: config.model, runnerProvider: config.provider,
acpxAgent: config.acpxAgent, binding,
});
const target = await tests.resolveAdapterTestExecutionContext({ agentId: agent.id, companyId: agent.companyId, adapterType: agent.adapterType, environmentId });
const target = providerApi
? { executionTarget: null, environmentName: null, fallbackChecks: [], release: async (_status?: "released" | "failed") => {} }
: await tests.resolveAdapterTestExecutionContext({ agentId: agent.id, companyId: agent.companyId, adapterType: agent.adapterType, environmentId });
let status: "released" | "failed" = "failed";
try {
if (target.fallbackChecks.length) throw Object.assign(new Error("The test environment is unavailable"), { code: "test_environment_unavailable" });
+7 -1
View File
@@ -17,6 +17,8 @@ import { agentCredentialService } from "./agent-credentials.js";
import { agentIdentityService } from "./agent-identity.js";
import { initializePrimaryAgent } from "./primary-agent.js";
import { resolveAgentRunnerTaskDriverForCompany, resolveNewAgentRunnerForCompany } from "./agent-runner-selection.js";
import { instanceSettingsService } from "./instance-settings.js";
import { assertProviderApiPlacementPolicy, isProviderApiSetupDraft, resolveProviderApiPlacement } from "./native-runtime/provider-api-placement.js";
export async function prepareAgentHire(db: Db, companyId: string, input: CreateAgentData, options?: CreateAgentOptions): Promise<CreateAgentData> {
await notifyAttentionRetentionWork(db, { companyId });
@@ -49,7 +51,11 @@ export async function prepareAgentHire(db: Db, companyId: string, input: CreateA
? await secretService(db).normalizeAdapterConfigForPersistence(companyId, data.adapterConfig, { adapterType })
: {};
const adapterConfig = normalizePaperclipRunnerAdapterConfig(adapterType, rawAdapterConfig);
if (adapterType === "paperclip_runner" && !["openai_dot", "grok_bot", "muse"].includes(String(adapterConfig.provider)) &&
const profile = { adapterType, adapterConfig };
const providerApiDraft = options?.setupOnly === true && isProviderApiSetupDraft(profile);
const providerApi = providerApiDraft ? null : resolveProviderApiPlacement({ agent: profile });
if (providerApi) assertProviderApiPlacementPolicy(await instanceSettingsService(db).getGeneral());
if (!providerApiDraft && !providerApi && adapterType === "paperclip_runner" && !["openai_dot", "grok_bot", "muse"].includes(String(adapterConfig.provider)) &&
(await resolveAgentRunnerTaskDriverForCompany(db, companyId, data.defaultEnvironmentId))?.warmTaskVersion === 1) {
adapterConfig.lifecycleMode = "warm";
}
+3 -1
View File
@@ -24,13 +24,15 @@ import { trackIdleWork } from "./task-admission.js";
import { environmentService } from "./environments.js";
import { instanceSettingsService } from "./instance-settings.js";
import { pluginEnvironmentRequiresAgent } from "./plugin-environment-driver.js";
import { isProviderApiSetupDraft } from "./native-runtime/provider-api-placement.js";
export { scheduleAgentLifecycle, processAgentLifecycle } from "../modules/agent-lifecycle/index.js";
export type { LifecycleDriver, LifecycleAgent } from "../modules/agent-lifecycle/index.js";
export function createAgentLifecycleEffects(_hooks: BudgetServiceHooks = {}): lifecycle.LifecycleEffects {
return {
async setupRequiresPreparation(db, companyId, environmentId) {
async setupRequiresPreparation(db, companyId, environmentId, profile) {
if (isProviderApiSetupDraft(profile)) return false;
const id = environmentId ?? (await instanceSettingsService(db).get()).defaultEnvironmentId;
const environment = id ? await environmentService(db).getById(id) : null;
if (!environment || !await pluginEnvironmentRequiresAgent(db, environment)) return false;
@@ -46,18 +46,96 @@ describe("server-owned Codex runner selection", () => {
const config = { model: "caller-model", repoUrl: "https://github.com/acme/repo", repoStartingRef: "feature", runtimeEnvType: "cloud", env: { CURSOR_API_KEY: { type: "secret_ref", secretId: "saved-key", version: "latest" } }, instructionsFilePath: "/assigned/AGENTS.md", autoCreatePR: false };
expect(agentRunnerAvailability("cursor_cloud", target)).toEqual({ supportedRunners: ["paperclip", "legacy"], defaultRunner: "legacy" });
expect(resolveNewAgentRunner({ adapterType: "cursor_cloud", adapterConfig: config, target })).toEqual({ adapterType: "cursor_cloud", adapterConfig: config });
for (const runner of [undefined, "auto"] as const) {
for (const driver of ["unavailable", "sandbox", "ssh"]) {
expect(() => resolveNewAgentRunner({ adapterType: "cursor_cloud", adapterConfig: config, runner, target: { driver } }))
.toThrow(expect.objectContaining({ status: 422, details: { code: "agent_runner_unavailable" } }));
}
}
expect(resolveNewAgentRunner({ adapterType: "cursor_cloud", adapterConfig: config, runner: "legacy", target: { driver: "sandbox" } }))
.toEqual({ adapterType: "cursor_cloud", adapterConfig: config });
const converted = resolveNewAgentRunner({ adapterType: "cursor_cloud", adapterConfig: config, runner: "paperclip", target });
expect(converted).toEqual({ adapterType: "paperclip_runner", adapterConfig: { model: config.model, repoUrl: config.repoUrl, repoRef: "feature", cloudEnvType: "cloud", env: config.env, instructionsFilePath: config.instructionsFilePath, autoCreatePR: false, provider: "cursor_cloud", lifecycleMode: "per_turn" } });
expect(resolveNewAgentRunner({ ...converted, runner: "legacy", target })).toEqual({ adapterType: "cursor_cloud", adapterConfig: config });
});
it("rejects unsupported Cursor Cloud conversions and remote controllers without changing the original", () => {
it("rejects unsupported Cursor Cloud settings and keeps API placement independent of the selected Computer", () => {
const config = { model: "caller-model", repoUrl: "https://github.com/acme/repo" };
for (const patch of [{ promptTemplate: "custom instructions" }, { runtimeEnvType: "pool" }, { model: "" }]) {
expect(() => resolveNewAgentRunner({ adapterType: "cursor_cloud", adapterConfig: { ...config, ...patch }, runner: "paperclip", target })).toThrow();
}
expect(config).toEqual({ model: "caller-model", repoUrl: "https://github.com/acme/repo" });
expect(agentRunnerAvailability("cursor_cloud", { ...target, driver: "ssh" })).toEqual({ supportedRunners: ["legacy"], defaultRunner: "legacy" });
expect(() => resolveNewAgentRunner({ adapterType: "cursor_cloud", adapterConfig: config, runner: "paperclip", target: { ...target, driver: "ssh" } })).toThrow("unavailable");
expect(agentRunnerAvailability("cursor_cloud", { ...target, driver: "ssh" })).toEqual({ supportedRunners: ["paperclip"], defaultRunner: "paperclip" });
expect(resolveNewAgentRunner({ adapterType: "cursor_cloud", adapterConfig: config, runner: "paperclip", target: { ...target, driver: "ssh" } })).toMatchObject({ adapterType: "paperclip_runner", adapterConfig: { provider: "cursor_cloud" } });
});
it("saves native Cursor Cloud without probing or binding a managed Computer", async () => {
state.settings = { defaultEnvironmentId: "managed", experimental: { enableManagedSandboxOnly: true } };
const saved = await resolveNewAgentRunnerForCompany({} as never, "company", {
adapterType: "cursor_cloud", runner: "paperclip", defaultEnvironmentId: "managed",
adapterConfig: { model: "caller-model", repoUrl: "https://github.com/acme/repo" },
});
expect(saved).toMatchObject({ adapterType: "paperclip_runner", defaultEnvironmentId: null, adapterConfig: { provider: "cursor_cloud" } });
expect(state.getEnvironment).not.toHaveBeenCalled();
expect(state.ssh).not.toHaveBeenCalled();
expect(state.sandboxQualification).not.toHaveBeenCalled();
});
it("refreshes managed adapter policy before API-only Cursor admission without a Computer", async () => {
vi.stubEnv("PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN", "synthetic-cloud-test-token");
state.settings = { experimental: { enableManagedSandboxOnly: true, enableNativeRunner: true } };
state.managedEnvironment.mockResolvedValue(null);
const input = { adapterType: "cursor_cloud", runner: "auto" as const, setupOnly: true, adapterConfig: {}, disabledTypes: [] };
expect(await resolveNewAgentRunnerForCompany({} as never, "company", input))
.toMatchObject({ adapterType: "paperclip_runner", defaultEnvironmentId: null, adapterConfig: { provider: "cursor_cloud" } });
for (const disabled of ["cursor_cloud", "paperclip_runner"]) {
state.disabled = [disabled];
await expect(resolveNewAgentRunnerForCompany({} as never, "company", input))
.rejects.toMatchObject({ status: 422, details: { code: "agent_runner_unavailable" } });
}
await expect(resolveNewAgentRunnerForCompany({} as never, "company", {
adapterType: "paperclip_runner", adapterConfig: { provider: "cursor_cloud", model: "caller-model", repoUrl: "https://github.com/acme/repo", lifecycleMode: "per_turn" },
})).rejects.toMatchObject({ status: 422, details: { code: "agent_runner_unavailable" } });
expect(state.managedEnvironment).not.toHaveBeenCalled();
expect(state.getEnvironment).not.toHaveBeenCalled();
expect(state.resolveEnvironment).not.toHaveBeenCalled();
expect(state.ssh).not.toHaveBeenCalled();
expect(state.sandboxQualification).not.toHaveBeenCalled();
});
it.each([undefined, "auto"] as const)("defaults a new managed Cursor setup draft to the native API controller without a Computer (%s)", async runner => {
state.settings = { experimental: { enableManagedSandboxOnly: true, enableNativeRunner: true } };
state.managedEnvironment.mockResolvedValue(null);
const saved = await resolveNewAgentRunnerForCompany({} as never, "company", {
adapterType: "cursor_cloud", runner, setupOnly: true, adapterConfig: {},
});
expect(saved).toMatchObject({ adapterType: "paperclip_runner", defaultEnvironmentId: null,
adapterConfig: { provider: "cursor_cloud", lifecycleMode: "per_turn" } });
expect(state.managedEnvironment).not.toHaveBeenCalled();
expect(state.getEnvironment).not.toHaveBeenCalled();
});
it.each([
{ setupOnly: false, enabled: true },
{ setupOnly: true, enabled: false },
])("does not use the new-draft Cursor API default outside its admission scope (%j)", async ({ setupOnly, enabled }) => {
state.settings = { experimental: { enableManagedSandboxOnly: true, enableNativeRunner: enabled } };
state.managedEnvironment.mockResolvedValue(null);
await expect(resolveNewAgentRunnerForCompany({} as never, "company", {
adapterType: "cursor_cloud", runner: "auto", setupOnly, adapterConfig: {},
})).rejects.toMatchObject({ status: 422, details: { code: "managed_sandbox_unavailable" } });
});
it("preserves an explicit Legacy choice on a held Cursor setup draft", async () => {
state.settings = { experimental: { enableManagedSandboxOnly: true, enableNativeRunner: true } };
expect(await resolveNewAgentRunnerForCompany({} as never, "company", {
adapterType: "cursor_cloud", runner: "legacy", setupOnly: true, adapterConfig: {},
})).toEqual({ adapterType: "cursor_cloud", adapterConfig: {} });
expect(state.managedEnvironment).not.toHaveBeenCalled();
});
it("refuses native Cursor save and availability under Kubernetes-only policy without provisioning", async () => {
state.settings = { general: { executionMode: "kubernetes" } };
await expect(resolveNewAgentRunnerForCompany({} as never, "company", { adapterType: "cursor_cloud", runner: "paperclip",
adapterConfig: { model: "caller-model", repoUrl: "https://github.com/acme/repo" } }))
.rejects.toMatchObject({ status: 422, details: { code: "provider_api_kubernetes_execution_unsupported" } });
const selected = await resolveAgentRunnerTargetForCompany({} as never, "company");
expect(agentRunnerAvailability("cursor_cloud", selected).supportedRunners).toEqual([]);
expect(state.getEnvironment).not.toHaveBeenCalled();
expect(state.ensureKubernetesEnvironment).not.toHaveBeenCalled();
});
it("keeps legacy Cursor Cloud environment bindings and rejects their unsupported native conversion", () => {
const config = { model: "caller-model", repoUrl: "https://github.com/acme/repo",
@@ -389,7 +467,7 @@ describe("server-owned Codex runner selection", () => {
it("advertises Legacy for a forced Kubernetes target before lazy environment creation", async () => {
state.settings = { general: { executionMode: "kubernetes" } };
const selected = await resolveAgentRunnerTargetForCompany({} as never, "company");
expect(selected).toEqual({ driver: "sandbox" });
expect(selected).toEqual({ driver: "sandbox", executionMode: "kubernetes" });
expect(agentRunnerAvailability("codex_local", selected, state.disabled)).toEqual({ supportedRunners: ["legacy"], defaultRunner: "legacy" });
expect(state.kubernetesEnvironment).toHaveBeenCalledWith("company");
expect(state.ensureKubernetesEnvironment).not.toHaveBeenCalled();
+58 -14
View File
@@ -1,3 +1,4 @@
import { assertProviderApiPlacementPolicy, managedCursorApiDefaultEnabled, resolveProviderApiPlacement } from "./native-runtime/provider-api-placement.js";
import type { Db } from "@paperclipai/db";
import { agentHarnessType, paperclipRunnerProfileForHarness, isEnvironmentDriverSupportedForAdapter, paperclipRunnerSupportsPlatform, type AgentRunnerChoice, type AgentRunnerAvailability } from "@paperclipai/shared";
import { normalizeLegacyRunnerProvider, PAPERCLIP_RUNNER_DEFAULT_MODELS } from "@paperclipai/adapter-utils";
@@ -16,6 +17,7 @@ import { localAgentRunnerTarget } from "../lib/agent-runner-platform.js";
import { normalizeClaudeRunnerOptions, type AgentConfigurationWarning } from "@paperclipai/shared";
import { usesManagedCloudSandboxImage } from "./sandbox-snapshot-qualification.js";
import { environmentCustomImageTemplateMatchesBaseConfig } from "./environment-custom-image-runtime.js";
import type { ExecutionPolicy } from "./execution-allowlist.js";
export type RunnerTarget = {
driver: string; platform?: string; architecture?: string; libc?: "glibc" | "musl" | "unknown"; glibcVersion?: string;
@@ -23,6 +25,7 @@ export type RunnerTarget = {
runnerTasks?: boolean;
managedCloudImage?: boolean;
warmTaskVersion?: 1;
executionMode?: ExecutionPolicy["executionMode"];
};
export const AGENT_RUNNER_SSH_PLATFORM_COMMAND = "uname -s; uname -m; if paperclip_glibc_version=$(getconf GNU_LIBC_VERSION 2>/dev/null) && [ -n \"$paperclip_glibc_version\" ]; then printf 'glibc\\n%s\\n' \"$paperclip_glibc_version\"; else case \"$(ldd --version 2>&1)\" in *musl*) printf 'musl\\n' ;; *) printf 'unknown\\n' ;; esac; fi";
export interface AgentRunnerSelection {
@@ -52,10 +55,15 @@ function runnerAdaptersAvailable(harness: string, driver: string, disabledTypes:
export function agentRunnerAvailability(harness: string, target: RunnerTarget = localAgentRunnerTarget(), disabledTypes: string[] = localDisabledTypes()): AgentRunnerAvailability {
if (harness === "cursor_cloud") {
const supported = target.driver === "local" && runnerAdaptersAvailable(harness, target.driver, disabledTypes)
&& (!target.platform || paperclipRunnerSupportsPlatform(harness, target.platform, target.architecture ?? "", target.libc, target.glibcVersion));
return { supportedRunners: supported ? ["paperclip", "legacy"] : ["legacy"], defaultRunner: "legacy" };
if (target.executionMode === "kubernetes") return { supportedRunners: [], defaultRunner: "legacy" };
const controller = localAgentRunnerTarget();
const supported = runnerAdaptersAvailable(harness, "local", disabledTypes)
&& paperclipRunnerSupportsPlatform(harness, controller.platform, controller.architecture, controller.libc, controller.glibcVersion);
const legacy = isEnvironmentDriverSupportedForAdapter(harness, target.driver);
return { supportedRunners: [...(supported ? ["paperclip" as const] : []), ...(legacy ? ["legacy" as const] : [])],
defaultRunner: legacy || !supported ? "legacy" : "paperclip" };
}
if (target.driver === "unavailable") return { supportedRunners: [], defaultRunner: "legacy" };
if (target.runnerTasks || target.managedCloudImage) {
return runnerAdaptersAvailable(harness, target.driver, disabledTypes)
? { supportedRunners: target.runnerTasks ? ["paperclip"] : ["paperclip", "legacy"], defaultRunner: "paperclip" }
@@ -187,10 +195,16 @@ function resolveAgentRunner(input: AgentRunnerSelection): { adapterType: string;
if (native && (input.runner === undefined || input.runner === "paperclip" || (harness !== "codex_local" && input.runner !== "legacy"))) return { adapterType, adapterConfig: normalizeLegacyRunnerProvider(config) };
const availability = agentRunnerAvailability(harness, input.target, disabledTypes);
const choice = input.runner ?? "auto";
const useNative = choice === "paperclip" || (choice === "auto" && availability.defaultRunner === "paperclip");
const useNative = choice === "paperclip" || (choice === "auto" && harness !== "cursor_cloud" && availability.defaultRunner === "paperclip");
if (!useNative) {
if (harness === "cursor_cloud") {
if (!native) return { adapterType, adapterConfig: config };
if (!native) {
if (choice !== "legacy" && !availability.supportedRunners.includes("legacy")) throw unprocessable(
"Automatic Cursor selection is unavailable for this environment. Choose a supported runner in Advanced.",
{ code: "agent_runner_unavailable" },
);
return { adapterType, adapterConfig: config };
}
if (config.lifecycleMode !== undefined && config.lifecycleMode !== "per_turn") incompatible(["lifecycleMode"]);
for (const [current, legacy] of [["cloudEnvType", "runtimeEnvType"], ["cloudEnvName", "runtimeEnvName"], ["repoRef", "repoStartingRef"], ["prUrl", "repoPullRequestUrl"]]) {
if (config[current] !== undefined) config[legacy] = config[current];
@@ -363,7 +377,33 @@ function resolveAgentRunner(input: AgentRunnerSelection): { adapterType: string;
}
export async function resolveNewAgentRunnerForCompany(db: Db, companyId: string, input: AgentRunnerSelection & { defaultEnvironmentId?: string | null }) {
input = { ...input, disabledTypes: await adapterSettingsService(db).getDisabledTypes() };
const disabledTypes = await adapterSettingsService(db).getDisabledTypes();
input = { ...input, disabledTypes };
const cursor = agentHarnessType(input.adapterType ?? "process", input.adapterConfig) === "cursor_cloud";
const nativeCursor = cursor && input.runner !== "legacy"
&& (input.adapterType === "paperclip_runner" || input.runner === "paperclip");
const cursorSetupDefault = cursor && input.setupOnly === true
&& (input.runner === undefined || input.runner === "auto");
if (nativeCursor || cursorSetupDefault) {
const { instanceSettingsService } = await import("./instance-settings.js");
const settings = await instanceSettingsService(db).get();
// Only a held new setup may adopt the API-only managed default. Existing
// legacy agents still require an explicit conversion decision.
const useSetupDefault = cursorSetupDefault && managedCursorApiDefaultEnabled(settings.experimental);
if (nativeCursor || useSetupDefault) {
if (disabledTypes.includes("cursor_cloud") || disabledTypes.includes("paperclip_runner")) {
throw unprocessable("Cursor Cloud is disabled on this instance. Enable its adapter or choose another harness.", { code: "agent_runner_unavailable" });
}
assertProviderApiPlacementPolicy(settings.general ?? {});
const selected = resolveNewAgentRunner({ ...input, ...(useSetupDefault ? { runner: "paperclip" as const } : {}), target: localAgentRunnerTarget() });
try { if (!input.setupOnly) resolveProviderApiPlacement({ agent: selected }); }
catch (error) {
if (error instanceof PaperclipRunnerProviderProfileError) throw unprocessable(error.message, { code: error.code });
throw error;
}
return { ...selected, defaultEnvironmentId: null };
}
}
if (!paperclipRunnerProfileForHarness(agentHarnessType(input.adapterType ?? "process", input.adapterConfig))
|| input.runner === "legacy" || (input.adapterType === "paperclip_runner" && (input.runner === undefined || input.runner === "paperclip"))) return resolveNewAgentRunner(input);
const target = await resolveAgentRunnerTargetForCompany(db, companyId, input.defaultEnvironmentId, input.disabledTypes);
@@ -372,7 +412,8 @@ export async function resolveNewAgentRunnerForCompany(db: Db, companyId: string,
export async function resolveAgentRunnerTargetForCompany(db: Db, companyId: string, defaultEnvironmentId?: string | null, disabledTypes?: string[]): Promise<RunnerTarget> {
await waitForExternalAdapters();
const { environment, driver } = await resolveAgentRunnerEnvironmentForCompany(db, companyId, defaultEnvironmentId);
const { environment, driver, executionMode } = await resolveAgentRunnerEnvironmentForCompany(db, companyId, defaultEnvironmentId);
const withPolicy = (value: RunnerTarget): RunnerTarget => ({ ...value, ...(executionMode ? { executionMode } : {}) });
let target: RunnerTarget = driver === "local" ? localAgentRunnerTarget()
: { driver };
if (driver === "plugin" && environment) {
@@ -386,16 +427,16 @@ export async function resolveAgentRunnerTargetForCompany(db: Db, companyId: stri
environmentId: environment.id,
provider: typeof environment.config.provider === "string" ? environment.config.provider : null,
});
if (!template?.templateRef) return { driver, managedCloudImage: true };
if (!template?.templateRef) return withPolicy({ driver, managedCloudImage: true });
try {
const parsed = parseEnvironmentDriverConfig(environment);
if (parsed.driver === "sandbox" && !environmentCustomImageTemplateMatchesBaseConfig({
template,
baseConfig: parsed.config,
secretRefExcludePaths: await resolveSandboxProviderSecretRefPaths(db, template.provider),
})) return { driver, managedCloudImage: true };
})) return withPolicy({ driver, managedCloudImage: true });
} catch {
return { driver };
return withPolicy({ driver });
}
}
const { readSandboxPlatformQualification } = await import("./environment-probe.js");
@@ -413,7 +454,7 @@ export async function resolveAgentRunnerTargetForCompany(db: Db, companyId: stri
const [os = "", arch = "", libc, glibcVersion] = output.trim().split(/\r?\n/);
target = { driver, platform: os === "Linux" ? "linux" : os === "Darwin" ? "darwin" : "unknown", architecture: arch === "x86_64" ? "x64" : ["aarch64", "arm64"].includes(arch) ? "arm64" : arch, ...(os === "Linux" && libc !== undefined ? { libc: libc === "glibc" || libc === "musl" ? libc : "unknown", ...(libc === "glibc" && glibcVersion !== undefined ? { glibcVersion } : {}) } : {}) };
}
return target;
return withPolicy(target);
}
export async function resolveAgentRunnerEnvironmentForCompany(db: Db, companyId: string, defaultEnvironmentId?: string | null) {
@@ -432,16 +473,19 @@ async function resolveAgentEnvironmentForCompany(db: Db, companyId: string, defa
const { instanceSettingsService } = await import("./instance-settings.js");
const { environmentService } = await import("./environments.js");
const settings = await instanceSettingsService(db).get();
const executionMode = settings.general?.executionMode;
const envs = environmentService(db);
const environmentId = defaultEnvironmentId ?? settings.defaultEnvironmentId;
let environment = environmentId ? await envs.getById(environmentId) : null;
if (settings.experimental?.enableManagedSandboxOnly && (!environment || environment.driver === "local")) {
environment = await envs.findManagedSandboxEnvironment(companyId);
if (!environment) throw unprocessable("The managed sandbox is unavailable. Restore Paperclip Computer and retry.", { code: "managed_sandbox_unavailable" });
if (!environment) throw unprocessable("The managed sandbox is unavailable. Restore Paperclip Computer and retry.", {
code: "managed_sandbox_unavailable", ...(executionMode ? { executionMode } : {}),
});
}
if (settings.general?.executionMode === "kubernetes") {
environment = await envs.findKubernetesEnvironment(companyId);
if (!environment) return { environment: null, driver: "sandbox" };
if (!environment) return { environment: null, driver: "sandbox", executionMode };
}
if (environmentId && !environment) throw unprocessable("Environment not found.");
if (environment) {
@@ -449,7 +493,7 @@ async function resolveAgentEnvironmentForCompany(db: Db, companyId: string, defa
if (boundCompanyIds.length > 0 && !boundCompanyIds.includes(companyId)) throw forbidden("The selected environment belongs to another company.", { code: "environment_company_mismatch" });
if (environment.status === "archived") throw unprocessable("Environment is archived.");
}
return { environment, driver: environment?.driver ?? "local" };
return { environment, driver: environment?.driver ?? "local", executionMode };
}
// The plugin's manifest declaration decides whether its environment runs
+4 -1
View File
@@ -79,7 +79,10 @@ export async function appendHeartbeatRunEvent(
input: AppendHeartbeatRunEventInput,
): Promise<AppendHeartbeatRunEventResult> {
const projectsRetention = input.eventType === "lifecycle" && Boolean(input.message?.startsWith(RETRY_EXHAUSTION_MESSAGE_PREFIX));
const projectsRecovery = ["native.process_start_requested", "native.process_identity_recorded", "native.local_process_stopped"].includes(input.eventType);
// Terminal proof may arrive after controller retirement and a quiet Stop
// check. Register the scoped wake before writing it, in the same transaction.
const projectsRecovery = ["native.process_start_requested", "native.process_identity_recorded", "native.local_process_stopped"].includes(input.eventType)
|| Boolean(input.nativeSource && ["turn.completed", "turn.failed", "turn.cancelled", "turn.interrupted"].includes(input.eventType));
const projectsReassessment = readNativeEventEvidenceOutcome(input.payload?.prpEvent) !== null;
const projectsProgress = isSafeNativeChatProgressEventType(input.eventType);
const projectsResponse = isNativeResponseEvidence(input.eventType, input.payload?.prpEvent);
+11 -6
View File
@@ -1,3 +1,4 @@
import { resolveProviderApiPlacement } from "./native-runtime/provider-api-placement.js";
import { registerDeferredReceiptWork } from "./heartbeat/deferred-receipt-work.js";
import { reconcileTaskChatRetryReceipts } from "./task-retry.js";
import { registerIssueRecoveryWork } from "./recovery/work.js";
@@ -2266,7 +2267,8 @@ export function heartbeatService(
const defaultIsolatedWorkspacesEnabled =
isolatedWorkspacesEnabled &&
experimentalInstanceSettings.enableIsolatedWorkspacesByDefault;
const externalProvider = heartbeatExternalProvider({ run, agent });
const providerApiPlacement = resolveProviderApiPlacement({ run, agent });
const externalProvider = heartbeatExternalProvider({ run, agent }) ?? providerApiPlacement?.provider;
const [taskWorkspaceIntentRow] = issueId && !externalProvider ? await db.select({ intent: issues.workspaceSelection }).from(issues)
.where(and(eq(issues.id, issueId), eq(issues.companyId, agent.companyId))) : [];
const freshWorkspace = await readFreshWorkspaceReadiness(db, run);
@@ -2968,7 +2970,7 @@ export function heartbeatService(
runtimeConfig = { ...runtimeConfig, env: gitExecutionEnv };
for (const key of MANAGED_GITHUB_TOKEN_KEYS) secretKeys.add(key);
context.githubAuthenticationMode = useHostGitHub ? "host" : "managed";
if (!useHostGitHub) {
if (!useHostGitHub && !providerApiPlacement) {
const githubLaunchers = await timePreparation("github_launchers", () => prepareHeartbeatGitHubLaunchers({
native: agent.adapterType === "paperclip_runner",
githubConfigured: githubSelection.configured,
@@ -2990,7 +2992,7 @@ export function heartbeatService(
runtimeConfig = { ...runtimeConfig, env: githubLaunchers.env };
secretKeys.add("PAPERCLIP_GITHUB_BROKER_TOKEN");
}
context.paperclipEnvironment = {
context.paperclipEnvironment = selectedEnvironment && activeEnvironmentLease ? {
id: selectedEnvironment.id,
name: selectedEnvironment.name,
driver: selectedEnvironment.driver,
@@ -3017,7 +3019,7 @@ export function heartbeatService(
: undefined,
}
: {}),
};
} : { placement: providerApiPlacement };
await db
.update(heartbeatRuns)
.set({
@@ -3244,8 +3246,8 @@ export function heartbeatService(
executionWorkspace,
persistedExecutionWorkspace,
executionTarget,
environmentDriver: selectedEnvironment.driver,
leaseMetadata: activeEnvironmentLease.lease.metadata,
environmentDriver: selectedEnvironment?.driver ?? "provider_api",
leaseMetadata: activeEnvironmentLease?.lease.metadata ?? {},
}));
const adapterEnv = Object.fromEntries(
Object.entries(parseObject(runtimeConfig.env)).filter(
@@ -3932,6 +3934,9 @@ export function heartbeatService(
await settleNativeRunProcess(db, run.id).catch(error => {
logger.warn({ err: error, runId: run.id }, "failed to release settled native process identity");
});
await acknowledgeRemoteStop(run.id, run.companyId).catch(error => {
logger.warn({ err: error, runId: run.id }, "failed to acknowledge Stop after native process settlement");
});
}
await db.update(heartbeatRuns).set({
executionStage: sql`case when ${heartbeatRuns.status} = 'cancelled' then 'settled' else ${heartbeatRuns.executionStage} end`,
@@ -1,3 +1,4 @@
import { assertProviderApiPlacementPolicy, resolveProviderApiPlacement } from "../native-runtime/provider-api-placement.js";
import { heartbeatExternalProvider, nativeExecutionUsesTaskWorkspace } from "./external-provider.js";
import { heartbeatRuns, issues, type Db, type agents } from "@paperclipai/db";
import { and, eq } from "drizzle-orm";
@@ -106,16 +107,18 @@ export async function selectHeartbeatEnvironment(db: Db, input: HeartbeatEnviron
: null;
// This retained name covers both external bridges; neither adopts task files.
const isDotRun = heartbeatExternalProvider({ run, agent }) !== null;
const providerApiPlacement = resolveProviderApiPlacement({ run, agent });
const controllerOnly = isDotRun || providerApiPlacement !== null;
const persistedNativeExecutionWorkspaceId =
persistedNativeExecutionInput?.binding.executionWorkspaceId ?? null;
providerApiPlacement ? null : persistedNativeExecutionInput?.binding.executionWorkspaceId ?? null;
const fresh = input.task.freshWorkspace?.mode === "fresh_isolated_workspace";
const requestedExecutionWorkspaceId = isDotRun ? null :
const requestedExecutionWorkspaceId = controllerOnly ? null :
persistedNativeExecutionWorkspaceId ??
readNonEmptyString(issueRef?.executionWorkspaceId);
let existingExecutionWorkspace = requestedExecutionWorkspaceId
? await executionWorkspacesSvc.getStoredById(agent.companyId, requestedExecutionWorkspaceId)
: null;
if (existingExecutionWorkspace && issueId && !isDotRun && !fresh
if (existingExecutionWorkspace && issueId && !controllerOnly && !fresh
&& existingExecutionWorkspace.id === readNonEmptyString(issueRef?.executionWorkspaceId)
&& (existingExecutionWorkspace.status === "archived" || existingExecutionWorkspace.status === "cleanup_failed")
&& (readGitCacheExpiration(existingExecutionWorkspace as Parameters<typeof readGitCacheExpiration>[0])?.workspaceRemoved
@@ -154,7 +157,7 @@ export async function selectHeartbeatEnvironment(db: Db, input: HeartbeatEnviron
const workspaceReuseRequest =
resolveExecutionWorkspaceReuseRequestForIssue({
issueExecutionWorkspaceId: requestedExecutionWorkspaceId,
issueExecutionWorkspacePreference: isDotRun ? null : nativeRecoveryExecutionWorkspaceId
issueExecutionWorkspacePreference: controllerOnly ? null : nativeRecoveryExecutionWorkspaceId
? "reuse_existing"
: (issueRef?.executionWorkspacePreference ?? null),
existingExecutionWorkspaceStatus:
@@ -168,11 +171,11 @@ export async function selectHeartbeatEnvironment(db: Db, input: HeartbeatEnviron
: null;
const workspaceAuthorizationActor = { type: "agent" as const, agentId: agent.id, companyId: agent.companyId,
source: "agent_jwt" as const, runId: run.id, onBehalfOfUserId: responsibleUserId === "local-board" ? null : responsibleUserId };
if (!isDotRun && !persistedNativeExecutionInput && !nativeRecoveryExecutionWorkspaceId) {
if (!controllerOnly && !persistedNativeExecutionInput && !nativeRecoveryExecutionWorkspaceId) {
const workspaceRequiringAccess = reusableExistingExecutionWorkspace ?? boundSourceWorkspace;
if (workspaceRequiringAccess && issueId) {
await assertTaskWorkspaceExecutionAccess(db, workspaceAuthorizationActor, agent.companyId, { issueId, workspaceId: workspaceRequiringAccess.id });
} else if (!isDotRun && executionProjectId && (hasSelectedWorkspaceSource || workspaceProjectContext?.hasWorkspace)) {
} else if (!controllerOnly && executionProjectId && (hasSelectedWorkspaceSource || workspaceProjectContext?.hasWorkspace)) {
await assertTaskWorkspaceSourceProjectAccess(db, workspaceAuthorizationActor, agent.companyId, executionProjectId);
}
}
@@ -191,10 +194,12 @@ export async function selectHeartbeatEnvironment(db: Db, input: HeartbeatEnviron
const managedSandboxOnly =
(await instanceSettings.getExperimental()).enableManagedSandboxOnly ===
true;
const managedSandboxEnvironment = managedSandboxOnly
const managedSandboxEnvironment = managedSandboxOnly && !providerApiPlacement
? await environmentsSvc.findManagedSandboxEnvironment(agent.companyId)
: null;
const environmentResolution = resolveExecutionWorkspaceEnvironmentId({
const environmentResolution = providerApiPlacement
? { environmentId: null, source: "default" as const }
: resolveExecutionWorkspaceEnvironmentId({
lowTrustIssueEnvironmentId: trustPreset.kind === "low_trust_review"
? parseIssueExecutionWorkspaceSettings(issueContext?.executionWorkspaceSettings, {includeEnvironmentId: true})?.environmentId : null,
agentDefaultEnvironmentId: agent.defaultEnvironmentId,
@@ -218,8 +223,9 @@ export async function selectHeartbeatEnvironment(db: Db, input: HeartbeatEnviron
};
const executionForcedToKubernetes =
isExecutionForcedToKubernetes(executionPolicy);
if (providerApiPlacement) assertProviderApiPlacementPolicy(executionPolicy);
let selectedEnvironmentId = environmentResolution.environmentId;
if (executionForcedToKubernetes) {
if (executionForcedToKubernetes && !providerApiPlacement) {
let kubernetesEnvironment =
await environmentsSvc.findKubernetesEnvironment(agent.companyId);
if (!kubernetesEnvironment) {
@@ -292,7 +298,7 @@ export async function selectHeartbeatEnvironment(db: Db, input: HeartbeatEnviron
: selectedEnvironmentId
? await environmentsSvc.getById(selectedEnvironmentId)
: null;
const nativeChatWorkspaceScope = isDotRun ? null : await findNativeChatWorkspaceScope(db, {
const nativeChatWorkspaceScope = controllerOnly ? null : await findNativeChatWorkspaceScope(db, {
adapterType: agent.adapterType,
environmentDriver: selectedEnvironmentForConfig?.driver ?? null,
companyId: agent.companyId,
@@ -391,7 +397,7 @@ export async function selectHeartbeatEnvironment(db: Db, input: HeartbeatEnviron
// project or issue settings request serialization. Sandbox protection still uses
// the existing holder staleness and workspace_busy retry ladder.
if (
!isDotRun && issueRef?.projectWorkspaceId &&
!controllerOnly && issueRef?.projectWorkspaceId &&
effectiveExecutionWorkspaceMode === "shared_workspace"
) {
const workspaceHolder = await findSharedWorkspaceHolder({
@@ -472,18 +478,18 @@ export async function selectHeartbeatEnvironment(db: Db, input: HeartbeatEnviron
);
}
}
if (!isDotRun && taskWorkspaceIntent && !boundSourceWorkspace && !nativeRecoveryExecutionWorkspaceId) {
if (!controllerOnly && taskWorkspaceIntent && !boundSourceWorkspace && !nativeRecoveryExecutionWorkspaceId) {
await executionWorkspacesSvc.validateStoredSelectionForRun({ companyId: agent.companyId,
actor: { type: "agent", agentId: agent.id, companyId: agent.companyId, runId: run.id, source: "agent_jwt", onBehalfOfUserId: responsibleUserId === "local-board" ? null : responsibleUserId },
selection: taskWorkspaceIntent.selection, issueId,
preserveImplicitDefaultSettings: ["project", "task_default"].includes(taskWorkspaceIntent.source) });
}
const explicitTaskDirectory = !isDotRun && taskWorkspaceIntent?.selection.kind === "task_directory";
const explicitTaskDirectory = !controllerOnly && taskWorkspaceIntent?.selection.kind === "task_directory";
const explicitDirectoryStrategies = [
...(reusableExistingExecutionWorkspace?.providerType === "adapter_managed" ? [config.workspaceStrategy] : []),
issueAssigneeOverrides?.adapterConfig?.workspaceStrategy,
projectExecutionWorkspacePolicy?.workspaceStrategy, issueExecutionWorkspaceSettings?.workspaceStrategy];
const boundTaskDirectory = !isDotRun && !fresh && !nativeRecoveryExecutionWorkspaceId && issueId !== null && issueRef !== null &&
const boundTaskDirectory = !controllerOnly && !fresh && !nativeRecoveryExecutionWorkspaceId && issueId !== null && issueRef !== null &&
requestedShouldReuseExisting && requestedExecutionWorkspaceMode === "isolated_workspace" &&
!workspaceProjectContext?.hasWorkspace && !issueRef.projectWorkspaceId &&
taskWorkspaceIntent?.selection.kind !== "configured_source" &&
@@ -503,7 +509,7 @@ export async function selectHeartbeatEnvironment(db: Db, input: HeartbeatEnviron
if (retainedDirectory && !boundTaskDirectory) throw new WorkspaceValidationFailure("The saved Retry task directory is unavailable.", {
workspaceValidation: { reason: "isolated_task_directory_binding_mismatch", issueId },
});
const useIsolatedTaskDirectory = !isDotRun && issueRef !== null && (reuseTaskDirectory || (fresh && !workspaceProjectContext?.hasWorkspace && !issueRef.projectWorkspaceId) || shouldUseIsolatedTaskDirectory({
const useIsolatedTaskDirectory = !controllerOnly && issueRef !== null && (reuseTaskDirectory || (fresh && !workspaceProjectContext?.hasWorkspace && !issueRef.projectWorkspaceId) || shouldUseIsolatedTaskDirectory({
trustPreset: trustPreset.kind,
environmentDriver: selectedEnvironmentForConfig?.driver ?? null,
mode: requestedExecutionWorkspaceMode,
@@ -520,6 +526,7 @@ export async function selectHeartbeatEnvironment(db: Db, input: HeartbeatEnviron
persistedRunnerProfile,
persistedNativeExecutionInput,
isDotRun,
providerApiPlacement,
existingExecutionWorkspace,
nativeRecoveryExecutionWorkspaceId,
workspaceReuseRequest,
@@ -24,5 +24,5 @@ type TaskWorkspaceExecution = Exclude<NativeExecutionInput, {
export function nativeExecutionUsesTaskWorkspace(execution: NativeExecutionInput | null): execution is TaskWorkspaceExecution {
return execution !== null && execution.schema !== "paperclip.native-execution-input.v6"
&& execution.schema !== "paperclip.native-execution-input.v8";
&& execution.schema !== "paperclip.native-execution-input.v8" && execution.provider.kind !== "cursor_cloud";
}
@@ -60,6 +60,11 @@ export function registerLiveRecoveryWork(db: Db, coordinator: ReturnType<typeof
: Date.parse(String((row.metadata?.legacyShutdownWorkspaceSettlement as Record<string, unknown>)?.deadlineAt)) || Date.now(),
}));
} else {
// Retirement may commit before Stop acknowledgement. This is only a
// work hint; the owner rechecks the complete provider/process proof.
const pendingApiStop = and(eq(heartbeatRuns.status, "cancelled"), eq(heartbeatRuns.driverKind, "cursor_cloud_api"),
sql`${heartbeatRuns.runnerProfileJson}->>'backend' = 'cursor_cloud_api'`,
sql`${heartbeatRuns.resultJson}->'executionCancellation'->>'state' = 'requested'`);
const candidates = await db.select({ id: heartbeatRuns.id, companyId: heartbeatRuns.companyId,
nextAttemptAt: nativeRunFinalizations.nextAttemptAt, leaseExpiresAt: nativeRunFinalizations.leaseExpiresAt })
.from(heartbeatRuns).leftJoin(nativeRunFinalizations, and(eq(nativeRunFinalizations.runId, heartbeatRuns.id),
@@ -76,9 +81,9 @@ export function registerLiveRecoveryWork(db: Db, coordinator: ReturnType<typeof
: kind === "active" ? and(eq(heartbeatRuns.status, "running"),
sql`${heartbeatRuns.contextSnapshot}->'nativeQuestionCancellation' is null`)
: and(eq(heartbeatRuns.runtimeMode, "native"), inArray(heartbeatRuns.status, ["failed", "cancelled", "timed_out", "interrupted"]),
isNotNull(heartbeatRuns.finishedAt), or(isNotNull(heartbeatRuns.processPid),
isNotNull(heartbeatRuns.processGroupId), isNotNull(heartbeatRuns.processStartedAt)),
settledNativeProcessIdentityCondition(db)),
isNotNull(heartbeatRuns.finishedAt), or(pendingApiStop,
and(or(isNotNull(heartbeatRuns.processPid), isNotNull(heartbeatRuns.processGroupId),
isNotNull(heartbeatRuns.processStartedAt)), settledNativeProcessIdentityCondition(db)))),
)).orderBy(asc(heartbeatRuns.id)).limit(100);
rows = candidates.map(row => ({ id: row.id, companyId: row.companyId, dueAt: kind === "native"
? Math.max(Date.now(), row.nextAttemptAt?.getTime() ?? 0, row.leaseExpiresAt?.getTime() ?? 0) : Date.now() }));
+2 -1
View File
@@ -2775,7 +2775,8 @@ export function createHeartbeatRecovery(db: Db, dependencies: HeartbeatRecoveryD
scope.onDeferredDeadline(Date.now() + 30_000);
return;
}
await releaseSettledNativeRunProcess(db, scope.runId, scope);
const outcome = await releaseSettledNativeRunProcess(db, scope.runId, scope);
if (outcome !== "pending") await acknowledgeRemoteStop(scope.runId, scope.companyId);
},
orphanLease: scope => sweepOrphanedActiveLeases({ ...scope, backoffMs: 5 * 60_000 }),
shutdown: scope => sweepLegacyShutdownWorkspaceSettlements(db, new Date(), scope),
@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import type { Db } from "@paperclipai/db";
import { activityLog, agents, agentWakeupRequests, caseEvents, caseHandoffs, cases, companies, environmentLeases, heartbeatRuns, issueComments, issues, nativeRunFinalizations } from "@paperclipai/db";
import { activityLog, agents, agentWakeupRequests, caseEvents, caseHandoffs, cases, companies, environmentLeases, heartbeatRunEvents, heartbeatRuns, issueComments, issues, nativeRunFinalizations } from "@paperclipai/db";
import { eq, inArray, sql } from "drizzle-orm";
import { afterEach, describe, expect, it, vi } from "vitest";
import { runningProcesses } from "../../adapters/index.js";
@@ -24,9 +24,18 @@ import { caseHandoffService } from "../case-handoffs.js";
import { withQueuedCommentIdsInWakePayload } from "../issue-queued-comment-queue.js";
import { instanceSettingsService } from "../instance-settings.js";
import { issueService } from "../issues.js";
import { buildNativeExecutionInput } from "../native-runtime/native-execution-input.js";
import { nativeRuntimeContextFixture } from "../native-runtime/runtime-context.test-fixture.js";
import { appendHeartbeatRunEvent } from "../heartbeat-run-events.js";
import { acknowledgedNativeApiStopExecutionHasStopped } from "../acknowledged-native-stop.js";
import { nativeSha256 } from "../native-runtime/canonical.js";
import { callbacks, runControlPostgresSupport, setupRunControlDatabase } from "./__tests__/run-control-fixtures.js";
vi.mock("../live-events.js", () => ({ publishLiveEvent: vi.fn() }));
vi.mock("../acknowledged-native-stop.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../acknowledged-native-stop.js")>();
return { ...actual, acknowledgedNativeApiStopExecutionHasStopped: vi.fn(actual.acknowledgedNativeApiStopExecutionHasStopped) };
});
vi.mock("../adapter-execution-control.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../adapter-execution-control.js")>();
return {
@@ -628,6 +637,7 @@ describe.skipIf(!runControlPostgresSupport.supported)("heartbeat run-control dat
releasedAt: proof === "missing" ? null : new Date(), cleanupStatus: proof === "missing" ? null : "success",
metadata: proof === "missing" ? {} : { remoteExecutionTermination: receipt } });
const control = createHeartbeatRunControl(db, callbacks(db));
vi.mocked(publishLiveEvent).mockClear();
await control.acknowledgeRemoteStop(run.id, company.id);
await control.acknowledgeRemoteStop(run.id, company.id);
const [stopped] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, run.id));
@@ -635,6 +645,149 @@ describe.skipIf(!runControlPostgresSupport.supported)("heartbeat run-control dat
if (proof === "confirmed") expect(stopped.resultJson?.executionCancellation).toMatchObject({ proof: "provider_termination_receipt" });
expect(stopped.resultJson?.workspaceRestoreFailure).toBe("restore_failed");
expect(stopped.resultJson?.workspaceRestoreRecovery).toMatchObject({ leaseIds: [leaseId] });
expect(vi.mocked(publishLiveEvent).mock.calls).toEqual(proof === "confirmed" ? [[{
companyId: company.id,
type: "heartbeat.run.status",
payload: expect.objectContaining({ runId, agentId: agent.id, status: "cancelled" }),
}]] : []);
});
it.each(["confirmed", "missing_process_stop", "later_start", "missing_terminal", "unproven_terminal",
"foreign_terminal", "foreign_binding", "missing_placement", "unexpected_lease", "running_pid",
"wrong_intent", "pending_ack", "unresolved_mode", "missing_hash", "wrong_hash", "malformed_terminal",
"race_event_start", "race_profile_binding", "race_benign_event",
"natural_completed", "natural_cancelled", "natural_error", "natural_expired",
"mismatched_terminal_status", "local_failure", "unproven_cancelled",
"pre_stop_completed", "pre_stop_cancelled", "pre_stop_error", "pre_stop_expired", "pre_stop_interrupted",
"pre_stop_later_turn", "pre_stop_unproven",
] as const)("publishes an API-only native Stop only after its exact provider and controller stopped: %s", async proof => {
const { company, agent } = await fixture();
const [issue] = await db.insert(issues).values({ companyId: company.id, title: "API-only Stop", assigneeAgentId: agent.id }).returning();
const runId = randomUUID(), sessionId = randomUUID(), runnerId = randomUUID();
const recordedAt = new Date(Date.now() - 1_000).toISOString();
const nativeExecutionInput = buildNativeExecutionInput({ companyId: company.id, agentId: agent.id,
runId: proof === "foreign_binding" ? randomUUID() : runId, provider: "cursor_cloud", model: "explicit-model",
cursorCloud: { schema: "paperclip.cursor-cloud.config.v1", repos: [{ url: "https://github.com/example/qualification" }] },
issue: { id: issue.id, identifier: "RC-1", title: issue.title, description: null, workMode: "standard" },
taskPrompt: "Stop the foreground command", normalizedSessionId: sessionId,
workspace: { id: randomUUID(), cwd: "/private/controller", repoUrl: null, repoRef: null, branchName: null },
completionContract: { id: randomUUID(), sha256: `sha256:${"a".repeat(64)}`, schemaVersion: "paperclip.run-result.v1",
contract: { revision: "1", objective: "Stop the foreground command", criteria: [{ id: "objective", requirement: "Stop the foreground command" }] } },
runtimeContext: nativeRuntimeContextFixture(),
});
const profile = { backend: "cursor_cloud_api", nativeExecutionInput,
...(proof === "missing_placement" ? {} : { executionPlacement: { kind: "provider_api", provider: "cursor_cloud", controller: "paperclip" } }) };
const [run] = await db.insert(heartbeatRuns).values({ id: runId, companyId: company.id, agentId: agent.id,
nativeIssueId: issue.id, runtimeMode: proof === "unresolved_mode" ? "legacy" : "native", driverKind: "cursor_cloud_api",
nativeSessionId: sessionId, runnerInstanceId: runnerId, runnerProfileJson: profile,
status: "cancelled", finishedAt: new Date(), processPid: proof === "running_pid" ? process.pid : null,
resultJson: { executionCancellation: { state: "requested", requestedAt: recordedAt },
cancelledByActorType: "user", cancelledByUserId: "operator", nativeCancellation: {
schema: "paperclip.native-cancellation.v1", runId: proof === "wrong_intent" ? randomUUID() : runId,
companyId: company.id, issueId: issue.id, scope: "run", reasonCode: "cancellation_run_only",
dispatchState: proof === "pending_ack" ? "pending" : "acknowledged", dispatched: true,
effects: ["release_run_resources"], recordedAt, acknowledgedAt: new Date().toISOString(),
intentAuditId: randomUUID(), acknowledgementAuditId: randomUUID(),
} },
}).returning();
if (proof === "unexpected_lease") await db.insert(environmentLeases).values({ companyId: company.id, issueId: issue.id,
heartbeatRunId: runId, provider: "local", status: "released", leasePolicy: "retain_on_failure",
releasedAt: new Date(), cleanupStatus: "success" });
if (proof !== "missing_terminal") {
const preStop = proof.startsWith("pre_stop_");
const terminalProof = proof === "pre_stop_later_turn" || proof === "pre_stop_unproven" ? "natural_completed"
: preStop ? `natural_${proof.slice("pre_stop_".length)}` : proof;
const terminalAt = preStop ? new Date(Date.parse(recordedAt) - 1_000) : new Date();
const terminalType = terminalProof === "natural_cancelled" || terminalProof === "unproven_cancelled" ? "turn.cancelled"
: ["natural_error", "natural_expired", "local_failure"].includes(terminalProof) ? "turn.failed"
: terminalProof === "natural_completed" || terminalProof === "mismatched_terminal_status" ? "turn.completed" : "turn.interrupted";
const terminalError = terminalProof === "natural_error" ? { code: "cursor_cloud_error", message: "Cursor Cloud run failed" }
: terminalProof === "natural_expired" ? { code: "cursor_cloud_expired", message: "Cursor Cloud run expired" }
: terminalProof === "local_failure" ? { code: "process_lost", message: "Synthetic local process loss" } : null;
const event = { schema: "paperclip.prp.event.v1", schemaVersion: 1, eventType: terminalType,
sourceKind: "runner", sourceInstanceId: runnerId, sourceEventId: `${runnerId}:${runId}:1`, sourceSeq: 1,
runId, turnId: "api-stop-turn", normalizedSessionId: proof === "foreign_terminal" ? randomUUID() : sessionId,
emittedAt: terminalAt.toISOString(), priority: 1,
payload: { status: terminalProof === "mismatched_terminal_status" ? "interrupted" : terminalType.slice("turn.".length), error: terminalError,
...(terminalProof === "natural_cancelled" ? { providerTerminalState: "completed", reason: "cancelled_after_provider_completed" } : {}),
...(terminalProof === "unproven_cancelled" ? { providerTerminalState: "unknown", reason: "operator_cancel_requested" } : {}),
...(proof === "unproven_terminal" || proof === "pre_stop_unproven" ? { providerTerminalObserved: false } : {}) } };
await appendHeartbeatRunEvent(db, { companyId: company.id, agentId: agent.id, runId,
eventType: event.eventType, payload: { prpEvent: event }, nativeSource: {
sourceInstanceId: runnerId, sourceEventId: event.sourceEventId, sourceSeq: 1,
protocolSchemaVersion: 1, canonicalPayload: event,
} });
if (preStop) await db.update(heartbeatRunEvents).set({ createdAt: terminalAt }).where(eq(heartbeatRunEvents.runId, runId));
if (proof === "pre_stop_later_turn") {
const later = { ...event, eventType: "turn.started", sourceEventId: `${runnerId}:${runId}:2`, sourceSeq: 2,
turnId: "later-api-turn", emittedAt: new Date().toISOString(), payload: { status: "running" } };
await appendHeartbeatRunEvent(db, { companyId: company.id, agentId: agent.id, runId,
eventType: later.eventType, payload: { prpEvent: later }, nativeSource: {
sourceInstanceId: runnerId, sourceEventId: later.sourceEventId, sourceSeq: 2,
protocolSchemaVersion: 1, canonicalPayload: later,
} });
}
if (["missing_hash", "wrong_hash", "malformed_terminal"].includes(proof)) {
const malformed = { ...event, priority: undefined };
await db.update(heartbeatRunEvents).set({
sourcePayloadSha256: proof === "missing_hash" ? null : proof === "wrong_hash" ? "0".repeat(64) : nativeSha256(malformed),
...(proof === "malformed_terminal" ? { payload: { prpEvent: malformed } } : {}),
}).where(eq(heartbeatRunEvents.runId, runId));
}
}
const control = createHeartbeatRunControl(db, callbacks(db));
vi.mocked(publishLiveEvent).mockClear();
if (proof === "confirmed") {
await control.acknowledgeRemoteStop(run.id, company.id);
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, run.id)))[0]
.resultJson?.executionCancellation).toMatchObject({ state: "requested" });
expect(publishLiveEvent).not.toHaveBeenCalled();
}
if (proof !== "missing_process_stop") await appendHeartbeatRunEvent(db, { companyId: company.id,
agentId: agent.id, runId, eventType: "native.local_process_stopped", payload: { processPid: 424242, processGroupId: 424242 } });
if (proof === "later_start") await appendHeartbeatRunEvent(db, { companyId: company.id,
agentId: agent.id, runId, eventType: "native.process_start_requested", payload: { targetKind: "local" } });
if (proof.startsWith("race_")) {
const actual = await vi.importActual<typeof import("../acknowledged-native-stop.js")>("../acknowledged-native-stop.js");
vi.mocked(acknowledgedNativeApiStopExecutionHasStopped).mockImplementationOnce(async (...args) => {
const proven = await actual.acknowledgedNativeApiStopExecutionHasStopped(...args);
expect(proven).toBe(true);
if (proof === "race_event_start" || proof === "race_benign_event") {
const [before] = await db.select({ cursor: heartbeatRuns.nextEventSeq }).from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
await appendHeartbeatRunEvent(db, { companyId: company.id, agentId: agent.id, runId,
eventType: proof === "race_event_start" ? "native.process_start_requested" : "lifecycle",
payload: proof === "race_event_start" ? { targetKind: "local" } : { reason: "synthetic finalization log" } });
const [after] = await db.select({ cursor: heartbeatRuns.nextEventSeq }).from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
expect(after.cursor).toBe(before.cursor + 1);
} else {
await db.update(heartbeatRuns).set({ runnerProfileJson: {
...profile,
nativeExecutionInput: { ...nativeExecutionInput, binding: { ...nativeExecutionInput.binding, issueId: randomUUID() } },
} }).where(eq(heartbeatRuns.id, runId));
}
return proven;
});
}
await control.acknowledgeRemoteStop(run.id, company.id);
if (proof === "race_benign_event") expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, run.id)))[0]
.resultJson?.executionCancellation).toMatchObject({ state: "acknowledged" });
await control.acknowledgeRemoteStop(run.id, company.id);
const [stopped] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, run.id));
const acknowledged = ["confirmed", "race_benign_event", "natural_completed", "natural_cancelled", "natural_error", "natural_expired",
"pre_stop_completed", "pre_stop_cancelled", "pre_stop_error", "pre_stop_expired", "pre_stop_interrupted"].includes(proof);
expect(stopped.resultJson?.executionCancellation).toMatchObject({ state: acknowledged ? "acknowledged" : "requested" });
if (acknowledged) expect(stopped.resultJson?.executionCancellation).toMatchObject({ proof: "provider_api_stop_receipt" });
expect(vi.mocked(publishLiveEvent).mock.calls).toEqual(acknowledged ? [[{
companyId: company.id,
type: "heartbeat.run.status",
payload: expect.objectContaining({ runId, agentId: agent.id, status: "cancelled" }),
}]] : []);
if (acknowledged) {
const payload = vi.mocked(publishLiveEvent).mock.calls[0]![0].payload;
expect(payload).not.toHaveProperty("resultJson");
expect(payload).not.toHaveProperty("executionCancellation");
expect(payload).not.toHaveProperty("runnerProfileJson");
}
});
it("keeps pre-Stop input pending until a later user message authorizes one continuation", async () => {
+58 -21
View File
@@ -12,6 +12,8 @@ import { PROCESS_IDENTITY_RECORDED, PROCESS_START_REQUESTED, readNativeLocalProc
import { isHeartbeatRunTerminalStatus } from "./run-lifecycle.js";
import { pendingCaseHandoffForRun, cancelCaseHandoffsForIssue, isCaseHandoffSourceReleased } from "./case-handoff-lifecycle.js";
import { publishActivity, type ActivityPublication } from "../activity-log.js";
import { buildHeartbeatRunStatusLiveEventPayload } from "../heartbeat-run-status-payload.js";
import { publishLiveEvent } from "../live-events.js";
import { DEFERRED_WAKE_CONTEXT_KEY } from "./queue.js";
import { CANCELLABLE_HEARTBEAT_RUN_STATUSES } from "./recovery.js";
import {
@@ -37,6 +39,7 @@ import { settleSlackConversation } from "../slack-conversation-lifecycle.js";
import { readQueuedInteractionResponse } from "../queued-interaction-response.js";
import {
isAcknowledgedNativeStop,
acknowledgedNativeApiStopExecutionHasStopped,
acknowledgedNativeStopExecutionHasStopped,
} from "../acknowledged-native-stop.js";
import { completeTerminatedRemoteNativeSessionCleanup } from "../../vendor/paperclip-runner/index.js";
@@ -506,27 +509,61 @@ export function createHeartbeatRunControl(db: Db, dependencies: HeartbeatRunCont
completeTerminatedRemoteNativeSessionCleanup({ companyId, runId, remoteCleanupScope });
}
}
const stopped = state?.status === "cancelled" ? await getRun(runId) : null;
// Native interrupt acknowledgement is not physical containment. Publish the
// shared Stop receipt only after every exact remote lease confirms teardown.
// Keep workspace preservation separate: a stopped provider can still owe files.
if (stopped?.status === "cancelled" &&
parseObject(stopped.resultJson?.executionCancellation).state === "requested" &&
(stopped.runtimeMode === "native" ? isAcknowledgedNativeStop(stopped)
: stopped.runtimeMode === "legacy" && await runUsedConversationAdapter(db, stopped)) &&
await remoteExecutionHasStopped(db, companyId, runId)) {
await db.update(heartbeatRuns).set({
resultJson: sql`coalesce(${heartbeatRuns.resultJson}, '{}'::jsonb) || ${JSON.stringify({
executionCancellation: { ...parseObject(stopped.resultJson?.executionCancellation),
state: "acknowledged", acknowledgedAt: new Date().toISOString(),
proof: "provider_termination_receipt" },
...(stopped.runtimeMode === "legacy" ? { conversationContinuation: CONVERSATION_CONTINUATION_POLICY } : {}),
})}::jsonb`,
updatedAt: new Date(),
}).where(and(eq(heartbeatRuns.id, runId), eq(heartbeatRuns.companyId, companyId),
eq(heartbeatRuns.status, "cancelled"),
sql`${heartbeatRuns.resultJson}->'executionCancellation'->>'state' = 'requested'`,
stopped.runtimeMode === "native" ? sql`${heartbeatRuns.resultJson}->'nativeCancellation' = ${JSON.stringify(stopped.resultJson?.nativeCancellation)}::jsonb` : undefined));
// Finalization can append a harmless event after proof was read. Retry
// only a lost API compare-and-set, with fresh complete proof each time.
for (let attempt = 0; attempt < 3; attempt++) {
const stopped = state?.status === "cancelled" ? await getRun(runId) : null;
// Native interrupt acknowledgement is not physical containment. Require
// exact remote lease teardown, or the API provider's terminal receipt and
// the local controller's retirement, before publishing shared Stop proof.
// Keep workspace preservation separate: a stopped provider can still owe files.
if (stopped?.status === "cancelled" && stopped.companyId === companyId &&
parseObject(stopped.resultJson?.executionCancellation).state === "requested" &&
(stopped.runtimeMode === "native" ? isAcknowledgedNativeStop(stopped)
: stopped.runtimeMode === "legacy" && await runUsedConversationAdapter(db, stopped))) {
const apiStopped = stopped.runtimeMode === "native" && await acknowledgedNativeApiStopExecutionHasStopped(db, stopped);
if (!apiStopped && !await remoteExecutionHasStopped(db, companyId, runId)) return;
const acknowledged = await db.update(heartbeatRuns).set({
resultJson: sql`coalesce(${heartbeatRuns.resultJson}, '{}'::jsonb) || ${JSON.stringify({
executionCancellation: { ...parseObject(stopped.resultJson?.executionCancellation),
state: "acknowledged", acknowledgedAt: new Date().toISOString(),
proof: apiStopped ? "provider_api_stop_receipt" : "provider_termination_receipt" },
...(stopped.runtimeMode === "legacy" ? { conversationContinuation: CONVERSATION_CONTINUATION_POLICY } : {}),
})}::jsonb`,
updatedAt: new Date(),
}).where(and(eq(heartbeatRuns.id, runId), eq(heartbeatRuns.companyId, companyId),
eq(heartbeatRuns.status, "cancelled"),
sql`${heartbeatRuns.resultJson}->'executionCancellation'->>'state' = 'requested'`,
apiStopped ? and(eq(heartbeatRuns.runtimeMode, "native"), eq(heartbeatRuns.driverKind, "cursor_cloud_api"),
eq(heartbeatRuns.nextEventSeq, stopped.nextEventSeq),
eq(heartbeatRuns.agentId, stopped.agentId), eq(heartbeatRuns.nativeIssueId, stopped.nativeIssueId!),
isNull(heartbeatRuns.processPid), isNull(heartbeatRuns.processGroupId),
eq(heartbeatRuns.nativeSessionId, stopped.nativeSessionId!), eq(heartbeatRuns.runnerInstanceId, stopped.runnerInstanceId!),
sql`${heartbeatRuns.runnerProfileJson}->'executionPlacement' = ${JSON.stringify({ kind: "provider_api", provider: "cursor_cloud", controller: "paperclip" })}::jsonb`,
sql`${heartbeatRuns.runnerProfileJson}->>'backend' = 'cursor_cloud_api'`,
sql`jsonb_build_object(
'companyId', ${heartbeatRuns.runnerProfileJson}->'nativeExecutionInput'->'binding'->'companyId',
'runId', ${heartbeatRuns.runnerProfileJson}->'nativeExecutionInput'->'binding'->'runId',
'agentId', ${heartbeatRuns.runnerProfileJson}->'nativeExecutionInput'->'binding'->'agentId',
'issueId', ${heartbeatRuns.runnerProfileJson}->'nativeExecutionInput'->'binding'->'issueId'
) = ${JSON.stringify({ companyId, runId, agentId: stopped.agentId, issueId: stopped.nativeIssueId })}::jsonb`,
sql`jsonb_build_object(
'normalizedSessionId', ${heartbeatRuns.runnerProfileJson}->'nativeExecutionInput'->'session'->'normalizedSessionId',
'driverKind', ${heartbeatRuns.runnerProfileJson}->'nativeExecutionInput'->'session'->'driverKind'
) = ${JSON.stringify({ normalizedSessionId: stopped.nativeSessionId, driverKind: "cursor_cloud_api" })}::jsonb`,
sql`${heartbeatRuns.runnerProfileJson}->'nativeExecutionInput'->'provider'->>'kind' = 'cursor_cloud'`,
sql`not exists (select 1 from ${environmentLeases} where ${environmentLeases.companyId} = ${heartbeatRuns.companyId}
and ${environmentLeases.heartbeatRunId} = ${heartbeatRuns.id})`) : undefined,
stopped.runtimeMode === "native" ? sql`${heartbeatRuns.resultJson}->'nativeCancellation' = ${JSON.stringify(stopped.resultJson?.nativeCancellation)}::jsonb` : undefined)).returning({ id: heartbeatRuns.id });
if (acknowledged.length > 0) {
publishLiveEvent({
companyId: stopped.companyId,
type: "heartbeat.run.status",
payload: buildHeartbeatRunStatusLiveEventPayload(stopped),
});
}
if (!apiStopped || acknowledged.length > 0) return;
} else return;
}
}
@@ -135,7 +135,7 @@ export interface HeartbeatRuntimeExecutionInput {
executionTarget: Selection["workspace"]["executionTarget"];
workspaceOperationRecorder: ReturnType<ReturnType<typeof workspaceOperationService>["createRecorder"]>;
nativeWorkspaceSync: Selected["nativeWorkspaceSync"];
workspaceRestoreSource: Parameters<typeof recordLegacyWorkspaceRestoreFailure>[3];
workspaceRestoreSource: Parameters<typeof recordLegacyWorkspaceRestoreFailure>[3] | null;
remoteExecution: unknown;
};
config: Pick<Selection["config"], "runtimeConfig" | "resolvedConfig" | "managedAiRuntime"> & {
@@ -937,6 +937,7 @@ export async function executeHeartbeatRuntime(db: Db, input: HeartbeatRuntimeExe
input.effects.onWorkspaceRestoreFailure(requiredWorkspaceRestoreEvidence);
// Retention is the fallback even if recording this receipt fails.
input.effects.onProviderResourceDisposition("stop_and_retain");
if (workspaceRestoreSource === null) throw new Error("legacy_workspace_restore_source_unavailable");
await recordLegacyWorkspaceRestoreFailure(db, run, requiredWorkspaceRestoreEvidence, workspaceRestoreSource);
}
for (const stream of ["stdout", "stderr"] as const) {
@@ -2,10 +2,11 @@ import { randomUUID } from "node:crypto";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { activityLog, agents, companies, completionContracts, createDb, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS, heartbeatRuns, issues, nativeRunFinalizations, type Db } from "@paperclipai/db";
import { activityLog, agents, companies, completionContracts, createDb, applyPendingMigrations, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS, executionWorkspaces, heartbeatRuns, issues, nativeRunFinalizations, type Db } from "@paperclipai/db";
import { eq, sql } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js";
import { resolveExternalTestDatabaseUrl } from "../../__tests__/helpers/external-test-database.js";
import { CHAT_CONTROL_RECOVERY_ADMISSION_KEY } from "../chat-control-recovery-stop.js";
import { buildNativeRuntimeContext } from "../native-runtime/runtime-context.js";
import { nativeRuntimeContextFixture } from "../native-runtime/runtime-context.test-fixture.js";
@@ -28,7 +29,8 @@ vi.mock("../native-runtime/native-workspace-sync.js", async (original) => ({
prepareNativeWorkspaceSync: vi.fn(async () => null),
}));
const support = await getEmbeddedPostgresTestSupport();
const externalDatabaseUrl = resolveExternalTestDatabaseUrl("PAPERCLIP_RUNTIME_SELECTION_TEST_DATABASE_URL");
const support = externalDatabaseUrl ? { supported: true } : await getEmbeddedPostgresTestSupport();
describe.skipIf(!support.supported)("heartbeat runtime selection boundary", () => {
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: Db;
@@ -36,7 +38,9 @@ describe.skipIf(!support.supported)("heartbeat runtime selection boundary", () =
beforeAll(async () => {
instructionRoot = await mkdtemp(join(tmpdir(), "paperclip-selection-instructions-"));
await writeFile(join(instructionRoot, "AGENTS.md"), "Complete the assigned task.\n");
database = await startEmbeddedPostgresTestDatabase("paperclip-runtime-selection");
if (externalDatabaseUrl) await applyPendingMigrations(externalDatabaseUrl);
database = externalDatabaseUrl ? { connectionString: externalDatabaseUrl, cleanup: async () => {} }
: await startEmbeddedPostgresTestDatabase("paperclip-runtime-selection");
db = createDb(database.connectionString);
await db.execute(sql`set client_min_messages = warning`);
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
@@ -78,6 +82,65 @@ describe.skipIf(!support.supported)("heartbeat runtime selection boundary", () =
const coordinators = (id: string) => db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, id));
const sandbox = { kind: "remote" as const, transport: "sandbox" as const, remoteCwd: "/workspace", reusableLeaseConfigured: false };
it.each([false, true])("persists the Cursor provider API placement and completion authority without a Computer lease (ephemeral: %s)", async ephemeral => {
vi.stubEnv("PAPERCLIP_EPHEMERAL_SERVER", String(ephemeral));
const { input, run, issue } = await fixture();
input.agent = { ...input.agent, adapterConfig: { provider: "cursor_cloud", model: "caller-model", repoUrl: "https://github.com/example/qualification" } };
await db.update(agents).set({ adapterConfig: input.agent.adapterConfig }).where(eq(agents.id, input.agent.id));
input.workspace.activeEnvironmentLease = null;
input.nativeRuntimeResolution = resolveHeartbeatNativeRuntimeMode({ persisted: run, enabled: true, runtimeConfig: {}, adapterConfig: input.agent.adapterConfig,
agent: input.agent, issue, target: null, workspaceId: null });
const result = await selectHeartbeatRuntime(db, input);
expect(result.selected).toBe(true);
if (!result.selected) throw new Error("Expected native Cursor selection");
expect(result.nativeExecution?.provider).toMatchObject({ kind: "cursor_cloud", model: "caller-model" });
expect(await read(run.id)).toMatchObject({ runtimeMode: "native", driverKind: "cursor_cloud_api", runnerProfileJson: {
backend: "cursor_cloud_api", executionPlacement: { kind: "provider_api", provider: "cursor_cloud", controller: "paperclip" },
nativeExecutionInput: result.nativeExecution,
} });
expect(await db.select().from(completionContracts)).toHaveLength(1);
expect(await coordinators(run.id)).toHaveLength(1);
expect(prepareNativeWorkspaceSync).not.toHaveBeenCalled();
expect(input.stopControllerLease).toHaveBeenCalledOnce();
});
it.each(["cursor_cloud_api", null] as const)("restores a historical Cursor workspace binding without allocating a Computer (driver: %s)", async driverKind => {
const { input, run, issue } = await fixture();
input.agent = { ...input.agent, adapterConfig: { provider: "cursor_cloud", model: "caller-model", repoUrl: "https://github.com/example/qualification" } };
await db.update(agents).set({ adapterConfig: input.agent.adapterConfig }).where(eq(agents.id, input.agent.id));
const [workspace] = await db.insert(executionWorkspaces).values({ companyId: input.agent.companyId, sourceIssueId: issue.id,
name: "Previously admitted Cursor workspace", mode: "shared_workspace", strategyType: "project_primary", cwd: "/previous-cursor-workspace" }).returning();
input.workspace.persistedExecutionWorkspace = workspace;
input.workspace.activeEnvironmentLease = null;
input.nativeRuntimeResolution = resolveHeartbeatNativeRuntimeMode({ persisted: run, enabled: true, runtimeConfig: {}, adapterConfig: input.agent.adapterConfig,
agent: input.agent, issue, target: null, workspaceId: workspace.id });
const selected = await selectHeartbeatRuntime(db, input);
if (!selected.selected || !selected.nativeExecution) throw new Error("Expected native Cursor selection");
expect(selected.nativeExecution.binding.executionWorkspaceId).toBe(workspace.id);
const saved = await read(run.id);
const historicalProfile: Record<string, unknown> = { ...saved.runnerProfileJson, sessionCheckpoint: { retained: "Cursor provider checkpoint" } };
delete historicalProfile.executionPlacement;
await db.update(heartbeatRuns).set({ runnerProfileJson: historicalProfile, driverKind }).where(eq(heartbeatRuns.id, run.id));
input.run = await read(run.id);
input.nativeRuntimeResolution = resolveHeartbeatNativeRuntimeMode({ persisted: input.run, enabled: true, runtimeConfig: {}, adapterConfig: input.agent.adapterConfig,
agent: input.agent, issue, target: null, workspaceId: null });
input.session.persistedRunnerProfile = historicalProfile;
input.session.persistedNativeExecutionInput = selected.nativeExecution;
input.workspace.persistedExecutionWorkspace = null;
input.workspace.executionWorkspace.cwd = "/private-cursor-controller";
vi.mocked(buildNativeRuntimeContext).mockClear();
const resumed = await selectHeartbeatRuntime(db, input);
expect(resumed).toMatchObject({ selected: true, nativeExecution: selected.nativeExecution, nativeRunnerInstanceId: selected.nativeRunnerInstanceId });
expect(await read(run.id)).toMatchObject({ driverKind: "cursor_cloud_api" });
expect((await read(run.id)).runnerProfileJson).toMatchObject({ ...historicalProfile,
executionPlacement: { kind: "provider_api", provider: "cursor_cloud", controller: "paperclip" } });
expect(await db.select().from(executionWorkspaces)).toEqual([workspace]);
expect(await db.select().from(completionContracts)).toHaveLength(1);
expect(await coordinators(run.id)).toHaveLength(1);
expect(prepareNativeWorkspaceSync).not.toHaveBeenCalled();
expect(buildNativeRuntimeContext).not.toHaveBeenCalled();
});
it("persists and admits a standing-chat response contract without requiring the unavailable action", async () => {
const { input, issue, agent, run } = await fixture();
const [conversation] = await db.update(issues).set({ title: "Chat with CEO", description: null,
@@ -1,3 +1,4 @@
import { resolveProviderApiPlacement } from "../native-runtime/provider-api-placement.js";
import { readClaimedNativeStopRecovery } from "../native-runtime/native-cancellation-request.js";
import { museRunnerBroker } from "../muse-runner-broker.js";
import { assertEphemeralNativeLifecycle, readRemoteWorkspaceAuthority } from "../remote-workspaces.js";
@@ -123,7 +124,7 @@ export interface HeartbeatRuntimeSelectionInput {
persistedExecutionWorkspace: { id: string; metadata?: Record<string, unknown> | null } | null;
executionWorkspace: Omit<Parameters<typeof buildNativeExecutionInput>[0]["workspace"], "id">;
executionTarget: Parameters<typeof prepareNativeWorkspaceSync>[0]["target"];
activeEnvironmentLease: Pick<Parameters<typeof prepareNativeWorkspaceSync>[0], "lease">;
activeEnvironmentLease: Pick<Parameters<typeof prepareNativeWorkspaceSync>[0], "lease"> | null;
isDotRun: boolean;
projectRepositoryPaths: string[];
onCheckpoint?: Parameters<typeof prepareNativeWorkspaceSync>[0]["onCheckpoint"];
@@ -279,7 +280,11 @@ export async function selectHeartbeatRuntime(db: Db, input: HeartbeatRuntimeSele
throw new Error("native_runtime_ineligible: issue is required");
}
const nativeExecutionWorkspaceId =
persistedExecutionWorkspace?.id ?? run.id;
persistedNativeExecutionInput?.provider.kind === "cursor_cloud"
// Older Cursor runs keep their admitted binding identity. Their task
// files and Computer are not part of the recovered API controller.
? persistedNativeExecutionInput.binding.executionWorkspaceId
: persistedExecutionWorkspace?.id ?? run.id;
const nativeReviewContext = readNativeReviewAssignmentContext(context);
const nativeReview = nativeReviewContext ? await measureRunPhase("runtime_selection.review", () => getNativeReviewAssignment(db, {
companyId: agent.companyId, issueId: issueRef.id, agentId: agent.id,
@@ -514,10 +519,10 @@ export async function selectHeartbeatRuntime(db: Db, input: HeartbeatRuntimeSele
((executionTarget?.kind === "remote" && executionTarget.transport === "sandbox" && !!executionTarget.runnerTask) ||
supportsManagedNativeWarmSession({ ...nativeRuntimeResolution.profile, acpxAgent: parseObject(agent.adapterConfig).acpxAgent }, managedAiRuntime?.attribution));
const effectiveLifecyclePolicy = persistedNativeExecutionInput?.session.lifecyclePolicy ??
(["openai_dot_mcp", "grok_bot_mcp", "muse_external"].includes(nativeRuntimeResolution.profile.backend) || managedAiRuntime && !supportsManagedWarmSession
(["openai_dot_mcp", "grok_bot_mcp", "muse_external", "cursor_cloud_api"].includes(nativeRuntimeResolution.profile.backend) || managedAiRuntime && !supportsManagedWarmSession
? { mode: "per_turn" as const, idleTimeoutMs: null }
: environmentLifecyclePolicy ?? agentLifecyclePolicy);
if (!["openai_dot_mcp", "grok_bot_mcp", "muse_external"].includes(nativeRuntimeResolution.profile.backend)) {
if (!["openai_dot_mcp", "grok_bot_mcp", "muse_external", "cursor_cloud_api"].includes(nativeRuntimeResolution.profile.backend)) {
assertEphemeralNativeLifecycle(effectiveLifecyclePolicy.mode);
}
if (
@@ -774,7 +779,7 @@ export async function selectHeartbeatRuntime(db: Db, input: HeartbeatRuntimeSele
}),
) ??
`# ${issueRef.identifier ?? issueRef.id}: ${issueRef.title}`,
!["openai_dot_mcp", "grok_bot_mcp", "muse_external"].includes(nativeRuntimeResolution.profile.backend) && projectRepositoryPaths.length > 0
!["openai_dot_mcp", "grok_bot_mcp", "muse_external", "cursor_cloud_api"].includes(nativeRuntimeResolution.profile.backend) && projectRepositoryPaths.length > 0
? `## Project repositories\nThe task workspace also contains these editable Git repositories:\n${projectRepositoryPaths.map((repo) => `- ${repo}`).join("\n")}`
: null,
].filter(Boolean).join("\n\n"),
@@ -970,6 +975,7 @@ export async function selectHeartbeatRuntime(db: Db, input: HeartbeatRuntimeSele
runnerProfileJson: {
...nativeRuntimeResolution.profile,
...lockedProfile,
...(nativeExecution?.provider.kind === "cursor_cloud" ? { executionPlacement: resolveProviderApiPlacement({ agent, run }) } : {}),
nativePreparationFencingVersion: !lockedRun.runnerInstanceId && !lockedRun.nativeSessionId
? NATIVE_PREPARATION_FENCING_VERSION
: lockedProfile.nativePreparationFencingVersion,
@@ -1058,7 +1064,8 @@ export async function selectHeartbeatRuntime(db: Db, input: HeartbeatRuntimeSele
});
if (!nativeSelected) return { selected: false };
stopControllerLease();
nativeWorkspaceSync = readRemoteWorkspaceAuthority(persistedExecutionWorkspace?.metadata) ? null
if (nativeExecution?.provider.kind !== "cursor_cloud" && !activeEnvironmentLease) throw new Error("native_environment_lease_unavailable");
nativeWorkspaceSync = nativeExecution?.provider.kind === "cursor_cloud" || readRemoteWorkspaceAuthority(persistedExecutionWorkspace?.metadata) ? null
: await measureRunPhase("runtime_selection.workspace_sync", () => prepareNativeWorkspaceSync({
db,
runId: run.id,
@@ -1066,7 +1073,7 @@ export async function selectHeartbeatRuntime(db: Db, input: HeartbeatRuntimeSele
workspaceId: nativeExecutionWorkspaceId,
workspaceLocalDir: executionWorkspace.cwd,
target: executionTarget,
lease: activeEnvironmentLease.lease,
lease: activeEnvironmentLease!.lease,
restartRecovery: runOptions.nativeRestartRecovery,
sameRunRecovery: Boolean(runOptions.nativeLeaseOwner),
resourceDisposition: providerResourceDispositionForRun,
@@ -126,6 +126,56 @@ describe.skipIf(!support.supported && !externalDatabaseUrl)("heartbeat workspace
};
return { input, source, issue, run, envOrchestrator };
}
it.each([false, true])("places Cursor Cloud in its provider API without a Computer under managed-only policy (ephemeral: %s)", async ephemeral => {
vi.stubEnv("PAPERCLIP_EPHEMERAL_SERVER", String(ephemeral));
const { input, source, issue, run } = await fixture();
input.agent = { ...input.agent, adapterType: "paperclip_runner", adapterConfig: {
provider: "cursor_cloud", model: "caller-model", repoUrl: "https://github.com/example/qualification",
} };
await db.update(agents).set({ adapterType: input.agent.adapterType, adapterConfig: input.agent.adapterConfig }).where(eq(agents.id, input.agent.id));
const settingsBefore = await instanceSettingsService(db).get();
try {
await instanceSettingsService(db).updateExperimental({ enableManagedSandboxOnly: true, enableNativeRunner: true });
const selectionInput = {
run: input.run, agent: input.agent,
task: { issueRef: issue, issueContext: issue, issueId: issue.id, context: input.task.context,
executionProjectId: issue.projectId, workspaceProjectContext: { hasWorkspace: true }, boundSourceWorkspace: null,
hasSelectedWorkspaceSource: true, taskWorkspaceIntent: null, responsibleUserId: null },
policy: { ...input.policy }, config: { config: input.agent.adapterConfig, sourceWorkspaceConfig: input.agent.adapterConfig, issueAssigneeOverrides: null },
services: { environmentsSvc: environmentService(db), executionWorkspacesSvc: executionWorkspaceService(db),
instanceSettings: instanceSettingsService(db), findSharedWorkspaceHolder: async () => null, findExecutionWorkspaceHolder: async () => null },
};
const selection = await selectHeartbeatEnvironment(db, selectionInput);
expect(selection).toMatchObject({ providerApiPlacement: { kind: "provider_api", provider: "cursor_cloud", controller: "paperclip" },
selectedEnvironmentId: null, selectedEnvironmentForConfig: null, executionPolicy: { managedSandboxOnly: true } });
input.environment = { ...input.environment, selectedEnvironmentId: selection.selectedEnvironmentId,
selectedEnvironmentForConfig: selection.selectedEnvironmentForConfig, environmentResolution: selection.environmentResolution,
resolvedInstanceSettings: selection.resolvedInstanceSettings };
input.policy.executionPolicy = selection.executionPolicy;
const acquire = vi.spyOn(input.services.envOrchestrator, "acquireForRun");
const realize = vi.spyOn(input.services.envOrchestrator, "realizeForRun");
const resolveWorkspace = vi.spyOn(input.services, "resolveWorkspaceForRun");
const result = await prepareHeartbeatWorkspace(db, input);
expect(result).toMatchObject({ selectedEnvironment: null, activeEnvironmentLease: null, executionTarget: null,
persistedExecutionWorkspace: null, projectRepositoryPaths: [], hostExecutionWorkspaceConfig: {}, skipHostProvision: true });
expect(result.executionWorkspace.cwd).toContain(`/cursor-cloud-controllers/${input.agent.companyId}/${input.agent.id}/${issue.id}`);
expect(result.executionWorkspace).toMatchObject({ projectId: null, repoUrl: null, repoRef: null, created: false });
expect(await readFile(join(source, "README.md"), "utf8")).toBe("source content\n");
expect(acquire).not.toHaveBeenCalled(); expect(realize).not.toHaveBeenCalled(); expect(resolveWorkspace).not.toHaveBeenCalled();
expect(await db.select().from(environmentLeases).where(eq(environmentLeases.heartbeatRunId, run.id))).toEqual([]);
expect(await db.select().from(executionWorkspaces)).toEqual([]);
expect((await instanceSettingsService(db).getExperimental()).enableManagedSandboxOnly).toBe(true);
await instanceSettingsService(db).updateGeneral({ executionMode: "kubernetes" });
await expect(selectHeartbeatEnvironment(db, selectionInput)).rejects.toMatchObject({ status: 422,
details: { code: "provider_api_kubernetes_execution_unsupported" } });
} finally {
await instanceSettingsService(db).updateGeneral({ executionMode: settingsBefore.general.executionMode ?? "any" });
await instanceSettingsService(db).updateExperimental({
enableManagedSandboxOnly: settingsBefore.experimental.enableManagedSandboxOnly,
enableNativeRunner: settingsBefore.experimental.enableNativeRunner,
});
}
});
it.each(["unconfigured", "operator-disabled"] as const)("auto-enrollment keeps %s native sandboxes on their existing host path", async mode => {
const { input, source, envOrchestrator } = await fixture();
input.agent.adapterType = "paperclip_runner";
@@ -1713,7 +1763,7 @@ describe.skipIf(!support.supported && !externalDatabaseUrl)("heartbeat workspace
if (transport !== "daytona") {
await expect(prepareNativeWorkspaceSync({ db, runId: input.run.id, companyId: issue.companyId,
workspaceId: first.persistedExecutionWorkspace!.id, workspaceLocalDir: first.executionWorkspace.cwd,
target, lease: result.activeEnvironmentLease.lease })).resolves.toBeNull();
target, lease: result.activeEnvironmentLease!.lease })).resolves.toBeNull();
}
expect(admittedRoot).not.toBe(first.executionWorkspace.cwd);
} else {
@@ -1817,7 +1867,7 @@ describe.skipIf(!support.supported && !externalDatabaseUrl)("heartbeat workspace
const result = await prepareHeartbeatWorkspace(db, input);
expect((await readIssue(issue.id)).executionWorkspaceId).toBe(result.persistedExecutionWorkspace!.id);
expect(result.persistedExecutionWorkspace!.name).toBe("Realized workspace");
result.activeEnvironmentLease.lease.metadata!.remoteCwd = "/rebound";
expect(result.workspaceRestoreSource.metadata?.remoteCwd).toBe("/original");
result.activeEnvironmentLease!.lease.metadata!.remoteCwd = "/rebound";
expect(result.workspaceRestoreSource!.metadata?.remoteCwd).toBe("/original");
});
});
@@ -1,3 +1,4 @@
import { resolveProviderApiPlacement } from "../native-runtime/provider-api-placement.js";
import { assertEphemeralWorkspaceExecution, readRemoteWorkspaceAuthority, RemoteWorkspaceWriterActiveError, reserveRemoteWorkspace, shouldUseRemoteWorkspaces } from "../remote-workspaces.js";
import { WORKSPACE_BUSY_HOLDER_STALE_AFTER_MS, WorkspaceBusyDeferral } from "./retries.js";
import { WORKSPACE_BUSY_RETRY_REASON } from "../../modules/run-dispatch/index.js";
@@ -127,10 +128,10 @@ export interface HeartbeatWorkspacePreparationInput {
executionPolicy: Parameters<typeof evaluateExecutionAllowlist>[0];
};
environment: {
selectedEnvironmentId: string;
selectedEnvironmentId: string | null;
localEnvironment: Environment;
selectedEnvironmentForConfig: Environment | null;
environmentResolution: ExecutionWorkspaceEnvironmentResolution;
environmentResolution: ExecutionWorkspaceEnvironmentResolution | { environmentId: null; source: "default" };
resolvedInstanceSettings: Awaited<ReturnType<ReturnType<typeof instanceSettingsService>["get"]>>;
};
config: {
@@ -169,13 +170,14 @@ function readNonEmptyString(value: unknown): string | null {
export async function prepareHeartbeatWorkspace(db: Db, input: HeartbeatWorkspacePreparationInput) {
const sourceIssueId = input.reuse.reusableExistingExecutionWorkspace?.sourceIssueId
?? input.reuse.existingExecutionWorkspace?.sourceIssueId ?? input.task.issueId;
if (!sourceIssueId || input.reuse.isDotRun) return prepareHeartbeatWorkspaceLocked(db, input);
if (!sourceIssueId || input.reuse.isDotRun || resolveProviderApiPlacement(input)) return prepareHeartbeatWorkspaceLocked(db, input);
return withIsolatedRepositorySourceLock(db, input.agent.companyId, sourceIssueId,
() => prepareHeartbeatWorkspaceLocked(db, input));
}
async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspacePreparationInput) {
const { run, agent, controllerLease, nativeRunnerPreparationSpans } = input;
const providerApiPlacement = resolveProviderApiPlacement(input);
const fresh = input.task.freshWorkspace?.mode === "fresh_isolated_workspace";
const timeWorkspace = createHeartbeatPreparationTiming(run.id);
const {
@@ -217,7 +219,8 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace
} = input.reuse;
// An ordinary clean task directory already has a new per-attempt identity.
// It is not a repository source and must not pass through Git copy admission.
const freshNeedsSeed = fresh && !isDotRun && !useIsolatedTaskDirectory &&
const controllerOnly = isDotRun || providerApiPlacement !== null;
const freshNeedsSeed = fresh && !controllerOnly && !useIsolatedTaskDirectory &&
!(nativeChatWorkspaceScope && !nativeChatWorkspaceScope.projectId);
const {
envOrchestrator,
@@ -237,14 +240,14 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace
}
}
const enrolledRemoteAuthority = readRemoteWorkspaceAuthority((reusableExistingExecutionWorkspace ?? existingExecutionWorkspace)?.metadata) !== null;
let remoteAuthority = !isDotRun && shouldUseRemoteWorkspaces({
let remoteAuthority = !controllerOnly && shouldUseRemoteWorkspaces({
adapterType: agent.adapterType,
environmentDriver: selectedEnvironmentForConfig?.driver ?? null,
experimentalEnabled: (process.env.PAPERCLIP_EPHEMERAL_SERVER === "true" || resolvedInstanceSettings.experimental.remoteWorkspaceStorageEnabled) && Boolean(issueId && issueRef)
&& !projectExecutionWorkspacePolicy?.storageProviderPluginId,
existingWorkspace: reusableExistingExecutionWorkspace ?? existingExecutionWorkspace,
});
assertEphemeralWorkspaceExecution({ externalConversation: isDotRun,
if (!providerApiPlacement) assertEphemeralWorkspaceExecution({ externalConversation: isDotRun,
adapterType: agent.adapterType,
pluginTaskWorkspace, remoteAuthority });
const assertRemotePreparationOwned = run.runtimeMode === "native" ? () => assertNativeSessionPreparationAuthority({
@@ -260,7 +263,7 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace
"Remote workspace storage requires a task.", { reason: "remote_workspace_task_required" });
const remoteSourceState: { value: Extract<Awaited<ReturnType<typeof resolveRemoteWorkspaceSource>>, { authority: "remote" }> | null } = { value: null };
const pluginConfig = parseObject(selectedEnvironmentForConfig?.config);
if (fresh && !isDotRun && pluginTaskWorkspace) {
if (fresh && !controllerOnly && pluginTaskWorkspace) {
const plugin = typeof pluginConfig.pluginKey === "string" ? await pluginRegistryService(db).getByKey(pluginConfig.pluginKey) : null;
const driver = plugin?.manifestJson.environmentDrivers?.find(value => value.driverKey === pluginConfig.driverKey);
if (driver?.freshWorkspaceVersion !== 1) throw new WorkspaceValidationFailure(
@@ -298,10 +301,11 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace
}
: null,
resolveSelectedEnvironmentDriver: async () => {
if (providerApiPlacement) return "provider_api";
const preflightEnvironment = await envOrchestrator.resolveEnvironment(
{
companyId: agent.companyId,
selectedEnvironmentId,
selectedEnvironmentId: selectedEnvironmentId ?? localEnvironment.id,
localEnvironmentId: localEnvironment.id,
},
);
@@ -329,7 +333,7 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace
remoteAuthority = false;
// Source resolution can retain a configured host folder. Check the
// resolved authority before materializing it or acquiring a sandbox.
assertEphemeralWorkspaceExecution({ externalConversation: isDotRun,
if (!providerApiPlacement) assertEphemeralWorkspaceExecution({ externalConversation: isDotRun,
adapterType: agent.adapterType, pluginTaskWorkspace, remoteAuthority });
}
else {
@@ -354,10 +358,10 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace
};
}
}
if (isDotRun) {
if (controllerOnly) {
// This is private controller storage, never a provider filesystem.
// External providers receive workspace.access=none and cwd=null.
const cwd = path.resolve(resolvePaperclipInstanceRoot(), "runtime", "paperclip-runner", (persistedNativeExecutionInput?.provider.kind ?? mergedConfig.provider) === "muse" ? "muse-controllers" : "dot-controllers", agent.companyId, run.id);
const cwd = path.resolve(resolvePaperclipInstanceRoot(), "runtime", "paperclip-runner", providerApiPlacement ? "cursor-cloud-controllers" : (persistedNativeExecutionInput?.provider.kind ?? mergedConfig.provider) === "muse" ? "muse-controllers" : "dot-controllers", agent.companyId, ...(providerApiPlacement ? [agent.id, issueId ?? run.id] : [run.id]));
await fs.mkdir(cwd, { recursive: true, mode: 0o700 });
return { cwd, source: "agent_home" as const, projectId: null, workspaceId: null, repoUrl: null, repoRef: null,
workspaceHints: [], warnings: [], baseCwdFallback: false, materializationFailures: [], additionalWorkspaces: [], referencedProjectFailures: [] };
@@ -497,7 +501,7 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace
let providerFresh: PluginFreshWorkspace | undefined;
let previousProviderWorkspaceId: string | null = null;
let previousProviderBindingRevision = 0;
if (input.task.freshWorkspace?.mode === "fresh_isolated_workspace" && !isDotRun && issueId) {
if (input.task.freshWorkspace?.mode === "fresh_isolated_workspace" && !controllerOnly && issueId) {
const [task] = await db.select({ workspaceId: issues.executionWorkspaceId, revision: issues.workspaceBindingRevision }).from(issues)
.where(and(eq(issues.id, issueId), eq(issues.companyId, agent.companyId)));
previousProviderWorkspaceId = task?.workspaceId ?? null;
@@ -513,7 +517,7 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace
previousWorkspaceId: previousProviderWorkspaceId });
}
}
const pluginPrepared = isDotRun || remoteAuthority ? null : await preparePluginWorkspace(db, input.services.workspaceWorkers, {
const pluginPrepared = controllerOnly || remoteAuthority ? null : await preparePluginWorkspace(db, input.services.workspaceWorkers, {
companyId: agent.companyId, projectId: resolvedWorkspace.projectId, projectWorkspaceId: resolvedWorkspace.workspaceId,
issueId, runId: run.id, agentId: agent.id, responsibleUserId,
existingWorkspaceId: providerFresh ? providerFresh.previousWorkspaceId : reusableExistingExecutionWorkspace?.id ?? issueRef?.executionWorkspaceId ?? null,
@@ -544,7 +548,7 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace
boundWorkspace: reusableExistingExecutionWorkspace ?? existingExecutionWorkspace,
}));
const skipHostProvision = copyOnlyProvisioning || hostProvisionDriver === "sandbox" || hostProvisionDriver === "ssh" || hostProvisionDriver === "plugin";
const hostExecutionWorkspaceConfig = isDotRun ? {} :
const hostExecutionWorkspaceConfig = controllerOnly ? {} :
stripHostWorkspaceProvision({
config: fresh && !remoteAuthority ? { ...mergedConfig, workspaceStrategy: { type: "project_primary" } } : mergedConfig,
skipHostProvision,
@@ -726,7 +730,7 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace
warnings: [], created: false, branchCreatedByRuntime: false,
baseRefSha: pluginPrepared.metadata?.workspaceProviderBaseSha as string | null,
} as RealizedExecutionWorkspace, reusedExecutionWorkspace: Boolean(reusableExistingExecutionWorkspace), policy: workspaceReuseProvisioningPolicy }
: isDotRun || pluginTaskWorkspace || remoteAuthority ? { executionWorkspace: { ...executionWorkspaceBase, strategy: pluginTaskWorkspace || remoteAuthority ? latestWorkspaceStrategyType : "project_primary" as const, cwd: freshTaskReservation?.cwd ?? resolvedWorkspace.cwd, branchName: remoteSourceState.value?.source.kind === "git" ? remoteSourceState.value!.source.branchName ?? null : null, worktreePath: null, warnings: [], created: false, branchCreatedByRuntime: false } as RealizedExecutionWorkspace, reusedExecutionWorkspace: (pluginTaskWorkspace || remoteAuthority) && workspaceReuseProvisioningPolicy.shouldRestoreExistingWorkspace && Boolean(reusableExistingExecutionWorkspace), policy: workspaceReuseProvisioningPolicy } : await timeWorkspace("workspace_provision", () => provisionExecutionWorkspaceForFreshnessDecision<RealizedExecutionWorkspace>(
: controllerOnly || pluginTaskWorkspace || remoteAuthority ? { executionWorkspace: { ...executionWorkspaceBase, strategy: pluginTaskWorkspace || remoteAuthority ? latestWorkspaceStrategyType : "project_primary" as const, cwd: freshTaskReservation?.cwd ?? resolvedWorkspace.cwd, branchName: remoteSourceState.value?.source.kind === "git" ? remoteSourceState.value!.source.branchName ?? null : null, worktreePath: null, warnings: [], created: false, branchCreatedByRuntime: false } as RealizedExecutionWorkspace, reusedExecutionWorkspace: (pluginTaskWorkspace || remoteAuthority) && workspaceReuseProvisioningPolicy.shouldRestoreExistingWorkspace && Boolean(reusableExistingExecutionWorkspace), policy: workspaceReuseProvisioningPolicy } : await timeWorkspace("workspace_provision", () => provisionExecutionWorkspaceForFreshnessDecision<RealizedExecutionWorkspace>(
{
requestedShouldReuseExisting,
existingExecutionWorkspaceId:
@@ -854,7 +858,7 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace
const resolvedProjectWorkspaceId =
resolvedWorkspaceReusePolicy.shouldRestoreExistingWorkspace && reusableExistingExecutionWorkspace?.strategyType === "git_worktree"
? reusableExistingExecutionWorkspace.projectWorkspaceId
: isDotRun ? null : issueRef?.projectWorkspaceId ?? resolvedWorkspace.workspaceId ?? null;
: controllerOnly ? null : issueRef?.projectWorkspaceId ?? resolvedWorkspace.workspaceId ?? null;
let persistedExecutionWorkspace: ExecutionWorkspace | null = null;
let issueExecutionWorkspaceIdForRun =
issueRef?.executionWorkspaceId ?? null;
@@ -875,7 +879,7 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace
const bindIssueToPersistedExecutionWorkspace = async (
workspace: ExecutionWorkspace | null,
) => {
if (isDotRun || !issueId || !workspace || nativeRecoveryExecutionWorkspaceId) {
if (controllerOnly || !issueId || !workspace || nativeRecoveryExecutionWorkspaceId) {
return;
}
const nextIssueWorkspaceMode =
@@ -996,6 +1000,7 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace
try {
if (remoteAuthority) {
if (!remoteSourceState.value || !issueId) throw new Error("remote_workspace_source_missing");
if (!selectedEnvironmentId) throw new Error("remote_workspace_environment_missing");
await assertRemotePreparationOwned();
const reserved = await reserveRemoteWorkspace({
db, companyId: agent.companyId, issueId, runId: run.id, agentId: agent.id,
@@ -1053,7 +1058,7 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace
),
},
))
: !isDotRun && !persistedNativeExecutionInput && (resolvedProjectId || issueRef)
: !controllerOnly && !persistedNativeExecutionInput && (resolvedProjectId || issueRef)
? await timeWorkspace("workspace_record_create", () => executionWorkspacesSvc.create({
companyId: agent.companyId,
projectId: resolvedProjectId,
@@ -1178,7 +1183,7 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace
const projectRepositoryPaths: string[] = [];
if (remoteAuthority) projectRepositoryPaths.push(...(remoteSourceState.value?.source.repositories ?? [])
.filter(repository => !repository.readOnly).map(repository => repository.relativePath));
if (!pluginPrepared && !pluginTaskWorkspace && !remoteAuthority && !isDotRun && persistedExecutionWorkspace && issueId && !persistedNativeExecutionInput && !nativeRecoveryExecutionWorkspaceId) {
if (!pluginPrepared && !pluginTaskWorkspace && !remoteAuthority && !controllerOnly && persistedExecutionWorkspace && issueId && !persistedNativeExecutionInput && !nativeRecoveryExecutionWorkspaceId) {
const taskRepositories = await timeWorkspace("workspace_repository_admission", () =>
executionWorkspacesSvc.prepareTaskRepositoriesForAdmission({ companyId: agent.companyId,
issueId, workspaceId: persistedExecutionWorkspace!.id, cwd: executionWorkspace.cwd,
@@ -1231,6 +1236,21 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace
})
.where(eq(heartbeatRuns.id, run.id));
}
if (providerApiPlacement) {
await controllerLease.assertOwned();
return {
resolvedWorkspace, hostExecutionWorkspaceConfig, skipHostProvision: true,
latestWorkspaceConfigMetadata, workspaceConfigFreshness, workspaceOperationRecorder,
executionWorkspace, reusedExecutionWorkspace, resolvedWorkspaceReusePolicy,
persistedExecutionWorkspace: null, projectRepositoryPaths: [],
environmentAcquireStartedAtMs: Date.now(), selectedEnvironment: null,
activeEnvironmentLease: null,
realizationResult: { remoteExecution: null, executionTarget: null, persistedExecutionWorkspace: null,
lease: null, workspaceRealization: null },
environmentRealizeEndedAtMs: Date.now(), workspaceRealization: null,
executionTarget: null, workspaceRestoreSource: null,
};
}
const environmentAcquireStartedAtMs = Date.now();
let acquiredEnvironment: Awaited<
ReturnType<typeof envOrchestrator.acquireForRun>
@@ -1254,7 +1274,7 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace
}
acquiredEnvironment = await timeWorkspace("workspace_environment_acquire", () => envOrchestrator.acquireForRun({
companyId: agent.companyId,
selectedEnvironmentId,
selectedEnvironmentId: selectedEnvironmentId ?? localEnvironment.id,
localEnvironmentId: localEnvironment.id,
adapterType: agent.adapterType,
adapterConfig: parseObject(agent.adapterConfig),
@@ -1423,7 +1443,7 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace
if (wake) wake.executionContinuation = continuation;
}
}
if (!pluginPrepared && !pluginTaskWorkspace && !remoteAuthority && !isDotRun && issueId && (persistedNativeExecutionInput || nativeRecoveryExecutionWorkspaceId)
if (!pluginPrepared && !pluginTaskWorkspace && !remoteAuthority && !controllerOnly && issueId && (persistedNativeExecutionInput || nativeRecoveryExecutionWorkspaceId)
&& !(executionTarget?.kind === "remote" && (executionTarget.transport === "ssh"
|| (executionTarget.transport === "sandbox" && executionTarget.runnerTask)))) {
const workspaceId = persistedNativeExecutionInput?.binding.executionWorkspaceId ?? nativeRecoveryExecutionWorkspaceId!;
@@ -4,9 +4,24 @@ import { join } from "node:path";
import { and, eq } from "drizzle-orm";
import { heartbeatRuns, type Db } from "@paperclipai/db";
import { loadConfig } from "../../config.js";
import { parseObject } from "../../adapters/utils.js";
import { archiveCursorCloudSessionCheckpoint, CURSOR_CLOUD_DEFAULT_TIMEOUT_MS, type NativeExecutionInput, type PersistedNativeSession } from "../../vendor/paperclip-runner/index.js";
import { createRemoteMcpRelayToken, registerRemoteMcpRelay, remoteMcpRelayUrl } from "./remote-mcp-relay.js";
/** Normalize Cursor's terminal evidence before shared Stop authority consumes it. */
export function isConfirmedCursorCloudTerminalObservation(event: { eventType: string; payload: unknown }): boolean {
const payload = parseObject(event.payload), errorCode = parseObject(payload.error).code;
// Containment can join an already confirmed provider end. A local failure or
// an explicitly unobserved provider end cannot prove remote termination.
const confirmedTerminal = (event.eventType === "turn.interrupted" && payload.status === "interrupted")
|| (event.eventType === "turn.completed" && payload.status === "completed")
|| (event.eventType === "turn.cancelled" && payload.status === "cancelled"
&& payload.providerTerminalState === "completed" && payload.reason === "cancelled_after_provider_completed")
|| (event.eventType === "turn.failed" && payload.status === "failed" && typeof errorCode === "string"
&& ["cursor_cloud_error", "cursor_cloud_expired"].includes(errorCode));
return confirmedTerminal && (payload.providerTerminalObserved === undefined || payload.providerTerminalObserved === true);
}
export function resolveCursorCloudPublicOrigin(): string | undefined {
return loadConfig().authPublicBaseUrl;
}
@@ -0,0 +1,56 @@
import { randomUUID } from "node:crypto";
import { describe, expect, it } from "vitest";
import { buildNativeExecutionInput } from "./native-execution-input.js";
import { nativeRuntimeContextFixture } from "./runtime-context.test-fixture.js";
import { resolveProviderApiPlacement } from "./provider-api-placement.js";
import { evaluateExecutionAllowlist } from "../execution-allowlist.js";
const placement = { kind: "provider_api", provider: "cursor_cloud", controller: "paperclip" };
const agent = { adapterType: "paperclip_runner", adapterConfig: {
provider: "cursor_cloud", model: "caller-model", repoUrl: "https://github.com/example/qualification",
} };
function execution(provider: "cursor_cloud" | "codex") {
return buildNativeExecutionInput({ companyId: randomUUID(), agentId: randomUUID(), runId: randomUUID(), provider,
model: "caller-model", ...(provider === "cursor_cloud" ? { cursorCloud: { schema: "paperclip.cursor-cloud.config.v1" as const, repos: [{ url: "https://github.com/example/qualification" }] } } : {}),
issue: { id: randomUUID(), identifier: "API-1", title: "Bound task", description: null, workMode: "standard" },
taskPrompt: "Complete the task", normalizedSessionId: randomUUID(),
workspace: { id: randomUUID(), cwd: "/private/controller", repoUrl: null, repoRef: null, branchName: null },
completionContract: { id: randomUUID(), sha256: `sha256:${"a".repeat(64)}`, schemaVersion: "paperclip.run-result.v1",
contract: { revision: "1", objective: "Complete the task", criteria: [{ id: "objective", requirement: "Complete the task" }] } },
runtimeContext: nativeRuntimeContextFixture(),
});
}
describe("provider API controller placement", () => {
it("derives API placement only from a validated native Cursor contract while local agent code stays denied", () => {
expect(resolveProviderApiPlacement({ agent })).toEqual(placement);
expect(resolveProviderApiPlacement({ agent: { ...agent, adapterType: "cursor_cloud" } })).toBeNull();
expect(resolveProviderApiPlacement({ agent: { ...agent, adapterConfig: { provider: "codex", executionPlacement: placement } } })).toBeNull();
expect(evaluateExecutionAllowlist({ managedSandboxOnly: true }, { driver: "local", provider: null }).allowed).toBe(false);
});
it.each(["command", "extraArgs", "cwd", "workspaceStrategy", "workspaceRuntime", "executionPlacement", "providerApiPlacement", "controllerPlacement", "apiOnly"])("refuses configurable host authority through %s", key => {
expect(() => resolveProviderApiPlacement({ agent: { ...agent, adapterConfig: { ...agent.adapterConfig, [key]: key === "extraArgs" ? ["--execute"] : "injected" } } })).toThrow();
});
it("requires exact model and refuses unrelated environment or a preconfigured home", () => {
for (const patch of [{ model: "" }, { env: { NODE_OPTIONS: "--require=untrusted" } }, { homeMode: "preconfigured" }]) {
expect(() => resolveProviderApiPlacement({ agent: { ...agent, adapterConfig: { ...agent.adapterConfig, ...patch } } })).toThrow();
}
});
it("restores the admitted provider rather than reinterpreting a later agent configuration", () => {
const nativeExecutionInput = execution("cursor_cloud");
const run = { runtimeMode: "native", runnerProfileJson: { backend: "cursor_cloud_api", executionPlacement: placement, nativeExecutionInput } };
expect(resolveProviderApiPlacement({ agent: { ...agent, adapterConfig: { provider: "codex" } }, run })).toEqual(placement);
expect(() => resolveProviderApiPlacement({ agent, run: { ...run, runnerProfileJson: { ...run.runnerProfileJson, backend: "codex_app_server" } } })).toThrow("binding_mismatch");
expect(() => resolveProviderApiPlacement({ agent, run: { ...run, runnerProfileJson: { ...run.runnerProfileJson, nativeExecutionInput: execution("codex") } } })).toThrow("binding_mismatch");
expect(resolveProviderApiPlacement({ agent, run: { runtimeMode: "native", runnerProfileJson: { nativeExecutionInput: execution("codex") } } })).toBeNull();
});
it("binds historical Cursor input to its backend and recorded driver before granting API placement", () => {
const nativeExecutionInput = execution("cursor_cloud");
const run = { runtimeMode: "native", driverKind: "cursor_cloud_api", runnerProfileJson: { backend: "cursor_cloud_api", nativeExecutionInput } };
expect(resolveProviderApiPlacement({ agent, run })).toEqual(placement);
expect(resolveProviderApiPlacement({ agent, run: { ...run, driverKind: null } })).toEqual(placement);
expect(resolveProviderApiPlacement({ agent, run: { ...run, driverKind: null, runnerProfileJson: { nativeExecutionInput } } })).toEqual(placement);
expect(() => resolveProviderApiPlacement({ agent, run: { ...run, runnerProfileJson: { ...run.runnerProfileJson, backend: "codex_app_server" } } })).toThrow("binding_mismatch");
expect(() => resolveProviderApiPlacement({ agent, run: { ...run, driverKind: "codex_app_server" } })).toThrow("binding_mismatch");
});
});
@@ -0,0 +1,60 @@
import { parseObject } from "../../adapters/utils.js";
import { unprocessable } from "../../errors.js";
import { isExecutionForcedToKubernetes, type ExecutionPolicy } from "../execution-allowlist.js";
import { parseNativeExecutionInput } from "../../vendor/paperclip-runner/index.js";
import { resolvePaperclipRunnerNativeProviderInput } from "./provider-profile.js";
export interface ProviderApiPlacement {
kind: "provider_api";
provider: "cursor_cloud";
controller: "paperclip";
}
export function managedCursorApiDefaultEnabled(experimental: {
enableManagedSandboxOnly?: boolean; enableNativeRunner?: boolean;
} | null | undefined): boolean {
return experimental?.enableManagedSandboxOnly === true && experimental.enableNativeRunner === true;
}
export function assertProviderApiPlacementPolicy(policy: ExecutionPolicy): void {
if (isExecutionForcedToKubernetes(policy)) {
throw unprocessable("Cursor Cloud API execution is unavailable under the Kubernetes-only policy.", {
code: "provider_api_kubernetes_execution_unsupported",
});
}
}
/** Held setup may be incomplete. This only skips unused Computer preparation;
* active hire and verification must still derive a fully validated placement. */
export function isProviderApiSetupDraft(agent: { adapterType?: string; adapterConfig?: unknown }): boolean {
return agent.adapterType === "paperclip_runner" && parseObject(agent.adapterConfig).provider === "cursor_cloud";
}
/** Placement is server-owned. A provider's API client is not an agent Computer. */
export function resolveProviderApiPlacement(input: {
agent: { adapterType: string; adapterConfig: unknown };
run?: { runtimeMode: string | null; runnerProfileJson: unknown; driverKind?: string | null };
}): ProviderApiPlacement | null {
const profile = parseObject(input.run?.runnerProfileJson);
const persisted = input.run?.runtimeMode === "native" && profile.nativeExecutionInput !== undefined
? parseNativeExecutionInput(profile.nativeExecutionInput) : null;
if (persisted?.provider.kind === "cursor_cloud"
&& ((profile.backend != null && profile.backend !== "cursor_cloud_api")
|| (input.run?.driverKind != null && input.run.driverKind !== "cursor_cloud_api"))) {
throw new Error("provider_api_placement_binding_mismatch");
}
const saved = profile.executionPlacement;
const savedRecord = parseObject(saved);
if (saved !== undefined && (Object.keys(savedRecord).sort().join(",") !== "controller,kind,provider"
|| savedRecord.kind !== "provider_api" || savedRecord.provider !== "cursor_cloud" || savedRecord.controller !== "paperclip"
|| persisted?.provider.kind !== "cursor_cloud" || profile.backend !== "cursor_cloud_api")) {
throw new Error("provider_api_placement_binding_mismatch");
}
if (persisted) return persisted.provider.kind === "cursor_cloud"
? { kind: "provider_api", provider: "cursor_cloud", controller: "paperclip" } : null;
const config = parseObject(input.agent.adapterConfig);
if (input.agent.adapterType !== "paperclip_runner" || config.provider !== "cursor_cloud") return null;
// Validate the provider's complete launch contract before granting placement.
resolvePaperclipRunnerNativeProviderInput({ backend: "cursor_cloud_api", adapterConfig: config });
return { kind: "provider_api", provider: "cursor_cloud", controller: "paperclip" };
}
@@ -182,7 +182,7 @@ export function resolvePaperclipRunnerCursorCloudConfig(config: Record<string, u
throw new PaperclipRunnerProviderProfileError("paperclip_runner_cursor_cloud_config_invalid", `Choose one matching value for ${current} and ${legacy}.`);
}
}
const unsupported = ["codexPermissionMode", "opencodePermissionMode", "acpxPermissionMode", "acpxSessionMode", "command", "extraArgs", "promptTemplate", "bootstrapPromptTemplate", "modelReasoningEffort", "reasoningEffort", "effort", "modelParams"]
const unsupported = ["codexPermissionMode", "opencodePermissionMode", "acpxPermissionMode", "acpxSessionMode", "command", "extraArgs", "promptTemplate", "bootstrapPromptTemplate", "modelReasoningEffort", "reasoningEffort", "effort", "modelParams", "executionPlacement", "providerApiPlacement", "controllerPlacement", "apiOnly", "cwd", "workspaceRuntime", "workspaceStrategy"]
.filter(key => config[key] !== undefined && config[key] !== "" && !(Array.isArray(config[key]) && config[key].length === 0));
if (config.lifecycleMode !== undefined && config.lifecycleMode !== "per_turn") unsupported.push("lifecycleMode");
if (config.homeMode === "preconfigured") unsupported.push("homeMode");
+3
View File
@@ -758,6 +758,9 @@ describe("runner E2E catalog", () => {
'summary:"PAPERCLIP_E2E_PLAN_DONE_nonce"',
);
expect(task!.buildPrompt("nonce")).toContain("first call get_task_context");
expect(task!.buildPrompt("nonce")).toContain("criteria:CURRENT_TURN_CRITERIA");
expect(task!.buildPrompt("nonce")).toContain("entry for every current criterion ID");
expect(task!.buildPrompt("nonce")).not.toContain('criterionId:"objective"');
expect(task!.buildPrompt("nonce")).toContain(
"identifies the exact revised Plan revision used as the confirmation target as accepted",
);
+1 -1
View File
@@ -685,7 +685,7 @@ export const runnerTasks: readonly RunnerTaskFixture[] = [
"Only after the revised plan is accepted, implement it by posting one final visible task-thread response containing exactly " +
`PAPERCLIP_E2E_PLAN_DONE_${nonce}` +
" and mark the task Done.",
`For a native runner, remain in the requested planning collaboration mode. Call write_document for key \`plan\`, then call request_human_input exactly once with interactionKind \`confirmation\`, targetRevisionId set to the returned latest Plan revision, and continuationPolicy \`wake_assignee\`. For both the initial Plan and the revised Plan, those two tool calls form one indivisible response sequence: immediately after write_document succeeds, request_human_input must be your next action using that call's returned latestRevisionId. Do not emit assistant text, end the response or heartbeat, or stop after write_document alone before the matching confirmation request succeeds. Do not call paperclip_finish while waiting for either Plan confirmation. When an acceptance wake arrives, first call get_task_context. Treat the wake as valid only when that control-plane result is for the current task and identifies the exact revised Plan revision used as the confirmation target as accepted; otherwise do not finish and continue waiting for the matching revision-bound confirmation. After that verification succeeds, your immediate next action must be the paperclip_finish tool call. Do not call list_documents or any other tool, and do not emit any assistant text, acknowledgement, progress note, or preamble between verification and paperclip_finish. Use the current turn completion revision and criterion IDs (CURRENT_TURN_REVISION below is a placeholder, not a literal). Call paperclip_finish exactly once with {reportedWorkDisposition:"done",summary:"PAPERCLIP_E2E_PLAN_DONE_${nonce}",completionClaim:{contractRevision:CURRENT_TURN_REVISION,objectiveSatisfied:true,criteria:[{criterionId:"objective",status:"satisfied",evidenceRefs:[]}],remainingWork:[]},evidence:[],verification:[]}. Wait for that tool call to succeed, then emit only PAPERCLIP_E2E_PLAN_DONE_${nonce} as the complete final response. Do not write a user-facing final response before paperclip_finish succeeds, and do not call another tool.`,
`For a native runner, remain in the requested planning collaboration mode. Call write_document for key \`plan\`, then call request_human_input exactly once with interactionKind \`confirmation\`, targetRevisionId set to the returned latest Plan revision, and continuationPolicy \`wake_assignee\`. For both the initial Plan and the revised Plan, those two tool calls form one indivisible response sequence: immediately after write_document succeeds, request_human_input must be your next action using that call's returned latestRevisionId. Do not emit assistant text, end the response or heartbeat, or stop after write_document alone before the matching confirmation request succeeds. Do not call paperclip_finish while waiting for either Plan confirmation. When an acceptance wake arrives, first call get_task_context. Treat the wake as valid only when that control-plane result is for the current task and identifies the exact revised Plan revision used as the confirmation target as accepted; otherwise do not finish and continue waiting for the matching revision-bound confirmation. After that verification succeeds, your immediate next action must be the paperclip_finish tool call. Do not call list_documents or any other tool, and do not emit any assistant text, acknowledgement, progress note, or preamble between verification and paperclip_finish. Use the current turn completion revision and criterion IDs. CURRENT_TURN_REVISION and CURRENT_TURN_CRITERIA below are placeholders, not literals. CURRENT_TURN_CRITERIA must contain one {criterionId:<current criterion ID>,status:"satisfied",evidenceRefs:[]} entry for every current criterion ID. Call paperclip_finish exactly once with {reportedWorkDisposition:"done",summary:"PAPERCLIP_E2E_PLAN_DONE_${nonce}",completionClaim:{contractRevision:CURRENT_TURN_REVISION,objectiveSatisfied:true,criteria:CURRENT_TURN_CRITERIA,remainingWork:[]},evidence:[],verification:[]}. Wait for that tool call to succeed, then emit only PAPERCLIP_E2E_PLAN_DONE_${nonce} as the complete final response. Do not write a user-facing final response before paperclip_finish succeeds, and do not call another tool.`,
`For a legacy runner, use the public Paperclip API. The first PUT of the \`plan\` issue document creates it. For every later PUT, first GET the current document and set \`baseRevisionId\` to its \`latestRevisionId\`; a 409 means you must GET again and retry with the new latest revision. Create a \`request_confirmation\` targeting the successful PUT response's \`latestRevisionId\` with \`continuationPolicy: wake_assignee\`, and move the issue to \`in_review\` while waiting. After the revised Plan is accepted, write PAPERCLIP_E2E_PLAN_DONE_${nonce} exactly once through one atomic issue PATCH with status \`done\` and that exact comment; do not POST a separate comment or perform a second write.`,
"Do not create files, child tasks, or unrelated work, and do not expose credentials.",
].join("\n"),
@@ -212,7 +212,7 @@ describe("Cursor Cloud replay bundle treatment", () => {
it("the actual driver reconnects the real client with its saved cursor and deduplicates the replayed frame without creating another prompt", async () => {
const directory = await root(), events: PrpEvent[] = [], proofs: CursorCloudReplayFaultProof[] = [];
const nativeRun = "run-33333333-3333-4333-8333-333333333333", time = "2026-10-28T00:00:00Z";
let nativeAgent = "", creates = 0, streams = 0;
let nativeAgent = "", creates = 0, streams = 0, cancelled = false;
const requests: Array<{ path: string; headers: Headers }> = [];
const peer: typeof fetch = async (resource, init) => {
const path = new URL(String(resource)).pathname, headers = new Headers(init?.headers); requests.push({ path, headers });
@@ -226,7 +226,8 @@ it("the actual driver reconnects the real client with its saved cursor and dedup
const withheld = 'event: assistant\nid: withheld\ndata: {"text":"must be replayed"}\n\n';
return bytes([Buffer.from(first + withheld + (streams === 1 ? "" : `event: assistant\nid: new-cursor\ndata: {"text":"Recovered."}\n\nevent: result\nid: terminal\ndata: {"runId":"${nativeRun}","status":"FINISHED"}\n\n`))]);
}
if (path.endsWith(nativeRun)) return Response.json({ id: nativeRun, agentId: nativeAgent, status: streams < 2 ? "RUNNING" : "FINISHED", createdAt: time, updatedAt: time });
if (path.endsWith("/cancel") && init?.method === "POST") { cancelled = true; return Response.json({ id: nativeRun }); }
if (path.endsWith(nativeRun)) return Response.json({ id: nativeRun, agentId: nativeAgent, status: cancelled ? "CANCELLED" : streams < 2 ? "RUNNING" : "FINISHED", createdAt: time, updatedAt: time });
throw new Error("Unexpected controlled request");
};
const client = new CursorCloudClient({ apiKey: "private-auth-canary", fetch: createCursorCloudReplayFaultFetch({ fetch: peer, digest, record: value => proofs.push(value) }) });
@@ -101,7 +101,7 @@ export function createCursorCloudReplayFaultFetch(input: {
if (cutNextRead) {
proof.cutCount += 1;
save();
controller.error(new Error("Injected Cursor Cloud SSE disconnect"));
controller.error(new TypeError("Injected Cursor Cloud SSE disconnect"));
void reader.cancel().catch(() => {});
return;
}
@@ -302,7 +302,7 @@ export function CodexLocalConfigFields({
)}
{runnerManaged && runnerProvider === "grok_bot" && <GrokBotConnection companyId={companyId} agentId={agentId} />}
{runnerManaged && isCursorCloud && <>
<p className="text-sm text-muted-foreground">Experimental. Runs in Cursor Cloud and requires a local Paperclip computer. Cursor manages command permissions; effective model identity is unavailable. Agent environment settings support only CURSOR_API_KEY. Configure hosted secrets in your Cursor Cloud environment.</p>
<p className="text-sm text-muted-foreground">Experimental. Cursor hosts this agent’s execution environment. A Paperclip computer is not required. Cursor manages command permissions; effective model identity is unavailable. Agent environment settings support only CURSOR_API_KEY. Configure hosted secrets in your Cursor Cloud environment.</p>
{[
{ key: "repoUrl", label: "GitHub repository", fallback: "", legacy: undefined, placeholder: "https://github.com/your-org/repo", hint: "Select a repository or a named Cursor cloud environment." },
{ key: "repoRef", label: "Starting ref", fallback: config.repoStartingRef ?? "", legacy: "repoStartingRef", placeholder: "Repository default" },
@@ -1324,6 +1324,49 @@ describe("AgentConfigForm environment selector", () => {
expect(result.container.querySelector('[aria-label="Environment override"]')).toBeNull();
});
it.each([null, "saved-computer"])("tests native Cursor Cloud without a Computer when the saved target is %s", async defaultEnvironmentId => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableEnvironments: true, enableManagedSandboxOnly: true });
mockInstanceSettingsApi.get.mockResolvedValue({ defaultEnvironmentId: "instance-computer" });
mockAdaptersApi.list.mockResolvedValue([{ type: "cursor_cloud", supportedRunners: ["paperclip", "legacy"], defaultRunner: "legacy" }]);
const result = await renderForm([], {
adapterType: "paperclip_runner", defaultEnvironmentId,
adapterConfig: { provider: "cursor_cloud", model: "claude-sonnet-4-5", repoUrl: "https://github.com/example/qualification" },
}, { showAdapterTestEnvironmentButton: true });
roots.push(result.root);
expect(result.container.querySelector('[aria-label="Environment override"]')).toBeNull();
await runTest(result.container);
expect(mockAgentsApi.testEnvironment).toHaveBeenCalledExactlyOnceWith("company-1", "paperclip_runner", expect.objectContaining({ environmentId: null, agentId: "agent-1" }));
expect(mockAgentsApi.qualifyRunner).not.toHaveBeenCalled();
expect(mockEnvironmentsApi.probe).not.toHaveBeenCalled();
});
it("clears a Computer inherited by a new native Cursor Cloud draft", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableEnvironments: true, enableManagedSandboxOnly: true });
mockAdaptersApi.list.mockResolvedValue([{ type: "cursor_cloud", supportedRunners: ["paperclip", "legacy"], defaultRunner: "legacy" }]);
const result = await renderCreateForm([], {
adapterType: "paperclip_runner", runner: "paperclip", defaultEnvironmentId: "previous-computer",
adapterSchemaValues: { provider: "cursor_cloud", model: "claude-sonnet-4-5" },
});
roots.push(result.root);
expect(result.onChange).toHaveBeenCalledWith({ defaultEnvironmentId: "" });
expect(result.container.querySelector('[aria-label="Environment override"]')).toBeNull();
});
it("tests the native default explicitly when creating Cursor Cloud with automatic runner selection", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableEnvironments: true, enableManagedSandboxOnly: true });
mockAdaptersApi.list.mockResolvedValue([{ type: "cursor_cloud", supportedRunners: ["paperclip"], defaultRunner: "paperclip" }]);
const result = await renderCreateForm([], {
adapterType: "cursor_cloud", runner: "auto", model: "claude-sonnet-4-5",
adapterSchemaValues: { provider: "cursor_cloud", repoUrl: "https://github.com/example/qualification" },
}, { showAdapterTestEnvironmentButton: true });
roots.push(result.root);
await runTest(result.container);
expect(mockAgentsApi.testEnvironment).toHaveBeenCalledExactlyOnceWith("company-1", "cursor_cloud", expect.objectContaining({
environmentId: null, runner: "paperclip",
}));
});
it("requests the native Kimi model catalog while preserving an explicit saved model", async () => {
mockAgentsApi.adapterModels.mockResolvedValue([]);
const result = await renderForm([], {
+15 -7
View File
@@ -659,6 +659,11 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
? (runner === "paperclip" || (runner === "auto" && runnerAvailability?.defaultRunner === "paperclip") ? "paperclip_runner" : harnessType)
: adapterType;
const isCursorCloudRunner = harnessType === "cursor_cloud" && fieldsAdapterType === "paperclip_runner";
useEffect(() => {
if (props.mode !== "create" || !isCursorCloudRunner || !props.values.defaultEnvironmentId) return;
props.onChange({ defaultEnvironmentId: "" });
}, [props.mode, isCursorCloudRunner, isCreate ? props.values.defaultEnvironmentId : undefined,
isCreate ? props.onChange : undefined]);
useEffect(() => {
if (props.mode !== "create" || !isRunnerHarness) return;
const resolvedRunner = runner === "auto" ? runnerAvailability?.defaultRunner : runner;
@@ -992,10 +997,10 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
// Only applies in create mode and only once the K8s environment is loaded; if
// none is available the UI surfaces a notice instead of silently selecting it.
useEffect(() => {
if (!isCreate || !set || !forcedKubernetes || !kubernetesEnvironment) return;
if (!isCreate || !set || isCursorCloudRunner || !forcedKubernetes || !kubernetesEnvironment) return;
if (currentDefaultEnvironmentId === kubernetesEnvironment.id) return;
set({ defaultEnvironmentId: kubernetesEnvironment.id });
}, [isCreate, set, forcedKubernetes, kubernetesEnvironment, currentDefaultEnvironmentId]);
}, [isCreate, set, isCursorCloudRunner, forcedKubernetes, kubernetesEnvironment, currentDefaultEnvironmentId]);
const runnableEnvironments = useMemo(
() => environments.filter((environment) => {
@@ -1172,7 +1177,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
let settings = instanceSettings;
let environmentList = environments;
let managedSandboxOnly = experimentalSettings?.enableManagedSandboxOnly === true;
if (!rawCurrentDefaultEnvironmentId) {
if (!isCursorCloudRunner && !rawCurrentDefaultEnvironmentId) {
// The agent has no own environment, so the Test resolves the instance
// default, the local default, or the managed sandbox. Resolve the
// settings, the environment list, and the managed-sandbox-only policy
@@ -1210,7 +1215,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
// policy is on, probe the managed sandbox the real run uses instead. The
// resolver throws when no managed sandbox is available, which the mutation
// surfaces as a fail-closed error rather than a local host probe.
const environmentId = resolveAdapterTestEnvironmentId({
const environmentId = isCursorCloudRunner ? null : resolveAdapterTestEnvironmentId({
agentDefaultEnvironmentId: rawCurrentDefaultEnvironmentId || null,
instanceDefaultEnvironmentId: settings?.defaultEnvironmentId ?? null,
localDefaultEnvironmentId: resolveLocalDefaultEnvironmentId(environmentList),
@@ -1227,6 +1232,9 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
const aiConnection = isCreate ? undefined : aiRuntimeConnectionBindingSchema.safeParse(
(overlay.runtime.runtimeConfig as Record<string, unknown> | undefined)?.aiConnection ?? props.agent.runtimeConfig.aiConnection,
).data;
const testRunnerSelection = isCursorCloudRunner
? { runner: "paperclip" as const }
: isRunnerHarness && (isCreate ? runner !== "auto" : overlay.runner !== undefined) ? { runner } : {};
if (!isCreate && props.agent.adapterType === "paperclip_runner" && adapterType === "paperclip_runner"
&& runnerAvailability?.defaultRunner !== "paperclip"
&& !isExternalRunner && aiConnection?.mode !== "router" && !isDirty && environmentId && canQualifyRunnerEnvironment(environmentList.find(environment => environment.id === environmentId))) {
@@ -1246,9 +1254,9 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
: adapterConfig.provider === "acpx" && adapterConfig.acpxAgent === "claude" ? "claude_local"
: adapterType
: adapterType;
return testAgentSetup({ companyId: selectedCompanyId, adapterType, providerAdapter, adapterConfig, agentId, aiConnection, environmentId, ...(isRunnerHarness && (isCreate ? runner !== "auto" : overlay.runner !== undefined) ? { runner } : {}) });
return testAgentSetup({ companyId: selectedCompanyId, adapterType, providerAdapter, adapterConfig, agentId, aiConnection, environmentId, ...testRunnerSelection });
}
return agentsApi.testEnvironment(selectedCompanyId, adapterType, { adapterConfig, agentId, aiConnection, environmentId, ...(isRunnerHarness && (isCreate ? runner !== "auto" : overlay.runner !== undefined) ? { runner } : {}) });
return agentsApi.testEnvironment(selectedCompanyId, adapterType, { adapterConfig, agentId, aiConnection, environmentId, ...testRunnerSelection });
},
});
const [testActionPending, setTestActionPending] = useState(false);
@@ -1718,7 +1726,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
)}
{/* ---- Execution ---- */}
{forcedKubernetes ? (
{isCursorCloudRunner ? null : forcedKubernetes ? (
// Instance execution policy forces the managed Kubernetes sandbox
// (executionMode=kubernetes): never offer local / non-Kubernetes targets.
// Render the environment read-only instead of the selectable picker.
+48
View File
@@ -218,6 +218,54 @@ it("offers one Codex harness without a runner/provider tile", async () => {
expect(document.querySelectorAll('input[value="codex_local"]')).toHaveLength(1);
expect(document.querySelector("select")).toBeNull();
});
it("opens native Cursor Cloud from the ordinary hosted adapter picker", async () => {
await act(async () => {
cache.setQueryData(queryKeys.health, { status: "ok", cloud: { managed: true } });
cache.setQueryData(queryKeys.adapters.availability("company-1", null), [...state.adapters,
{ type: "cursor_cloud", loaded: true, supportedRunners: ["paperclip"], defaultRunner: "paperclip" }]);
});
await name();
const cursor = document.querySelector<HTMLInputElement>('input[value="cursor_cloud"]');
expect(cursor).not.toBeNull();
await act(async () => cursor!.click());
await click("Configure agent");
expect(agents.update).toHaveBeenCalledWith(agents.create.mock.calls[0][1].setupId, expect.objectContaining({
adapterType: "paperclip_runner", runner: "paperclip", adapterConfig: { provider: "cursor_cloud" },
}), "company-1");
const query = new URL(state.navigate.mock.calls[0][0], "http://local").searchParams;
expect(query.get("adapterType")).toBe("paperclip_runner");
expect(query.get("runnerProvider")).toBe("cursor_cloud");
expect(query.get("runner")).toBe("paperclip");
});
it("hides explicitly unsupported harnesses while keeping the qualified Cursor API card", async () => {
await act(async () => {
cache.setQueryData(queryKeys.health, { status: "ok", cloud: { managed: true } });
cache.setQueryData(queryKeys.adapters.availability("company-1", null), [
{ type: "codex_local", loaded: true, supportedRunners: [] },
{ type: "opencode_local", loaded: true, supportedRunners: [] },
{ type: "paperclip_runner", loaded: true, supportedRunners: [] },
{ type: "cursor_cloud", loaded: true, supportedRunners: ["paperclip"], defaultRunner: "paperclip" },
]);
});
await name();
expect(document.querySelector('input[value="codex_local"]')).toBeNull();
expect(document.querySelector('input[value="opencode_local"]')).toBeNull();
expect(document.querySelector('input[value="copilot_runtime"]')).toBeNull();
expect(document.querySelector('input[value="antigravity_runtime"]')).toBeNull();
expect(document.querySelector('input[value="cursor_cloud"]')).not.toBeNull();
});
it.each(["gate off", "native unavailable"])("keeps hosted Cursor Cloud hidden when %s", async reason => {
await act(async () => {
cache.setQueryData(queryKeys.health, { status: "ok", cloud: { managed: true } });
if (reason === "gate off") cache.setQueryData(queryKeys.instance.experimentalSettings, { enableNativeRunner: false });
cache.setQueryData(queryKeys.adapters.availability("company-1", null), [...state.adapters,
{ type: "cursor_cloud", loaded: true, supportedRunners: reason === "native unavailable" ? ["legacy"] : ["paperclip"], defaultRunner: "legacy" }]);
});
await name();
expect(document.querySelector('input[value="cursor_cloud"]')).toBeNull();
});
it.each([true, false, undefined])("opens saved-token Copilot setup without an experimental override (%s)", async enableNativeRunner => {
await act(async () => cache.setQueryData(queryKeys.instance.experimentalSettings, { enableNativeRunner }));
await name();
+32
View File
@@ -23,6 +23,8 @@ import { pendingConnectionIntentInteraction } from "@/fixtures/issueThreadIntera
import { TaskChatScrollNavigation, useTaskChatScrollNavigation } from "./task-chat/scroll-navigation";
import { CoordinatorPresentation } from "./coordinator/CoordinatorPresentation";
import type { HeartbeatRunEvent } from "@paperclipai/shared";
import type { HeartbeatRun } from "@paperclipai/shared";
import { waitForStoppedRuns } from "@/lib/wait-for-stopped-runs";
const transcriptState = vi.hoisted(() => ({
transcriptByRun: new Map(),
@@ -5221,6 +5223,36 @@ describe("TaskChatThread live transcript", () => {
});
describe("TaskChatThread composer execution controls", () => {
it("reconciles a timed-out Stop from exact company-bound historical proof after its live action disappears", async () => {
vi.useFakeTimers();
try {
const run = { id: "stopped-run", status: "running", runtimeMode: "native" as const, invocationSource: "issue", triggerDetail: null,
startedAt: "2026-11-03T15:00:00Z", finishedAt: null, createdAt: "2026-11-03T15:00:00Z", agentId: "agent-1", agentName: "Alex", adapterType: "paperclip_runner" };
const requested: Partial<HeartbeatRun> & { runtimeMode: "native" } = {
id: run.id, companyId: "company", status: "cancelled", runtimeMode: "native",
resultJson: { executionCancellation: { state: "requested" } },
};
const onStop = () => waitForStoppedRuns([run.id], { timeoutMs: 1000, getRun: async () => requested as HeartbeatRun });
const base = { comments: [], onAdd: async () => {}, issueStatus: "in_progress", companyId: "company" };
render(<TaskChatThread {...base} activeRun={run} onCancelRun={onStop} />);
await act(async () => { container.querySelector<HTMLButtonElement>('[data-testid="task-chat-composer-stop"]')!.click(); });
await act(async () => { await vi.advanceTimersByTimeAsync(1000); });
expect(container.querySelector('[role="alert"]')?.textContent).toContain("stop was requested");
const historical = { runId: run.id, agentId: run.agentId, adapterType: run.adapterType, createdAt: run.createdAt,
startedAt: run.startedAt, finishedAt: run.startedAt, status: "cancelled", runtimeMode: "native" as const,
resultJson: { executionCancellation: { state: "acknowledged" }, nativeCancellation: {
schema: "paperclip.native-cancellation.v1", runId: run.id, companyId: "company", dispatchState: "acknowledged" } } };
render(<TaskChatThread {...base} linkedRuns={[{ ...historical, runtimeMode: undefined }]} />);
await act(async () => {}); expect(container.querySelector('[role="alert"]')).not.toBeNull();
render(<TaskChatThread {...base} linkedRuns={[{ ...historical, resultJson: { executionCancellation: { state: "acknowledged" } } }]} />);
await act(async () => {}); expect(container.querySelector('[role="alert"]')).not.toBeNull();
render(<TaskChatThread {...base} linkedRuns={[{ ...historical, resultJson: { ...historical.resultJson,
nativeCancellation: { ...historical.resultJson.nativeCancellation, companyId: "foreign" } } }]} />);
await act(async () => {}); expect(container.querySelector('[role="alert"]')).not.toBeNull();
render(<TaskChatThread {...base} linkedRuns={[historical]} />);
await act(async () => {}); expect(container.querySelector('[role="alert"]')).toBeNull();
} finally { vi.useRealTimers(); }
});
it.each(["process", "paperclip_runner"])("passes the task's stop action through for %s execution", async (adapterType) => {
const onStop = vi.fn(async () => {});
const run = {
+7
View File
@@ -143,6 +143,7 @@ import { issuesApi } from "@/api/issues";
import { queryKeys } from "@/lib/queryKeys";
import { useQueryClient } from "@tanstack/react-query";
import { TaskChatPresentationProvider } from "@/components/task-chat/presentation-mode";
import { isRunStopConfirmed } from "@/lib/wait-for-stopped-runs";
const EMPTY_BROWSERS: TaskBrowser[] = [];
const COMPACT_RUN_LOG_READ_LIMIT_BYTES = 32_000;
@@ -587,6 +588,11 @@ export function TaskChatThread(props: TaskChatThreadProps) {
resumeAssigneePending = false,
onOpenSkill,
} = props;
const confirmedStoppedRunIds = useMemo(() => new Set((linkedRuns ?? []).filter((run) =>
companyId && (run.runtimeMode === "legacy" || run.runtimeMode === "native") && isRunStopConfirmed({
id: run.runId, companyId, status: run.status, runtimeMode: run.runtimeMode, resultJson: run.resultJson ?? null,
}),
).map((run) => run.runId)), [companyId, linkedRuns]);
const retryFailedRunHandler =
isTerminalIssueStatus(issueStatus) ||
interactions?.some((interaction) => interaction.status === "pending") ||
@@ -3485,6 +3491,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
!("clientStatus" in comment && comment.clientStatus)
).map((comment) => comment.clientRequestId!))}
onStop={liveRun ? onCancelRun : undefined}
confirmedStoppedRunIds={confirmedStoppedRunIds}
stopPending={stopPending}
stopScope={stopScope}
workMode={issueWorkMode}
@@ -89,7 +89,7 @@ export function AgentBasicsDialog({
queryFn: () => adaptersApi.list({ companyId: companyId! }),
enabled: open && Boolean(companyId),
});
const nativeRunner = adapters?.find(adapter => adapter.type === "paperclip_runner" && adapter.loaded && !adapter.disabled);
const nativeRunner = adapters?.find(adapter => adapter.type === "paperclip_runner" && adapter.loaded && !adapter.disabled && adapter.supportedRunners?.length !== 0);
const copilotAvailable = Boolean(nativeRunner) && PAPERCLIP_RUNNER_ACPX_PROFILES.some(profile => profile.value === "copilot" && profile.qualified);
const kimiAvailable = Boolean(nativeRunner) && experimental.data?.enableNativeRunner === true
&& PAPERCLIP_RUNNER_ACPX_PROFILES.some(profile => profile.value === "kimi" && profile.qualified);
@@ -100,18 +100,20 @@ export function AgentBasicsDialog({
(adapter) =>
adapter.loaded &&
!adapter.disabled &&
adapter.supportedRunners?.length !== 0 &&
adapter.type !== "paperclip_runner" &&
isNewAgentAdapterAllowed(adapter.type, {
cloud,
nativeRunnerEnabled: experimental.data?.enableNativeRunner === true,
}) &&
(!cloud || adapter.type !== "cursor_cloud" || adapter.supportedRunners?.includes("paperclip")) &&
!["process", "http"].includes(adapter.type) &&
!getAdapterDisplay(adapter.type).comingSoon,
), ...(copilotAvailable ? [{ ...nativeRunner!, type: "copilot_runtime" }] : []),
...(antigravityAvailable ? [{ ...nativeRunner!, type: "antigravity_runtime" }] : []),
...(kimiAvailable ? [{ ...nativeRunner!, type: "kimi_runtime" }] : [])];
const experimentalRunnerAvailable = experimental.data?.enableNativeRunner === true
&& adapters?.some(adapter => adapter.type === "paperclip_runner" && adapter.loaded && !adapter.disabled);
&& adapters?.some(adapter => adapter.type === "paperclip_runner" && adapter.loaded && !adapter.disabled && adapter.supportedRunners?.length !== 0);
const validAdapter = choices.some((adapter) => adapter.type === adapterType)
|| (experimentalRunnerAvailable && adapterType === "paperclip_runner" && (runnerProvider !== "antigravity" || antigravityQualified));
async function submit() {
@@ -142,8 +144,10 @@ export function AgentBasicsDialog({
if (active.current) setName(agent.name);
}
saved.current = agent;
const nextAdapter = ["openai_dot", "copilot_runtime", "antigravity_runtime", "kimi_runtime"].includes(adapterType) ? "paperclip_runner" : adapterType;
const nextProvider = adapterType === "kimi_runtime" ? "kimi" : adapterType === "antigravity_runtime" ? "antigravity" : adapterType === "copilot_runtime" ? "copilot" : adapterType === "openai_dot" ? "openai_dot" : adapterType === "paperclip_runner" ? runnerProvider : "codex";
const hostedCursor = cloud && adapterType === "cursor_cloud";
const nextAdapter = hostedCursor || ["openai_dot", "copilot_runtime", "antigravity_runtime", "kimi_runtime"].includes(adapterType) ? "paperclip_runner" : adapterType;
const nextProvider = hostedCursor ? "cursor_cloud" : adapterType === "kimi_runtime" ? "kimi" : adapterType === "antigravity_runtime" ? "antigravity" : adapterType === "copilot_runtime" ? "copilot" : adapterType === "openai_dot" ? "openai_dot" : adapterType === "paperclip_runner" ? runnerProvider : "codex";
const nextRunner = hostedCursor ? "paperclip" as const : "auto" as const;
// Pending providers can show setup while the saved name-only draft stays held.
const pendingAntigravity = nextProvider === "antigravity" && !antigravityQualified;
if (step === "adapter" || (!creatingRecord && agent.name !== name.trim())) {
@@ -155,7 +159,7 @@ export function AgentBasicsDialog({
setupAdapterChoice: { adapterType: nextAdapter, runnerProvider: nextProvider, runner: "auto" } },
} : {}),
...(step === "adapter" && !pendingAntigravity ? {
adapterType: nextAdapter, runner: "auto" as const,
adapterType: nextAdapter, runner: nextRunner,
adapterConfig: nextAdapter === "paperclip_runner" ? {
provider: ["claude", "grok", "copilot", "antigravity", "kimi"].includes(nextProvider) ? "acpx" : nextProvider,
...(["claude", "grok", "copilot", "antigravity", "kimi"].includes(nextProvider) ? { acpxAgent: nextProvider } : {}),
@@ -171,7 +175,7 @@ export function AgentBasicsDialog({
if (!active.current) return;
if (step === "name") setStep("adapter");
else onContinue({ name: agent.name, adapterType: nextAdapter, runnerProvider: nextProvider,
runner: "auto", setupAgentId: agent.id });
runner: nextRunner, setupAgentId: agent.id });
} catch (cause) {
if (active.current) setSaveError(cause instanceof Error ? cause.message : "Could not save agent setup.");
} finally {
+14 -12
View File
@@ -315,7 +315,7 @@ function Setup({
let environmentId: string | null = null;
let environmentError: string | null = null;
try {
environmentId = forced.forced
environmentId = nativeCursorCloud ? null : forced.forced
? (forced.kubernetesEnvironment?.id ?? null)
: resolveAdapterTestEnvironmentId({
agentDefaultEnvironmentId: environmentOverride || null,
@@ -414,6 +414,7 @@ function Setup({
const cloud = Boolean(useCloudInstance());
const available =
(!nativeCursorCloud || experimental.data?.enableNativeRunner === true) &&
(!cloud || adapterType !== "cursor_cloud" || nativeCursorCloud) &&
isNewAgentAdapterAllowed(adapterType, {
cloud,
nativeRunnerEnabled: experimental.data?.enableNativeRunner === true,
@@ -447,12 +448,13 @@ function Setup({
!general.error &&
!agents.error &&
!caps.error &&
(!(managedOnly || forced.forced) || environmentId),
(nativeCursorCloud || !(managedOnly || forced.forced) || environmentId),
);
const needsCurrentSetup = ["codex_local", "claude_local", "opencode_local", "pi_local"].includes(adapterType);
const preparingComputer = created?.pauseReason === "setup" && ["preparing", "pausing"].includes(created.lifecycleState ?? "");
const environmentSelectionSaved = (!environmentOverride && created?.defaultEnvironmentId == null)
|| (created?.defaultEnvironmentId ?? null) === (environmentOverride || (forced.forced || managedOnly ? environmentId : null));
const environmentSelectionSaved = nativeCursorCloud
|| (!environmentOverride && created?.defaultEnvironmentId == null)
|| (created?.defaultEnvironmentId ?? null) === (environmentOverride || (forced.forced || managedOnly ? environmentId : null));
const ready = (pendingAntigravity || Boolean(created) && environmentSelectionSaved) && !environmentSaving && !preparingComputer && connectionSettingsReady && (!needsCurrentSetup || (
envs.isSuccess && !envs.isFetching
&& settings.isSuccess && !settings.isFetching
@@ -614,7 +616,7 @@ function Setup({
provider: ["claude", "grok", "copilot", "pi", "antigravity", "kimi"].includes(runnerProvider) ? "acpx" : runnerProvider,
...(["claude", "grok", "copilot", "pi", "antigravity", "kimi"].includes(runnerProvider) ? { acpxAgent: runnerProvider } : {}),
...(antigravityCandidate ? { model: antigravityCandidate.model } : {}),
} : {}, defaultEnvironmentId: environmentOverride || (forced.forced || managedOnly ? environmentId : null),
} : {}, defaultEnvironmentId: nativeCursorCloud ? null : environmentOverride || (forced.forced || managedOnly ? environmentId : null),
runtimeConfig: buildNewAgentRuntimeConfig({ heartbeatEnabled: false }),
}).then(async agent => {
await cache.cancelQueries({ queryKey: queryKeys.agents.detail(agent.id) });
@@ -677,7 +679,7 @@ function Setup({
submitted = true;
const agent = await agentsApi.update(created.id, {
adapterType: requestAdapterType, runner, adapterConfig: config,
defaultEnvironmentId: selectedEnvironment || (forced.forced || managedOnly ? environmentId : null),
defaultEnvironmentId: nativeCursorCloud ? null : selectedEnvironment || (forced.forced || managedOnly ? environmentId : null),
runtimeConfig: { ...created.runtimeConfig, ...(binding ? { aiConnection: binding } : {}) },
...(antigravityCandidate ? { metadata: { ...created.metadata, setupAdapterPending: false, setupAdapterChoice: undefined } } : {}),
...(nextConnection?.applyStoredClaudeLogin ? { applyStoredClaudeLogin: true } : {}),
@@ -805,7 +807,7 @@ function Setup({
adapterType: requestAdapterType,
...(["codex_local", "claude_local", "opencode_local", "pi_local", "cursor_cloud"].includes(adapterType) && runner !== "auto" ? { runner } : {}),
adapterConfig: config,
defaultEnvironmentId:
defaultEnvironmentId: nativeCursorCloud ? null :
environmentOverride ||
(forced.forced || managedOnly ? environmentId : null),
runtimeConfig: { ...buildNewAgentRuntimeConfig({ heartbeatEnabled: false }), ...(aiBinding ? { aiConnection: aiBinding } : {}) },
@@ -995,7 +997,7 @@ function Setup({
This adapter is unavailable. Choose an enabled adapter.
</p>
)}
{(managedOnly || forced.forced) &&
{!nativeCursorCloud && (managedOnly || forced.forced) &&
!envs.isPending &&
!environmentId && (
<p role="alert" className="text-sm text-destructive">
@@ -1444,7 +1446,7 @@ function Setup({
)}
{adapterType === "cursor_cloud" && (
<>
{nativeCursorCloud && <p className="text-sm text-muted-foreground">Experimental. Requires a local Paperclip computer. Cursor manages cloud command permissions; the effective model is not acknowledged.</p>}
{nativeCursorCloud && <p className="text-sm text-muted-foreground">Experimental. Cursor hosts this agent’s execution environment. A Paperclip computer is not required. Cursor manages cloud command permissions; the effective model is not acknowledged.</p>}
<div className="grid gap-5 sm:grid-cols-2">
<Field label="GitHub repository">
<Input
@@ -1480,7 +1482,7 @@ function Setup({
onChange={value => { setRunner(value); resetTest(); }} />
</details>}
</section>
{adapterType !== "hermes_gateway" && (adapterType !== "cursor_cloud" || nativeCursorCloud) && (
{adapterType !== "hermes_gateway" && adapterType !== "cursor_cloud" && (
<section className="space-y-5">
<h3 className="text-sm font-semibold">Environment</h3>
<select
@@ -1493,10 +1495,10 @@ function Setup({
}}
>
<option value="">
Default: {nativeCursorCloud ? environment ? environmentDisplayLabel(environment) : "Local machine" : environmentLabel}
Default: {environmentLabel}
</option>
{(envs.data ?? [])
.filter((env) => env.status === "active" && (!managedOnly || env.driver !== "local") && (!nativeCursorCloud || env.driver === "local"))
.filter((env) => env.status === "active" && (!managedOnly || env.driver !== "local"))
.map((env) => (
<option key={env.id} value={env.id}>
{environmentDisplayLabel(env)}
@@ -3185,6 +3185,61 @@ describe("composer Stop", () => {
return observed as HeartbeatRun & { runtimeMode: "native" };
}
it.each([{ runIds: ["first"] }, { runIds: ["first", "second"] }])("clears a timed-out Stop only after every exact target is confirmed: $runIds", async ({ runIds }) => {
vi.useFakeTimers();
try {
const onStop = vi.fn(() => waitForStoppedRuns(runIds, {
getRun: vi.fn(async id => ({ ...nativeStopAwaitingContainment(), id })), timeoutMs: 1000,
}));
const onAdd = vi.fn();
render(<TaskChatComposer workMode="standard" onAdd={onAdd} onStop={onStop} />);
flushSync(() => stopButton()!.click());
await act(async () => { await vi.advanceTimersByTimeAsync(1000); });
expect(container.querySelector('[role="alert"]')?.textContent).toContain("stop was requested");
render(<TaskChatComposer workMode="standard" onAdd={onAdd} confirmedStoppedRunIds={new Set(["unrelated"])} />);
await act(async () => {});
expect(container.querySelector('[role="alert"]')).not.toBeNull();
if (runIds.length > 1) {
render(<TaskChatComposer workMode="standard" onAdd={onAdd} confirmedStoppedRunIds={new Set([runIds[0]])} />);
await act(async () => {});
expect(container.querySelector('[role="alert"]')).not.toBeNull();
}
render(<TaskChatComposer workMode="standard" onAdd={onAdd} confirmedStoppedRunIds={new Set(runIds)} />);
await act(async () => {});
expect(container.querySelector('[role="alert"]')).toBeNull();
expect(onAdd).not.toHaveBeenCalled();
} finally { vi.useRealTimers(); }
});
it("keeps a newer Stop attempt independent of an earlier target's confirmation", async () => {
vi.useFakeTimers();
try {
const attempt = (id: string) => () => waitForStoppedRuns([id], {
getRun: vi.fn(async () => ({ ...nativeStopAwaitingContainment(), id })), timeoutMs: 1000,
});
render(<TaskChatComposer workMode="standard" onAdd={vi.fn()} onStop={attempt("first")} />);
flushSync(() => stopButton()!.click());
await act(async () => { await vi.advanceTimersByTimeAsync(1000); });
render(<TaskChatComposer workMode="standard" onAdd={vi.fn()} onStop={attempt("second")} />);
flushSync(() => stopButton()!.click());
await act(async () => { await vi.advanceTimersByTimeAsync(1000); });
render(<TaskChatComposer workMode="standard" onAdd={vi.fn()} confirmedStoppedRunIds={new Set(["first"])} />);
await act(async () => {});
expect(container.querySelector('[role="alert"]')).not.toBeNull();
render(<TaskChatComposer workMode="standard" onAdd={vi.fn()} confirmedStoppedRunIds={new Set(["second"])} />);
await act(async () => {});
expect(container.querySelector('[role="alert"]')).toBeNull();
} finally { vi.useRealTimers(); }
});
it("retains an ordinary Stop error when unrelated stop proof arrives", async () => {
render(<TaskChatComposer workMode="standard" onAdd={vi.fn()} onStop={async () => { throw new Error("Stop permission denied"); }} />);
flushSync(() => stopButton()!.click()); await flushAsync();
render(<TaskChatComposer workMode="standard" onAdd={vi.fn()} confirmedStoppedRunIds={new Set(["run"])} />);
await flushAsync();
expect(container.querySelector('[role="alert"]')?.textContent).toBe("Stop permission denied");
});
it("finishes one Stop without a stale warning when the server proves containment after native acknowledgement", async () => {
const requested = nativeStopAwaitingContainment();
let publishContainment!: (value: HeartbeatRun) => void;
@@ -134,6 +134,7 @@ interface TaskChatComposerProps {
};
confirmedSubmissionIds?: ReadonlySet<string>;
onStop?: () => Promise<void>;
confirmedStoppedRunIds?: ReadonlySet<string>;
stopPending?: boolean;
stopScope?: "leaf" | "subtree";
workMode: IssueWorkMode;
@@ -389,6 +390,7 @@ export function TaskChatComposer({
creation,
confirmedSubmissionIds,
onStop,
confirmedStoppedRunIds,
stopPending = false,
workMode,
onWorkModeChange,
@@ -423,7 +425,7 @@ export function TaskChatComposer({
onRunnerGoalReassign,
}: TaskChatComposerProps) {
const streamlined = useStreamlinedTaskChatPresentation();
const stopControl = useComposerStop(onStop, stopPending);
const stopControl = useComposerStop(onStop, stopPending, confirmedStoppedRunIds);
const [localBody, setBody] = useState(() => (draftKey ? loadDraft(draftKey) : ""));
const body = creation ? creation.value : localBody;
const [submitting, setSubmitting] = useState(false);
+14 -8
View File
@@ -1,27 +1,33 @@
import { useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { StopVerificationError } from "@/lib/wait-for-stopped-runs";
/** Keeps stop requests independent of draft submission and prevents double clicks. */
export function useComposerStop(onStop?: () => Promise<void>, pending = false) {
export function useComposerStop(onStop?: () => Promise<void>, pending = false, confirmedStoppedRunIds?: ReadonlySet<string>) {
const inFlight = useRef(false);
const [stopping, setStopping] = useState(false);
const [error, setError] = useState<string | null>(null);
const [failure, setFailure] = useState<{ message: string; runIds?: readonly string[] } | null>(null);
useEffect(() => {
if (failure?.runIds?.length && failure.runIds.every((id) => confirmedStoppedRunIds?.has(id))) {
// A queued effect from an older attempt must not clear a newer failure.
setFailure((current) => current === failure ? null : current);
}
}, [failure, confirmedStoppedRunIds]);
async function stop() {
if (!onStop || inFlight.current || pending) return;
inFlight.current = true;
setStopping(true);
setError(null);
setFailure(null);
try {
await onStop();
} catch (err) {
setError(
err instanceof Error ? err.message : "Unable to stop. Try again.",
);
setFailure({ message: err instanceof Error ? err.message : "Unable to stop. Try again.",
...(err instanceof StopVerificationError ? { runIds: err.runIds } : {}) });
} finally {
inFlight.current = false;
setStopping(false);
}
}
return { stop, stopping: stopping || pending, error };
return { stop, stopping: stopping || pending, error: failure?.message ?? null };
}
+1
View File
@@ -26,6 +26,7 @@ export function isNewAgentAdapterAllowed(
if (type === "paperclip_runner" && runnerProvider === "antigravity") return PAPERCLIP_RUNNER_ACPX_PROFILES.some(profile => profile.value === "antigravity" && profile.qualified)
|| Boolean(candidateHarnesses?.some(candidate => candidate.agent === "antigravity" && candidate.model));
if (type === "paperclip_runner") return runnerProvider === "copilot" || nativeRunnerEnabled;
if (cloud && type === "cursor_cloud") return nativeRunnerEnabled;
if (cloud) return CLOUD_ADAPTERS.has(type);
return true;
}
+8
View File
@@ -17,6 +17,14 @@ function nativeRun(id: string, receipt: Record<string, unknown> = {}): Heartbeat
}
afterEach(() => vi.useRealTimers());
describe("stop confirmation", () => {
it("retains every actual target ID on an uncertain multi-run Stop", async () => {
vi.useFakeTimers();
const error = waitForStoppedRuns(["already", "pending", "pending"], {
initialRuns: [run("already", "cancelled")], getRun: vi.fn(async () => run("pending", "running")), timeoutMs: 1000,
}).catch((failure: unknown) => failure);
await vi.advanceTimersByTimeAsync(1000);
expect(await error).toMatchObject({ name: "StopVerificationError", runIds: ["already", "pending"] });
});
it("waits for embedded ACP acknowledgment even after terminal status", async () => {
vi.useFakeTimers();
const getRun = vi.fn()
+25 -9
View File
@@ -6,7 +6,21 @@ const STOPPED_STATUSES = new Set(["succeeded", "interrupted", "failed", "cancell
const VERIFICATION_ERROR = "The stop was requested, but stopping could not be verified. Refresh and try Stop again if work is still running.";
function isStopped(run: HeartbeatRun) {
type StopRunEvidence = Pick<HeartbeatRun, "id" | "companyId" | "resultJson"> & {
status: string;
runtimeMode?: string;
};
export class StopVerificationError extends Error {
readonly runIds: readonly string[];
constructor(message: string, runIds: readonly string[]) {
super(message);
this.name = "StopVerificationError";
this.runIds = Object.freeze([...new Set(runIds)]);
}
}
export function isRunStopConfirmed(run: StopRunEvidence) {
if (!STOPPED_STATUSES.has(run.status)) return false;
const adapterCancellation = run.resultJson?.executionCancellation;
if (adapterCancellation && typeof adapterCancellation === "object"
@@ -32,14 +46,15 @@ export async function waitForStoppedRuns(
) {
const getRun = options.getRun ?? heartbeatsApi.get;
const deadline = Date.now() + (options.timeoutMs ?? 30_000);
let remaining = [...new Set(runIds)].filter((id) =>
!options.initialRuns?.some((run) => run?.id === id && isStopped(run)),
const targets = [...new Set(runIds)];
let remaining = targets.filter((id) =>
!options.initialRuns?.some((run) => run?.id === id && isRunStopConfirmed(run)),
);
let transientFailure = false;
while (remaining.length > 0) {
if (Date.now() >= deadline) {
throw new Error(transientFailure ? VERIFICATION_ERROR
: "The stop was requested, but work is still stopping. Try Stop again if it continues.");
throw new StopVerificationError(transientFailure ? VERIFICATION_ERROR
: "The stop was requested, but work is still stopping. Try Stop again if it continues.", targets);
}
let states;
let timeout: ReturnType<typeof setTimeout> | undefined;
@@ -55,22 +70,23 @@ export async function waitForStoppedRuns(
]);
} catch (error) {
const transient = error instanceof TypeError || (error instanceof ApiError && error.status >= 500);
if (!transient || Date.now() >= deadline) throw new Error(VERIFICATION_ERROR);
if (!transient || Date.now() >= deadline) throw new StopVerificationError(VERIFICATION_ERROR, targets);
transientFailure = true;
} finally {
clearTimeout(timeout);
}
if (states) {
if (states.some((run, index) => !run || run.id !== remaining[index])) {
throw new Error(VERIFICATION_ERROR);
throw new StopVerificationError(VERIFICATION_ERROR, targets);
}
transientFailure = false;
remaining = remaining.filter((_id, index) => !isStopped(states[index]!));
remaining = remaining.filter((_id, index) => !isRunStopConfirmed(states[index]!));
}
if (remaining.length === 0) return;
if (Date.now() >= deadline) {
throw new Error(
throw new StopVerificationError(
"The stop was requested, but work is still stopping. Try Stop again if it continues.",
targets,
);
}
await new Promise((resolve) =>
+16
View File
@@ -2032,6 +2032,22 @@ describe("IssueDetail", () => {
expect(mockIssuesApi.listComments).not.toHaveBeenCalled();
});
it("forwards exact Stop proof and runtime mode from run history into the task thread", async () => {
queryClient.setQueryData(["issues", "history-expanded", "PAP-1"], false);
const issue = createIssue();
mockIssuesApi.get.mockResolvedValue(issue);
const run = { runId: "stopped-run", runtimeMode: "native", status: "cancelled", agentId: "agent-1", createdAt: new Date().toISOString(),
resultJson: { executionCancellation: { state: "acknowledged", proof: "provider_api_stop_receipt" },
nativeCancellation: { schema: "paperclip.native-cancellation.v1", runId: "stopped-run", companyId: issue.companyId, dispatchState: "acknowledged" } } };
mockActivityApi.recentRunsForIssue.mockResolvedValue([run]);
await act(async () => renderIssueView());
await waitForAssertion(() => {
const props = mockIssueChatThreadRender.mock.calls.at(-1)?.[0];
expect(props?.companyId).toBe(issue.companyId);
expect(props?.linkedRuns).toEqual([expect.objectContaining(run)]);
});
});
it("loads full first-run activity from the task menu without an older-message disclosure", async () => {
queryClient.setQueryData(["issues", "history-expanded", "PAP-1"], false);
mockIssuesApi.get.mockResolvedValue(createIssue());