Carry plugin task refusals and batch project reads (#20762)

This commit is contained in:
Nicky Leach authored and GitHub committed 2026-11-11 00:49:03 -08:00
1 parent f125b823f4
commit 4d141cdf64
21 files changed
+303 -44

No files matched your search

+11
View File
@@ -719,6 +719,17 @@ filesystem — there is no such capability. The browser still goes through the
worker via `getData` / `performAction`, and the worker only exposes paths it
chose to expose.
### Reading project snapshots
Workers with `projects.read`, `project.workspaces.read`, and
`projects.instance-secrets` can call
`ctx.projects.readSnapshots(projectIds, companyId, entityType)` to read up to
64 distinct projects in one call. Each snapshot includes workspaces, the
worker's authorized instance secrets, and its own company-scoped entity of the
given type keyed by the project ID. A missing, archived, inaccessible, or
changing project fails the whole request. Older hosts return `null` through
the SDK. Keep instance secrets inside the trusted worker.
### When to keep worker-mediated project workspace browsing
When the data lives inside an existing project workspace, keep the browsing
@@ -121,6 +121,8 @@ export const environmentTaskOperationSchema = z.discriminatedUnion("kind", [
export const environmentTaskResultSchema = z.discriminatedUnion("kind", [
z.object({ kind: z.literal("accepted"), taskId: providerId }).strict(),
z.object({ kind: z.literal("admission_rejected"), taskId: providerId,
reason: z.enum(["session_state_unavailable", "account_binding_mismatch"]) }).strict(),
z.object({ kind: z.literal("released"), taskId: providerId }).strict(),
z.object({ kind: z.literal("resume"), taskId: providerId, outcome: z.enum(["resumed", "unavailable"]) }).strict(),
z.object({ kind: z.literal("copilot_metadata"), taskId: providerId, metadata: z.record(z.string(), z.unknown()),
@@ -210,6 +212,7 @@ export interface PluginEnvironmentTaskParams extends Omit<PluginEnvironmentDrive
export function parseEnvironmentTaskResult(operation: PluginEnvironmentTaskOperation, taskId: string, value: unknown): PluginEnvironmentTaskResult {
const result = environmentTaskResultSchema.parse(value);
const expected = operation.kind === "release" ? "released" : operation.kind === "resume" ? "resume" : operation.kind === "read_file" ? "file" : operation.kind === "describe" ? "description" : operation.kind === "prepare" ? "prepared" : operation.kind === "check_project_auth" ? "project_auth" : ["read_state", "archive_state"].includes(operation.kind) ? "state" : ["status", "connection", "credentials", "copilot_metadata"].includes(operation.kind) ? operation.kind : "accepted";
if (result.taskId !== taskId || result.kind !== expected) throw new Error("Invalid plugin-provided Runner execution response");
const rejectedSubmit = operation.kind === "submit" && result.kind === "admission_rejected";
if (result.taskId !== taskId || (result.kind !== expected && !rejectedSubmit)) throw new Error("Invalid plugin-provided Runner execution response");
return result;
}
@@ -48,9 +48,10 @@
* @see PLUGIN_SPEC.md §15 — Capability Model
*/
import { isDeepStrictEqual } from "node:util";
import type { PluginCapability } from "@paperclipai/shared";
import type { WorkerHostCallContext, WorkerToHostMethods, WorkerToHostMethodName } from "./protocol.js";
import { PLUGIN_RPC_ERROR_CODES } from "./protocol.js";
import { PLUGIN_RPC_ERROR_CODES, validateProjectSnapshotRequest } from "./protocol.js";
// ---------------------------------------------------------------------------
// Error types
@@ -444,6 +445,7 @@ const METHOD_CAPABILITY_MAP: Record<WorkerToHostMethodName, PluginCapability | n
"projects.list": "projects.read",
"projects.get": "projects.read",
"projects.getInstanceSecrets": "projects.instance-secrets",
"projects.readSnapshots": "projects.instance-secrets",
"projects.resolveGitCredential": "projects.git-credentials",
"projects.listWorkspaces": "project.workspaces.read",
"projects.getPrimaryWorkspace": "project.workspaces.read",
@@ -849,6 +851,32 @@ export function createHostClientHandlers(
const companyId = resolveRequiredCompanyId("projects.getInstanceSecrets", params, context);
return services.projects.getInstanceSecrets({ ...params, companyId });
}),
"projects.readSnapshots": gated("projects.readSnapshots", async (params, context) => {
const companyId = resolveRequiredCompanyId("projects.readSnapshots", params, context);
requireCapability("projects.get");
requireCapability("projects.listWorkspaces");
const { projectIds, entityType } = params;
validateProjectSnapshotRequest(projectIds, entityType);
const snapshots: WorkerToHostMethods["projects.readSnapshots"][1] = [];
for (const projectId of projectIds) {
const scope = { companyId, projectId };
const project = await services.projects.get(scope);
if (project?.id !== projectId || project.companyId !== companyId || project.archivedAt) {
throw new Error("Project snapshot unavailable");
}
const query = { entityType, scopeKind: "company" as const, scopeId: companyId, externalId: projectId, limit: 1 };
const [entity] = await services.entities.list(query);
const instanceSecrets = await services.projects.getInstanceSecrets(scope);
const workspaces = await services.projects.listWorkspaces(scope);
const current = await services.projects.get(scope);
const [latest] = await services.entities.list(query);
if (!isDeepStrictEqual(current, project) || !isDeepStrictEqual(latest, entity)) {
throw new Error("Project snapshot changed during read");
}
snapshots.push({ projectId, workspaces, entity: entity ?? null, instanceSecrets });
}
return snapshots;
}),
"projects.get": gated("projects.get", async (params) => {
return services.projects.get(params);
}),
+1
View File
@@ -329,6 +329,7 @@ export type {
PluginEntityRecord,
PluginEntityQuery,
PluginWorkspace,
PluginProjectSnapshot,
PluginExecutionWorkspaceMetadata,
Company,
Project,
+16 -8
View File
@@ -26,6 +26,7 @@ import type {
PluginLauncherRenderContextSnapshot,
PluginLauncherRenderEnvironment,
PluginStateScopeKind,
PluginEntityQuery,
Company,
Project,
Issue,
@@ -70,6 +71,7 @@ import type {
PluginJobContext,
PluginExecutionWorkspaceMetadata,
PluginWorkspace,
PluginProjectSnapshot,
ToolRunContext,
ToolResult,
PluginLocalFolderListing,
@@ -310,6 +312,15 @@ export interface PluginInvocationContext {
traceparent?: string;
}
export function validateProjectSnapshotRequest(projectIds: unknown, entityType: unknown): void {
if (!Array.isArray(projectIds) || !projectIds.length || projectIds.length > 64 ||
new Set(projectIds).size !== projectIds.length ||
projectIds.some(id => typeof id !== "string" || !/^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i.test(id)) ||
typeof entityType !== "string" || !/^[a-zA-Z0-9._-]{1,128}$/.test(entityType)) {
throw new Error("Invalid project snapshot scope");
}
}
/**
* Context provided to host-side worker→host handlers after the worker echoes a
* host-issued invocation id.
@@ -1734,14 +1745,7 @@ export interface WorkerToHostMethods {
},
];
"entities.list": [
params: {
entityType?: string;
scopeKind?: PluginStateScopeKind;
scopeId?: string;
externalId?: string;
limit?: number;
offset?: number;
},
params: PluginEntityQuery,
result: Array<{
id: string;
entityType: string;
@@ -1855,6 +1859,10 @@ export interface WorkerToHostMethods {
params: { projectId: string; companyId: string },
result: Record<string, string>,
];
"projects.readSnapshots": [
params: { projectIds: string[]; companyId: string; entityType: string },
result: PluginProjectSnapshot[],
];
"projects.get": [
params: { projectId: string; companyId: string },
result: Project | null,
+16
View File
@@ -75,6 +75,7 @@ import type {
PluginPerformActionActorContext,
PluginPerformActionContext,
} from "./protocol.js";
import { validateProjectSnapshotRequest } from "./protocol.js";
export interface TestHarnessOptions {
/** Plugin manifest used to seed capability checks and metadata. */
@@ -1044,6 +1045,21 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
if (!isInCompany(project, companyId) || project.archivedAt) throw new Error("Project instance secrets unavailable");
return structuredClone(projectInstanceSecrets.get(JSON.stringify([companyId, projectId])) ?? {});
},
async readSnapshots(projectIds, companyId, entityType) {
requireCapability(manifest, capabilitySet, "projects.read");
requireCapability(manifest, capabilitySet, "project.workspaces.read");
requireCapability(manifest, capabilitySet, "projects.instance-secrets");
requireCompanyId(companyId);
validateProjectSnapshotRequest(projectIds, entityType);
return Promise.all(projectIds.map(async projectId => {
const project = await ctx.projects.get(projectId, companyId);
if (!project || project.archivedAt) throw new Error("Project snapshot unavailable");
const [entity] = await ctx.entities.list({ entityType, scopeKind: "company", scopeId: companyId, externalId: projectId, limit: 1 });
const instanceSecrets = await ctx.projects.getInstanceSecrets(projectId, companyId);
const workspaces = await ctx.projects.listWorkspaces(projectId, companyId);
return { projectId, workspaces, entity: entity ?? null, instanceSecrets };
}));
},
async list(input) {
requireCapability(manifest, capabilitySet, "projects.read");
const companyId = requireCompanyId(input?.companyId);
+13 -18
View File
@@ -13,6 +13,7 @@ import type {
PaperclipPluginManifestV1,
PluginStateScopeKind,
PluginEventType,
PluginEntityQuery,
PluginToolDeclaration,
PluginLauncherDeclaration,
Company,
@@ -64,6 +65,7 @@ import type { PluginPerformActionContext } from "./protocol.js";
export type {
PaperclipPluginManifestV1,
PluginEntityQuery,
PluginJobDeclaration,
PluginWebhookDeclaration,
PluginToolDeclaration,
@@ -344,24 +346,6 @@ export interface PluginEntityRecord {
updatedAt: string;
}
/**
* Query parameters for `ctx.entities.list()`.
*/
export interface PluginEntityQuery {
/** Filter by entity type. */
entityType?: string;
/** Filter by scope kind. */
scopeKind?: PluginStateScopeKind;
/** Filter by scope ID. */
scopeId?: string;
/** Filter by external ID. */
externalId?: string;
/** Maximum number of results to return. */
limit?: number;
/** Number of results to skip (for pagination). */
offset?: number;
}
// ---------------------------------------------------------------------------
// Project workspace metadata (read-only via ctx.projects)
// ---------------------------------------------------------------------------
@@ -876,6 +860,13 @@ export interface PluginEntitiesClient {
*
* @see PLUGIN_SPEC.md §7 — Project Workspaces
*/
export interface PluginProjectSnapshot {
projectId: string;
workspaces: PluginWorkspace[];
entity: PluginEntityRecord | null;
instanceSecrets: Record<string, string>;
}
export interface PluginProjectsClient {
/** Transient GitHub credential for a stored repository in the current host-issued
* lifecycle/workspace invocation. Requires projects.git-credentials. Never persist or log. */
@@ -883,6 +874,10 @@ export interface PluginProjectsClient {
/** Trusted worker access to this plugin’s native project credentials.
* Requires projects.instance-secrets; never expose via UI/data handlers. */
getInstanceSecrets(projectId: string, companyId: string): Promise<Record<string, string>>;
/** Read authorized project workspaces, instance secrets and one plugin-owned entity per project.
* Missing or changing projects reject the batch. Null means the host does not support this method.
* Keep the result in the trusted worker. */
readSnapshots(projectIds: string[], companyId: string, entityType: string): Promise<PluginProjectSnapshot[] | null>;
/**
* List projects visible to the plugin.
@@ -761,6 +761,14 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
async getInstanceSecrets(projectId, companyId) {
return callHost("projects.getInstanceSecrets", { projectId, companyId });
},
async readSnapshots(projectIds, companyId, entityType) {
try {
return await callHost("projects.readSnapshots", { projectIds, companyId, entityType });
} catch (error) {
if (error instanceof JsonRpcCallError && error.code === JSONRPC_ERROR_CODES.METHOD_NOT_FOUND) return null;
throw error;
}
},
async listWorkspaces(projectId: string, companyId: string) {
return callHost("projects.listWorkspaces", { projectId, companyId });
+2
View File
@@ -923,6 +923,8 @@ export interface PluginCompanySettings {
* Query filter for `ctx.entities.list`.
*/
export interface PluginEntityQuery {
scopeKind?: PluginStateScopeKind;
scopeId?: string;
/** Optional filter by entity type (e.g. 'project', 'issue'). */
entityType?: string;
/** Optional filter by external system identifier. */
@@ -61,6 +61,28 @@ describe("stopped Runner checkpoint settlement", () => {
});
describe("plugin-provided Runner execution", () => {
it("records a confirmed admission refusal so lease cleanup never looks for a Runner", async () => {
const value = row();
const fixture = database(value);
const refusal = { kind: "admission_rejected", taskId: "attempt-1", reason: "session_state_unavailable" };
const workers = worker(refusal);
await expect(executeEnvironmentTask(fixture.db, workers as never, {
companyId: "company", leaseId, operation: submit,
})).resolves.toEqual(refusal);
const update = new PgDialect().sqlToQuery(fixture.sets.at(-1)!.metadata as never);
expect(update.sql).toContain("- 'runnerTaskIdentity'");
const durable = JSON.parse(String(update.params[0]));
expect(durable).toEqual({
runnerTaskAdmissionRejection: { taskId: "attempt-1", reason: "session_state_unavailable" },
runnerTaskDispatched: false, runnerTaskExecutionStopped: true, runnerTaskSessionRetained: false,
});
Object.assign(value.lease.metadata, durable);
await expect(settleDispatchedEnvironmentTask(database(value).db, workers as never, {
companyId: "company", id: leaseId,
})).resolves.toBe("stopped");
expect(workers.call.mock.calls).toHaveLength(1);
});
it.each([false, true])("retires an undispatched warm task only with required preparation proof (%s)", async preparationStopped => {
const value = row();
Object.assign(value.lease.metadata, { runnerTaskDispatched: false,
@@ -615,5 +637,10 @@ describe("plugin-provided Runner execution", () => {
expect(environmentTaskOperationSchema.safeParse({ ...submit, runner: { ...submit.runner, unknownField: true } }).success).toBe(false);
expect(() => parseEnvironmentTaskResult({ kind: "status" }, "attempt-1", { kind: "accepted", taskId: "attempt-1" })).toThrow();
expect(parseEnvironmentTaskResult(submit, "attempt-1", { kind: "accepted", taskId: "attempt-1" }).kind).toBe("accepted");
const rejected = { kind: "admission_rejected", taskId: "attempt-1", reason: "account_binding_mismatch" };
expect(parseEnvironmentTaskResult(submit, "attempt-1", rejected)).toEqual(rejected);
expect(() => parseEnvironmentTaskResult(submit, "attempt-2", rejected)).toThrow();
expect(() => parseEnvironmentTaskResult(submit, "attempt-1", { ...rejected, reason: "unknown" })).toThrow();
expect(() => parseEnvironmentTaskResult({ kind: "stop" }, "attempt-1", rejected)).toThrow();
});
});
@@ -0,0 +1,66 @@
import { describe, expect, it, vi } from "vitest";
import { createHostClientHandlers, type HostServices } from "../../../packages/plugins/sdk/src/host-client-factory.js";
import type { PluginCapability } from "@paperclipai/shared";
const companyId = "00000000-0000-4000-8000-000000000001";
const otherCompany = "00000000-0000-4000-8000-000000000002";
const first = "00000000-0000-4000-8000-000000000003";
const second = "00000000-0000-4000-8000-000000000004";
const capabilities: PluginCapability[] = ["projects.read", "project.workspaces.read", "projects.instance-secrets"];
const entityType = "project-settings";
function fixture(granted = capabilities) {
const projects = new Map([first, second].map(id => [id, { id, companyId, archivedAt: null as Date | null }]));
const entities = new Map([first, second].map(id => [id, { id, status: "ready" }]));
const services = {
projects: {
get: vi.fn(async ({ projectId }: { projectId: string }) => structuredClone(projects.get(projectId) ?? null)),
listWorkspaces: vi.fn(async ({ projectId }: { projectId: string }) => [{ projectId }]),
getInstanceSecrets: vi.fn(async ({ projectId }: { projectId: string }) => ({ fixture: projectId })),
},
entities: { list: vi.fn(async ({ externalId }: { externalId: string }) => entities.has(externalId) ? [{ ...entities.get(externalId) }] : []) },
};
const handlers = createHostClientHandlers({ pluginId: "project-plugin", capabilities: granted, services: services as unknown as HostServices });
return { services, projects, entities, read: (projectIds: string[], requestedCompany = companyId) => handlers["projects.readSnapshots"](
{ projectIds, companyId: requestedCompany, entityType }, { invocationScope: { companyId } },
) };
}
describe("plugin project snapshots", () => {
it("returns the requested projects in order with only the plugin's company-scoped entity", async () => {
const f = fixture();
const result = await f.read([second, first]);
expect(result.map(row => [row.projectId, row.workspaces[0].projectId, row.entity?.id, row.instanceSecrets.fixture]))
.toEqual([[second, second, second, second], [first, first, first, first]]);
expect(f.services.entities.list.mock.calls.map(([query]) => query)).toEqual([second, second, first, first].map(externalId => ({
entityType, scopeKind: "company", scopeId: companyId, externalId, limit: 1,
})));
});
it("requires each underlying capability and the invocation's company", async () => {
for (const omitted of capabilities) {
const f = fixture(capabilities.filter(capability => capability !== omitted));
await expect(f.read([first])).rejects.toThrow("missing required capability");
expect(f.services.projects.get).not.toHaveBeenCalled();
}
const f = fixture();
await expect(f.read([first], otherCompany)).rejects.toThrow(/invocation.*company/i);
await expect(f.read([first, first])).rejects.toThrow("Invalid project snapshot scope");
expect(f.services.projects.get).not.toHaveBeenCalled();
});
it.each(["wrong company", "archived", "missing", "changed entity"])("rejects the whole batch for a %s project", async failure => {
const f = fixture();
if (failure === "wrong company") f.projects.set(second, { id: second, companyId: otherCompany, archivedAt: null });
else if (failure === "archived") f.projects.get(second)!.archivedAt = new Date();
else if (failure === "missing") f.projects.delete(second);
else f.services.projects.listWorkspaces.mockImplementation(async ({ projectId }: { projectId: string }) => {
if (projectId === second) f.entities.get(second)!.status = "changed";
return [{ projectId }];
});
await expect(f.read([first, second])).rejects.toThrow(/Project snapshot (unavailable|changed during read)/);
if (failure !== "changed entity") {
expect(f.services.projects.getInstanceSecrets).not.toHaveBeenCalledWith({ companyId, projectId: second });
}
});
});
@@ -42,6 +42,27 @@ function seededIssue(input: Partial<Issue> & Pick<Issue, "id" | "companyId" | "t
}
describe("plugin SDK test harness", () => {
it("rejects project snapshot requests that the host would deny", async () => {
const manifest: PaperclipPluginManifestV1 = {
id: "paperclip.test-project-snapshots",
apiVersion: 1,
version: "0.1.0",
displayName: "Project Snapshots",
description: "Test plugin",
author: "Paperclip",
categories: ["automation"],
capabilities: ["projects.read", "project.workspaces.read", "projects.instance-secrets"],
entrypoints: { worker: "./dist/worker.js" },
};
const harness = createTestHarness({ manifest });
const projectId = "10000000-0000-4000-8000-000000000001";
await expect(harness.ctx.projects.readSnapshots([], "company", "settings")).rejects.toThrow("Invalid project snapshot scope");
await expect(harness.ctx.projects.readSnapshots([projectId, projectId], "company", "settings")).rejects.toThrow("Invalid project snapshot scope");
await expect(harness.ctx.projects.readSnapshots([projectId], "company", "bad type")).rejects.toThrow("Invalid project snapshot scope");
const denied = createTestHarness({ manifest: { ...manifest, capabilities: [] } });
await expect(denied.ctx.projects.readSnapshots([], "company", "settings")).rejects.toThrow("missing required capability");
});
it("returns scoped execution workspace metadata with the read capability", async () => {
const manifest: PaperclipPluginManifestV1 = {
id: "paperclip.test-execution-workspace-metadata",
@@ -372,6 +372,14 @@ describeEmbeddedPostgres("plugin tenant isolation (company_id FK)", () => {
expect(fromInstance?.companyId).toBeNull();
expect(fromInstance?.title).toBe("instance");
const services = buildHostServices(db, pluginId, "tenant-isolation-test", createEventBusStub());
await expect(services.entities.list({
entityType: "issue", scopeKind: "company", scopeId: companyB, externalId: "ext-shared", limit: 1,
})).resolves.toEqual([expect.objectContaining({ title: "B" })]);
await expect(services.entities.list({
entityType: "issue", scopeKind: "company", scopeId: randomUUID(), externalId: "ext-shared", limit: 1,
})).resolves.toEqual([]);
// Unknown tenant returns null, not another tenant's row.
const unknown = await registry.getEntityByExternalId(
pluginId,
@@ -258,7 +258,7 @@ export async function executeEnvironmentTask(db: Db, workers: PluginWorkerManage
if (operation.kind === "submit" || operation.kind === "copilot_metadata") {
// No ticket, headers, environment values, or launch paths belong in metadata.
const marked = await db.update(environmentLeases).set({
metadata: sql`coalesce(${environmentLeases.metadata}, '{}'::jsonb) || ${JSON.stringify({ runnerTaskLease: true, runnerTaskDispatched: true, runnerTaskExecutionStopped: false, runnerTaskSessionRetained: false, ...(operation.kind === "submit" ? { runnerTaskIdentity: operation.runner, runnerTaskResumeFrom: operation.resumeFrom ?? null } : {}) })}::jsonb`,
metadata: sql`(coalesce(${environmentLeases.metadata}, '{}'::jsonb) - 'runnerTaskAdmissionRejection') || ${JSON.stringify({ runnerTaskLease: true, runnerTaskDispatched: true, runnerTaskExecutionStopped: false, runnerTaskSessionRetained: false, ...(operation.kind === "submit" ? { runnerTaskIdentity: operation.runner, runnerTaskResumeFrom: operation.resumeFrom ?? null } : {}) })}::jsonb`,
}).where(and(eq(environmentLeases.id, lease.id), eq(environmentLeases.companyId, input.companyId),
eq(environmentLeases.status, "active"), sql`coalesce(${environmentLeases.metadata}->>'runnerTaskRetiring', 'false') != 'true'`,
requiredProjectAuthProof ? sql`${environmentLeases.metadata}->'runnerTaskProjectAuthValidation' = ${JSON.stringify(requiredProjectAuthProof)}::jsonb` : undefined,
@@ -280,7 +280,9 @@ export async function executeEnvironmentTask(db: Db, workers: PluginWorkerManage
if (marked.length !== 1) throw new Error("Plugin warm task attachment lost authority");
}
try {
const stoppedWithoutSession = !!run && metadata.runnerTaskExecutionStopped === true && metadata.runnerTaskSessionRetained !== true;
const admissionRejection = metadata.runnerTaskAdmissionRejection as { taskId?: unknown } | undefined;
const stoppedWithoutSession = !!run && metadata.runnerTaskExecutionStopped === true &&
metadata.runnerTaskSessionRetained !== true && admissionRejection?.taskId !== taskId;
if (operation.kind === "describe" && (metadata.runnerTaskReused === true || stoppedWithoutSession)) {
const state = parseEnvironmentTaskResult({kind:"status"}, taskId, await callWorker("environmentTask", {
driverKey: metadata.driverKey, companyId: input.companyId, environmentId: environment?.id ?? null,
@@ -408,6 +410,20 @@ export async function executeEnvironmentTask(db: Db, workers: PluginWorkerManage
const result = await callWorker("environmentTask", params, timeout);
if (executionContext) context!.assertOpen();
const parsed = parseEnvironmentTaskResult(operation, taskId, result);
if (operation.kind === "submit" && parsed.kind === "admission_rejected") {
const recorded = await db.update(environmentLeases).set({
metadata: sql`(coalesce(${environmentLeases.metadata}, '{}'::jsonb) - 'runnerTaskIdentity') || ${JSON.stringify({
runnerTaskAdmissionRejection: { taskId, reason: parsed.reason },
runnerTaskDispatched: false, runnerTaskExecutionStopped: true, runnerTaskSessionRetained: false,
})}::jsonb`,
}).where(and(eq(environmentLeases.id, lease.id), eq(environmentLeases.companyId, input.companyId),
eq(environmentLeases.providerLeaseId, taskId),
sql`${environmentLeases.metadata}->>'runnerTaskDispatched' = 'true'`,
sql`${environmentLeases.metadata}->'runnerTaskIdentity' = ${JSON.stringify(operation.runner)}::jsonb`,
)).returning({ id: environmentLeases.id });
if (recorded.length !== 1) throw new Error("Plugin Runner admission receipt is stale");
return parsed;
}
if (stoppedSourceCheckpoint && parsed.kind === "state" && isSettledReadyRunnerState(parsed.state)) {
parsed.state = { ...parsed.state, lifecycle: "suspended" };
}
@@ -13989,6 +13989,16 @@ describe("native session bounded recovery", () => {
},
);
it("stops same-run retries when the execution provider proves task admission was rejected", () => {
for (const prefix of ["", "runner_ingress_unavailable: runnerd exited unexpectedly with code 1: "]) {
const code = nativeSessionFailureSourceCode(new Error(`${prefix}runner_task_admission_rejected: session_state_unavailable`));
expect(code).toBe("runner_task_admission_rejected");
expect(nativeSessionFailureDisposition(1, new Date(), code)).toEqual({
phase: "terminal_failure", failureCode: code, nextAttemptAt: null,
});
}
});
it("stops same-run retries when the runner proves the saved provider session incompatible", () => {
const failure = new NativeProviderStateIncompatibleError(
"Error: PRP command run.attach failed: ACPX durable launch profile digest does not match runner startup",
@@ -8426,6 +8426,7 @@ export function nativeSessionFailureDisposition(
sourceFailureCode?: ReturnType<typeof nativeSessionFailureSourceCode>,
) {
const permanentFailure =
sourceFailureCode === "runner_task_admission_rejected" ||
sourceFailureCode === "runner_ingress_access_rejected" ||
sourceFailureCode === "native_provider_runtime_missing" ||
sourceFailureCode === "native_provider_model_rejected" ||
@@ -8543,6 +8544,7 @@ export function nativeSessionFailureSourceCode(
| typeof NATIVE_CURRENT_WAKE_COMMENTS_CHANGED_AFTER_READ
| "provider_session_open_rejected"
| "provider_session_open_timeout"
| "runner_task_admission_rejected"
| "runner_ingress_access_rejected"
| "runner_ingress_acquisition_failed"
| "local_runtime_api_listener_unavailable"
@@ -8583,6 +8585,9 @@ export function nativeSessionFailureSourceCode(
}
if (runnerClosedTurnStartSession(error)) return NATIVE_PROVIDER_SESSION_CLOSED;
const message = error instanceof Error ? error.message : String(error);
if (/^(?:runner_ingress_unavailable: runnerd exited unexpectedly with code 1: )?runner_task_admission_rejected: (session_state_unavailable|account_binding_mismatch)$/.test(message)) {
return "runner_task_admission_rejected";
}
if (/classification=runtime_package_missing\)/.test(message))
return "native_provider_runtime_missing";
if (/native_provider_model_rejected/i.test(message))
@@ -8679,6 +8684,9 @@ export function nativeSessionFailureSourceCode(
const NATIVE_PROVIDER_SIDE_EFFECT_PENDING_ACTION =
"The saved provider session has an unfinished provider turn, or a tool call whose result never reached the runner, so Paperclip did not replace the session or retry it. Reconcile the action: check whether it ran and record the outcome. The task then continues in a fresh provider session in the same workspace. The saved session and workspace are unchanged.";
const RUNNER_TASK_ADMISSION_REJECTED_ACTION =
"The execution provider refused to start this task. Review the admission reason before retrying. Automatic retries are stopped.";
const NATIVE_PROVIDER_STATE_INCOMPATIBLE_ACTION =
"The current runner cannot restore this task's saved provider session, so retrying this run cannot succeed. Retry the task to continue in a fresh provider session with the same workspace. The earlier provider conversation is not carried over.";
@@ -12425,6 +12433,8 @@ async function executePaperclipNativeSessionAttempt(
? nativeCleanupRefusalAction(completedTurnCleanup)
: sourceFailureCode === "runner_remote_provider_verification_unavailable"
? "Provider files could not be verified after two read attempts. Inspect the retained workspace and command transport before retrying the original run."
: sourceFailureCode === "runner_task_admission_rejected"
? RUNNER_TASK_ADMISSION_REJECTED_ACTION
: sourceFailureCode === "runner_ingress_access_rejected"
? "The computer provider rejected access. Restore the environment's credentials or permissions before retrying. Automatic retries are stopped."
: sourceFailureCode === "native_provider_terminal_failed"
@@ -12694,6 +12704,8 @@ async function executePaperclipNativeSessionAttempt(
? NATIVE_PROVIDER_SESSION_CLOSED_ACTION
: sourceFailureCode === "native_session_cleanup_quarantined"
? nativeCleanupRefusalAction(completedTurnCleanup)
: sourceFailureCode === "runner_task_admission_rejected"
? RUNNER_TASK_ADMISSION_REJECTED_ACTION
: sourceFailureCode === "runner_ingress_access_rejected"
? "The computer provider rejected access. Restore the environment's credentials or permissions before retrying. Automatic retries are stopped."
: sourceFailureCode === "native_provider_terminal_failed"
@@ -440,6 +440,23 @@ describe("plugin native Runner ownership", () => {
expect(task.mock.calls.find(([op]) => op.kind === "stop")).toBeUndefined();
await registration.release(); await registration.checkpoint("settled"); await handle.completion;
});
it("surfaces a proven admission rejection without polling or stopping the task", async () => {
const { task } = provider();
const perform = task.getMockImplementation()!;
task.mockImplementation(async op => op.kind === "submit"
? { kind: "admission_rejected", taskId: "task", reason: "session_state_unavailable" } : perform(op));
const owner = createPluginRunnerTaskOwner({ taskId: "task", projectAuthCheckVersion: 1, task, projectIds: [], harness: "codex" });
await prepare(owner);
const registration = await owner.registration({} as never, "run");
const process = owner.launcher(launchSpec());
registration.activate();
await expect(registration.ready()).rejects.toThrow("runner_task_admission_rejected: session_state_unavailable");
await expect(registration.failure).rejects.toThrow("runner_task_admission_rejected: session_state_unavailable");
await expect(process.completion).resolves.toMatchObject({ code: 1 });
await registration.checkpoint("unsettled");
expect(task.mock.calls.map(([op]) => op.kind)).toEqual(["prepare", "check_project_auth", "submit"]);
});
it("reconciles a lost submit reply without launching another attempt", async () => {
const { task } = provider();
const perform = task.getMockImplementation()!;
@@ -71,7 +71,7 @@ export function createPluginRunnerTaskOwner(input: {
let adoptionVerified = false;
let markReady!: () => void;
let failReady!: (error: unknown) => void;
let confirmStopped: (code: number) => void = () => {};
let confirmStopped: (code: number, error?: Error) => void = () => {};
let stoppedState: Extract<PluginEnvironmentTaskResult, { kind: "status" }> | undefined;
let completed = false;
let retiredBeforeClaim = false;
@@ -225,11 +225,11 @@ export function createPluginRunnerTaskOwner(input: {
return true;
} };
const completion = new Promise<Awaited<RunnerProcessHandle["completion"]>>(resolve => {
confirmStopped = code => {
confirmStopped = (code, error) => {
if (child.exitCode !== null) return;
child.exitCode = code;
failReady(new Error("Plugin Runner stopped before connection"));
resolve({ code, signal: null, stdout: "", stderr: "" });
failReady(error ?? new Error("Plugin Runner stopped before connection"));
resolve({ code, signal: null, stdout: "", stderr: error?.message ?? "" });
};
});
void (async () => {
@@ -248,9 +248,17 @@ export function createPluginRunnerTaskOwner(input: {
return;
}
submitted = true;
try { await call(operation); } catch {
const result = await call(operation).catch(async () => {
// Submission can have succeeded remotely. Inspect the same saved task.
await readStatus();
});
if (result?.kind === "admission_rejected") {
submitted = false;
removeStartupAbort();
const error = new Error(`runner_task_admission_rejected: ${result.reason}`);
rejectFailure(error);
confirmStopped(1, error);
return;
}
let stopSent = false;
while (child.exitCode === null) {
+1 -1
View File
@@ -1754,7 +1754,7 @@ export function buildHostServices(
return registry.upsertEntity(pluginId, params as any) as any;
},
async list(params) {
return registry.listEntities(pluginId, params as any) as any;
return registry.listEntities(pluginId, params) as any;
},
},
+2
View File
@@ -541,6 +541,8 @@ export function pluginRegistryService(db: Db) {
const conditions = [eq(pluginEntities.pluginId, pluginId)];
if (query?.entityType) conditions.push(eq(pluginEntities.entityType, query.entityType));
if (query?.externalId) conditions.push(eq(pluginEntities.externalId, query.externalId));
if (query?.scopeKind) conditions.push(eq(pluginEntities.scopeKind, query.scopeKind));
if (query?.scopeId) conditions.push(eq(pluginEntities.scopeId, query.scopeId));
return db
.select()
@@ -13,23 +13,23 @@
"packages/plugins/sdk/src/environment-stop-error.ts": "b0e03f8aa2b2170ba095da5c7138050fced3f3cb",
"packages/plugins/sdk/src/environment-sync-error.ts": "7d6a51a95da49a7da4525aebbab6382dc5c44ae8",
"packages/plugins/sdk/src/environment-task-error.ts": "98aae8a06743b38884b68cb9a3ec44c57b34c0e4",
"packages/plugins/sdk/src/environment-tasks.ts": "3dd691d4423402ab7c0d3249a03bf6b22eeb78f2",
"packages/plugins/sdk/src/host-client-factory.ts": "f1bba9f1830701510f2c94bc623465cb2d93413b",
"packages/plugins/sdk/src/environment-tasks.ts": "98e5f69a00b81eb6e8c25bdb5fcdc22e5485ef18",
"packages/plugins/sdk/src/host-client-factory.ts": "e0301c390ea13f2340b669383f07af2455e48c8a",
"packages/plugins/sdk/src/idle-drain.test.ts": "cc59f7881e7d46c89395a1d93d74f6c6b9b0a64a",
"packages/plugins/sdk/src/idle-drain.ts": "ae6e4c6be4657a3d541396922b3bc551b8db557f",
"packages/plugins/sdk/src/index.ts": "20e52d9e4ad3d5015cfbc604da49756e568e34ba",
"packages/plugins/sdk/src/index.ts": "4e573c181198ea98442321cc136341741e856ff7",
"packages/plugins/sdk/src/invocation-tracer.ts": "b28ac4aa4bc3e82658abac4a8081ed4508008696",
"packages/plugins/sdk/src/protocol.duplex-channel.test.ts": "18255eb3d63038dc459240c111cbcb29fcba25cc",
"packages/plugins/sdk/src/protocol.ts": "215258cc8862b342acdbd6446fb49d00cc578fd8",
"packages/plugins/sdk/src/testing.ts": "eb496adf22c62b6d68ef467c25465afd0973d236",
"packages/plugins/sdk/src/types.ts": "9555522abfd796c59b1439dd433be356ec344bb5",
"packages/plugins/sdk/src/protocol.ts": "95e8af999dabf93b1b0e531edfec4c80daf52b61",
"packages/plugins/sdk/src/testing.ts": "fa99065efa867d8f8a9372cb4a448b8f9d687883",
"packages/plugins/sdk/src/types.ts": "80375e46504f96c7c0c1f156319dfb81275d16cc",
"packages/plugins/sdk/src/ui/clipboard.ts": "1f42069c97d731e636a6ad5481dad22d20c0ed63",
"packages/plugins/sdk/src/ui/components.ts": "3f0594408a1bb5ac3a8d00e709f91877dd260359",
"packages/plugins/sdk/src/ui/hooks.ts": "686d02cf4b35f1cddb79a83a4c4d81fd52f0100e",
"packages/plugins/sdk/src/ui/index.ts": "f7d1cee64a8f0c924bd10a1bd20745a52fe2060e",
"packages/plugins/sdk/src/ui/runtime.ts": "998428e9701eba3b762d3417236d32c2d0d5763d",
"packages/plugins/sdk/src/ui/types.ts": "5a5644acf6554d2e5e1d000bfd03b97d57001eb2",
"packages/plugins/sdk/src/worker-rpc-host.ts": "f6b801fc08596a8ab6344f12ac0ee80f256b2066",
"packages/plugins/sdk/src/worker-rpc-host.ts": "72f844508f695737a44d34b01b3949ec3be10aec",
"packages/plugins/sdk/src/workspace-operation-error.ts": "2cd247d002d83b163239229e631bca4e519e5075",
"packages/plugins/sdk/src/workspace-provider.ts": "c212c3baffd4151010060c9ec7393bd989a9e833",
"packages/plugins/sdk/tsconfig.json": "d62ebdff94bc4b75dabf1fcad187e1e7a90fc794",
@@ -405,7 +405,7 @@
"packages/shared/src/types/issue.ts": "4598caecc50e60450a1204de832a4fcd7991096d",
"packages/shared/src/types/live.ts": "b90e55e4de8600c6fc17d7fa8d89079d4ae655ec",
"packages/shared/src/types/native-finalization.ts": "c026a73003ae232fea4f7624d561493b383df358",
"packages/shared/src/types/plugin.ts": "31fe2fb3346dabbf83aa7e37193bb0dd229ea8a7",
"packages/shared/src/types/plugin.ts": "36e0f50d97f96532cb1fbdf3223368d92a7e9b1e",
"packages/shared/src/types/project.ts": "2a20b4d8772d5f0fa5015828818b025abc7cedcf",
"packages/shared/src/types/quota.ts": "a0686a9afb3111a4de093f66200dc0c9152e883e",
"packages/shared/src/types/resource-memberships.ts": "9eab6fad50facf3e489f8c40e5bedd92e1d1faf8",