Merge master into sleep blocker inventory

Preserve the explicit external database fixture and owned-client cleanup while adopting the upstream PostgreSQL shutdown timeout in both sleep suites.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-11-02 16:49:21 -06:00
commit 4a9974ea03
314 files changed
+10887 -2969

No files matched your search

@@ -2,7 +2,7 @@ import { notifyInstructionCollection } from "./instruction-collection-notificati
import fs from "node:fs/promises";
import path from "node:path";
import { isDeepStrictEqual } from "node:util";
import { and, eq, inArray, isNull, sql } from "drizzle-orm";
import { and, eq, inArray, isNull, sql, type SQL } from "drizzle-orm";
import { agents, environmentLeases, environments, agentInstructionWorkingCopies as copies, withDedicatedDbConnection, type Db } from "@paperclipai/db";
import { syncDirectoryToSsh, restoreWorkspaceFromSshExecution } from "@paperclipai/adapter-utils/ssh";
import { prepareAdapterExecutionTargetRuntime, runAdapterExecutionTargetShellCommand, type AdapterExecutionTarget, type PreparedAdapterExecutionTargetRuntime } from "@paperclipai/adapter-utils/execution-target";
@@ -16,7 +16,7 @@ import type { AuthorizationActor } from "./authorization.js";
import type { EnvironmentRuntimeService } from "./environment-runtime.js";
import type { Environment, EnvironmentLease } from "@paperclipai/shared";
import { agentDirectoryBaselineDigest, agentDirectoryProbeProgram, observeLocalAgentDirectory } from "./agent-directory-probe.js";
import { hasRemoteTerminationReceipt } from "./remote-execution-termination.js";
import { hasRemoteTerminationReceipt, resolveRemoteTerminationLease } from "./remote-execution-termination.js";
import { cachedAgentFileManifest, captureAgentFileCheckpoint, checkpointBaseline, checkpointSnapshot, type AgentFileManifest } from "./agent-file-checkpoints.js";
import { withWorkspaceLockKeepalive } from "./native-runtime/native-workspace-lock-keepalive.js";
import { logger } from "../middleware/logger.js";
@@ -46,7 +46,7 @@ function baseline(row: Copy) {
function actor(row: Copy): AuthorizationActor {
return { type: "agent", companyId: row.companyId, agentId: row.agentId, runId: row.runId, onBehalfOfUserId: row.responsibleUserId };
}
export function agentDirectoryWorkingCopyService(db: Db, get: (companyId: string, runId: string) => Promise<Copy | null>, patch: (row: Copy, values: Partial<typeof copies.$inferInsert>) => Promise<Copy>, environmentRuntime?: EnvironmentRuntimeService) {
export function agentDirectoryWorkingCopyService(db: Db, get: (companyId: string, runId: string) => Promise<Copy | null>, patch: (row: Copy, values: Partial<typeof copies.$inferInsert>, condition?: SQL) => Promise<Copy>, environmentRuntime?: EnvironmentRuntimeService) {
const store = agentFileStore(db);
async function insertCopy(values: typeof copies.$inferInsert) {
return db.transaction(async tx => {
@@ -503,7 +503,15 @@ export function agentDirectoryWorkingCopyService(db: Db, get: (companyId: string
return row.state === "superseded" || !row.processStoppedAt ||
completed.has(row.state) && row.receipt?.cleanupPending === false;
}
async function settleCompletedCleanupRetry(row: Copy) {
if (completed.has(row.state) && row.processStoppedAt && !row.receipt?.retainedByRunId && row.receipt?.cleanupPending === false && row.nextAttemptAt) {
return patch(row, { nextAttemptAt: null }, sql`${copies.receipt} = ${JSON.stringify(row.receipt)}::jsonb
AND ${copies.processStoppedAt} IS NOT NULL`);
}
return row;
}
async function release(row: Copy, target?: AdapterExecutionTarget | null) {
row = await settleCompletedCleanupRetry(row);
if (releaseIsNoop(row) || !await owns(row)) return;
const destroyedOnly = row.receipt?.cleanupDestroyedOnly === true;
if (row.receipt?.retainedByRunId || row.state === "superseded" || !await owns(row) || !row.processStoppedAt) return;
@@ -559,7 +567,7 @@ export function agentDirectoryWorkingCopyService(db: Db, get: (companyId: string
: eq(environmentLeases.environmentId, row.location.slice("remote:".length)),
));
if (leases.length !== 1) return false;
const lease = leases[0]!;
const lease = await resolveRemoteTerminationLease(db, leases[0]!);
if (lease.environmentId !== null && row.location !== `remote:${lease.environmentId}`) return false;
const remoteCwd = cleanup?.remoteCwd ?? lease.metadata?.remoteCwd;
return typeof remoteCwd === "string" &&
@@ -588,7 +596,7 @@ export function agentDirectoryWorkingCopyService(db: Db, get: (companyId: string
cleanup?.leaseId ? eq(environmentLeases.id, cleanup.leaseId) : eq(environmentLeases.environmentId, row.location.slice("remote:".length)),
));
if (leases.length !== 1) return null;
const lease = leases[0]!;
const lease = await resolveRemoteTerminationLease(db, leases[0]!);
const destroyedWithoutEnvironment = lease.environmentId === null && hasRemoteTerminationReceipt(lease)
&& (lease.metadata?.remoteExecutionTermination as { state?: string } | undefined)?.state === "destroyed";
if (row.location !== `remote:${lease.environmentId}` && !destroyedWithoutEnvironment) return null;
@@ -658,10 +666,12 @@ export function agentDirectoryWorkingCopyService(db: Db, get: (companyId: string
checkpointWarm: (row: Copy, target?: AdapterExecutionTarget | null) => serial(row, current => current.receipt?.warm === true ? checkpoint(current, target) : Promise.resolve(current), "agent_directory_checkpoint"),
collectStopped: (row: Copy, target?: AdapterExecutionTarget | null) => serial(row, current => collectStopped(current, target), "agent_directory_collect"),
release: async (row: Copy) => {
const current = await get(row.companyId, row.runId);
let current = await get(row.companyId, row.runId);
if (!current) return;
current = await settleCompletedCleanupRetry(current);
// This check changes no receipt or bytes. A concurrent stop merely leaves
// cleanup to its owner or the recovery sweep; it grants no new authority.
if (!current || releaseIsNoop(current)) return;
if (releaseIsNoop(current)) return;
await serial(current, release, "agent_directory_release");
},
};
@@ -129,8 +129,8 @@ export function agentInstructionWorkingCopyService(db: Db, options: { environmen
};
return db.transaction(tx => write(tx as unknown as Db));
}
async function patch(row: Copy, values: Partial<typeof copies.$inferInsert>) {
const updated = await tryPatch(row, values);
async function patch(row: Copy, values: Partial<typeof copies.$inferInsert>, condition?: SQL) {
const updated = await tryPatch(row, values, false, condition);
// A late cleanup must not overwrite an explicit resolution or a newer
// baseline. Canonical content has its own independent head CAS.
return updated ?? (await get(row.companyId, row.runId))!;
@@ -499,8 +499,8 @@ export function agentInstructionWorkingCopyService(db: Db, options: { environmen
function directoryCleanupEntry(row: Copy | null): DirectoryCleanupEntry | null {
return row && ["saved", "unchanged", "resolved", "unavailable"].includes(row.state)
&& row.processStoppedAt && isAgentDirectoryCopy(row) && !row.receipt?.retainedByRunId
&& row.receipt?.cleanupPending !== false
&& (Object.hasOwn(row.receipt!, "baseline") || row.receipt?.cleanupPending === true)
&& (row.receipt?.cleanupPending === false ? row.nextAttemptAt !== null
: Object.hasOwn(row.receipt!, "baseline") || row.receipt?.cleanupPending === true)
? { id: row.runId, companyId: row.companyId, dueAt: row.nextAttemptAt?.getTime() ?? 0 } : null;
}
function directoryCleanupPredicate() {
@@ -508,8 +508,9 @@ export function agentInstructionWorkingCopyService(db: Db, options: { environmen
AND ${copies.processStoppedAt} IS NOT NULL
AND ${copies.receipt}->>'schema' = 'paperclip.agent-files.v1'
AND coalesce(${copies.receipt}->>'retainedByRunId', '') = ''
AND ${copies.receipt}->>'cleanupPending' IS DISTINCT FROM 'false'
AND (${copies.receipt} ? 'baseline' OR ${copies.receipt}->>'cleanupPending' = 'true')`;
AND ((${copies.receipt}->>'cleanupPending' = 'false' AND ${copies.nextAttemptAt} IS NOT NULL)
OR (${copies.receipt}->>'cleanupPending' IS DISTINCT FROM 'false'
AND (${copies.receipt} ? 'baseline' OR ${copies.receipt}->>'cleanupPending' = 'true')))`;
}
async function loadDirectoryCleanup(scope: DirectoryCleanupScope | undefined, admitted: () => boolean) {
const entries: DirectoryCleanupEntry[] = [];
@@ -0,0 +1,72 @@
import { createServer } from "node:http";
import { once } from "node:events";
import { describe, expect, it, vi } from "vitest";
import { probeAiAccountIdentity } from "./ai-account-identity.js";
const metadata = { provider: "anthropic", method: "subscription" } as const;
function fixture(body: unknown, status = 200) {
return vi.fn<typeof fetch>().mockResolvedValue(Response.json(body, { status }));
}
describe("AI account identity", () => {
it("returns only safe account details from the selected bearer profile", async () => {
const request = fixture({ account: { uuid: "private-seat", email: "avery@example.test", secret: "provider-private" },
organization: { uuid: "private-organization", name: "Example team" }, access_token: "private-token" });
const result = await probeAiAccountIdentity(metadata, "selected-credential", request);
expect(result).toMatchObject({ status: "available", accountLabel: "avery@example.test", organizationLabel: "Example team" });
expect(request).toHaveBeenCalledWith("https://api.anthropic.com/api/oauth/profile", expect.objectContaining({
redirect: "error", signal: expect.any(AbortSignal),
headers: { Authorization: "Bearer selected-credential", "anthropic-beta": "oauth-2025-04-20" },
}));
for (const secret of ["private-seat", "private-organization", "provider-private", "private-token", "selected-credential"]) {
expect(JSON.stringify(result)).not.toContain(secret);
}
});
it.each([401, 403, 429, 500])("reports HTTP %s without exposing the provider response", async status => {
const result = await probeAiAccountIdentity(metadata, "selected-credential", fixture({ error: "private-provider-error" }, status));
expect(result).toMatchObject({ accountLabel: null, organizationLabel: null });
expect(result.status).toBe(status === 401 || status === 403 ? "unavailable" : "error");
if (status === 403) expect(result).toMatchObject({ errorCode: "permission_denied", message: expect.stringContaining("did not permit") });
expect(JSON.stringify(result)).not.toContain("private-provider-error");
});
it.each([{}, { account: { email: "not-an-email" } }, { account: { email: "avery@example.test\nprivate" } }])("does not infer an email from an incomplete profile %j", async profile => {
expect(await probeAiAccountIdentity(metadata, "selected-credential", fixture(profile))).toMatchObject({
status: "unavailable", errorCode: "invalid_response", accountLabel: null,
});
});
it("does not call a provider for API keys or other sign-in methods", async () => {
const request = fixture({});
expect((await probeAiAccountIdentity({ provider: "anthropic", method: "api_key" }, "", request)).errorCode).toBe("unsupported");
expect((await probeAiAccountIdentity({ provider: "openai", method: "subscription" }, "", request)).errorCode).toBe("unsupported");
expect(request).not.toHaveBeenCalled();
});
it("aborts a stalled real response body while unrelated requests remain responsive", async () => {
const server = createServer((req, res) => {
if (req.url === "/profile") { res.writeHead(200, { "Content-Type": "application/json" }); res.flushHeaders(); }
else res.end("responsive");
});
server.listen(0, "127.0.0.1");
await once(server, "listening");
const address = server.address();
if (!address || typeof address === "string") throw new Error("Missing fixture address");
const origin = `http://127.0.0.1:${address.port}`;
const timeout = AbortSignal.timeout.bind(AbortSignal);
const deadline = vi.spyOn(AbortSignal, "timeout").mockImplementation(() => timeout(100));
try {
const pending = probeAiAccountIdentity(metadata, "selected-credential", (_url, init) => fetch(`${origin}/profile`, init));
expect(await (await fetch(`${origin}/unrelated`)).text()).toBe("responsive");
const result = await pending;
expect(result.status).toBe("error");
expect(result.accountLabel).toBeNull();
expect(deadline).toHaveBeenCalledWith(15_000);
} finally {
deadline.mockRestore();
server.closeAllConnections();
await new Promise<void>(resolve => server.close(() => resolve()));
}
});
});
@@ -0,0 +1,43 @@
import type { AiConnectionAccountIdentity, AiConnectionMetadata } from "@paperclipai/shared";
import { readUsage } from "./ai-connection-usage.js";
type Observation = Omit<AiConnectionAccountIdentity, "connectionId" | "grantId">;
type FailureCode = NonNullable<Observation["errorCode"]>;
const messages: Record<FailureCode, string> = {
unsupported: "Account details are not available for this sign-in method.",
connection_unavailable: "Reconnect or enable this connection to check its account details.",
authentication_required: "Reconnect this account to check its account details.",
permission_denied: "Anthropic did not permit this connection to read the account email.",
rate_limited: "Anthropic limited this account check. Try again later.",
provider_unavailable: "Could not read the account details. Try again later.",
invalid_response: "Anthropic did not return an account email.",
};
const object = (value: unknown): Record<string, unknown> => value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
const label = (value: unknown): string | null => typeof value === "string" && value.trim().length > 0
&& value.length <= 254 && !/[\u0000-\u001f\u007f]/.test(value) ? value.trim() : null;
/** Never uses ambient CLI identity or exposes provider IDs, tokens, or raw responses. */
export async function probeAiAccountIdentity(
metadata: AiConnectionMetadata,
credential: string,
request: typeof fetch = fetch,
): Promise<Observation> {
const base = { checkedAt: new Date().toISOString(), accountLabel: null, organizationLabel: null };
if (metadata.provider !== "anthropic" || metadata.method !== "subscription" || metadata.routing) {
return { ...base, status: "unavailable", errorCode: "unsupported", message: messages.unsupported };
}
try {
const profile = await readUsage("https://api.anthropic.com/api/oauth/profile", {
Authorization: `Bearer ${credential}`, "anthropic-beta": "oauth-2025-04-20",
}, request);
const accountLabel = label(object(profile.account).email);
if (!accountLabel || !/^[^\s@/\\]+@[^\s@/\\]+\.[^\s@/\\]+$/.test(accountLabel)) {
return { ...base, status: "unavailable", errorCode: "invalid_response", message: messages.invalid_response };
}
return { ...base, status: "available", accountLabel, organizationLabel: label(object(profile.organization).name) };
} catch (error) {
const code = object(error).code;
const errorCode: FailureCode = typeof code === "string" && Object.hasOwn(messages, code) ? code as FailureCode : "provider_unavailable";
return { ...base, status: errorCode === "authentication_required" || errorCode === "permission_denied" ? "unavailable" : "error", errorCode, message: messages[errorCode] };
}
}
+28 -8
View File
@@ -36,6 +36,7 @@ import {
type AiConnectionAttribution,
type AiConnectionMetadata,
type AiManagedConnectionSummary,
type AiConnectionAccountIdentity,
type AiConnectionUsage,
type CreateAiConnection,
type AiConnectionLoginIntent,
@@ -49,6 +50,7 @@ import { logger } from "../middleware/logger.js";
import { logActivity } from "./activity-log.js";
import { secretService } from "./secrets.js";
import { probeAiConnectionUsage } from "./ai-connection-usage.js";
import { probeAiAccountIdentity } from "./ai-account-identity.js";
/** Same human audience displayed by the existing Connections identity controls. */
function canUseCredential(
@@ -225,28 +227,46 @@ export function aiConnectionService(db: Db) {
});
});
}
/** On-demand account observation for either runner; never probes ambient CLI auth. */
async function probeUsage(companyId: string, userId: string, connectionId: string, grantId?: string): Promise<AiConnectionUsage> {
async function readableAccount(companyId: string, userId: string, connectionId: string, grantId?: string) {
if (!(await membership(companyId, userId))) throw forbidden("An active company member is required");
const candidates = (await rows(companyId)).filter((row) => row.connection.id === connectionId && (!grantId || row.grant.id === grantId));
const audience = await db.select().from(connectionGrantMembers).where(eq(connectionGrantMembers.companyId, companyId));
const visible = candidates.filter(({ grant }) => canUseCredential(grant, userId, audience.filter((member) => member.grantId === grant.id)));
if (!visible.length) throw notFound("AI connection not found");
if (visible.length > 1) throw unprocessable("Choose a credential grant for this usage check");
if (visible.length > 1) throw unprocessable("Choose a credential grant for this account check");
const row = visible[0]!;
const metadata = aiConnectionMetadataSchema.safeParse(row.connection.config.ai);
if (!metadata.success) throw notFound("AI connection not found");
const identity = { connectionId: row.connection.id, grantId: row.grant.id, ...metadata.data };
return { row, metadata: metadata.data };
}
/** On-demand account observation for either runner; never probes ambient CLI auth. */
async function probeUsage(companyId: string, userId: string, connectionId: string, grantId?: string): Promise<AiConnectionUsage> {
const { row, metadata } = await readableAccount(companyId, userId, connectionId, grantId);
const identity = { connectionId: row.connection.id, grantId: row.grant.id, ...metadata };
const empty = { ...identity, checkedAt: new Date().toISOString(), source: null, planType: null, limits: [], overage: null };
if (row.grant.status !== "active" || !row.connection.enabled || row.connection.status !== "active" ||
row.connection.healthStatus !== "ok" || aiSubscriptionNeedsIsolatedLogin(row.connection.config)) {
return { ...empty, status: "unavailable", errorCode: "connection_unavailable", message: "Reconnect or enable this AI connection before checking usage." };
}
if (!supportsAiConnectionUsage(metadata.data.provider, metadata.data.method)) {
return { ...identity, ...await probeAiConnectionUsage(metadata.data, "") };
if (!supportsAiConnectionUsage(metadata.provider, metadata.method)) {
return { ...identity, ...await probeAiConnectionUsage(metadata, "") };
}
const value = await credential(row);
return { ...identity, ...await probeAiConnectionUsage(metadata.data, value) };
return { ...identity, ...await probeAiConnectionUsage(metadata, value) };
}
async function accountIdentity(companyId: string, userId: string, connectionId: string, grantId?: string,
audit?: { actorType: "user" | "agent" | "system"; actorId: string; heartbeatRunId?: string | null }): Promise<AiConnectionAccountIdentity> {
const { row, metadata } = await readableAccount(companyId, userId, connectionId, grantId);
const identity = { connectionId: row.connection.id, grantId: row.grant.id };
if (row.grant.status !== "active" || !row.connection.enabled || row.connection.status !== "active" ||
row.connection.healthStatus !== "ok" || aiSubscriptionNeedsIsolatedLogin(row.connection.config)) {
return { ...identity, status: "unavailable", checkedAt: new Date().toISOString(), accountLabel: null, organizationLabel: null,
errorCode: "connection_unavailable", message: "Reconnect or enable this connection to check its account details." };
}
// Unsupported methods must not open their credentials just to report this limit.
const value = metadata.provider === "anthropic" && metadata.method === "subscription" && !metadata.routing
? await credential(row, 0, { actorType: "user", actorId: userId, ...audit, responsibleUserId: userId }) : "";
return { ...identity, ...await probeAiAccountIdentity(metadata, value) };
}
async function select(input: {
companyId: string;
@@ -1218,5 +1238,5 @@ export function aiConnectionService(db: Db) {
return [{ ...row, summary }];
});
}
return { list, selectDecision, selectFastResponse, selectPromptInjectionPrevention, quotaAccounts, subscriptionAccounts: (companyId: string, userId: string) => quotaAccounts(companyId, userId, true), refreshQuotaCredential, select, credential, runtimeCredential, probeUsage, save, setDefault, membership, markAuthenticationFailed };
return { list, selectDecision, selectFastResponse, selectPromptInjectionPrevention, quotaAccounts, subscriptionAccounts: (companyId: string, userId: string) => quotaAccounts(companyId, userId, true), refreshQuotaCredential, select, credential, runtimeCredential, probeUsage, accountIdentity, save, setDefault, membership, markAuthenticationFailed };
}
+21 -12
View File
@@ -1,18 +1,27 @@
import { trace, type Span } from "@opentelemetry/api";
import { withDatabaseOperation } from "@paperclipai/db";
type BudgetOperation = "budget.accounting.lock_wait" | "budget.accounting.recover"
| "budget.accounting.settlement" | "budget.accounting.transaction"
| "budget.admission.evaluate" | "budget.admission.reserve"
| "budget.cancellation.deliver" | "budget.enforcement.deliver"
| "budget.enforcement.scope" | "budget.exposure.evaluate";
/** Operator-gated OpenTelemetry only; these spans contain no prompt, receipt,
* credential, or provider error text. Diagnostics never change accounting. */
export async function withBudgetSpan<T>(name: string, work: () => Promise<T>, attributes?: Record<string, number | boolean>) {
if (!process.env.OTEL_EXPORTER_OTLP_ENDPOINT?.trim()) return work();
let operation: Promise<T> | undefined;
const run = (span?: Span) => operation ??= Promise.resolve().then(work).finally(() => {
try { span?.end(); } catch { /* Diagnostics cannot change accounting. */ }
export function withBudgetSpan<T>(name: BudgetOperation, work: () => Promise<T>, attributes?: Record<string, number | boolean>) {
return withDatabaseOperation(name, async () => {
if (!process.env.OTEL_EXPORTER_OTLP_ENDPOINT?.trim()) return work();
let operation: Promise<T> | undefined;
const run = (span?: Span) => operation ??= Promise.resolve().then(work).finally(() => {
try { span?.end(); } catch { /* Diagnostics cannot change accounting. */ }
});
try {
const traced = trace.getTracer("paperclip.budgets").startActiveSpan(name, { attributes }, span => run(span));
// Some external SDK implementations can fail before or after the callback.
// Own the accounting promise so either failure still executes work once.
void Promise.resolve(traced).catch(() => undefined);
} catch { /* Preserve the accounting operation if span creation fails. */ }
return operation ?? run();
});
try {
const traced = trace.getTracer("paperclip.budgets").startActiveSpan(name, { attributes }, span => run(span));
// Some external SDK implementations can fail before or after the callback.
// Own the accounting promise so either failure still executes work once.
void Promise.resolve(traced).catch(() => undefined);
} catch { /* Preserve the accounting operation if span creation fails. */ }
return operation ?? run();
}
@@ -1,6 +1,6 @@
import { randomBytes, randomUUID } from "node:crypto";
import { agents, companies, createDb, heartbeatRuns } from "@paperclipai/db";
import { createDatabaseResponseCapture, installDatabaseResponseCapture } from "@paperclipai/db/response-capture";
import { createFullDatabaseResponseCapture, installDatabaseResponseCapture } from "../../__tests__/helpers/full-database-response-capture.js";
import { eq } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js";
@@ -49,7 +49,7 @@ describe.skipIf(!support.supported)("chat authorization run reads", () => {
const [before] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, scope.failedRunId));
const events: Array<Record<string, unknown>> = [];
const now = Date.now();
const capture = createDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event));
const capture = createFullDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event));
installDatabaseResponseCapture(db, { heartbeatRuns }, capture);
try {
const selected = await db.transaction(tx => readChatAuthorizationRun(tx, scope, true));
@@ -58,7 +58,7 @@ describe.skipIf(!support.supported)("chat authorization run reads", () => {
expect(selected.run).not.toHaveProperty("runnerProfileJson");
expect(selected.run).not.toHaveProperty("stdoutExcerpt");
} finally { capture.flush(); }
const summary = events.find(event => event.event === "paperclip.db_response_capture.summary.v1")!;
const summary = events.find(event => event.event === "paperclip.test_db_response_capture.summary.v1")!;
expect(summary.completedQueries).toBeGreaterThan(0);
expect(summary.responseValueBytes).toBeLessThan(4096);
const retry = await db.transaction(tx => readChatAuthorizationRun(tx, scope, false));
@@ -2,7 +2,7 @@ import { createHmac } from "node:crypto";
import { eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { applyPendingMigrations, chatActions, createDb } from "@paperclipai/db";
import { createDatabaseResponseCapture, installDatabaseResponseCapture } from "@paperclipai/db/response-capture";
import { createFullDatabaseResponseCapture, installDatabaseResponseCapture } from "../../../__tests__/helpers/full-database-response-capture.js";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../../../__tests__/helpers/embedded-postgres.js";
import { resolveExternalTestDatabaseUrl } from "../../../__tests__/helpers/external-test-database.js";
import { cleanupIngressFixture, githubIngressFixture as createFixture } from "../../../__tests__/helpers/github-webhook-ingress.js";
@@ -36,7 +36,7 @@ describe.skipIf(!support.supported)("GitHub webhook ingress claim PostgreSQL beh
const f = await githubIngressFixture(db, createGitHubWebhookIngress, 768 * 1024);
const action = await f.insertAction();
const events: Array<Record<string, unknown>> = [], now = Date.now();
const capture = createDatabaseResponseCapture({ start: now - 1, end: now + 59_000 }, event => events.push(event));
const capture = createFullDatabaseResponseCapture({ start: now - 1, end: now + 59_000 }, event => events.push(event));
installDatabaseResponseCapture(db, { chatActions }, capture);
try { expect((await f.service.processGitHubWebhookIngress(action.id))?.status).toBe(202); }
finally { capture.flush(); }
@@ -59,7 +59,7 @@ describe.skipIf(!support.supported)("GitHub webhook ingress claim PostgreSQL beh
// A corrupt body must not let a nonclaimant overwrite a live or delayed claim.
const action = await f.insertAction({ status, result, payload: { ...f.payload, bodySha256: "0".repeat(64) } });
const events: Array<Record<string, unknown>> = [], now = Date.now();
const capture = createDatabaseResponseCapture({ start: now - 1, end: now + 59_000 }, event => events.push(event));
const capture = createFullDatabaseResponseCapture({ start: now - 1, end: now + 59_000 }, event => events.push(event));
installDatabaseResponseCapture(db, { chatActions }, capture);
let response: Response | null;
try { response = await f.service.processGitHubWebhookIngress(action.id); }
@@ -67,7 +67,7 @@ describe.skipIf(!support.supported)("GitHub webhook ingress claim PostgreSQL beh
expect(response?.status ?? null).toBe(accepted ? 202 : null);
expect(await f.readAction(action.id)).toEqual(action);
expect(f.calls).toEqual([]);
expect(events.find(event => event.event === "paperclip.db_response_capture.summary.v1")?.responseValueBytes).toBeLessThan(1024);
expect(events.find(event => event.event === "paperclip.test_db_response_capture.summary.v1")?.responseValueBytes).toBeLessThan(1024);
});
it.each(["received", "failed", "processing"])("settles eligible malformed %s payloads without replay", async status => {
+18 -3
View File
@@ -1,3 +1,4 @@
import { issueDisplayStatusSql } from "./issue-display-status.js";
import { externalConversationStateSql } from "./slack-conversation-state.js";
import { and, desc, eq, gte, inArray, isNotNull, isNull, notInArray, or, sql } from "drizzle-orm";
import type { SQL } from "drizzle-orm";
@@ -21,6 +22,7 @@ import {
COMPANY_ARTIFACTS_MAX_QUERY_LENGTH,
ISSUE_PRIORITIES,
ISSUE_STATUSES,
ISSUE_DISPLAY_STATUSES,
SYSTEM_ISSUE_DOCUMENT_KEYS,
type CompanyArtifact,
type CompanySearchArtifactSummary,
@@ -50,6 +52,7 @@ type IssueSearchRow = {
title: string;
description: string | null;
status: string;
displayStatus: string;
priority: string;
assigneeAgentId: string | null;
assigneeUserId: string | null;
@@ -193,6 +196,7 @@ function emptySearchCounts(): Record<CompanySearchCountType, number> {
function emptyFilterOptionCounts(): CompanySearchFilterOptionCounts {
return {
status: {},
displayStatus: {},
priority: {},
assigneeAgentId: {},
assigneeUserId: {},
@@ -224,7 +228,7 @@ function updatedWithinStart(value: string | undefined, now = new Date()): Date |
}
function issueOnlyFiltersActive(query: CompanySearchQuery) {
return query.status.length > 0
return (query.status.length > 0 || query.displayStatus.length > 0)
|| query.priority.length > 0
|| query.assigneeAgentId !== undefined
|| Boolean(query.assigneeUserId)
@@ -236,7 +240,7 @@ function issueOnlyFiltersActive(query: CompanySearchQuery) {
function activeIssueFilters(query: CompanySearchQuery): Array<{ key: CompanySearchIssueFilterKey; values: string[] }> {
const filters: Array<{ key: CompanySearchIssueFilterKey; values: string[] }> = [];
if (query.status.length > 0) filters.push({ key: "status", values: query.status });
if (query.status.length > 0 || query.displayStatus.length > 0) filters.push({ key: "status", values: [...query.status, ...query.displayStatus] });
if (query.assigneeAgentId !== undefined) filters.push({ key: "assigneeAgentId", values: [query.assigneeAgentId ?? "null"] });
if (query.assigneeUserId) filters.push({ key: "assigneeUserId", values: [query.assigneeUserId] });
if (query.projectId) filters.push({ key: "projectId", values: [query.projectId] });
@@ -251,6 +255,7 @@ function queryWithoutFilter(query: CompanySearchQuery, key: CompanySearchIssueFi
return {
...query,
status: key === "status" ? [] : query.status,
displayStatus: key === "status" ? [] : query.displayStatus,
priority: key === "priority" ? [] : query.priority,
assigneeAgentId: key === "assigneeAgentId" ? undefined : query.assigneeAgentId,
assigneeUserId: key === "assigneeUserId" ? undefined : query.assigneeUserId,
@@ -265,6 +270,7 @@ function queryWithoutIssueFilters(query: CompanySearchQuery): CompanySearchQuery
return {
...query,
status: [],
displayStatus: [],
priority: [],
assigneeAgentId: undefined,
assigneeUserId: undefined,
@@ -277,6 +283,7 @@ function queryWithoutIssueFilters(query: CompanySearchQuery): CompanySearchQuery
function issueFilterConditions(companyId: string, query: CompanySearchQuery, omit?: CompanySearchIssueFilterKey): SQL[] {
const conditions: SQL[] = [];
if (omit !== "status" && query.displayStatus.length) conditions.push(inArray(issueDisplayStatusSql(), query.displayStatus));
if (omit !== "status" && query.status.length > 0) {
conditions.push(query.status.length === 1 ? eq(issues.status, query.status[0]!) : inArray(issues.status, query.status));
}
@@ -315,6 +322,7 @@ function issueFilterConditions(companyId: string, query: CompanySearchQuery, omi
// matched-issues CTE (plain columns, no drizzle table references).
function matchedFacetConditions(companyId: string, query: CompanySearchQuery, omit?: CompanySearchIssueFilterKey): SQL[] {
const conditions: SQL[] = [];
if (omit !== "status" && query.displayStatus.length) conditions.push(sql`m.display_status = ANY(${sqlTextArray(query.displayStatus)})`);
if (omit !== "status" && query.status.length > 0) {
conditions.push(sql`m.status = ANY(${sqlTextArray(query.status)})`);
}
@@ -447,6 +455,7 @@ function issueResult(row: IssueSearchRow, prefix: string, normalizedQuery: strin
identifier: row.identifier,
title: row.title,
status: row.status as CompanySearchIssueSummary["status"],
displayStatus: row.displayStatus as CompanySearchIssueSummary["displayStatus"],
priority: row.priority as CompanySearchIssueSummary["priority"],
assigneeAgentId: row.assigneeAgentId,
assigneeUserId: row.assigneeUserId,
@@ -626,7 +635,7 @@ export function companySearchService(db: Db) {
conditions.length > 0 ? sql`WHERE ${sql.join(conditions, sql` AND `)}` : sql``;
// Count branches must match the result branch's column list; the
// trailing NULLs pad the issue data columns.
const countTail = sql`, NULL::uuid, NULL::text, NULL::text, NULL::text, NULL::text, NULL::text, NULL::uuid, NULL::text, NULL::uuid, NULL::timestamptz, NULL::timestamptz, NULL::double precision, NULL::text[]`;
const countTail = sql`, NULL::uuid, NULL::text, NULL::text, NULL::text, NULL::text, NULL::text, NULL::text, NULL::uuid, NULL::text, NULL::uuid, NULL::timestamptz, NULL::timestamptz, NULL::double precision, NULL::text[]`;
const branches: SQL[] = [];
const wantResultRows = scopeIncludesIssues(scope)
@@ -652,6 +661,7 @@ export function companySearchService(db: Db) {
(SELECT issue_text.description FROM issues issue_text
WHERE issue_text.id = m.id AND issue_text.company_id = ${companyId}) AS description,
m.status,
m.display_status AS "displayStatus",
m.priority,
m.assignee_agent_id AS "assigneeAgentId",
m.assignee_user_id AS "assigneeUserId",
@@ -690,6 +700,7 @@ export function companySearchService(db: Db) {
GROUP BY 2
`;
branches.push(facetBranch("facet:status", sql`m.status`, "status"));
branches.push(facetBranch("facet:displayStatus", sql`m.display_status`, "status"));
branches.push(facetBranch("facet:priority", sql`m.priority`, "priority"));
branches.push(facetBranch("facet:assigneeAgentId", sql`m.assignee_agent_id`, "assigneeAgentId", [sql`m.assignee_agent_id IS NOT NULL`]));
branches.push(facetBranch("facet:assigneeUserId", sql`m.assignee_user_id`, "assigneeUserId", [sql`m.assignee_user_id IS NOT NULL`]));
@@ -757,6 +768,7 @@ export function companySearchService(db: Db) {
title: row.title,
description: row.description,
status: row.status,
displayStatus: row.displayStatus,
priority: row.priority,
assigneeAgentId: row.assigneeAgentId,
assigneeUserId: row.assigneeUserId,
@@ -780,6 +792,9 @@ export function companySearchService(db: Db) {
if (row.kind === "type:issue") aggregates.typeCounts.issue = count;
else if (row.kind === "type:comment") aggregates.typeCounts.comment = count;
else if (row.kind === "type:document") aggregates.typeCounts.document = count;
else if (row.kind === "facet:displayStatus" && row.value && (ISSUE_DISPLAY_STATUSES as readonly string[]).includes(row.value)) {
aggregates.filterOptionCounts.displayStatus![row.value as keyof NonNullable<CompanySearchFilterOptionCounts["displayStatus"]>] = count;
}
else if (row.kind === "facet:status" && row.value && (ISSUE_STATUSES as readonly string[]).includes(row.value)) {
aggregates.filterOptionCounts.status[row.value as keyof CompanySearchFilterOptionCounts["status"]] = count;
} else if (row.kind === "facet:priority" && row.value && (ISSUE_PRIORITIES as readonly string[]).includes(row.value)) {
+7 -5
View File
@@ -32,7 +32,7 @@ type PreparedAuthorization = {
expandedTargets: Set<string>;
referencedConnections: string[];
currentGatewayResource: ConnectionManagementResource | undefined;
savedNonAiConnection: boolean;
savedConnection: boolean;
};
type ConnectionCapability = { allowed: boolean; reason: string; operation: string };
@@ -88,7 +88,7 @@ export function connectionManagementService(db: Db) {
const expandedTargets = new Set(options.targetAgentIds ?? []);
const referencedConnections: string[] = [];
let currentGatewayResource: ConnectionManagementResource | undefined;
let savedNonAiConnection = false;
let savedConnection = false;
if (resource.gatewayId && !options.gatewayContextChecked) {
const [gateway] = await db.select().from(toolMcpGateways).where(and(eq(toolMcpGateways.id, resource.gatewayId), eq(toolMcpGateways.companyId, resource.companyId)));
if (!gateway) throw forbidden("Gateway is unavailable in this company");
@@ -159,7 +159,7 @@ export function connectionManagementService(db: Db) {
if (resource.connectionId) {
const [connection] = await db.select().from(toolConnections).where(and(eq(toolConnections.id, resource.connectionId), eq(toolConnections.companyId, resource.companyId)));
if (!connection) throw forbidden("Connection is unavailable in this company");
savedNonAiConnection = connection.connectionPurpose !== "ai";
savedConnection = true;
resource = { ...resource, selfServiceUserId: connection.createdByUserId,
purpose: connection.connectionPurpose === "ai" ? "ai" : connection.connectionPurpose === "channel" ? "chat" : "tool",
provider: typeof connection.config.sourceTemplateKey === "string" ? connection.config.sourceTemplateKey
@@ -241,7 +241,7 @@ export function connectionManagementService(db: Db) {
}
}
return { actor, action, resource, options, responsibleUserId, workContext, capturedRun,
expandedTargets, referencedConnections, currentGatewayResource, savedNonAiConnection };
expandedTargets, referencedConnections, currentGatewayResource, savedConnection };
}
async function evaluateTarget(prepared: PreparedAuthorization, targetAgentId: string | undefined): Promise<AuthorizationDecision> {
@@ -388,7 +388,9 @@ export function connectionManagementService(db: Db) {
}
const resolved = prepared.resource;
const isCanonicalAgentId = (id: string) => isUuidLike(id) && id === id.toLowerCase();
const eligible = actor.type === "board" && prepared.savedNonAiConnection
// Direct AI accounts use the same target checks. Pools with members still
// use the full evaluator through referencedConnections below.
const eligible = actor.type === "board" && prepared.savedConnection
&& [resolved.profileId, resolved.gatewayId, resolved.routineId, resolved.projectId, resolved.issueId].every(id => id == null)
&& !Object.hasOwn(resolved, "requestedProvider") && !Object.hasOwn(resolved, "requestedInstallCompanyIds")
&& !resolved.selfServiceAgentInstalls
@@ -1,4 +1,5 @@
import { subscribeDatabaseWork, databaseWorkPending, reconcileDatabaseWork } from "@paperclipai/db/work-signals";
import { withDatabaseOperation } from "@paperclipai/db";
import { createWorkScheduler } from "./work-scheduler.js";
import { type DeliveryQueue } from "./delivery-work-notifications.js";
import { beginIdleTrackedWork } from "./task-admission.js";
@@ -70,7 +71,7 @@ export function createDeliveryWorkCoordinator(input: {
const attempt = new AbortController();
let runFailed = false;
controller = attempt;
running = Promise.resolve().then(() => reconcileDatabaseWork(input.owner, queue))
running = withDatabaseOperation(`delivery.${queue}`, () => Promise.resolve().then(() => reconcileDatabaseWork(input.owner, queue))
.then(() => workerStopped || !(input.canRun() || (task.runDuringDrain && input.canReconcile?.())) ? undefined : task.run(attempt.signal).catch(error => { runFailed = true; throw error; })).then(() => workerStopped ? false : task.hasPending()).then(pending => {
pendingWork = pending || databaseWorkPending(input.owner, queue);
if (pendingWork) schedule(task.retryMs);
@@ -98,7 +99,7 @@ export function createDeliveryWorkCoordinator(input: {
controller = null;
finish();
if (dirty) schedule(0);
});
}));
}
function wake() {
if (workerStopped) return;
+18 -2
View File
@@ -1,6 +1,6 @@
import { createHash } from "node:crypto";
import { agentWakeupRequests, chatActions, chatDeliveries, chatEndpoints, type Db } from "@paperclipai/db";
import { and, eq, inArray, or, sql, type SQLWrapper } from "drizzle-orm";
import { agentWakeupRequests, chatActions, chatConversations, chatDeliveries, chatEndpoints, type Db } from "@paperclipai/db";
import { and, eq, exists, inArray, or, sql, type SQLWrapper } from "drizzle-orm";
import { HttpError } from "../errors.js";
export interface CommittedChatResponseAuthorizationInput {
@@ -233,6 +233,22 @@ export interface DeferredChatWakeScope {
issueId: string;
requestId: string;
}
/** Identify saved input by its canonical receipt, never by a caller's source
* label. This is selection only; the inbound owner rechecks live authority. */
export function deferredChatInputPredicate(db: Db) {
return exists(db.select({ id: chatActions.id }).from(chatActions)
.innerJoin(chatConversations, and(eq(chatConversations.companyId, chatActions.companyId),
eq(chatConversations.id, chatActions.conversationId)))
.innerJoin(chatEndpoints, and(eq(chatEndpoints.companyId, chatActions.companyId),
eq(chatEndpoints.id, chatActions.endpointId), eq(chatEndpoints.id, chatConversations.endpointId)))
.where(and(eq(chatActions.companyId, agentWakeupRequests.companyId), eq(chatActions.id, agentWakeupRequests.id),
eq(chatActions.kind, "inbound_wakeup"), eq(chatActions.status, "processed"),
eq(chatEndpoints.assignedAgentId, agentWakeupRequests.agentId), eq(chatEndpoints.externalExecutionPolicy, "restricted"),
sql`${chatConversations.issueId}::text = ${agentWakeupRequests.payload}->>'issueId'`,
sql`${chatActions.payload}->>'issueId' = ${agentWakeupRequests.payload}->>'issueId'`,
sql`${chatActions.payload}->>'agentId' = ${agentWakeupRequests.agentId}::text`)));
}
const deferredChatWakeOwners = new WeakMap<Db, (input: DeferredChatWakeScope) => Promise<void>>();
/** The live inbound owner reauthorizes the original source. Cleanup owns no chat authority. */
@@ -1,9 +1,19 @@
import { eq, sql } from "drizzle-orm";
import { and, eq, or, sql } from "drizzle-orm";
import { environmentLeases, sandboxCreationOperations, type Db } from "@paperclipai/db";
import { hasDeferredEnvironmentCleanup } from "./environment-cleanup-notifications.js";
export const ENVIRONMENT_CLEANUP_RETRY_MS = 5 * 60_000;
export function recoverableSandboxCreationCondition() {
return or(eq(sandboxCreationOperations.state, "pending"), and(
eq(sandboxCreationOperations.state, "needs_reconciliation"),
sql`coalesce(${sandboxCreationOperations.metadata}->>'creationReconciliationReason', '') <> 'company_deleted'`,
sql`${sandboxCreationOperations.metadata}->>'runnerTaskLease' = 'true'`,
sql`jsonb_typeof(${sandboxCreationOperations.metadata}->'runnerTaskCreationContextId') = 'string' and ${sandboxCreationOperations.metadata}->>'runnerTaskCreationContextId' <> ''`,
sql`jsonb_typeof(${sandboxCreationOperations.metadata}->'creationProviderLeaseId') = 'string' and ${sandboxCreationOperations.metadata}->>'creationProviderLeaseId' <> ''`,
));
}
export function pendingCleanupDeadlineSql(now = Date.now()) {
const deadline = sql`case
when ${environmentLeases.metadata}->>'pendingCleanupInFlight' = 'true'
@@ -21,7 +31,7 @@ export async function nextEnvironmentCleanupAt(db: Db, backoffMs = ENVIRONMENT_C
extract(epoch from ${environmentLeases.updatedAt}) * 1000 + ${backoffMs},
${pendingCleanupDeadlineSql()}))` }).from(environmentLeases).where(eq(environmentLeases.status, "pending_cleanup"));
const [creation] = await db.select({ next: sql<string | null>`min(extract(epoch from ${sandboxCreationOperations.nextAttemptAt}) * 1000)` })
.from(sandboxCreationOperations).where(eq(sandboxCreationOperations.state, "pending"));
.from(sandboxCreationOperations).where(recoverableSandboxCreationCondition());
const deadlines = [row?.next, creation?.next].filter((value): value is string => value != null).map(Number);
const saved = deadlines.length ? Math.min(...deadlines) : null;
const buffered = hasDeferredEnvironmentCleanup(db) ? Date.now() + backoffMs : null;
+4 -4
View File
@@ -3382,7 +3382,7 @@ function createPluginEnvironmentDriver(
async releaseRunLease(input) {
await db.update(environmentLeases).set({ metadata: sql`coalesce(${environmentLeases.metadata}, '{}'::jsonb) || '{"pluginLeaseCleanupMethod":"release"}'::jsonb` }).where(and(eq(environmentLeases.id, input.lease.id), eq(environmentLeases.companyId, input.lease.companyId)));
const taskStopped = await settleDispatchedEnvironmentTask(db, workerManager, input.lease) ||
(input.lease.metadata?.runnerTaskLease === true && input.lease.metadata?.runnerTaskDispatched !== true);
(input.lease.metadata?.runnerTaskLease === true && input.lease.metadata?.runnerTaskDispatched !== true && !input.lease.metadata?.runnerTaskStartup && !input.lease.metadata?.runnerTaskStartupCleanup);
const { plugin, driverKey, driverConfig } = await resolvePluginDriverForRelease(input);
await workerManager.call(plugin.id, "environmentReleaseLease", {
driverKey,
@@ -3425,7 +3425,7 @@ function createPluginEnvironmentDriver(
async destroyRunLease(input) {
await db.update(environmentLeases).set({ metadata: sql`coalesce(${environmentLeases.metadata}, '{}'::jsonb) || '{"pluginLeaseCleanupMethod":"destroy"}'::jsonb` }).where(and(eq(environmentLeases.id, input.lease.id), eq(environmentLeases.companyId, input.lease.companyId)));
const taskStopped = await settleDispatchedEnvironmentTask(db, workerManager, input.lease) ||
(input.lease.metadata?.runnerTaskLease === true && input.lease.metadata?.runnerTaskDispatched !== true);
(input.lease.metadata?.runnerTaskLease === true && input.lease.metadata?.runnerTaskDispatched !== true && !input.lease.metadata?.runnerTaskStartup && !input.lease.metadata?.runnerTaskStartupCleanup);
const { pluginKey, driverKey, driverConfig } = await resolvePluginDriverForRelease(input);
await destroyPluginEnvironmentLease({
db,
@@ -3462,7 +3462,7 @@ function createPluginEnvironmentDriver(
async retryPendingSandboxTeardown(input) {
const taskStopped = await settleDispatchedEnvironmentTask(db, workerManager, input.lease) ||
(input.lease.metadata?.runnerTaskLease === true && input.lease.metadata?.runnerTaskDispatched !== true);
(input.lease.metadata?.runnerTaskLease === true && input.lease.metadata?.runnerTaskDispatched !== true && !input.lease.metadata?.runnerTaskStartup && !input.lease.metadata?.runnerTaskStartupCleanup);
const { plugin, driverKey, driverConfig } = await resolvePluginDriverForRelease(input);
const environmentId = input.environment?.id ?? readString(input.lease.metadata?.environmentId);
if (!environmentId) throw new Error("Plugin lease cleanup has no saved environment identity");
@@ -3778,7 +3778,7 @@ export function environmentRuntimeService(
},
/** Dispatch plugin-provided Runner execution, including resource preparation and task lifecycle. */
async task(input: { companyId: string; leaseId: string; operation: PluginEnvironmentTaskOperation }) {
async task(input: { companyId: string; leaseId: string; operation: PluginEnvironmentTaskOperation; executionContext?: unknown }) {
if (!options.pluginWorkerManager) throw new Error("Plugin-provided Runner execution worker manager unavailable");
return executeEnvironmentTask(db, options.pluginWorkerManager, input);
},
+90 -19
View File
@@ -1,15 +1,17 @@
import { attachSandboxCreation, environmentService } from "./environments.js";
import { createOwnedSandbox, recordSandboxCreationResource } from "./sandbox-creation-operations.js";
import { createOwnedSandbox, queueSandboxCreationCleanup, recordSandboxCreationResource } from "./sandbox-creation-operations.js";
import { writeEnvironmentCleanup } from "./environment-cleanup-notifications.js";
import { releaseProviderRecoveryTransfers, stageProviderFreshWorkspace } from "./provider-fresh-workspace.js";
import { readWorkspaceProviderBinding } from "@paperclipai/shared";
import { settlePluginTaskCredentialHandoff } from "./plugin-task-credential-writeback.js";
import { and, eq, inArray, or, sql } from "drizzle-orm";
import { environmentLeases, executionWorkspaces, environments, heartbeatRuns, issues, projects, type Db } from "@paperclipai/db";
import { freshWorkspaceSchema, environmentTaskOperationSchema, parseEnvironmentTaskResult, type PluginEnvironmentTaskOperation } from "@paperclipai/plugin-sdk";
import { freshWorkspaceSchema, environmentTaskOperationSchema, parseEnvironmentTaskResult,
type HostToWorkerMethodName, type HostToWorkerMethods, type PluginEnvironmentTaskOperation } from "@paperclipai/plugin-sdk";
import { pluginRegistryService } from "./plugin-registry.js";
import { hasRemoteTerminationReceipt, remoteExecutionHasStopped, remoteTerminationReceipt } from "./remote-execution-termination.js";
import type { PluginWorkerManager } from "./plugin-worker-manager.js";
import { NativeExecutionContext } from "./native-runtime/native-execution-context.js";
export async function pluginEnvironmentSupportsTasks(db: Db, workers: PluginWorkerManager, lease: { companyId: string; metadata: Record<string, unknown> | null }) {
const metadata = lease.metadata ?? {};
@@ -31,6 +33,7 @@ export async function executeEnvironmentTask(db: Db, workers: PluginWorkerManage
companyId: string;
leaseId: string;
operation: PluginEnvironmentTaskOperation;
executionContext?: unknown;
}) {
const operation = environmentTaskOperationSchema.parse(input.operation);
const [row] = await db.select({ lease: environmentLeases, environment: environments, run: heartbeatRuns })
@@ -40,6 +43,13 @@ export async function executeEnvironmentTask(db: Db, workers: PluginWorkerManage
.where(and(eq(environmentLeases.id, input.leaseId), eq(environmentLeases.companyId, input.companyId))).limit(1);
if (!row || !row.lease.providerLeaseId) throw new Error("Plugin-provided Runner execution lease unavailable");
const { lease, environment, run } = row;
const context = input.executionContext;
if (context !== undefined) {
if (!(context instanceof NativeExecutionContext) || !run) {
throw new Error("native_execution_context_unavailable");
}
context.assertScope({ companyId: input.companyId, agentId: run.agentId, runId: run.id });
}
const taskId = row.lease.providerLeaseId;
const metadata = lease.metadata ?? {};
const probe = metadata.runnerTaskProbe as { runId?: unknown; sessionId?: unknown; expiresAtMs?: unknown } | undefined;
@@ -128,6 +138,19 @@ export async function executeEnvironmentTask(db: Db, workers: PluginWorkerManage
!workers.getWorker(plugin.id)?.supportedMethods.includes("environmentTask")) {
throw new Error("Plugin-provided Runner execution provider unavailable");
}
const callWorker = async <M extends HostToWorkerMethodName>(method: M, params: HostToWorkerMethods[M][0], timeout?: number): Promise<HostToWorkerMethods[M][1]> => {
if (!context) {
const cleanup = metadata.runnerTaskStartupCleanup as { contextId?: unknown; requiresPreparationStop?: unknown } | undefined;
if (["stop", "status", "complete", "credentials"].includes(operation.kind) && typeof cleanup?.contextId === "string" && typeof cleanup.requiresPreparationStop === "boolean") {
return workers.call(plugin.id, method, params, timeout, undefined, undefined, cleanup.contextId);
}
return timeout === undefined ? workers.call(plugin.id, method, params) : workers.call(plugin.id, method, params, timeout);
}
const settled = context.trackWorker();
const result = await workers.call(plugin.id, method, params, Math.max(1, Math.min(timeout ?? 15_000, context.deadlineAtMs - Date.now())), undefined, settled, context.id);
context.assertOpen();
return result;
};
const issue = scopesProjects && lease.issueId ? await db.select({ id: issues.id }).from(issues)
.where(and(eq(issues.id, lease.issueId), eq(issues.companyId, input.companyId))).limit(1).then(rows => rows[0]) : null;
if (scopesProjects && lease.issueId && !issue) throw new Error("Plugin-provided Runner execution issue unavailable");
@@ -219,7 +242,7 @@ export async function executeEnvironmentTask(db: Db, workers: PluginWorkerManage
}
try {
if (operation.kind === "describe" && metadata.runnerTaskReused === true) {
const state = parseEnvironmentTaskResult({kind:"status"}, taskId, await workers.call(plugin.id, "environmentTask", {
const state = parseEnvironmentTaskResult({kind:"status"}, taskId, await callWorker("environmentTask", {
driverKey: metadata.driverKey, companyId: input.companyId, environmentId: environment?.id ?? null,
issueId: lease.issueId, config: driverConfig, taskId, runId: run?.id ?? null, agentId: run?.agentId ?? null,
projectIds, lease: {providerLeaseId: taskId, metadata}, operation:{kind:"status"},
@@ -233,37 +256,74 @@ export async function executeEnvironmentTask(db: Db, workers: PluginWorkerManage
runnerTaskSessionRetained:state.sessionStateAvailable === true})}::jsonb`,
}).where(and(eq(environmentLeases.id, metadata.runnerTaskPreviousLeaseId), eq(environmentLeases.companyId, input.companyId)));
const replacementDriverKey = metadata.driverKey;
let abandoned = false;
const fresh = await createOwnedSandbox(db, {
companyId: input.companyId, environmentId: environment!.id, heartbeatRunId: lease.heartbeatRunId,
issueId: lease.issueId, executionWorkspaceId: lease.executionWorkspaceId, provider: lease.provider!,
pluginId: plugin.id, driverKey: metadata.driverKey, config: {}, requestKey: taskId,
metadata: { driver: "plugin", pluginId: plugin.id, pluginKey: plugin.pluginKey, driverKey: metadata.driverKey, runnerTaskLease: true },
}, identity => workers.call(plugin.id, "environmentAcquireLease", {driverKey:replacementDriverKey,
companyId:input.companyId,environmentId:environment!.id,issueId:lease.issueId,config:driverConfig,
runId:identity.runId,creationOperationId:identity.id,agentId:run!.agentId,adapterType:"paperclip_runner",
...(typeof metadata.runnerTaskRequestedExpiresAt === "string" ? {requestedExpiresAt:metadata.runnerTaskRequestedExpiresAt} : {})}));
metadata: { driver: "plugin", pluginId: plugin.id, pluginKey: plugin.pluginKey, driverKey: metadata.driverKey,
runnerTaskLease: true, agentId: run!.agentId,
...(context ? { runnerTaskCreationContextId: context.id } : {}) },
}, async identity => {
const params = { driverKey: replacementDriverKey, companyId: input.companyId, environmentId: environment!.id,
issueId: lease.issueId, config: driverConfig, runId: identity.runId, creationOperationId: identity.id,
agentId: run!.agentId, adapterType: "paperclip_runner",
...(typeof metadata.runnerTaskRequestedExpiresAt === "string" ? { requestedExpiresAt: metadata.runnerTaskRequestedExpiresAt } : {}) };
if (!context) return workers.call(plugin.id, "environmentAcquireLease", params, 30_000);
const settled = context.trackWorker();
let observedResource: Parameters<typeof queueSandboxCreationCleanup>[1] | undefined;
let dispatched = false;
try {
return await context.wait(() => {
dispatched = true;
return workers.call(plugin.id, "environmentAcquireLease", params, 30_000, undefined, settled, context.id,
async result => {
if (!result.providerLeaseId || result.providerLeaseId === taskId) throw new Error("Plugin replacement task identity unavailable");
const resource = { id: identity.id, companyId: input.companyId, environmentId: environment!.id,
providerLeaseId: result.providerLeaseId, providerMetadata: result.metadata };
await recordSandboxCreationResource(db, resource);
observedResource = resource;
if (abandoned || !context.active || context.signal.aborted || Date.now() >= context.deadlineAtMs) {
await queueSandboxCreationCleanup(db, resource);
}
});
});
} catch (error) {
if (!dispatched) settled();
abandoned = true;
if (observedResource) await queueSandboxCreationCleanup(db, observedResource);
throw error;
}
});
if (!fresh.providerLeaseId || fresh.providerLeaseId === taskId) throw new Error("Plugin replacement task identity unavailable");
await recordSandboxCreationResource(db, { id: String(fresh.metadata!.creationOperationId), companyId: input.companyId,
environmentId: environment!.id, providerLeaseId: fresh.providerLeaseId });
const {runnerTaskIdentity:_identity,runnerTaskPriorIdentity:_prior,runnerTaskCredentialHandoff:_handoff,
runnerTaskResumeFrom:_resume,runnerTaskPreviousLeaseId:_previous,...rest}=metadata;
const replacementMetadata={...rest,providerMetadata:fresh.metadata ?? {},runnerTaskReused:false,runnerTaskDispatched:false,
runnerTaskExecutionStopped:false,runnerTaskSessionRetained:false};
{
try {
context?.assertOpen();
if (!context) await recordSandboxCreationResource(db, { id: String(fresh.metadata!.creationOperationId), companyId: input.companyId,
environmentId: environment!.id, providerLeaseId: fresh.providerLeaseId });
const expiresAt=fresh.expiresAt ? new Date(fresh.expiresAt) : null;
const deadline=typeof metadata.runnerTaskRequestedExpiresAt === "string" ? Date.parse(metadata.runnerTaskRequestedExpiresAt) : null;
if ((expiresAt && (!Number.isFinite(expiresAt.getTime()) || expiresAt.getTime() <= Date.now())) ||
(deadline !== null && (!Number.isFinite(deadline) || !expiresAt || expiresAt.getTime() > deadline)))
throw new Error("Plugin replacement task expiry is not bounded");
await attachSandboxCreation(db, String(fresh.metadata!.creationOperationId), { companyId: input.companyId, environmentId: environment!.id, heartbeatRunId: lease.heartbeatRunId }, async tx => {
context?.assertOpen();
const [updated] = await tx.update(environmentLeases).set({providerLeaseId:fresh.providerLeaseId,expiresAt,
metadata:{...replacementMetadata,creationOperationId:fresh.metadata!.creationOperationId},
}).where(and(eq(environmentLeases.id,lease.id),eq(environmentLeases.providerLeaseId,taskId),
eq(environmentLeases.status,"active"))).returning({id:environmentLeases.id});
eq(environmentLeases.status,"active"), sql`coalesce(${environmentLeases.metadata}->>'runnerTaskRetiring', 'false') != 'true'`)).returning({id:environmentLeases.id});
if (!updated) throw new Error("Plugin task replacement ownership changed");
return (await environmentService(tx).getLeaseById(updated.id))!;
const attached = (await environmentService(tx).getLeaseById(updated.id))!;
context?.assertOpen();
return attached;
});
} catch (error) {
if (context) await queueSandboxCreationCleanup(db, { id: String(fresh.metadata!.creationOperationId), companyId: input.companyId,
environmentId: environment!.id, providerLeaseId: fresh.providerLeaseId, providerMetadata: fresh.metadata });
throw error;
}
return executeEnvironmentTask(db, workers, input);
}
@@ -275,14 +335,17 @@ export async function executeEnvironmentTask(db: Db, workers: PluginWorkerManage
return snapshot.credential;
});
}
const result = await workers.call(plugin.id, "environmentTask", {
const params = {
driverKey: metadata.driverKey, companyId: input.companyId, environmentId: environment?.id ?? null,
issueId: lease.issueId, config: driverConfig,
taskId, runId: run?.id ?? (activeProbe ? String(probe.runId) : null), agentId: run?.agentId ?? null, projectIds,
lease: { providerLeaseId: lease.providerLeaseId, metadata: lease.metadata ?? undefined },
...(workspaceBinding ? { workspace: workspaceBinding } : {}),
...(freshWorkspace && scopesProjects ? { fresh: freshWorkspace } : {}), operation,
}, operation.kind === "copilot_metadata" ? Math.max(1, Math.min(40_000, Number(probe?.expiresAtMs) - Date.now())) : 15_000);
};
const timeout = operation.kind === "copilot_metadata" ? Math.max(1, Math.min(40_000, Number(probe?.expiresAtMs) - Date.now()))
: 15_000;
const result = await callWorker("environmentTask", params, timeout);
const parsed = parseEnvironmentTaskResult(operation, taskId, result);
if (operation.kind === "read_file") {
const [current] = await db.select({ lease: environmentLeases, run: heartbeatRuns }).from(environmentLeases)
@@ -316,7 +379,9 @@ export async function executeEnvironmentTask(db: Db, workers: PluginWorkerManage
}
if ((parsed.kind === "status" || parsed.kind === "copilot_metadata") && parsed.executionStopped === true) {
await db.update(environmentLeases).set({
metadata: sql`coalesce(${environmentLeases.metadata}, '{}'::jsonb) || ${JSON.stringify({ runnerTaskExecutionStopped: true, runnerTaskSessionRetained: parsed.kind === "status" && parsed.sessionStateAvailable === true })}::jsonb`,
metadata: sql`coalesce(${environmentLeases.metadata}, '{}'::jsonb) || ${JSON.stringify({ runnerTaskExecutionStopped: true,
runnerTaskSessionRetained: parsed.kind === "status" && parsed.sessionStateAvailable === true,
...(parsed.kind === "status" && parsed.preparationStopped === true ? { runnerTaskPreparationStopped: true } : {}) })}::jsonb`,
}).where(and(eq(environmentLeases.id, lease.id), eq(environmentLeases.companyId, input.companyId)));
}
if (operation.kind === "complete") {
@@ -353,16 +418,22 @@ export async function settleDispatchedEnvironmentTask(db: Db, workers: PluginWor
metadata: sql`coalesce(${environmentLeases.metadata}, '{}'::jsonb) || '{"runnerTaskRetiring":true}'::jsonb`,
}).where(and(eq(environmentLeases.id, lease.id), eq(environmentLeases.companyId, lease.companyId)))
.returning({ metadata: environmentLeases.metadata, heartbeatRunId: environmentLeases.heartbeatRunId });
if (current?.metadata?.runnerTaskDispatched !== true) {
const startup = current?.metadata?.runnerTaskStartup;
const startupCleanup = current?.metadata?.runnerTaskStartupCleanup as { requiresPreparationStop?: boolean } | undefined;
if (current?.metadata?.runnerTaskDispatched !== true && !startup && !startupCleanup) {
if (current?.metadata) await settlePluginTaskCredentialHandoff(db, { companyId: lease.companyId, leaseId: lease.id }, current.metadata,
async () => { throw new Error("Undispatched task must not request credentials"); });
return current?.metadata?.runnerTaskExecutionStopped === true;
}
const scope = { companyId: lease.companyId, leaseId: lease.id };
try {
const contextId = (startupCleanup as { contextId?: unknown } | undefined)?.contextId ?? (startup as { contextId?: unknown } | undefined)?.contextId;
const draining = typeof contextId === "string" ? workers.drainStartup?.(contextId) : undefined;
void draining?.catch(() => undefined);
await executeEnvironmentTask(db, workers, { ...scope, operation: { kind: "stop" } });
const state = await executeEnvironmentTask(db, workers, { ...scope, operation: { kind: "status" } });
if (state.kind !== "status" || state.executionStopped !== true) throw new Error("Plugin Runner termination is unconfirmed");
if (state.kind !== "status" || state.executionStopped !== true || ((startup || startupCleanup?.requiresPreparationStop) && state.preparationStopped !== true)) throw new Error("Plugin Runner termination is unconfirmed");
await draining;
await executeEnvironmentTask(db, workers, { ...scope, operation: { kind: "complete" } });
return true;
} catch {
+1 -1
View File
@@ -16,7 +16,7 @@ import { hasVerifiedWorkspaceRepair, workspaceRecoveryDisplay } from "./workspac
const blockerContextKeys = ["issueId", "taskId", "source", "connectionIntentResolution",
"interactionKind", "interactionStatus", "mutation", "interactionId"] as const;
const blockerProfileKeys = ["adapterDispatch", "nativeExecutionInput", "nativeWorkspaceSync"] as const;
const blockerProfileKeys = ["adapterDispatch", "nativeExecutionInput", "nativeWorkspaceSync", "remoteWorkspace"] as const;
const blockerContextColumn = sql<Record<string, unknown> | null>`case
when ${heartbeatRuns.contextSnapshot} is null then null else (
select coalesce(jsonb_object_agg(field.key, field.value), '{}'::jsonb)
@@ -1,7 +1,7 @@
import { resolveExternalTestDatabaseUrl } from "../__tests__/helpers/external-test-database.js";
import { enableRunFailureReporting } from "../__tests__/helpers/run-failure-reporting.js";
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { eq, inArray } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { agents, companies, completionContracts, createDb, environmentLeases, environments, heartbeatRuns, issues, issueRecoveryActions, issueThreadInteractions, nativeRunFinalizations, nativeRunResults, statusDecisions, workAssessments } from "@paperclipai/db";
import {
@@ -78,6 +78,55 @@ describeEmbeddedPostgres("reconcileAbandonedExecutionControl reports a genuine f
return { companyId, agentId, issueId, runId };
}
it("recognizes pending cleanup selected only by the run deadline batch", async () => {
const f = await seedAbandonedRunFixture(), now = new Date();
const cleanupIds = Array.from({ length: 51 }, () => randomUUID());
const hidden = cleanupIds[50]!;
await db.insert(heartbeatRuns).values(cleanupIds.map(id => ({ id, companyId: f.companyId, agentId: f.agentId,
nativeIssueId: f.issueId, runtimeMode: "native", status: "running",
executionControlDeadlineAt: id === hidden ? new Date(now.getTime() - 120_000) : null })));
await db.insert(nativeRunFinalizations).values(cleanupIds.map(runId => ({ runId, companyId: f.companyId,
issueId: f.issueId, phase: "terminal_failure", failureCode: "native_startup_cleanup_pending",
controlDeadlineAt: new Date(now.getTime() - (runId === hidden ? 60_000 : 120_000)) })));
const attempted: string[] = [];
try {
await reconcileAbandonedExecutionControl(db, now, { startupCleanup: async ({ runId }) => { attempted.push(runId); throw new Error("Provider unavailable"); } });
expect(attempted).toContain(hidden);
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, hidden)))[0].status).toBe("running");
expect((await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, hidden)))[0].controlDeadlineAt! > now).toBe(true);
} finally {
await db.update(nativeRunFinalizations).set({ controlDeadlineAt: null }).where(inArray(nativeRunFinalizations.runId, cleanupIds));
await db.update(heartbeatRuns).set({ executionControlDeadlineAt: null }).where(inArray(heartbeatRuns.id, cleanupIds));
}
});
it("moves failed startup cleanup retries behind later expired runs", async () => {
const f = await seedAbandonedRunFixture();
const now = new Date();
const cleanupIds = Array.from({ length: 50 }, () => randomUUID());
const firstDeadline = new Date(now.getTime() - 120_000);
await db.insert(heartbeatRuns).values(cleanupIds.map(id => ({ id, companyId: f.companyId, agentId: f.agentId,
nativeIssueId: f.issueId, runtimeMode: "native", status: "running", executionControlDeadlineAt: firstDeadline,
contextSnapshot: { issueId: f.issueId } })));
await db.insert(nativeRunFinalizations).values(cleanupIds.map(runId => ({ runId, companyId: f.companyId,
issueId: f.issueId, phase: "terminal_failure", failureCode: "native_startup_cleanup_pending", controlDeadlineAt: firstDeadline })));
const attempted: string[] = [];
const startupCleanup = async ({ runId }: { runId: string }) => { attempted.push(runId); throw new Error("Provider unavailable"); };
try {
await reconcileAbandonedExecutionControl(db, now, { startupCleanup });
await reconcileAbandonedExecutionControl(db, now, { startupCleanup });
expect(new Set(attempted)).toEqual(new Set(cleanupIds));
expect(attempted).toHaveLength(50);
const [later] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId));
expect(later?.status).toBe("failed");
const retries = await db.select().from(nativeRunFinalizations).where(inArray(nativeRunFinalizations.runId, cleanupIds));
expect(retries.every(row => row.controlDeadlineAt && row.controlDeadlineAt > now)).toBe(true);
} finally {
await db.update(nativeRunFinalizations).set({ controlDeadlineAt: null }).where(inArray(nativeRunFinalizations.runId, cleanupIds));
await db.update(heartbeatRuns).set({ executionControlDeadlineAt: null }).where(inArray(heartbeatRuns.id, cleanupIds));
}
});
it("reports exactly one Sentry event for a genuine finalization-deadline failure", async () => {
const { runId } = await seedAbandonedRunFixture();
const captureCallsBefore = mockCaptureRunFailure.mock.calls.length;
@@ -8,7 +8,7 @@ import { notifyInstructionCollection } from "./instruction-collection-notificati
import { notifyExecutionRunWork } from "./execution-control-notifications.js";
import { randomUUID } from "node:crypto";
import { logger } from "../middleware/logger.js";
import { and, eq, isNotNull, lte, sql } from "drizzle-orm";
import { and, asc, eq, isNotNull, lte, sql } from "drizzle-orm";
import {
agents,
heartbeatRuns,
@@ -22,17 +22,25 @@ import { hasLiveLegacyController } from "./legacy-controller-lease.js";
import { reportRunFailure } from "./run-failure-report.js";
import { getNativeReviewAssignment, readNativeReviewAssignmentContext } from "./native-runtime/native-review-participant.js";
import { preserveNativeFailedWorkspace } from "./native-runtime/native-failed-workspace-recovery.js";
import { EXECUTION_RECONCILIATION_INTERVAL_MS } from "./execution-control-deadline.js";
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
import { NATIVE_STARTUP_CLEANUP_PENDING, readNativeStartupContext, promoteNativeStartupCleanup } from "./native-runtime/native-startup-cleanup.js";
/** Only newly recorded control deadlines are eligible. Upgrades never replay ambiguous historical runs. */
export async function reconcileAbandonedExecutionControl(
db: Db,
now = new Date(),
options: { startupCleanup?: (scope: { companyId: string; runId: string }) => Promise<unknown> } = {},
) {
const nativeDue = await db
.select({
runId: nativeRunFinalizations.runId,
issueId: nativeRunFinalizations.issueId,
companyId: nativeRunFinalizations.companyId,
failureCode: nativeRunFinalizations.failureCode,
leaseOwner: nativeRunFinalizations.leaseOwner,
controllerGeneration: nativeRunFinalizations.controllerGeneration,
attempt: nativeRunFinalizations.attempt,
})
.from(nativeRunFinalizations)
.where(
@@ -41,20 +49,28 @@ export async function reconcileAbandonedExecutionControl(
lte(nativeRunFinalizations.controlDeadlineAt, now),
),
)
.orderBy(asc(nativeRunFinalizations.controlDeadlineAt), asc(nativeRunFinalizations.runId))
.limit(50);
const controlDue = await db
.select({
runId: heartbeatRuns.id,
companyId: heartbeatRuns.companyId,
context: heartbeatRuns.contextSnapshot,
nativeIssueId: nativeRunFinalizations.issueId,
failureCode: nativeRunFinalizations.failureCode,
leaseOwner: nativeRunFinalizations.leaseOwner,
controllerGeneration: nativeRunFinalizations.controllerGeneration,
attempt: nativeRunFinalizations.attempt,
})
.from(heartbeatRuns)
.leftJoin(nativeRunFinalizations, eq(nativeRunFinalizations.runId, heartbeatRuns.id))
.where(
and(
isNotNull(heartbeatRuns.executionControlDeadlineAt),
lte(heartbeatRuns.executionControlDeadlineAt, now),
),
)
.orderBy(asc(heartbeatRuns.executionControlDeadlineAt), asc(heartbeatRuns.id))
.limit(50);
const due = [
...new Map(
@@ -62,10 +78,10 @@ export async function reconcileAbandonedExecutionControl(
...nativeDue,
...controlDue.map((row) => ({
...row,
issueId:
issueId: row.nativeIssueId ?? (
typeof row.context?.issueId === "string"
? row.context.issueId
: null,
: null),
})),
].map((row) => [row.runId, row]),
).values(),
@@ -78,6 +94,25 @@ export async function reconcileAbandonedExecutionControl(
while (nextCandidate < due.length) {
const candidate = due[nextCandidate++]!;
try {
if (candidate.failureCode === NATIVE_STARTUP_CLEANUP_PENDING) {
if (!options.startupCleanup) continue;
try {
await options.startupCleanup({ companyId: candidate.companyId, runId: candidate.runId });
} finally {
await db.transaction(async tx => {
if (candidate.issueId) await tx.select({ id: issues.id }).from(issues).where(eq(issues.id, candidate.issueId)).for("update");
const [owner] = await tx.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, candidate.runId)).for("update");
if (owner?.failureCode !== NATIVE_STARTUP_CLEANUP_PENDING || !owner.controlDeadlineAt || owner.controlDeadlineAt > now ||
owner.leaseOwner !== candidate.leaseOwner || owner.controllerGeneration !== candidate.controllerGeneration || owner.attempt !== candidate.attempt) return;
const cleanupRetryAt = new Date(Math.max(now.getTime(), Date.now()) + EXECUTION_RECONCILIATION_INTERVAL_MS);
await notifyDeliveryWork(tx, DELIVERY_QUEUES.executionFinalization);
await tx.update(nativeRunFinalizations).set({ controlDeadlineAt: cleanupRetryAt }).where(eq(nativeRunFinalizations.runId, candidate.runId));
await tx.update(heartbeatRuns).set({ executionControlDeadlineAt: cleanupRetryAt }).where(and(eq(heartbeatRuns.id, candidate.runId),
isNotNull(heartbeatRuns.executionControlDeadlineAt), lte(heartbeatRuns.executionControlDeadlineAt, now)));
});
}
continue;
}
// Set inside the transaction only when the write below genuinely
// transitions the run into "failed". Read after the transaction
// commits, so a rolled-back write never reports a false failure.
@@ -128,6 +163,18 @@ export async function reconcileAbandonedExecutionControl(
].some((deadline) => deadline && deadline <= now)
)
return false;
if (coordinator?.failureCode === NATIVE_STARTUP_CLEANUP_PENDING) return false;
const startupContext = coordinator && readNativeStartupContext(run, coordinator);
if (startupContext) {
if (!await promoteNativeStartupCleanup(tx as unknown as Db, run, coordinator!, now)) {
const retryAt = new Date(Math.max(now.getTime() + EXECUTION_RECONCILIATION_INTERVAL_MS,
startupContext.deadlineAtMs, coordinator!.leaseExpiresAt?.getTime() ?? 0));
await notifyDeliveryWork(tx, DELIVERY_QUEUES.executionFinalization);
await tx.update(nativeRunFinalizations).set({ controlDeadlineAt: retryAt }).where(eq(nativeRunFinalizations.runId, run.id));
await tx.update(heartbeatRuns).set({ executionControlDeadlineAt: retryAt }).where(eq(heartbeatRuns.id, run.id));
}
return false;
}
await tx
.update(heartbeatRuns)
.set({ executionControlDeadlineAt: null })
@@ -1,4 +1,5 @@
import { AsyncLocalStorage } from "node:async_hooks";
import { withDatabaseOperation } from "@paperclipai/db";
import { EXECUTION_PREPARATION_SPAN_ATTRS, getActiveStepContext, runWithRuntimeParent } from "@paperclipai/adapter-utils/acpx-engine/startup-timing";
import { createHash } from "node:crypto";
import { isSpanContextValid, ROOT_CONTEXT, SpanStatusCode, trace, type Context } from "@opentelemetry/api";
@@ -156,7 +157,7 @@ function measure<T>(runId: string, name: string, work: () => T | Promise<T>, ind
ended: false,
details: !independent && previous?.runId === runId ? previous.details : new Map<string, number>(),
};
operation = Promise.resolve().then(() =>
operation = withDatabaseOperation(name, () => Promise.resolve().then(() =>
ownership.run(owned, () => runWithRuntimeParent(owned.parent, work))).then(value => {
try { span?.end(); } catch {}
owned.ended = true;
@@ -166,10 +167,10 @@ function measure<T>(runId: string, name: string, work: () => T | Promise<T>, ind
try { span?.end(); } catch {}
owned.ended = true;
throw error;
});
}));
return operation;
};
if (!endpointEnabled()) return (async () => work())();
if (!endpointEnabled()) return withDatabaseOperation(name, async () => work());
try {
const parent = independent ? undefined : activeParent ?? getRunExecutionTraceContext(runId);
const trigger = parent ? undefined : trace.getActiveSpan()?.spanContext();
@@ -202,20 +203,22 @@ export function withRunWorkspaceFinalizationTiming<T>(runId: string, work: () =>
export function withRunResponsePresentationTiming<T>(runId: string, work: () => T | Promise<T>): Promise<T> {
const owned = ownership.getStore();
if (owned?.runId === runId && owned.phase === "task.response_presentation" && !owned.ended) {
return (async () => work())();
return withDatabaseOperation("task.response_presentation", async () => work());
}
return measure(runId, "task.response_presentation", work, true);
}
export async function measureRunPhase<T>(phase: ExecutionPhase, work: () => T | Promise<T>, counts?: { fileCount?: number; bytes?: number }): Promise<T> {
const owned = ownership.getStore();
if (!endpointEnabled() || !owned || owned.ended) return work();
const count = owned.details.get(phase) ?? 0;
if (count >= 16) return work();
owned.details.set(phase, count + 1);
// A startup step can be more specific than the owning run phase. Keep that
// causal parent while the child publishes its own context to plugin workers.
return measure(owned.runId, phase, work, false, counts, getActiveStepContext()?.parentContext as Context | undefined);
export function measureRunPhase<T>(phase: ExecutionPhase, work: () => T | Promise<T>, counts?: { fileCount?: number; bytes?: number }): Promise<T> {
return withDatabaseOperation(phase, async () => {
const owned = ownership.getStore();
if (!endpointEnabled() || !owned || owned.ended) return work();
const count = owned.details.get(phase) ?? 0;
if (count >= 16) return work();
owned.details.set(phase, count + 1);
// A startup step can be more specific than the owning run phase. Keep that
// causal parent while the child publishes its own context to plugin workers.
return measure(owned.runId, phase, work, false, counts, getActiveStepContext()?.parentContext as Context | undefined);
});
}
export function beginRunPhase(phase: ExecutionPhase): (failed?: boolean) => void {
@@ -52,7 +52,7 @@ const preflightProfile = sql<Run["runnerProfileJson"]>`case
then jsonb_build_object('adapterType', field.value->'adapterType') else field.value end), '{}'::jsonb)
from jsonb_each(case when jsonb_typeof(${heartbeatRuns.runnerProfileJson}) = 'object'
then ${heartbeatRuns.runnerProfileJson} else '{}'::jsonb end) as field(key, value)
where field.key in ('adapterDispatch', 'nativeWorkspaceSync')
where field.key in ('adapterDispatch', 'nativeWorkspaceSync', 'remoteWorkspace')
) end`;
const continuationRunFields = {
id: heartbeatRuns.id, companyId: heartbeatRuns.companyId, agentId: heartbeatRuns.agentId,
+38 -7
View File
@@ -398,6 +398,7 @@ import {
isNativeRunnerOwnershipHeld,
NATIVE_OWNERSHIP_UNVERIFIED_ERROR_CODE,
} from "./native-runtime/native-runner-ownership.js";
import { NativeStartupCleanupPendingError, requestNativeStartupCleanup, verifyNativeStartupCleanup, readNativeStartupCleanup } from "./native-runtime/native-startup-cleanup.js";
import {
emitAgentTaskRun,
emitAgentTaskRunById,
@@ -1436,6 +1437,14 @@ export function heartbeatService(
}
const runControl = createHeartbeatRunControl(db, {
verifyStartupCleanup: async run => {
await verifyNativeStartupCleanup(db, run, () => envOrchestrator.releaseForRun({
heartbeatRunId: run.id, companyId: run.companyId, agentId: run.agentId, status: "expired",
cancelActiveWork: true, providerResourceDisposition: "destroy",
}));
const marker = readNativeStartupCleanup(run)!;
await options.pluginWorkerManager?.retireStartup?.(marker.contextId);
},
getRun,
getRunCleanupState,
getRunExecutionWait,
@@ -1759,7 +1768,7 @@ export function heartbeatService(
},
});
})().catch((error) => {
if (!nativeRecoveryDeadlines.has(runId)) {
if (!nativeRecoveryDeadlines.has(runId) || nativeRecoveryDeadlines.get(runId) === pending) {
scheduleNativeSessionResumeDispatch(runId, new Date(Date.now() + 30_000), mode);
}
logger.error(
@@ -1956,6 +1965,19 @@ export function heartbeatService(
Parameters<typeof cleanupGitHubOperationLaunchers>[0] | null = null;
let nativeSessionResumeScheduled = false;
let nativeOwnershipHeld = false;
let nativeStartupCleanupScheduled = false;
let nativeStartupCleanupError: NativeStartupCleanupPendingError | undefined;
const requestStartupCleanup = async (error: NativeStartupCleanupPendingError) => {
nativeStartupCleanupError = error;
try {
nativeStartupCleanupScheduled = await requestNativeStartupCleanup(db, { companyId: run.companyId, agentId: run.agentId, runId: run.id,
contextId: error.contextId, deadlineAtMs: error.deadlineAtMs, leaseOwner: error.leaseOwner,
leaseId: error.leaseId, requiresPreparationStop: error.requiresPreparationStop });
} catch (writeError) {
scheduleNativeSessionResumeDispatch(run.id, new Date(Date.now() + 30_000), "restart");
throw writeError;
}
};
let instructionWorkingCopy: ReturnType<typeof createHeartbeatInstructionWorkingCopy> | null = null;
let nativeDispatchStarted = false;
let nativeWorkspaceFinalizeScheduled = false;
@@ -3527,6 +3549,10 @@ export function heartbeatService(
}));
} catch (err) {
await persistUsageCaptureFailure?.();
if (err instanceof NativeStartupCleanupPendingError) {
await requestStartupCleanup(err);
return;
}
if (err instanceof NativeControllerDetachedForRestartError) {
nativeSessionResumeScheduled = true;
return;
@@ -3659,7 +3685,12 @@ export function heartbeatService(
});
}
} catch (outerErr) {
if (
if (nativeStartupCleanupScheduled || nativeStartupCleanupError || outerErr instanceof NativeStartupCleanupPendingError) {
const pending = outerErr instanceof NativeStartupCleanupPendingError ? outerErr : nativeStartupCleanupError;
if (!nativeStartupCleanupScheduled && pending) await requestStartupCleanup(pending).catch(error => {
logger.warn({ err: error, runId: run.id }, "Native startup cleanup intent will retry through owned recovery");
});
} else if (
nativeOwnershipHeld ||
outerErr instanceof NativeRunnerOwnershipUnverifiedError
) {
@@ -3696,7 +3727,7 @@ export function heartbeatService(
);
} else {
await preserveNativeWorkspaceOnFailure();
if (!nativeOwnershipHeld) await runCompletion.failRunSetup({
if (!nativeOwnershipHeld && !nativeStartupCleanupScheduled && !nativeStartupCleanupError) await runCompletion.failRunSetup({
run, runId, outerErr, signal: executionControl.controller.signal,
providerDispatchStarted: legacyAdapterEntered || nativeDispatchStarted,
identityRedactor, readFailureReportSecrets,
@@ -3746,7 +3777,7 @@ export function heartbeatService(
latestRun &&
!nativeSessionResumeScheduled &&
!nativeWorkspaceFinalizeScheduled &&
!nativeOwnershipHeld
!nativeOwnershipHeld && !nativeStartupCleanupScheduled && !nativeStartupCleanupError
) {
latestRun = await terminalizeRunOnLeaseRelease(latestRun).catch(
(terminalizeErr) => {
@@ -3772,7 +3803,7 @@ export function heartbeatService(
if (
!nativeSessionResumeScheduled &&
!nativeWorkspaceFinalizeScheduled &&
!nativeOwnershipHeld
!nativeOwnershipHeld && !nativeStartupCleanupScheduled && !nativeStartupCleanupError
) {
// Keep launchers during same-run recovery. At a terminal boundary all
// operations have settled; clean before the remote lease can be stopped.
@@ -3895,7 +3926,7 @@ export function heartbeatService(
}).where(and(eq(heartbeatRuns.id, run.id), eq(heartbeatRuns.status, "cancelled")));
}
if (latestRun?.runtimeMode === "native" && isHeartbeatRunTerminalStatus(latestRun.status) &&
!nativeSessionResumeScheduled && !nativeWorkspaceFinalizeScheduled && !nativeOwnershipHeld) {
!nativeSessionResumeScheduled && !nativeWorkspaceFinalizeScheduled && !nativeOwnershipHeld && !nativeStartupCleanupScheduled && !nativeStartupCleanupError) {
await settleNativeRunProcess(db, run.id).catch(error => {
logger.warn({ err: error, runId: run.id }, "failed to release settled native process identity");
});
@@ -3932,7 +3963,7 @@ export function heartbeatService(
caseHandoffCleanupSettled = hasStoppedLocalProcessTree(latestRun);
}
if (caseHandoffCleanupSettled && latestRun && !nativeSessionResumeScheduled && !nativeWorkspaceFinalizeScheduled &&
!nativeOwnershipHeld && isHeartbeatRunTerminalStatus(latestRun.status)) {
!nativeOwnershipHeld && !nativeStartupCleanupScheduled && !nativeStartupCleanupError && isHeartbeatRunTerminalStatus(latestRun.status)) {
const handoff = await pendingCaseHandoffForRun(db, run.id);
if (handoff) await recordCaseHandoffExecutionReleased(db, run.id, handoff.issueId);
}
@@ -1,6 +1,6 @@
import { randomBytes, randomUUID } from "node:crypto";
import { heartbeatRuns, issues, type Db } from "@paperclipai/db";
import { createDatabaseResponseCapture, installDatabaseResponseCapture } from "@paperclipai/db/response-capture";
import { createFullDatabaseResponseCapture, installDatabaseResponseCapture } from "../../__tests__/helpers/full-database-response-capture.js";
import { eq, sql } from "drizzle-orm";
import { describe, expect, it, vi } from "vitest";
import { isAcknowledgedNativeStop } from "../acknowledged-native-stop.js";
@@ -31,7 +31,7 @@ describe.skipIf(!runControlPostgresSupport.supported)("native Stop evidence read
contextSnapshot: { savedInput: bulk }, runnerProfileJson: { savedInput: bulk }, stdoutExcerpt: bulk,
stderrExcerpt: bulk, resultJson: result }).returning();
const events: Array<Record<string, unknown>> = [], now = Date.now();
const capture = createDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event));
const capture = createFullDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event));
installDatabaseResponseCapture(db, { heartbeatRuns }, capture);
let evidence: Awaited<ReturnType<typeof readNativeStopExecutionEvidence>>;
try { evidence = await readNativeStopExecutionEvidence(db, company.id, id); }
@@ -68,7 +68,7 @@ describe.skipIf(!runControlPostgresSupport.supported)("native Stop evidence read
runtimeMode: "native", status: "cancelled" }).returning();
await db.execute(sql`update ${heartbeatRuns} set result_json = ${JSON.stringify(saved)}::jsonb where id = ${run.id}`);
const events: Array<Record<string, unknown>> = [], now = Date.now();
const capture = createDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event));
const capture = createFullDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event));
installDatabaseResponseCapture(db, { heartbeatRuns }, capture);
try {
const evidence = await readNativeStopExecutionEvidence(db, company.id, run.id);
+5 -2
View File
@@ -2299,7 +2299,7 @@ export function createHeartbeatQueue(db: Db, dependencies: HeartbeatQueueDepende
sql`${agentWakeupRequests.payload}->>'issueId' = ${issueId ?? ""}`,
));
const retry = readTaskRetryIntent(receipt?.payload);
if (!receipt || retry?.generationId !== receipt.id || retry.canonicalIntentId ||
if (!receipt || retry?.generationId !== receipt.id || retry.canonicalIntentId || retry.queuedChatRequestId ||
retry.actorId !== receipt.requestedByActorId) return null;
const chatRetry = retry.chatActionId && durableRequest?.id === retry.chatActionId &&
durableRequest.failedRunRetry?.failedRunId === retry.sourceRunId;
@@ -2518,6 +2518,7 @@ export function createHeartbeatQueue(db: Db, dependencies: HeartbeatQueueDepende
// retain distinct durable receipts even when the same gate blocks them.
const coalesceExecutionWait =
opts.requestedByActorType === "system" &&
opts.allowRunCoalescing !== false &&
!receiptRequest &&
!wakeCommentId &&
queuedCommentIdsFromRunContext(enrichedContextSnapshot).length === 0 &&
@@ -4369,7 +4370,9 @@ export function createHeartbeatQueue(db: Db, dependencies: HeartbeatQueueDepende
or(isNull(heartbeatRuns.nativeIssueId), eq(heartbeatRuns.nativeIssueId, issue.id)),
)).then(rows => rows[0] ?? null)
: null;
const canCoalesceComments = !isConversation(issue) && opts.allowRunCoalescing !== false;
// External input owns an exact authorized source batch. Board input
// must retain its separate receipt, including during normal resume.
const canCoalesceComments = !durableRequest && !isConversation(issue) && opts.allowRunCoalescing !== false;
// A resumed receipt already passed admission under this issue lock.
// Consume it with its successor even when chat keeps other messages
// in separate turns, or finalization will deliver it a second time.
+38 -2
View File
@@ -168,13 +168,13 @@ describe("heartbeat recovery module callbacks", () => {
expect(database.access).not.toHaveBeenCalled();
});
it("persists an authentication ownership hold before its event without terminating or retrying", async () => {
it.each(["adopted_runner_authentication_timeout"] as const)("persists a %s ownership hold before its event without terminating or retrying", async reason => {
const database = guardedDatabase();
const deps = callbacks(database.db);
const run = { id: "run", runtimeMode: "native", status: "running" } as Run;
const blocked = { ...run, nativePhase: "terminal_failure", errorCode: NATIVE_OWNERSHIP_UNVERIFIED_ERROR_CODE };
deps.setRunStatusFromLive.mockResolvedValue({ updated: true, run: blocked });
expect(await createHeartbeatRecovery(database.db, deps).markNativeOwnershipUnverified(run, { reason: "adopted_runner_authentication_timeout" })).toBe(blocked);
expect(await createHeartbeatRecovery(database.db, deps).markNativeOwnershipUnverified(run, { reason })).toBe(blocked);
expect(deps.setRunStatusFromLive).toHaveBeenCalledWith("run", "running", ["running"], expect.objectContaining({ nativePhase: "terminal_failure" }));
expect(deps.appendRunEvent).toHaveBeenCalledAfter(deps.setRunStatusFromLive);
expect(deps.cancelHeartbeatNativeRun).not.toHaveBeenCalled();
@@ -207,6 +207,42 @@ describePostgres("heartbeat recovery module ownership", () => {
return (await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, issueId, invocationSource: "automation", status: "running", runtimeMode: "legacy" }).returning())[0];
}
it("routes an expired pre-capability startup to cleanup before generic orphan ownership holds", async () => {
const run = await seedRun(), leaseId = randomUUID(), environmentId = randomUUID(), leaseOwner = randomUUID();
const deadlineAtMs = Date.now() - 1_000, contextId = randomUUID();
await db.update(agents).set({ adapterType: "paperclip_runner" }).where(eq(agents.id, run.agentId));
await db.update(issues).set({ executionRunId: run.id }).where(eq(issues.id, run.issueId!));
await db.update(heartbeatRuns).set({ runtimeMode: "native", nativeIssueId: run.issueId, nativePhase: "observed",
runnerInstanceId: randomUUID(), nativeSessionId: randomUUID(), runnerProfileJson: { nativeStartupContext: {
schema: "paperclip.native-startup-context.v1", companyId: run.companyId, agentId: run.agentId, runId: run.id,
contextId, deadlineAtMs, leaseId, leaseOwner, controllerGeneration: 1, attempt: 1 } } }).where(eq(heartbeatRuns.id, run.id));
await db.insert(nativeRunFinalizations).values({ runId: run.id, companyId: run.companyId, issueId: run.issueId!,
phase: "observed", attempt: 1, leaseOwner, controllerGeneration: 1, leaseExpiresAt: new Date(Date.now() - 1_000),
controllerBootId: randomUUID(), controllerPid: process.pid, controllerProcessStartedAt: new Date() });
await db.insert(environments).values({ id: environmentId, name: environmentId, driver: "plugin", config: {} });
await db.insert(environmentLeases).values({ id: leaseId, companyId: run.companyId, environmentId, heartbeatRunId: run.id,
issueId: run.issueId, provider: "plugin", providerLeaseId: "early-task", status: "active", metadata: { runnerTaskLease: true } });
const deps = callbacks(db);
deps.getRun.mockImplementation(async id => (await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, id)))[0] ?? null);
native.claims.mockImplementationOnce(claimNativeRestartRecoveries);
try {
await createHeartbeatRecovery(db, deps).recoverActiveExecutions({ runId: run.id });
expect(native.claims).toHaveBeenCalledWith(expect.objectContaining({ runIds: [run.id] }));
const [current] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, run.id));
expect(current).toMatchObject({ status: "running", errorCode: "native_startup_cleanup_pending",
resultJson: { nativeStartupCleanup: { contextId, state: "pending" } } });
expect(deps.executeRun).not.toHaveBeenCalled();
expect(deps.setRunStatusFromLive).not.toHaveBeenCalled();
} finally {
await db.delete(environmentLeases).where(eq(environmentLeases.id, leaseId));
await db.delete(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, run.id));
await db.delete(heartbeatRunEvents).where(eq(heartbeatRunEvents.runId, run.id));
await db.delete(activityLog).where(eq(activityLog.runId, run.id));
await db.update(issues).set({ executionRunId: null }).where(eq(issues.id, run.issueId!));
await db.delete(heartbeatRuns).where(eq(heartbeatRuns.id, run.id));
}
});
async function ownedProcess() {
const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)", `paperclip-recovery-${randomUUID()}`], {
stdio: "ignore", detached: true, env: { PATH: process.env.PATH },
+13 -2
View File
@@ -1,3 +1,4 @@
import { readNativeStartupCleanup, readNativeStartupContext } from "../native-runtime/native-startup-cleanup.js";
import { pendingFailedNativeRetryCancellationCondition } from "../native-runtime/native-cancellation-request.js";
import { notifyLiveRunRecovery } from "../live-run-recovery-notifications.js";
import { registerLiveRecoveryWork } from "./live-recovery-work.js";
@@ -1038,7 +1039,9 @@ export function createHeartbeatRecovery(db: Db, dependencies: HeartbeatRecoveryD
? "Native restart recovery is waiting for safe ownership evidence"
: disposition.kind === "already_finalized"
? "Native restart recovery found an already-finalized result"
: "Native restart recovery blocked ambiguous or conflicting ownership",
: disposition.reason === "native_startup_cleanup_pending"
? "Native startup timed out; cancellation and verified cleanup are queued"
: "Native restart recovery blocked ambiguous or conflicting ownership",
payload: {
restartKind: isClaim ? disposition.restartKind : restartKind,
recoveryRequestId: isClaim
@@ -2251,6 +2254,10 @@ export function createHeartbeatRecovery(db: Db, dependencies: HeartbeatRecoveryD
nativeControllerProcessStartedAt:
nativeRunFinalizations.controllerProcessStartedAt,
nativeControllerLeaseExpiresAt: nativeRunFinalizations.leaseExpiresAt,
nativeLeaseOwner: nativeRunFinalizations.leaseOwner,
nativeControllerGeneration: nativeRunFinalizations.controllerGeneration,
nativeAttempt: nativeRunFinalizations.attempt,
nativeResultId: nativeRunFinalizations.resultId,
nativeWorkspaceFinalizationPending: pendingNativeWorkspaceFinalizationCondition(db),
})
.from(heartbeatRuns)
@@ -2271,7 +2278,10 @@ export function createHeartbeatRecovery(db: Db, dependencies: HeartbeatRecoveryD
&& !isNativeRunnerOwnershipHeld(run)
&& !activeRunExecutions.has(run.id) && !runningProcesses.has(run.id),
).map(({ run }) => run.id);
const remoteRecoveryRunIds = new Set(await listExpiredNativeRunnerAuthorities(db, now, expiredNativeRunIds));
const earlyStartupRunIds = activeRuns.filter(row => expiredNativeRunIds.includes(row.run.id) &&
readNativeStartupContext(row.run, { leaseOwner: row.nativeLeaseOwner, controllerGeneration: row.nativeControllerGeneration ?? 0,
attempt: row.nativeAttempt ?? 0, resultId: row.nativeResultId })).map(row => row.run.id);
const remoteRecoveryRunIds = new Set([...earlyStartupRunIds, ...await listExpiredNativeRunnerAuthorities(db, now, expiredNativeRunIds)]);
if (remoteRecoveryRunIds.size) {
const recovery = await recoverNativeRunsAfterRestart(now, [...remoteRecoveryRunIds]);
for (const claim of recovery.claims) resumedRunIds.add(claim.runId);
@@ -2321,6 +2331,7 @@ export function createHeartbeatRecovery(db: Db, dependencies: HeartbeatRecoveryD
nativeControllerLeaseExpiresAt,
nativeWorkspaceFinalizationPending,
} of activeRuns) {
if (run.runtimeMode === "native" && readNativeStartupCleanup(run)) continue;
if (parseObject(run.contextSnapshot).nativeQuestionCancellation) continue;
// Native recovery owns these dispositions. Its writes happened after this
// inventory was read; do not reap or overwrite them using the old row.
+36 -15
View File
@@ -123,7 +123,8 @@ import {
} from "../native-runtime/index.js";
import { runningProcesses } from "../../adapters/index.js";
import { parseObject } from "../../adapters/utils.js";
import { isNativeRunnerOwnershipHeld } from "../native-runtime/native-runner-ownership.js";
import { isNativeRunnerOwnershipHeld, nativeRunnerOwnershipNotHeldCondition } from "../native-runtime/native-runner-ownership.js";
import { readNativeStartupCleanup, acknowledgeNativeStartupCleanup } from "../native-runtime/native-startup-cleanup.js";
import {
withCurrentBudgetEnforcement,
type BudgetEnforcementScope,
@@ -177,6 +178,7 @@ export interface HeartbeatRunControlDependencies extends Pick<ReturnType<typeof
Pick<ReturnType<typeof createHeartbeatLifecycle>,
"mergeRunStopMetadataForAgent" | "setRunStatusFromLive" | "setRunStatus" | "setWakeupStatus" | "appendRunEvent">,
Pick<HeartbeatRetryDependencies, "finalizeAgentStatus"> {
verifyStartupCleanup?: (run: typeof heartbeatRuns.$inferSelect) => Promise<void>;
options: {
closeWarmNativeSessionsForRun?: (input: {
runId: string;
@@ -763,6 +765,10 @@ export function createHeartbeatRunControl(db: Db, dependencies: HeartbeatRunCont
eq(agentWakeupRequests.requestedByActorType, "user"),
));
const intent = readTaskRetryIntent(wake?.payload);
if (intent?.queuedChatRequestId) {
await reconcileTaskChatRetryReceipts(db, { companyId, requestId });
return;
}
if (!wake || !intent || intent.chatActionId || intent.canonicalIntentId || intent.generationId !== wake.id ||
intent.actorId !== wake.requestedByActorId) return;
const issueId = readNonEmptyString(wake.payload?.issueId);
@@ -788,6 +794,7 @@ export function createHeartbeatRunControl(db: Db, dependencies: HeartbeatRunCont
scope.requestId ? eq(agentWakeupRequests.id, scope.requestId) : undefined,
eq(agentWakeupRequests.reason, "task_retry"), eq(agentWakeupRequests.status, "deferred_issue_execution"),
sql`${agentWakeupRequests.payload}->'taskRetry'->>'chatActionId' is null`,
sql`${agentWakeupRequests.payload}->'taskRetry'->>'queuedChatRequestId' is null`,
notInArray(issues.status, ["done", "cancelled"])))
.orderBy(asc(agentWakeupRequests.updatedAt)).limit(50);
const chatEnabled = !retries.some(retry => retry.conversationAgentId)
@@ -1394,13 +1401,19 @@ export function createHeartbeatRunControl(db: Db, dependencies: HeartbeatRunCont
}
let terminationSettled = false;
try {
await cancelHeartbeatNativeRun({
db,
runId: run.id,
reason,
runtimeMode: run.runtimeMode,
...(options.cancellationRequestId ? { cancellationRequestId: options.cancellationRequestId } : {}),
});
const startupCleanupRun = run.runtimeMode === "native" ? await getRun(run.id) : null;
if (startupCleanupRun && readNativeStartupCleanup(startupCleanupRun) && !control && !running && !activeRunExecutions.has(run.id)) {
if (!dependencies.verifyStartupCleanup) throw conflict("Native startup cleanup worker unavailable");
await dependencies.verifyStartupCleanup(startupCleanupRun);
} else {
await cancelHeartbeatNativeRun({
db,
runId: run.id,
reason,
runtimeMode: run.runtimeMode,
...(options.cancellationRequestId ? { cancellationRequestId: options.cancellationRequestId } : {}),
});
}
if (running) {
await terminateHeartbeatRunProcess({
pid: running.child.pid,
@@ -1461,12 +1474,13 @@ export function createHeartbeatRunControl(db: Db, dependencies: HeartbeatRunCont
}
const finishedAt = new Date();
const persistedCancellationResult =
run.runtimeMode === "native"
? await getRun(run.id).then((current) =>
parseObject(current?.resultJson),
)
: {};
let persistedCancellationRun = run.runtimeMode === "native" ? await getRun(run.id) : null;
if (persistedCancellationRun && readNativeStartupCleanup(persistedCancellationRun)) {
if (!dependencies.verifyStartupCleanup) throw conflict("Native startup cleanup worker unavailable");
await dependencies.verifyStartupCleanup(persistedCancellationRun);
persistedCancellationRun = await getRun(run.id);
}
const persistedCancellationResult = parseObject(persistedCancellationRun?.resultJson);
return await setRunStatusFromLive(
run.id,
"cancelled",
@@ -1515,12 +1529,18 @@ export function createHeartbeatRunControl(db: Db, dependencies: HeartbeatRunCont
persistedCancellationResult.nativeCancellation,
}
: {}),
...(Object.hasOwn(persistedCancellationResult, "nativeStartupCleanup")
? { nativeStartupCleanup: persistedCancellationResult.nativeStartupCleanup } : {}),
},
}
: {}),
},
undefined,
pendingNativeRetry ? nativeRetryCancellationCommitCondition(persistedCancellationResult) : undefined,
and(pendingNativeRetry ? nativeRetryCancellationCommitCondition(persistedCancellationResult)
: persistedCancellationRun && readNativeStartupCleanup(persistedCancellationRun) ? and(nativeRunnerOwnershipNotHeldCondition(),
sql`not exists (select 1 from ${nativeRunFinalizations} where ${nativeRunFinalizations.runId} = ${run.id} and ${nativeRunFinalizations.resultId} is not null)`)
: undefined,
run.runtimeMode === "native" ? sql`coalesce(${heartbeatRuns.resultJson}->'nativeStartupCleanup'->>'state', '') <> 'pending'` : undefined),
);
} catch (error) {
if (processCancellationSettlement) {
@@ -1552,6 +1572,7 @@ export function createHeartbeatRunControl(db: Db, dependencies: HeartbeatRunCont
const cancelled = cancellation.run;
if (cancellation.updated && cancelled) {
if (readNativeStartupCleanup(cancelled)) await acknowledgeNativeStartupCleanup(db, { companyId: cancelled.companyId, runId: cancelled.id });
await setWakeupStatus(run.wakeupRequestId, "cancelled", {
finishedAt: cancelled.finishedAt ?? new Date(),
error: reason,
@@ -1,3 +1,4 @@
import { readNativeStartupCleanup } from "../native-runtime/native-startup-cleanup.js";
import { notifyStrandedAgentStatus } from "../live-run-recovery-notifications.js";
import { notifyRunDispatch } from "../run-dispatch-notifications.js";
import { preserveAcceptedRunUsageProjectionSql } from "../usage-receipts.js";
@@ -1159,6 +1160,9 @@ export function createHeartbeatLifecycle(db: Db, dependencies: HeartbeatLifecycl
.where(eq(heartbeatRuns.id, runId))
.then((rows) => rows[0] ?? null);
const previousStatus = previous?.run ?? null;
if (status === "failed" && patch?.errorCode === "process_lost" && previousStatus?.runtimeMode === "native" && readNativeStartupCleanup(previousStatus)) {
return { updated: false, run: previousStatus };
}
if (previousStatus && (status === "cancelled" || status === "interrupted")) {
patch = { ...patch, resultJson: cancellationResultJson(previousStatus, status, patch?.resultJson, patch?.errorCode, patch?.error) };
@@ -1,5 +1,5 @@
import { randomBytes, randomUUID } from "node:crypto";
import { createDatabaseResponseCapture, installDatabaseResponseCapture } from "@paperclipai/db/response-capture";
import { createFullDatabaseResponseCapture, installDatabaseResponseCapture } from "../../__tests__/helpers/full-database-response-capture.js";
import type { Db } from "@paperclipai/db";
import {
agents,
@@ -186,13 +186,13 @@ describePostgres("heartbeat run state database wiring", () => {
stdoutExcerpt: payload, stderrExcerpt: payload,
}).returning();
const observed = createDb(database.connectionString), events: Array<Record<string, unknown>> = [], now = Date.now();
const capture = createDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event));
const capture = createFullDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event));
installDatabaseResponseCapture(observed, { heartbeatRuns }, capture);
try {
const state = extracted.createHeartbeatRunState(observed);
const read = await state.getRunExecutionWait(companyId, run.id);
capture.flush();
const summary = events.find(event => event.event === "paperclip.db_response_capture.summary.v1")!;
const summary = events.find(event => event.event === "paperclip.test_db_response_capture.summary.v1")!;
expect(summary.completedQueries).toBe(2);
expect(summary.responseValueBytes).toBeLessThan(4096);
expect(read).toMatchObject({ id: run.id, companyId, agentId: agent.id, status: "cancelled", contextSnapshot: context,
@@ -228,7 +228,7 @@ describePostgres("heartbeat run state database wiring", () => {
resultJson: { stdout: payload }, stdoutExcerpt: payload, stderrExcerpt: payload,
}).returning();
const observed = createDb(database.connectionString), events: Array<Record<string, unknown>> = [], now = Date.now();
const capture = createDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event));
const capture = createFullDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event));
installDatabaseResponseCapture(observed, { heartbeatRuns }, capture);
try {
const state = extracted.createHeartbeatRunState(observed);
@@ -236,7 +236,7 @@ describePostgres("heartbeat run state database wiring", () => {
id: run.id, companyId, scopeKind: "issue", issueId: issue.id, executionWorkspaceId: workspaceId,
});
capture.flush();
const summary = events.find(event => event.event === "paperclip.db_response_capture.summary.v1")!;
const summary = events.find(event => event.event === "paperclip.test_db_response_capture.summary.v1")!;
expect(summary.completedQueries).toBe(1);
expect(summary.responseValueBytes).toBeLessThan(1024);
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, run.id)))[0]).toEqual(run);
@@ -260,7 +260,7 @@ describePostgres("heartbeat run state database wiring", () => {
}).returning();
const observed = createDb(database.connectionString);
const events: Array<Record<string, unknown>> = [], now = Date.now();
const capture = createDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event));
const capture = createFullDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event));
installDatabaseResponseCapture(observed, { heartbeatRuns }, capture);
try {
const state = extracted.createHeartbeatRunState(observed);
@@ -270,7 +270,7 @@ describePostgres("heartbeat run state database wiring", () => {
runtimeModeResolvedAt: null, startedAt: run.startedAt, createdAt: run.createdAt,
contextSnapshot: { issueId: issue.id }, runnerProfileJson: { adapterDispatch: { adapterType: "paperclip_runner" } } });
capture.flush();
const summary = events.find(event => event.event === "paperclip.db_response_capture.summary.v1")!;
const summary = events.find(event => event.event === "paperclip.test_db_response_capture.summary.v1")!;
expect(summary.completedQueries).toBe(2);
expect(summary.responseValueBytes).toBeLessThan(2048);
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, run.id)))[0]).toEqual(run);
@@ -321,7 +321,7 @@ describePostgres("heartbeat run state database wiring", () => {
await state.getRunWorkspaceSettlement(run.id);
const events: Array<Record<string, unknown>> = [];
const now = Date.now();
const capture = createDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event));
const capture = createFullDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event));
installDatabaseResponseCapture(db, { heartbeatRuns }, capture);
expect(await state.getRunCleanupState(run.id)).toEqual({
id: run.id, companyId, agentId: agent.id, runtimeMode: run.runtimeMode,
@@ -334,7 +334,7 @@ describePostgres("heartbeat run state database wiring", () => {
contextSnapshot: { timerClaimWasFirstHeartbeat: true },
});
capture.flush();
const summary = events.find(event => event.event === "paperclip.db_response_capture.summary.v1")!;
const summary = events.find(event => event.event === "paperclip.test_db_response_capture.summary.v1")!;
expect(summary.completedQueries).toBe(2);
expect(summary.responseValueBytes).toBeLessThan(2000);
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, run.id)))[0]).toEqual(run);
@@ -1,3 +1,4 @@
import { NativeStartupCleanupPendingError } from "../native-runtime/native-startup-cleanup.js";
import { NativeControllerRecoveryWaitingError } from "../native-runtime/native-controller-recovery.js";
import { usesNativeUsageDelivery } from "../native-runtime/native-usage-accounting.js";
@@ -1072,15 +1073,21 @@ export async function executeHeartbeatRuntime(db: Db, input: HeartbeatRuntimeExe
&& !(nativeExecution && "access" in nativeExecution.workspace && nativeExecution.workspace.access === "none")) {
input.effects.onProviderResourceDisposition("stop_and_retain");
}
await writeExecutionControl(db, DELIVERY_QUEUES.executionFinalization, tx => tx
.update(heartbeatRuns)
.set({ executionControlDeadlineAt: new Date(Date.now() + 60_000) })
.where(
and(
eq(heartbeatRuns.id, run.id),
eq(heartbeatRuns.status, "running"),
),
), { companyId: run.companyId });
try {
await writeExecutionControl(db, DELIVERY_QUEUES.executionFinalization, tx => tx
.update(heartbeatRuns)
.set({ executionControlDeadlineAt: new Date(Date.now() + 60_000) })
.where(
and(
eq(heartbeatRuns.id, run.id),
eq(heartbeatRuns.status, "running"),
),
), { companyId: run.companyId });
} catch (writeError) {
if (adapterErr instanceof NativeStartupCleanupPendingError) throw adapterErr;
throw writeError;
}
if (adapterErr instanceof NativeStartupCleanupPendingError) throw adapterErr;
if (
issueRef &&
context.resumeSessionGoalHeartbeat === true &&
@@ -7,7 +7,7 @@ import { pathToFileURL } from "node:url";
import { promisify } from "node:util";
import { captureDirectorySnapshot, directorySnapshotSha256, serializeDirectorySnapshot } from "@paperclipai/adapter-utils/workspace-restore-merge";
import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
import { agentWakeupRequests, heartbeatRunEvents, companyObservability, issueRecoveryActions, agents, authUsers, companyMemberships, companies, createDb, applyPendingMigrations, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS, environmentLeases, environments, executionWorkspaces, executionWorkspaceRepositoryAccess, heartbeatRuns, issues, issueAccessGrants, nativeRunFinalizations, plugins, projects, projectWorkspaces, type Db } from "@paperclipai/db";
import { agentWakeupRequests, heartbeatRunEvents, companyObservability, issueRecoveryActions, agents, authUsers, companyMemberships, companies, createDb, applyPendingMigrations, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS, environmentLeases, environments, executionWorkspaces, executionWorkspaceRepositoryAccess, heartbeatRuns, issues, issueAccessGrants, nativeRunFinalizations, plugins, projects, projectWorkspaces, workspaceOperations, 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";
@@ -170,15 +170,16 @@ describe.skipIf(!support.supported && !externalDatabaseUrl)("heartbeat workspace
});
it.each([
{ kind: "empty", repoUrl: null, branchMode: null, fresh: false, ephemeral: true },
{ kind: "git", repoUrl: "https://github.com/example/remote.git", branchMode: null, fresh: false },
{ kind: "git", repoUrl: "ssh://git@github.com/example/remote.git", branchMode: null, fresh: false },
{ kind: "git", repoUrl: "git@github.com:example/remote.git", branchMode: null, fresh: false },
{ kind: "git", repoUrl: "https://github.com/example/remote.git", branchMode: "existing", fresh: false },
{ kind: "git", repoUrl: "https://github.com/example/remote.git", branchMode: "create", fresh: false },
{ kind: "git", repoUrl: "https://github.com/example/remote.git", branchMode: "create", fresh: true },
] as const)("reserves a $kind remote workspace ($repoUrl, branch: $branchMode, fresh: $fresh) without materializing a controller checkout", async (scenario) => {
const { kind, repoUrl, branchMode, fresh } = scenario;
{ kind: "empty", repoUrl: null, branchMode: null, fresh: false, strategyChange: false, ephemeral: true },
{ kind: "git", repoUrl: "https://github.com/example/remote.git", branchMode: null, fresh: false, strategyChange: false },
{ kind: "git", repoUrl: "ssh://git@github.com/example/remote.git", branchMode: null, fresh: false, strategyChange: false },
{ kind: "git", repoUrl: "git@github.com:example/remote.git", branchMode: null, fresh: false, strategyChange: false },
{ kind: "git", repoUrl: "https://github.com/example/remote.git", branchMode: "existing", fresh: false, strategyChange: false },
{ kind: "git", repoUrl: "https://github.com/example/remote.git", branchMode: "create", fresh: false, strategyChange: false },
{ kind: "git", repoUrl: "https://github.com/example/remote.git", branchMode: "create", fresh: true, strategyChange: false },
{ kind: "git", repoUrl: "https://github.com/example/remote.git", branchMode: "create", fresh: true, strategyChange: true },
] as const)("reserves a $kind remote workspace ($repoUrl, branch: $branchMode, fresh: $fresh, strategy change: $strategyChange) without materializing a controller checkout", async (scenario) => {
const { kind, repoUrl, branchMode, fresh, strategyChange } = scenario;
const { input, source, issue, envOrchestrator } = await fixture();
const [savedEnvironment] = await db.insert(environments).values({ name: `Remote ${randomUUID()}`,
driver: "sandbox", status: "active", config: { provider: "daytona" } }).returning();
@@ -194,7 +195,7 @@ describe.skipIf(!support.supported && !externalDatabaseUrl)("heartbeat workspace
if (branchMode) {
input.policy.requestedExecutionWorkspaceMode = "isolated_workspace";
input.policy.effectiveExecutionWorkspaceMode = "isolated_workspace";
input.config.mergedConfig = { workspaceStrategy: { type: "git_worktree", baseRef: "main",
input.config.mergedConfig = strategyChange ? { workspaceStrategy: { type: "project_primary" } } : { workspaceStrategy: { type: "git_worktree", baseRef: "main",
...(branchMode === "existing" ? { existingBranch: "feature/diverged" } : { branchTemplate: "test-{{issue.identifier}}" }) } };
}
if (kind === "git") {
@@ -243,9 +244,44 @@ describe.skipIf(!support.supported && !externalDatabaseUrl)("heartbeat workspace
expect(workspace.mode).toBe(branchMode ? "isolated_workspace" : "shared_workspace");
expect(workspace.metadata?.remoteWorkspace).toMatchObject({ source: { kind }, writerRunId: input.run.id });
if (branchMode) expect(workspace.metadata?.remoteWorkspace).toMatchObject({ source: {
ref: "main", branchMode, branchName: branchMode === "existing" ? "feature/diverged" : "test-WSP-1",
ref: "main", branchMode,
...(strategyChange ? {} : { branchName: branchMode === "existing" ? "feature/diverged" : "test-WSP-1" }),
} });
await expect(access(workspace.cwd!)).rejects.toThrow();
if ((fresh || strategyChange) && branchMode === "create") {
expect(workspace.strategyType).toBe("git_worktree");
await db.update(heartbeatRuns).set({ status: "succeeded", finishedAt: new Date() })
.where(eq(heartbeatRuns.id, input.run.id));
const [saved] = await db.update(executionWorkspaces).set({ metadata: {
...workspace.metadata,
remoteWorkspace: { ...workspace.metadata!.remoteWorkspace as object, writerRunId: null, dirty: false },
} }).where(eq(executionWorkspaces.id, workspace.id)).returning();
const [nextRun] = await db.insert(heartbeatRuns).values({ companyId: issue.companyId,
agentId: input.agent.id, issueId: issue.id, scopeKind: "issue", status: "running", runtimeMode: "legacy" }).returning();
input.run = nextRun;
input.task.freshWorkspace = null;
input.task.issueRef = { ...input.task.issueRef!, executionWorkspaceId: saved.id, executionWorkspacePreference: "reuse_existing" };
input.reuse.existingExecutionWorkspace = toExecutionWorkspace(saved);
input.reuse.reusableExistingExecutionWorkspace = toExecutionWorkspace(saved);
input.reuse.requestedShouldReuseExisting = true;
input.reuse.workspaceReuseRequest = resolveExecutionWorkspaceReuseRequestForIssue({
issueExecutionWorkspaceId: saved.id, issueExecutionWorkspacePreference: "reuse_existing", existingExecutionWorkspaceStatus: "active",
});
if (strategyChange) input.config.mergedConfig = { workspaceStrategy: { type: "git_worktree" } };
await expect(prepareHeartbeatWorkspace(db, input)).rejects.toThrow("reached remote acquisition");
const operations = await db.select().from(workspaceOperations).where(eq(workspaceOperations.heartbeatRunId, nextRun.id));
const freshness = operations.filter(operation => operation.phase === "workspace_config_freshness");
if (strategyChange) {
expect(freshness).toHaveLength(1);
expect(freshness[0].metadata).toMatchObject({ action: "replace", changedCategories: ["strategy"] });
} else expect(freshness).toEqual([]);
const [retained] = await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, saved.id));
expect(retained.id).toBe(workspace.id);
expect(retained.metadata?.configFingerprint).toMatchObject({
workspaceHash: (saved.metadata?.configFingerprint as { workspaceHash: string }).workspaceHash,
});
expect(retained.metadata?.remoteWorkspace).toMatchObject({ writerRunId: nextRun.id });
}
} finally {
for (const [key, value] of previous) {
if (value === undefined) delete process.env[key]; else process.env[key] = value;
@@ -543,7 +543,7 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace
const skipHostProvision = copyOnlyProvisioning || hostProvisionDriver === "sandbox" || hostProvisionDriver === "ssh" || hostProvisionDriver === "plugin";
const hostExecutionWorkspaceConfig = isDotRun ? {} :
stripHostWorkspaceProvision({
config: fresh ? { ...mergedConfig, workspaceStrategy: { type: "project_primary" } } : mergedConfig,
config: fresh && !remoteAuthority ? { ...mergedConfig, workspaceStrategy: { type: "project_primary" } } : mergedConfig,
skipHostProvision,
});
const executionWorkspaceBase = {
@@ -0,0 +1,37 @@
import { sql, type SQL } from "drizzle-orm";
import { issues } from "@paperclipai/db";
import { PROVIDER_QUOTA_MONITOR_SERVICE_NAME, type IssueDisplayStatus } from "@paperclipai/shared";
/** Read-only projection, shared by task reads, search, filters and counts.
* The persisted deadline is the scheduler's source of truth. Locks and recovery
* take precedence; a retained monitor alone must not disguise active work.
*/
export function issueDisplayStatusSql(): SQL<IssueDisplayStatus> {
// Drizzle removes column qualifiers in a single-table SELECT. Keep these
// outer references explicitly qualified so correlated reads cannot bind to
// the run/recovery table's own id or company_id instead.
const issueId = sql`"issues"."id"`;
const companyId = sql`"issues"."company_id"`;
return sql<IssueDisplayStatus>`CASE WHEN
${issues.status} = 'in_progress'
AND ${issues.assigneeAgentId} IS NOT NULL AND ${issues.assigneeUserId} IS NULL
AND ${issues.monitorNextCheckAt} IS NOT NULL
AND ${issues.executionRunId} IS NULL AND ${issues.checkoutRunId} IS NULL
AND ${issues.executionState}->>'currentParticipant' IS NULL
AND COALESCE(${issues.executionState}#>>'{monitor,status}', 'scheduled') = 'scheduled'
AND ${issues.executionPolicy}->'monitor' IS NOT NULL
AND COALESCE(${issues.executionPolicy}#>>'{monitor,serviceName}', '') <> ${PROVIDER_QUOTA_MONITOR_SERVICE_NAME}
AND NOT EXISTS (
SELECT 1 FROM heartbeat_runs display_run
WHERE display_run.company_id = ${companyId}
AND display_run.issue_id = ${issueId}
AND display_run.status IN ('queued', 'running')
)
AND NOT EXISTS (
SELECT 1 FROM issue_recovery_actions display_recovery
WHERE display_recovery.company_id = ${companyId}
AND display_recovery.source_issue_id = ${issueId}
AND display_recovery.status IN ('active', 'escalated')
)
THEN 'monitoring' ELSE ${issues.status} END`;
}
+2
View File
@@ -1,3 +1,4 @@
import { issueDisplayStatusSql } from "./issue-display-status.js";
import { and, eq, inArray, isNull, type SQL } from "drizzle-orm";
import { issues, type Db } from "@paperclipai/db";
import { ISSUE_SUMMARIES_MAX_IDS } from "@paperclipai/shared";
@@ -18,6 +19,7 @@ export async function listIssueSummaries(
title: issues.title,
identifier: issues.identifier,
status: issues.status,
displayStatus: issueDisplayStatusSql(),
updatedAt: issues.updatedAt,
externalConversationState: externalConversationStateSql(),
projectId: issues.projectId,
+55 -9
View File
@@ -1,3 +1,5 @@
import type { IssueDisplayStatus } from "@paperclipai/shared";
import { issueDisplayStatusSql } from "./issue-display-status.js";
import { notifyStrandedAgentStatus, notifyActiveRunSilence } from "./live-run-recovery-notifications.js";
import { finishedIdleWake } from "./idle-work-proof.js";
import { notifyDeferredExecutionEligibility } from "./deferred-execution-notifications.js";
@@ -1900,6 +1902,7 @@ export function parseStatusFilter(
export interface IssueFilters {
attention?: "blocked";
status?: string | readonly string[];
displayStatus?: string | readonly string[];
/**
* Filter by assignee agent ID.
* - `string` (UUID): match issues assigned to that agent.
@@ -1944,14 +1947,14 @@ export interface IssueFilters {
q?: string;
limit?: number;
offset?: number;
sortField?: "updated" | "id";
sortField?: "updated" | "created" | "id";
afterId?: string;
sortDir?: "asc" | "desc";
/** ISO 8601 timestamp — only return issues with updatedAt strictly after this value. */
updatedSince?: string;
}
type IssueRow = typeof issues.$inferSelect & { externalConversationState?: "active" | "waiting" | null };
type IssueRow = typeof issues.$inferSelect & { displayStatus?: IssueDisplayStatus; externalConversationState?: "active" | "waiting" | null };
type IssueLabelRow = typeof labels.$inferSelect;
type IssuePlanDecompositionRow = typeof issuePlanDecompositions.$inferSelect;
type IssueActiveRunRow = {
@@ -2559,12 +2562,13 @@ function blockerWorkspaceFacts(companyId: string, workspaceScope: SQL) {
}
/** An issue's own operation takes precedence over the legacy NULL fallback.
* Only a strictly later successful finalize clears either barrier. */
* A later successful finalize clears either barrier. A successful operation
* enclosed by its owning finalize is also saved, although it started later. */
function pendingFinalizeBlockerCondition(companyId: string, issueId: SQL, workspaceId: SQL) {
return sql`${workspaceId} is not null and exists (
select 1 from (values (1)) seed(value)
left join lateral (
select phase, status, started_at from workspace_operations blocker_operation
select phase, status, started_at, finished_at, heartbeat_run_id from workspace_operations blocker_operation
where blocker_operation.company_id = ${companyId}::uuid
and blocker_operation.execution_workspace_id = ${workspaceId}
and blocker_operation.issue_id = ${issueId}
@@ -2575,6 +2579,20 @@ function pendingFinalizeBlockerCondition(companyId: string, issueId: SQL, worksp
and coalesce(latest_blocker_operation.status, blocker_workspace_facts.status) = 'succeeded')
and (blocker_workspace_facts.finalized_at is null
or blocker_workspace_facts.finalized_at <= coalesce(latest_blocker_operation.started_at, blocker_workspace_facts.started_at))
and not (
latest_blocker_operation.status = 'succeeded'
and latest_blocker_operation.finished_at is not null
and exists (
select 1 from workspace_operations owning_finalize
where owning_finalize.company_id = ${companyId}::uuid
and owning_finalize.execution_workspace_id = ${workspaceId}
and owning_finalize.issue_id = ${issueId}
and owning_finalize.heartbeat_run_id = latest_blocker_operation.heartbeat_run_id
and owning_finalize.phase = 'workspace_finalize' and owning_finalize.status = 'succeeded'
and owning_finalize.started_at <= latest_blocker_operation.started_at
and owning_finalize.finished_at >= latest_blocker_operation.finished_at
)
)
)`;
}
@@ -2929,7 +2947,7 @@ function myLastTouchAtExpr(companyId: string, userId: string) {
COALESCE(${myLastCommentAt}, to_timestamp(0)),
COALESCE(${myLastReadAt}, to_timestamp(0)),
COALESCE(CASE WHEN ${issues.createdByUserId} = ${userId} AND ${humanCreatedIssueCondition()} THEN ${issues.createdAt} ELSE NULL END, to_timestamp(0)),
COALESCE(CASE WHEN ${issues.assigneeUserId} = ${userId} THEN ${issues.updatedAt} ELSE NULL END, to_timestamp(0))
COALESCE(CASE WHEN ${issues.assigneeUserId} = ${userId} THEN ${issues.createdAt} ELSE NULL END, to_timestamp(0))
)
`;
}
@@ -2996,6 +3014,7 @@ function unreadForUserCondition(companyId: string, userId: string) {
FROM ${issueComments}
WHERE ${issueComments.issueId} = ${issues.id}
AND ${issueComments.companyId} = ${companyId}
AND ${issueComments.deletedAt} IS NULL
AND (
${issueComments.authorUserId} IS NULL
OR ${issueComments.authorUserId} <> ${userId}
@@ -3118,8 +3137,10 @@ export function deriveIssueUserContext(
issue.createdByUserId === userId && !isAgentCreatedIssue(issue)
? normalizeDate(issue.createdAt)
: null;
// Assignment establishes inbox participation, not a read. Other people's
// comments also advance updatedAt, so only creation is a stable fallback.
const assignedTouchAt =
issue.assigneeUserId === userId ? normalizeDate(issue.updatedAt) : null;
issue.assigneeUserId === userId ? normalizeDate(issue.createdAt) : null;
const myLastTouchAt =
[myLastCommentAt, myLastReadAt, createdTouchAt, assignedTouchAt]
.filter((value): value is Date => value instanceof Date)
@@ -3218,6 +3239,11 @@ function issueListOrderBy(
},
) {
if (sortField === "id") return [sortDir === "desc" ? desc(issues.id) : asc(issues.id)];
if (sortField === "created") {
return sortDir === "asc"
? [asc(issues.createdAt), asc(issues.id)]
: [desc(issues.createdAt), desc(issues.id)];
}
const canonicalLastActivityAt = issueCanonicalLastActivityAtExpr(companyId);
if (sortField === "updated") {
const activityOrder =
@@ -3603,6 +3629,7 @@ function appendBlockerAttentionEdges(
}
type IssueRelationSummaryRow = {
displayStatus?: IssueDisplayStatus;
relatedId: string;
identifier: string | null;
title: string;
@@ -3620,6 +3647,7 @@ function summarizeIssueRelationRow(
identifier: row.identifier,
title: row.title,
status: row.status as IssueRelationIssueSummary["status"],
displayStatus: row.displayStatus,
priority: row.priority as IssueRelationIssueSummary["priority"],
assigneeAgentId: row.assigneeAgentId,
assigneeUserId: row.assigneeUserId,
@@ -3735,6 +3763,7 @@ async function terminalExplicitBlockersByRoot(
identifier: issues.identifier,
title: issues.title,
status: issues.status,
displayStatus: issueDisplayStatusSql(),
priority: issues.priority,
assigneeAgentId: issues.assigneeAgentId,
assigneeUserId: issues.assigneeUserId,
@@ -5039,6 +5068,7 @@ const issueListSelect = {
END
`,
status: issues.status,
displayStatus: issueDisplayStatusSql(),
statusVersion: issues.statusVersion,
lastStatusDecisionId: issues.lastStatusDecisionId,
workMode: issues.workMode,
@@ -5124,7 +5154,8 @@ async function userCommentStatsForIssues(
lastExternalCommentAt: sql<Date | null>`
MAX(
CASE
WHEN ${issueComments.authorUserId} IS NULL OR ${issueComments.authorUserId} <> ${userId}
WHEN ${issueComments.deletedAt} IS NULL
AND (${issueComments.authorUserId} IS NULL OR ${issueComments.authorUserId} <> ${userId})
THEN ${issueComments.createdAt}
END
)
@@ -5279,6 +5310,7 @@ async function blockedByMapForIssues(
identifier: issues.identifier,
title: issues.title,
status: issues.status,
displayStatus: issueDisplayStatusSql(),
priority: issues.priority,
assigneeAgentId: issues.assigneeAgentId,
assigneeUserId: issues.assigneeUserId,
@@ -5301,6 +5333,7 @@ async function blockedByMapForIssues(
identifier: row.identifier,
title: row.title,
status: row.status as IssueRelationIssueSummary["status"],
displayStatus: row.displayStatus,
priority: row.priority as IssueRelationIssueSummary["priority"],
assigneeAgentId: row.assigneeAgentId,
assigneeUserId: row.assigneeUserId,
@@ -6420,6 +6453,8 @@ async function blockedInboxIssueConditions(
filters?.lowTrustBoundary,
);
if (lowTrustCondition) conditions.push(lowTrustCondition);
const displayStatuses = parseStatusFilter(filters?.displayStatus);
if (displayStatuses.length) conditions.push(inArray(issueDisplayStatusSql(), displayStatuses));
const statuses = parseStatusFilter(filters?.status);
if (statuses.length > 0) {
conditions.push(
@@ -6872,7 +6907,7 @@ export function issueService(db: Db) {
async function getIssueByUuid(id: string) {
const row = await retryIdempotentDatabaseOperation(() => db
.select({ ...getTableColumns(issues), externalConversationState: externalConversationStateSql() })
.select({ ...getTableColumns(issues), displayStatus: issueDisplayStatusSql(), externalConversationState: externalConversationStateSql() })
.from(issues)
.where(eq(issues.id, id))
.then((rows) => rows[0] ?? null));
@@ -6883,7 +6918,7 @@ export function issueService(db: Db) {
async function getIssueByIdentifier(identifier: string) {
const row = await retryIdempotentDatabaseOperation(() => db
.select({ ...getTableColumns(issues), externalConversationState: externalConversationStateSql() })
.select({ ...getTableColumns(issues), displayStatus: issueDisplayStatusSql(), externalConversationState: externalConversationStateSql() })
.from(issues)
.where(eq(issues.identifier, identifier.toUpperCase()))
.then((rows) => rows[0] ?? null));
@@ -7482,6 +7517,7 @@ export function issueService(db: Db) {
identifier: issues.identifier,
title: issues.title,
status: issues.status,
displayStatus: issueDisplayStatusSql(),
priority: issues.priority,
assigneeAgentId: issues.assigneeAgentId,
assigneeUserId: issues.assigneeUserId,
@@ -7502,6 +7538,7 @@ export function issueService(db: Db) {
identifier: issues.identifier,
title: issues.title,
status: issues.status,
displayStatus: issueDisplayStatusSql(),
priority: issues.priority,
assigneeAgentId: issues.assigneeAgentId,
assigneeUserId: issues.assigneeUserId,
@@ -8193,6 +8230,9 @@ export function issueService(db: Db) {
if (filters?.sortField === "id" && filters.attention) {
throw unprocessable("ID ordering is not supported for blocked attention lists");
}
if (filters?.sortField === "created" && filters.attention) {
throw unprocessable("Creation-time ordering is not supported for blocked attention lists");
}
if (filters?.afterId !== undefined && (
!isUuidLike(filters.afterId) || filters.sortField !== "id" ||
filters.sortDir !== "asc" || (filters.offset ?? 0) !== 0
@@ -8281,6 +8321,8 @@ export function issueService(db: Db) {
filters?.lowTrustBoundary,
);
if (lowTrustCondition) conditions.push(lowTrustCondition);
const displayStatuses = parseStatusFilter(filters?.displayStatus);
if (displayStatuses.length) conditions.push(inArray(issueDisplayStatusSql(), displayStatuses));
const statuses = parseStatusFilter(filters?.status);
if (statuses.length === 1) {
conditions.push(eq(issues.status, statuses[0]));
@@ -8574,6 +8616,8 @@ export function issueService(db: Db) {
conditions.push(nonIdleSlackIssueCondition());
}
}
const displayStatuses = parseStatusFilter(filters?.displayStatus);
if (displayStatuses.length) conditions.push(inArray(issueDisplayStatusSql(), displayStatuses));
const statuses = parseStatusFilter(filters?.status);
if (statuses.length === 1)
conditions.push(eq(issues.status, statuses[0]!));
@@ -8895,6 +8939,7 @@ export function issueService(db: Db) {
identifier: issues.identifier,
title: issues.title,
status: issues.status,
displayStatus: issueDisplayStatusSql(),
priority: issues.priority,
assigneeAgentId: issues.assigneeAgentId,
assigneeUserId: issues.assigneeUserId,
@@ -9522,6 +9567,7 @@ export function issueService(db: Db) {
identifier: issues.identifier,
title: issues.title,
status: issues.status,
displayStatus: issueDisplayStatusSql(),
priority: issues.priority,
assigneeAgentId: issues.assigneeAgentId,
assigneeUserId: issues.assigneeUserId,
@@ -1,5 +1,5 @@
import { heartbeatRuns, type Db } from "@paperclipai/db";
import { createDatabaseResponseCapture, installDatabaseResponseCapture } from "@paperclipai/db/response-capture";
import { createFullDatabaseResponseCapture, installDatabaseResponseCapture } from "../../../../__tests__/helpers/full-database-response-capture.js";
import { eq } from "drizzle-orm";
import { expect } from "vitest";
@@ -21,11 +21,11 @@ export async function verifyCommittedReplayPayloadReads(
const [before] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
const events: Array<Record<string, unknown>> = [];
const now = Date.now();
const capture = createDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event));
const capture = createFullDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event));
installDatabaseResponseCapture(db, { heartbeatRuns }, capture);
try { await replay(); }
finally { capture.flush(); }
const summary = events.find(event => event.event === "paperclip.db_response_capture.summary.v1")!;
const summary = events.find(event => event.event === "paperclip.test_db_response_capture.summary.v1")!;
expect(summary.completedQueries).toBeGreaterThan(0);
expect(summary.responseValueBytes).toBeLessThan(100_000);
const [after] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
import { and, eq, sql } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { agents, authUsers, companies, completionContracts, createDb, heartbeatRunEvents, heartbeatRuns, issueComments, issues,
nativeRunFinalizations, nativeRunResults, chatTaskHandoffs, chatCompletionDeliveries, agentWakeupRequests, type Db } from "@paperclipai/db";
nativeRunFinalizations, nativeRunResults, issueThreadInteractions, chatTaskHandoffs, chatCompletionDeliveries, agentWakeupRequests, type Db } from "@paperclipai/db";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js";
import { resolveExternalTestDatabaseUrl } from "../../__tests__/helpers/external-test-database.js";
import { NativeRunCoordinatorStore } from "./native-run-coordinator-store.js";
@@ -26,7 +26,7 @@ describe.skipIf(!support.supported)("committed Agent Chat reply recovery", () =>
}, 60_000);
afterAll(async () => { await db?.$client.end(); await database?.cleanup(); });
async function fixture(conversation = true, options: { succeed?: boolean } = {}) {
async function fixture(conversation = true, options: { succeed?: boolean; disposition?: "done" | "needs_review" } = {}) {
const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(), runId = randomUUID();
const sessionId = randomUUID(), runnerId = randomUUID(), userId = `fixture-${randomUUID()}`, wakeId = randomUUID();
await db.insert(authUsers).values({ id: userId, name: "Conversation owner", email: `${userId}@example.test`,
@@ -53,10 +53,12 @@ describe.skipIf(!support.supported)("committed Agent Chat reply recovery", () =>
const prpEvent = { runId, normalizedSessionId: sessionId, turnId: "hire-turn", sourceEventId: "fixture-final-answer",
payload: { kind: "agentMessage", channel: "final", providerPhase: "final", text: reply } };
await db.insert(heartbeatRunEvents).values({ companyId, runId, agentId, seq: 170, eventType: "item.completed", payload: { prpEvent } });
await port.completeRun({ result: { schema: "paperclip.run_result.v1", reportedWorkDisposition: "done",
const disposition = options.disposition ?? "done";
await port.completeRun({ result: { schema: "paperclip.run_result.v1", reportedWorkDisposition: disposition,
summary: "A completed hire, with its final message preserved separately.", completionClaim: { contractRevision: "test",
objectiveSatisfied: true, criteria: [], remainingWork: [] }, evidence: [], verification: [], attentionRequests: [], artifacts: [] },
terminal: { schema: "paperclip.prp.terminal.v1", runTerminalState: "succeeded", turnTerminalState: "completed", reportedWorkDisposition: "done" }, turnId: "hire-turn" });
objectiveSatisfied: true, criteria: [], remainingWork: [] }, evidence: [], verification: [],
attentionRequests: disposition === "needs_review" ? [{ kind: "review", ownerClass: "human", summary: "Review the completed work." }] : [], artifacts: [] },
terminal: { schema: "paperclip.prp.terminal.v1", runTerminalState: "succeeded", turnTerminalState: "completed", reportedWorkDisposition: disposition }, turnId: "hire-turn" });
await finalizeNativeRun({ db, runId, workspaceFinalizeStatus: "succeeded", projectRunStatus: false });
expect((await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, runId)))[0].phase).toBe("committed");
if (options.succeed !== false) await db.update(heartbeatRuns).set({ status: "succeeded", finishedAt: new Date("2020-01-01T00:02:00Z") }).where(eq(heartbeatRuns.id, runId));
@@ -641,6 +643,38 @@ describe.skipIf(!support.supported)("committed Agent Chat reply recovery", () =>
expect(await comments(f.runId)).toEqual([expect.objectContaining({ body: f.reply })]);
expect((await issue(f.issueId)).conversationAgentId).toBeNull();
});
it("recovers a deferred review reply once without resolving the review or rerunning work", async () => {
const f = await fixture(false, { disposition: "needs_review" });
const before = await issue(f.issueId);
const reviews = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.issueId, f.issueId));
expect(before.status).toBe("in_review");
expect(reviews).toEqual([expect.objectContaining({ status: "pending" })]);
expect(await comments(f.runId)).toEqual([]);
const work = nativeResponsePresentationWork(db), entries = await work.load(f.input);
expect(entries).toHaveLength(1);
await expect(work.check(entries[0])).resolves.toBeNull();
expect(await comments(f.runId)).toEqual([expect.objectContaining({ body: f.reply, createdByRunId: f.runId })]);
expect(await issue(f.issueId)).toEqual({ ...before, updatedAt: expect.any(Date) });
expect(await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.issueId, f.issueId))).toEqual(reviews);
expect(await work.load(f.input)).toEqual([]);
await expect(recoverCommittedNativeChatResponse(db, f.input)).resolves.toBe(false);
expect(await comments(f.runId)).toHaveLength(1);
expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.nativeIssueId, f.issueId))).toHaveLength(1);
});
it.each(["missing-final", "wrong-turn", "digest", "skip-comment"])(
"keeps deferred review response recovery closed for %s", async fault => {
const f = await fixture(false, { disposition: "needs_review" });
if (fault === "missing-final") await db.delete(heartbeatRunEvents).where(eq(heartbeatRunEvents.runId, f.runId));
if (fault === "wrong-turn") await db.update(heartbeatRunEvents).set({ payload: { prpEvent: { ...f.prpEvent, turnId: "another-turn" } } })
.where(and(eq(heartbeatRunEvents.runId, f.runId), eq(heartbeatRunEvents.seq, 170)));
if (fault === "digest") await db.update(nativeRunResults).set({ canonicalSha256: "invalid-digest" }).where(eq(nativeRunResults.runId, f.runId));
if (fault === "skip-comment") await db.update(heartbeatRuns).set({ contextSnapshot: { issueId: f.issueId, skipIssueComment: true } }).where(eq(heartbeatRuns.id, f.runId));
const before = await issue(f.issueId);
await expect(recoverCommittedNativeChatResponse(db, f.input)).resolves.toBe(false);
expect(await comments(f.runId)).toEqual([]);
expect(await issue(f.issueId)).toEqual(before);
},
);
it("rolls back a partial reply write without changing the waiting state or consuming recovery", async () => {
const f = await fixture(), abort = new Error("fixture-commit-aborted");
const failing = new Proxy(db, { get(target, key) { return key === "transaction"
@@ -19,6 +19,50 @@ describe("assigned MCP runner tools", () => {
const searchName = "paperclip_search_assigned_tools";
const callName = "paperclip_call_assigned_tool";
it.each(["", " \t\n"])("discovers and calls assigned tools through MCP with a blank schema reference %j", async schemaTool => {
const github = descriptor("github_bot:read_pull_request");
const f = fixture([github, descriptor("calendar.search")]);
const assigned = await createAssignedMcpTools({ ...f, delivery: "on_demand" });
const bridge = await startOpenCodeMcpBridge({ tools: assigned.definitions(), handler: call => assigned.execute(call) });
let id = 0;
const call = async (name: string, args: Record<string, unknown>) => {
const response = await fetch(bridge.url, {
method: "POST", headers: { Authorization: `Bearer ${bridge.secret}`, "Content-Type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", id: ++id, method: "tools/call", params: { name, arguments: args } }),
});
const result = (await response.json()).result;
expect(result.isError).not.toBe(true);
return JSON.parse(result.content[0].text);
};
try {
const page = await call(searchName, { query: "github", schemaTool });
expect(page.tools).toHaveLength(1);
expect(page.tools[0]).toMatchObject({ description: expect.stringContaining(github.name), inputSchema: github.parametersSchema });
expect(await call(searchName, { query: "github", schemaTool, schemaOffset: 0 })).toEqual(page);
expect((await call(searchName, { query: "", schemaTool })).tools).toHaveLength(2);
await call(callName, { name: page.tools[0].name, arguments: { query: "fixture" } });
expect(f.executeTool).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({
tool: github.name, parameters: { query: "fixture" }, gatewayPublicId: f.gatewayPublicId, sessionToken: f.bearerToken,
}));
f.listToolsForNamedGateway.mockResolvedValue([]);
expect(await call(searchName, { query: "github", schemaTool })).toEqual({ tools: [], nextOffset: null });
} finally {
await bridge.close();
}
});
it("explains how to recover from an unknown schema reference or an offset without a reference", async () => {
const f = fixture([descriptor("github_bot:read_pull_request")]);
const assigned = await createAssignedMcpTools({ ...f, delivery: "on_demand" });
await expect(assigned.execute({ tool: searchName, arguments: { query: "github", schemaTool: "unknown" } }))
.rejects.toThrow("Omit schemaTool to search assigned tools");
for (const schemaTool of [undefined, "", " \t\n"]) {
await expect(assigned.execute({ tool: searchName, arguments: { query: "github", schemaTool, schemaOffset: 1 } }))
.rejects.toThrow("A positive schemaOffset requires an exact inputSchemaRef in schemaTool");
}
expect(f.executeTool).not.toHaveBeenCalled();
});
it.each([undefined, "on_demand"] as const)("advertises usable page limits through MCP discovery (%s)", async delivery => {
const f = fixture(Array.from({ length: 25 }, (_, i) => descriptor(`operation_${i}`)));
const assigned = await createAssignedMcpTools({ ...f, delivery });
@@ -21,8 +21,8 @@ const ON_DEMAND_TOOLS: ToolDefinition[] = [
query: { type: "string", maxLength: 200 },
offset: { type: "integer", minimum: 0 },
limit: { type: "integer", minimum: 1, maximum: 20, default: 5, description: "Maximum results per page (1 to 20; default 5)." },
schemaTool: { type: "string", description: "Exact inputSchemaRef from a search result." },
schemaOffset: { type: "integer", minimum: 0 },
schemaTool: { type: "string", description: "Exact inputSchemaRef from a search result. Omit or leave blank for a normal tool search." },
schemaOffset: { type: "integer", minimum: 0, description: "Schema chunk offset. A positive offset requires an exact inputSchemaRef in schemaTool." },
},
required: ["query"],
},
@@ -116,14 +116,17 @@ export async function createAssignedMcpTools(input: {
const offset = args.offset ?? 0;
const limit = args.limit ?? 5;
const schemaOffset = args.schemaOffset ?? 0;
const hasSchemaTool = typeof args.schemaTool === "string" && args.schemaTool.trim().length > 0;
if (typeof args.query !== "string" || args.query.length > 200 ||
typeof offset !== "number" || !Number.isSafeInteger(offset) || offset < 0 ||
typeof limit !== "number" || !Number.isSafeInteger(limit) || limit < 1 || limit > 20 ||
(args.schemaTool !== undefined && typeof args.schemaTool !== "string") ||
typeof schemaOffset !== "number" || !Number.isSafeInteger(schemaOffset) || schemaOffset < 0 ||
(args.schemaOffset !== undefined && args.schemaTool === undefined)) {
typeof schemaOffset !== "number" || !Number.isSafeInteger(schemaOffset) || schemaOffset < 0) {
throw new Error("assigned_mcp_tool_invalid_arguments");
}
if (schemaOffset > 0 && !hasSchemaTool) {
throw new Error("assigned_mcp_tool_invalid_arguments: A positive schemaOffset requires an exact inputSchemaRef in schemaTool. Omit schemaTool and schemaOffset to search assigned tools.");
}
// Intersect fresh grants with this session's pinned catalog. Revocations
// take effect immediately; newly assigned tools require a new session.
const current = new Map(deduplicateToolDescriptors(await input.gateway.listToolsForNamedGateway({
@@ -135,9 +138,9 @@ export async function createAssignedMcpTools(input: {
return fresh && permits(tool) && permits(tool, currentWorkMode) &&
permits(fresh) && permits(fresh, currentWorkMode);
});
if (typeof args.schemaTool === "string") {
if (hasSchemaTool) {
const selected = authorized.find(([name]) => name === args.schemaTool);
if (!selected) throw new Error("assigned_mcp_tool_unknown");
if (!selected) throw new Error("assigned_mcp_tool_unknown: schemaTool must match an authorized inputSchemaRef. Omit schemaTool to search assigned tools, then use an exact inputSchemaRef from the results.");
const schema = JSON.stringify(selected[1].parametersSchema);
const end = schemaOffset + schemaChunkCharacters;
return {
@@ -29,7 +29,7 @@ import {
toolApplications,
toolConnections,
} from "@paperclipai/db";
import { createDatabaseResponseCapture, installDatabaseResponseCapture } from "@paperclipai/db/response-capture";
import { createFullDatabaseResponseCapture, installDatabaseResponseCapture } from "../../__tests__/helpers/full-database-response-capture.js";
import { startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js";
import { createLocalDiskStorageProvider } from "../../storage/local-disk-provider.js";
@@ -322,7 +322,7 @@ describe("native same-conversation chat attachment reuse", () => {
const deliveries = await db.select().from(chatDeliveries).where(eq(chatDeliveries.endpointId, endpointId));
const bulk = randomBytes(64 * 1024).toString("base64");
const events: Array<Record<string, unknown>> = [];
let capture: ReturnType<typeof createDatabaseResponseCapture> | undefined;
let capture: ReturnType<typeof createFullDatabaseResponseCapture> | undefined;
try {
await db.update(chatEndpoints).set({ communicationInstructions: bulk,
setup: { ...endpoint.setup, unusedContent: bulk } as typeof endpoint.setup,
@@ -335,7 +335,7 @@ describe("native same-conversation chat attachment reuse", () => {
.where(eq(chatDeliveries.id, delivery.id));
}
const now = Date.now();
capture = createDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event));
capture = createFullDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event));
installDatabaseResponseCapture(db, { chatMessageLinks, chatDeliveries, chatConversations, chatEndpoints,
chatEndpointResources, chatExternalPrincipals, chatIdentityLinks, companyMemberships }, capture);
for (const lockMode of ["read", "blocking", "nonblocking"] as const) {
@@ -8,7 +8,7 @@ import { tmpdir } from "node:os";
import path from "node:path";
import { and, eq, ne, sql } from "drizzle-orm";
import { createDatabaseResponseCapture, installDatabaseResponseCapture } from "@paperclipai/db/response-capture";
import { createFullDatabaseResponseCapture, installDatabaseResponseCapture } from "../../__tests__/helpers/full-database-response-capture.js";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import {
agentWakeupRequests,
@@ -922,7 +922,7 @@ describe("native external-chat response wait", () => {
await authorizeChatConversationForBoundRun(db, fixture, contextSnapshot, "read");
const observed = createDb(temporary?.connectionString ?? externalTestDatabaseUrl!);
const events: Array<Record<string, unknown>> = [], now = Date.now();
const capture = createDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event));
const capture = createFullDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event));
installDatabaseResponseCapture(observed, { issues, heartbeatRuns }, capture);
try {
expect(await listExternalReviewWaits(observed, fixture.companyId, [fixture.issueId])).toEqual(governed ? [] : [
@@ -5462,7 +5462,7 @@ describe("native external-chat response wait", () => {
if (reply) await db.update(chatActions).set({ result: { ...reply.result, savedProviderOutput: payload } }).where(eq(chatActions.id, reply.id));
const observed = createDb(temporary?.connectionString ?? externalTestDatabaseUrl!);
const events: Array<Record<string, unknown>> = [], now = Date.now();
const capture = createDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event));
const capture = createFullDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event));
installDatabaseResponseCapture(observed, { heartbeatRuns, chatActions }, capture);
try {
const context = await new PaperclipRunnerToolAuthority(observed, fixture)
@@ -1,4 +1,4 @@
import { randomUUID } from "node:crypto";
import { createHash, randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { agents, companies, createDb, documentRevisions, executionWorkspaces, executionWorkspaceRepositories, heartbeatRuns, issues, issueThreadInteractions, issueTreeHolds, issueDocuments } from "@paperclipai/db";
@@ -19,11 +19,11 @@ describe("native final-response feedback", () => {
let db: ReturnType<typeof createDb>;
beforeAll(async () => { temporary = await startRecoveryPostgres("native-final-response-"); db = createDb(temporary.connectionString); });
afterAll(async () => { await temporary?.cleanup(); });
async function fixture() {
const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(), runId = randomUUID();
await db.insert(companies).values({ id: companyId, name: "Feedback", issuePrefix: `FB${companyId.slice(0, 8).toUpperCase()}` });
async function fixture(existingCompanyId?: string) {
const companyId = existingCompanyId ?? randomUUID(), agentId = randomUUID(), issueId = randomUUID(), runId = randomUUID();
if (!existingCompanyId) await db.insert(companies).values({ id: companyId, name: "Feedback", issuePrefix: `FB${companyId.slice(0, 8).toUpperCase()}` });
await db.insert(agents).values({ id: agentId, companyId, name: "Worker", adapterType: "paperclip_runner", status: "active" });
await db.insert(issues).values({ id: issueId, companyId, identifier: `FB${companyId.slice(0, 8).toUpperCase()}-1`, title: "Save the task document", status: "in_progress", assigneeAgentId: agentId });
await db.insert(issues).values({ id: issueId, companyId, identifier: `FB${companyId.slice(0, 8).toUpperCase()}-${existingCompanyId ? 2 : 1}`, title: "Save the task document", status: "in_progress", assigneeAgentId: agentId });
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, nativeIssueId: issueId, status: "running", runtimeMode: "native", contextSnapshot: { issueId } });
await db.update(issues).set({ executionRunId: runId }).where(eq(issues.id, issueId));
const authority = new PaperclipRunnerToolAuthority(db, { companyId, agentId, issueId, runId });
@@ -32,6 +32,17 @@ describe("native final-response feedback", () => {
} }) as { document: { id: string; latestRevisionId: string }; documentHref: string };
return { companyId, agentId, issueId, runId, saved };
}
async function publishedFileReport(value: Awaited<ReturnType<typeof fixture>>, objective = "Save a document on this task. Export the results as a CSV."): Promise<PrpStructuredRunResult> {
await db.update(issues).set({ description: objective }).where(eq(issues.id, value.issueId));
const bytes = Buffer.from("category,total\nverified,3\n");
const authority = new PaperclipRunnerToolAuthority(db, { companyId: value.companyId, agentId: value.agentId,
issueId: value.issueId, runId: value.runId, workspaceRoot: "/workspace/test", executionTargetKind: "remote", readRemoteTaskFile: async () => bytes });
const receipt = await authority.execute({ tool: "register_deliverable", callId: "csv", arguments: {
idempotencyKey: "csv", filename: "results.csv", contentType: "text/csv", contentRef: "results.csv",
byteSize: bytes.length, sha256: createHash("sha256").update(bytes).digest("hex"), title: "Results CSV",
} }) as { downloadPath: string };
return { ...done, evidence: [{ ref: `${receipt.downloadPath}#download` }] };
}
it("rejects a duplicate approval report for the exact pending tool-action card", async () => {
const value = await fixture(), interactionId = randomUUID();
await db.insert(issueThreadInteractions).values({ id: interactionId, companyId: value.companyId, issueId: value.issueId,
@@ -211,6 +222,55 @@ describe("native final-response feedback", () => {
expect(feedback).toContain("When compatible with the requested response format");
expect(feedback).not.toContain("publish secrets");
});
it.each(["evidence", "artifacts", "criterion"] as const)("accepts its generated saved-document link in %s alongside a published file", async location => {
const value = await fixture();
const report = await publishedFileReport(value);
const feedback = await nativeCompletionFeedback(db, value.runId, report);
const href = /\[Saved document\]\(([^)]+)\)/u.exec(feedback)?.[1];
expect(href).toBe(value.saved.documentHref);
const referenced = location === "evidence" ? { ...report, evidence: [...report.evidence, { ref: href! }] }
: location === "artifacts" ? { ...report, artifacts: [{ kind: "document", ref: href! }] }
: { ...report, completionClaim: { ...report.completionClaim, criteria: [{ criterionId: "document", status: "satisfied" as const, evidenceRefs: [href!] }] } };
await expect(nativeCompletionFeedback(db, value.runId, referenced))
.resolves.toContain("Completion report accepted");
});
it("accepts its generated document guidance link when the objective requests only a CSV", async () => {
const value = await fixture();
await db.update(issues).set({ title: "Export the results as a CSV" }).where(eq(issues.id, value.issueId));
const report = await publishedFileReport(value, "Export the results as a CSV.");
const feedback = await nativeCompletionFeedback(db, value.runId, report);
const href = /\[Saved document\]\(([^)]+)\)/u.exec(feedback)?.[1];
expect(href).toBe(value.saved.documentHref);
await expect(nativeCompletionFeedback(db, value.runId, {
...report, evidence: [...report.evidence, { ref: href! }],
})).resolves.toContain("Completion report accepted");
});
it("retains foreign task, foreign company, bogus link, and local file guards", async () => {
const current = await fixture(), otherTask = await fixture(current.companyId), otherCompany = await fixture();
const report = await publishedFileReport(current);
for (const ref of [otherTask.saved.documentHref, otherCompany.saved.documentHref,
current.saved.documentHref.replace("#document-output", "#document-missing"),
current.saved.documentHref.replace("/issues/", "/not-issues/"),
"//files.example.test/results.csv", "/tmp/results.csv", "results.csv"]) {
await expect(nativeCompletionFeedback(db, current.runId, { ...report, evidence: [...report.evidence, { ref }] }))
.rejects.toThrow("workspace-only file");
}
});
it("does not accept the link after its current revision loses publication proof", async () => {
const value = await fixture(), report = await publishedFileReport(value);
const referenced = { ...report, evidence: [...report.evidence, { ref: value.saved.documentHref }] };
await expect(nativeCompletionFeedback(db, value.runId, referenced)).resolves.toContain("Completion report accepted");
await documentService(db).upsertIssueDocument({ format: "markdown", issueId: value.issueId, key: "output", title: "Replacement",
body: "Revision without a matching native publication receipt", baseRevisionId: value.saved.document.latestRevisionId,
createdByAgentId: value.agentId, createdByRunId: value.runId });
await expect(nativeCompletionFeedback(db, value.runId, referenced)).rejects.toThrow("requested task document is only a workspace file");
});
it("does not use a published document link as the requested downloadable file", async () => {
const value = await fixture();
await db.update(issues).set({ description: "Save a document on this task. Export the results as a CSV." }).where(eq(issues.id, value.issueId));
await expect(nativeCompletionFeedback(db, value.runId, { ...done, evidence: [{ ref: value.saved.documentHref }] }))
.rejects.toThrow("no accessible delivery evidence");
});
it("does not link stale saved revisions", async () => {
const value = await fixture();
await documentService(db).upsertIssueDocument({ format: "markdown", issueId: value.issueId, key: "output", title: "Updated", body: "New version",
@@ -17,7 +17,6 @@ import { and, eq, inArray, notInArray } from "drizzle-orm";
import {
approvals,
agents,
companies,
heartbeatRuns,
completionContracts,
issueApprovals,
@@ -40,10 +39,7 @@ function record(value: unknown): Record<string, unknown> {
/** Reconstruct links from current task state, never from provider-supplied URLs. */
async function savedDocumentLinks(db: Db, run: typeof heartbeatRuns.$inferSelect, issue: typeof issues.$inferSelect) {
const saved = await publishedTaskDocuments(db, { companyId: run.companyId, issueId: issue.id });
if (!saved.length) return [];
const [company] = await db.select({ issuePrefix: companies.issuePrefix }).from(companies).where(eq(companies.id, run.companyId));
if (!company) return [];
return saved.map(document => `[Saved document](/${encodeURIComponent(company.issuePrefix)}/issues/${encodeURIComponent(issue.identifier ?? issue.id)}#document-${encodeURIComponent(document.key)})`);
return saved.map(document => `[Saved document](${document.href})`);
}
/** Read current constraints before accepting the report, not a premature status commit. */
@@ -0,0 +1,37 @@
import { afterEach, expect, it, vi } from "vitest";
import * as childProcess from "node:child_process";
import * as hotRestart from "../hot-restart.js";
import { getServerInfoSnapshot, resetServerInfoCacheForTests } from "../../server-info.js";
import { currentNativeControllerIdentity } from "./native-restart-recovery.js";
vi.mock("node:child_process", async importOriginal => ({
...await importOriginal<typeof import("node:child_process")>(),
execFileSync: vi.fn(() => {
throw new Error("Git metadata is unavailable; identity must still work");
}),
}));
afterEach(() => {
vi.restoreAllMocks();
resetServerInfoCacheForTests();
});
it.each([true, false])("keeps controller identity independent of Git commands (OS start available=%s)", async available => {
const observed = "2026-11-02T12:00:00.000Z";
const fallback = getServerInfoSnapshot({ gitCommand: () => { throw new Error("no checkout"); } }).processStartedAt;
resetServerInfoCacheForTests();
vi.spyOn(hotRestart, "readProcessStartedAt").mockImplementation(async () => {
if (!available) throw new Error("process start unavailable");
return observed;
});
const subprocess = vi.mocked(childProcess.execFileSync);
subprocess.mockClear();
const first = await currentNativeControllerIdentity();
const second = await currentNativeControllerIdentity();
expect(first).toEqual(second);
expect(first.pid).toBe(process.pid);
expect(first.processStartedAt.toISOString()).toBe(available ? observed : fallback);
expect(subprocess).not.toHaveBeenCalled();
});
@@ -1,5 +1,5 @@
import { and, eq } from "drizzle-orm";
import { assets, documents, documentRevisions, heartbeatRuns, issueDocuments, issueAttachments, issueWorkProducts, mcpAttachmentUploads, dotRunnerAssignments, type Db } from "@paperclipai/db";
import { assets, companies, documents, documentRevisions, heartbeatRuns, issueDocuments, issueAttachments, issues, issueWorkProducts, mcpAttachmentUploads, dotRunnerAssignments, type Db } from "@paperclipai/db";
import type { PrpStructuredRunResult } from "../../vendor/paperclip-runner/index.js";
function evidenceRefs(value: unknown): string[] {
@@ -184,8 +184,11 @@ export function explicitlyRequestsTaskDocumentOutput(objective: string): boolean
*/
export async function publishedTaskDocuments(db: Db, binding: { companyId: string; issueId: string }) {
const saved = await db.select({ id: documents.id, revisionId: documents.latestRevisionId,
key: issueDocuments.key, resultJson: heartbeatRuns.resultJson })
key: issueDocuments.key, resultJson: heartbeatRuns.resultJson,
issuePrefix: companies.issuePrefix, issueIdentifier: issues.identifier })
.from(issueDocuments)
.innerJoin(issues, and(eq(issues.id, issueDocuments.issueId), eq(issues.companyId, binding.companyId)))
.innerJoin(companies, eq(companies.id, issues.companyId))
.innerJoin(documents, and(eq(documents.id, issueDocuments.documentId), eq(documents.companyId, binding.companyId)))
.innerJoin(documentRevisions, and(eq(documentRevisions.id, documents.latestRevisionId),
eq(documentRevisions.documentId, documents.id), eq(documentRevisions.companyId, binding.companyId)))
@@ -197,7 +200,8 @@ export async function publishedTaskDocuments(db: Db, binding: { companyId: strin
const receipt = record(value), result = record(receipt.result), published = record(result.document);
return receipt.operationId === "write_document" && ["applied", "duplicate"].includes(String(result.disposition))
&& published.id === document.id && published.latestRevisionId === document.revisionId;
})).map(({ id, revisionId, key }) => ({ id, revisionId, key }));
})).map(({ id, revisionId, key, issuePrefix, issueIdentifier }) => ({ id, revisionId, key,
href: `/${encodeURIComponent(issuePrefix)}/issues/${encodeURIComponent(issueIdentifier ?? binding.issueId)}#document-${encodeURIComponent(key)}` }));
}
/** Files cited as completed output must be reachable outside the agent workspace. */
@@ -209,17 +213,21 @@ export async function validateNativeDeliverableEvidence(
if (result.reportedWorkDisposition !== "done") return;
const fileRequested = explicitlyRequestsFileOutput(binding.objective);
const taskDocumentRequested = explicitlyRequestsTaskDocumentOutput(binding.objective);
const publishedTaskDocument = taskDocumentRequested && (await publishedTaskDocuments(db, binding)).length > 0;
const artifactRefs = new Set(evidenceRefs(result.artifacts));
const refs = new Set([
...evidenceRefs(result.evidence),
...artifactRefs,
...result.completionClaim.criteria.flatMap(({ evidenceRefs }) => evidenceRefs),
]);
const citesTaskDocument = [...refs].some(ref => /^\/[^/?#]+\/issues\/[^/?#]+#document-[^/?#]+$/u.test(ref.trim()));
const publishedDocuments = taskDocumentRequested || citesTaskDocument ? await publishedTaskDocuments(db, binding) : [];
const publishedTaskDocument = taskDocumentRequested && publishedDocuments.length > 0;
const documentHrefs = new Set(publishedDocuments.map(document => document.href));
let registeredAttachment = false;
for (const value of refs) {
if (typeof value !== "string") continue;
const ref = value.trim();
if (documentHrefs.has(ref)) continue;
const attachmentPath = /^\/api\/attachments\/([^/?#]+)\/content(?:[?#].*)?$/u.exec(ref);
if (ref.startsWith("deliverable:") || attachmentPath) {
const id = attachmentPath?.[1] ?? ref.slice("deliverable:".length);
@@ -32,8 +32,8 @@ const invocations = [];
const send = (id, result) => process.stdout.write(JSON.stringify({jsonrpc:"2.0",id,result})+"\\n");
readline.createInterface({input:process.stdin,crlfDelay:Infinity}).on("line", line => {
const message = JSON.parse(line);
if (message.method === "initialize") send(message.id, {ok:true,supportedMethods:["environmentTask","configChanged","health"]});
else if (message.method === "environmentTask" || message.method === "environmentDestroyLease") {
if (message.method === "initialize") send(message.id, {ok:true,supportedMethods:["environmentTask","environmentAcquireLease","configChanged","health"]});
else if (message.method === "environmentTask" || message.method === "environmentDestroyLease" || message.method === "environmentAcquireLease") {
held.push(message.id);
invocations.push(message.paperclipInvocation?.id);
if (message.params.config.hostWrite) process.stdout.write(JSON.stringify({jsonrpc:"2.0",id:"host-"+message.id,method:"companies.get",params:{companyId:"company"},paperclipInvocationId:message.paperclipInvocation.id})+"\\n");
@@ -43,7 +43,7 @@ readline.createInterface({input:process.stdin,crlfDelay:Infinity}).on("line", li
else if (message.method === "configChanged") {
if (message.params.config.exit) process.exit(1);
if (message.params.config.hostAfter) process.stdout.write(JSON.stringify({jsonrpc:"2.0",id:"late-host",method:"companies.get",params:{companyId:"company"},paperclipInvocationId:invocations.at(-1)})+"\\n");
if (message.params.config.release) for (const id of held.splice(0)) send(id, {kind:"description",taskId:"task"});
if (message.params.config.release) for (const id of held.splice(0)) { send(id, {providerLeaseId:"late-task",kind:"description",taskId:"task"}); if (message.params.config.duplicate) send(id, {providerLeaseId:"late-task",kind:"description",taskId:"task"}); }
send(message.id, {});
} else if (message.method === "shutdown") {send(message.id, {});setImmediate(()=>process.exit(0));}
});
@@ -59,6 +59,70 @@ async function startWorker(hostHandlers: Record<string, unknown> = {}) {
}
describe("Core startup responsibility across real worker IPC", () => {
it("drops only a dead acquisition reply placeholder while retaining real writes until verified retirement", async () => {
let release!: () => void, writing!: () => void;
const gate = new Promise<void>(resolve => { release = resolve; }), entered = new Promise<void>(resolve => { writing = resolve; });
const manager = await startWorker({ "companies.get": async () => { writing(); await gate; return {}; } });
const settled = vi.fn(), observe = vi.fn(async () => {});
try {
const acquisition = manager.call(manifest.id, "environmentAcquireLease", { companyId: "company", environmentId: "environment",
driverKey: "tasks", config: { hostWrite: true }, runId: "run", agentId: "agent", adapterType: "paperclip_runner" },
10, undefined, settled, "dead-acquire", observe);
const rejected = expect(acquisition).rejects.toThrow("timed out");
await entered; await rejected;
await manager.stopWorker(manifest.id);
let drained = false;
const draining = manager.drainStartup!("dead-acquire").then(() => { drained = true; });
await new Promise<void>(resolve => setImmediate(resolve));
expect(drained).toBe(false);
expect(settled).not.toHaveBeenCalled();
release(); await draining;
expect(observe).not.toHaveBeenCalled();
expect(settled).not.toHaveBeenCalled();
await manager.retireStartup!("dead-acquire");
expect(settled).toHaveBeenCalledOnce();
} finally { release(); await manager.stopAll(); }
}, 25_000);
it.each([false, true])("retains a received acquisition reply through failed recording and retirement (worker exits=%s)", async workerExits => {
const manager = await startWorker(), settled = vi.fn();
let release!: () => void, recording!: () => void;
const gate = new Promise<void>(resolve => { release = resolve; }), started = new Promise<void>(resolve => { recording = resolve; });
let unavailable = true;
const observe = vi.fn(async (result: unknown) => {
expect(result).toMatchObject({ providerLeaseId: "late-task" });
if (unavailable) throw new Error("Database unavailable");
recording(); await gate;
});
try {
const acquire = manager.call(manifest.id, "environmentAcquireLease", { companyId: "company", environmentId: "environment",
driverKey: "tasks", config: {}, runId: "run", agentId: "agent", adapterType: "paperclip_runner" }, 10, undefined, settled, "late-acquire", observe);
await expect(acquire).rejects.toThrow("timed out");
await manager.call(manifest.id, "configChanged", { config: { release: true, duplicate: true } });
await vi.waitFor(() => expect(observe).toHaveBeenCalledOnce());
await new Promise<void>(resolve => setImmediate(resolve));
expect(settled).not.toHaveBeenCalled();
if (workerExits) await manager.stopWorker(manifest.id);
unavailable = false;
vi.useFakeTimers();
const failed = manager.retireStartup!("late-acquire").catch(error => error.message);
await started;
expect(observe).toHaveBeenCalledTimes(2);
await vi.advanceTimersByTimeAsync(15_000);
expect(await failed).toBe("Task cleanup is still pending");
expect(settled).not.toHaveBeenCalled();
vi.useRealTimers();
let retired = false;
const retiring = manager.retireStartup!("late-acquire").then(() => { retired = true; });
await new Promise<void>(resolve => setImmediate(resolve));
expect(retired).toBe(false);
release(); await retiring;
expect(settled).toHaveBeenCalledOnce();
await manager.retireStartup!("late-acquire");
expect(observe).toHaveBeenCalledTimes(2);
} finally { vi.useRealTimers(); release(); await manager.stopAll(); }
});
it.each(["stop", "replace", "stopAll"])("keeps stopped startup work reachable through %s until verified retirement", async mode => {
let release!: () => void, entered!: () => void;
const gate = new Promise<void>(resolve => { release = resolve; }), started = new Promise<void>(resolve => { entered = resolve; });
@@ -4,7 +4,7 @@ import { join } from "node:path";
import { expect, it } from "vitest";
import { removeNativeHarnessBackup } from "./native-harness-backup-cleanup.js";
it("removes retired backups containing immutable skills without modifying live files or symlink targets", () => {
it("removes retired backups containing immutable skills without modifying live files or symlink targets", async () => {
const base = mkdtempSync(join(tmpdir(), "paperclip-backup-cleanup-"));
const retired = join(base, "previous");
const nested = join(retired, "claude", "skills", "first-task");
@@ -19,12 +19,12 @@ it("removes retired backups containing immutable skills without modifying live f
chmodSync(retired, 0o555);
chmodSync(live, 0o555);
try {
removeNativeHarnessBackup(retired);
await removeNativeHarnessBackup(retired);
expect(existsSync(retired)).toBe(false);
expect(readFileSync(join(live, "SKILL.md"), "utf8")).toBe("live instructions");
expect(lstatSync(live).mode & 0o777).toBe(0o555);
expect(lstatSync(join(live, "SKILL.md")).mode & 0o777).toBe(0o444);
expect(() => removeNativeHarnessBackup(retired)).not.toThrow();
await expect(removeNativeHarnessBackup(retired)).resolves.toBeUndefined();
} finally {
for (const directory of [retired, join(retired, "claude", "skills"), nested, live]) {
if (existsSync(directory)) chmodSync(directory, 0o700);
@@ -1,18 +1,23 @@
import { chmodSync, lstatSync, readdirSync, rmSync } from "node:fs";
import { chmod, lstat, readdir, rm } from "node:fs/promises";
import { join } from "node:path";
/** Remove a retired controller-owned backup, never a live harness directory. */
export function removeNativeHarnessBackup(root: string): void {
export async function removeNativeHarnessBackup(root: string): Promise<void> {
// Archives preserve immutable skill directory modes. Deleting their children
// requires write permission on those directories, even with rm's force flag.
// Only change directories in the backup being discarded. lstat deliberately
// avoids following provider-created symlinks into live or unrelated trees.
const prepareDirectory = (path: string): void => {
const metadata = lstatSync(path, { throwIfNoEntry: false });
const prepareDirectory = async (path: string): Promise<void> => {
const metadata = await lstat(path).catch((error: NodeJS.ErrnoException) => {
if (error.code === "ENOENT") return undefined;
throw error;
});
if (!metadata?.isDirectory()) return;
chmodSync(path, (metadata.mode & 0o777) | 0o700);
for (const name of readdirSync(path)) prepareDirectory(join(path, name));
await chmod(path, (metadata.mode & 0o777) | 0o700);
for (const entry of await readdir(path, { withFileTypes: true })) {
if (entry.isDirectory()) await prepareDirectory(join(path, entry.name));
}
};
prepareDirectory(root);
rmSync(root, { recursive: true, force: true });
await prepareDirectory(root);
await rm(root, { recursive: true, force: true });
}
@@ -1,4 +1,6 @@
import { promises as fs } from "node:fs";
import { existsSync, promises as fs } from "node:fs";
import { spawn } from "node:child_process";
import { createServer, type ServerResponse } from "node:http";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
@@ -32,7 +34,17 @@ describe("native harness backup publication", () => {
});
it("publishes the completed replacement once and removes its predecessor", async () => {
const input = await fixture(); const changes: unknown[] = [];
unsubscribe = onNativeHarnessBackupChange(input.db, change => changes.push(change));
const previous = path.join(backupRoot, "previous");
for (const root of [input.current, previous]) {
const skill = path.join(root, "skills", "immutable");
await fs.mkdir(skill, { recursive: true });
await fs.writeFile(path.join(skill, "SKILL.md"), "saved instructions", { mode: 0o444 });
await fs.chmod(skill, 0o555);
}
unsubscribe = onNativeHarnessBackupChange(input.db, change => {
expect(existsSync(previous)).toBe(false);
changes.push(change);
});
await publishNativeHarnessBackup({ ...input, stamp: async current => {
expect(changes).toEqual([]);
expect(await fs.readFile(path.join(current, "manifest.json"), "utf8")).toBe("replacement");
@@ -41,4 +53,85 @@ describe("native harness backup publication", () => {
expect(await fs.readdir(backupRoot)).toEqual(["current"]);
expect(changes).toEqual([{ companyId: input.companyId, scopeDigest: input.scopeDigest }]);
});
it("serves an unrelated request while a retired backup is being removed", async () => {
const input = await fixture();
for (let index = 0; index < 40; index++) {
const directory = path.join(input.current, `skill-${index}`);
await fs.mkdir(directory);
await Promise.all(Array.from({ length: 50 }, (_, file) =>
fs.writeFile(path.join(directory, `file-${file}`), "retired instructions", { mode: 0o444 })));
await fs.chmod(directory, 0o555);
}
let publishing = false;
let probeReceived: (response: ServerResponse | null) => void;
const probeReady = new Promise<ServerResponse | null>(resolve => { probeReceived = resolve; });
const server = createServer((request, response) => {
if (request.url === "/probe") { probeReceived(response); return; }
response.end("ok");
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
const address = server.address();
if (!address || typeof address === "string") throw new Error("HTTP probe did not start");
const client = spawn(process.execPath, ["--input-type=module", "-e", `
import { Agent, get } from "node:http";
const agent = new Agent({ keepAlive: true });
const request = path => new Promise((resolve, reject) => {
get({ hostname: "127.0.0.1", port: Number(process.argv[1]), path, agent }, response => {
response.resume();
response.on("end", () => resolve(response.headers["x-publication-active"]));
}).on("error", reject);
});
await request("/");
process.send({ kind: "ready" });
process.once("message", async () => {
const active = await request("/probe");
process.send({ kind: "result", active }, () => { agent.destroy(); process.disconnect(); });
});
`, String(address.port)], { stdio: ["ignore", "ignore", "inherit", "ipc"] });
const message = (kind: string) => new Promise<{ active?: string }>((resolve, reject) => {
const timer = setTimeout(() => { cleanup(); reject(new Error(`HTTP probe ${kind} timed out`)); }, 10_000);
const received = (value: { kind?: string; active?: string }) => {
if (value.kind === kind) { cleanup(); resolve(value); }
};
const failed = () => { cleanup(); reject(new Error(`HTTP probe exited before ${kind}`)); };
const cleanup = () => {
clearTimeout(timer); client.off("message", received); client.off("exit", failed); client.off("error", failed);
};
client.on("message", received); client.once("exit", failed); client.once("error", failed);
});
let publication: Promise<void> | undefined;
try {
await message("ready");
const response = message("result");
publishing = true;
publication = publishNativeHarnessBackup({
...input,
stamp: async () => {
client.send({ kind: "probe" });
const response = await probeReady;
if (!response) return;
setImmediate(() => {
response.setHeader("x-publication-active", publishing ? "1" : "0");
response.end("ok");
});
},
}).finally(() => { publishing = false; });
const result = await response;
await publication;
expect(result.active).toBe("1");
expect(await fs.readdir(backupRoot)).toEqual(["current"]);
expect(await fs.readFile(path.join(input.current, "manifest.json"), "utf8")).toBe("replacement");
} finally {
probeReceived!(null);
try { await publication; }
finally {
client.kill();
server.closeAllConnections();
await new Promise<void>(resolve => server.close(() => resolve()));
}
}
}, 15_000);
});
@@ -9,7 +9,7 @@ export async function publishNativeHarnessBackup(input: {
}) {
const currentRoot = resolve(input.backupRoot, "current");
const previousRoot = resolve(input.backupRoot, "previous");
removeNativeHarnessBackup(previousRoot);
await removeNativeHarnessBackup(previousRoot);
let movedCurrent = false;
if (existsSync(currentRoot)) {
renameSync(currentRoot, previousRoot);
@@ -19,10 +19,10 @@ export async function publishNativeHarnessBackup(input: {
renameSync(input.pendingRoot, currentRoot);
await input.stamp?.(currentRoot);
} catch (error) {
if (existsSync(currentRoot)) removeNativeHarnessBackup(currentRoot);
if (existsSync(currentRoot)) await removeNativeHarnessBackup(currentRoot);
if (movedCurrent && existsSync(previousRoot) && !existsSync(currentRoot)) renameSync(previousRoot, currentRoot);
throw error;
}
removeNativeHarnessBackup(previousRoot);
await removeNativeHarnessBackup(previousRoot);
noteNativeHarnessBackupChange(input.db, { companyId: input.companyId, scopeDigest: input.scopeDigest });
}
@@ -48,8 +48,13 @@ export function nativeResponsePresentationCandidates(scope?: NativeResponsePrese
sql`${heartbeatRunEvents.payload} #>> '{prpEvent,payload,channel}' = 'final'`,
sql`${heartbeatRunEvents.payload} #>> '{prpEvent,payload,text}' ~ '[^[:space:]]'`,
))))),
sql`${nativeRunResults.resultJson} #>> '{result,reportedWorkDisposition}' = 'done'`,
sql`${nativeRunResults.resultJson} #>> '{terminal,reportedWorkDisposition}' = 'done'`),
or(
and(sql`${nativeRunResults.resultJson} #>> '{result,reportedWorkDisposition}' = 'done'`,
sql`${nativeRunResults.resultJson} #>> '{terminal,reportedWorkDisposition}' = 'done'`),
and(sql`(coalesce(${issues.conversationAgentId}::text, '') = '' or coalesce(${issues.conversationUserId}, '') = '')`,
sql`${nativeRunResults.resultJson} #>> '{result,reportedWorkDisposition}' = 'needs_review'`,
sql`${nativeRunResults.resultJson} #>> '{terminal,reportedWorkDisposition}' = 'needs_review'`),
)),
and(sql`${nativeRunResults.resultJson} #>> '{result,reportedWorkDisposition}' = 'yielded'`,
sql`${nativeRunResults.resultJson} #>> '{terminal,reportedWorkDisposition}' = 'yielded'`,
sql`jsonb_typeof(${nativeRunResults.resultJson} #> '{result,summary}') = 'string'
@@ -31,13 +31,14 @@ import {
} from "@paperclipai/db";
import { REMOTE_CONTROLLER_LEASE_MS } from "./native-runner-recovery-contract.js";
import { readProcessStartedAt } from "../hot-restart.js";
import { getServerInfoSnapshot } from "../../server-info.js";
import { getServerProcessStartedAt } from "../../server-info.js";
import { redactSensitiveText } from "../../redaction.js";
import { issueRecoveryActionService } from "../issue-recovery-actions.js";
import { isNewTerminalRunFailure, reportRunFailure } from "../run-failure-report.js";
import { isNativeRunnerOwnershipHeld } from "./native-runner-ownership.js";
import { nativeRestartInterruptedTurnId, type PersistedNativeSession } from "../../vendor/paperclip-runner/index.js";
import { retireSupersededNativeRetry } from "./native-superseded-retry.js";
import { requestNativeStartupCleanup, readNativeStartupContext, promoteNativeStartupCleanup } from "./native-startup-cleanup.js";
export type NativeControllerIdentity = {
bootId: string;
@@ -157,7 +158,7 @@ async function observedProcessStart(pid: number): Promise<Date | null> {
export async function currentNativeControllerIdentity(): Promise<NativeControllerIdentity> {
const observed = await observedProcessStart(process.pid);
const fallback = new Date(getServerInfoSnapshot().processStartedAt);
const fallback = new Date(getServerProcessStartedAt());
return {
bootId: controllerBootId,
pid: process.pid,
@@ -623,22 +624,41 @@ export async function claimNativeRestartRecoveries(input: {
} as const;
}
const startupContext = readNativeStartupContext(row.run, row.coordinator);
const startupRetryAt = startupContext ? new Date(Math.max(now.getTime() + 30_000,
startupContext.deadlineAtMs, row.coordinator.leaseExpiresAt?.getTime() ?? 0)) : undefined;
if (input.liveRunExecutions?.has(row.run.id)) {
return {
kind: "awaiting_evidence",
runId: row.run.id,
reason: "current_controller_preparing",
...(startupRetryAt ? { retryAt: startupRetryAt } : {}),
} as const;
}
if (startupContext) {
if (await promoteNativeStartupCleanup(tx as unknown as Db, row.run, row.coordinator, now)) {
return { kind: "blocked", runId: row.run.id, reason: "native_startup_cleanup_pending" } as const;
}
return { kind: "awaiting_evidence", runId: row.run.id, reason: "native_startup_cleanup_pending", retryAt: startupRetryAt } as const;
}
const profile = row.run.runnerProfileJson ?? {};
const storedRemoteWorkspace = remoteWorkspaceRunReferenceSchema.safeParse(profile.remoteWorkspace);
const remoteWorkspace = readNativeWorkspaceSyncReference(profile.nativeWorkspaceSync)
?? (storedRemoteWorkspace.success ? storedRemoteWorkspace.data : null);
const [pluginTask] = await tx.select({ id: environmentLeases.id }).from(environmentLeases).where(and(
const [pluginTask] = await tx.select({ id: environmentLeases.id, metadata: environmentLeases.metadata }).from(environmentLeases).where(and(
eq(environmentLeases.companyId, row.run.companyId), eq(environmentLeases.heartbeatRunId, row.run.id),
sql`${environmentLeases.metadata}->>'runnerTaskLease' = 'true'`,
)).orderBy(desc(environmentLeases.createdAt)).limit(1);
const startup = pluginTask?.metadata?.runnerTaskStartup as { contextId?: unknown; deadlineAtMs?: unknown; runId?: unknown } | undefined;
if (startup?.runId === row.run.id && typeof startup.contextId === "string" &&
Number.isSafeInteger(startup.deadlineAtMs) && Number(startup.deadlineAtMs) <= now.getTime() &&
!row.run.sessionIdAfter && row.coordinator.leaseOwner && row.coordinator.leaseExpiresAt && row.coordinator.leaseExpiresAt <= now) {
await requestNativeStartupCleanup(tx as unknown as Db, { companyId: row.run.companyId, agentId: row.run.agentId,
runId: row.run.id, contextId: startup.contextId, deadlineAtMs: Number(startup.deadlineAtMs),
leaseOwner: row.coordinator.leaseOwner, requiresPreparationStop: true, leaseId: pluginTask!.id }, { publish: false });
return { kind: "blocked", runId: row.run.id, reason: "native_startup_cleanup_pending" } as const;
}
const remoteSandbox = profile.nativeWorkspaceSync != null || profile.remoteWorkspace != null;
const remoteProcess = remoteSandbox || !!pluginTask;
const durableAuthority = (remoteWorkspace || pluginTask) && await hasNativeRunnerCheckpoint(tx, {
@@ -921,15 +921,17 @@ async function recoverCommittedNativeChatResponseInScope(
&& run.contextSnapshot?.issueId === issue.id
&& typeof run.contextSnapshot.conversationSessionGeneration === "number"
&& run.contextSnapshot.conversationSessionGeneration === issue.conversationSessionGeneration;
// A deferred ordinary-task review still needs its recorded final reply.
// Publishing that reply must not resolve the interaction or the task.
if (
(!conversation || currentConversation) &&
run.status === "succeeded" &&
record(run.contextSnapshot).skipIssueComment !== true &&
!isExternalChatPresentationContext(run.contextSnapshot) &&
result.reportedWorkDisposition === "done" &&
terminal.runTerminalState === "succeeded" &&
terminal.turnTerminalState === "completed" &&
terminal.reportedWorkDisposition === "done" &&
((result.reportedWorkDisposition === "done" && terminal.reportedWorkDisposition === "done") ||
(!conversation && result.reportedWorkDisposition === "needs_review" && terminal.reportedWorkDisposition === "needs_review")) &&
(await resolveChatRunPresentationAuthorizationReason(tx, input)) === "internal_agent_write"
) {
const rows = await tx.select({ seq: heartbeatRunEvents.seq, payload: heartbeatRunEvents.payload })
@@ -13734,7 +13734,7 @@ async function createRunnerdBackendWithinSessionClaim(
} : undefined,
});
} finally {
removeNativeHarnessBackup(pendingRoot);
await removeNativeHarnessBackup(pendingRoot);
}
},
{
@@ -0,0 +1,208 @@
import { and, eq, isNull, sql } from "drizzle-orm";
import { environmentLeases, heartbeatRuns, issues, nativeRunFinalizations, sandboxCreationOperations, type Db } from "@paperclipai/db";
import { conflict } from "../../errors.js";
import { persistActivity, publishActivity } from "../activity-log.js";
import { DELIVERY_QUEUES, notifyDeliveryWork } from "../delivery-work-notifications.js";
import { hasRemoteTerminationReceipt } from "../remote-execution-termination.js";
import { isNativeRunnerOwnershipHeld } from "./native-runner-ownership.js";
export const NATIVE_STARTUP_CLEANUP_PENDING = "native_startup_cleanup_pending";
export class NativeStartupCleanupPendingError extends Error {
constructor(readonly contextId: string, readonly deadlineAtMs: number, readonly leaseOwner: string,
readonly requiresPreparationStop: boolean, readonly leaseId?: string) {
super(NATIVE_STARTUP_CLEANUP_PENDING);
this.name = "NativeStartupCleanupPendingError";
}
}
type Run = typeof heartbeatRuns.$inferSelect;
export interface NativeStartupCleanup {
schema: "paperclip.native-startup-cleanup.v1";
companyId: string; agentId: string; runId: string;
contextId: string; deadlineAtMs: number; leaseOwner: string;
controllerGeneration: number; attempt: number; leaseId: string; taskId: string;
requiresPreparationStop: boolean; state: "pending" | "complete"; auditId: string;
}
export function readNativeStartupCleanup(run: Pick<Run, "id" | "companyId" | "agentId" | "resultJson">): NativeStartupCleanup | null {
const marker = run.resultJson?.nativeStartupCleanup as NativeStartupCleanup | undefined;
return marker?.schema === "paperclip.native-startup-cleanup.v1" && marker.companyId === run.companyId &&
marker.runId === run.id && marker.agentId === run.agentId && ["contextId", "leaseOwner", "leaseId", "taskId", "auditId"].every(key =>
typeof marker[key as keyof NativeStartupCleanup] === "string" && String(marker[key as keyof NativeStartupCleanup]).length > 0) &&
Number.isSafeInteger(marker.deadlineAtMs) && Number.isSafeInteger(marker.controllerGeneration) && marker.controllerGeneration > 0 &&
Number.isSafeInteger(marker.attempt) && marker.attempt > 0 && typeof marker.requiresPreparationStop === "boolean" &&
["pending", "complete"].includes(marker.state) ? marker : null;
}
export function readNativeStartupContext(run: Run,
owner: { leaseOwner: string | null; controllerGeneration: number; attempt: number; resultId: string | null }) {
const value = run.runnerProfileJson?.nativeStartupContext as { schema?: unknown; companyId?: unknown; agentId?: unknown; runId?: unknown;
contextId?: unknown; deadlineAtMs?: unknown; leaseId?: unknown; leaseOwner?: unknown; controllerGeneration?: unknown; attempt?: unknown } | undefined;
if (run.runtimeMode !== "native" || run.status !== "running" || run.sessionIdAfter || owner.resultId || isNativeRunnerOwnershipHeld(run) ||
!owner.leaseOwner || owner.controllerGeneration < 1 || owner.attempt < 1 || value?.schema !== "paperclip.native-startup-context.v1" ||
value.companyId !== run.companyId || value.agentId !== run.agentId || value.runId !== run.id || value.leaseOwner !== owner.leaseOwner ||
value.controllerGeneration !== owner.controllerGeneration || value.attempt !== owner.attempt ||
typeof value.contextId !== "string" || !value.contextId || typeof value.leaseId !== "string" || !value.leaseId ||
!Number.isSafeInteger(value.deadlineAtMs)) return null;
return { contextId: value.contextId, leaseId: value.leaseId, deadlineAtMs: Number(value.deadlineAtMs) };
}
export async function clearNativeStartupContext(db: Db, input: {
companyId: string; runId: string; contextId: string; leaseOwner: string; controllerGeneration: number;
}): Promise<void> {
const cleared = await db.update(heartbeatRuns).set({
runnerProfileJson: sql`${heartbeatRuns.runnerProfileJson} - 'nativeStartupContext'`,
}).where(and(eq(heartbeatRuns.companyId, input.companyId), eq(heartbeatRuns.id, input.runId),
eq(heartbeatRuns.status, "running"),
sql`coalesce(${heartbeatRuns.resultJson}->'nativeStartupCleanup'->>'state', '') != 'pending'`,
sql`${heartbeatRuns.runnerProfileJson}->'nativeStartupContext'->>'contextId' = ${input.contextId}`,
sql`${heartbeatRuns.runnerProfileJson}->'nativeStartupContext'->>'leaseOwner' = ${input.leaseOwner}`,
sql`${heartbeatRuns.runnerProfileJson}->'nativeStartupContext'->>'controllerGeneration' = ${String(input.controllerGeneration)}`,
sql`exists (select 1 from ${nativeRunFinalizations} where ${nativeRunFinalizations.runId} = ${input.runId}
and ${nativeRunFinalizations.leaseOwner} = ${input.leaseOwner}
and ${nativeRunFinalizations.controllerGeneration} = ${input.controllerGeneration})`,
)).returning({ id: heartbeatRuns.id });
if (cleared.length !== 1) throw new Error("Native startup fallback lost authority");
}
export async function promoteNativeStartupCleanup(db: Db, run: Run,
owner: { leaseOwner: string | null; leaseExpiresAt: Date | null; controllerGeneration: number; attempt: number; resultId: string | null },
now: Date): Promise<boolean> {
const startup = readNativeStartupContext(run, owner);
if (!startup || startup.deadlineAtMs > now.getTime() || !owner.leaseOwner || !owner.leaseExpiresAt || owner.leaseExpiresAt > now || isNativeRunnerOwnershipHeld(run)) return false;
const [lease] = await db.select().from(environmentLeases).where(and(eq(environmentLeases.id, startup.leaseId),
eq(environmentLeases.companyId, run.companyId), eq(environmentLeases.heartbeatRunId, run.id),
sql`${environmentLeases.metadata}->>'runnerTaskLease' = 'true'`));
if (!lease) return false;
const prepared = lease.metadata?.runnerTaskStartup as { contextId?: unknown; runId?: unknown } | undefined;
return requestNativeStartupCleanup(db, { companyId: run.companyId, agentId: run.agentId, runId: run.id,
...startup, leaseOwner: owner.leaseOwner,
requiresPreparationStop: prepared?.contextId === startup.contextId && prepared.runId === run.id }, { publish: false });
}
export async function requestNativeStartupCleanup(db: Db, input: {
companyId: string; agentId: string; runId: string; contextId: string; deadlineAtMs: number; leaseOwner: string;
requiresPreparationStop: boolean;
leaseId?: string;
}, options: { publish?: boolean } = {}): Promise<boolean> {
let publication: Parameters<typeof publishActivity>[0] | null = null;
const [binding] = await db.select({ issueId: nativeRunFinalizations.issueId }).from(nativeRunFinalizations).where(and(
eq(nativeRunFinalizations.companyId, input.companyId), eq(nativeRunFinalizations.runId, input.runId)));
if (!binding) return false;
const requested = await db.transaction(async tx => {
await tx.select({ id: issues.id }).from(issues).where(and(eq(issues.companyId, input.companyId), eq(issues.id, binding.issueId))).for("update");
const [coordinator] = await tx.select().from(nativeRunFinalizations).where(and(
eq(nativeRunFinalizations.companyId, input.companyId), eq(nativeRunFinalizations.runId, input.runId))).for("update");
const [run] = await tx.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, input.companyId),
eq(heartbeatRuns.id, input.runId), eq(heartbeatRuns.agentId, input.agentId))).for("update");
if (!run || !coordinator || run.runtimeMode !== "native" || run.status !== "running" || run.sessionIdAfter || coordinator.resultId ||
isNativeRunnerOwnershipHeld(run)) return false;
const existing = readNativeStartupCleanup(run);
if (existing) return existing.contextId === input.contextId;
if (coordinator.leaseOwner !== input.leaseOwner) return false;
const leases = await tx.select().from(environmentLeases).where(and(eq(environmentLeases.companyId, input.companyId),
eq(environmentLeases.heartbeatRunId, input.runId), input.leaseId ? eq(environmentLeases.id, input.leaseId) : undefined,
sql`${environmentLeases.metadata}->>'runnerTaskLease' = 'true'`)).for("update");
if (leases.length !== 1 || !leases[0]!.providerLeaseId) throw new Error("Native startup cleanup lease unavailable");
const lease = leases[0]!;
const activity = await persistActivity(tx as unknown as Db, { companyId: input.companyId, actorType: "system",
actorId: "native-startup-cleanup", action: "native.startup_cleanup_requested", entityType: "heartbeat_run", entityId: run.id,
agentId: run.agentId, runId: run.id, issueId: run.nativeIssueId ?? undefined,
details: { contextId: input.contextId, deadlineAtMs: input.deadlineAtMs, leaseId: lease.id,
controllerGeneration: coordinator.controllerGeneration, reason: "startup_worker_unsettled" } });
if (!activity.activity?.id) throw new Error("Native startup cleanup audit unavailable");
publication = activity.publication;
const preparation = lease.metadata?.runnerTaskStartup as { contextId?: unknown; runId?: unknown } | undefined;
const marker: NativeStartupCleanup = { schema: "paperclip.native-startup-cleanup.v1", companyId: run.companyId, agentId: run.agentId,
runId: run.id, contextId: input.contextId, deadlineAtMs: input.deadlineAtMs, leaseOwner: input.leaseOwner,
controllerGeneration: coordinator.controllerGeneration, attempt: coordinator.attempt, leaseId: lease.id,
taskId: lease.providerLeaseId!, requiresPreparationStop: input.requiresPreparationStop ||
(preparation?.contextId === input.contextId && preparation.runId === run.id), state: "pending", auditId: activity.activity.id };
await notifyDeliveryWork(tx, DELIVERY_QUEUES.executionFinalization);
await notifyDeliveryWork(tx, DELIVERY_QUEUES.environmentCleanup);
await tx.update(heartbeatRuns).set({ errorCode: NATIVE_STARTUP_CLEANUP_PENDING,
error: "Native startup timed out. Cancellation and verified cleanup are pending.",
resultJson: sql`coalesce(${heartbeatRuns.resultJson}, '{}'::jsonb) || ${JSON.stringify({ nativeStartupCleanup: marker,
startupCancellation: { ...run.resultJson?.startupCancellation as object, requestedAt: new Date().toISOString() } })}::jsonb`,
updatedAt: new Date() }).where(eq(heartbeatRuns.id, run.id));
await tx.update(nativeRunFinalizations).set({ phase: "terminal_failure", failureCode: NATIVE_STARTUP_CLEANUP_PENDING,
nextAttemptAt: null, controlDeadlineAt: new Date(), recoveryState: "blocked", updatedAt: new Date() })
.where(eq(nativeRunFinalizations.runId, run.id));
await tx.update(environmentLeases).set({ metadata: sql`coalesce(${environmentLeases.metadata}, '{}'::jsonb) || ${JSON.stringify({
runnerTaskRetiring: true, runnerTaskStartupCleanup: { contextId: marker.contextId, requiresPreparationStop: marker.requiresPreparationStop },
})}::jsonb`, updatedAt: new Date() }).where(eq(environmentLeases.id, lease.id));
return true;
});
if (publication && options.publish !== false) publishActivity(publication);
return requested;
}
async function assertStartupCreationsStopped(db: Db, marker: NativeStartupCleanup): Promise<void> {
const creations = await db.select({ creation: sandboxCreationOperations, lease: environmentLeases }).from(sandboxCreationOperations)
.leftJoin(environmentLeases, eq(environmentLeases.id, sandboxCreationOperations.leaseId)).where(and(
eq(sandboxCreationOperations.companyId, marker.companyId), eq(sandboxCreationOperations.runId, marker.runId),
sql`${sandboxCreationOperations.metadata}->>'runnerTaskLease' = 'true'`));
if (creations.some(({ creation, lease }) => creation.state !== "attached" &&
(!["cleanup_pending", "resolved"].includes(creation.state) || !lease || lease.companyId !== marker.companyId || lease.heartbeatRunId !== marker.runId ||
lease.providerLeaseId !== creation.metadata.creationProviderLeaseId || lease.cleanupStatus !== "success" || !hasRemoteTerminationReceipt(lease)))) {
throw conflict("Task cleanup is still pending", { code: NATIVE_STARTUP_CLEANUP_PENDING });
}
}
export async function verifyNativeStartupCleanup(db: Db, run: Run, release: () => Promise<unknown>): Promise<void> {
const marker = readNativeStartupCleanup(run);
if (!marker || isNativeRunnerOwnershipHeld(run) || !["running", "cancelled"].includes(run.status)) throw conflict("Native startup cleanup intent unavailable");
const read = async () => {
const [lease] = await db.select().from(environmentLeases).where(and(eq(environmentLeases.companyId, run.companyId), eq(environmentLeases.id, marker.leaseId)));
if (!lease || lease.heartbeatRunId !== run.id || lease.providerLeaseId !== marker.taskId) throw conflict("Native startup cleanup target changed");
return lease;
};
const [owner] = await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, run.id));
if (!owner || owner.controllerGeneration !== marker.controllerGeneration || owner.attempt !== marker.attempt ||
owner.leaseOwner !== marker.leaseOwner || owner.resultId) throw conflict("Native startup cleanup ownership changed");
let lease = await read();
const stopped = (value: typeof lease) => hasRemoteTerminationReceipt(value) && value.cleanupStatus === "success" &&
(!marker.requiresPreparationStop || value.metadata?.runnerTaskPreparationStopped === true);
if (!stopped(lease)) { await release(); lease = await read(); }
if (!stopped(lease)) throw conflict("Task cleanup is still pending", { code: NATIVE_STARTUP_CLEANUP_PENDING });
await assertStartupCreationsStopped(db, marker);
await db.transaction(async tx => {
await tx.select({ id: issues.id }).from(issues).where(and(eq(issues.companyId, run.companyId), eq(issues.id, run.nativeIssueId!))).for("update");
const [coordinator] = await tx.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, run.id)).for("update");
const [current] = await tx.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, run.id)).for("update");
const observed = current && readNativeStartupCleanup(current);
const [receipt] = await tx.select().from(environmentLeases).where(eq(environmentLeases.id, marker.leaseId)).for("update");
if (!coordinator || !current || isNativeRunnerOwnershipHeld(current) || coordinator.resultId || coordinator.controllerGeneration !== marker.controllerGeneration ||
coordinator.attempt !== marker.attempt || coordinator.leaseOwner !== marker.leaseOwner || observed?.contextId !== marker.contextId ||
!receipt || receipt.providerLeaseId !== marker.taskId || receipt.heartbeatRunId !== run.id || !stopped(receipt)) {
throw conflict("Native startup cleanup ownership changed");
}
await assertStartupCreationsStopped(tx as unknown as Db, marker);
await tx.update(heartbeatRuns).set({ resultJson: sql`jsonb_set(${heartbeatRuns.resultJson}, '{nativeStartupCleanup,state}', '"complete"'::jsonb)`, updatedAt: new Date() })
.where(eq(heartbeatRuns.id, run.id));
});
}
export async function acknowledgeNativeStartupCleanup(db: Db, input: { companyId: string; runId: string }): Promise<void> {
const [binding] = await db.select({ issueId: nativeRunFinalizations.issueId }).from(nativeRunFinalizations).where(and(
eq(nativeRunFinalizations.companyId, input.companyId), eq(nativeRunFinalizations.runId, input.runId)));
if (!binding) return;
await db.transaction(async tx => {
await tx.select({ id: issues.id }).from(issues).where(and(eq(issues.companyId, input.companyId), eq(issues.id, binding.issueId))).for("update");
const [owner] = await tx.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, input.runId)).for("update");
const [run] = await tx.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, input.runId)).for("update");
const marker = run && readNativeStartupCleanup(run);
if (!owner || !run || run.status !== "cancelled" || !marker || marker.state !== "complete" || owner.resultId ||
owner.failureCode !== NATIVE_STARTUP_CLEANUP_PENDING || owner.controllerGeneration !== marker.controllerGeneration ||
owner.attempt !== marker.attempt || owner.leaseOwner !== marker.leaseOwner) return;
const [lease] = await tx.select().from(environmentLeases).where(eq(environmentLeases.id, marker.leaseId)).for("update");
if (!lease || lease.heartbeatRunId !== run.id || lease.providerLeaseId !== marker.taskId || !hasRemoteTerminationReceipt(lease) ||
(marker.requiresPreparationStop && lease.metadata?.runnerTaskPreparationStopped !== true)) return;
await assertStartupCreationsStopped(tx as unknown as Db, marker);
await notifyDeliveryWork(tx, DELIVERY_QUEUES.agentLifecycle);
await notifyDeliveryWork(tx, DELIVERY_QUEUES.environmentCleanup);
await tx.update(nativeRunFinalizations).set({ leaseOwner: null, leaseExpiresAt: null, controlDeadlineAt: null, recoveryState: null,
failureCode: "native_retry_cancelled", updatedAt: new Date() }).where(and(eq(nativeRunFinalizations.runId, input.runId), isNull(nativeRunFinalizations.resultId)));
});
}
@@ -36,7 +36,8 @@ export function unreconciledNativeExportPredicate() {
and ${heartbeatRuns.nativeIssueId} = ${issueRecoveryActions.sourceIssueId}
and ${heartbeatRuns.id}::text = ${issueRecoveryActions.evidence}->>'runId'
and ${heartbeatRuns.runtimeMode} = 'native' and ${heartbeatRuns.status} = 'failed'
and ${heartbeatRuns.runnerProfileJson}->'nativeWorkspaceSync'->>'state' = 'prepared')`,
and (${heartbeatRuns.runnerProfileJson}->'nativeWorkspaceSync'->>'state' = 'prepared'
or ${heartbeatRuns.runnerProfileJson}->'remoteWorkspace'->>'state' = 'prepared'))`,
);
}
@@ -19,7 +19,7 @@ import {
issueThreadInteractions,
issues,
} from "@paperclipai/db";
import { createDatabaseResponseCapture, installDatabaseResponseCapture } from "@paperclipai/db/response-capture";
import { createFullDatabaseResponseCapture, installDatabaseResponseCapture } from "../../__tests__/helpers/full-database-response-capture.js";
import { startRecoveryPostgres } from "../../__tests__/helpers/recovery-postgres.js";
import { initializeRunIdentity, reserveSteeredIdentity, reconcileSteeredIdentity } from "../run-identity.js";
import { documentService } from "../documents.js";
@@ -248,7 +248,7 @@ describe("PaperclipRunnerToolAuthority", () => {
}).where(eq(heartbeatRuns.id, runId));
const [expanded] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
const observed = createDb(temporary!.connectionString), events: Array<Record<string, unknown>> = [], now = Date.now();
const capture = createDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event));
const capture = createFullDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event));
installDatabaseResponseCapture(observed, { heartbeatRuns, issues, agents, companies }, capture);
try {
const reader = new PaperclipRunnerToolAuthority(observed, binding);
@@ -291,7 +291,7 @@ describe("PaperclipRunnerToolAuthority", () => {
await db.update(heartbeatRuns).set({ contextSnapshot: context }).where(eq(heartbeatRuns.id, runId));
const [expanded] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
const observed = createDb(temporary!.connectionString), events: Array<Record<string, unknown>> = [], now = Date.now();
const capture = createDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event));
const capture = createFullDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event));
installDatabaseResponseCapture(observed, { heartbeatRuns, issues, agents, companies }, capture);
try {
const reader = new PaperclipRunnerToolAuthority(observed, binding);
@@ -537,6 +537,9 @@ const KNOWN_PROVIDER_SPAN_NAMES: ReadonlySet<string> = new Set([
"postUploadCommand",
"session.open",
"session.close",
"lease.wait_for_teardown", "lease.lookup", "lease.refresh", "lease.channel_cleanup",
"lease.start", "lease.recover", "lease.verify", "lease.workspace", "lease.sentinel",
"lease.shell", "lease.create", "lease.expiry", "lease.cleanup",
"plugin.worker", "plugin.host.identity.read", "plugin.host.config.read", "plugin.host.progress.read", "plugin.host.progress.write",
"plugin.workspace.prepare", "plugin.storage.queue", "plugin.storage.execute",
"plugin.runtime.queue", "plugin.runtime.prepare", "plugin.runtime.reconcile", "plugin.runtime.verify",
@@ -564,6 +567,7 @@ const PROVIDER_SPAN_ATTR_ALLOWLIST: ReadonlySet<string> = new Set<string>([
SPAN_ATTRS.transferWallMs,
SPAN_ATTRS.transferGuardCount,
SPAN_ATTRS.transferDirection,
SPAN_ATTRS.state,
"plugin.operation", "plugin.reused", "plugin.item_count", "plugin.bytes", "plugin.call_count",
]);
@@ -595,6 +599,10 @@ export function clampProviderSpanAttributes(
if (!raw) return clamped;
for (const [key, value] of Object.entries(raw)) {
if (!PROVIDER_SPAN_ATTR_ALLOWLIST.has(key)) continue;
if (key === SPAN_ATTRS.state) {
if (typeof value === "string" && ["started", "stopped", "archived", "error", "other"].includes(value)) clamped[key] = value;
continue;
}
if (key === "plugin.operation") {
if (typeof value === "string" && ["request", "queue.wait", "launch.wait", "mount.refresh", "guest.setup", "process.execute", "script.workspace", "script.git", "script.settings", "script.publication", "cleanup", "process.start", "hello", "checkpoint.load"].includes(value)) clamped[key] = value;
continue;
+71 -13
View File
@@ -725,6 +725,7 @@ export interface PluginWorkerHandle {
executeLogSink?: ExecuteLogSink,
onWorkerSettled?: () => void,
startupContextId?: string,
observeResult?: (result: HostToWorkerMethods[M][1]) => Promise<void>,
): Promise<HostToWorkerMethods[M][1]>;
/**
@@ -865,6 +866,7 @@ export interface PluginWorkerManager {
executeLogSink?: ExecuteLogSink,
onWorkerSettled?: () => void,
startupContextId?: string,
observeResult?: (result: HostToWorkerMethods[M][1]) => Promise<void>,
): Promise<HostToWorkerMethods[M][1]>;
/**
@@ -914,6 +916,10 @@ export function createPluginWorkerHandle(
// Unlike RPC promises, these survive timeouts. A late response is the
// completion receipt. A worker crash cannot manufacture one.
const unsettledCalls = new Map<string | number, () => void>();
const observedResults = new Map<string | number, {
contextId: string; response?: JsonRpcResponse; running?: Promise<void>;
observe: (result: unknown) => Promise<void>; finish: () => void;
}>();
const startupCalls = new Map<string, Set<() => void>>();
const startupRetirements = new Map<string, Promise<void>>();
const startupHostHandlers = new Map<string, Set<Promise<void>>>();
@@ -1156,24 +1162,50 @@ export function createPluginWorkerHandle(
/**
* Handle a JSON-RPC response from the worker (matching a pending request).
*/
function settleResponse(response: JsonRpcResponse): void {
const id = response.id!;
unsettledCalls.get(id)?.();
unsettledCalls.delete(id);
const pending = pendingRequests.get(id);
if (!pending) { log.warn({ id }, "received response for unknown request id"); return; }
clearTimeout(pending.timer);
pendingRequests.delete(id);
pending.resolve(response);
}
function processObservedResult(id: string | number): Promise<void> {
const observer = observedResults.get(id);
if (!observer?.response) return Promise.resolve();
if (observer.running) return observer.running;
const response = observer.response;
const processing = (async () => {
if (isJsonRpcSuccessResponse(response)) await observer.observe(response.result);
observedResults.delete(id);
observer.finish();
settleResponse(response);
})();
observer.running = processing;
void processing.catch(() => {
const pending = pendingRequests.get(id);
pending?.resolve(createErrorResponse(id, JSONRPC_ERROR_CODES.INTERNAL_ERROR, "Plugin result could not be recorded"));
}).finally(() => { if (observer.running === processing) observer.running = undefined; });
return processing;
}
function handleResponse(response: JsonRpcResponse): void {
const id = response.id;
if (id === null || id === undefined) {
log.warn("received response with null/undefined id");
return;
}
unsettledCalls.get(id)?.();
unsettledCalls.delete(id);
const pending = pendingRequests.get(id);
if (!pending) {
log.warn({ id }, "received response for unknown request id");
const observer = observedResults.get(id);
if (observer) {
if (observer.response) return;
observer.response = response;
void processObservedResult(id).catch(() => log.warn({ id }, "plugin result recording will retry during cleanup"));
return;
}
clearTimeout(pending.timer);
pendingRequests.delete(id);
pending.resolve(response);
settleResponse(response);
}
function readNonEmptyString(value: unknown): string | null {
@@ -3087,6 +3119,11 @@ export function createPluginWorkerHandle(
// Read-only requests cannot continue in an exited worker. Do not infer
// completion for execute/cleanup calls, whose provider work may outlive it.
for (const finish of [...unsettledInspections]) finish();
for (const [id, observer] of observedResults) {
if (observer.response) continue;
observedResults.delete(id);
observer.finish();
}
// Close the one login pseudo-terminal route with a fixed non-secret exit and
// clear the route one time. The pending pseudo-terminal calls
@@ -3423,8 +3460,9 @@ export function createPluginWorkerHandle(
executeLogSink?: ExecuteLogSink,
onWorkerSettled?: () => void,
startupContextId?: string,
observeResult?: (result: HostToWorkerMethods[M][1]) => Promise<void>,
): Promise<HostToWorkerMethods[M][1]> {
const dispatched = measureRunPhase("execution.plugin_dispatch", () => callInternalRaw(method, params, timeoutMs, executeLogSink, onWorkerSettled, startupContextId));
const dispatched = measureRunPhase("execution.plugin_dispatch", () => callInternalRaw(method, params, timeoutMs, executeLogSink, onWorkerSettled, startupContextId, observeResult));
// Tracing wraps the RPC in a new promise. Guard the promise handed to the
// caller as well, without swallowing its eventual rejection for that caller.
void dispatched.catch(() => undefined);
@@ -3438,6 +3476,7 @@ export function createPluginWorkerHandle(
executeLogSink?: ExecuteLogSink,
onWorkerSettled?: () => void,
startupContextId?: string,
observeResult?: (result: HostToWorkerMethods[M][1]) => Promise<void>,
): Promise<HostToWorkerMethods[M][1]> {
const rpcPromise = new Promise<HostToWorkerMethods[M][1]>((resolve, reject) => {
if (startupContextId && startupRetirements.has(startupContextId)) {
@@ -3484,6 +3523,19 @@ export function createPluginWorkerHandle(
}
if (method === "environmentInspectLease") unsettledInspections.add(finish);
}
if (observeResult && startupContextId) {
let finish!: () => void;
const processing = new Promise<void>(resolve => { finish = resolve; });
let handlers = startupHostHandlers.get(startupContextId);
if (!handlers) { handlers = new Set(); startupHostHandlers.set(startupContextId, handlers); }
handlers.add(processing);
observedResults.set(id, { contextId: startupContextId, observe: observeResult as (result: unknown) => Promise<void>, finish: () => {
startupHostHandlers.get(startupContextId)?.delete(processing);
if (startupHostHandlers.get(startupContextId)?.size === 0) startupHostHandlers.delete(startupContextId);
finish();
onStartupWorkChanged?.();
} });
}
const timeout = resolveRpcCallTimeoutMs(timeoutMs, rpcTimeoutMs);
const invocationScope = deriveInvocationScope(method, params);
const invocation = invocationScope ? registerInvocation(invocationScope) : null;
@@ -3550,6 +3602,8 @@ export function createPluginWorkerHandle(
} catch (err) {
clearTimeout(timer);
pendingRequests.delete(id);
observedResults.get(id)?.finish();
observedResults.delete(id);
unsettledCalls.get(id)?.();
unsettledCalls.delete(id);
clearInvocation(invocation);
@@ -3637,6 +3691,7 @@ export function createPluginWorkerHandle(
hasStartupWork() { return startupCalls.size > 0 || startupHostHandlers.size > 0 || startupRetirements.size > 0; },
async drainStartup(contextId: string) {
revokeStartupInvocations(contextId);
for (const [id, observer] of observedResults) if (observer.contextId === contextId) void processObservedResult(id).catch(() => undefined);
await waitForStartupHandlers(contextId);
},
async retireStartup(contextId: string) {
@@ -3644,6 +3699,7 @@ export function createPluginWorkerHandle(
if (current) return current;
const retiring = (async () => {
revokeStartupInvocations(contextId);
for (const [id, observer] of observedResults) if (observer.contextId === contextId) void processObservedResult(id).catch(() => undefined);
await waitForStartupHandlers(contextId);
for (const finish of [...startupCalls.get(contextId) ?? []]) finish();
})();
@@ -3662,6 +3718,7 @@ export function createPluginWorkerHandle(
executeLogSink?: ExecuteLogSink,
onWorkerSettled?: () => void,
startupContextId?: string,
observeResult?: (result: HostToWorkerMethods[M][1]) => Promise<void>,
): Promise<HostToWorkerMethods[M][1]> {
if (status !== "running" && status !== "starting") {
onWorkerSettled?.();
@@ -3671,7 +3728,7 @@ export function createPluginWorkerHandle(
),
);
}
return callInternal(method, params, timeoutMs, executeLogSink, onWorkerSettled, startupContextId);
return callInternal(method, params, timeoutMs, executeLogSink, onWorkerSettled, startupContextId, observeResult);
},
openLoginPtySession(input: LoginPtyOpenInput) {
@@ -4032,6 +4089,7 @@ export function createPluginWorkerManager(
executeLogSink?: ExecuteLogSink,
onWorkerSettled?: () => void,
startupContextId?: string,
observeResult?: (result: HostToWorkerMethods[M][1]) => Promise<void>,
): Promise<HostToWorkerMethods[M][1]> {
const handle = workers.get(pluginId);
if (!startupContextId && ["environmentReleaseLease", "environmentDestroyLease"].includes(method)) {
@@ -4057,7 +4115,7 @@ export function createPluginWorkerManager(
// A caller timeout is not worker completion. Inspection capacity is
// released only by its actual reply, send failure, or worker exit.
const finish = onWorkerSettled ? () => { finishInspection?.(); onWorkerSettled(); } : finishInspection;
const result = handle.call(method, params, timeoutMs, executeLogSink, finish, startupContextId);
const result = handle.call(method, params, timeoutMs, executeLogSink, finish, startupContextId, observeResult);
if (method === "configChanged") void result.then(() => {
for (const listener of configApplied) {
try { listener(); }
@@ -1,6 +1,6 @@
import { randomBytes, randomUUID } from "node:crypto";
import { agents, companies, createDb, issueRecoveryActions, issues } from "@paperclipai/db";
import { createDatabaseResponseCapture, installDatabaseResponseCapture } from "@paperclipai/db/response-capture";
import { createFullDatabaseResponseCapture, installDatabaseResponseCapture } from "../../__tests__/helpers/full-database-response-capture.js";
import { eq } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js";
@@ -60,7 +60,7 @@ describe.skipIf(!support.supported)("recovery issue state reads", () => {
const service = recoveryService(db, { enqueueWakeup });
const events: Array<Record<string, unknown>> = [];
const now = Date.now();
const capture = createDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event));
const capture = createFullDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event));
installDatabaseResponseCapture(db, { issues, issueRecoveryActions }, capture);
try {
await service.reconcileStrandedAssignedIssues();
@@ -100,7 +100,7 @@ describe.skipIf(!support.supported)("recovery issue state reads", () => {
const service = recoveryService(db, { enqueueWakeup });
const events: Array<Record<string, unknown>> = [];
const now = Date.now();
const capture = createDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event));
const capture = createFullDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event));
installDatabaseResponseCapture(db, { agents, issues, issueRecoveryActions }, capture);
try {
expect(await service.reconcileStrandedAssignedIssues()).toMatchObject({ skipped: 1 });
@@ -1,6 +1,6 @@
import { randomBytes, randomUUID } from "node:crypto";
import { agents, companies, createDb, heartbeatRuns } from "@paperclipai/db";
import { createDatabaseResponseCapture, installDatabaseResponseCapture } from "@paperclipai/db/response-capture";
import { createFullDatabaseResponseCapture, installDatabaseResponseCapture } from "../../__tests__/helpers/full-database-response-capture.js";
import { eq } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js";
@@ -37,7 +37,7 @@ describe.skipIf(!support.supported)("legacy disposition source reads", () => {
errorCode: "adapter_failed", resultJson: { executionRecovery: { providerWorkStarted: true }, stdout: bulk } }).returning();
const events: Array<Record<string, unknown>> = [];
const now = Date.now();
const capture = createDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event));
const capture = createFullDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event));
installDatabaseResponseCapture(db, { heartbeatRuns }, capture);
try {
expect(await readLegacyDispositionSource(db, { companyId, issueId, runId: before.id }))
@@ -77,7 +77,7 @@ describe.skipIf(!support.supported)("legacy disposition source reads", () => {
const enqueueWakeup = vi.fn(async () => { throw new Error("native work must not enter legacy recovery"); });
const service = recoveryService(db, { enqueueWakeup });
const events: Array<Record<string, unknown>> = [], now = Date.now();
const capture = createDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event));
const capture = createFullDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event));
installDatabaseResponseCapture(db, { heartbeatRuns }, capture);
try { expect(await service.reconcileLegacyContinuation(before.id)).toBe("skipped"); }
finally { capture.flush(); }
@@ -1,5 +1,8 @@
import { and, eq } from "drizzle-orm";
import { environmentLeases, type Db } from "@paperclipai/db";
import { retiredReusableLeasePredecessors } from "./environment-lease-handoff.js";
type Lease = typeof environmentLeases.$inferSelect;
type LeaseIdentity = {
id: string; companyId: string; heartbeatRunId: string | null;
@@ -39,12 +42,36 @@ export function remoteLeaseCleanupScope(lease: Pick<LeaseIdentity, "provider" |
? JSON.stringify([lease.provider, lease.providerLeaseId]) : undefined;
}
export async function resolveRemoteTerminationLease(db: Db, lease: Lease): Promise<Lease> {
if (hasRemoteTerminationReceipt(lease) || lease.status !== "expired" ||
lease.leasePolicy !== "reuse_by_environment" || !lease.provider || !lease.providerLeaseId) return lease;
const history = await db.select().from(environmentLeases).where(and(
eq(environmentLeases.provider, lease.provider), eq(environmentLeases.providerLeaseId, lease.providerLeaseId),
));
const current = history.find(row => row.id === lease.id);
if (!current || current.companyId !== lease.companyId || current.heartbeatRunId !== lease.heartbeatRunId) return lease;
const tips = history.filter(row => !Object.hasOwn(row.metadata ?? {}, "reusableLeaseSuccessor"));
if (tips.length !== 1) return current;
const tip = tips[0]!;
const receipt = tip.metadata?.remoteExecutionTermination as Record<string, unknown> | undefined;
if (!hasRemoteTerminationReceipt(tip) || receipt?.state !== "destroyed") return current;
const predecessors = retiredReusableLeasePredecessors(tip, history.filter(row => row.id !== tip.id));
if (!predecessors?.some(row => row.id === current.id)) return current;
const termination = remoteTerminationReceipt(current, { providerLeaseId: current.providerLeaseId, state: "destroyed" });
if (!termination) return current;
return { ...current, metadata: { ...current.metadata, remoteExecutionTermination: {
...termination, confirmedAt: receipt.confirmedAt,
} } };
}
export async function stoppedRemoteCleanupScopes(db: Db, companyId: string, runId: string) {
const leases = await db.select().from(environmentLeases).where(and(
eq(environmentLeases.companyId, companyId), eq(environmentLeases.heartbeatRunId, runId),
));
if (leases.length === 0 || !leases.every(hasRemoteTerminationReceipt)) return null;
return [...new Set(leases.map(lease => remoteLeaseCleanupScope(lease)!))];
if (leases.length === 0) return null;
const resolved = await Promise.all(leases.map(lease => resolveRemoteTerminationLease(db, lease)));
if (!resolved.every(hasRemoteTerminationReceipt)) return null;
return [...new Set(resolved.map(lease => remoteLeaseCleanupScope(lease)!))];
}
export async function remoteExecutionHasStopped(db: Db, companyId: string, runId: string) {
@@ -64,6 +64,7 @@ export interface SavedWorkspaceFiles {
export interface RemoteWorkspaceFileOptions {
environmentRuntime?: Pick<EnvironmentRuntimeService, "execute" | "syncOut">;
/** Verify the exact company-owned, committed snapshot before exposing files. */
loadSavedWorkspace?(input: {
companyId: string;
workspaceId: string;
@@ -290,7 +291,11 @@ export function createRemoteWorkspaceFiles(db: Db, options: RemoteWorkspaceFileO
...(input.signal ? { signal: input.signal } : {}) });
return { root: saved.root, source: { kind: "saved", savedAt: saved.savedAt, snapshotId: authority.snapshotId } as WorkspaceFileSource };
};
if (authority.location) {
// A final save clears dirty state and writer authority. Its accepted
// bytes do not depend on an allocation that can already be closing.
if (authority.snapshotId && !authority.dirty && !authority.writerRunId && options.loadSavedWorkspace) {
({ root, source } = await loadSaved());
} else if (authority.location) {
const [leaseRow] = await db.select().from(environmentLeases).where(and(
eq(environmentLeases.companyId, input.companyId), eq(environmentLeases.id, authority.location.leaseId),
)).limit(1);
@@ -7,10 +7,21 @@ import { promisify } from "node:util";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { captureRemoteWorkspaceDirectory, remoteWorkspaceArchiveScript, restoreRemoteWorkspaceArchive } from "./remote-workspace-snapshot-archive.js";
const archiveIo = vi.hoisted(() => ({ afterChmod: undefined as ((target: string, mode: number) => void) | undefined }));
const archiveIo = vi.hoisted(() => ({
afterChmod: undefined as ((target: string, mode: number) => void) | undefined,
beforeOpen: undefined as ((target: string) => Promise<void>) | undefined,
captures: [] as string[],
}));
vi.mock("node:fs/promises", async importOriginal => {
const actual = await importOriginal<typeof import("node:fs/promises")>();
return { ...actual, async chmod(target: string, mode: number) {
return { ...actual, async open(...args: Parameters<typeof actual.open>) {
await archiveIo.beforeOpen?.(String(args[0]));
return actual.open(...args);
}, async mkdtemp(...args: Parameters<typeof actual.mkdtemp>) {
const directory = await actual.mkdtemp(...args);
if (String(args[0]).includes("pc-workspace-capture-")) archiveIo.captures.push(String(directory));
return directory;
}, async chmod(target: string, mode: number) {
await actual.chmod(target, mode); archiveIo.afterChmod?.(target, mode);
} };
});
@@ -22,7 +33,10 @@ const git = async (cwd: string, ...args: string[]) => (await execute("git", ["-C
describe("portable remote workspace archives", () => {
let root: string;
beforeEach(async () => { root = await fs.mkdtemp(path.join(os.tmpdir(), "pc-workspace-archive-test-")); });
afterEach(async () => { archiveIo.afterChmod = undefined; await fs.rm(root, { recursive: true, force: true }); });
afterEach(async () => {
archiveIo.afterChmod = undefined; archiveIo.beforeOpen = undefined; archiveIo.captures = [];
await fs.rm(root, { recursive: true, force: true });
});
async function source() { const cwd = path.join(root, "source"); await fs.mkdir(cwd); return cwd; }
async function capture(cwd: string) { const archivePath = path.join(root, "snapshot.pcws"); return { archivePath, ...await captureRemoteWorkspaceDirectory({ cwd, archivePath }) }; }
async function restore(snapshot: Awaited<ReturnType<typeof capture>>) {
@@ -57,6 +71,76 @@ describe("portable remote workspace archives", () => {
const snapshot = await capture(await source()); const restored = await restore(snapshot);
expect(await fs.readdir(restored)).toEqual([]);
});
it("captures with room for one archive and bounded metadata without another copy of working files", async () => {
const cwd = await source(), payload = Buffer.alloc(4 * 1024 * 1024, 123);
await fs.writeFile(path.join(cwd, "work.bin"), payload);
const archivePath = path.join(root, "snapshot.pcws");
async function bytes(directory: string): Promise<number> {
let total = 0;
for (const name of await fs.readdir(directory)) {
const file = path.join(directory, name), stat = await fs.lstat(file);
total += stat.isDirectory() ? await bytes(file) : stat.size;
}
return total;
}
archiveIo.beforeOpen = async target => {
if (target !== archivePath) return;
let staged = 0;
for (const directory of archiveIo.captures) staged += await bytes(directory);
if (staged + payload.length > payload.length + 128 * 1024) {
throw Object.assign(new Error("No space left for the archive"), { code: "ENOSPC" });
}
};
const snapshot = await capture(cwd);
archiveIo.beforeOpen = undefined;
const digest = (bytes: Buffer) => createHash("sha256").update(bytes).digest("hex");
expect(digest(await fs.readFile(path.join(await restore(snapshot), "work.bin")))).toBe(digest(payload));
expect(digest(await fs.readFile(path.join(cwd, "work.bin")))).toBe(digest(payload));
});
it.each(["content", "symlink", "added-file"])("rejects a concurrent %s change and removes its incomplete archive", async change => {
const cwd = await source(), file = path.join(cwd, "work.txt"), archivePath = path.join(root, "snapshot.pcws");
await fs.writeFile(file, "original\n");
const outside = path.join(root, "outside.txt"); await fs.writeFile(outside, "must stay outside\n");
archiveIo.beforeOpen = async target => {
if (target !== archivePath) return;
archiveIo.beforeOpen = undefined;
if (change === "content") await fs.writeFile(file, "changed\n");
if (change === "added-file") await fs.writeFile(path.join(cwd, "new.txt"), "new file\n");
if (change === "symlink") { await fs.unlink(file); await fs.symlink(outside, file); }
};
await expect(capture(cwd)).rejects.toThrow();
await expect(fs.access(archivePath)).rejects.toMatchObject({ code: "ENOENT" });
expect(await fs.readFile(outside, "utf8")).toBe("must stay outside\n");
for (const directory of archiveIo.captures) await expect(fs.access(directory)).rejects.toMatchObject({ code: "ENOENT" });
});
it("cleans a cancelled capture without deleting a pre-existing archive", async () => {
const cwd = await source(), archivePath = path.join(root, "snapshot.pcws");
await fs.writeFile(path.join(cwd, "file"), "retained\n");
const controller = new AbortController(), reason = new Error("capture cancelled");
archiveIo.beforeOpen = async target => { if (target === archivePath) controller.abort(reason); };
await expect(captureRemoteWorkspaceDirectory({ cwd, archivePath, signal: controller.signal })).rejects.toBe(reason);
await expect(fs.access(archivePath)).rejects.toMatchObject({ code: "ENOENT" });
archiveIo.beforeOpen = undefined;
await fs.writeFile(archivePath, "existing archive\n");
await expect(capture(cwd)).rejects.toMatchObject({ code: "EEXIST" });
expect(await fs.readFile(archivePath, "utf8")).toBe("existing archive\n");
expect(await fs.readFile(path.join(cwd, "file"), "utf8")).toBe("retained\n");
});
it("rejects Git reference changes during archive output and keeps the new reference", async () => {
const cwd = await source(), archivePath = path.join(root, "snapshot.pcws");
await git(cwd, "init", "-q", "-b", "main");
await fs.writeFile(path.join(cwd, "file"), "retained\n");
await git(cwd, "add", "."); await git(cwd, "commit", "-qm", "base");
const head = await git(cwd, "rev-parse", "HEAD");
archiveIo.beforeOpen = async target => {
if (target !== archivePath) return;
archiveIo.beforeOpen = undefined;
await git(cwd, "update-ref", "refs/heads/later-work", head);
};
await expect(capture(cwd)).rejects.toThrow("remote_workspace_archive_changed");
await expect(fs.access(archivePath)).rejects.toMatchObject({ code: "ENOENT" });
expect(await git(cwd, "rev-parse", "refs/heads/later-work")).toBe(head);
});
it("rejects a Files entry limit before extraction while ordinary restores keep their full contents", async () => {
const cwd = await source(); await fs.writeFile(path.join(cwd, "first.txt"), "first\n"); await fs.writeFile(path.join(cwd, "second.txt"), "second\n");
const snapshot = await capture(cwd), targetDir = path.join(root, "limited");
@@ -120,9 +120,28 @@ async function workspaceArchive(input: {
if (state) fail("git_operation_in_progress: finish or abort the Git operation before saving the workspace");
}
};
const readStableFile = async (source: string, consume?: (bytes: Buffer) => Promise<void>) => {
check();
const original = await fs.lstat(source, { bigint: true });
const handle = await fs.open(source, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK);
try {
const before = await handle.stat({ bigint: true });
if (!before.isFile() || original.dev !== before.dev || original.ino !== before.ino || await fs.realpath(source) !== source) fail("changed");
const digest = crypto.createHash("sha256"); let size = 0;
for await (const bytes of handle.createReadStream({ autoClose: false, signal: input.signal })) {
check(); digest.update(bytes); size += bytes.length;
await consume?.(bytes);
}
const after = await handle.stat({ bigint: true }), current = await fs.lstat(source, { bigint: true });
if (size !== Number(before.size) || await fs.realpath(source) !== source
|| ["dev", "ino", "size", "mode", "mtimeNs", "ctimeNs"].some(key => before[key as keyof typeof before] !== after[key as keyof typeof after]
|| after[key as keyof typeof after] !== current[key as keyof typeof current])) fail("changed");
return { sha256: digest.digest("hex"), size, mode: Number(before.mode & 0o777n) };
} finally { await handle.close(); }
};
const directories: string[] = [];
const excludedPaths = new Set<string>();
const walk = async (root: string, copyTo?: string, skipGit = false) => {
const walk = async (root: string, skipGit = false) => {
const entries: Entry[] = [];
const visit = async (relative: string, parentIsRepository = false) => {
const directory = relative ? path.join(root, relative) : root;
@@ -137,7 +156,7 @@ async function workspaceArchive(input: {
if (input.exclude?.some(excluded => next === excluded || next.startsWith(`${excluded}/`)) || next === ".paperclip-runtime" || next.startsWith(".paperclip-runtime/")) {
excludedPaths.add(next); continue;
}
const source = path.join(root, next), target = copyTo ? path.join(copyTo, next) : null;
const source = path.join(root, next);
const stat = await fs.lstat(source);
if (skipGit && name === ".git") { if (stat.isSymbolicLink()) fail("unsafe_git_link"); directories.push(relative); continue; }
if (stat.isDirectory() && name === "node_modules") {
@@ -150,30 +169,13 @@ async function workspaceArchive(input: {
}
if (stat.isDirectory()) {
entries.push({ path: next, type: "directory", mode: stat.mode & 0o777 });
if (target) await fs.mkdir(target, { mode: 0o700 });
await visit(next, inRepository);
} else if (stat.isSymbolicLink()) {
const link = await fs.readlink(source);
if (!safeLink(next, link)) fail("unsafe_symlink");
entries.push({ path: next, type: "symlink", target: link });
if (target) await fs.symlink(link, target);
} else if (stat.isFile()) {
const handle = await fs.open(source, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK);
let output: Awaited<ReturnType<typeof fs.open>> | undefined;
try {
const before = await handle.stat({ bigint: true });
if (!before.isFile() || BigInt(stat.dev) !== before.dev || BigInt(stat.ino) !== before.ino || await fs.realpath(source) !== source) fail("changed");
if (target) output = await fs.open(target, "wx", 0o600);
const digest = crypto.createHash("sha256"); let bytes = 0;
for await (const part of handle.createReadStream({ autoClose: false, signal: input.signal })) {
check(); digest.update(part); bytes += part.length;
await output?.writeFile(part);
}
const after = await handle.stat({ bigint: true }), current = await fs.lstat(source, { bigint: true });
if (bytes !== Number(before.size) || await fs.realpath(source) !== source || ["dev", "ino", "size", "mtimeNs", "ctimeNs"].some(key => before[key as keyof typeof before] !== after[key as keyof typeof after] || after[key as keyof typeof after] !== current[key as keyof typeof current])) fail("changed");
entries.push({ path: next, type: "file", mode: stat.mode & 0o777, size: bytes, sha256: digest.digest("hex") });
if (target) await fs.chmod(target, stat.mode & 0o777);
} finally { await output?.close(); await handle.close(); }
entries.push({ path: next, type: "file", ...await readStableFile(source) });
} else fail("unsupported_entry");
}
};
@@ -188,9 +190,10 @@ async function workspaceArchive(input: {
const root = await fs.realpath(input.cwd!);
if (!(await fs.lstat(root)).isDirectory()) fail("source_invalid");
const scratch = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "pc-workspace-capture-")));
let archiveCreated = false, completed = false;
try {
const tree = path.join(scratch, "tree"); await fs.mkdir(tree, { mode: 0o700 });
const before = await walk(root, tree, true);
const before = await walk(root, true);
const repositories = [...new Set(directories)];
const gitSignatures = new Map<string, string>();
const signature = async (repo: string, settings?: Awaited<ReturnType<typeof portableGitSettings>>) => {
@@ -207,6 +210,7 @@ async function workspaceArchive(input: {
// private repository. The selected worktree's index and HEAD are separate.
await git(root, ["clone", "--mirror", "--no-hardlinks", "--dissociate", "--", repo, normalized]);
const destination = path.join(tree, relative, ".git");
await fs.mkdir(path.dirname(destination), { recursive: true, mode: 0o700 });
await fs.rename(normalized, destination);
const sourceGit = (await git(repo, ["rev-parse", "--absolute-git-dir"])).trim();
const sourceCommon = (await git(repo, ["rev-parse", "--path-format=absolute", "--git-common-dir"])).trim();
@@ -232,35 +236,38 @@ async function workspaceArchive(input: {
const [object, ref] = row.split(" "); return `update ${ref} ${object}\n`;
}).join("");
if (updates) await git(restoredRepo, ["update-ref", "--stdin"], updates);
await git(restoredRepo, ["repack", "-a", "-d"]);
await fs.rm(path.join(destination, "objects", "info", "alternates"), { force: true });
await git(restoredRepo, ["fsck", "--connectivity-only", "--no-dangling"]);
if (await signature(repo) !== gitSignatures.get(relative)) fail("changed");
}
directories.length = 0;
const after = await walk(root, undefined, true);
if (JSON.stringify(before) !== JSON.stringify(after)) fail("changed");
for (const relative of repositories) await assertGitOperationComplete(path.join(root, relative));
const entries = await walk(tree);
// Keep private staging writable for cleanup; the manifest preserves the
// source modes and restoration applies them only after writing children.
const originals = new Map(before.map(entry => [entry.path, entry]));
for (const entry of entries) if (entry.type === "directory") {
const original = originals.get(entry.path); if (original) entry.mode = original.mode;
}
const entries = [...before, ...(await walk(tree)).filter(entry => !originals.has(entry.path))]
.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
const manifest = { schema, exclusionPolicy: "paperclip.remote-workspace-exclusions.v1", excluded: [...excludedPaths].sort(), entries }, body = Buffer.from(JSON.stringify(manifest));
if (body.length > 64 * 1024 * 1024) fail("manifest_too_large");
const digest = manifestHash(manifest), length = Buffer.alloc(4); length.writeUInt32BE(body.length);
const output = await fs.open(input.archivePath, "wx", 0o600);
archiveCreated = true;
try {
await output.writeFile(Buffer.concat([magic, length, body]));
for (const entry of entries) if (entry.type === "file") {
for await (const bytes of createReadStream(path.join(tree, entry.path), { signal: input.signal })) { check(); await output.writeFile(bytes); }
const observed = await readStableFile(path.join(originals.has(entry.path) ? root : tree, entry.path), bytes => output.writeFile(bytes));
if (observed.size !== entry.size || observed.sha256 !== entry.sha256 || observed.mode !== entry.mode) fail("changed");
}
} finally { await output.close(); }
directories.length = 0;
if (JSON.stringify(before) !== JSON.stringify(await walk(root, true))) fail("changed");
for (const relative of repositories) {
if (await signature(path.join(root, relative)) !== gitSignatures.get(relative)) fail("changed");
}
if (input.manifestPath) await fs.writeFile(input.manifestPath, body, { flag: "wx", mode: 0o600 });
return { schema, manifestDigest: digest, ...await hashFile(input.archivePath), entries: entries.length };
} finally { await fs.rm(scratch, { recursive: true, force: true }); }
const content = await hashFile(input.archivePath);
completed = true;
return { schema, manifestDigest: digest, ...content, entries: entries.length };
} finally {
if (archiveCreated && !completed) await fs.rm(input.archivePath, { force: true });
await fs.rm(scratch, { recursive: true, force: true });
}
}
if (!input.targetDir || !/^[a-f0-9]{64}$/.test(input.expectedManifestDigest ?? "")) fail("restore_binding_invalid");
if (input.maxEntries !== undefined && (!Number.isSafeInteger(input.maxEntries) || input.maxEntries < 0)) fail("restore_binding_invalid");
@@ -86,7 +86,12 @@ suite("remote workspace snapshot storage", () => {
it("rejects object corruption and leaves the requested plaintext path unpublished", async () => {
const f = await fixture(), ref = await uploadWorkspaceSnapshot(f.upload);
const objectPath = path.join(root, "objects", ref.objectKey), object = await fs.open(objectPath, "r+");
try { await object.write(Buffer.from("X"), 0, 1, 15); } finally { await object.close(); }
try {
const byte = Buffer.alloc(1);
expect((await object.read(byte, 0, 1, 15)).bytesRead).toBe(1);
byte[0] = byte[0]! ^ 1;
await object.write(byte, 0, 1, 15);
} finally { await object.close(); }
const targetPath = path.join(root, "corrupted.pcws");
await expect(loadWorkspaceSnapshotArchive({ storage, reference: ref, targetPath })).rejects.toThrow("object_hash_mismatch");
await expect(fs.access(targetPath)).rejects.toMatchObject({ code: "ENOENT" });
@@ -4,9 +4,9 @@ import { and, eq, lte, or, sql } from "drizzle-orm";
import { companies, environments, sandboxCreationOperations, type Db } from "@paperclipai/db";
import { readEnvironmentCreationCleanupError, type PluginEnvironmentLease, type PluginEnvironmentLeaseObservation } from "@paperclipai/plugin-sdk";
import { assertEnvironmentCompanyBinding, environmentService } from "./environments.js";
import { ENVIRONMENT_CLEANUP_RETRY_MS } from "./environment-cleanup-state.js";
import { ENVIRONMENT_CLEANUP_RETRY_MS, recoverableSandboxCreationCondition } from "./environment-cleanup-state.js";
import { logger } from "../middleware/logger.js";
import { notifyEnvironmentCleanup } from "./environment-cleanup-notifications.js";
import { notifyEnvironmentCleanup, writeEnvironmentCleanup } from "./environment-cleanup-notifications.js";
export type SandboxCreationOperation = typeof sandboxCreationOperations.$inferSelect;
@@ -78,26 +78,69 @@ export async function createOwnedSandbox(
* This enriches the existing owner; it never transfers or reopens ownership. */
export async function recordSandboxCreationResource(db: Db, input: {
id: string; companyId: string; environmentId: string; providerLeaseId: string;
providerMetadata?: Record<string, unknown>;
}): Promise<void> {
const [recorded] = await db.update(sandboxCreationOperations).set({
metadata: sql`coalesce(${sandboxCreationOperations.metadata}, '{}'::jsonb) || ${JSON.stringify({ creationProviderLeaseId: input.providerLeaseId })}::jsonb`,
updatedAt: new Date(),
}).where(and(eq(sandboxCreationOperations.id, input.id), eq(sandboxCreationOperations.companyId, input.companyId),
eq(sandboxCreationOperations.originalEnvironmentId, input.environmentId),
or(sql`${sandboxCreationOperations.metadata}->>'creationProviderLeaseId' is null`,
sql`${sandboxCreationOperations.metadata}->>'creationProviderLeaseId' = ${input.providerLeaseId}`),
)).returning({ id: sandboxCreationOperations.id });
if (!recorded) throw new Error("Sandbox creation resource identity changed");
await writeEnvironmentCleanup(db, async tx => {
const [recorded] = await tx.update(sandboxCreationOperations).set({
nextAttemptAt: sql`case when ${sandboxCreationOperations.state} = 'needs_reconciliation' then clock_timestamp() else ${sandboxCreationOperations.nextAttemptAt} end`,
metadata: sql`coalesce(${sandboxCreationOperations.metadata}, '{}'::jsonb) || ${JSON.stringify({ creationProviderLeaseId: input.providerLeaseId, ...(input.providerMetadata ? { creationProviderMetadata: input.providerMetadata } : {}) })}::jsonb`,
updatedAt: new Date(),
}).where(and(eq(sandboxCreationOperations.id, input.id), eq(sandboxCreationOperations.companyId, input.companyId),
eq(sandboxCreationOperations.originalEnvironmentId, input.environmentId),
or(sql`${sandboxCreationOperations.metadata}->>'creationProviderLeaseId' is null`,
sql`${sandboxCreationOperations.metadata}->>'creationProviderLeaseId' = ${input.providerLeaseId}`),
)).returning({ id: sandboxCreationOperations.id });
if (!recorded) throw new Error("Sandbox creation resource identity changed");
}, { companyId: input.companyId });
}
export async function queueSandboxCreationCleanup(db: Db, input: {
id: string; companyId: string; environmentId: string; providerLeaseId: string;
providerMetadata?: Record<string, unknown>;
}): Promise<void> {
await db.transaction(async tx => {
await tx.select({ id: environments.id }).from(environments).where(eq(environments.id, input.environmentId)).for("update");
const [operation] = await tx.select().from(sandboxCreationOperations).where(and(eq(sandboxCreationOperations.id, input.id),
eq(sandboxCreationOperations.companyId, input.companyId), eq(sandboxCreationOperations.originalEnvironmentId, input.environmentId))).for("update");
if (!operation || (operation.metadata.creationProviderLeaseId && operation.metadata.creationProviderLeaseId !== input.providerLeaseId)) {
throw new Error("Sandbox creation cleanup ownership changed");
}
if (["attached", "cleanup_pending", "resolved"].includes(operation.state)) return;
const contextId = operation.metadata.runnerTaskLease === true && typeof operation.metadata.runnerTaskCreationContextId === "string"
? operation.metadata.runnerTaskCreationContextId : null;
if (operation.state !== "pending" && !(operation.state === "needs_reconciliation" && contextId &&
operation.metadata.creationProviderLeaseId === input.providerLeaseId)) throw new Error("Sandbox creation cleanup ownership changed");
await notifyEnvironmentCleanup(tx, { companyId: operation.companyId, runId: operation.runId });
const lease = await environmentService(tx as unknown as Db).insertPendingCleanupLease({
companyId: operation.companyId, environmentId: operation.originalEnvironmentId,
heartbeatRunId: operation.runId, issueId: operation.issueId, executionWorkspaceId: operation.executionWorkspaceId,
provider: operation.provider, providerLeaseId: input.providerLeaseId,
metadata: { ...operation.metadata, creationOperationId: operation.id, environmentId: operation.originalEnvironmentId,
providerMetadata: input.providerMetadata ?? operation.metadata.creationProviderMetadata ?? {}, runnerTaskRetiring: true,
...(contextId ? { runnerTaskStartupCleanup: { contextId, requiresPreparationStop: false } } : {}) },
failureReason: "interrupted_task_creation",
});
await tx.update(sandboxCreationOperations).set({ state: "cleanup_pending", leaseId: lease.id, updatedAt: new Date(),
metadata: { ...operation.metadata, creationProviderLeaseId: input.providerLeaseId,
creationProviderMetadata: input.providerMetadata ?? operation.metadata.creationProviderMetadata ?? {} },
}).where(eq(sandboxCreationOperations.id, operation.id));
});
}
export async function recoverSandboxCreations(db: Db,
inspect: (operation: SandboxCreationOperation) => Promise<PluginEnvironmentLeaseObservation | null>,
): Promise<void> {
const due = await db.select().from(sandboxCreationOperations).where(and(
eq(sandboxCreationOperations.state, "pending"), lte(sandboxCreationOperations.nextAttemptAt, new Date()),
recoverableSandboxCreationCondition(), lte(sandboxCreationOperations.nextAttemptAt, new Date()),
)).limit(100);
for (const candidate of due) {
try {
if (candidate.metadata.runnerTaskLease === true && typeof candidate.metadata.runnerTaskCreationContextId === "string" &&
typeof candidate.metadata.creationProviderLeaseId === "string") {
await queueSandboxCreationCleanup(db, { id: candidate.id, companyId: candidate.companyId,
environmentId: candidate.originalEnvironmentId, providerLeaseId: candidate.metadata.creationProviderLeaseId });
continue;
}
const [existingCompany] = await db.select({ id: companies.id }).from(companies).where(eq(companies.id, candidate.companyId)).limit(1);
const observation = existingCompany ? await inspect(candidate) : null;
const cleanup = observation?.creationCleanup ? readEnvironmentCreationCleanupError({ data: {
@@ -140,7 +183,7 @@ export async function recoverSandboxCreations(db: Db,
? { nextAttemptAt: new Date(Date.now() + ENVIRONMENT_CLEANUP_RETRY_MS), updatedAt: new Date() }
: { state: "needs_reconciliation", updatedAt: new Date(),
metadata: sql`coalesce(${sandboxCreationOperations.metadata}, '{}'::jsonb) || '{"creationReconciliationReason":"company_deleted"}'::jsonb` })
.where(and(eq(sandboxCreationOperations.id, candidate.id), eq(sandboxCreationOperations.state, "pending")));
.where(and(eq(sandboxCreationOperations.id, candidate.id), recoverableSandboxCreationCondition()));
return Boolean(company);
});
logger.warn({ errorKind: retry ? "sandbox_creation_recovery_failed" : "sandbox_creation_needs_reconciliation", operationId: candidate.id, companyId: candidate.companyId },
+2
View File
@@ -1,4 +1,5 @@
import { notifyStatusCardEvaluation } from "./status-card-notifications.js";
import { issueDisplayStatusSql } from "./issue-display-status.js";
import { createHash } from "node:crypto";
import { and, desc, eq, gt, gte, inArray, isNotNull, isNull, lte, ne, or, sql, type SQL } from "drizzle-orm";
import {
@@ -552,6 +553,7 @@ export function statusCardService(
identifier: issues.identifier,
title: issues.title,
status: issues.status,
displayStatus: issueDisplayStatusSql(),
priority: issues.priority,
assigneeAgentId: issues.assigneeAgentId,
assigneeUserId: issues.assigneeUserId,
+66 -10
View File
@@ -6,7 +6,7 @@ import { z } from "zod";
import { randomUUID } from "node:crypto";
import { and, desc, eq, inArray, or, sql } from "drizzle-orm";
import { agentWakeupRequests, chatActions, chatConversations, chatEndpoints, heartbeatRuns, issues, issueComments, runIdentityContexts, issueRecoveryActions, type Db } from "@paperclipai/db";
import { conflict, forbidden, notFound } from "../errors.js";
import { conflict, forbidden, notFound, HttpError } from "../errors.js";
import { persistActivity } from "./activity-log.js";
import { createSystemRecovery, readSystemRecovery } from "./system-recovery-state.js";
import { adoptSystemRecovery, updateSystemRecovery } from "./system-recovery.js";
@@ -17,6 +17,10 @@ import { preserveNativeFailedWorkspace } from "./native-runtime/native-failed-wo
import { NativeWorkspaceFinalizationBusyError } from "./native-runtime/native-workspace-finalization-ownership.js";
import { adapterExecutionControls } from "./adapter-execution-control.js";
import { hasRequiredWorkspaceRecovery } from "./workspace-restore-recovery-state.js";
import { deferredChatInputPredicate, resumeDeferredChatWake } from "./durable-chat-wakeup.js";
import { notifyDeferredExecutionWork } from "./deferred-execution-notifications.js";
import { hasPendingRunCleanup } from "./execution-blocker.js";
import { PersistedExecutionWaitRefusal } from "./deferred-execution-wait.js";
export async function taskRetryMutationAllowed(db: Db, input: {
companyId: string; issueId: string; actorId: string; actorSource: TaskRetryIntent["actorSource"];
@@ -45,6 +49,7 @@ const taskRetryIntentSchema = z.object({
schema: z.literal("paperclip.task-retry.v1"), generationId: z.string().uuid(), sourceRunId: z.string().uuid(),
actorId: z.string().min(1), actorSource: z.enum(["local_implicit", "session", "board_key", "cloud_tenant"]).default("session"), strategy: z.enum(["evaluate", "continue", "fresh_isolated_workspace", "finalize_result"]),
chatActionId: z.string().uuid().optional(), canonicalIntentId: z.string().uuid().optional(),
queuedChatRequestId: z.string().uuid().optional(),
boardMessageOrigin: z.object({ commentId: z.string().uuid(), identityContextId: z.string().uuid() }).optional(),
});
export type TaskRetryIntent = z.infer<typeof taskRetryIntentSchema>;
@@ -159,7 +164,7 @@ async function readBoardMessageRetrySource(db: Db, input: {
sql`${agentWakeupRequests.payload}->>'issueId' = ${input.issueId}`));
const intent = readTaskRetryIntent(receipt?.payload);
if (!intent?.boardMessageOrigin || intent.generationId !== receipt!.id || intent.actorId !== input.actorId ||
intent.chatActionId || intent.canonicalIntentId || intent.sourceRunId !== successor.previousRunId) return null;
intent.chatActionId || intent.queuedChatRequestId || intent.canonicalIntentId || intent.sourceRunId !== successor.previousRunId) return null;
const [identity] = await db.select({ id: runIdentityContexts.id }).from(runIdentityContexts).where(and(
eq(runIdentityContexts.id, successor.identityContextId), eq(runIdentityContexts.companyId, input.companyId),
eq(runIdentityContexts.runId, input.sourceRunId), eq(runIdentityContexts.responsibleUserId, input.actorId),
@@ -279,6 +284,7 @@ export async function requestTaskRetry(input: {
sourceRunId: latest.id, actorId: input.actorId,
actorSource: taskRetryIntentSchema.shape.actorSource.parse(input.actorSource ?? "session"), strategy: "evaluate",
...(active && activeIntent?.chatActionId ? { chatActionId: activeIntent.chatActionId } : {}),
...(active && activeIntent?.queuedChatRequestId ? { queuedChatRequestId: activeIntent.queuedChatRequestId } : {}),
...(active ? { canonicalIntentId: active.id } : {}) };
// Preserve external chat authority through its existing admission owner.
// Generic board Retry must never manufacture a chat-inbound request.
@@ -290,7 +296,20 @@ export async function requestTaskRetry(input: {
? await readTaskRetryBoardMessageOrigin(db, { companyId: issue.companyId,
issueId: issue.id, agentId: issue.assigneeAgentId, sourceRunId: latest.id, actorId: input.actorId }) : null;
if (boardMessageOrigin) intent.boardMessageOrigin = boardMessageOrigin;
if (chat && !active && !busy && !boardMessageOrigin && (latest.contextSnapshot?.source === "issue.comment.reopen" || latest.contextSnapshot?.boardMessageRetry === true) &&
if (chat && !active && latest.status === "succeeded") {
const [savedInput] = await tx.select({ id: agentWakeupRequests.id }).from(agentWakeupRequests).where(and(
eq(agentWakeupRequests.companyId, issue.companyId), eq(agentWakeupRequests.agentId, issue.assigneeAgentId),
eq(agentWakeupRequests.status, "deferred_issue_execution"), sql`${agentWakeupRequests.runId} is null`,
sql`${agentWakeupRequests.payload}->>'issueId' = ${issue.id}`, deferredChatInputPredicate(db),
)).orderBy(desc(agentWakeupRequests.requestedAt), desc(agentWakeupRequests.id)).limit(1).for("update");
// A completed turn stays history. Only independently admitted input can
// continue, with its original identity and current inbound authorization.
if (savedInput) {
intent.queuedChatRequestId = savedInput.id;
await notifyDeferredExecutionWork(tx, { companyId: issue.companyId, requestId: id });
}
}
if (chat && !active && !busy && !boardMessageOrigin && !intent.queuedChatRequestId && (latest.contextSnapshot?.source === "issue.comment.reopen" || latest.contextSnapshot?.boardMessageRetry === true) &&
latest.responsibleUserId === input.actorId) {
throw conflict("This saved message cannot be retried safely. Send a new message on the task to continue from saved work.",
{ code: "chat_failed_run_retry_not_authorized", reason: "saved_board_message_not_replayable" });
@@ -301,7 +320,7 @@ export async function requestTaskRetry(input: {
status: active || busy ? "coalesced" : "deferred_issue_execution",
runId: active?.runId ?? (busy ? latest.id : null),
payload: { issueId: issue.id, taskRetry: intent }, finishedAt: active || busy ? new Date() : null });
if (chat && !boardMessageOrigin && !active && !busy) {
if (chat && !boardMessageOrigin && !intent.queuedChatRequestId && !active && !busy) {
if (!input.prepareChatRetry) throw conflict("Retry is unavailable for this connected conversation.");
intent.chatActionId = (await input.prepareChatRetry(db, { companyId: issue.companyId, issueId: issue.id,
agentId: issue.assigneeAgentId, failedRunId: latest.id, initiatedByUserId: input.actorId, retryIntentId: id })).actionId;
@@ -351,21 +370,58 @@ export async function reconcileTaskChatRetryReceipts(db: Db, scope: { companyId?
scope.companyId ? eq(agentWakeupRequests.companyId, scope.companyId) : undefined,
scope.requestId ? eq(agentWakeupRequests.id, scope.requestId) : undefined,
eq(agentWakeupRequests.reason, "task_retry"), eq(agentWakeupRequests.status, "deferred_issue_execution"),
sql`${agentWakeupRequests.payload}#>>'{taskRetry,chatActionId}' is not null`,
or(sql`${agentWakeupRequests.payload}#>>'{taskRetry,chatActionId}' is not null`,
sql`${agentWakeupRequests.payload}#>>'{taskRetry,queuedChatRequestId}' is not null`),
)).orderBy(agentWakeupRequests.updatedAt).limit(50);
for (const wake of pending) {
const intent = readTaskRetryIntent(wake.payload);
if (!intent?.chatActionId) continue;
if (!intent || (!intent.chatActionId && !intent.queuedChatRequestId)) continue;
const actionId = intent.queuedChatRequestId ?? intent.chatActionId!;
const [chat] = await db.select().from(chatActions).where(and(eq(chatActions.companyId, wake.companyId),
eq(chatActions.id, intent.chatActionId), eq(chatActions.kind, "failed_run_retry"),
eq(chatActions.id, actionId), eq(chatActions.kind, intent.queuedChatRequestId ? "inbound_wakeup" : "failed_run_retry"),
sql`${chatActions.payload}->>'issueId' = ${String(wake.payload?.issueId)}`));
const [receipt] = chat ? await db.select().from(agentWakeupRequests).where(and(
let [receipt] = chat ? await db.select().from(agentWakeupRequests).where(and(
eq(agentWakeupRequests.companyId, wake.companyId), eq(agentWakeupRequests.id, chat.id),
eq(agentWakeupRequests.agentId, wake.agentId))) : [];
let inputDenied = false;
if (intent.queuedChatRequestId && !scope.settleOnly && !receipt?.runId) {
const issueId = String(wake.payload?.issueId);
const admission = await db.transaction(async tx => {
if (intent.generationId !== wake.id || intent.canonicalIntentId ||
wake.requestedByActorType !== "user" || wake.requestedByActorId !== intent.actorId ||
!wake.idempotencyKey?.startsWith("task-retry:")) return "denied";
const [issue] = await tx.select().from(issues).where(and(eq(issues.id, issueId),
eq(issues.companyId, wake.companyId))).for("update");
if (!issue || issue.assigneeAgentId !== wake.agentId || ["done", "cancelled"].includes(issue.status)) return "denied";
if (await retireSupersededTaskRetry(tx as unknown as Db, { companyId: wake.companyId, issueId,
requestId: wake.id, sourceRunId: intent.sourceRunId })) return "denied";
if (!await taskRetryMutationAllowed(tx as unknown as Db, { companyId: wake.companyId, issueId,
actorId: intent.actorId, actorSource: intent.actorSource })) return "denied";
if (issue.conversationAgentId && (issue.conversationUserId !== intent.actorId ||
!(await instanceSettingsService(tx as unknown as Db).getExperimental()).enableAgentChat)) return "denied";
if (adapterExecutionControls.has(intent.sourceRunId) ||
await hasPendingRunCleanup(tx as unknown as Db, wake.companyId, intent.sourceRunId)) return "waiting";
return "ready";
});
inputDenied = admission === "denied" || !chat || !receipt;
if (!inputDenied && admission === "ready" && receipt?.status === "deferred_issue_execution") {
try {
await resumeDeferredChatWake(db, { companyId: wake.companyId, agentId: wake.agentId, issueId, requestId: actionId });
} catch (error) {
if (!(error instanceof PersistedExecutionWaitRefusal)) {
if (!(error instanceof HttpError) || ![403, 404, 409].includes(error.status)) throw error;
inputDenied = true;
}
}
[receipt] = await db.select().from(agentWakeupRequests).where(and(eq(agentWakeupRequests.companyId, wake.companyId),
eq(agentWakeupRequests.id, actionId), eq(agentWakeupRequests.agentId, wake.agentId)));
}
inputDenied ||= Boolean(receipt && ["failed", "skipped", "cancelled"].includes(receipt.status));
}
// A deferred chat receipt is admitted input, not a completed dispatch.
// Re-stage that exact action through chat's owner; it rechecks authority
// and normal queue policy before updating the existing receipt.
if (!scope.settleOnly && chat?.status === "processed" && chat.payload.taskRetryIntentId === wake.id &&
if (!intent.queuedChatRequestId && !scope.settleOnly && chat?.status === "processed" && chat.payload.taskRetryIntentId === wake.id &&
chat.payload.failedRunId === intent.sourceRunId && receipt?.status === "deferred_issue_execution" &&
!receipt.runId && receipt.payload?.taskRetryRequestId === wake.id) {
await db.transaction(async tx => {
@@ -375,7 +431,7 @@ export async function reconcileTaskChatRetryReceipts(db: Db, scope: { companyId?
eq(chatActions.status, "processed"), eq(chatActions.updatedAt, chat.updatedAt)));
});
}
const failed = chat && ["failed", "cancelled"].includes(chat.status);
const failed = inputDenied || (chat && ["failed", "cancelled"].includes(chat.status));
const write = (writer: Pick<Db, "update">) => writer.update(agentWakeupRequests).set({
...(receipt?.runId ? { status: "coalesced", runId: receipt.runId, finishedAt: new Date() }
: failed ? { status: "failed", finishedAt: new Date() } : {}),
+2 -1
View File
@@ -1,3 +1,4 @@
import { issueDisplayStatusSql } from "./issue-display-status.js";
import { sql, type SQL } from "drizzle-orm";
import { COMPANY_SEARCH_MAX_QUERY_LENGTH, COMPANY_SEARCH_MAX_TOKENS } from "@paperclipai/shared";
import { visibleIssueCondition } from "./issue-visibility.js";
@@ -134,7 +135,7 @@ export function taskSearchCtes(companyId: string, search: TaskSearch, includeCon
// The search page fetches descriptions only for its result window.
const flags = (fuzzyMatch: SQL) => sql`
SELECT issues.id, issues.identifier, issues.title,
issues.status, issues.priority, issues.assignee_agent_id, issues.assignee_user_id,
issues.status, ${issueDisplayStatusSql()} AS display_status, issues.priority, issues.assignee_agent_id, issues.assignee_user_id,
issues.project_id, issues.created_at, issues.updated_at,
${identExact} AS ident_exact, ${identStarts} AS ident_starts,
${phraseMatch(sql`issues.identifier`)} AS ident_phrase,
@@ -103,6 +103,24 @@ export function profileEntryMatchesCatalog(
return false;
}
export function profileCatalogMatcher(entries: ProfileEntryMatch[]) {
const applications = new Set<string | null>();
const connections = new Set<string | null>();
const catalogIds = new Set<string | null>();
const toolNames = new Set<string | null>();
const risks = new Set<string | null>();
for (const entry of entries) {
if (entry.selectorType === "application") applications.add(entry.applicationId);
else if (entry.selectorType === "connection") connections.add(entry.connectionId);
else if (entry.selectorType === "catalog_entry") catalogIds.add(entry.catalogEntryId);
else if (entry.selectorType === "tool_name") toolNames.add(entry.toolName);
else if (entry.selectorType === "risk_level") risks.add(entry.riskLevel);
}
return (entry: ProfileCatalogMatch) => applications.has(entry.applicationId)
|| connections.has(entry.connectionId) || catalogIds.has(entry.id)
|| toolNames.has(entry.toolName) || risks.has(entry.riskLevel);
}
export function summarizeProfile(input: {
profile: typeof toolProfiles.$inferSelect;
entries: Array<typeof toolProfileEntries.$inferSelect>;
@@ -110,22 +128,18 @@ export function summarizeProfile(input: {
catalog: ProfileCatalogEntry[];
agentIds: string[];
}): ToolProfileSummary {
const includes = input.entries.filter((entry) => entry.effect === "include");
const excludes = input.entries.filter((entry) => entry.effect === "exclude");
const included = profileCatalogMatcher(input.entries.filter((entry) => entry.effect === "include"));
const excluded = profileCatalogMatcher(input.entries.filter((entry) => entry.effect === "exclude"));
const allowedCatalogIds = new Set<string>();
const allowedApplicationIds = new Set<string>();
const excludedCatalogIds = new Set<string>();
for (const catalogEntry of input.catalog) {
const excluded = excludes.some((entry) =>
profileEntryMatchesCatalog(entry, catalogEntry),
);
if (excluded) excludedCatalogIds.add(catalogEntry.id);
if (excluded) continue;
const included = includes.some((entry) =>
profileEntryMatchesCatalog(entry, catalogEntry),
);
if (input.profile.defaultAction === "allow" || included) {
if (excluded(catalogEntry)) {
excludedCatalogIds.add(catalogEntry.id);
continue;
}
if (input.profile.defaultAction === "allow" || included(catalogEntry)) {
allowedCatalogIds.add(catalogEntry.id);
if (catalogEntry.applicationId)
allowedApplicationIds.add(catalogEntry.applicationId);
@@ -203,32 +217,28 @@ export function pendingNewToolsForProfile(input: {
return [];
const watermark = input.profile.newToolsReviewedAt ?? input.profile.createdAt;
const catalogById = new Map(input.catalog.map((entry) => [entry.id, entry]));
const scopedIncludes = input.entries.filter(
(entry) =>
entry.effect === "include" &&
(entry.selectorType === "application" ||
entry.selectorType === "connection" ||
entry.selectorType === "catalog_entry"),
);
if (scopedIncludes.length === 0) return [];
const coveredApplications = new Set<string | null>();
const coveredConnections = new Set<string | null>();
for (const entry of input.entries) {
if (entry.effect !== "include") continue;
if (entry.selectorType === "application") coveredApplications.add(entry.applicationId);
else if (entry.selectorType === "connection") coveredConnections.add(entry.connectionId);
else if (entry.selectorType === "catalog_entry" && entry.catalogEntryId) {
const scopedEntry = catalogById.get(entry.catalogEntryId);
if (!scopedEntry) continue;
coveredConnections.add(scopedEntry.connectionId);
if (scopedEntry.applicationId) coveredApplications.add(scopedEntry.applicationId);
}
}
if (coveredApplications.size === 0 && coveredConnections.size === 0) return [];
const explicitlyReviewed = profileCatalogMatcher(input.entries);
return input.catalog
.filter(
(catalogEntry) =>
catalogEntry.status === "active" ||
catalogEntry.status === "quarantined",
)
.filter((catalogEntry) => catalogEntry.firstSeenAt > watermark)
.filter((catalogEntry) =>
scopedIncludes.some((entry) =>
profileCoversCatalogScope({ entry, catalogEntry, catalogById }),
),
)
.filter(
(catalogEntry) =>
!input.entries.some((entry) =>
profileEntryMatchesCatalog(entry, catalogEntry),
),
(catalogEntry.status === "active" || catalogEntry.status === "quarantined")
&& catalogEntry.firstSeenAt > watermark
&& (coveredApplications.has(catalogEntry.applicationId) || coveredConnections.has(catalogEntry.connectionId))
&& !explicitlyReviewed(catalogEntry),
)
.map((catalogEntry) => ({
catalogEntryId: catalogEntry.id,
@@ -3,7 +3,7 @@ import type { Db } from "@paperclipai/db";
import { agents, toolCatalogEntries, toolProfileBindings, toolProfileEntries, toolProfiles } from "@paperclipai/db";
import type { ToolCatalogEntry, ToolConnection, ToolProfileEffectiveSummary, ToolProfileWithDetails } from "@paperclipai/shared";
import { effectiveToolProfileBindings, profileIdsInBindingOrder } from "../../tool-profile-binding-precedence.js";
import { buildProfileDetails, profileCatalogColumns, profileEntryMatchesCatalog, toProfileBinding, toProfileEntry, type ProfileCatalogEntry, type ProfileEntryMatch, type ProfileCatalogMatch } from "./projection.js";
import { buildProfileDetails, profileCatalogColumns, profileCatalogMatcher, toProfileBinding, toProfileEntry, type ProfileCatalogEntry, type ProfileEntryMatch, type ProfileCatalogMatch } from "./projection.js";
type ProfileAssignmentDependencies = {
assertOptionalAgent(companyId: string, agentId: string | null | undefined, label: string): Promise<void>;
resolveInstalledConnectionsForAgent(companyId: string, agentId: string): Promise<ToolConnection[]>;
@@ -37,18 +37,13 @@ function resolveCatalogAccess(
const excludes = profileEntries.filter(
(entry) => entry.effect === "exclude",
);
const included = profileCatalogMatcher(includes);
const excluded = profileCatalogMatcher(excludes);
for (const catalogEntry of catalog) {
if (
excludes.some((entry) =>
profileEntryMatchesCatalog(entry, catalogEntry),
)
)
continue;
if (excluded(catalogEntry)) continue;
if (
profile.defaultAction === "allow" ||
includes.some((entry) =>
profileEntryMatchesCatalog(entry, catalogEntry),
)
included(catalogEntry)
) {
allowedCatalogIds.add(catalogEntry.id);
allowedToolNames.add(catalogEntry.toolName);
@@ -25,6 +25,10 @@ export class WorkspaceSourceInspectionLimit extends Error {}
export function workspaceRecoverySourceEligible(includeCompleted = false) {
const actionEligible = sql`(${issueRecoveryActions.status} in ('active', 'escalated') or
(${issueRecoveryActions.status} = 'cancelled'
and ${inArray(issueRecoveryActions.cause, workspaceExportCauses)}
and jsonb_typeof(${issueRecoveryActions.evidence}#>'{workspaceRecovery,requestIds}') = 'array'
and ${issueRecoveryActions.evidence}#>'{workspaceRecovery,requestIds}' <> '[]'::jsonb) or
(${issueRecoveryActions.status} = 'resolved' and (
(${issueRecoveryActions.evidence}#>>'{automaticRecovery,policy}' = 'preserve_without_replay_v1'
and ${issueRecoveryActions.evidence}#>>'{automaticRecovery,replay}' = 'blocked'
@@ -49,6 +49,10 @@ import { and, eq, inArray, sql } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { agents, approvals, budgetPolicies, costEvents, completionContracts, issueApprovals, issueTreeHolds, issueTreeHoldMembers, companies, createDb, applyPendingMigrations, environmentLeases, environments, heartbeatRuns, issueComments, issueRecoveryActions, issues, nativeRunFinalizations, nativeRunResults, agentWakeupRequests, workspaceOperations } from "@paperclipai/db";
import { resumePreservedResultFinalization } from "./preserved-result-finalization.js";
import { finalizeNativeRun } from "./native-runtime/native-run-finalizer.js";
import { CONTROL_PLANE_CONFORMANCE_RESULT, CONTROL_PLANE_CONFORMANCE_TERMINAL } from "../vendor/paperclip-runner/testing.js";
import { issueThreadInteractions } from "@paperclipai/db";
import { issueRecoveryActionService } from "./issue-recovery-actions.js";
import type { AdapterSandboxExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
import type { EnvironmentLease } from "@paperclipai/shared";
import { startEmbeddedPostgresTestDatabase } from "../__tests__/helpers/embedded-postgres.js";
@@ -190,6 +194,149 @@ describe("automatic retained workspace recovery", () => {
const action = async (id: string) => (await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.id, id)))[0]!;
const run = async (id: string) => (await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, id)))[0]!;
function boardRecoveryApp() {
const app = express();
app.use(express.json());
app.use((req, _res, next) => { req.actor = { type: "board", source: "local_implicit", userId: "local-board" }; next(); });
app.use("/api", issueRoutes(db, {} as Parameters<typeof issueRoutes>[1]));
app.use(errorHandler);
return app;
}
async function acceptedRemoteExport() {
const f = await seed("native", false);
const source = await run(f.runId), workspaceId = randomUUID();
const remoteCwd = await realpath(f.remote);
const git = (args: string[], cwd = remoteCwd) => new Promise<string>((resolve, reject) =>
execFile("git", args, { cwd }, (error, stdout) => error ? reject(error) : resolve(stdout.trim())));
await git(["init"]);
await git(["add", "."]);
await git(["-c", "user.name=Recovery fixture", "-c", "user.email=recovery@example.invalid", "commit", "-m", "Completed work"]);
const commit = await git(["rev-parse", "HEAD"]);
targets.get(f.lease.id)!.remoteCwd = remoteCwd;
f.runtime.resumeRunLease.mockImplementation(async () => ({ providerLeaseId: f.providerLeaseId, metadata: { remoteCwd } }));
const authority = { schema: "paperclip.remote-workspace.v1", epoch: 1, snapshotId: null,
source: { kind: "empty" }, writerRunId: f.runId, dirty: true,
location: { environmentId: f.lease.environmentId!, leaseId: f.lease.id, providerLeaseId: f.providerLeaseId, remoteCwd } };
await db.insert(executionWorkspaces).values({ id: workspaceId, companyId: f.companyId, sourceIssueId: f.issueId,
name: "Completed remote work", cwd: remoteCwd, providerType: "cloud_sandbox", mode: "isolated_workspace",
strategyType: "project_primary", status: "active", metadata: { remoteWorkspace: authority } });
await db.update(issues).set({ executionWorkspaceId: workspaceId }).where(eq(issues.id, f.issueId));
const [lease] = await db.select().from(environmentLeases).where(eq(environmentLeases.id, f.lease.id));
await db.update(environmentLeases).set({ executionWorkspaceId: workspaceId, leasePolicy: "reuse_by_environment",
metadata: { ...lease.metadata, remoteCwd, agentId: source.agentId } }).where(eq(environmentLeases.id, lease.id));
const contractId = randomUUID(), resultId = randomUUID();
await db.insert(completionContracts).values({ id: contractId, companyId: f.companyId, issueId: f.issueId,
revision: 1, schemaVersion: "paperclip.completion-contract.v1", policyVersion: "test", risk: "low",
completionAuthority: "agent_claim_policy", incompleteCriteriaPolicy: "preserve_non_terminal",
contractJson: { revision: CONTROL_PLANE_CONFORMANCE_RESULT.completionClaim.contractRevision,
objective: "Complete the work", criteria: [{ id: "objective", requirement: "Complete the task" }] },
canonicalSha256: "contract", createdByActorType: "system", createdByActorId: "test" });
await db.update(heartbeatRuns).set({ completionContractId: contractId, resultJson: {}, runnerProfileJson: {
adapterDispatch: { adapterType: "paperclip_runner" },
remoteWorkspace: { schema: "paperclip.remote-workspace-run.v1", workspaceId, leaseId: lease.id,
providerLeaseId: f.providerLeaseId, remoteCwd, epoch: 1, state: "prepared", snapshotId: null },
} }).where(eq(heartbeatRuns.id, f.runId));
await db.insert(nativeRunResults).values({ id: resultId, companyId: f.companyId, issueId: f.issueId, runId: f.runId,
completionContractId: contractId, schemaStatus: "accepted", serverFingerprint: "result", canonicalSha256: "result",
resultJson: { terminal: CONTROL_PLANE_CONFORMANCE_TERMINAL, result: CONTROL_PLANE_CONFORMANCE_RESULT } });
await db.update(nativeRunFinalizations).set({ resultId, failureCode: "native_workspace_sync_out_retry_exhausted" })
.where(eq(nativeRunFinalizations.runId, f.runId));
await db.update(issueRecoveryActions).set({ cause: "native_workspace_sync_out_retry_exhausted", evidence: { runId: f.runId } })
.where(eq(issueRecoveryActions.id, f.actionId));
await adoptSystemRecovery(db, f);
return { ...f, source, workspaceId, resultId, git, commit };
}
it.each([false, true])("keeps Retry behind an accepted remote export and resumes only its saved result (review pending: %s)", async reviewPending => {
const f = await acceptedRemoteExport();
const { source, workspaceId, resultId, git, commit } = f;
const interactionId = randomUUID();
if (reviewPending) await db.insert(issueThreadInteractions).values({ id: interactionId, companyId: f.companyId,
issueId: f.issueId, sourceRunId: f.runId, createdByAgentId: source.agentId, kind: "request_confirmation",
title: "Review saved work", effectiveResolverPolicy: "human_only",
payload: { version: 1, prompt: "Review before publishing", rejectRequiresReason: true,
target: { type: "custom", key: "layout_review", label: "Review saved work" } } });
await db.insert(heartbeatRuns).values({ companyId: f.companyId, agentId: source.agentId, status: "running" });
const app = boardRecoveryApp();
const intent = { observedRunId: f.runId, idempotencyKey: randomUUID() };
const retry = (await request(app).post(`/api/issues/${f.issueId}/retry`).send(intent).expect(202)).body;
expect((await request(app).post(`/api/issues/${f.issueId}/retry`).send(intent).expect(202)).body.requestId).toBe(retry.requestId);
await heartbeatService(db).resumeTaskRetry(f.companyId, retry.requestId!);
expect(await db.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, f.companyId),
sql`coalesce(${heartbeatRuns.nativeIssueId}::text, ${heartbeatRuns.issueId}::text,
${heartbeatRuns.contextSnapshot}->>'issueId') = ${f.issueId}`))).toHaveLength(1);
expect(await getExecutionBlocker(db, f.companyId, f.issueId)).toMatchObject({ runId: f.runId, workspaceRepairRequired: true });
await Promise.all(await reconcileRetainedWorkspaces(db, f.environmentRuntime, async () => {
await heartbeatService(db).resumeTaskRetry(f.companyId, retry.requestId!);
}) ?? []);
expect(readSystemRecovery((await action(f.actionId)).evidence)?.state).toBe("complete");
const [workspace] = await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, workspaceId));
const saved = readRemoteWorkspaceAuthority(workspace.metadata)!;
const restored = await createSavedWorkspaceLoader(db)({ companyId: f.companyId, workspaceId, snapshotId: saved.snapshotId! });
try {
expect(await readFile(path.join(restored.root, "saved.txt"), "utf8")).toBe("finished work\n");
expect(await git(["rev-parse", "HEAD"], restored.root)).toBe(commit);
}
finally { await restored.cleanup(); }
const [coordinator] = await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.runId));
expect(coordinator).toMatchObject({ phase: "workspace_finalizing", resultId });
expect((await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, retry.requestId!)))[0])
.toMatchObject({ status: "coalesced", runId: f.runId, payload: { taskRetry: { strategy: "finalize_result" } } });
expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, f.companyId))).toHaveLength(2);
await finalizeNativeRun({ db, runId: f.runId, workspaceFinalizeStatus: "succeeded", projectRunStatus: true });
expect((await db.select().from(issues).where(eq(issues.id, f.issueId)))[0])
.toMatchObject({ status: reviewPending ? "in_review" : "done", executionRunId: null, checkoutRunId: null });
expect((await run(f.runId)).status).toBe("succeeded");
if (reviewPending) expect((await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, interactionId)))[0])
.toMatchObject({ status: "pending", result: null, resolvedAt: null });
}, 30_000);
it("preserves an explicitly requested superseded remote export without applying its result over newer work", async () => {
const f = await acceptedRemoteExport();
const [later] = await db.insert(heartbeatRuns).values({ companyId: f.companyId, agentId: f.source.agentId,
issueId: f.issueId, status: "cancelled", runtimeMode: "legacy", finishedAt: new Date(),
contextSnapshot: { issueId: f.issueId }, createdAt: new Date(f.source.createdAt.getTime() + 1_000) }).returning();
const [currentWorkspace] = await db.insert(executionWorkspaces).values({ companyId: f.companyId,
sourceIssueId: f.issueId, name: "Newer workspace", cwd: path.join(root, later.id), providerType: "cloud_sandbox",
mode: "isolated_workspace", strategyType: "project_primary", status: "active" }).returning();
await db.update(issues).set({ executionWorkspaceId: currentWorkspace.id }).where(eq(issues.id, f.issueId));
const laterAction = await issueRecoveryActionService(db).upsertSourceScoped({ companyId: f.companyId,
sourceIssueId: f.issueId, kind: "active_run_watchdog", cause: "legacy_execution_requires_reconciliation",
fingerprint: later.id, evidence: { runId: later.id }, nextAction: "Inspect the stopped work",
ownerType: "board", supersedeOnIdentityChange: true });
expect((await action(f.actionId)).status).toBe("cancelled");
await Promise.all(await reconcileRetainedWorkspaces(db, f.environmentRuntime, async () => {}) ?? []);
expect(f.runtime.resumeRunLease).not.toHaveBeenCalled();
const app = boardRecoveryApp();
const recoveryRequest = { actionId: f.actionId, runId: f.runId, idempotencyKey: randomUUID() };
await request(app).post(`/api/issues/${f.issueId}/recovery-actions/recover-workspace`).send(recoveryRequest).expect(202);
const admitted = await action(f.actionId);
await request(app).post(`/api/issues/${f.issueId}/recovery-actions/recover-workspace`).send(recoveryRequest).expect(202);
expect(await action(f.actionId)).toEqual(admitted);
await db.update(heartbeatRuns).set({ status: "running", finishedAt: null }).where(eq(heartbeatRuns.id, later.id));
await Promise.all(await reconcileRetainedWorkspaces(db, f.environmentRuntime, async () => {}) ?? []);
expect(readSystemRecovery((await action(f.actionId)).evidence)?.failure?.code).toBe("workspace_writer_active");
expect(f.runtime.resumeRunLease).not.toHaveBeenCalled();
await db.update(heartbeatRuns).set({ status: "cancelled", finishedAt: new Date() }).where(eq(heartbeatRuns.id, later.id));
await makeRetryDue(f.actionId);
await Promise.all(await reconcileRetainedWorkspaces(db, f.environmentRuntime, async () => {}) ?? []);
expect(readSystemRecovery((await action(f.actionId)).evidence)?.state).toBe("complete");
const [workspace] = await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, f.workspaceId));
const saved = readRemoteWorkspaceAuthority(workspace.metadata)!;
const restored = await createSavedWorkspaceLoader(db)({ companyId: f.companyId, workspaceId: f.workspaceId, snapshotId: saved.snapshotId! });
try {
expect(await f.git(["rev-parse", "HEAD"], restored.root)).toBe(f.commit);
expect(await readFile(path.join(restored.root, "saved.txt"), "utf8")).toBe("finished work\n");
} finally { await restored.cleanup(); }
expect((await db.select().from(issues).where(eq(issues.id, f.issueId)))[0])
.toMatchObject({ status: "blocked", executionWorkspaceId: currentWorkspace.id });
expect(await action(laterAction.id)).toEqual(expect.objectContaining({ status: "active", evidence: { runId: later.id } }));
expect((await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.runId)))[0])
.toMatchObject({ phase: "terminal_failure", resultId: f.resultId });
expect(await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, f.companyId))).toEqual([]);
}, 30_000);
it.each([
{ status: "failed", runtimeMode: "native", uploadFailure: "none" },
{ status: "cancelled", runtimeMode: "native", uploadFailure: "none" },