mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-07 07:55:47 +01:00
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:
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] };
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() } : {}),
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in new issue
Block a user