From a31fc90b17b978d92539dd7da054d5bc7005d3c2 Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 30 Sep 2026 15:42:26 -0500 Subject: [PATCH] test(runner-e2e): bound remote Stop proof to process lifetime Distinguish live snapshots from the automatic owned-process retirement seal. Preserve local four-phase observation and separately revalidate remote UI/API cancellation state without claiming later filesystem reads. Version the suite and calibrate stale/replayed/foreign/lost-descendant evidence. Co-Authored-By: Paperclip --- tests/runner-e2e/FIXTURES.md | 15 ++++- tests/runner-e2e/README.md | 7 ++- tests/runner-e2e/catalog.ts | 2 +- .../runner-e2e/native-active-stop-evidence.ts | 52 ++++++++++++++-- tests/runner-e2e/native-active-stop-flow.ts | 37 ++++++++---- tests/runner-e2e/native-active-stop.test.ts | 59 ++++++++++++++++++- 6 files changed, 147 insertions(+), 25 deletions(-) diff --git a/tests/runner-e2e/FIXTURES.md b/tests/runner-e2e/FIXTURES.md index ed816646763..756d35ceb21 100644 --- a/tests/runner-e2e/FIXTURES.md +++ b/tests/runner-e2e/FIXTURES.md @@ -415,9 +415,18 @@ interrupted or normal terminal evidence fails. Only after this proof does the fixture attempt a stale public answer, requiring HTTP409 and an unanswerable browser card. It retains one cancelled run, issue `in_progress`, exact target absence through continuous observation, and all observed owned descendants -retired. Remote proof is sealed before environment teardown; unproven cleanup -fails independently. Only fully attested bootstrap reads may precede the one -tested operation; alternate operations or attempts are rejected. +retired. Local files require four fresh observations through cleanup. Daytona +instead retains two live snapshots (baseline and pending) plus one automatic +owned-process-retirement seal, with a continuous zero-mutation watcher and the +same complete root/descendant journal. Suite version 2 retains this as +`paperclip.e2e.native-active-stop-remote-retirement.v1`; it explicitly records +`filesystemAfterRetirementObserved:false`. The per-turn observer seals itself +when the owned tree retires, before the sandbox is released. Reading that receipt +later is not a fresh post-UI or post-cleanup filesystem observation. Relabeled, +reused, missing or out-of-order samples fail. The stale-answer and rendered UI +checks remain separate, followed by fresh API cancellation/no-extra-run checks +both after UI and in cleanup. Unproven cleanup fails independently. Only fully +attested bootstrap reads may precede the one tested operation; alternate operations or attempts are rejected. The existing `copilot-protection` denial remains distinct: rejecting a permission before Stop does not exercise this pending-callback boundary. This new suite has diff --git a/tests/runner-e2e/README.md b/tests/runner-e2e/README.md index 316a105910b..2ceae652869 100644 --- a/tests/runner-e2e/README.md +++ b/tests/runner-e2e/README.md @@ -1447,7 +1447,12 @@ not qualify this case. Native permission cards use runtime requests; ordinary issue-interaction rows are not substituted for their authority. Pending and final screenshots, `native-active-stop-pending.json`, `native-active-stop-settlement.json` and cleanup evidence retain the boundaries. Daytona also requires the exact -owned lease and sealed remote observer proof before sandbox deletion. +owned lease and sealed remote observer proof before sandbox deletion. Suite +version 2 records remote filesystem coverage only through verified retirement +of the owned runner/provider tree, with no target or workspace mutations; it +does not call a later read of the seal a fresh observation. Local cases retain +four filesystem phases through cleanup. Remote UI/stale-answer checks and final +API checks are separate from this lifetime-bound filesystem proof. This suite adds an active-work cancellation oracle; it does not reinterpret older denial or cancellation results. Current live qualification is pending. diff --git a/tests/runner-e2e/catalog.ts b/tests/runner-e2e/catalog.ts index 96fd15eac46..06f37ab7dd2 100644 --- a/tests/runner-e2e/catalog.ts +++ b/tests/runner-e2e/catalog.ts @@ -1052,7 +1052,7 @@ export const runnerSuites: readonly RunnerSuiteFixture[] = [ description: "Stop while one exact Cursor or Copilot native permission remains unanswered; require cancelled provider settlement, caller-owned acknowledgement, stale-answer refusal and independent retirement/no effects.", groups: ["native"], profiles: extendedHarnessProfiles.filter(profile => ["cursor", "copilot"].includes(profile.qualificationCandidate ?? "")), environments: runnerEnvironments, tasks: nativeActiveStopTasks, expectedMatrixSize: 4, - definitionMetadata: { version: 1, qualification: "pending", scheduling: "explicit-only", evidence: "paperclip.e2e.native-active-stop-settlement.v1", pendingObservation: "retained-api-before-caller-uuid-stop", normalCompletionAccepted: false, permissionPolicy: "approve-reads", lifecycle: "per_turn", remoteEvidence: "owned-lease-sealed-observer", providerDeath: "not-covered" }, + definitionMetadata: { version: 2, qualification: "pending", scheduling: "explicit-only", evidence: "paperclip.e2e.native-active-stop-settlement.v1", pendingObservation: "retained-api-before-caller-uuid-stop", normalCompletionAccepted: false, permissionPolicy: "approve-reads", lifecycle: "per_turn", remoteEvidence: "paperclip.e2e.native-active-stop-remote-retirement.v1", remoteObservationCoverage: "continuous-through-owned-process-retirement", filesystemAfterRemoteRetirementObserved: false, localObservationCoverage: "four-phases-through-cleanup", providerDeath: "not-covered" }, }, { id: "copilot-protection", label: "Copilot native protection", manualOnly: true, diff --git a/tests/runner-e2e/native-active-stop-evidence.ts b/tests/runner-e2e/native-active-stop-evidence.ts index 89e6ba5c3d4..ab8008afeea 100644 --- a/tests/runner-e2e/native-active-stop-evidence.ts +++ b/tests/runner-e2e/native-active-stop-evidence.ts @@ -1,6 +1,7 @@ import { createHash } from "node:crypto"; import { canonicalJson } from "../../packages/shared/src/portability-hash.js"; import { hasAcpxNativeOrigin } from "./acpx-native-origin.js"; +import { assertCopilotRemoteRetirement, copilotRemoteDeniedSample, type CopilotRemoteSnapshot } from "./copilot-protection-evidence.js"; import { readCopilotToolEvidence } from "./copilot-evidence.js"; import { readCursorToolEvidence } from "./cursor-native-evidence.js"; import { bootstrapReadExecutionId, withoutProvenBootstrapReads, type BootstrapReadProof } from "./native-bootstrap-read-proof.js"; @@ -158,18 +159,59 @@ export function readActiveStopSettlement(input: { events: readonly unknown[]; ru taskStillInProgress: true, normalCompletionAccepted: false, replayAllowed: false }; } -/** Read-only watcher/process receipts are mandatory through teardown, even if - * canonical cancellation already passed. A missing phase is not no-effect proof. */ +export interface ActiveStopRemoteObservation { + phase: "before-request" | "pending" | "owned-process-retirement"; + source: "live-snapshot" | "retirement-seal"; + snapshot: CopilotRemoteSnapshot; +} + +/** A per-turn Daytona observer seals itself when the owned tree retires. The + * host may retrieve that receipt later; retrieval is not a fresh observation. */ +export function readActiveStopRemoteRetirement(input: { + scope: Pick; observations: readonly ActiveStopRemoteObservation[]; +}) { + const observations = input.observations; + fail(observations.length === 3 + && observations.map(o => `${o.phase}:${o.source}`).join(",") === "before-request:live-snapshot,pending:live-snapshot,owned-process-retirement:retirement-seal", + "remote seal cannot stand in for a fresh causal sample"); + const [baseline, pending, terminal] = observations.map(o => o.snapshot) as [CopilotRemoteSnapshot, CopilotRemoteSnapshot, CopilotRemoteSnapshot]; + fail(baseline.binding.companyId === input.scope.companyId && baseline.binding.runId === input.scope.runId, "remote observation belongs to another run"); + for (const snapshot of [baseline, pending, terminal]) { + fail(!copilotRemoteDeniedSample(snapshot, baseline, input.scope.target, "pending").exists, "remote target changed"); + } + fail(BigInt(baseline.observedMonotonicNs) < BigInt(pending.observedMonotonicNs) + && BigInt(pending.observedMonotonicNs) < BigInt(terminal.observedMonotonicNs), "remote observation reused or out of order"); + fail(!baseline.setup.published && pending.setup.published && pending.setup.sha256 === terminal.setup.sha256 + && baseline.processes.captured && pending.processes.captured + && baseline.processes.live.includes(baseline.processes.root?.pid ?? -1) + && pending.processes.live.includes(pending.processes.root?.pid ?? -1), "remote pending lifetime unproven"); + assertCopilotRemoteRetirement(terminal, baseline); + assertCopilotRemoteRetirement(terminal, pending); + for (const snapshot of [baseline, pending]) { + fail(snapshot.processes.journal.every(p => terminal.processes.journal.some(q => + p.pid === q.pid && p.startTicks === q.startTicks && p.bootId === q.bootId)), "remote descendant journal lost"); + } + return { schema: "paperclip.e2e.native-active-stop-remote-retirement.v1" as const, + coverage: "continuous-through-owned-process-retirement" as const, filesystemAfterRetirementObserved: false as const, + binding: terminal.binding, target: input.scope.target, + observations: observations.map(o => ({ phase: o.phase, source: o.source, + observedMonotonicNs: o.snapshot.observedMonotonicNs, snapshotSha256: hash(o.snapshot) })) }; +} + +/** Local files remain accessible through teardown. Remote per-turn files do + * not: require the complete lifetime seal, never relabel it as a later sample. */ export function assertActiveStopRetirement(input: { completed: boolean; identityChanged: boolean; processes: { captured: boolean; live: readonly number[] }; watcher: { complete: boolean; targetMutationCount: number }; - samples: ReadonlyArray<{ phase: string; absent: boolean }>; -}) { +} & ({ environment: "local"; samples: ReadonlyArray<{ phase: string; absent: boolean }> } + | { environment: "daytona"; remote: Parameters[0] })) { fail(input.completed && !input.identityChanged && input.processes.captured && input.processes.live.length === 0 && input.watcher.complete && input.watcher.targetMutationCount === 0, "retirement or continuous no-effect proof is incomplete"); - fail(input.samples.length === 4 && input.samples.every(s => s.absent === true) + if (input.environment === "daytona") return readActiveStopRemoteRetirement(input.remote); + fail(input.environment === "local" && input.samples.length === 4 && input.samples.every(s => s.absent === true) && input.samples.map(s => s.phase).join(",") === "before-request,pending,after-stop,after-cleanup", "causal target observation missing or changed"); + return { coverage: "local-through-cleanup" as const }; } diff --git a/tests/runner-e2e/native-active-stop-flow.ts b/tests/runner-e2e/native-active-stop-flow.ts index 1c4b407b721..576976f5dc0 100644 --- a/tests/runner-e2e/native-active-stop-flow.ts +++ b/tests/runner-e2e/native-active-stop-flow.ts @@ -7,7 +7,7 @@ import { createTaskThroughUi } from "./user-actions.js"; import { createDeniedTargetFixture, exists, observeRunProcesses } from "./copilot-local-fixtures.js"; import { assertCopilotRemoteRetirement, copilotRemoteDeniedSample, prepareCopilotRemoteAction, type CopilotRemoteBootstrap, type CopilotRemoteFixture, type CopilotRemoteSnapshot } from "./copilot-protection-evidence.js"; import { cursorDeniedCommand } from "./cursor-native-evidence.js"; -import { assertActiveStopRetirement, readActiveStopCaller, observeActiveStopPending, readActiveStopSettlement, type ActiveStopCaller, type ActiveStopPending, type ActiveStopScope } from "./native-active-stop-evidence.js"; +import { assertActiveStopRetirement, readActiveStopCaller, observeActiveStopPending, readActiveStopSettlement, type ActiveStopRemoteObservation, type ActiveStopCaller, type ActiveStopPending, type ActiveStopScope } from "./native-active-stop-evidence.js"; import type { BootstrapReadProof } from "./native-bootstrap-read-proof.js"; import type { LiveFixtureValues } from "./live-fixtures.js"; import type { MatrixExecution } from "./types.js"; @@ -60,6 +60,8 @@ export async function runNativeActiveStopFlow(input: { const observer = remote ? undefined : observeRunProcesses(); let processes: { captured: boolean; live: number[] } = { captured: false, live: [] }; let processIdentity: string | undefined, processError = false; + const remoteObservations: ActiveStopRemoteObservation[] = []; + let retirement: ReturnType | undefined; const samples: Array<{ phase: string; absent: boolean }> = []; let fixture: CopilotRemoteFixture | undefined, baseline: CopilotRemoteSnapshot | undefined, sealed: CopilotRemoteSnapshot | undefined; let completed: Awaited> | undefined; @@ -80,7 +82,9 @@ export async function runNativeActiveStopFlow(input: { let value: boolean; if (remote) { if (!fixture || !baseline) throw new Error("Missing remote active Stop observer"); - const snap = sealed ?? await fixture.snapshot(phase); + if (phase !== "pending") throw new Error("Remote filesystem phase requires a fresh live snapshot"); + const snap = await fixture.snapshot(phase); + remoteObservations.push({ phase, source: "live-snapshot", snapshot: snap }); value = !copilotRemoteDeniedSample(snap, baseline, target, "pending").exists; await input.evidence(`active-stop-${phase}-remote.json`, snap); } else value = !await exists(local!.targetPath); @@ -98,17 +102,18 @@ export async function runNativeActiveStopFlow(input: { } else if (processes.captured && processes.live.length) { await pollUntil({ label: "active Stop provider retirement", deadlineAt: Date.now() + 5000, intervalMs: 100, load: async () => { observeProcesses(); return processes; }, accept: p => p.live.length === 0 }); } - await sample("after-cleanup"); + if (!remote) await sample("after-cleanup"); const watch = remote ? sealed!.watcher : local!.watcher.finish(); - assertActiveStopRetirement({ completed: Boolean(completed), identityChanged: processError, processes, watcher: watch, samples }); + retirement = assertActiveStopRetirement({ completed: Boolean(completed), identityChanged: processError, processes, watcher: watch, + ...(remote ? { environment: "daytona" as const, remote: { scope: scope(), observations: remoteObservations } } : { environment: "local" as const, samples }) }); if (!completed) throw new Error("Missing active Stop settlement"); readActiveStopSettlement({ ...await load(), ...completed, bootstrap: bootstrap() }); - receipt.push({ id: "active-stop-retired-no-effects", passed: true, detail: "Exact run root/descendants retired; unanswered target remained absent through continuous observation" }); + receipt.push({ id: "active-stop-retired-no-effects", passed: true, detail: remote ? "Exact run root/descendants retired with continuous no-effects proof through retirement; later UI/API checks do not claim fresh filesystem observation" : "Exact run root/descendants retired; unanswered target remained absent through cleanup" }); return receipt; } finally { if (timer) clearInterval(timer); try { - await input.evidence("native-active-stop-cleanup.json", { processes, processError, samples, sealed, watcher: local?.watcher.finish(), checks: receipt }); + await input.evidence("native-active-stop-cleanup.json", { processes, processError, samples, remoteObservations, retirement, sealed, watcher: local?.watcher.finish(), checks: receipt }); } finally { if (remote) await fixture?.close(); } @@ -133,7 +138,8 @@ export async function runNativeActiveStopFlow(input: { const bound = await input.remoteBootstrap!.bindAndRelease({ issueId: issue.id, runId: runs[0]!.id, targets: [target], actionPrompt: async value => { fixture = value; if (provider === "cursor") command = cursorDeniedCommand(join(value.remoteCwd, target)); const prepared = await prepareCopilotRemoteAction({ fixture: value, companyId: fixtures.company.id, environmentId: fixtures.environment.id, runId: runs[0]!.id, target, prompt: prompt() }); - baseline = prepared.baseline; samples.push({ phase: "before-request", absent: true }); + baseline = prepared.baseline; + remoteObservations.push({ phase: "before-request", source: "live-snapshot", snapshot: baseline }); await input.evidence("active-stop-before-request-remote.json", baseline); return prepared.prompt; } }); if (bound !== fixture) throw new Error("Remote active Stop publication identity changed"); @@ -154,8 +160,13 @@ export async function runNativeActiveStopFlow(input: { const response = await api.request.post(`/api/heartbeat-runs/${runs[0]!.id}/runtime-requests/${encodeURIComponent(completed.pending.requestId)}/resolve`, { data: { turnId: completed.pending.turnId, requestKind: "permission_approval", resolution: { action: "accept" } } }); check("stale-answer-refused", response.status() === 409, "Stopped permission rejects a later answer rather than replaying work"); await load(); readActiveStopSettlement({ ...await load(), ...completed, bootstrap: bootstrap() }); - if (remote) { sealed = await fixture!.finish(); assertCopilotRemoteRetirement(sealed, baseline!); processes = sealed.processes; } - await sample("after-stop"); + if (remote) { + // finish drains the observer's automatic retirement seal. It does not + // extend the remote watch through subsequent host UI/cleanup assertions. + sealed = await fixture!.finish(); assertCopilotRemoteRetirement(sealed, baseline!); processes = sealed.processes; + remoteObservations.push({ phase: "owned-process-retirement", source: "retirement-seal", snapshot: sealed }); + await input.evidence("active-stop-owned-process-retirement-remote.json", sealed); + } else await sample("after-stop"); await page.reload(); const finalUiTimeout = () => { const remaining = input.deadlineAt - Date.now(); @@ -174,12 +185,14 @@ export async function runNativeActiveStopFlow(input: { .toHaveCount(0, { timeout: finalUiTimeout() }); await expect(card.getByRole("button", { name: "Deny", exact: true })) .toHaveCount(0, { timeout: finalUiTimeout() }); + await load(); readActiveStopSettlement({ ...await load(), ...completed, bootstrap: bootstrap() }); check("one-unfinished-cancelled-run", runs.length === 1 && issue.status === "in_progress" && runs[0]!.status === "cancelled", "No automatic follow-up run or false task completion"); await input.capture("final-state", "Stopped native permission is no longer answerable", "final-state.png"); - await input.evidence("api-state.json", { issue, run: runs[0], runs, checks, samples, runEvents: events, runEventsByRun: [{ runId: runs[0]!.id, events }], activeStop: completed }); + await input.evidence("api-state.json", { issue, run: runs[0], runs, checks, samples, remoteObservations, runEvents: events, runEventsByRun: [{ runId: runs[0]!.id, events }], activeStop: completed }); return { issue, runs, checks }; } finally { - // Keep watcher/process observation live through the registered cleanup assertion. - await input.evidence("native-active-stop-checks.json", { issue, runs, checks, samples, completed }); + // Local observation continues through cleanup. Remote cleanup revalidates + // the lifetime seal and fresh API state without claiming later file reads. + await input.evidence("native-active-stop-checks.json", { issue, runs, checks, samples, remoteObservations, completed }); } } diff --git a/tests/runner-e2e/native-active-stop.test.ts b/tests/runner-e2e/native-active-stop.test.ts index 86bdf28bec8..adc1175529a 100644 --- a/tests/runner-e2e/native-active-stop.test.ts +++ b/tests/runner-e2e/native-active-stop.test.ts @@ -1,6 +1,6 @@ import { createHash } from "node:crypto"; import { describe, expect, it, vi } from "vitest"; -import { assertActiveStopRetirement, readActiveStopCaller, observeActiveStopPending, readActiveStopSettlement, type ActiveStopProvider } from "./native-active-stop-evidence.js"; +import { assertActiveStopRetirement, readActiveStopRemoteRetirement, type ActiveStopRemoteObservation, readActiveStopCaller, observeActiveStopPending, readActiveStopSettlement, type ActiveStopProvider } from "./native-active-stop-evidence.js"; import { stopAtPendingPermission } from "./native-active-stop-flow.js"; import { runnerMatrix, runnerSuites, suiteDefinitionHash } from "./catalog.js"; import { selectRunnerExecutions, parseRunnerSelectors } from "./selectors.js"; @@ -166,7 +166,7 @@ describe("explicit active Stop discovery", () => { expect(cells).toHaveLength(4); expect(suite.manualOnly).toBe(true); expect(cells.map(e => `${e.profile.qualificationCandidate}/${e.environment.id}`).sort()).toEqual(["copilot/daytona", "copilot/local", "cursor/daytona", "cursor/local"]); expect(cells.every(e => e.task.expectedRunCount === 1 && e.task.flow === "native_active_stop" && e.task.expectedTerminalState?.run === "cancelled")).toBe(true); - expect(suite.definitionMetadata).toMatchObject({ version: 1, normalCompletionAccepted: false, providerDeath: "not-covered" }); + expect(suite.definitionMetadata).toMatchObject({ version: 2, normalCompletionAccepted: false, providerDeath: "not-covered" }); expect(suiteDefinitionHash(suite)).not.toBe(suiteDefinitionHash({ ...suite, definitionMetadata: { ...suite.definitionMetadata, normalCompletionAccepted: true } })); expect(selectRunnerExecutions(parseRunnerSelectors(["--all"])).some(e => e.suite.id === suite.id)).toBe(false); }); @@ -174,7 +174,7 @@ describe("explicit active Stop discovery", () => { describe("active Stop cleanup remains mandatory", () => { - const proof = () => ({ completed: true, identityChanged: false, processes: { captured: true, live: [] as number[] }, + const proof = () => ({ environment: "local" as const, completed: true, identityChanged: false, processes: { captured: true, live: [] as number[] }, watcher: { complete: true, targetMutationCount: 0 }, samples: ["before-request", "pending", "after-stop", "after-cleanup"].map(phase => ({ phase, absent: true })) }); it("requires all causal samples plus the continuous watcher and retired owned identities", () => expect(() => assertActiveStopRetirement(proof())).not.toThrow()); it.each([ @@ -188,3 +188,56 @@ describe("active Stop cleanup remains mandatory", () => { ["missing settlement", (p: ReturnType) => { p.completed = false; }], ] as const)("rejects %s even after an apparent cancelled run", (_label, mutate) => { const p = proof(); mutate(p); expect(() => assertActiveStopRetirement(p)).toThrow(); }); }); + +function remoteRetirementProof() { + const root = { pid: 50, ppid: 1, startTicks: "200", bootId: "12345678-1234-1234-1234-123456789abc" }; + const child = { ...root, pid: 51, ppid: 50, startTicks: "201" }; + const snapshot = (sequence: number, live: number[]) => ({ + binding: { companyId: "company", environmentId: "env", runId: "run", leaseId: "lease", sandboxId: "sandbox", image: `image@sha256:${"a".repeat(64)}`, remoteCwd: "/home/daytona/workspace" }, + observedAtMs: sequence, receivedAtMs: sequence + 1, observedMonotonicNs: String(sequence), complete: true, workspace: {}, + targets: { "target.txt": { absent: true, sha256: null, complete: true, mutationCount: 0, parent: { dev: "1", ino: "2" } } }, + watcher: { complete: true, targetMutationCount: 0, workspaceMutationCount: 0 }, + processes: { captured: true, root, journal: [root, child], live }, + setup: { path: ".action.txt", sha256: sequence === 1 ? null : `sha256:${"b".repeat(64)}`, published: sequence !== 1 }, attached: null, + }); + const observations: ActiveStopRemoteObservation[] = [ + { phase: "before-request", source: "live-snapshot", snapshot: snapshot(1, [50, 51]) }, + { phase: "pending", source: "live-snapshot", snapshot: snapshot(2, [50, 51]) }, + { phase: "owned-process-retirement", source: "retirement-seal", snapshot: snapshot(3, []) }, + ]; + return { scope: { companyId: "company", runId: "run", target: "target.txt" }, observations }; +} +describe("Daytona active Stop observation lifetime", () => { + it("binds one continuous owned-tree retirement seal without claiming a later filesystem observation", () => { + const remote = remoteRetirementProof(), terminal = remote.observations[2]!.snapshot; + expect(assertActiveStopRetirement({ environment: "daytona", completed: true, identityChanged: false, + processes: terminal.processes, watcher: terminal.watcher, remote })).toMatchObject({ + schema: "paperclip.e2e.native-active-stop-remote-retirement.v1", coverage: "continuous-through-owned-process-retirement", + filesystemAfterRetirementObserved: false, observations: [ + { phase: "before-request", source: "live-snapshot" }, { phase: "pending", source: "live-snapshot" }, + { phase: "owned-process-retirement", source: "retirement-seal" }, + ], + }); + }); + it.each([ + ["seal relabeled fresh", (p: ReturnType) => { p.observations[2]!.source = "live-snapshot"; }], + ["seal replayed as pending", (p: ReturnType) => { p.observations[1]!.snapshot = p.observations[2]!.snapshot; }], + ["after-UI seal replay", (p: ReturnType) => { p.observations.push({ ...p.observations[2]!, phase: "after-cleanup" as any }); }], + ["missing pending", (p: ReturnType) => { p.observations.splice(1, 1); }], + ["all foreign run snapshots", (p: ReturnType) => { for (const o of p.observations) o.snapshot.binding.runId = "other"; }], + ["all foreign company snapshots", (p: ReturnType) => { for (const o of p.observations) o.snapshot.binding.companyId = "other"; }], + ["foreign lease", (p: ReturnType) => { p.observations[2]!.snapshot.binding.leaseId = "other"; }], + ["foreign run", (p: ReturnType) => { p.observations[1]!.snapshot.binding.runId = "other"; }], + ["replacement root", (p: ReturnType) => { p.observations[2]!.snapshot.processes.root = { ...p.observations[2]!.snapshot.processes.root!, startTicks: "300" }; }], + ["live child", (p: ReturnType) => { p.observations[2]!.snapshot.processes.live = [51]; }], + ["lost child journal", (p: ReturnType) => { p.observations[2]!.snapshot.processes.journal.pop(); }], + ["unobserved pending root", (p: ReturnType) => { p.observations[1]!.snapshot.processes.live = []; }], + ["transient target mutation", (p: ReturnType) => { p.observations[2]!.snapshot.watcher.targetMutationCount = 1; }], + ["workspace mutation", (p: ReturnType) => { p.observations[2]!.snapshot.watcher.workspaceMutationCount = 1; }], + ["late target present", (p: ReturnType) => { p.observations[2]!.snapshot.targets["target.txt"]!.absent = false; }], + ["incomplete watcher", (p: ReturnType) => { p.observations[2]!.snapshot.watcher.complete = false; }], + ["changed action", (p: ReturnType) => { p.observations[2]!.snapshot.setup.sha256 = `sha256:${"c".repeat(64)}`; }], + ] as const)("rejects %s", (_label, mutate) => { + const proof = remoteRetirementProof(); mutate(proof); expect(() => readActiveStopRemoteRetirement(proof)).toThrow(); + }); +});