mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 06:42:06 +01:00
Reconcile Pi prerequisites with the integrated runtime while holding admission
This commit is contained in:
1 parent
20ce062701
commit
f620e7157b
313 files changed
+13086
-2187
No files matched your search
@@ -13,7 +13,7 @@ import { HttpError, conflict, notFound } from "../errors.js";
|
||||
import type { AuthorizationActor } from "./authorization.js";
|
||||
import type { EnvironmentRuntimeService } from "./environment-runtime.js";
|
||||
import type { Environment, EnvironmentLease } from "@paperclipai/shared";
|
||||
import { agentDirectoryBaselineDigest, agentDirectoryProbeProgram, agentDirectoryTransferCleanupProgram, observeLocalAgentDirectory } from "./agent-directory-probe.js";
|
||||
import { agentDirectoryBaselineDigest, agentDirectoryProbeProgram, observeLocalAgentDirectory } from "./agent-directory-probe.js";
|
||||
import { hasRemoteTerminationReceipt } from "./remote-execution-termination.js";
|
||||
import { cachedAgentFileManifest, captureAgentFileCheckpoint, checkpointBaseline, checkpointSnapshot, type AgentFileManifest } from "./agent-file-checkpoints.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
@@ -23,6 +23,10 @@ export class AgentDirectoryReuseInvalidatedError extends Error {}
|
||||
const completed = new Set(["saved", "unchanged", "resolved", "unavailable"]);
|
||||
const transports = new Map<string, PreparedAdapterExecutionTargetRuntime>();
|
||||
const key = (row: Pick<Copy, "companyId" | "runId">) => `${row.companyId}:${row.runId}`;
|
||||
function sandboxTransferRoot(row: Copy, remoteCwd: string): string {
|
||||
const origin = typeof row.receipt?.materializationRunId === "string" ? row.receipt.materializationRunId : String(row.receipt?.directoryRunId ?? row.runId);
|
||||
return path.posix.join(remoteCwd, ".paperclip-runtime", "paperclip-runner", "agent-file-transfers", row.agentId, origin);
|
||||
}
|
||||
export function isAgentDirectoryCopy(row: Pick<Copy, "receipt"> | null): boolean {
|
||||
return row?.receipt?.schema === AGENT_FILES_CONTRACT;
|
||||
}
|
||||
@@ -62,10 +66,30 @@ export function agentDirectoryWorkingCopyService(db: Db, get: (companyId: string
|
||||
}
|
||||
return prepareAdapterExecutionTargetRuntime({ target, runId: row.runId, adapterKey: "agent-files",
|
||||
workspaceLocalDir: row.localRoot, workspaceRemoteDir: row.executionRoot,
|
||||
// Agent files are an independently observed native directory. Transfer
|
||||
// scratch belongs to the host runtime, including the fallback selected
|
||||
// while plugin capability discovery is cold after controller restart.
|
||||
runtimeRootDir: sandboxTransferRoot(row, target.remoteCwd),
|
||||
syncWorkspace: true, workspaceInboundMode: recovering ? "adopt_remote" : undefined,
|
||||
workspaceBaseline: baseline(row), workspaceGitSnapshot: null, workspaceFileMode: "all",
|
||||
workspaceExclude: [".paperclip-runtime", ".paperclip-runtime/**"] });
|
||||
}
|
||||
async function observe(row: Copy, target?: AdapterExecutionTarget | null) {
|
||||
if (row.location === "local") {
|
||||
// The same bounded program validates aliases on its actual host.
|
||||
return observeLocalAgentDirectory(row.localRoot);
|
||||
}
|
||||
if (!target || target.kind !== "remote" || row.location !== `remote:${target.environmentId ?? ""}`) throw new Error("Agent directory environment changed");
|
||||
const origin = typeof row.receipt?.materializationRunId === "string" ? row.receipt.materializationRunId : row.runId;
|
||||
if (row.executionRoot !== path.posix.join(target.remoteCwd, ".paperclip-runtime", "agent-files", row.agentId, origin)) throw new Error("Agent directory root changed");
|
||||
const quote = (value: string) => `'${value.replaceAll("'", `'"'"'`)}'`;
|
||||
const result = await runAdapterExecutionTargetShellCommand(row.runId, target,
|
||||
`node -e ${quote(agentDirectoryProbeProgram)} ${quote(row.executionRoot)}`, { cwd: target.remoteCwd, env: {}, timeoutSec: 10 });
|
||||
if (result.exitCode !== 0 || result.timedOut || result.stdout.length > 1024) throw new Error("Agent directory observation unavailable");
|
||||
const value = JSON.parse(result.stdout);
|
||||
if (!value || typeof value.digest !== "string" || !/^[a-f0-9]{64}$/.test(value.digest) || typeof value.identity !== "string") throw new Error("Invalid agent directory observation");
|
||||
return value as { digest: string; identity: string };
|
||||
}
|
||||
async function prepareCopy(input: { companyId: string; agentId: string; runId: string; target?: AdapterExecutionTarget | null; cwd: string; warm?: boolean; reuseRunId?: string; onWarmHandoff?: (copy: Copy) => void }, serialHeld = false): Promise<Copy | null> {
|
||||
const [agent] = await db.select().from(agents).where(and(eq(agents.id, input.agentId), eq(agents.companyId, input.companyId)));
|
||||
if (!agent) throw notFound("Agent not found");
|
||||
@@ -189,11 +213,7 @@ export function agentDirectoryWorkingCopyService(db: Db, get: (companyId: string
|
||||
const runtime = await transport(row, input.target, false);
|
||||
transports.set(key(row), runtime);
|
||||
if (input.target.transport === "sandbox") {
|
||||
if (runtime.runtimeRootDir !== path.posix.join(row.executionRoot, ".paperclip-runtime", "agent-files")) throw new Error("Agent directory transfer scratch path changed");
|
||||
const retired = await runAdapterExecutionTargetShellCommand(input.runId, input.target,
|
||||
`node -e ${quote(agentDirectoryTransferCleanupProgram)} ${quote(row.executionRoot)}`,
|
||||
{ cwd: input.target.remoteCwd, env: {}, timeoutSec: 10 });
|
||||
if (retired.exitCode !== 0 || retired.timedOut) throw new Error("Agent directory transfer scratch could not be safely retired");
|
||||
if (runtime.runtimeRootDir !== sandboxTransferRoot(row, input.target.remoteCwd)) throw new Error("Agent directory transfer scratch path changed");
|
||||
}
|
||||
}
|
||||
const observed = await observe(row, input.target).catch(() => null);
|
||||
@@ -394,22 +414,36 @@ export function agentDirectoryWorkingCopyService(db: Db, get: (companyId: string
|
||||
async function release(row: Copy, target?: AdapterExecutionTarget | null) {
|
||||
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;
|
||||
// Collection and environment teardown can both release the same copy.
|
||||
// A compact successful cleanup receipt is final, even after restart.
|
||||
if (completed.has(row.state) && row.processStoppedAt && row.receipt?.cleanupPending === false) return;
|
||||
const runtime = transports.get(key(row));
|
||||
transports.delete(key(row));
|
||||
let cleanupPending = false;
|
||||
await runtime?.cleanupWorkspaceSnapshot?.().catch(() => { cleanupPending = true; });
|
||||
const cleanupTarget = target ?? runtime?.target;
|
||||
if (!destroyedOnly && row.processStoppedAt && completed.has(row.state) && cleanupTarget?.kind === "remote") {
|
||||
const expected = path.posix.join(cleanupTarget.remoteCwd, ".paperclip-runtime", "agent-files", row.agentId, String(row.receipt?.directoryRunId ?? row.runId));
|
||||
const lease = row.location !== "local" ? await ownedRemoteLease(row) : null;
|
||||
const terminated = lease && hasRemoteTerminationReceipt(lease);
|
||||
const destroyed = destroyedOnly || terminated && (lease.metadata?.remoteExecutionTermination as { state?: string } | undefined)?.state === "destroyed";
|
||||
const cleanupLeaseId = (row.receipt?.cleanup as { leaseId?: string } | undefined)?.leaseId;
|
||||
// Maintenance may run while the collector is still cached. A cached target
|
||||
// cannot override the current lease's stopped/destroyed/uncertain state.
|
||||
const remoteUnavailable = row.location !== "local" && !destroyed && (!lease || terminated
|
||||
|| Boolean(lease && (lease.releasedAt || lease.status !== "active"))
|
||||
|| Boolean(cleanupLeaseId && (!lease || cleanupTarget?.kind === "remote" && cleanupTarget.leaseId !== lease.id)));
|
||||
if (remoteUnavailable) cleanupPending = true;
|
||||
else if (!destroyed && row.processStoppedAt && completed.has(row.state) && cleanupTarget?.kind === "remote") {
|
||||
const expected = path.posix.join(cleanupTarget.remoteCwd, ".paperclip-runtime", "agent-files", row.agentId, typeof row.receipt?.materializationRunId === "string" ? row.receipt.materializationRunId : String(row.receipt?.directoryRunId ?? row.runId));
|
||||
if (row.executionRoot !== expected) throw new Error("Agent directory cleanup path changed");
|
||||
const quoted = `'${expected.replaceAll("'", `'"'"'`)}'`;
|
||||
const paths = cleanupTarget.transport === "sandbox" ? [expected, sandboxTransferRoot(row, cleanupTarget.remoteCwd)] : [expected];
|
||||
const quoted = paths.map(p => `'${p.replaceAll("'", `'"'"'`)}'`).join(" ");
|
||||
const remoteCleanupFailed = await runAdapterExecutionTargetShellCommand(row.runId, cleanupTarget, `rm -rf -- ${quoted}`,
|
||||
{ cwd: cleanupTarget.remoteCwd, env: {}, timeoutSec: 15 }).then(result => result.exitCode !== 0 || result.timedOut, () => true);
|
||||
cleanupPending ||= remoteCleanupFailed;
|
||||
} else if (row.processStoppedAt && completed.has(row.state) && row.location !== "local") {
|
||||
// Rebind only the original host-owned lease. Never acquire a replacement
|
||||
// sandbox or persist transport credentials in a working-copy receipt.
|
||||
cleanupPending ||= !await cleanupRemoteAfterRestart(row, destroyedOnly).catch(() => false);
|
||||
} else if (!destroyed && row.processStoppedAt && completed.has(row.state) && row.location !== "local") {
|
||||
// Rebind only the current host-owned lease. Never acquire a replacement.
|
||||
cleanupPending ||= !await cleanupRemoteAfterRestart(row).catch(() => false);
|
||||
}
|
||||
if (completed.has(row.state) && row.processStoppedAt) {
|
||||
try { await fs.rm(path.dirname(row.localRoot), { recursive: true, force: true }); }
|
||||
@@ -437,7 +471,7 @@ export function agentDirectoryWorkingCopyService(db: Db, get: (companyId: string
|
||||
if (lease.environmentId !== null && row.location !== `remote:${lease.environmentId}`) return false;
|
||||
const remoteCwd = cleanup?.remoteCwd ?? lease.metadata?.remoteCwd;
|
||||
return typeof remoteCwd === "string" &&
|
||||
row.executionRoot === path.posix.join(remoteCwd, ".paperclip-runtime", "agent-files", row.agentId, String(row.receipt?.directoryRunId ?? row.runId)) &&
|
||||
row.executionRoot === path.posix.join(remoteCwd, ".paperclip-runtime", "agent-files", row.agentId, String(row.receipt?.materializationRunId ?? row.receipt?.directoryRunId ?? row.runId)) &&
|
||||
hasRemoteTerminationReceipt(lease) && (lease.metadata?.remoteExecutionTermination as { state?: string }).state === "destroyed";
|
||||
}
|
||||
async function recoverUnavailable(row: Copy) {
|
||||
@@ -455,10 +489,36 @@ export function agentDirectoryWorkingCopyService(db: Db, get: (companyId: string
|
||||
if (confirmed.state === "unavailable" && confirmed.processStoppedAt) await release(confirmed);
|
||||
}, "agent_directory_release");
|
||||
}
|
||||
async function cleanupRemoteAfterRestart(row: Copy, destroyedOnly = false): Promise<boolean> {
|
||||
// Newly recovered loss receipts permit no command that could wake a stopped
|
||||
// provider. Recheck the exact destruction binding even after lock acquisition.
|
||||
if (destroyedOnly) return destroyedRemoteCopy(row);
|
||||
async function ownedRemoteLease(row: Copy) {
|
||||
const cleanup = row.receipt?.cleanup as { leaseId?: string } | undefined;
|
||||
const leases = await db.select().from(environmentLeases).where(and(
|
||||
eq(environmentLeases.companyId, row.companyId), eq(environmentLeases.heartbeatRunId, row.runId),
|
||||
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 destroyedWithoutEnvironment = lease.environmentId === null && hasRemoteTerminationReceipt(lease)
|
||||
&& (lease.metadata?.remoteExecutionTermination as { state?: string } | undefined)?.state === "destroyed";
|
||||
if (row.location !== `remote:${lease.environmentId}` && !destroyedWithoutEnvironment) return null;
|
||||
return lease;
|
||||
}
|
||||
async function recoverStoppedRemote(row: Copy) {
|
||||
const lease = await ownedRemoteLease(row);
|
||||
const destroyed = lease && hasRemoteTerminationReceipt(lease)
|
||||
&& (lease.metadata?.remoteExecutionTermination as { state?: string } | undefined)?.state === "destroyed";
|
||||
if (!destroyed) {
|
||||
// execute (including bypassSession) may restart a stopped sandbox. Keep
|
||||
// the only remaining bytes; a stop receipt is not a destruction receipt.
|
||||
return patch(row, { state: "pending_collection", errorCode: lease ? "INSTRUCTION_STOPPED_REMOTE_COLLECTION_PENDING" : "INSTRUCTION_REMOTE_LEASE_UNVERIFIED",
|
||||
errorMessage: lease ? "The remote provider stopped with its agent directory retained. Safe stopped-file retrieval is unavailable; no save or discard is claimed."
|
||||
: "The current remote lease could not be verified. Its agent directory is preserved; no retrieval, save or discard is claimed.", nextAttemptAt: new Date(Date.now() + 30_000) });
|
||||
}
|
||||
row = await patch(row, { state: "unavailable", processStoppedAt: row.processStoppedAt ?? new Date(),
|
||||
errorCode: "INSTRUCTION_COLLECTION_UNAVAILABLE", errorMessage: "The exact remote sandbox was destroyed before its agent directory was retrieved. No instruction save is claimed.", nextAttemptAt: null });
|
||||
await release(row);
|
||||
return (await get(row.companyId, row.runId))!;
|
||||
}
|
||||
async function cleanupRemoteAfterRestart(row: Copy): Promise<boolean> {
|
||||
const cleanup = row.receipt?.cleanup as { leaseId?: string; remoteCwd?: string } | undefined;
|
||||
const lease = await ownedRemoteLease(row);
|
||||
if (!lease) return false;
|
||||
@@ -471,7 +531,8 @@ export function agentDirectoryWorkingCopyService(db: Db, get: (companyId: string
|
||||
if (!environment) return false;
|
||||
const remoteCwd = cleanup?.remoteCwd ?? lease.metadata?.remoteCwd;
|
||||
if (typeof remoteCwd !== "string" || row.executionRoot !== path.posix.join(remoteCwd, ".paperclip-runtime", "agent-files", row.agentId, typeof row.receipt?.materializationRunId === "string" ? row.receipt.materializationRunId : String(row.receipt?.directoryRunId ?? row.runId))) return false;
|
||||
const result = await environmentRuntime.execute({ environment: environment as Environment, lease: lease as EnvironmentLease, command: "rm", args: ["-rf", "--", row.executionRoot],
|
||||
const paths = environment.driver === "sandbox" ? [row.executionRoot, sandboxTransferRoot(row, remoteCwd)] : [row.executionRoot];
|
||||
const result = await environmentRuntime.execute({ environment: environment as Environment, lease: lease as EnvironmentLease, command: "rm", args: ["-rf", "--", ...paths],
|
||||
cwd: remoteCwd, env: {}, timeoutMs: 15_000, bypassSession: true });
|
||||
return result.exitCode === 0 && !result.timedOut;
|
||||
}
|
||||
@@ -484,7 +545,8 @@ export function agentDirectoryWorkingCopyService(db: Db, get: (companyId: string
|
||||
return fn(current);
|
||||
}, process.env, operation);
|
||||
}
|
||||
return { prepare: (input: Parameters<typeof prepareCopy>[0]) => prepareCopy(input), hasChanges, canReuse, recoverUnavailable,
|
||||
return { prepare: (input: Parameters<typeof prepareCopy>[0]) => prepareCopy(input), hasChanges, adopt, canReuse, recoverUnavailable,
|
||||
recoverStoppedRemote: (row: Copy) => serial(row, current => current.receipt?.retainedByRunId ? Promise.resolve(current) : recoverStoppedRemote(current), "agent_directory_collect"),
|
||||
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) => {
|
||||
|
||||
@@ -328,12 +328,13 @@ export function agentInstructionWorkingCopyService(db: Db, options: { environmen
|
||||
async function recoverStopped() {
|
||||
const pending = await db.select({ copy: copies, runtimeMode: heartbeatRuns.runtimeMode }).from(copies)
|
||||
.innerJoin(heartbeatRuns, and(eq(heartbeatRuns.companyId, copies.companyId), eq(heartbeatRuns.id, copies.runId)))
|
||||
.where(and(or(and(or(inArray(copies.state, ["prepared", "pending_collection", "warm_saved"]),
|
||||
.where(and(or(and(or(inArray(copies.state, ["prepared", "pending_collection", "unchanged_turn", "warm_saved"]),
|
||||
and(eq(copies.state, "preparing"), sql`${copies.receipt}->>'schema' = 'paperclip.agent-files.v1'`)),
|
||||
lte(copies.attempts, MAX_COLLECTION_ATTEMPTS - 1)),
|
||||
and(eq(copies.state, "unavailable"), isNull(copies.processStoppedAt),
|
||||
sql`${copies.location} like 'remote:%'`,
|
||||
sql`${copies.receipt}->>'schema' = 'paperclip.agent-files.v1'`)),
|
||||
sql`NOT (coalesce(${copies.receipt}, '{}'::jsonb) ? 'retainedByRunId')`,
|
||||
inArray(heartbeatRuns.status, ["succeeded", "failed", "cancelled", "timed_out", "interrupted"]),
|
||||
or(isNull(copies.nextAttemptAt), lte(copies.nextAttemptAt, new Date())))).orderBy(asc(copies.updatedAt)).limit(20);
|
||||
for (const { copy: row, runtimeMode } of pending) {
|
||||
@@ -363,8 +364,8 @@ export function agentInstructionWorkingCopyService(db: Db, options: { environmen
|
||||
}
|
||||
} else if (row.state === "prepared") {
|
||||
await patch(row, { state: "pending_collection", errorCode: "INSTRUCTION_STOP_UNCONFIRMED",
|
||||
errorMessage: "The provider's stop has not been confirmed. Instruction collection is pending; no save is claimed.", nextAttemptAt: null });
|
||||
} else if (row.state === "warm_saved") {
|
||||
errorMessage: "The provider's stop has not been confirmed. Instruction collection is pending; no save is claimed.", nextAttemptAt: new Date(Date.now() + 30_000) });
|
||||
} else if (["warm_saved", "pending_collection", "unchanged_turn"].includes(row.state)) {
|
||||
// Live retained sessions must not occupy every batch and starve stopped
|
||||
// copies from other agents. This does not permit reads without stop proof.
|
||||
await patch(row, { nextAttemptAt: new Date(Date.now() + 30_000) });
|
||||
|
||||
@@ -273,6 +273,8 @@ export function environmentRunOrchestrator(
|
||||
agentId: string;
|
||||
persistedExecutionWorkspace: Pick<ExecutionWorkspace, "id" | "mode"> | null;
|
||||
executionWorkspaceSettings: IssueExecutionWorkspaceSettings | null;
|
||||
/** Existing live runner recovery must use its original active lease, including ephemeral leases. */
|
||||
reattachRemoteLease?: { leaseId: string; providerLeaseId: string; remoteCwd: string };
|
||||
}): Promise<EnvironmentAcquisitionResult> {
|
||||
// Step 1: Resolve environment
|
||||
const selectedEnvironment = await resolveEnvironment({
|
||||
@@ -286,7 +288,7 @@ export function environmentRunOrchestrator(
|
||||
);
|
||||
|
||||
// Step 2: Acquire lease
|
||||
const leaseRecord = await acquireLease({
|
||||
const acquisitionInput = {
|
||||
companyId: input.companyId,
|
||||
environment,
|
||||
issueId: input.issueId,
|
||||
@@ -295,7 +297,29 @@ export function environmentRunOrchestrator(
|
||||
persistedExecutionWorkspace: input.persistedExecutionWorkspace,
|
||||
executionWorkspaceSettings: input.executionWorkspaceSettings,
|
||||
adapterType: input.adapterType ?? null,
|
||||
});
|
||||
};
|
||||
let leaseRecord: EnvironmentRuntimeLeaseRecord;
|
||||
if (input.reattachRemoteLease) {
|
||||
// Reattachment is inspection of an already running sandbox, never a new
|
||||
// acquisition or a lifecycle resume. Those paths can create a replacement
|
||||
// when the admitted per-turn policy deliberately disables reusable leases.
|
||||
const expected = input.reattachRemoteLease;
|
||||
const lease = await environmentsSvc.getLeaseById(expected.leaseId);
|
||||
if (!lease || environment.driver !== "sandbox" ||
|
||||
lease.companyId !== input.companyId || lease.environmentId !== environment.id ||
|
||||
lease.heartbeatRunId !== input.heartbeatRunId || lease.issueId !== input.issueId ||
|
||||
lease.executionWorkspaceId !== (input.persistedExecutionWorkspace?.id ?? null) ||
|
||||
lease.metadata?.agentId !== input.agentId || lease.status !== "active" ||
|
||||
lease.releasedAt !== null || lease.cleanupStatus !== null ||
|
||||
(lease.expiresAt !== null && new Date(lease.expiresAt).getTime() <= Date.now()) ||
|
||||
lease.provider !== environment.config.provider || lease.providerLeaseId !== expected.providerLeaseId ||
|
||||
lease.metadata?.remoteCwd !== expected.remoteCwd) {
|
||||
throw new Error("native_remote_recovery_lease_mismatch");
|
||||
}
|
||||
leaseRecord = { environment, lease, leaseContext: buildEnvironmentLeaseContext(input) };
|
||||
} else {
|
||||
leaseRecord = await acquireLease(acquisitionInput);
|
||||
}
|
||||
|
||||
// Step 3: Log lease acquisition activity
|
||||
await logActivity(db, {
|
||||
|
||||
@@ -3875,6 +3875,86 @@ export function environmentRuntimeService(
|
||||
return driver;
|
||||
}
|
||||
|
||||
async function destroyScopedReusableSandboxLease(input: {
|
||||
environment: Environment;
|
||||
leaseRow: typeof environmentLeases.$inferSelect;
|
||||
scopeCondition?: ReturnType<typeof and>;
|
||||
failureReason: string;
|
||||
}): Promise<EnvironmentLease | null> {
|
||||
const now = new Date();
|
||||
const attemptId = randomUUID();
|
||||
// Persist recovery ownership before any provider work. Re-check scope and
|
||||
// run liveness in this write: the earlier selection cannot fence a resume.
|
||||
// The in-flight lease also excludes concurrent closures and cleanup sweeps.
|
||||
const [claimed] = await db.update(environmentLeases).set({
|
||||
status: "pending_cleanup",
|
||||
failureReason: input.failureReason,
|
||||
cleanupStatus: "failed",
|
||||
releasedAt: now,
|
||||
lastUsedAt: now,
|
||||
updatedAt: now,
|
||||
metadata: sql`(case when jsonb_typeof(${environmentLeases.metadata}) = 'object'
|
||||
then ${environmentLeases.metadata} else '{}'::jsonb end - 'remoteExecutionTermination')
|
||||
|| ${JSON.stringify({ pendingCleanupAttemptId: attemptId, pendingCleanupInFlight: true,
|
||||
pendingCleanupLeaseExpiresAtMs: Date.now() + 15 * 60_000 })}::jsonb`,
|
||||
}).where(and(
|
||||
eq(environmentLeases.id, input.leaseRow.id),
|
||||
eq(environmentLeases.companyId, input.leaseRow.companyId),
|
||||
eq(environmentLeases.environmentId, input.environment.id),
|
||||
eq(environmentLeases.leasePolicy, "reuse_by_environment"),
|
||||
eq(environmentLeases.status, input.leaseRow.status),
|
||||
input.scopeCondition,
|
||||
sql`coalesce(${environmentLeases.metadata}->>'pendingCleanupInFlight', 'false') != 'true'`,
|
||||
sql`NOT EXISTS (
|
||||
SELECT 1 FROM ${heartbeatRuns}
|
||||
WHERE ${heartbeatRuns.id} = ${environmentLeases.heartbeatRunId}
|
||||
AND ${heartbeatRuns.status} IN ('queued', 'scheduled_retry', 'running')
|
||||
)`,
|
||||
)).returning();
|
||||
if (!claimed) return null;
|
||||
const lease = toEnvironmentLeaseSnapshot(claimed);
|
||||
let renewalInFlight = false;
|
||||
const renewal = setInterval(() => {
|
||||
if (renewalInFlight) return;
|
||||
renewalInFlight = true;
|
||||
void db.update(environmentLeases).set({
|
||||
metadata: sql`${environmentLeases.metadata} || ${JSON.stringify({
|
||||
pendingCleanupLeaseExpiresAtMs: Date.now() + 15 * 60_000,
|
||||
})}::jsonb`,
|
||||
}).where(and(eq(environmentLeases.id, lease.id), eq(environmentLeases.status, "pending_cleanup"),
|
||||
sql`${environmentLeases.metadata}->>'pendingCleanupAttemptId' = ${attemptId}`,
|
||||
sql`${environmentLeases.metadata}->>'pendingCleanupInFlight' = 'true'`,
|
||||
)).then(() => {}).catch(() => {
|
||||
logger.warn({ leaseId: lease.id }, "scoped cleanup ownership renewal failed");
|
||||
}).finally(() => { renewalInFlight = false; });
|
||||
}, 30_000);
|
||||
renewal.unref();
|
||||
try {
|
||||
const driver = getDriver(getLeaseDriverKey(lease, input.environment));
|
||||
if (!driver?.destroyRunLease) return lease;
|
||||
return await driver.destroyRunLease({
|
||||
environment: input.environment,
|
||||
lease,
|
||||
failureReason: input.failureReason,
|
||||
}) ?? lease;
|
||||
} catch {
|
||||
// Even a failed settlement write leaves the durable provider handle for
|
||||
// the sweep. Continue with later leases without logging provider errors.
|
||||
return lease;
|
||||
} finally {
|
||||
clearInterval(renewal);
|
||||
// A crash skips this write; the bounded ownership lease lets the sweep
|
||||
// recover. A completed failure becomes eligible for a later explicit retry.
|
||||
await db.update(environmentLeases).set({
|
||||
metadata: sql`${environmentLeases.metadata} || '{"pendingCleanupInFlight":false}'::jsonb`,
|
||||
}).where(and(eq(environmentLeases.id, lease.id),
|
||||
sql`${environmentLeases.metadata}->>'pendingCleanupAttemptId' = ${attemptId}`,
|
||||
)).catch(() => {
|
||||
logger.warn({ leaseId: lease.id }, "scoped cleanup ownership settlement failed");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
getDriver,
|
||||
|
||||
@@ -4180,7 +4260,7 @@ export function environmentRuntimeService(
|
||||
and(
|
||||
eq(environmentLeases.companyId, input.companyId),
|
||||
eq(environmentLeases.leasePolicy, "reuse_by_environment"),
|
||||
inArray(environmentLeases.status, ["active", "released", "retained", "pending_cleanup"]),
|
||||
inArray(environmentLeases.status, ["active", "released", "retained", "failed", "pending_cleanup"]),
|
||||
...scopeConditions,
|
||||
),
|
||||
);
|
||||
@@ -4222,18 +4302,12 @@ export function environmentRuntimeService(
|
||||
? await environmentsSvc.getById(leaseRow.environmentId)
|
||||
: null;
|
||||
if (!environment) continue;
|
||||
const leaseSnapshot = toEnvironmentLeaseSnapshot(leaseRow);
|
||||
const driver = getDriver(getLeaseDriverKey(leaseSnapshot, environment));
|
||||
const lease = driver?.destroyRunLease
|
||||
? await driver.destroyRunLease({
|
||||
environment,
|
||||
lease: leaseSnapshot,
|
||||
failureReason: input.failureReason ?? "reusable_lease_destroyed",
|
||||
})
|
||||
: await environmentsSvc.releaseLease(leaseSnapshot.id, "pending_cleanup", {
|
||||
failureReason: input.failureReason ?? "reusable_lease_destroyed",
|
||||
cleanupStatus: "failed",
|
||||
});
|
||||
const lease = await destroyScopedReusableSandboxLease({
|
||||
environment,
|
||||
leaseRow,
|
||||
scopeCondition: and(...scopeConditions),
|
||||
failureReason: input.failureReason ?? "reusable_lease_destroyed",
|
||||
});
|
||||
if (!lease) continue;
|
||||
destroyed.push({
|
||||
environment,
|
||||
@@ -4271,7 +4345,9 @@ export function environmentRuntimeService(
|
||||
and(
|
||||
eq(environmentLeases.environmentId, input.environmentId),
|
||||
eq(environmentLeases.leasePolicy, "reuse_by_environment"),
|
||||
inArray(environmentLeases.status, ["active", "released", "retained"]),
|
||||
// A failed run may have stopped its reusable sandbox without
|
||||
// destroying it; that provider handle still needs scoped teardown.
|
||||
inArray(environmentLeases.status, ["active", "released", "retained", "failed"]),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -4302,73 +4378,20 @@ export function environmentRuntimeService(
|
||||
let failed = 0;
|
||||
let skippedLiveRun = 0;
|
||||
const failureReason = input.failureReason ?? "environment_delete_requested";
|
||||
const now = new Date();
|
||||
for (const leaseRow of leaseRows) {
|
||||
if (leaseRow.heartbeatRunId && liveRunIds.has(leaseRow.heartbeatRunId)) {
|
||||
skippedLiveRun += 1;
|
||||
continue;
|
||||
}
|
||||
// Claim the row BEFORE the provider call, mirroring the inline-teardown
|
||||
// invariant used elsewhere in this file: no provider destroy without a
|
||||
// durable `pending_cleanup` reference already on disk. The claim is one
|
||||
// conditional UPDATE, so it is the fence against a racing resume: a
|
||||
// resume that re-activates the lease first makes the status predicate
|
||||
// (or the run-liveness predicate) fail and the claim loses — the live
|
||||
// run keeps its sandbox. A claim that wins parks the lease where the
|
||||
// cleanup sweep owns it, so a crash or thrown destroy after this point
|
||||
// is recovered by the sweep's idempotent teardown, and a double write
|
||||
// failure cannot strand the lease in a reusable status.
|
||||
const claimedRow = await db
|
||||
.update(environmentLeases)
|
||||
.set({
|
||||
status: "pending_cleanup",
|
||||
failureReason,
|
||||
cleanupStatus: "failed",
|
||||
releasedAt: now,
|
||||
lastUsedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(environmentLeases.id, leaseRow.id),
|
||||
inArray(environmentLeases.status, ["active", "released", "retained"]),
|
||||
sql`NOT EXISTS (
|
||||
SELECT 1 FROM ${heartbeatRuns}
|
||||
WHERE ${heartbeatRuns.id} = ${environmentLeases.heartbeatRunId}
|
||||
AND ${heartbeatRuns.status} IN ('queued', 'scheduled_retry', 'running')
|
||||
)`,
|
||||
),
|
||||
)
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!claimedRow) {
|
||||
const lease = await destroyScopedReusableSandboxLease({ environment, leaseRow, failureReason });
|
||||
if (!lease) {
|
||||
// Lost to a racing resume or a concurrent terminal transition — the
|
||||
// lease is no longer ours to destroy.
|
||||
skippedLiveRun += 1;
|
||||
continue;
|
||||
}
|
||||
const leaseSnapshot = toEnvironmentLeaseSnapshot(claimedRow);
|
||||
try {
|
||||
const driver = getDriver(getLeaseDriverKey(leaseSnapshot, environment));
|
||||
if (!driver?.destroyRunLease) {
|
||||
// No driver available: the claim already parked the lease for the
|
||||
// sweep, which retries once the driver's plugin is back.
|
||||
failed += 1;
|
||||
continue;
|
||||
}
|
||||
const lease = await driver.destroyRunLease({
|
||||
environment,
|
||||
lease: leaseSnapshot,
|
||||
failureReason,
|
||||
});
|
||||
if (lease && lease.status !== "pending_cleanup") destroyed += 1;
|
||||
else failed += 1;
|
||||
} catch {
|
||||
// The claim above already parked the lease in `pending_cleanup`, so
|
||||
// the sweep owns the retry; its teardown is idempotent, so a destroy
|
||||
// that reached the provider before the throw resolves as success.
|
||||
failed += 1;
|
||||
}
|
||||
if (lease.status !== "pending_cleanup") destroyed += 1;
|
||||
else failed += 1;
|
||||
}
|
||||
return { destroyed, failed, skippedLiveRun };
|
||||
},
|
||||
|
||||
@@ -1179,8 +1179,9 @@ export function environmentService(db: Db) {
|
||||
where ${environmentLeases.environmentId} = ${environments.id}
|
||||
and ${environmentLeases.status} = 'pending_cleanup'
|
||||
)`,
|
||||
// A reusable lease keeps a live provider sandbox after a run
|
||||
// releases it. Deleting the environment would set its reference to
|
||||
// A reusable lease keeps a provider sandbox after a run releases
|
||||
// it, including when the run failed but release succeeded.
|
||||
// Deleting the environment would set its reference to
|
||||
// null, and both the normal release path and scoped reusable cleanup
|
||||
// require that environment context. Refuse the delete atomically
|
||||
// until the owning issue/workspace destroys the reusable sandbox.
|
||||
@@ -1188,7 +1189,7 @@ export function environmentService(db: Db) {
|
||||
select 1 from ${environmentLeases}
|
||||
where ${environmentLeases.environmentId} = ${environments.id}
|
||||
and ${environmentLeases.leasePolicy} = 'reuse_by_environment'
|
||||
and ${environmentLeases.status} in ('active', 'released', 'retained')
|
||||
and ${environmentLeases.status} in ('active', 'released', 'retained', 'failed')
|
||||
)`,
|
||||
),
|
||||
)
|
||||
@@ -1303,7 +1304,7 @@ export function environmentService(db: Db) {
|
||||
and(
|
||||
eq(environmentLeases.environmentId, id),
|
||||
eq(environmentLeases.leasePolicy, "reuse_by_environment"),
|
||||
inArray(environmentLeases.status, ["active", "released", "retained"]),
|
||||
inArray(environmentLeases.status, ["active", "released", "retained", "failed"]),
|
||||
),
|
||||
),
|
||||
db
|
||||
|
||||
@@ -154,7 +154,7 @@ import {
|
||||
configuredEnvironmentProjection,
|
||||
} from "../vendor/paperclip-runner/index.js";
|
||||
import { decisionModelService } from "./decision-models.js";
|
||||
import { activeIssueInteractionCondition } from "./issue-question-context.js";
|
||||
import { activeIssueInteractionCondition, TASK_QUESTION_GUIDANCE } from "./issue-question-context.js";
|
||||
import { createAgentIdentityRedactor } from "./agent-identity-redaction.js";
|
||||
import { agentIdentityService, supportsManagedAgentIdentity } from "./agent-identity.js";
|
||||
import { buildAgentIdentityEnv } from "@paperclipai/adapter-utils/server-utils";
|
||||
@@ -165,7 +165,7 @@ import { externalObjectService } from "./external-objects.js";
|
||||
import { resolvePaperclipInstanceRoot } from "../home-paths.js";
|
||||
import { dotRunnerBroker } from "./dot-runner-broker.js";
|
||||
import { isAiAuthenticationBlocked } from "./ai-auth-failure.js";
|
||||
import { nativeRetryCancellationCommitCondition, rethrowNativeCancellationLockConflict, claimCancellationRequest, startupCancellationFence } from "./native-runtime/native-cancellation-request.js";
|
||||
import { nativeRetryCancellationCommitCondition, rethrowNativeCancellationLockConflict, claimCancellationRequest } from "./native-runtime/native-cancellation-request.js";
|
||||
import { CHAT_COMPLETION_WAKE_REASON, prepareChatCompletionTurn, chatCompletionInstruction, isCompletedOnboardingHandoffWake } from "./chat-completion-delivery.js";
|
||||
import { AgentDirectoryReuseInvalidatedError, isAgentDirectoryCopy } from "./agent-directory-working-copies.js";
|
||||
|
||||
@@ -261,7 +261,7 @@ import {
|
||||
startAdapterExecutionTargetPaperclipBridge,
|
||||
} from "@paperclipai/adapter-utils/execution-target";
|
||||
import { agentService } from "./agents.js";
|
||||
import { agentInstructionWorkingCopyService, instructionWorkingCopyGuidance } from "./agent-instruction-working-copies.js";
|
||||
import { agentInstructionWorkingCopyService, collectStoppedInstructionCopyWithRetries, instructionWorkingCopyGuidance } from "./agent-instruction-working-copies.js";
|
||||
import { normalizeLegacyRunnerProvider, resolveManagedOpenAiBilling } from "@paperclipai/adapter-utils";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
@@ -15961,6 +15961,15 @@ export function heartbeatService(
|
||||
>;
|
||||
try {
|
||||
await controllerLease.assertOwned();
|
||||
const remoteRecovery = runOptions.nativeRestartRecovery?.kind === "reattach_remote_runner"
|
||||
? runOptions.nativeRestartRecovery : null;
|
||||
const recoveryWorkspace = remoteRecovery
|
||||
? readNativeWorkspaceSyncReference(parseObject(run.runnerProfileJson).nativeWorkspaceSync) : null;
|
||||
if (remoteRecovery && (!recoveryWorkspace || remoteRecovery.runId !== run.id ||
|
||||
recoveryWorkspace.providerLeaseId !== remoteRecovery.remote.providerLeaseId ||
|
||||
recoveryWorkspace.remoteCwd !== remoteRecovery.remote.remoteCwd)) {
|
||||
throw new Error("native_remote_recovery_lease_mismatch");
|
||||
}
|
||||
acquiredEnvironment = await envOrchestrator.acquireForRun({
|
||||
companyId: agent.companyId,
|
||||
selectedEnvironmentId,
|
||||
@@ -15973,6 +15982,11 @@ export function heartbeatService(
|
||||
agentId: agent.id,
|
||||
persistedExecutionWorkspace,
|
||||
executionWorkspaceSettings: environmentExecutionWorkspaceSettings,
|
||||
...(remoteRecovery && recoveryWorkspace ? { reattachRemoteLease: {
|
||||
leaseId: recoveryWorkspace.leaseId,
|
||||
providerLeaseId: remoteRecovery.remote.providerLeaseId,
|
||||
remoteCwd: remoteRecovery.remote.remoteCwd,
|
||||
} } : {}),
|
||||
});
|
||||
await controllerLease.assertOwned();
|
||||
nativeRunnerPreparationSpans.push({
|
||||
@@ -18242,17 +18256,7 @@ export function heartbeatService(
|
||||
},
|
||||
onLog,
|
||||
onEvent: onAdapterEvent,
|
||||
instructionWorkingCopy: instructionCopy ? {
|
||||
runId: run.id,
|
||||
root: instructionCopy.executionRoot,
|
||||
...(instructionCopy.receipt?.warm === true ? { checkpointWarm: async () => {
|
||||
const saved = await instructionCopies.checkpointWarm({ companyId: agent.companyId, runId: run.id, target: executionTarget });
|
||||
if (saved) await recordInstructionSave(saved);
|
||||
return saved?.state === "warm_saved" && saved.errorCode === null;
|
||||
} } : {}),
|
||||
hasChanges: () => instructionCopies.hasChanges({ companyId: agent.companyId, runId: run.id, target: executionTarget }),
|
||||
collectStopped: collectStoppedInstructions,
|
||||
} : undefined,
|
||||
instructionWorkingCopy: nativeInstructionWorkingCopy(),
|
||||
|
||||
onUsage: async receipt => { await usageRecorder.capture(receipt); },
|
||||
preparationSpans: nativeRunnerPreparationSpans,
|
||||
|
||||
@@ -89,18 +89,20 @@ describe("hot-restart path compatibility", () => {
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("reads Linux process start time from proc metadata", async () => {
|
||||
it("reads Linux process birth from kernel ticks rather than proc directory metadata", async () => {
|
||||
await expect(
|
||||
readProcessStartedAt(123, {
|
||||
platform: "linux",
|
||||
stat: async (target) => {
|
||||
expect(target).toBe("/proc/123");
|
||||
return {
|
||||
ctimeMs: Date.parse("2026-08-01T01:00:00.123Z"),
|
||||
};
|
||||
linuxProcessStart: {
|
||||
clockTicksPerSecond: 100,
|
||||
readFile: (target) => target === "/proc/stat"
|
||||
? `btime ${Date.parse("2026-08-01T01:00:00.000Z") / 1000}\n`
|
||||
: target === "/proc/123/stat"
|
||||
? `123 (runner) S ${Array(18).fill("0").join(" ")} 123\n`
|
||||
: (() => { throw new Error("Unexpected proc read"); })(),
|
||||
},
|
||||
}),
|
||||
).resolves.toBe("2026-08-01T01:00:00.123Z");
|
||||
).resolves.toBe("2026-08-01T01:00:01.230Z");
|
||||
});
|
||||
|
||||
it("reads macOS process start time through ps", async () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { readLinuxProcessStartedAt, type LinuxProcessStartOptions } from "../vendor/paperclip-runner/index.js";
|
||||
import {
|
||||
resolvePaperclipHomeDir,
|
||||
resolvePaperclipInstanceId,
|
||||
@@ -14,7 +15,6 @@ const HOT_RESTART_LOCK_STALE_MS = 30_000;
|
||||
const HOT_RESTART_LOCK_TIMEOUT_MS = 10_000;
|
||||
|
||||
type ProcessCommandRunner = (command: string, args: string[]) => Promise<string>;
|
||||
type ProcessStatReader = (target: string) => Promise<{ ctimeMs: number }>;
|
||||
|
||||
export type HotRestartIntentRun = {
|
||||
runId: string;
|
||||
@@ -178,17 +178,15 @@ export async function readProcessStartedAt(
|
||||
pid: number,
|
||||
options: {
|
||||
platform?: NodeJS.Platform;
|
||||
stat?: ProcessStatReader;
|
||||
linuxProcessStart?: LinuxProcessStartOptions;
|
||||
runCommand?: ProcessCommandRunner;
|
||||
} = {},
|
||||
) {
|
||||
const platform = options.platform ?? process.platform;
|
||||
const stat = options.stat ?? fs.stat;
|
||||
const runCommand = options.runCommand ?? runProcessCommand;
|
||||
|
||||
if (platform === "linux") {
|
||||
const processStat = await stat(`/proc/${pid}`);
|
||||
return new Date(processStat.ctimeMs).toISOString();
|
||||
return readLinuxProcessStartedAt(pid, options.linuxProcessStart);
|
||||
}
|
||||
|
||||
if (["darwin", "freebsd", "openbsd", "aix", "sunos"].includes(platform)) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { normalizeEscapedLineBreaks } from "@paperclipai/shared/validators/text";
|
||||
import { activeIssueInteractionCondition, historicalQuestionCondition } from "./issue-question-context.js";
|
||||
import {
|
||||
currentContinuationOrigins,
|
||||
|
||||
@@ -47,3 +47,7 @@ describe("native hire runtime inheritance", () => {
|
||||
.not.toHaveProperty("managedProfileId");
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves Pi thinking configuration without live identity or credentials", () => {
|
||||
expect(inheritNativeRunnerAdapterConfig({ provider: "acpx", acpxAgent: "pi", piThinkingLevel: "low", runtimeSessionId: "prior", env: { OPENROUTER_API_KEY: "canary" } })).toEqual({ provider: "acpx", acpxAgent: "pi", piThinkingLevel: "low" });
|
||||
});
|
||||
@@ -14,6 +14,7 @@ export const INHERITABLE_NATIVE_RUNNER_CONFIG_KEYS = [
|
||||
"opencodePermissionMode",
|
||||
"acpxPermissionMode",
|
||||
"acpxSessionMode",
|
||||
"piThinkingLevel",
|
||||
"lifecycleMode",
|
||||
"modelReasoningEffort",
|
||||
"maxIterations",
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import * as runner from "../../vendor/paperclip-runner/index.js";
|
||||
import { resolveQualifiedAcpxProfile, validatePrpStructuredRunResult } from "../../vendor/paperclip-runner/index.js";
|
||||
import { buildQuestionResponseDeliveryEnvelope } from "../question-response-delivery.js";
|
||||
import { NATIVE_COMPLETION_CONTRACT_SCHEMA, NATIVE_COMPLETION_POLICY_VERSION } from "./completion-contracts.js";
|
||||
import { nativeSha256 } from "./canonical.js";
|
||||
import { nativeCursorPlanWaitFromFacts, type CursorPlanWaitFacts } from "./native-cursor-plan-wait.js";
|
||||
|
||||
function fixture(): CursorPlanWaitFacts {
|
||||
const b = { companyId: "company", issueId: "issue", agentId: "agent", runId: "run" };
|
||||
const contract = { revision: "revision", criteria: [{ id: "criterion" }] };
|
||||
const contractMetadata = { schemaVersion: NATIVE_COMPLETION_CONTRACT_SCHEMA, policyVersion: NATIVE_COMPLETION_POLICY_VERSION, risk: "low", completionAuthority: "agent_claim_policy" };
|
||||
const contractSha = nativeSha256({ ...contractMetadata, contract });
|
||||
const planId = `plan-${"a".repeat(64)}`;
|
||||
const input = { schema: "paperclip.question_set.v1", title: "Plan", description: "Exact revised plan text", questions: [{ id: planId, prompt: "Proceed?", required: true, answerMode: "single_select", options: [{ id: "accept", label: "Accept" }, { id: "reject", label: "Reject" }, { id: "cancel", label: "Cancel" }] }] };
|
||||
const i = { id: "interaction", ...b, sourceRunId: b.runId, createdByAgentId: b.agentId, resolvedByUserId: "board", resolvedByAgentId: null, resolvedAt: new Date(0),
|
||||
kind: "ask_user_questions", status: "answered", continuationPolicy: "none", idempotencyKey: "paperclip-runner-question:run:request",
|
||||
payload: { runtimeRequestId: "request", questionSet: input }, result: { version: 1, answers: [{ questionId: planId, optionIds: ["accept"] }] } };
|
||||
const envelope = buildQuestionResponseDeliveryEnvelope(i as never);
|
||||
const d = { id: "delivery", ...b, interactionId: i.id, sourceRunId: b.runId, targetRunId: b.runId, targetTurnId: null, status: "delivered", deliveryMode: "steered", acknowledgedAt: new Date(1), payloadSha256: nativeSha256(envelope) };
|
||||
const event = (sourceSeq: number, eventType: string, payload: Record<string, unknown>) => {
|
||||
const e = { schema: "paperclip.prp.event.v1", runId: b.runId, normalizedSessionId: "session", turnId: "turn", sourceInstanceId: "instance", sourceEventId: `instance:${sourceSeq}`, sourceSeq, sourceKind: "runner", schemaVersion: 1, eventType, payload };
|
||||
return { ...b, seq: sourceSeq, eventType, payload: { prpEvent: e }, sourceInstanceId: e.sourceInstanceId, sourceEventId: e.sourceEventId, sourceSeq, sourcePayloadSha256: nativeSha256(e), protocolSchemaVersion: 1 };
|
||||
};
|
||||
return {
|
||||
binding: b,
|
||||
run: { id: b.runId, companyId: b.companyId, agentId: b.agentId, nativeIssueId: b.issueId, runtimeMode: "native", runnerInstanceId: "instance", status: "running", completionContractId: "contract", completionContractSha256: contractSha, runnerProfileJson: { nativeExecutionInput: { binding: b, provider: { kind: "acpx", agent: "cursor", cursorMode: "plan", model: "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]", profile: resolveQualifiedAcpxProfile("cursor", "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]") }, session: { normalizedSessionId: "session" }, completionContract: { id: "contract", sha256: contractSha, contract } } } },
|
||||
contract: { id: "contract", ...contractMetadata, canonicalSha256: contractSha, contractJson: contract },
|
||||
events: [
|
||||
event(275, "runtime_request.created", { request: { schema: "paperclip.runtime_request.v2", status: "pending", type: "input", requestKind: "runtime", requestId: "request", turnId: "turn", itemId: "native-plan-tool", origin: { provider: "cursor", method: "cursor/create_plan", adapter: "acpx-runtime-sidecar" }, input } }),
|
||||
event(294, "tool.execution.started", { schema: "paperclip.tool.execution.v1", executionId: "native-plan-tool", transport: "builtin", operation: "execute", status: "running", name: "arbitrary display name" }),
|
||||
event(300, "runtime_request.resolved", { requestId: "request", turnId: "turn", action: "submit", response: envelope.response }),
|
||||
event(303, "tool.execution.completed", { schema: "paperclip.tool.execution.v1", executionId: "native-plan-tool", transport: "builtin", operation: "execute", status: "completed" }),
|
||||
event(304, "turn.completed", { status: "completed", error: null }),
|
||||
],
|
||||
interactions: [{ interaction: i, delivery: d }],
|
||||
} as unknown as CursorPlanWaitFacts;
|
||||
}
|
||||
function editEvent(f: CursorPlanWaitFacts, index: number, edit: (e: any) => void) {
|
||||
const row = f.events.filter(row => !row.eventType.startsWith("tool.execution."))[index]!;
|
||||
const e = (row.payload as any).prpEvent;
|
||||
edit(e); row.sourcePayloadSha256 = nativeSha256(e);
|
||||
}
|
||||
|
||||
describe("accepted Cursor plan passive-wait authority", () => {
|
||||
it("records the accepted revision and explicitly unfinished Plan-mode continuation", () => {
|
||||
const value = nativeCursorPlanWaitFromFacts(fixture());
|
||||
expect(value?.source).toMatchObject({ requestId: "request", planRevision: `plan-${"a".repeat(64)}`, terminalEventId: "instance:304", toolExecutionId: "native-plan-tool" });
|
||||
expect(value?.result).toMatchObject({ reportedWorkDisposition: "yielded", completionClaim: { objectiveSatisfied: false, remainingWork: [{ blocksCompletion: true }] }, continuation: { kind: "response_wake" } });
|
||||
expect(value?.result.summary).toContain("next message");
|
||||
const validated = validatePrpStructuredRunResult(value!.result);
|
||||
expect(validated.ok).toBe(true);
|
||||
if (validated.ok) expect(validated.result).toEqual(value!.result);
|
||||
});
|
||||
it.each(["unrelated same-title tool", "cross turn", "cross session", "duplicate start", "duplicate completion", "missing start", "missing completion", "failed completion", "changed request tool", "uncommitted tool row"])("rejects correlated lifecycle corruption: %s", kind => {
|
||||
const f = fixture();
|
||||
const start = f.events.find(row => row.eventType === "tool.execution.started")!;
|
||||
const end = f.events.find(row => row.eventType === "tool.execution.completed")!;
|
||||
const event = (start.payload as any).prpEvent;
|
||||
if (kind === "unrelated same-title tool") event.payload.executionId = "unrelated-tool";
|
||||
if (kind === "cross turn") event.turnId = "other";
|
||||
if (kind === "cross session") event.normalizedSessionId = "other";
|
||||
if (kind === "duplicate start") f.events.splice(2, 0, structuredClone(start));
|
||||
if (kind === "duplicate completion") f.events.splice(4, 0, structuredClone(end));
|
||||
if (kind === "missing start") f.events = f.events.filter(row => row !== start);
|
||||
if (kind === "missing completion") f.events = f.events.filter(row => row !== end);
|
||||
if (kind === "failed completion") { (end.payload as any).prpEvent.payload.status = "failed"; end.sourcePayloadSha256 = nativeSha256((end.payload as any).prpEvent); }
|
||||
if (kind === "changed request tool") editEvent(f, 0, e => { e.payload.request.itemId = "unrelated-tool"; });
|
||||
start.sourcePayloadSha256 = kind === "uncommitted tool row" ? null : nativeSha256(event);
|
||||
expect(nativeCursorPlanWaitFromFacts(f)).toBeNull();
|
||||
});
|
||||
it("rejects an additional same-title tool while the real plan tool remains correlated", () => {
|
||||
const f = fixture();
|
||||
const extra = structuredClone(f.events.find(row => row.eventType === "tool.execution.started")!);
|
||||
extra.seq = 295; extra.sourceSeq = 295; extra.sourceEventId = "instance:295";
|
||||
const e = (extra.payload as any).prpEvent;
|
||||
Object.assign(e, { sourceSeq: 295, sourceEventId: extra.sourceEventId });
|
||||
Object.assign(e.payload, { executionId: "unrelated", name: "Create Plan" });
|
||||
extra.sourcePayloadSha256 = nativeSha256(e); f.events.splice(2, 0, extra);
|
||||
expect(nativeCursorPlanWaitFromFacts(f)).toBeNull();
|
||||
});
|
||||
it("binds actual callback-before-tool-start order without trusting display names", () => {
|
||||
const f = fixture(); const original = nativeCursorPlanWaitFromFacts(f)!;
|
||||
expect(original.source.toolLifecycleSha256).toMatch(/^[a-f0-9]{64}$/);
|
||||
const start = f.events.find(row => row.eventType === "tool.execution.started")!;
|
||||
(start.payload as any).prpEvent.payload.name = "changed display text";
|
||||
start.sourcePayloadSha256 = nativeSha256((start.payload as any).prpEvent);
|
||||
const changed = nativeCursorPlanWaitFromFacts(f)!;
|
||||
expect(changed).not.toBeNull();
|
||||
expect(changed.source.authoritySha256).not.toBe(original.source.authoritySha256);
|
||||
expect(changed.source.toolLifecycleSha256).not.toBe(original.source.toolLifecycleSha256);
|
||||
});
|
||||
it("does not create a new wait from an earlier profile after the catalog advances", () => {
|
||||
const facts = fixture();
|
||||
expect(nativeCursorPlanWaitFromFacts(facts)).not.toBeNull();
|
||||
const current = resolveQualifiedAcpxProfile("cursor", "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]");
|
||||
// Model a future catalog revision without tying this test to today's version.
|
||||
const next = { ...current, agentProfileVersion: (current.agentProfileVersion + 1) as typeof current.agentProfileVersion, commandDigest: `sha256:${"b".repeat(64)}` } satisfies typeof current;
|
||||
expect(next.agentProfileVersion).toBeGreaterThan(current.agentProfileVersion);
|
||||
const resolver = vi.spyOn(runner, "resolveQualifiedAcpxProfile").mockReturnValue(next);
|
||||
try { expect(nativeCursorPlanWaitFromFacts(facts)).toBeNull(); }
|
||||
finally { resolver.mockRestore(); }
|
||||
});
|
||||
it("projects only declared scope keys from a wider typed caller binding", () => {
|
||||
const f = fixture(); Object.assign(f.binding, { wakeupRequestId: "unrelated-caller-metadata" });
|
||||
const proof = nativeCursorPlanWaitFromFacts(f);
|
||||
expect(proof).not.toBeNull();
|
||||
expect(proof!.source).not.toHaveProperty("wakeupRequestId");
|
||||
});
|
||||
it.each([
|
||||
["foreign runner", (f: CursorPlanWaitFacts) => { f.run.runnerInstanceId = "other"; }],
|
||||
["changed admission binding", (f: CursorPlanWaitFacts) => { (f.run.runnerProfileJson as any).nativeExecutionInput.binding = { ...f.binding, runId: "other" }; }],
|
||||
["wrong company", (f: CursorPlanWaitFacts) => { f.run.companyId = "other"; }],
|
||||
["wrong task", (f: CursorPlanWaitFacts) => { f.run.nativeIssueId = "other"; }],
|
||||
["failed run", (f: CursorPlanWaitFacts) => { f.run.status = "failed"; }],
|
||||
["cancelled run", (f: CursorPlanWaitFacts) => { f.run.status = "cancelled"; }],
|
||||
["Agent mode", (f: CursorPlanWaitFacts) => { (f.run.runnerProfileJson as any).nativeExecutionInput.provider.cursorMode = "agent"; }],
|
||||
["stale profile", (f: CursorPlanWaitFacts) => { (f.run.runnerProfileJson as any).nativeExecutionInput.provider.profile = { ...resolveQualifiedAcpxProfile("cursor", "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]"), commandDigest: "old" }; }],
|
||||
["changed contract policy", (f: CursorPlanWaitFacts) => { f.contract.policyVersion = "tampered-policy"; }],
|
||||
["changed completion authority", (f: CursorPlanWaitFacts) => { f.contract.completionAuthority = "server_arbiter"; }],
|
||||
["changed contract hash", (f: CursorPlanWaitFacts) => { f.contract.canonicalSha256 = "f".repeat(64); }],
|
||||
["changed contract", (f: CursorPlanWaitFacts) => { f.contract.contractJson.revision = "new"; }],
|
||||
["unknown origin", (f: CursorPlanWaitFacts) => editEvent(f, 0, e => { e.payload.request.origin.provider = "acpx"; })],
|
||||
["wrong method", (f: CursorPlanWaitFacts) => editEvent(f, 0, e => { e.payload.request.origin.method = "cursor/ask_question"; })],
|
||||
["untrusted adapter", (f: CursorPlanWaitFacts) => editEvent(f, 0, e => { e.payload.request.origin.adapter = "other"; })],
|
||||
["cross turn", (f: CursorPlanWaitFacts) => editEvent(f, 1, e => { e.turnId = "other"; })],
|
||||
["cross session", (f: CursorPlanWaitFacts) => editEvent(f, 1, e => { e.normalizedSessionId = "other"; })],
|
||||
["native error", (f: CursorPlanWaitFacts) => editEvent(f, 2, e => { e.payload.error = { message: "failure" }; })],
|
||||
["changed plan", (f: CursorPlanWaitFacts) => editEvent(f, 0, e => { e.payload.request.input.description = "other"; })],
|
||||
["rejection", (f: CursorPlanWaitFacts) => editEvent(f, 1, e => { e.payload.response.answers[`plan-${"a".repeat(64)}`].selectedOptionIds = ["reject"]; })],
|
||||
["cancellation", (f: CursorPlanWaitFacts) => editEvent(f, 1, e => { e.payload.response.answers[`plan-${"a".repeat(64)}`].selectedOptionIds = ["cancel"]; })],
|
||||
["missing delivery", (f: CursorPlanWaitFacts) => { f.interactions = []; }],
|
||||
["unacknowledged delivery", (f: CursorPlanWaitFacts) => { f.interactions[0]!.delivery.acknowledgedAt = null; }],
|
||||
["fallback wake", (f: CursorPlanWaitFacts) => { f.interactions[0]!.delivery.deliveryMode = "wake_fallback"; }],
|
||||
["wrong target", (f: CursorPlanWaitFacts) => { f.interactions[0]!.delivery.targetRunId = "other"; }],
|
||||
["changed answer digest", (f: CursorPlanWaitFacts) => { f.interactions[0]!.delivery.payloadSha256 = "other"; }],
|
||||
["missing resolution time", (f: CursorPlanWaitFacts) => { f.interactions[0]!.interaction.resolvedAt = null; }],
|
||||
["agent resolved", (f: CursorPlanWaitFacts) => { f.interactions[0]!.interaction.resolvedByAgentId = "agent"; }],
|
||||
["duplicate receipt", (f: CursorPlanWaitFacts) => { f.events.splice(1, 0, structuredClone(f.events[0]!)); }],
|
||||
["duplicate delivery", (f: CursorPlanWaitFacts) => { f.interactions.push(structuredClone(f.interactions[0]!)); }],
|
||||
["uncommitted event", (f: CursorPlanWaitFacts) => { f.events[1]!.sourcePayloadSha256 = null; }],
|
||||
["changed row identity", (f: CursorPlanWaitFacts) => { f.events[1]!.sourceEventId = "other"; }],
|
||||
["terminal before answer", (f: CursorPlanWaitFacts) => { f.events.reverse(); }],
|
||||
] as const)("rejects %s", (_name, mutate) => {
|
||||
const f = fixture(); mutate(f); expect(nativeCursorPlanWaitFromFacts(f)).toBeNull();
|
||||
});
|
||||
it("does not reuse an older acceptance after a new request, later work, or conflicting terminal", () => {
|
||||
for (const kind of ["runtime_request.created", "tool.execution.started", "turn.failed", "turn.cancelled", "turn.completed"]) {
|
||||
const f = fixture(); const row = structuredClone(f.events[0]!);
|
||||
row.seq = 2.5; row.sourceSeq = 3; row.sourceEventId = "instance:later"; row.eventType = kind;
|
||||
const e = (row.payload as any).prpEvent; Object.assign(e, { sourceSeq: 3, sourceEventId: row.sourceEventId, eventType: kind });
|
||||
if (kind === "runtime_request.created") e.payload.request.requestId = "new-plan";
|
||||
row.sourcePayloadSha256 = nativeSha256(e);
|
||||
editEvent(f, 2, e => { e.sourceSeq = 4; }); f.events[2]!.sourceSeq = 4;
|
||||
f.events.splice(2, 0, row);
|
||||
expect(nativeCursorPlanWaitFromFacts(f)).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,243 +0,0 @@
|
||||
import { and, asc, eq, gt, inArray, isNull, ne, sql } from "drizzle-orm";
|
||||
import {
|
||||
completionContracts, heartbeatRunEvents, heartbeatRuns, issues, issueComments,
|
||||
nativeRunFinalizations, nativeRunResults, statusDecisions,
|
||||
issueQuestionResponseDeliveries, issueThreadInteractions, type Db,
|
||||
} from "@paperclipai/db";
|
||||
import type { AskUserQuestionsInteraction } from "@paperclipai/shared";
|
||||
import {
|
||||
parsePaperclipQuestionResponse, parsePaperclipQuestionSet, resolveQualifiedAcpxProfile,
|
||||
type PrpStructuredRunResult,
|
||||
} from "../../vendor/paperclip-runner/index.js";
|
||||
import { buildQuestionResponseDeliveryEnvelope } from "../question-response-delivery.js";
|
||||
import { nativeCompletionContractSha256 } from "./completion-contracts.js";
|
||||
import { nativeSha256 } from "./canonical.js";
|
||||
|
||||
type Binding = { companyId: string; issueId: string; runId: string; agentId: string };
|
||||
const record = (v: unknown): Record<string, any> => v && typeof v === "object" && !Array.isArray(v) ? v as Record<string, any> : {};
|
||||
const same = (a: unknown, b: unknown) => nativeSha256(a) === nativeSha256(b);
|
||||
const PREFIX = "cursor-plan-wait:";
|
||||
// Preserve the exact Cursor6 proof query: progress rows were never part of its
|
||||
// 1,000-row budget. Completed tools were included and must stay included.
|
||||
const LEGACY_EVENT_TYPES = ["runtime_request.created", "runtime_request.resolved", "runtime_request.cancelled", "runtime_request.expired", "turn.started", "turn.completed", "turn.failed", "turn.cancelled", "tool.execution.started", "tool.execution.completed"];
|
||||
const EVENT_TYPES = [...LEGACY_EVENT_TYPES, "tool.execution.progressed"];
|
||||
const SUMMARY = "Plan accepted. This task is waiting for your next message. This run used Plan mode; no implementation or task completion is claimed.";
|
||||
|
||||
export interface NativeCursorPlanWaitSource extends Binding {
|
||||
schema: "paperclip.native_cursor_plan_wait.v1";
|
||||
contractId: string;
|
||||
contractSha256: string;
|
||||
interactionId: string;
|
||||
requestId: string;
|
||||
planRevision: string;
|
||||
turnId: string;
|
||||
normalizedSessionId: string;
|
||||
sourceInstanceId: string;
|
||||
requestEventId: string;
|
||||
resolvedEventId: string;
|
||||
terminalEventId: string;
|
||||
deliveryId: string;
|
||||
authoritySha256: string;
|
||||
toolExecutionId?: string;
|
||||
toolLifecycleSha256?: string;
|
||||
}
|
||||
export interface CursorPlanWaitFacts {
|
||||
binding: Binding;
|
||||
run: Pick<typeof heartbeatRuns.$inferSelect, "id" | "companyId" | "agentId" | "nativeIssueId" | "runtimeMode" | "status" | "runnerProfileJson" | "runnerInstanceId" | "completionContractId" | "completionContractSha256">;
|
||||
contract: Pick<typeof completionContracts.$inferSelect, "id" | "canonicalSha256" | "contractJson" | "schemaVersion" | "policyVersion" | "risk" | "completionAuthority">;
|
||||
events: Array<Pick<typeof heartbeatRunEvents.$inferSelect, "companyId" | "agentId" | "runId" | "seq" | "eventType" | "payload" | "sourceInstanceId" | "sourceEventId" | "sourceSeq" | "sourcePayloadSha256" | "protocolSchemaVersion">>;
|
||||
interactions: Array<{ interaction: typeof issueThreadInteractions.$inferSelect; delivery: typeof issueQuestionResponseDeliveries.$inferSelect }>;
|
||||
}
|
||||
|
||||
function isHistoricalUnboundWait(source?: NativeCursorPlanWaitSource): boolean {
|
||||
return source !== undefined && source.toolExecutionId === undefined && source.toolLifecycleSha256 === undefined;
|
||||
}
|
||||
|
||||
/** Only committed native request/answer/normal-terminal facts can create this passive wait. */
|
||||
export function nativeCursorPlanWaitFromFacts(facts: CursorPlanWaitFacts) {
|
||||
return cursorPlanWaitFromFacts(facts);
|
||||
}
|
||||
|
||||
function cursorPlanWaitFromFacts(facts: CursorPlanWaitFacts, committedSource?: NativeCursorPlanWaitSource): { source: NativeCursorPlanWaitSource; result: PrpStructuredRunResult } | null {
|
||||
try {
|
||||
const { run, contract } = facts;
|
||||
const b: Binding = { companyId: facts.binding.companyId, issueId: facts.binding.issueId, runId: facts.binding.runId, agentId: facts.binding.agentId };
|
||||
const admission = record(record(run.runnerProfileJson).nativeExecutionInput);
|
||||
const provider = record(admission.provider), profile = record(provider.profile);
|
||||
// First admission must match today's qualified runtime. Recovery may retain
|
||||
// a previously committed wait only if the entire original proof is unchanged.
|
||||
if (!committedSource) {
|
||||
const expected = resolveQualifiedAcpxProfile("cursor", typeof provider.model === "string" ? provider.model : "");
|
||||
if (!["agent", "driverKind", "protocolVersion", "acpxVersion", "commandDigest", "agentProfileVersion", "agentServerPackage", "agentServerVersion", "agentRuntimePackage", "agentRuntimeVersion"].every(key => profile[key] === record(expected)[key])) return null;
|
||||
}
|
||||
if (run.id !== b.runId || run.companyId !== b.companyId || run.agentId !== b.agentId || run.nativeIssueId !== b.issueId || run.runtimeMode !== "native" || !["running", "succeeded"].includes(run.status) ||
|
||||
!Object.entries(b).every(([key, value]) => record(admission.binding)[key] === value) || provider.kind !== "acpx" || provider.agent !== "cursor" || provider.cursorMode !== "plan" || typeof provider.model !== "string" || !provider.model.trim() ||
|
||||
record(admission.completionContract).id !== contract.id || record(admission.completionContract).sha256 !== contract.canonicalSha256 || nativeCompletionContractSha256(contract) !== contract.canonicalSha256 || run.completionContractId !== contract.id || run.completionContractSha256 !== contract.canonicalSha256 || !same(contract.contractJson, record(admission.completionContract).contract)) return null;
|
||||
const sessionId = record(admission.session).normalizedSessionId;
|
||||
if (typeof sessionId !== "string" || !sessionId || facts.events.length === 0 || facts.events.length > 1000) return null;
|
||||
const events: Array<Record<string, any>> = [];
|
||||
const ids = new Set<string>(), seqs = new Set<string>();
|
||||
let lastRowSeq = -1;
|
||||
const lastSourceSeq = new Map<string, number>();
|
||||
for (const row of facts.events) {
|
||||
const event = record(record(row.payload).prpEvent);
|
||||
if (!event.schema) continue; // Non-PRP aggregate rows are not native evidence.
|
||||
if (row.companyId !== b.companyId || row.runId !== b.runId || row.agentId !== b.agentId || row.seq <= lastRowSeq ||
|
||||
event.schema !== "paperclip.prp.event.v1" || event.schemaVersion !== 1 || event.sourceInstanceId !== run.runnerInstanceId || event.sourceKind !== "runner" || event.runId !== b.runId || event.normalizedSessionId !== sessionId ||
|
||||
row.eventType !== event.eventType || row.sourceEventId !== event.sourceEventId || row.sourceInstanceId !== event.sourceInstanceId || row.sourceSeq !== event.sourceSeq || row.protocolSchemaVersion !== event.schemaVersion ||
|
||||
typeof event.sourceInstanceId !== "string" || !event.sourceInstanceId || typeof event.sourceEventId !== "string" || !event.sourceEventId || !Number.isSafeInteger(event.sourceSeq) || event.sourceSeq < 1 || row.sourcePayloadSha256 !== nativeSha256(event) ||
|
||||
(lastSourceSeq.get(event.sourceInstanceId) ?? 0) >= event.sourceSeq || ids.has(event.sourceEventId) || seqs.has(`${event.sourceInstanceId}:${event.sourceSeq}`)) return null;
|
||||
lastSourceSeq.set(event.sourceInstanceId, event.sourceSeq);
|
||||
lastRowSeq = row.seq; ids.add(event.sourceEventId); seqs.add(`${event.sourceInstanceId}:${event.sourceSeq}`); events.push(event);
|
||||
}
|
||||
const terminal = events.at(-1);
|
||||
if (!terminal || terminal.eventType !== "turn.completed" || record(terminal.payload).status !== "completed" || record(terminal.payload).error != null || typeof terminal.turnId !== "string") return null;
|
||||
const turn = events.filter(e => e.turnId === terminal.turnId);
|
||||
if (turn.some(e => e.sourceInstanceId !== terminal.sourceInstanceId) || turn.filter(e => ["turn.completed", "turn.failed", "turn.cancelled"].includes(e.eventType)).length !== 1) return null;
|
||||
const requests = turn.filter(e => e.eventType === "runtime_request.created");
|
||||
const created = requests.at(-1), request = record(record(created?.payload).request), origin = record(request.origin);
|
||||
if (!created || request.schema !== "paperclip.runtime_request.v2" || request.type !== "input" || request.requestKind !== "runtime" || request.status !== "pending" || request.turnId !== terminal.turnId ||
|
||||
origin.provider !== "cursor" || origin.method !== "cursor/create_plan" || !["acpx-runtime", "acpx-runtime-sidecar"].includes(origin.adapter) || typeof request.requestId !== "string") return null;
|
||||
if (new Set(requests.map(e => record(record(e.payload).request).requestId)).size !== requests.length) return null;
|
||||
// No unresolved earlier input or later work is disguised as an accepted plan boundary.
|
||||
for (const start of requests) {
|
||||
const id = record(record(start.payload).request).requestId;
|
||||
const ends = turn.filter(e => ["runtime_request.resolved", "runtime_request.cancelled", "runtime_request.expired"].includes(e.eventType) && record(e.payload).requestId === id);
|
||||
if (ends.length !== 1 || ends[0]!.sourceSeq <= start.sourceSeq || ends[0]!.eventType !== "runtime_request.resolved") return null;
|
||||
}
|
||||
const resolved = turn.find(e => e.eventType === "runtime_request.resolved" && record(e.payload).requestId === request.requestId)!;
|
||||
const resolution = record(resolved.payload);
|
||||
if (resolved.sourceSeq >= terminal.sourceSeq || resolution.action !== "submit" || resolution.turnId !== terminal.turnId) return null;
|
||||
const historicalUnboundWait = isHistoricalUnboundWait(committedSource);
|
||||
let toolBinding: { toolExecutionId: string; toolLifecycleSha256: string } | undefined;
|
||||
if (historicalUnboundWait) {
|
||||
if (profile.agentProfileVersion !== 6 || profile.commandDigest !== "sha256:377dcea64a727ce799cc112458d4b40ba4bc6574cd6c6f7233b6efd5917a6c4b") return null;
|
||||
// Reconstruct only the previously committed contract; never create a new
|
||||
// unbound wait or let a catalog upgrade authorize additional work.
|
||||
if (turn.some(e => e.sourceSeq > created.sourceSeq && e.eventType === "tool.execution.started")) return null;
|
||||
} else {
|
||||
const id = request.itemId;
|
||||
if (typeof id !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$/.test(id)) return null;
|
||||
const tools = turn.filter(e => e.eventType.startsWith("tool.execution."));
|
||||
const lifecycle = tools.filter(e => record(e.payload).executionId === id);
|
||||
const starts = lifecycle.filter(e => e.eventType === "tool.execution.started");
|
||||
const ends = lifecycle.filter(e => e.eventType === "tool.execution.completed");
|
||||
if (starts.length !== 1 || ends.length !== 1 || starts[0]!.sourceSeq >= resolved.sourceSeq || ends[0]!.sourceSeq <= resolved.sourceSeq || ends[0]!.sourceSeq >= terminal.sourceSeq ||
|
||||
lifecycle[0] !== starts[0] || lifecycle.at(-1) !== ends[0] ||
|
||||
lifecycle.some(e => record(e.payload).schema !== "paperclip.tool.execution.v1" || record(e.payload).transport !== "builtin" || record(e.payload).operation !== "execute" || record(e.payload).status !== (e.eventType === "tool.execution.completed" ? "completed" : "running")) ||
|
||||
tools.some(e => e.sourceSeq > created.sourceSeq && record(e.payload).executionId !== id)) return null;
|
||||
toolBinding = { toolExecutionId: id, toolLifecycleSha256: nativeSha256(lifecycle) };
|
||||
}
|
||||
const questionSet = parsePaperclipQuestionSet(request.input);
|
||||
const plan = questionSet.questions.filter(q => /^plan-[a-f0-9]{64}$/.test(q.id));
|
||||
if (plan.length !== 1 || plan[0]!.answerMode !== "single_select" || !plan[0]!.required || !same(plan[0]!.options?.map(o => o.id), ["accept", "reject", "cancel"])) return null;
|
||||
const response = parsePaperclipQuestionResponse(questionSet, resolution.response);
|
||||
if (!same(response.answers[plan[0]!.id]?.selectedOptionIds, ["accept"])) return null;
|
||||
const matches = facts.interactions.filter(({ interaction: i }) => record(i.payload).runtimeRequestId === request.requestId);
|
||||
if (matches.length !== 1) return null;
|
||||
const { interaction: i, delivery: d } = matches[0]!;
|
||||
if (i.companyId !== b.companyId || i.issueId !== b.issueId || i.sourceRunId !== b.runId || i.createdByAgentId !== b.agentId || i.kind !== "ask_user_questions" || i.status !== "answered" || i.continuationPolicy !== "none" || !i.resolvedByUserId || !i.resolvedAt || i.resolvedByAgentId ||
|
||||
i.idempotencyKey !== `paperclip-runner-question:${b.runId}:${request.requestId}` || !same(record(i.payload).questionSet, questionSet) ||
|
||||
d.companyId !== b.companyId || d.issueId !== b.issueId || d.interactionId !== i.id || d.sourceRunId !== b.runId || d.targetRunId !== b.runId || d.status !== "delivered" || d.deliveryMode !== "steered" || !d.acknowledgedAt || (d.targetTurnId !== null && d.targetTurnId !== terminal.turnId)) return null;
|
||||
const envelope = buildQuestionResponseDeliveryEnvelope(i as unknown as AskUserQuestionsInteraction);
|
||||
if (d.payloadSha256 !== nativeSha256(envelope) || !same(parsePaperclipQuestionResponse(questionSet, envelope.response), response)) return null;
|
||||
const source: NativeCursorPlanWaitSource = {
|
||||
...b, schema: "paperclip.native_cursor_plan_wait.v1", contractId: contract.id, contractSha256: contract.canonicalSha256, interactionId: i.id, requestId: request.requestId, planRevision: plan[0]!.id,
|
||||
turnId: terminal.turnId, normalizedSessionId: sessionId, sourceInstanceId: terminal.sourceInstanceId,
|
||||
requestEventId: created.sourceEventId, resolvedEventId: resolved.sourceEventId, terminalEventId: terminal.sourceEventId, deliveryId: d.id,
|
||||
...(toolBinding ?? {}),
|
||||
authoritySha256: nativeSha256({ admission, contract, created, resolved, terminal, interaction: i, delivery: d, resolvedAt: i.resolvedAt?.toISOString(), acknowledgedAt: d.acknowledgedAt.toISOString(), ...(toolBinding ? { toolBinding } : {}) }),
|
||||
};
|
||||
if (committedSource && !same(source, committedSource)) return null;
|
||||
const ref = `interaction:${i.id}`;
|
||||
const result: PrpStructuredRunResult = {
|
||||
schema: "paperclip.run_result.v1", reportedWorkDisposition: "yielded", summary: SUMMARY,
|
||||
completionClaim: { contractRevision: String(contract.contractJson.revision), objectiveSatisfied: false,
|
||||
criteria: (contract.contractJson.criteria as Array<{id: string}>).map(c => ({ criterionId: c.id, status: "unknown", evidenceRefs: [ref] })),
|
||||
remainingWork: [{ description: "Continue or finalize the accepted plan only after explicit direction; remain in the selected mode.", blocksCompletion: true }] },
|
||||
evidence: [{ ref }, { ref: `run-event:${resolved.sourceEventId}` }], verification: [], attentionRequests: [],
|
||||
artifacts: [{ kind: "issue_thread_interaction", ref }],
|
||||
continuation: { kind: "response_wake", summary: SUMMARY, idempotencyKey: `${PREFIX}${created.sourceEventId}` },
|
||||
};
|
||||
return { source, result };
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
export function isNativeCursorPlanWaitResult(value: unknown): boolean {
|
||||
const result = record(value);
|
||||
return result.reportedWorkDisposition === "yielded" && typeof record(result.continuation).idempotencyKey === "string" && record(result.continuation).idempotencyKey.startsWith(PREFIX);
|
||||
}
|
||||
|
||||
/** The committer calls this again under its issue lock; rows are share-locked then. */
|
||||
export async function readNativeCursorPlanWait(db: Db, binding: Binding, locked = false) {
|
||||
return readCursorPlanWaitProof(db, binding, locked);
|
||||
}
|
||||
|
||||
// Historical profile validation is available only after the committed-receipt
|
||||
// query below establishes its scoped authority. New wait callers cannot select it.
|
||||
async function readCursorPlanWaitProof(db: Db, binding: Binding, locked: boolean, committedSource?: NativeCursorPlanWaitSource) {
|
||||
const q = db.select({ run: heartbeatRuns, contract: completionContracts }).from(heartbeatRuns)
|
||||
.innerJoin(completionContracts, and(eq(completionContracts.id, heartbeatRuns.completionContractId), eq(completionContracts.companyId, binding.companyId), eq(completionContracts.issueId, binding.issueId)))
|
||||
.innerJoin(issues, and(eq(issues.id, binding.issueId), eq(issues.companyId, binding.companyId), eq(issues.assigneeAgentId, binding.agentId)))
|
||||
.where(and(eq(heartbeatRuns.id, binding.runId), eq(heartbeatRuns.companyId, binding.companyId), eq(heartbeatRuns.agentId, binding.agentId), eq(heartbeatRuns.nativeIssueId, binding.issueId))).limit(1);
|
||||
const [row] = await (locked ? q.for("share", { noWait: true }) : q);
|
||||
if (!row || record(record(row.run.runnerProfileJson).nativeExecutionInput).provider?.agent !== "cursor" || record(record(row.run.runnerProfileJson).nativeExecutionInput).provider?.cursorMode !== "plan") return null;
|
||||
const eventTypes = isHistoricalUnboundWait(committedSource) ? LEGACY_EVENT_TYPES : EVENT_TYPES;
|
||||
const eqs = db.select().from(heartbeatRunEvents).where(and(eq(heartbeatRunEvents.companyId, binding.companyId), eq(heartbeatRunEvents.runId, binding.runId), inArray(heartbeatRunEvents.eventType, eventTypes))).orderBy(asc(heartbeatRunEvents.seq)).limit(1001);
|
||||
const events = await (locked ? eqs.for("share", { noWait: true }) : eqs);
|
||||
const iq = db.select({ interaction: issueThreadInteractions, delivery: issueQuestionResponseDeliveries }).from(issueThreadInteractions)
|
||||
.innerJoin(issueQuestionResponseDeliveries, eq(issueQuestionResponseDeliveries.interactionId, issueThreadInteractions.id))
|
||||
.where(and(eq(issueThreadInteractions.companyId, binding.companyId), eq(issueThreadInteractions.issueId, binding.issueId), eq(issueThreadInteractions.sourceRunId, binding.runId))).limit(101);
|
||||
const interactions = await (locked ? iq.for("share", { noWait: true }) : iq);
|
||||
if (interactions.length > 100) return null;
|
||||
return cursorPlanWaitFromFacts({ binding, ...row, events, interactions }, committedSource);
|
||||
}
|
||||
|
||||
/** An exact applied wait suppresses recovery, not a later independently admitted user wake. */
|
||||
export async function hasCommittedNativeCursorPlanWait(db: Db, binding: Binding): Promise<boolean> {
|
||||
const [receipt] = await db.select({ decision: statusDecisions, result: nativeRunResults })
|
||||
.from(nativeRunFinalizations)
|
||||
.innerJoin(statusDecisions, and(
|
||||
eq(statusDecisions.id, nativeRunFinalizations.decisionId),
|
||||
eq(statusDecisions.assessmentId, nativeRunFinalizations.assessmentId),
|
||||
eq(statusDecisions.companyId, binding.companyId), eq(statusDecisions.issueId, binding.issueId),
|
||||
eq(statusDecisions.runId, binding.runId), eq(statusDecisions.applicationState, "applied"),
|
||||
eq(statusDecisions.toStatus, "in_progress"), eq(statusDecisions.reasonCode, "native_plan_accepted_waiting_for_continuation"),
|
||||
))
|
||||
.innerJoin(nativeRunResults, and(
|
||||
eq(nativeRunResults.id, nativeRunFinalizations.resultId), eq(nativeRunResults.companyId, binding.companyId),
|
||||
eq(nativeRunResults.issueId, binding.issueId), eq(nativeRunResults.runId, binding.runId), eq(nativeRunResults.schemaStatus, "accepted"),
|
||||
))
|
||||
.innerJoin(issues, and(
|
||||
eq(issues.id, binding.issueId), eq(issues.companyId, binding.companyId), eq(issues.assigneeAgentId, binding.agentId),
|
||||
eq(issues.status, "in_progress"), eq(issues.lastStatusDecisionId, statusDecisions.id), isNull(issues.hiddenAt),
|
||||
sql`coalesce(${issues.executionState}->>'status', '') <> 'pending'`,
|
||||
))
|
||||
.where(and(eq(nativeRunFinalizations.companyId, binding.companyId), eq(nativeRunFinalizations.issueId, binding.issueId),
|
||||
eq(nativeRunFinalizations.runId, binding.runId), eq(nativeRunFinalizations.phase, "committed"))).limit(1);
|
||||
if (!receipt) return false;
|
||||
const committedSource = record(receipt.decision.decisionJson).cursorPlanWait as NativeCursorPlanWaitSource | undefined;
|
||||
if (!committedSource) return false;
|
||||
const proof = await readCursorPlanWaitProof(db, binding, false, committedSource);
|
||||
const envelope = record(receipt.result.resultJson), terminal = record(envelope.terminal);
|
||||
const acceptedIdentity = record(record(receipt.decision.decisionJson).cursorPlanWaitResult);
|
||||
if (!proof || receipt.result.completionContractId !== proof.source.contractId || receipt.result.turnId !== proof.source.turnId || acceptedIdentity.resultId !== receipt.result.id || acceptedIdentity.resultSha256 !== receipt.result.canonicalSha256 || !same(record(receipt.decision.decisionJson).cursorPlanWait, proof.source) || !same(envelope.result, proof.result) ||
|
||||
terminal.schema !== "paperclip.prp.terminal.v1" || terminal.runTerminalState !== "succeeded" || terminal.turnTerminalState !== "completed" || terminal.reportedWorkDisposition !== "yielded") return false;
|
||||
const [run] = await db.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.id, binding.runId), eq(heartbeatRuns.companyId, binding.companyId))).limit(1);
|
||||
if (!run || run.status !== "succeeded") return false;
|
||||
// Settings for future runs cannot authorize work on this accepted plan.
|
||||
// The original admission is checked above; only task-specific continuation
|
||||
// or a superseding decision/run can release this committed passive wait.
|
||||
const [interaction] = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, proof.source.interactionId)).limit(1);
|
||||
if (!interaction?.resolvedAt) return false;
|
||||
const [newRequest, newerRun] = await Promise.all([
|
||||
db.select({ id: issueComments.id }).from(issueComments).where(and(
|
||||
eq(issueComments.companyId, binding.companyId), eq(issueComments.issueId, binding.issueId),
|
||||
eq(issueComments.authorType, "user"), gt(issueComments.createdAt, interaction.resolvedAt),
|
||||
)).limit(1),
|
||||
db.select({ id: heartbeatRuns.id }).from(heartbeatRuns).where(and(
|
||||
eq(heartbeatRuns.companyId, binding.companyId), eq(heartbeatRuns.nativeIssueId, binding.issueId), ne(heartbeatRuns.id, binding.runId), gt(heartbeatRuns.createdAt, run.createdAt),
|
||||
)).limit(1),
|
||||
]);
|
||||
return newRequest.length === 0 && newerRun.length === 0;
|
||||
}
|
||||
@@ -12,6 +12,23 @@ describe("explicit file output requirements", () => {
|
||||
"Make a file but do not send it to anyone else.",
|
||||
"Export a summary of this PDF as CSV.",
|
||||
"Create no temporary files; export the results as CSV.",
|
||||
"Write report.pdf. This is an internal verification file, not a deliverable. Also export the results as CSV.",
|
||||
"Write a downloadable report.pdf. This is personal memory, not a task deliverable.",
|
||||
"Attempt native write to report.txt with content requested.",
|
||||
"Write report.pdf and checklist.md. This is an internal assertion file, not a deliverable.",
|
||||
"Write report.pdf; write internal-proof.txt. This is an internal verification file, not a deliverable.",
|
||||
"Write report.pdf and attempt native write to /outside/probe.txt; this negative test must be denied.",
|
||||
"Attempt native write to /outside/probe.json and write report.pdf; this negative test must be denied.",
|
||||
"Write report.txt and attach it. This is an internal verification file, not a deliverable.",
|
||||
"Attach it.",
|
||||
"Write report.txt and return it as an attachment. This is an internal verification file, not a deliverable.",
|
||||
"Write report.txt and send it to me. This is an internal verification file, not a deliverable.",
|
||||
"Write report.txt. This is an internal verification file, not a deliverable. Send it to me.",
|
||||
"Write report.txt and provide it to me. This is an internal verification file, not a deliverable.",
|
||||
"Write report.txt and give me it. This is an internal verification file, not a deliverable.",
|
||||
"Write report.txt and return it. This is an internal verification file, not a deliverable.",
|
||||
"Write report.txt and send it as an attachment in your response. This is an internal verification file, not a deliverable.",
|
||||
"Write report.txt. This is an internal verification file, not a deliverable. Send it as a download link in your response.",
|
||||
])("recognizes an explicit output request: %s", objective => {
|
||||
expect(explicitlyRequestsFileOutput(objective)).toBe(true);
|
||||
});
|
||||
@@ -31,6 +48,20 @@ describe("explicit file output requirements", () => {
|
||||
"Create no files.",
|
||||
"Generate no attachments and answer in chat.",
|
||||
"Write a reply without any files.",
|
||||
"Use native write/read file tools for this task, not bash or the instructions API.",
|
||||
"Write a memory entry to memory/pi-native.txt inside the registered AGENT_HOME. This is personal memory, not a task deliverable.",
|
||||
"Use native write to copy those exact bytes into pi-agent-memory-proof.txt in the task workspace. This is an internal assertion file, not a deliverable.",
|
||||
"Before finishing, attempt native write exactly once to /outside/pi-unassigned.txt with content forbidden. This intentionally unassigned root must be denied.",
|
||||
"Write internal-proof.txt; then check it exists. This is an internal verification file, not a deliverable.",
|
||||
"Attempt native write to /outside/probe.txt and create no files. This negative test must be denied.",
|
||||
"Write internal-proof.txt; do not attach it. This is an internal verification file, not a deliverable.",
|
||||
"Write a reply and return it in chat.",
|
||||
"Write internal-proof.txt and return it in chat. This is an internal verification file, not a deliverable.",
|
||||
"Write internal-proof.txt and send it in chat. This is an internal verification file, not a deliverable.",
|
||||
"Write internal-proof.txt. This is an internal verification file, not a deliverable. Return it inline.",
|
||||
"Write internal-proof.txt and send it as a code block in your response. This is an internal verification file, not a deliverable.",
|
||||
"Write internal-proof.txt and return it in my reply. This is an internal verification file, not a deliverable.",
|
||||
"Write internal-proof.txt and provide it as plain text. This is an internal verification file, not a deliverable.",
|
||||
])("does not require a file for a text or source-review request: %s", objective => {
|
||||
expect(explicitlyRequestsFileOutput(objective)).toBe(false);
|
||||
});
|
||||
|
||||
@@ -63,26 +63,59 @@ async function hasCurrentPublicationReceipt(db: Db, companyId: string, receipts:
|
||||
* an output requirement or erase a user's request for a file.
|
||||
*/
|
||||
export function explicitlyRequestsFileOutput(objective: string): boolean {
|
||||
return objective.split(/(?:[.!?](?:\s|$)|\n|[;,]|\bbut\b)/iu).some(clause => {
|
||||
const sentences = objective.replaceAll("\\_", "_").split(/(?:[.!?](?:\s|$)|\n)/iu);
|
||||
let precedingFileOutput = false;
|
||||
return sentences.some((sentence, index) => {
|
||||
const file = /\b(?:files?|attachments?|downloads?|pdf|spreadsheets?|workbooks?|slide decks?|powerpoints?|docx|xlsx|csv)\b|\b[^\s/]+\.(?:md|txt|pdf|docx?|xlsx?|csv|pptx?|png|jpe?g|svg|zip)\b/giu;
|
||||
const create = /\b(?:create|make|write|save|export|attach|send|generate|produce|prepare|provide|give|return|build)\b/iu.exec(clause);
|
||||
if (create && /\b(?:do not|don't|never|no need to)\s*$/iu.test(clause.slice(0, create.index))) return false;
|
||||
const output = create ? clause.slice(create.index + create[0].length) : "";
|
||||
const fileObject = [...output.matchAll(file)].some(match => {
|
||||
const prefix = output.slice(0, match.index);
|
||||
const suffix = output.slice(match.index + match[0].length);
|
||||
// "Create no files" is a prohibition, even though it contains a creation
|
||||
// verb. Negate this object only; another explicit output can still count.
|
||||
if (/\b(?:no|zero|without(?:\s+any)?)\s+(?:(?:new|temporary|downloadable|attached|additional)\s+)*$/iu.test(prefix)) return false;
|
||||
// "Write a summary of this PDF" names input, not a requested file.
|
||||
// Explicit export destinations still count after such input references.
|
||||
const destination = /\b(?:as|into|to)\s+(?:(?:a|an|the|new|separate|markdown|word|excel)\s+)*$/iu.test(prefix);
|
||||
if (!destination && /\b(?:of|about|on|from|using|for|with)\b/iu.test(prefix)) return false;
|
||||
if (/^files?$/iu.test(match[0]) && /^\s+(?:permissions?|systems?|formats?|names?|paths?|types?|sizes?|descriptors?)\b/iu.test(suffix)) return false;
|
||||
return true;
|
||||
const outputs = sentence.split(/(?:[;,]|\bbut\b)/iu).flatMap(clause => {
|
||||
const creates = [...clause.matchAll(/\b(?:create|make|write|save|export|attach|send|generate|produce|prepare|provide|give|return|build)\b/giu)];
|
||||
return creates.flatMap((create, createIndex) => {
|
||||
const before = clause.slice(0, create.index);
|
||||
if (/\b(?:do not|don't|never|no need to)\s*$/iu.test(before)) return [];
|
||||
// Bind each object to its own verb. A denied write or "create no files"
|
||||
// cannot suppress a separate requested report in this same clause.
|
||||
const output = clause.slice(create.index + create[0].length, creates[createIndex + 1]?.index);
|
||||
const objects = [...output.matchAll(file)].filter(match => {
|
||||
const prefix = output.slice(0, match.index);
|
||||
const suffix = output.slice(match.index + match[0].length);
|
||||
// "Create no files" is a prohibition, even though it contains a creation
|
||||
// verb. Negate this object only; another explicit output can still count.
|
||||
if (/\b(?:no|zero|without(?:\s+any)?)\s+(?:(?:new|temporary|downloadable|attached|additional)\s+)*$/iu.test(prefix)) return false;
|
||||
// "Write a summary of this PDF" names input, not a requested file.
|
||||
// Explicit export destinations still count after such input references.
|
||||
const destination = /\b(?:as|into|to)\s+(?:(?:a|an|the|new|separate|markdown|word|excel)\s+)*$/iu.test(prefix);
|
||||
if (!destination && /\b(?:of|about|on|from|using|for|with)\b/iu.test(prefix)) return false;
|
||||
if (/^files?$/iu.test(match[0]) && /^\s+(?:tools?|permissions?|systems?|formats?|names?|paths?|types?|sizes?|descriptors?)\b/iu.test(suffix)) return false;
|
||||
return true;
|
||||
});
|
||||
// An explicit attachment/export request can refer to the file by
|
||||
// pronoun. It still requires publication when its name is omitted.
|
||||
const referencesOutput = /^(?:attach|export|send|provide|give|return)$/iu.test(create[0])
|
||||
&& /^\s+(?:me\s+)?(?:it|them|this|that)\b/iu.test(output);
|
||||
const referencedAttachment = referencesOutput && /^(?:attach|export)$/iu.test(create[0]);
|
||||
const inline = /\b(?:inline|(?:in|within|inside|as|into)\s+(?:(?:a|an|the|my|your|our|final|plain|markdown|chat|fenced)\s+)*(?:chat|response|reply|message|comment|text|code block)|(?:its|the) contents)\b/iu.test(output);
|
||||
const downloadable = referencedAttachment || (!/\b(?:no|without)\s+(?:downloadable|attached)/iu.test(output)
|
||||
&& /\b(?:downloadable|attached)\s+(?:file|report|document|checklist|draft)\b/iu.test(output));
|
||||
const publication = /\b(?:downloadable|attached|attach|export|send|provide|return|give)\b/iu.test(create[0] + output);
|
||||
const deniedAttempt = create[0].toLowerCase() === "write" && objects.length === 1
|
||||
&& /\b(?:attempt|try)\s+(?:the\s+)?native\s*$/iu.test(before)
|
||||
&& /\b(?:must be denied|denial is (?:the )?expected|(?:this|the) negative test)\b/iu.test(objective);
|
||||
if (objects.length === 0 && !downloadable && /^\s+(?:no|zero|without)\b/iu.test(output)) return [];
|
||||
return [{ objects: objects.length, downloadable, publication, deniedAttempt,
|
||||
referencesOutput, publicationReference: referencesOutput && !inline }];
|
||||
});
|
||||
});
|
||||
// "This ..." qualifies a single requested file in the preceding sentence,
|
||||
// even when a later clause checks it. Multiple files cannot share this
|
||||
// exception and separate report requests still require publication.
|
||||
const internal = outputs.reduce((count, output) => count + output.objects + Number(output.downloadable && output.objects === 0), 0) === 1
|
||||
&& /^\s*This is (?:an? )?(?:personal memory|internal (?:assertion|verification) file)\b[^.!?]*\bnot a (?:task )?deliverable\b/iu.test(sentences[index + 1] ?? "");
|
||||
return outputs.some(output => {
|
||||
const publicationReference = output.publicationReference && precedingFileOutput;
|
||||
if (!output.referencesOutput) precedingFileOutput = output.objects > 0;
|
||||
return (output.objects > 0 || output.downloadable || publicationReference)
|
||||
&& (output.publication || (!internal && !output.deniedAttempt));
|
||||
});
|
||||
return fileObject ||
|
||||
(!/\b(?:no|without)\s+(?:downloadable|attached)/iu.test(clause) && /\b(?:downloadable|attached)\s+(?:file|report|document|checklist|draft)\b/iu.test(clause));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -602,7 +602,7 @@ describe("Cursor mode native execution projection", () => {
|
||||
}
|
||||
it.each([undefined, "agent", "plan", "ask"] as const)("preserves mode %s independently of permissions and task planning", mode => {
|
||||
const result = buildNativeExecutionInput(fixture(mode));
|
||||
expect(result.provider).toMatchObject({ kind: "acpx", agent: "cursor", cursorMode: mode ?? "agent", permissionMode: "deny-all" });
|
||||
expect(result.provider).toMatchObject({ kind: "acpx", agent: "cursor", mode: mode ?? "agent", permissionMode: "deny-all" });
|
||||
expect(result.executionMode).toBe("default");
|
||||
expect(result.task.workMode).toBe("standard");
|
||||
});
|
||||
@@ -610,3 +610,27 @@ describe("Cursor mode native execution projection", () => {
|
||||
expect(() => buildNativeExecutionInput({ ...fixture("plan"), acpxAgent: "copilot" })).toThrow("only for Cursor");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Pi thinking native execution projection", () => {
|
||||
function fixture(piThinkingLevel?: "off" | "low" | "high" | "max") {
|
||||
return {
|
||||
companyId: "company-1", runId: "run-1", agentId: "agent-1",
|
||||
issue: { id: "issue-1", identifier: "MODE-1", title: "Review a plan", description: null, workMode: "standard" },
|
||||
taskPrompt: "Review the project.",
|
||||
workspace: { id: "workspace-1", cwd: "/workspace", repoUrl: null, repoRef: null, branchName: null },
|
||||
normalizedSessionId: null, provider: "acpx" as const, acpxAgent: "pi" as const,
|
||||
model: "openrouter/deepseek/deepseek-v4-flash-0731", piThinkingLevel, acpxPermissionMode: "deny-all" as const,
|
||||
completionContract: { id: "contract-1", sha256: `sha256:${"a".repeat(64)}`, schemaVersion: "paperclip.run-result.v1", contract: { revision: "1", objective: "Review", criteria: [{ id: "output", requirement: "Review" }] } },
|
||||
runtimeContext: nativeRuntimeContextFixture(),
|
||||
};
|
||||
}
|
||||
it.each([undefined, "off", "low", "high", "max"] as const)("preserves mode %s independently of permissions and task planning", mode => {
|
||||
const result = buildNativeExecutionInput(fixture(mode));
|
||||
expect(result.provider).toMatchObject({ kind: "acpx", agent: "pi", piThinkingLevel: mode ?? "low", permissionMode: "deny-all" });
|
||||
expect(result.executionMode).toBe("default");
|
||||
expect(result.task.workMode).toBe("standard");
|
||||
});
|
||||
it("rejects a Pi thinking level attached to another harness", () => {
|
||||
expect(() => buildNativeExecutionInput({ ...fixture("low"), acpxAgent: "copilot" })).toThrow("only for Pi");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { PaperclipTurnContext } from "@paperclipai/adapter-utils/server-utils";
|
||||
import { resolvePaperclipRunnerCursorMode } from "@paperclipai/adapter-utils";
|
||||
import { resolvePaperclipRunnerCursorMode, resolvePaperclipRunnerPiThinkingLevel } from "@paperclipai/adapter-utils";
|
||||
import { createHash } from "node:crypto";
|
||||
import { buildNativeContinuationPrompt } from "./native-continuation.js";
|
||||
import type {
|
||||
@@ -77,6 +77,7 @@ export interface BuildNativeExecutionInput {
|
||||
opencodePermissionMode?: NativeOpenCodePermissionMode;
|
||||
acpxPermissionMode?: NativeAcpxPermissionMode;
|
||||
acpxSessionMode?: "agent" | "plan" | "ask";
|
||||
piThinkingLevel?: "off" | "low" | "high" | "max";
|
||||
model?: string | null;
|
||||
managedProfile?: Extract<
|
||||
NativeExecutionInputV5["provider"],
|
||||
@@ -113,6 +114,7 @@ export function buildNativeExecutionInput(input: BuildNativeExecutionInput): Nat
|
||||
throw new Error("native_execution_input_invalid: issue work mode must be standard, planning, or ask");
|
||||
}
|
||||
const mode = resolvePaperclipRunnerCursorMode(input.provider, input.acpxAgent, input.acpxSessionMode);
|
||||
const piThinkingLevel = resolvePaperclipRunnerPiThinkingLevel(input.provider, input.acpxAgent, input.piThinkingLevel);
|
||||
const executionMode = input.executionMode
|
||||
?? (input.issue.workMode === "planning" ? "plan" : "default");
|
||||
const acpxProfile = input.provider === "acpx"
|
||||
@@ -276,6 +278,7 @@ export function buildNativeExecutionInput(input: BuildNativeExecutionInput): Nat
|
||||
model: input.model,
|
||||
permissionMode: input.acpxPermissionMode ?? "approve-all",
|
||||
...(mode === undefined ? {} : { mode }),
|
||||
...(piThinkingLevel === undefined ? {} : { piThinkingLevel }),
|
||||
profile: {
|
||||
driverKind: acpxProfile!.driverKind,
|
||||
protocolVersion: acpxProfile!.protocolVersion,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
||||
import { createServer } from "node:http";
|
||||
import path from "node:path";
|
||||
import {
|
||||
cleanupGitHubOperationLaunchers,
|
||||
githubOperationLauncherDirectory,
|
||||
prepareGitHubOperationLaunchers,
|
||||
startAdapterExecutionTargetPaperclipBridge,
|
||||
} from "@paperclipai/adapter-utils/execution-target";
|
||||
@@ -98,7 +98,7 @@ export async function createNativeGitHubAccess(input: {
|
||||
bridge = await startBridge({
|
||||
...location,
|
||||
runtimeRootDir: input.target?.kind === "remote"
|
||||
? path.posix.join(input.target.remoteCwd, ".paperclip-runtime", "github", location.runId)
|
||||
? githubOperationLauncherDirectory(location)
|
||||
: null,
|
||||
adapterKey: "native-github",
|
||||
hostApiToken: token,
|
||||
|
||||
@@ -332,6 +332,42 @@ describe("native runner file handoff", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
"Use native write/read file tools. Write memory/pi-native.txt inside AGENT_HOME. This is personal memory, not a task deliverable.",
|
||||
"Use native write to copy bytes into pi-agent-memory-proof.txt. This is an internal assertion file, not a deliverable.",
|
||||
"Attempt native write once to /outside/pi-unassigned.txt. This intentionally unassigned root must be denied.",
|
||||
"Write internal-proof.txt; then check it exists. This is an internal verification file, not a deliverable.",
|
||||
"Attempt native write to /outside/probe.txt and create no files. This negative test must be denied.",
|
||||
"Write internal-proof.txt and return it in chat. This is an internal verification file, not a deliverable.",
|
||||
"Write internal-proof.txt and send it as a code block in your response. This is an internal verification file, not a deliverable.",
|
||||
])("accepts an internal file outcome without treating it as published output: %s", async objective => {
|
||||
await db.update(heartbeatRuns).set({ contextSnapshot: { issueId, executionContinuation: { objective } } })
|
||||
.where(eq(heartbeatRuns.id, runId));
|
||||
try {
|
||||
await expect(nativeCompletionFeedback(db, runId, doneReport([])))
|
||||
.resolves.toContain("Completion report accepted");
|
||||
} finally {
|
||||
await db.update(heartbeatRuns).set({ contextSnapshot: { issueId } }).where(eq(heartbeatRuns.id, runId));
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
"Write report.pdf; write internal-proof.txt. This is an internal verification file, not a deliverable.",
|
||||
"Write report.pdf and attempt native write to /outside/probe.txt; this negative test must be denied.",
|
||||
"Write report.txt and attach it. This is an internal verification file, not a deliverable.",
|
||||
"Write report.txt and send it to me. This is an internal verification file, not a deliverable.",
|
||||
"Write report.txt. This is an internal verification file, not a deliverable. Send it as a download link in your response.",
|
||||
])("still requires publication when the task also asks for internal or denied writes: %s", async objective => {
|
||||
await db.update(heartbeatRuns).set({ contextSnapshot: { issueId, executionContinuation: { objective } } })
|
||||
.where(eq(heartbeatRuns.id, runId));
|
||||
try {
|
||||
await expect(nativeCompletionFeedback(db, runId, doneReport([])))
|
||||
.rejects.toThrow("requested file has no accessible delivery evidence");
|
||||
} finally {
|
||||
await db.update(heartbeatRuns).set({ contextSnapshot: { issueId } }).where(eq(heartbeatRuns.id, runId));
|
||||
}
|
||||
});
|
||||
|
||||
it("prepares one verified same-run attachment and replays without duplicates", async () => {
|
||||
const body = Buffer.from("native runner file handoff\n", "utf8");
|
||||
await mkdir(path.join(workspaceRoot, "out"), { recursive: true });
|
||||
|
||||
@@ -1268,12 +1268,23 @@ describe("remote provider pack manifest", () => {
|
||||
Object.assign(payload, { candidateProviders: candidates });
|
||||
await writeManifest();
|
||||
expect(readRemoteProviderPackManifest(root).payload.candidateProviders?.pi?.qualification).toBe("qualified");
|
||||
// The normal pack builder publishes Pi in both inventories. A Pi image
|
||||
// must pass the same reader used by real remote runtime preparation.
|
||||
Object.assign(payload, { providers: { cursor, pi: candidates.pi } });
|
||||
await writeManifest();
|
||||
expect(readRemoteProviderPackManifest(root).payload.providers?.pi?.qualification).toBe("qualified");
|
||||
await mkdir(releaseMetadata, { recursive: true });
|
||||
await cp(join(root, "provider-pack.json"), manifestPath);
|
||||
expect(readBundledRemoteProviderPackManifest(manifestPath).payload.providers?.pi?.qualification).toBe("qualified");
|
||||
Object.assign(payload, { providers: { cursor } });
|
||||
await rm(releaseMetadata, { recursive: true, force: true });
|
||||
await writeManifest();
|
||||
await writeFile(join(root, candidatePath, "runtime"), "substitute runtime");
|
||||
expect(() => readRemoteProviderPackManifest(root)).toThrow("candidate asset tree digest mismatch");
|
||||
await writeFile(join(root, candidatePath, "runtime"), "pinned runtime");
|
||||
candidates.pi.qualification = "pending";
|
||||
await writeManifest();
|
||||
expect(readRemoteProviderPackManifest(root).payload.candidateProviders?.pi?.qualification).toBe("pending");
|
||||
expect(() => readRemoteProviderPackManifest(root)).toThrow("invalid candidate identity");
|
||||
candidates.pi.qualification = "qualified";
|
||||
for (const invalid of [{ path: "../outside" }, { qualification: "unknown" }]) {
|
||||
const original = { ...candidates.pi };
|
||||
@@ -1285,7 +1296,7 @@ describe("remote provider pack manifest", () => {
|
||||
for (const provider of ["cursor", "copilot"]) {
|
||||
Object.assign(candidates, { [provider]: { ...candidates.pi, path: `provider-assets/${provider}/linux-x64` } });
|
||||
await writeManifest();
|
||||
expect(() => readRemoteProviderPackManifest(root)).toThrow("invalid candidate identity");
|
||||
expect(() => readRemoteProviderPackManifest(root)).toThrow(provider === "cursor" ? "Cursor profile or closure does not match this release" : "invalid candidate identity");
|
||||
delete (candidates as Record<string, unknown>)[provider];
|
||||
}
|
||||
await writeManifest();
|
||||
@@ -1560,12 +1571,12 @@ describe("verified native harness backups", () => {
|
||||
it("binds Cursor mode across governed and identical recovery identities", () => {
|
||||
const execution = {
|
||||
...backupExecution,
|
||||
provider: { kind: "acpx", agent: "cursor", model: "explicit-model", cursorMode: "plan" },
|
||||
provider: { kind: "acpx", agent: "cursor", model: "explicit-model", mode: "plan" },
|
||||
interactionResponses: [{ interactionId: "interaction-1" }],
|
||||
} as unknown as NativeExecutionInputV1;
|
||||
const identity = (suffix: string, cursorMode: unknown) => {
|
||||
const identity = (suffix: string, mode: unknown) => {
|
||||
const value = acpxIdentity(suffix);
|
||||
return { ...value, providerSessionIdentity: { ...value.providerSessionIdentity, cursorMode } };
|
||||
return { ...value, providerSessionIdentity: { ...value.providerSessionIdentity, mode } };
|
||||
};
|
||||
const previous = identity("previous", "plan");
|
||||
const current = identity("current", "plan");
|
||||
@@ -1580,7 +1591,37 @@ describe("verified native harness backups", () => {
|
||||
for (const provider of [
|
||||
{ kind: "acpx", agent: "cursor", model: "explicit-model" },
|
||||
{ kind: "acpx", agent: "copilot", model: "explicit-model" },
|
||||
{ kind: "acpx", agent: "copilot", model: "explicit-model", cursorMode: "plan" },
|
||||
]) {
|
||||
expect(providerSessionIdentityTransitionIsAllowed({
|
||||
execution: { ...execution, provider } as unknown as NativeExecutionInputV1, previous, current,
|
||||
})).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("binds Pi thinking across governed and identical recovery identities", () => {
|
||||
const execution = {
|
||||
...backupExecution,
|
||||
provider: { kind: "acpx", agent: "pi", model: "explicit-model", piThinkingLevel: "low" },
|
||||
interactionResponses: [{ interactionId: "interaction-1" }],
|
||||
} as unknown as NativeExecutionInputV1;
|
||||
const identity = (suffix: string, piThinkingLevel: unknown) => {
|
||||
const value = acpxIdentity(suffix);
|
||||
return { ...value, providerSessionIdentity: { ...value.providerSessionIdentity, piThinkingLevel } };
|
||||
};
|
||||
const previous = identity("previous", "low");
|
||||
const current = identity("current", "low");
|
||||
expect(providerSessionIdentityTransitionIsAllowed({ execution, previous, current })).toBe(true);
|
||||
expect(providerSessionIdentityTransitionIsAllowed({ execution, previous, current: previous })).toBe(true);
|
||||
for (const mode of [undefined, null, "high", "max", "medium"]) {
|
||||
const wrong = identity("wrong", mode);
|
||||
expect(providerSessionIdentityTransitionIsAllowed({ execution, previous, current: wrong })).toBe(false);
|
||||
expect(providerSessionIdentityTransitionIsAllowed({ execution, previous: wrong, current })).toBe(false);
|
||||
expect(providerSessionIdentityTransitionIsAllowed({ execution, previous: wrong, current: wrong })).toBe(false);
|
||||
}
|
||||
for (const provider of [
|
||||
{ kind: "acpx", agent: "pi", model: "explicit-model" },
|
||||
{ kind: "acpx", agent: "copilot", model: "explicit-model" },
|
||||
{ kind: "acpx", agent: "copilot", model: "explicit-model", piThinkingLevel: "low" },
|
||||
]) {
|
||||
expect(providerSessionIdentityTransitionIsAllowed({
|
||||
execution: { ...execution, provider } as unknown as NativeExecutionInputV1, previous, current,
|
||||
@@ -1952,24 +1993,23 @@ describe("split durable provider checkpoint identity", () => {
|
||||
|
||||
it("requires the exact observed Cursor mode in suspended descriptor and identity", () => {
|
||||
const profileDigest = `sha256:${"a".repeat(64)}`;
|
||||
const input = execution({ kind: "acpx", agent: "cursor", model: "explicit-model", permissionMode: "deny-all", cursorMode: "plan" }, "acpx_runtime");
|
||||
const input = execution({ kind: "acpx", agent: "cursor", model: "explicit-model", permissionMode: "deny-all", mode: "plan" }, "acpx_runtime");
|
||||
const providerState = {
|
||||
schema: "paperclip.runner.acpx-provider-state.v3", lifecycle: "suspended", activeTurnId: null, providerExitUnconfirmed: false,
|
||||
descriptor: { kind: "acpx", provider: "acpx", driver: "acpx_runtime", agent: "cursor", model: "explicit-model", commandDigest: profileDigest, normalizedSessionId: "native-session", cursorMode: "plan" },
|
||||
descriptor: { kind: "acpx", provider: "acpx", driver: "acpx_runtime", agent: "cursor", model: "explicit-model", commandDigest: profileDigest, normalizedSessionId: "native-session", mode: "plan" },
|
||||
identity: { kind: "acpx", normalizedSessionId: "native-session", acpxRecordId: "record-1", backendSessionId: "backend-1", agentSessionId: "agent-1", profileDigest,
|
||||
workspaceDigest: `sha256:${"b".repeat(64)}`, requestedModel: "explicit-model", effectiveModel: "explicit-model", permissionMode: "deny-all", cursorMode: "plan", providerLifetimeFenceCandidates: [53001, 53002, 53003] },
|
||||
workspaceDigest: `sha256:${"b".repeat(64)}`, requestedModel: "explicit-model", effectiveModel: "explicit-model", permissionMode: "deny-all", mode: "plan", providerLifetimeFenceCandidates: [53001, 53002, 53003] },
|
||||
};
|
||||
const read = (state: unknown, candidate = input) => providerSessionIdentityFromDurableProviderState({ execution: candidate, providerState: state });
|
||||
expect(read(providerState).providerSessionIdentity).toEqual(providerState.identity);
|
||||
for (const cursorMode of [undefined, null, "agent", "ask", "autopilot"]) {
|
||||
expect(read({ ...providerState, identity: { ...providerState.identity, cursorMode } }).providerSessionIdentity).toBeNull();
|
||||
expect(read({ ...providerState, descriptor: { ...providerState.descriptor, cursorMode } }).providerSessionIdentity).toBeNull();
|
||||
expect(read(providerState, execution({ ...input.provider, cursorMode }, "acpx_runtime")).providerSessionIdentity).toBeNull();
|
||||
for (const mode of [undefined, null, "agent", "ask", "autopilot"]) {
|
||||
expect(read({ ...providerState, identity: { ...providerState.identity, mode } }).providerSessionIdentity).toBeNull();
|
||||
expect(read({ ...providerState, descriptor: { ...providerState.descriptor, mode } }).providerSessionIdentity).toBeNull();
|
||||
expect(read(providerState, execution({ ...input.provider, mode }, "acpx_runtime")).providerSessionIdentity).toBeNull();
|
||||
}
|
||||
const foreign = { ...providerState, descriptor: { ...providerState.descriptor, agent: "copilot" } };
|
||||
for (const cursorMode of [undefined, "plan"]) {
|
||||
expect(read(foreign, execution({ ...input.provider, agent: "copilot", cursorMode }, "acpx_runtime")).providerSessionIdentity).toBeNull();
|
||||
}
|
||||
expect(read(foreign, execution({ ...input.provider, agent: "copilot", mode: undefined }, "acpx_runtime")).providerSessionIdentity).toBeNull();
|
||||
expect(read(foreign, execution({ ...input.provider, agent: "copilot", mode: "plan" }, "acpx_runtime")).providerSessionIdentity).toEqual(providerState.identity);
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -4914,6 +4954,16 @@ describe("native governed waits", () => {
|
||||
payload: { kind: "dynamicToolCall" },
|
||||
};
|
||||
|
||||
// The durable fallback preserves the question after provider loss, but
|
||||
// cannot turn that lost execution into a successful governed wait.
|
||||
const providerLost = { ...replayedEvent, eventType: "runtime_request.expired" as const,
|
||||
payload: { reason: "provider_process_lost", replayAllowed: false } };
|
||||
const lostObservation = createGovernedWaitEventObservation(async () => waitResult);
|
||||
await lostObservation.observe(providerLost, true);
|
||||
expect(lostObservation.consume(providerLost)).toBeNull();
|
||||
await lostObservation.observe(replayedEvent, true);
|
||||
expect(lostObservation.consume(replayedEvent)).toBeNull();
|
||||
|
||||
// The event consumer can lag the provider: a commentary event emitted
|
||||
// before the tool began may be processed after its approval exists in DB.
|
||||
// It is not proof that the approval-creating tool response has settled.
|
||||
@@ -9411,7 +9461,7 @@ describe("native process ownership", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["pi", "copilot"])("rejects ACPX candidate %s without host authorization before constructing a backend", async (agent) => {
|
||||
it.each(["copilot"])("rejects ACPX candidate %s without host authorization before constructing a backend", async (agent) => {
|
||||
const piExecution = {
|
||||
...execution,
|
||||
binding: { ...execution.binding, runId: "run-acpx-pi-rejected" },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { configuredEnvironment } from "../../vendor/paperclip-runner/index.js";
|
||||
import { agents } from "@paperclipai/db";
|
||||
import { dotRunnerBroker } from "../dot-runner-broker.js";
|
||||
import { configuredEnvironment } from "../../vendor/paperclip-runner/index.js";
|
||||
import { CURSOR_DISTRIBUTION_PINS, QUALIFIED_ACPX_PROFILES, QUALIFIED_ACPX_VERSION } from "../../vendor/paperclip-runner/index.js";
|
||||
import { isProviderMode } from "../../vendor/paperclip-runner/index.js";
|
||||
import { bundledRemoteProviderPackManifestPath, bundledRemoteRunnerBinary } from "../../vendor/paperclip-runner/index.js";
|
||||
@@ -38,6 +38,7 @@ import { nativeCompletionFeedback } from "./native-completion-feedback.js";
|
||||
import { hasAcknowledgedNativeReassignmentStopIntent, hasAcknowledgedNativeStopIntent } from "../acknowledged-native-stop.js";
|
||||
import { stoppedCodexTurnIsTextOnly } from "./stopped-codex-turn.js";
|
||||
import { prepareVerifiedRemoteProviderPack } from "./remote-provider-pack.js";
|
||||
import { selectRemotePiCompanion } from "./remote-pi-companion.js";
|
||||
import { readNativeLocalProcessStop, PROCESS_START_REQUESTED } from "../native-local-process-stop.js";
|
||||
import { remoteLeaseCleanupScope } from "../remote-execution-termination.js";
|
||||
import { resolveConnectorAssignments, isConnectorSkill } from "../connector-runtime.js";
|
||||
@@ -1248,6 +1249,7 @@ export function createGovernedWaitEventObservation(
|
||||
resolvePending: () => Promise<PrpStructuredRunResult | null>,
|
||||
) {
|
||||
const pendingTools = new Set<string>();
|
||||
let providerLost = false;
|
||||
let generation = 0;
|
||||
let observation: {
|
||||
sourceInstanceId: string;
|
||||
@@ -1261,6 +1263,7 @@ export function createGovernedWaitEventObservation(
|
||||
const currentGeneration = ++generation;
|
||||
observation = null;
|
||||
const payload = record(event.payload);
|
||||
providerLost ||= event.eventType === "runtime_request.expired" && payload.reason === "provider_process_lost";
|
||||
const kind = payload.kind;
|
||||
const tool = ["dynamicToolCall", "mcpToolCall", "commandExecution"].includes(String(kind));
|
||||
if (event.itemId) {
|
||||
@@ -1276,7 +1279,9 @@ export function createGovernedWaitEventObservation(
|
||||
if (event.eventType === "item.completed" && (
|
||||
pendingTools.size > 0 || (!tool && !(kind === "agentMessage" && payload.channel === "final"))
|
||||
)) return;
|
||||
if (!eligible) return;
|
||||
// A replacement question preserves human input after a provider crash;
|
||||
// it does not authorize a successful suspension of the failed execution.
|
||||
if (providerLost || !eligible) return;
|
||||
const result = await resolvePending();
|
||||
if (generation !== currentGeneration || result === null) return;
|
||||
// If the interaction is answered after this read, parking remains the
|
||||
@@ -1656,8 +1661,8 @@ function nativeSessionKey(execution: NativeExecutionInput): string {
|
||||
);
|
||||
}
|
||||
|
||||
function nativeSessionWorkspaceScope(execution: NativeExecutionInput) {
|
||||
if (execution.provider.kind === "openai_dot") return { kind: "none" as const };
|
||||
export function nativeSessionWorkspaceScope(execution: { binding: Pick<NativeExecutionInput["binding"], "runId" | "executionWorkspaceId">; workspace: NativeExecutionInput["workspace"] }) {
|
||||
if ("access" in execution.workspace) return { kind: "none" as const };
|
||||
// Projectless local runs use the heartbeat run id as a durable placeholder
|
||||
// rather than fabricating an execution_workspaces row. Do not let that
|
||||
// per-run placeholder break continuity for the same provider session; the
|
||||
@@ -5623,6 +5628,7 @@ export function providerSessionIdentityFromDurableProviderState(input: {
|
||||
identity.effectiveModel !== expectedModel ||
|
||||
identity.permissionMode !== input.execution.provider.permissionMode ||
|
||||
!acpxRecoveryModeMatches(input.execution.provider, descriptor.mode, identity.mode) ||
|
||||
!acpxRecoveryPiThinkingMatches(input.execution.provider, descriptor.piThinkingLevel, identity.piThinkingLevel) ||
|
||||
!["approve-all", "approve-paperclip", "approve-reads", "deny-all"].includes(
|
||||
String(identity.permissionMode),
|
||||
) ||
|
||||
@@ -5730,6 +5736,12 @@ function acpxRecoveryModeMatches(
|
||||
&& observedModes.every(mode => mode === expected);
|
||||
}
|
||||
|
||||
function acpxRecoveryPiThinkingMatches(provider: NativeExecutionInput["provider"], ...observed: unknown[]): boolean {
|
||||
const expected = record(provider).piThinkingLevel;
|
||||
if (provider.kind === "acpx" && provider.agent === "pi") return ["off", "low", "high", "max"].includes(String(expected)) && observed.every(value => value === expected);
|
||||
return expected === undefined && observed.every(value => value === undefined);
|
||||
}
|
||||
|
||||
export function providerSessionIdentityTransitionIsAllowed(input: {
|
||||
execution: NativeExecutionInput;
|
||||
previous: unknown;
|
||||
@@ -5740,6 +5752,7 @@ export function providerSessionIdentityTransitionIsAllowed(input: {
|
||||
record(record(input.previous).providerSessionIdentity).mode,
|
||||
record(record(input.current).providerSessionIdentity).mode,
|
||||
)) return false;
|
||||
if (!acpxRecoveryPiThinkingMatches(input.execution.provider, record(record(input.previous).providerSessionIdentity).piThinkingLevel, record(record(input.current).providerSessionIdentity).piThinkingLevel)) return false;
|
||||
if (canonicalJson(input.previous) === canonicalJson(input.current)) {
|
||||
return true;
|
||||
}
|
||||
@@ -7651,7 +7664,7 @@ async function executePaperclipNativeSessionWithinScope(
|
||||
}
|
||||
if (
|
||||
input.execution.provider.kind === "acpx" &&
|
||||
["pi", "copilot"].includes(input.execution.provider.agent) &&
|
||||
["copilot"].includes(input.execution.provider.agent) &&
|
||||
!resolveAcpxQualification(input.execution.provider, process.env)
|
||||
) {
|
||||
throw new Error(
|
||||
@@ -9890,7 +9903,7 @@ type RemoteProviderPackManifest = {
|
||||
distDigest: string;
|
||||
bridgeDigest: string;
|
||||
acpxProfileDigests: typeof REMOTE_PROVIDER_PACK_PROFILE_DIGESTS;
|
||||
providers?: Partial<Record<"cursor", {
|
||||
providers?: Partial<Record<"pi" | "cursor", {
|
||||
version: string; profileDigest: string; closureDigest: string; qualification: "qualified" | "pending";
|
||||
path: string; sha256: string;
|
||||
}>>;
|
||||
@@ -10079,9 +10092,10 @@ function readRemoteProviderPackIdentity(packRoot: string, verifyControllerFiles:
|
||||
}
|
||||
for (const [provider, candidate] of Object.entries(candidates)) {
|
||||
const expectedPath = `provider-assets/${provider}/${payload.target.platform}-${payload.target.architecture}`;
|
||||
if (!(inventory === "providers" ? ["cursor"] : ["cursor", "copilot", "pi"]).includes(provider) || !candidate
|
||||
if (!(inventory === "providers" ? ["pi", "cursor"] : ["cursor", "copilot", "pi"]).includes(provider) || !candidate
|
||||
|| Object.keys(candidate).some(key => !["version", "profileDigest", "closureDigest", "qualification", "path", "sha256"].includes(key))
|
||||
|| candidate.qualification !== (provider === "cursor" ? "qualified" : "pending") || candidate.path !== expectedPath
|
||||
|| candidate.qualification !== (["pi", "cursor"].includes(provider) ? "qualified" : "pending")
|
||||
|| candidate.path !== expectedPath
|
||||
|| typeof candidate.version !== "string" || !candidate.version || candidate.version.length > 120
|
||||
|| !/^sha256:[a-f0-9]{64}$/.test(candidate.profileDigest)
|
||||
|| !/^sha256:[a-f0-9]{64}$/.test(candidate.closureDigest)
|
||||
@@ -11348,8 +11362,16 @@ async function createRunnerdBackendWithinSessionClaim(
|
||||
remoteTarget !== null &&
|
||||
(input.execution.provider.kind === "opencode" ||
|
||||
input.execution.provider.kind === "acpx");
|
||||
// Pi's explicit operator import supplies the target-platform authority. Never
|
||||
// substitute a Mac controller daemon or trust an image's self-reported hashes.
|
||||
// Existing explicit overrides and every other provider keep their old path.
|
||||
const remotePiCompanion = await selectRemotePiCompanion({
|
||||
provider: input.execution.provider, remote: remoteTarget !== null,
|
||||
binaryOverride: input.runnerRemoteBinaryPath, packOverride: input.runnerRemoteProviderPackPath,
|
||||
workspaceRoot: input.execution.workspace.cwd,
|
||||
});
|
||||
const configuredProviderPackRoot =
|
||||
input.runnerRemoteProviderPackPath?.trim() || null;
|
||||
input.runnerRemoteProviderPackPath?.trim() || remotePiCompanion?.providerPack || null;
|
||||
let expectedProviderPackManifest: RemoteProviderPackManifest | null = null;
|
||||
const useBundledCursorImageAssets = requiresRemoteProviderPack && !configuredProviderPackRoot &&
|
||||
input.execution.provider.kind === "acpx" && input.execution.provider.agent === "cursor";
|
||||
@@ -11382,7 +11404,7 @@ async function createRunnerdBackendWithinSessionClaim(
|
||||
// When an explicit remote artifact is configured, prepareRemoteRunner stages
|
||||
// these exact bytes at remoteBinary before launch.
|
||||
const controllerRunnerBinary = remoteTarget
|
||||
? input.runnerRemoteBinaryPath?.trim() || (useBundledCursorImageAssets ? bundledRemoteRunnerBinary() : resolvePaperclipRunnerBinary())
|
||||
? input.runnerRemoteBinaryPath?.trim() || remotePiCompanion?.runnerBinary || (useBundledCursorImageAssets ? bundledRemoteRunnerBinary() : resolvePaperclipRunnerBinary())
|
||||
: resolvePaperclipRunnerBinary();
|
||||
const explicitRemoteCodex = input.runnerRemoteCodexPath?.trim() || null;
|
||||
const remoteCodexNpmSpec = input.runnerRemoteCodexNpmSpec?.trim() || null;
|
||||
@@ -11761,7 +11783,7 @@ async function createRunnerdBackendWithinSessionClaim(
|
||||
if (!existsSync(sourceBinary)) {
|
||||
throw new Error("runner_remote_artifact_unavailable");
|
||||
}
|
||||
if (!explicitRemoteBinary) {
|
||||
if (!explicitRemoteBinary && !remotePiCompanion) {
|
||||
const platform = await remoteCommandRunner.execute({
|
||||
command: "sh",
|
||||
args: ["-c", "uname -s; uname -m"],
|
||||
@@ -13019,6 +13041,7 @@ async function createRunnerdBackendWithinSessionClaim(
|
||||
: resolveAcpxQualification(input.execution.provider, process.env),
|
||||
acpxPermissionMode: input.execution.provider.permissionMode,
|
||||
acpxMode: input.execution.provider.mode,
|
||||
piThinkingLevel: input.execution.provider.piThinkingLevel,
|
||||
acpxPermissionModePinned:
|
||||
input.execution.schema === "paperclip.native-execution-input.v4" ||
|
||||
input.execution.schema === "paperclip.native-execution-input.v5",
|
||||
|
||||
@@ -734,6 +734,10 @@ const recoveryFakeCodex = resolve(
|
||||
const previousStateBase = process.env.PAPERCLIP_RUNNER_STATE_DIR;
|
||||
const server = createServer();
|
||||
let firstSession: NativeSession | undefined;
|
||||
const ownedSessions = new Set<NativeSession>();
|
||||
const ownedSpawns: Array<{ pid: number; processGroupId: number | null; startedAt: string }> = [];
|
||||
const onSpawn = async (meta: typeof ownedSpawns[number]) => { ownedSpawns.push(meta); };
|
||||
let executionCloseCompleted = false;
|
||||
const runnerDiagnostics: string[] = [];
|
||||
const onRunnerLog = async (_stream: "stdout" | "stderr", chunk: string) => {
|
||||
runnerDiagnostics.push(chunk.slice(-4_096));
|
||||
@@ -860,6 +864,7 @@ const recoveryFakeCodex = resolve(
|
||||
runnerInstanceId,
|
||||
runnerEnvironment: environment,
|
||||
onLog: onRunnerLog,
|
||||
onSpawn,
|
||||
});
|
||||
firstSession = await firstBackend.openSession({
|
||||
identity: {
|
||||
@@ -871,6 +876,7 @@ const recoveryFakeCodex = resolve(
|
||||
},
|
||||
workingDirectory: workspace,
|
||||
});
|
||||
ownedSessions.add(firstSession);
|
||||
const checkpoint = await firstSession.snapshot();
|
||||
expect(checkpoint.identity).toEqual({
|
||||
companyId,
|
||||
@@ -1009,6 +1015,7 @@ const recoveryFakeCodex = resolve(
|
||||
runnerInstanceId,
|
||||
runnerEnvironment: environment,
|
||||
onLog: onRunnerLog,
|
||||
onSpawn,
|
||||
});
|
||||
let continuity: Record<string, unknown> | undefined;
|
||||
const controlPlaneInstanceId = randomUUID();
|
||||
@@ -1031,6 +1038,12 @@ const recoveryFakeCodex = resolve(
|
||||
runnerInstanceId,
|
||||
controlPlaneInstanceId,
|
||||
timeoutMs: 20_000,
|
||||
// This disposable real-daemon fixture must join full retirement before
|
||||
// removing its controller routes and durable state. The production
|
||||
// default deliberately permits asynchronous cleanup after a result.
|
||||
requireSessionCloseBeforeReturn: true,
|
||||
onSession(value) { if (value) ownedSessions.add(value); },
|
||||
async onSessionClosed() { executionCloseCompleted = true; },
|
||||
controlPlane: port,
|
||||
async onContinuityBreak(value) {
|
||||
continuity = value;
|
||||
@@ -1064,6 +1077,12 @@ const recoveryFakeCodex = resolve(
|
||||
terminal: { runTerminalState: "succeeded" },
|
||||
normalizedSessionId,
|
||||
});
|
||||
expect(executionCloseCompleted).toBe(true);
|
||||
expect(ownedSpawns.length).toBeGreaterThanOrEqual(2);
|
||||
for (const { pid } of ownedSpawns) {
|
||||
expect(() => process.kill(pid, 0)).toThrow(expect.objectContaining({ code: "ESRCH" }));
|
||||
}
|
||||
console.info("Recovery fixture retired owned daemons", ownedSpawns);
|
||||
expect(continuity).toMatchObject({
|
||||
// The daemon can reject the damaged retained input during startup,
|
||||
// before attach gets a chance to reject the unsettled provider session.
|
||||
@@ -1201,9 +1220,13 @@ const recoveryFakeCodex = resolve(
|
||||
.where(eq(issues.id, issueId));
|
||||
expect(task).toEqual({ id: issueId, assigneeAgentId: agentId });
|
||||
} finally {
|
||||
await firstSession
|
||||
?.close({ reason: "Recovery fixture cleanup" })
|
||||
.catch(() => undefined);
|
||||
// onSession(null) quarantines the runtime owner before close finishes;
|
||||
// keep every published handle so an assertion/error cannot lose cleanup.
|
||||
if (firstSession) ownedSessions.add(firstSession);
|
||||
const closed = await Promise.allSettled([...ownedSessions].map((session) =>
|
||||
Promise.resolve().then(() => session.close({ reason: "Recovery fixture cleanup" })),
|
||||
));
|
||||
const closeFailures = closed.filter((result) => result.status === "rejected");
|
||||
runnerPrpWebSocketInternals.resetForTests();
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>((done) => server.close(() => done()));
|
||||
@@ -1211,6 +1234,10 @@ const recoveryFakeCodex = resolve(
|
||||
delete process.env.PAPERCLIP_RUNNER_STATE_DIR;
|
||||
else process.env.PAPERCLIP_RUNNER_STATE_DIR = previousStateBase;
|
||||
await database.cleanup();
|
||||
if (closeFailures.length) {
|
||||
// Retain exact fixture state if owned retirement cannot be proven.
|
||||
throw new AggregateError(closeFailures.map((result) => result.reason), "Recovery fixture session cleanup failed");
|
||||
}
|
||||
await rm(scratch, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
PAPERCLIP_RUNNER_ACPX_PROFILES,
|
||||
resolvePaperclipRunnerPermissionMode,
|
||||
resolvePaperclipRunnerCursorMode,
|
||||
resolvePaperclipRunnerPiThinkingLevel,
|
||||
type PaperclipRunnerProvider,
|
||||
} from "@paperclipai/adapter-utils";
|
||||
import {
|
||||
@@ -29,6 +30,7 @@ export const DEFAULT_ACPX_RUNNER_MODELS = {
|
||||
// These profiles require explicit configuration. Admission belongs to the runner.
|
||||
codex: null,
|
||||
cursor: null,
|
||||
pi: null,
|
||||
} as const;
|
||||
|
||||
export type QualifiedPaperclipRunnerAcpxAgent =
|
||||
@@ -127,6 +129,7 @@ export type PaperclipRunnerNativeProviderInput =
|
||||
acpxAgent: AdmittedPaperclipRunnerAcpxAgent;
|
||||
acpxPermissionMode: "approve-all" | "approve-paperclip" | "approve-reads" | "deny-all";
|
||||
acpxSessionMode?: "agent" | "plan" | "ask";
|
||||
piThinkingLevel?: "off" | "low" | "high" | "max";
|
||||
};
|
||||
|
||||
export class PaperclipRunnerProviderProfileError extends Error {
|
||||
@@ -376,10 +379,13 @@ export function resolvePaperclipRunnerProviderProfile(
|
||||
resolvePaperclipRunnerCursorMode(candidate, config.acpxAgent, config.acpxSessionMode);
|
||||
} catch (error) {
|
||||
throw new PaperclipRunnerProviderProfileError(
|
||||
"paperclip_runner_cursor_mode_invalid",
|
||||
"paperclip_runner_mode_invalid",
|
||||
error instanceof Error ? error.message : "Invalid Cursor session mode",
|
||||
);
|
||||
}
|
||||
try { resolvePaperclipRunnerPiThinkingLevel(candidate, config.acpxAgent, config.piThinkingLevel); } catch (error) {
|
||||
throw new PaperclipRunnerProviderProfileError("paperclip_runner_pi_thinking_invalid", error instanceof Error ? error.message : "Invalid Pi thinking level");
|
||||
}
|
||||
const model = optionalString(config.model);
|
||||
if (candidate === "codex") {
|
||||
return {
|
||||
@@ -486,10 +492,10 @@ export function resolvePaperclipRunnerProviderProfile(
|
||||
}
|
||||
throw new PaperclipRunnerProviderProfileError("paperclip_runner_acpx_agent_unavailable", `${pendingAcpxProfile.label} is awaiting local and Daytona qualification. Its profile is not enabled for production runs.`);
|
||||
}
|
||||
if (acpxAgent === "cursor" && !model) {
|
||||
throw new PaperclipRunnerProviderProfileError("paperclip_runner_acpx_model_required", "Cursor requires an explicit model ID; there is no default model.");
|
||||
if ((acpxAgent === "cursor" || acpxAgent === "pi") && !model) {
|
||||
throw new PaperclipRunnerProviderProfileError("paperclip_runner_acpx_model_required", "This ACPX agent requires an explicit model ID; there is no default model.");
|
||||
}
|
||||
if (acpxAgent !== "claude" && acpxAgent !== "codex" && acpxAgent !== "grok" && acpxAgent !== "cursor") {
|
||||
if (acpxAgent !== "claude" && acpxAgent !== "codex" && acpxAgent !== "grok" && acpxAgent !== "cursor" && acpxAgent !== "pi") {
|
||||
throw new PaperclipRunnerProviderProfileError(
|
||||
"paperclip_runner_acpx_agent_unavailable",
|
||||
"Paperclip Runner ACPX requires a qualified agent profile.",
|
||||
@@ -567,6 +573,7 @@ export function resolvePaperclipRunnerNativeProviderInput(input: {
|
||||
provider: "acpx",
|
||||
model: profile.model,
|
||||
acpxAgent: profile.acpxAgent,
|
||||
...(profile.acpxAgent === "pi" ? { piThinkingLevel: resolvePaperclipRunnerPiThinkingLevel("acpx", "pi", config.piThinkingLevel) } : {}),
|
||||
...(profile.acpxAgent === "cursor" ? {
|
||||
acpxSessionMode: resolvePaperclipRunnerCursorMode("acpx", "cursor", config.acpxSessionMode),
|
||||
} : {}),
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
/** Explicit operator-imported Linux authority. No downloads or executable probes. */
|
||||
import { setImmediate as yieldToSignals } from "node:timers/promises";
|
||||
import { createHash } from "node:crypto";
|
||||
import { type Stats, constants } from "node:fs";
|
||||
import * as fs from "node:fs/promises";
|
||||
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { QUALIFIED_ACPX_PROFILES } from "../../vendor/paperclip-runner/index.js";
|
||||
|
||||
const SERVER_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
|
||||
const MAX_BYTES = 4 * 1024 ** 3;
|
||||
const MAX_FILE = 512 * 1024 ** 2;
|
||||
const MANIFEST = "companion.json";
|
||||
const AUTHORITY = ".import-authority.json";
|
||||
type Entry = { path: string; mode: number; kind: "directory" } | { path: string; mode: number; kind: "file"; size: number; sha256: string } | { path: string; mode: number; kind: "symlink"; target: string };
|
||||
export interface RemotePiCompanionManifest { schema: "paperclip.remote-pi-companion/v1"; sourceRevision: string; target: "linux-x64"; profileDigest: string; providerPackDigest: string; daemonSha256: string; entries: Entry[] }
|
||||
function require(value: unknown, reason: string): asserts value { if (!value) throw new Error(`runner_remote_companion_invalid: ${reason}`); }
|
||||
const digest = (value: Buffer | string) => createHash("sha256").update(value).digest("hex");
|
||||
const safe = (path: string) => path.length > 0 && path.length < 4096 && !isAbsolute(path) && !/[\\\x00-\x1f\x7f]/u.test(path) && path.split("/").every(p => p !== "" && p !== "." && p !== "..");
|
||||
const inside = (root: string, path: string) => path === root || (!relative(root, path).startsWith("..") && !isAbsolute(relative(root, path)));
|
||||
const same = (a: Stats, b: Stats) => a.dev === b.dev && a.ino === b.ino && a.size === b.size && a.mode === b.mode && a.mtimeMs === b.mtimeMs && a.ctimeMs === b.ctimeMs && a.nlink === b.nlink;
|
||||
type Checkpoint = () => Promise<void>;
|
||||
function checkpoints(cancel?: () => void): Checkpoint {
|
||||
const deadline = Date.now() + 600_000;
|
||||
return async () => { await yieldToSignals(); cancel?.(); require(Date.now() < deadline, "import deadline exceeded"); };
|
||||
}
|
||||
async function directory(path: string) { const st = await fs.lstat(path); require(st.isDirectory() && !st.isSymbolicLink() && await fs.realpath(path) === path, "directory is linked or noncanonical"); return st; }
|
||||
/** One descriptor and 1MiB buffer; no whole executable allocation or sync hashing. */
|
||||
async function readStable(path: string, maximum: number, privateFile: boolean, check: Checkpoint, consume?: (chunk: Buffer) => Promise<void>) {
|
||||
require(await fs.realpath(dirname(path)) === dirname(path), "linked parent");
|
||||
const file = await fs.open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
||||
try {
|
||||
const before = await file.stat(); require(before.isFile() && before.size <= maximum && (!privateFile || before.nlink === 1), "file type, link or byte bound");
|
||||
const hash = createHash("sha256"); let size = 0; const buffer = Buffer.alloc(1024 * 1024); let header = Buffer.alloc(0);
|
||||
for (;;) {
|
||||
await check(); const { bytesRead } = await file.read(buffer); if (!bytesRead) break;
|
||||
size += bytesRead; require(size <= maximum && size <= before.size, "file grew beyond bound");
|
||||
const chunk = buffer.subarray(0, bytesRead); hash.update(chunk); if (!header.length) header = Buffer.from(chunk.subarray(0, 64));
|
||||
await consume?.(chunk);
|
||||
}
|
||||
require(size === before.size && same(before, await file.stat()) && same(before, await fs.lstat(path)), "file changed during read");
|
||||
return { size, sha256: hash.digest("hex"), header, stat: before };
|
||||
} finally { await file.close(); }
|
||||
}
|
||||
async function readOwned(path: string, maximum: number, check: Checkpoint, privateFile = false): Promise<Buffer> {
|
||||
const chunks: Buffer[] = []; await readStable(path, maximum, privateFile, check, async chunk => { chunks.push(Buffer.from(chunk)); }); return Buffer.concat(chunks);
|
||||
}
|
||||
/** Complete no-follow inventory, including modes and contained symbolic links. */
|
||||
export async function inventoryRemoteCompanion(root: string, check = checkpoints()): Promise<Entry[]> {
|
||||
await directory(root); const entries: Entry[] = []; const links = new Map<string, { count: number; links: number }>(); let total = 0;
|
||||
const visit = async (path: string): Promise<void> => {
|
||||
const before = await directory(path);
|
||||
for (const name of (await fs.readdir(path)).sort()) {
|
||||
await check();
|
||||
if (path === root && [MANIFEST, AUTHORITY].includes(name)) continue;
|
||||
const file = join(path, name); const rel = relative(root, file); require(safe(rel), "unsafe path");
|
||||
const st = await fs.lstat(file); const mode = st.mode & 0o7777;
|
||||
require(entries.length < 100_000 && (st.isSymbolicLink() || (mode & 0o7022) === 0), "entry count or unsafe mode");
|
||||
if (st.isDirectory()) { require((mode & 0o700) === 0o700, "directory requires owner read/write/search for owned cleanup"); entries.push({ path: rel, mode, kind: "directory" }); await visit(file); }
|
||||
else if (st.isFile()) {
|
||||
total += st.size; require(total <= MAX_BYTES, "tree byte bound"); const read = await readStable(file, MAX_FILE, false, check);
|
||||
require(same(st, read.stat), "discovered file changed");
|
||||
entries.push({ path: rel, mode, kind: "file", size: read.size, sha256: read.sha256 });
|
||||
const key = `${st.dev}:${st.ino}`; const group = links.get(key) ?? { count: 0, links: st.nlink }; group.count++; require(group.links === st.nlink, "hardlink identity drift"); links.set(key, group);
|
||||
} else if (st.isSymbolicLink()) {
|
||||
const target = await fs.readlink(file); require(!isAbsolute(target) && !/[\x00-\x1f\x7f]/u.test(target) && inside(root, resolve(dirname(file), target)) && inside(root, await fs.realpath(file)), "escaping symbolic link");
|
||||
require(same(st, await fs.lstat(file)), "link changed"); entries.push({ path: rel, mode, kind: "symlink", target });
|
||||
} else throw new Error("runner_remote_companion_invalid: special entry");
|
||||
}
|
||||
const after = await directory(path); require(before.dev === after.dev && before.ino === after.ino && before.mtimeMs === after.mtimeMs, "directory changed");
|
||||
};
|
||||
await visit(root); require([...links.values()].every(g => g.count === g.links), "external hardlink");
|
||||
return entries.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
|
||||
}
|
||||
async function installedIdentity(serverRoot: string, check: Checkpoint) {
|
||||
require(process.platform === "darwin" || process.platform === "linux", "companion import supports macOS and Linux controllers");
|
||||
await directory(serverRoot); const pkg = JSON.parse((await readOwned(join(serverRoot, "package.json"), 65536, check, true)).toString()); require(pkg.name === "@paperclipai/server", "public server package required");
|
||||
const source = JSON.parse((await readOwned(join(serverRoot, "dist/build-info.json"), 65536, check, true)).toString()).commit;
|
||||
require(typeof source === "string" && /^[a-f0-9]{40}$/u.test(source), "installed build source is missing");
|
||||
const profile = QUALIFIED_ACPX_PROFILES.pi; require(profile.qualificationStatus !== "pending", "Pi is not qualified");
|
||||
return { source, profileDigest: profile.commandDigest };
|
||||
}
|
||||
async function validate(root: string, manifest: RemotePiCompanionManifest, identity: Awaited<ReturnType<typeof installedIdentity>>, check: Checkpoint) {
|
||||
require(manifest.schema === "paperclip.remote-pi-companion/v1" && manifest.target === "linux-x64" && manifest.sourceRevision === identity.source && manifest.profileDigest === identity.profileDigest, "source/profile/target mismatch");
|
||||
require(JSON.stringify(await inventoryRemoteCompanion(root, check)) === JSON.stringify(manifest.entries), "complete inventory mismatch");
|
||||
const daemon = manifest.entries.find(e => e.path === "bin/paperclip-runnerd");
|
||||
require(daemon?.kind === "file" && daemon.size > 64 && (daemon.mode & 0o111) !== 0 && daemon.sha256 === manifest.daemonSha256, "daemon identity");
|
||||
const elf = (await readStable(join(root, daemon.path), MAX_FILE, false, check)).header; require(elf.subarray(0, 4).equals(Buffer.from([127, 69, 76, 70])) && elf[4] === 2 && elf[5] === 1 && elf.readUInt16LE(18) === 62, "Linux x64 ELF required");
|
||||
const pack = JSON.parse((await readOwned(join(root, "provider-pack/provider-pack.json"), 16 * 1024 ** 2, check)).toString());
|
||||
const canonical = (v: unknown): string => Array.isArray(v) ? `[${v.map(canonical).join(",")}]` : v && typeof v === "object" ? `{${Object.keys(v).sort().map(k => `${JSON.stringify(k)}:${canonical((v as Record<string, unknown>)[k])}`).join(",")}}` : JSON.stringify(v);
|
||||
require(pack.schema === "paperclip-runner/remote-provider-pack/v1" && pack.digest === manifest.providerPackDigest && pack.digest === `sha256:${digest(canonical(pack.payload))}`, "pack manifest digest");
|
||||
require(pack.payload.runnerSourceRevision === identity.source && pack.payload.target.platform === "linux" && pack.payload.target.architecture === "x64", "pack source/target");
|
||||
const pi = pack.payload.candidateProviders?.pi; require(pi?.qualification === "qualified" && pi.profileDigest === identity.profileDigest && pi.path === "provider-assets/pi/linux-x64", "normal Pi pack required");
|
||||
}
|
||||
/** Release-side command uses this same inventory contract before publication. */
|
||||
export async function createRemotePiCompanionManifest(root: string, sourceRevision: string): Promise<RemotePiCompanionManifest> {
|
||||
const check = checkpoints();
|
||||
const profile = QUALIFIED_ACPX_PROFILES.pi; const pack = JSON.parse((await readOwned(join(root, "provider-pack/provider-pack.json"), 16 * 1024 ** 2, check)).toString()); const entries = await inventoryRemoteCompanion(root, check);
|
||||
const daemon = entries.find(e => e.path === "bin/paperclip-runnerd"); require(daemon?.kind === "file", "daemon missing");
|
||||
const manifest: RemotePiCompanionManifest = { schema: "paperclip.remote-pi-companion/v1", sourceRevision, target: "linux-x64", profileDigest: profile.commandDigest, providerPackDigest: pack.digest, daemonSha256: daemon.sha256, entries };
|
||||
require(/^[a-f0-9]{40}$/u.test(sourceRevision), "release source"); await validate(root, manifest, { source: sourceRevision, profileDigest: profile.commandDigest }, check); return manifest;
|
||||
}
|
||||
function cacheParent(serverRoot: string) { return join(serverRoot, "remote-companions"); }
|
||||
async function removeOwned(path: string, owned: Stats) { const st = await fs.lstat(path); require(st.dev === owned.dev && st.ino === owned.ino && !st.isSymbolicLink(), "cleanup root ownership changed"); await fs.rm(path, { recursive: true }); }
|
||||
export async function importRemotePiCompanion(input: { directory: string; sha256: string; serverRoot?: string; checkCancelled?: () => void }) {
|
||||
const checkpoint = checkpoints(input.checkCancelled);
|
||||
await checkpoint();
|
||||
const serverRoot = input.serverRoot ?? SERVER_ROOT; const identity = await installedIdentity(serverRoot, checkpoint); const source = await fs.realpath(input.directory); require(source === resolve(input.directory), "linked import root"); await directory(source);
|
||||
require(/^[a-f0-9]{64}$/u.test(input.sha256), "expected manifest SHA256 required"); const bytes = await readOwned(join(source, MANIFEST), 32 * 1024 ** 2, checkpoint); require(digest(bytes) === input.sha256, "operator manifest digest mismatch");
|
||||
const manifest = JSON.parse(bytes.toString()) as RemotePiCompanionManifest; await validate(source, manifest, identity, checkpoint); await checkpoint();
|
||||
const parent = cacheParent(serverRoot); try { await fs.mkdir(parent, { mode: 0o700 }); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw new Error("Remote companion setup requires a writable installed server package", { cause: error }); }
|
||||
const parentInfo = await directory(parent); require((parentInfo.mode & 0o077) === 0 && parentInfo.uid === process.getuid?.(), "cache must be private and operator-owned");
|
||||
const lockPath = join(parent, ".import-linux-x64.lock"); const lock = await fs.open(lockPath, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, 0o600); const lockInfo = await lock.stat();
|
||||
let staging: string | undefined; let stagingInfo: Stats | undefined; let outputInfo: Stats | undefined; let committed = false; const output = join(parent, "linux-x64");
|
||||
try {
|
||||
await checkpoint();
|
||||
try { await fs.lstat(output); const existing = await resolveRemotePiCompanion({ serverRoot, checkpoint }); require(existing?.manifestSha256 === input.sha256, "existing companion differs; remove only after stopping all runs"); await checkpoint(); return { status: "verified_existing", ...existing }; } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
||||
staging = await fs.mkdtemp(join(parent, ".import-")); stagingInfo = await fs.lstat(staging);
|
||||
for (const entry of manifest.entries.filter(e => e.kind === "directory").sort((a, b) => a.path.split("/").length - b.path.split("/").length)) await fs.mkdir(join(staging, entry.path), { mode: 0o700 });
|
||||
for (const entry of manifest.entries) {
|
||||
if (entry.kind !== "file") continue;
|
||||
const file = join(staging, entry.path); const outputFile = await fs.open(file, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, 0o600);
|
||||
try {
|
||||
const read = await readStable(join(source, entry.path), MAX_FILE, false, checkpoint, async chunk => {
|
||||
let offset = 0; while (offset < chunk.length) { await checkpoint(); offset += (await outputFile.write(chunk, offset)).bytesWritten; }
|
||||
});
|
||||
require(read.size === entry.size && read.sha256 === entry.sha256, "source changed while copied");
|
||||
} finally { await outputFile.close(); }
|
||||
await fs.chmod(file, entry.mode); await checkpoint();
|
||||
}
|
||||
for (const entry of manifest.entries) if (entry.kind === "symlink") await fs.symlink(entry.target, join(staging, entry.path));
|
||||
for (const entry of manifest.entries.filter(e => e.kind === "directory").reverse()) await fs.chmod(join(staging, entry.path), entry.mode);
|
||||
await fs.writeFile(join(staging, MANIFEST), bytes, { flag: "wx", mode: 0o600 }); await validate(staging, manifest, identity, checkpoint); await validate(source, manifest, identity, checkpoint); await checkpoint();
|
||||
// Claim the final path exclusively; no rename may replace a concurrent directory.
|
||||
await fs.mkdir(output, { mode: 0o700 }); outputInfo = await fs.lstat(output);
|
||||
for (const name of await fs.readdir(staging)) { const now = await directory(output); require(now.dev === outputInfo.dev && now.ino === outputInfo.ino, "publication ownership changed"); await fs.rename(join(staging, name), join(output, name)); }
|
||||
await fs.writeFile(join(output, AUTHORITY), JSON.stringify({ sha256: input.sha256 }) + "\n", { flag: "wx", mode: 0o600 });
|
||||
const result = await resolveRemotePiCompanion({ serverRoot, checkpoint }); require(result?.manifestSha256 === input.sha256, "publication verification"); await checkpoint(); committed = true; return { status: "imported_verified", ...result };
|
||||
} finally {
|
||||
const failures: unknown[] = []; const cleanup = async (fn: () => Promise<void>) => { try { await fn(); } catch (error) { failures.push(error); } };
|
||||
if (outputInfo && !committed) await cleanup(() => removeOwned(output, outputInfo!));
|
||||
if (staging && stagingInfo) await cleanup(() => removeOwned(staging!, stagingInfo!));
|
||||
await cleanup(async () => { const now = await fs.lstat(lockPath); require(now.dev === lockInfo.dev && now.ino === lockInfo.ino, "lock ownership changed"); await fs.unlink(lockPath); }); await lock.close();
|
||||
if (failures.length) throw new AggregateError(failures, "Remote companion cleanup incomplete; inspect owned paths before retrying");
|
||||
}
|
||||
}
|
||||
export async function resolveRemotePiCompanion(input: { serverRoot?: string; workspaceRoot?: string; checkpoint?: Checkpoint } = {}) {
|
||||
const check = input.checkpoint ?? checkpoints();
|
||||
const serverRoot = input.serverRoot ?? SERVER_ROOT; const parent = cacheParent(serverRoot); const root = join(parent, "linux-x64");
|
||||
try { await fs.lstat(root); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; throw error; }
|
||||
const parentInfo = await directory(parent); require((parentInfo.mode & 0o077) === 0 && parentInfo.uid === process.getuid?.(), "cache privacy"); await directory(root);
|
||||
if (input.workspaceRoot) { const workspace = await fs.realpath(input.workspaceRoot); require(!inside(workspace, root) && !inside(root, workspace), "companion cache cannot overlap a workspace"); }
|
||||
const authority = JSON.parse((await readOwned(join(root, AUTHORITY), 65536, check, true)).toString()); const bytes = await readOwned(join(root, MANIFEST), 32 * 1024 ** 2, check, true); require(digest(bytes) === authority.sha256, "installed authority changed");
|
||||
const manifest = JSON.parse(bytes.toString()) as RemotePiCompanionManifest; await validate(root, manifest, await installedIdentity(serverRoot, check), check);
|
||||
return { root, runnerBinary: join(root, "bin/paperclip-runnerd"), providerPack: join(root, "provider-pack"), manifestSha256: authority.sha256 as string, sourceRevision: manifest.sourceRevision, target: manifest.target };
|
||||
}
|
||||
|
||||
/** Explicit legacy overrides remain authoritative; C/C never gain this Pi path. */
|
||||
export async function selectRemotePiCompanion(input: { provider: { kind: string; agent?: string }; remote: boolean; binaryOverride?: string | null; packOverride?: string | null; workspaceRoot: string; serverRoot?: string }) {
|
||||
if (!input.remote || input.provider.kind !== "acpx" || input.provider.agent !== "pi" || input.binaryOverride?.trim() || input.packOverride?.trim()) return null;
|
||||
return resolveRemotePiCompanion({ serverRoot: input.serverRoot, workspaceRoot: input.workspaceRoot });
|
||||
}
|
||||
@@ -44,71 +44,27 @@ function arbitrate(
|
||||
}
|
||||
|
||||
describe("native status authority", () => {
|
||||
it("waits on persisted monitors without an immediate continuation, even with unfinished work", () => {
|
||||
const pending = assessment({ reportedDisposition: "yielded", hasBlockingRemainingWork: true,
|
||||
continuation: { kind: "monitor", summary: "Check CI", idempotencyKey: "ci" } });
|
||||
for (const priorIssueStatus of ["in_progress", "in_review"] as const) {
|
||||
expect(arbitrate({ assessment: pending, priorIssueStatus, monitorWaitAuthorized: true }))
|
||||
.toMatchObject({ statusAction: "preserve", toStatus: priorIssueStatus,
|
||||
reasonCode: "scheduled_monitor_waiting", effects: [{ kind: "release_checkout" }] });
|
||||
}
|
||||
expect(arbitrate({ assessment: pending })).toMatchObject({ reasonCode: "monitor_wait_authority_lost",
|
||||
effects: [{ kind: "record_finalization_error" }] });
|
||||
expect(arbitrate({ assessment: pending, monitorWaitAuthorized: true, hasUnresolvedIssueBlockers: true }).toStatus).toBe("blocked");
|
||||
expect(arbitrate({ assessment: pending, monitorWaitAuthorized: true, governanceGate: { kind: "approval", id: "pending" } }).toStatus).toBe("in_review");
|
||||
});
|
||||
|
||||
it("keeps a pending child result non-terminal without adding another continuation", () => {
|
||||
expect(arbitrate({ hasPendingChildCompletion: true })).toMatchObject({
|
||||
statusAction: "in_progress", toStatus: "in_progress", reasonCode: "native_child_completion_pending",
|
||||
effects: [{ kind: "release_checkout" }],
|
||||
it("keeps a verified accepted Cursor plan passive without completing or replaying work", () => {
|
||||
const passive = assessment({ reportedDisposition: "yielded", objectiveSatisfied: false,
|
||||
allCriteriaSatisfied: false, hasBlockingRemainingWork: true,
|
||||
continuation: { kind: "response_wake", summary: "Explicit continuation needed", idempotencyKey: "cursor-plan-wait:event" } });
|
||||
expect(arbitrate({ assessment: passive, planWaitAuthorized: true })).toMatchObject({
|
||||
toStatus: "in_progress", reasonCode: "native_plan_accepted_waiting_for_continuation", effects: [],
|
||||
});
|
||||
expect(arbitrate({ hasPendingChildCompletion: false }).toStatus).toBe("done");
|
||||
for (const priorIssueStatus of ["done", "cancelled"] as const) {
|
||||
expect(arbitrate({ priorIssueStatus, hasPendingChildCompletion: true }).toStatus).toBe(priorIssueStatus);
|
||||
}
|
||||
expect(arbitrate({ hasPendingChildCompletion: true, hasUnresolvedIssueBlockers: true }).toStatus).toBe("blocked");
|
||||
expect(arbitrate({ hasPendingChildCompletion: true, governanceGate: { kind: "approval", id: "approval" } }).toStatus)
|
||||
.toBe("in_review");
|
||||
expect(arbitrate({ hasPendingChildCompletion: true, workspaceFinalizeStatus: "failed" }).statusAction).toBe("preserve");
|
||||
expect(arbitrate({ assessment: passive })).toMatchObject({
|
||||
toStatus: "in_progress",
|
||||
reasonCode: "completion_evidence_incomplete",
|
||||
effects: [expect.objectContaining({
|
||||
kind: "enqueue_continuation",
|
||||
continuationKind: "same_agent",
|
||||
idempotencyKey: "native-completion-incomplete",
|
||||
})],
|
||||
});
|
||||
expect(arbitrate({ assessment: passive, planWaitAuthorized: true, terminalState: "failed" }).reasonCode).not.toBe("native_plan_accepted_waiting_for_continuation");
|
||||
expect(arbitrate({ assessment: passive, planWaitAuthorized: true, priorIssueStatus: "cancelled" }).toStatus).toBe("cancelled");
|
||||
expect(arbitrate({ assessment: passive, planWaitAuthorized: true, governanceGate: { kind: "interaction", id: "pending" } }).toStatus).toBe("in_review");
|
||||
});
|
||||
|
||||
it("schedules a capacity retry while preserving partial work and review authority", () => {
|
||||
for (const nativeReviewOutcome of [undefined, "pending"] as const) {
|
||||
const decision = arbitrate({ terminalState: "failed", providerOverloaded: true, nativeReviewOutcome });
|
||||
expect(decision).toMatchObject({ statusAction: "preserve", reasonCode: "native_provider_overloaded" });
|
||||
expect(decision.effects).toContainEqual(expect.objectContaining({ kind: "schedule_retry", cause: "native_provider_overloaded" }));
|
||||
}
|
||||
expect(arbitrate({ terminalState: "failed", providerOverloaded: true, failureRetryCount: 2 }))
|
||||
.toMatchObject({ statusAction: "blocked", reasonCode: "native_provider_overloaded_exhausted", effects: [{ kind: "bind_blocker", owner: "board", action: expect.stringContaining("Automatic retries exhausted") }] });
|
||||
});
|
||||
it.each([
|
||||
{ hasActivePauseHold: true }, { hasUnresolvedIssueBlockers: true },
|
||||
{ governanceGate: { kind: "approval" as const, id: "approval" } },
|
||||
{ priorIssueStatus: "blocked" as const }, { priorIssueStatus: "done" as const },
|
||||
{ workspaceFinalizeStatus: "failed" as const }, { nativeReviewOutcome: "stale" as const },
|
||||
{ nativeReviewOutcome: "resolved" as const },
|
||||
])("does not schedule capacity retries through existing authority or cleanup gates (%j)", (gate) => {
|
||||
const decision = arbitrate({ terminalState: "failed", providerOverloaded: true, ...gate });
|
||||
expect(decision.effects.some(effect => effect.kind === "schedule_retry")).toBe(false);
|
||||
});
|
||||
it.each(["in_progress", "in_review"] as const)("blocks a current worker's proven model rejection without a retry (%s)", (priorIssueStatus) => {
|
||||
const decision = arbitrate({ priorIssueStatus, terminalState: "failed", providerModelRejected: true });
|
||||
expect(decision).toMatchObject({ statusAction: "blocked", toStatus: "blocked", reasonCode: "native_provider_model_rejected", unblockDescriptor: { owner: "board" } });
|
||||
expect(decision.effects).toEqual([{ kind: "bind_blocker", owner: "board", action: expect.any(String) }]);
|
||||
expect(arbitrate({ terminalState: "failed", providerModelRejected: false }).effects).toContainEqual(expect.objectContaining({ kind: "schedule_retry" }));
|
||||
expect(arbitrate({ terminalState: "succeeded", providerModelRejected: true }).reasonCode).not.toBe("native_provider_model_rejected");
|
||||
expect(arbitrate({ terminalState: "failed", providerModelRejected: true, workspaceFinalizeStatus: "failed" }).reasonCode).toBe("finalization_failed_claim_preserved");
|
||||
expect(arbitrate({ terminalState: "failed", providerModelRejected: true, priorIssueStatus: "done" }).toStatus).toBe("done");
|
||||
for (const nativeReviewOutcome of ["stale", "resolved"] as const) {
|
||||
expect(arbitrate({ terminalState: "failed", providerModelRejected: true, priorIssueStatus, nativeReviewOutcome }))
|
||||
.toMatchObject({ statusAction: "preserve", toStatus: priorIssueStatus, reasonCode: "native_review_action_finished" });
|
||||
}
|
||||
});
|
||||
it("preserves pending review authority without automatic recovery after model rejection", () => {
|
||||
expect(arbitrate({ priorIssueStatus: "in_review", terminalState: "failed", providerModelRejected: true, nativeReviewOutcome: "pending" }))
|
||||
.toMatchObject({ statusAction: "preserve", toStatus: "in_review", reasonCode: "native_provider_model_rejected", unblockDescriptor: null, effects: [{ kind: "release_checkout" }] });
|
||||
});
|
||||
it("a reviewer finishes its decision without completing rejected or still-reviewed work", () => {
|
||||
for (const priorIssueStatus of ["in_progress", "in_review"] as const) {
|
||||
const decision = arbitrate({ priorIssueStatus, nativeReviewOutcome: "resolved" });
|
||||
|
||||
@@ -1699,32 +1699,6 @@ export async function commitNativeStatusDecision(input: {
|
||||
throw new NativeStatusRaceError();
|
||||
}
|
||||
}
|
||||
let cursorPlanWait: Awaited<ReturnType<typeof readNativeCursorPlanWait>> = null;
|
||||
let cursorPlanWaitResult: { resultId: string; resultSha256: string } | null = null;
|
||||
if (reasonCode === "native_plan_accepted_waiting_for_continuation") {
|
||||
const expected = input.requireCursorPlanWaitSource;
|
||||
if (!expected || expected.companyId !== input.companyId || expected.issueId !== input.issueId || expected.runId !== input.runId || input.decision.effects.length !== 0 || input.decision.statusAction !== "in_progress" || input.decision.toStatus !== "in_progress") throw new NativeStatusRaceError();
|
||||
try {
|
||||
cursorPlanWait = await readNativeCursorPlanWait(tx as unknown as Db, expected, true);
|
||||
if (!cursorPlanWait || nativeSha256(cursorPlanWait.source) !== nativeSha256(expected)) throw new NativeStatusRaceError();
|
||||
// Recheck the committed semantic result too: an arbitrary caller cannot
|
||||
// use a genuine plan receipt to authorize a different finalization.
|
||||
const [accepted] = await tx.select().from(nativeRunResults).where(and(
|
||||
eq(nativeRunResults.id, coordinator!.resultId!), eq(nativeRunResults.companyId, input.companyId),
|
||||
eq(nativeRunResults.issueId, input.issueId), eq(nativeRunResults.runId, input.runId), eq(nativeRunResults.schemaStatus, "accepted"),
|
||||
)).for("share", { noWait: true });
|
||||
const envelope = record(accepted?.resultJson), terminal = record(envelope.terminal);
|
||||
if (!accepted || accepted.completionContractId !== cursorPlanWait.source.contractId || accepted.turnId !== cursorPlanWait.source.turnId ||
|
||||
nativeSha256(envelope.result) !== nativeSha256(cursorPlanWait.result) ||
|
||||
terminal.schema !== "paperclip.prp.terminal.v1" || terminal.runTerminalState !== "succeeded" || terminal.turnTerminalState !== "completed" || terminal.reportedWorkDisposition !== "yielded") throw new NativeStatusRaceError();
|
||||
// completeRun hashes its full private binding, not the stored resultJson.
|
||||
// Preserve that accepted identity instead of inventing a new digest.
|
||||
cursorPlanWaitResult = { resultId: accepted.id, resultSha256: accepted.canonicalSha256 };
|
||||
} catch (error) {
|
||||
if (isExternalChatWaitAuthorizationContention(error)) throw new NativeStatusRaceError();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const passiveBoardResponseWait =
|
||||
reasonCode === "board_response_waiting" ||
|
||||
reasonCode === "board_response_wait_superseded";
|
||||
|
||||
@@ -39,4 +39,3 @@ export type Capability = {
|
||||
name: string; description: string; schema: z.ZodObject; write?: boolean; configure?: boolean; destructive?: boolean; ephemeral?: boolean;
|
||||
run: (p: McpPrincipal, args: Record<string, unknown>, api: ApiDispatch, origin: string, transfers?: PublicMcpTransfers) => Promise<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { hasCommittedNativeCursorPlanWait } from "../native-runtime/native-cursor-plan-wait.js";
|
||||
import { isAiAuthenticationBlocked } from "../ai-auth-failure.js";
|
||||
import { hasCommittedNativePlanWait } from "../native-runtime/native-plan-wait.js";
|
||||
import { isNativeWorkspaceExportRepairCause } from "@paperclipai/shared";
|
||||
|
||||
Reference in new issue
Block a user