Files
PaperClipAI/tests/runner-e2e/native-provider-loss-flow.ts
T
DottaandPaperclip d0341c36b9 Bind runner browser fixtures to prompt-only task creation
Use current composer controls and creation response IDs so generated titles cannot break task lookup. Verify standard, Plan, and Ask creation with paused agents before paid qualification.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-06 09:11:44 -05:00

154 lines
14 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { join } from "node:path";
import { expect, type Page } from "@playwright/test";
import { pollUntil, type RunnerApi } from "./api.js";
import { collectRunEvents } from "./run-observations.js";
import { createTaskThroughUi } from "./user-actions.js";
import { createDeniedTargetFixture, exists, observeRunProcesses, injectObservedRunLoss } from "./native-local-fixtures.js";
import { assertNativeRemoteRetirement, nativeRemoteDeniedSample, prepareNativeRemoteAction, type NativeRemoteBootstrap, type NativeRemoteFixture, type NativeRemoteSnapshot } from "./native-remote-evidence.js";
import { cursorDeniedCommand } from "./cursor-native-evidence.js";
import { readActiveStopCaller, observeActiveStopPending, type ActiveStopPending, type ActiveStopScope } from "./native-active-stop-evidence.js";
import type { BootstrapReadProof } from "./native-bootstrap-read-proof.js";
import { bootstrapReadExecutionId } from "./native-bootstrap-read-proof.js";
import type { LiveFixtureValues } from "./live-fixtures.js";
import type { MatrixExecution } from "./types.js";
type Row = Record<string, any>;
type Check = { id: string; passed: boolean; detail: string };
/** Transport loss must never turn an unanswered native mutation into success. */
export function assertProviderLossSettlement(run: Row, issue: Row, events: readonly Row[], pending: ActiveStopPending) {
const fail = (ok: boolean, reason: string) => { if (!ok) throw new Error(`Native provider loss: ${reason}`); };
fail(run.id === pending.scope.runId && run.companyId === pending.scope.companyId && run.nativeIssueId === pending.scope.issueId
&& run.status === "failed" && run.runtimeMode === "native" && typeof run.error === "string" && run.error.length > 0
&& issue.id === pending.scope.issueId && issue.companyId === pending.scope.companyId && issue.status === "blocked"
&& run.resultJson?.nativeCancellation == null, "failure must leave the task open without claiming Stop or success");
const frames = events.flatMap(row => row.payload?.prpEvent ? [row.payload.prpEvent] : []);
const closures = frames.filter(frame => ["runtime_request.resolved", "runtime_request.cancelled", "runtime_request.expired"].includes(frame.eventType)
&& frame.payload?.requestId === pending.requestId);
fail(closures.length === 1 && ["runtime_request.cancelled", "runtime_request.expired"].includes(closures[0].eventType)
&& closures[0].payload.turnId === pending.turnId && closures[0].payload.requestKind === "permission_approval"
&& closures[0].payload.action === undefined && closures[0].payload.response === undefined && closures[0].payload.replayAllowed !== true,
"undeliverable permission must close without acceptance or replay");
fail(!frames.some(frame => frame.eventType === "turn.completed" || frame.eventType === "completion.accepted"
|| frame.eventType === "tool.execution.completed" && frame.payload?.executionId === bootstrapReadExecutionId(pending.toolCallId)
&& frame.payload?.status === "completed"), "no completion or completed mutation after loss");
return { schema: "paperclip.e2e.native-provider-loss.v1", requestId: pending.requestId, turnId: pending.turnId,
requestClosure: closures[0].eventType, taskStillOpen: true, failedRun: true, replayAllowed: false };
}
export async function runNativeProviderLossFlow(input: {
page: Page; api: RunnerApi; fixtures: LiveFixtureValues; execution: MatrixExecution; nonce: string; workspacePath: string; deadlineAt: number;
remoteBootstrap?: NativeRemoteBootstrap;
registerCleanupAssertion(callback: () => Promise<Check[]>): void;
registerBeforeEnvironmentTeardownAssertion(callback: () => Promise<Check[]>): void;
observe(issue: Row, runs: Row[]): void;
capture(id: string, label: string, file: string): Promise<void>;
evidence(name: string, value: unknown): Promise<void>;
}) {
const { api, page, fixtures, execution, nonce } = input;
if (execution.profile.qualificationCandidate !== "cursor" || execution.task.id !== "pending-permission-provider-loss") throw new Error("Unknown provider-loss case");
const remote = execution.environment.id === "daytona";
if (remote && !input.remoteBootstrap) throw new Error("Provider loss requires its admitted remote observer");
const local = remote ? undefined : await createDeniedTargetFixture(input.workspacePath, `provider-loss-${nonce}.txt`);
const target = local?.targetRelativePath ?? `provider-loss-${nonce}.txt`;
let command = local ? cursorDeniedCommand(local.targetPath) : undefined;
const observer = remote ? undefined : observeRunProcesses();
let issue: Row = {}, runs: Row[] = [], events: Row[] = [];
let fixture: NativeRemoteFixture | undefined, baseline: NativeRemoteSnapshot | undefined, sealed: NativeRemoteSnapshot | undefined;
let processes: ReturnType<ReturnType<typeof observeRunProcesses>["sample"]> | undefined;
let pending: ActiveStopPending | undefined, settled = false;
const checks: Check[] = [], samples: Array<{ phase: string; absent: boolean }> = [];
const check = (id: string, ok: boolean, detail: string) => { checks.push({ id, passed: ok, detail }); expect(ok, detail).toBe(true); };
const scope = (): ActiveStopScope => ({ provider: "cursor", companyId: fixtures.company.id, issueId: issue.id, runId: runs[0]!.id, target, commandSha256: command!.commandSha256 });
const bootstrap = (): BootstrapReadProof | undefined => fixture ? { actionFile: fixture.actionFile, events } : undefined;
const authority = () => { const run = runs[0]!; return { pid: run.processPid, groupId: run.processGroupId, startedAt: run.processStartedAt, runId: run.id }; };
const load = async () => {
if (issue.id) issue = await api.get<Row>(`/api/issues/${issue.id}`);
const list = await api.get<Row[]>(`/api/companies/${fixtures.company.id}/heartbeat-runs?limit=100`);
runs = await Promise.all(list.map(run => api.get<Row>(`/api/heartbeat-runs/${run.id}`)));
if (runs.length > 1) throw new Error("Provider loss automatically dispatched another run");
events = runs[0] ? await collectRunEvents<Row>((afterSeq, limit) => api.get(`/api/heartbeat-runs/${runs[0]!.id}/events?afterSeq=${afterSeq}&limit=${limit}`)) : [];
if (observer && runs[0]?.processPid) processes = observer.sample(authority());
input.observe(issue, runs); return { run: runs[0] ?? {}, issue, events };
};
const sample = async (phase: string) => {
const absent = !await exists(local!.targetPath); samples.push({ phase, absent }); check(`no-effect-${phase}`, absent, "The exact unapproved target stays absent");
};
const cleanup = async () => {
const receipts: Check[] = [];
try {
if (remote) {
if (!fixture || !baseline) throw new Error("Provider-loss observer was not armed");
sealed ??= await fixture.finish(); assertNativeRemoteRetirement(sealed, baseline);
nativeRemoteDeniedSample(sealed, baseline, target, "after-cleanup");
} else {
await pollUntil({ label: "lost run's owned processes retire", deadlineAt: Date.now() + 5000, intervalMs: 100,
load: async () => { await load(); return processes; }, accept: value => Boolean(value?.captured && value.live.length === 0) });
await sample("after-cleanup"); const watch = local!.watcher.finish();
if (!watch.complete || watch.targetMutationCount !== 0 || samples.length !== 4 || !samples.every(row => row.absent)) throw new Error("Provider-loss continuous no-effects evidence is incomplete");
}
await load(); if (!settled || !pending) throw new Error("Provider-loss settlement was not established");
assertProviderLossSettlement(runs[0]!, issue, events, pending);
receipts.push({ id: "provider-loss-retired-no-replay", passed: true, detail: "Owned run and provider processes retired; pending mutation closed without replay or effects" });
return receipts;
} finally {
await input.evidence("native-provider-loss-cleanup.json", { processes, samples, sealed, receipts });
await fixture?.close();
}
};
(remote ? input.registerBeforeEnvironmentTeardownAssertion : input.registerCleanupAssertion)(cleanup);
const prompt = () => `${execution.task.buildPrompt(nonce)}\nUse this exact native shell command once:\n${command!.command}`;
try {
const caller = readActiveStopCaller(await api.get("/api/health"), await api.get("/api/auth/get-session"));
const agent = await api.get<Row>(`/api/agents/${fixtures.agent.id}`);
await api.patch(`/api/agents/${fixtures.agent.id}`, { adapterConfig: { ...agent.adapterConfig, acpxPermissionMode: "approve-reads", acpxSessionMode: "agent", timeoutSec: 120, lifecycleMode: "per_turn" } });
const project = await api.post<Row>(`/api/companies/${fixtures.company.id}/projects`, { name: `Native provider loss ${nonce}`, executionWorkspacePolicy: { enabled: true, defaultMode: "shared_workspace", sharedWorkspaceConcurrency: "serialize", allowIssueOverride: false, environmentId: fixtures.environment.id, workspaceStrategy: { type: "project_primary" } }, workspace: { name: "Primary", sourceType: "local_path", cwd: input.workspacePath, isPrimary: true } });
if (!remote) await sample("before-request");
const createdTask = await createTaskThroughUi({ page, issuePrefix: fixtures.company.issuePrefix!, agentName: fixtures.agent.name, title: execution.task.buildTitle(nonce), prompt: remote ? input.remoteBootstrap!.prompt(nonce) : prompt(), workMode: "standard", projectName: project.name });
issue = (await pollUntil({ label: "browser-created provider-loss task", deadlineAt: input.deadlineAt,
load: async () => (await api.get<Row[]>(`/api/companies/${fixtures.company.id}/issues?limit=100`)).find(row => row.id === createdTask.issueId), accept: Boolean }))!;
if (remote) {
await pollUntil({ label: "provider-loss bootstrap", deadlineAt: input.deadlineAt, load, accept: value => value.run.status === "running" });
fixture = await input.remoteBootstrap!.bindAndRelease({ issueId: issue.id, runId: runs[0]!.id, targets: [target], actionPrompt: async value => {
fixture = value; command = cursorDeniedCommand(join(value.remoteCwd, target));
const prepared = await prepareNativeRemoteAction({ fixture: value, companyId: fixtures.company.id, environmentId: fixtures.environment.id, runId: runs[0]!.id, target, prompt: prompt() });
baseline = prepared.baseline; await input.evidence("provider-loss-baseline.json", baseline); return prepared.prompt;
} });
}
await pollUntil({ label: "unanswered native permission before loss", deadlineAt: input.deadlineAt, intervalMs: 200, load,
accept: value => { pending = observeActiveStopPending({ ...value, scope: scope(), caller, cancellationRequestId: randomUUID(), bootstrap: bootstrap() }); return true; } });
await page.goto(`/${fixtures.company.issuePrefix}/issues/${issue.identifier ?? issue.id}`);
const card = page.getByTestId("task-chat-runtime-request").filter({ visible: true });
await expect(card).toHaveCount(1); await expect(card.getByRole("button", { name: "Deny", exact: true })).toBeEnabled();
await input.capture("pending-before-loss", "Unanswered native mutation before runtime loss", "pending-before-loss.png");
await input.evidence("provider-loss-pending.json", pending);
if (remote) {
const snap = await fixture!.snapshot("pending-before-loss"); nativeRemoteDeniedSample(snap, baseline!, target, "pending");
await input.evidence("provider-loss-pending-remote.json", snap);
if (!fixture!.injectOwnedRunLoss) throw new Error("Remote observer cannot inject an owned loss");
await input.evidence("provider-loss-injection.json", await fixture!.injectOwnedRunLoss());
} else {
await sample("pending"); await load();
if (!processes?.captured) throw new Error("Local run ownership was not observed");
await input.evidence("provider-loss-injection.json", injectObservedRunLoss(authority(), processes.journal));
}
await pollUntil({ label: "failed run after owned transport loss", deadlineAt: input.deadlineAt, intervalMs: 200, load,
accept: value => { if (["succeeded", "cancelled"].includes(value.run.status)) throw new Error("Provider loss falsely succeeded or became Stop"); return value.run.status === "failed"; } });
const settlement = assertProviderLossSettlement(runs[0]!, issue, events, pending!); settled = true;
const response = await api.request.post(`/api/heartbeat-runs/${runs[0]!.id}/runtime-requests/${encodeURIComponent(pending!.requestId)}/resolve`,
{ data: { turnId: pending!.turnId, requestKind: "permission_approval", resolution: { action: "accept" } } });
check("stale-acceptance-refused", response.status() === 409, "An approval cannot revive the lost provider or replay its mutation");
if (remote) { sealed = await fixture!.finish(); assertNativeRemoteRetirement(sealed, baseline!); nativeRemoteDeniedSample(sealed, baseline!, target, "terminal"); }
else await sample("after-loss");
await page.reload();
await expect(page.getByTestId("task-chat-thread").getByTestId("task-chat-collapsible-marker").filter({ has: page.getByText("Run failed", { exact: true }) })).toBeVisible({ timeout: 30000 });
await expect(page.getByTestId("task-chat-history-loading")).toHaveCount(0);
await expect(card.getByRole("button", { name: "Deny", exact: true })).toHaveCount(0);
await load(); assertProviderLossSettlement(runs[0]!, issue, events, pending!);
check("one-unfinished-failed-run", runs.length === 1 && issue.status === "blocked", "A visible blocked task owns the failure; no automatic mutation replay or false completion");
await input.capture("final-state", "Lost runtime fails clearly and leaves the task open", "final-state.png");
await input.evidence("api-state.json", { issue, runs, checks, settlement, runEvents: events, runEventsByRun: [{ runId: runs[0]!.id, events }] });
return { issue, runs, checks };
} finally { await input.evidence("provider-loss-checks.json", { issue, runs, checks, samples, settled }); }
}