diff --git a/tests/runner-e2e/FIXTURES.md b/tests/runner-e2e/FIXTURES.md index 756d35ceb21..8a40bbf69f2 100644 --- a/tests/runner-e2e/FIXTURES.md +++ b/tests/runner-e2e/FIXTURES.md @@ -397,7 +397,7 @@ request can stop; it does not accept a no-op Stop of an already terminal run. `native-active-stop` / `pending-permission-stop` has four explicit-only cells: Cursor and Copilot, each local and Daytona. Its `native_active_stop` flow retains -`paperclip.e2e.native-active-stop-pending.v1` from the public API while exactly one +`paperclip.e2e.native-active-stop-pending.v2` from the public API while exactly one native permission remains pending, the exact controller run is running, and no Stop or answer marker exists. The receipt independently binds native session, normalized session, turn, source instance, request/tool IDs, source sequences and @@ -407,6 +407,17 @@ boundaries. Remote provider clocks and database transaction timestamps never establish that ordering. The atomic caller UUID fence rejects an earlier racing Stop instead of borrowing its acknowledgement. +Suite version 3 accepts either canonical card/tool-start arrival order. The +exact native origin, canonical tool start and unanswered permission must all +exist in both pre-Stop API observations. The v2 pending receipt adds the native +origin and tool-start row hashes and source sequences. The fresh reread and +settlement must preserve those exact rows; a later tool start cannot backfill +missing pre-Stop evidence. Command/path, request, tool, turn, session and source +checks remain strict. The native evidence projector still requires the exact +tool origin before it can emit correlated permission evidence; this does not +claim the original ACP wire order. Old v1 receipts are not valid inputs to the +new grader. Earlier paid failures retain their original definition and grade. + `paperclip.e2e.native-active-stop-settlement.v1` accepts only `pending_permission_cancelled`: explicit-cancellation request closure with `replayAllowed:false`, then one exact `turn.cancelled`, plus the same scoped @@ -418,7 +429,7 @@ absence through continuous observation, and all observed owned descendants 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 +same complete root/descendant journal. Since suite version 2, this is retained 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 diff --git a/tests/runner-e2e/README.md b/tests/runner-e2e/README.md index cef013ea37f..0da55d9c33b 100644 --- a/tests/runner-e2e/README.md +++ b/tests/runner-e2e/README.md @@ -1448,6 +1448,15 @@ exact native write, observes its unanswered permission card, then sends the public Stop request with a freshly retained caller UUID. It never denies or approves that callback before Stop. +Suite version 3 accepts a permission card before or after its correlated tool +start. Both the exact native origin and canonical start must already exist with +the unanswered permission when the fixture retains and rechecks the pre-Stop +API snapshot. The v2 pending receipt binds all four rows by hash and source +sequence through settlement. Missing, changed or replayed evidence cannot be +filled in after Stop. This is a new grader definition; old v1 pending receipts +and failed paid attempts are not regraded. No original provider wire order is +inferred from API insertion or display order. + Passage requires a canonical cancelled request and cancelled provider turn, exact caller-intent acknowledgement, an unfinished task, rejection of a later stale answer, no follow-up run, continuous target no-effect observation and @@ -1457,7 +1466,7 @@ issue-interaction rows are not substituted for their authority. Pending and fina 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. Suite -version 2 records remote filesystem coverage only through verified retirement +version 2 introduced 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 diff --git a/tests/runner-e2e/catalog.ts b/tests/runner-e2e/catalog.ts index 06f37ab7dd2..cf1da325a54 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: 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" }, + definitionMetadata: { version: 3, 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 0225da2f2a2..c8f31226f91 100644 --- a/tests/runner-e2e/native-active-stop-evidence.ts +++ b/tests/runner-e2e/native-active-stop-evidence.ts @@ -12,10 +12,11 @@ export interface ActiveStopCaller { type: "board"; userId: "local-board"; source export type ActiveStopProvider = "cursor" | "copilot"; export interface ActiveStopScope { provider: ActiveStopProvider; companyId: string; issueId: string; runId: string; target: string; commandSha256?: string } export interface ActiveStopPending { - schema: "paperclip.e2e.native-active-stop-pending.v1"; + schema: "paperclip.e2e.native-active-stop-pending.v2"; caller: ActiveStopCaller; scope: ActiveStopScope; requestId: string; toolCallId: string; nativeSessionId: string; turnId: string; normalizedSessionId: string; sourceInstanceId: string; requestSourceSeq: number; permissionSourceSeq: number; requestRowSha256: string; permissionRowSha256: string; cancellationRequestId: string; observedMonotonicNs: string; + toolOriginSourceSeq: number; toolStartedSourceSeq: number; toolOriginRowSha256: string; toolStartedRowSha256: string; } const rec = (v: unknown): Row => v !== null && typeof v === "object" && !Array.isArray(v) ? v as Row : {}; const id = (v: unknown): v is string => typeof v === "string" && v.length > 0 && v.length <= 240 && !/[\u0000-\u001f\u007f]/u.test(v) && !v.includes("[REDACTED]"); @@ -99,13 +100,17 @@ function origin(events: readonly unknown[], scope: ActiveStopScope, bootstrap?: && executions.filter(x => x.event.eventType === "tool.execution.completed").length <= 1 && executions.every(x => rec(x.event.payload).schema === "paperclip.tool.execution.v1" && rec(x.event.payload).transport === "builtin" && ["tool.execution.started", "tool.execution.progressed", "tool.execution.completed"].includes(x.event.eventType) - && rec(x.event.payload).status === (x.event.eventType === "tool.execution.completed" ? "failed" : "running")) - && executions.find(x => x.event.eventType === "tool.execution.started")!.event.sourceSeq < created[0]!.event.sourceSeq, "duplicate canonical operation lifecycle"); + && rec(x.event.payload).status === (x.event.eventType === "tool.execution.completed" ? "failed" : "running")), "duplicate canonical operation lifecycle"); + // ACP permits a permission request before its tool notification. Require + // both in the pre-Stop observation, without imposing a card/start order. + const toolOrigin = rows.find(x => x.row.seq === tools[0]!.seq); + const toolStarted = executions.find(x => x.event.eventType === "tool.execution.started"); + fail(toolOrigin && toolStarted, "native operation origin missing"); const allowed = new Set(notices.filter(n => n.stage === "tool").map(n => bootstrapReadExecutionId(n.toolCallId))); fail(rows.filter(x => x.event.eventType.startsWith("tool.execution.")).every(x => allowed.has(rec(x.event.payload).executionId)), "unattributed operation"); fail(rows.filter(x => x.event.eventType === "tool.execution.completed" && rec(x.event.payload).executionId === bootstrapReadExecutionId(notice.toolCallId)) .every(x => rec(x.event.payload).status !== "completed"), "target operation completed"); - return { rows, notice, permission, created: created[0]!, request, stream }; + return { rows, notice, permission, created: created[0]!, toolOrigin, toolStarted, request, stream }; } export function observeActiveStopPending(input: { events: readonly unknown[]; run: Row; issue: Row; scope: ActiveStopScope; caller: ActiveStopCaller; cancellationRequestId: string; bootstrap?: BootstrapReadProof }): ActiveStopPending { const { run, issue, scope } = input, proof = origin(input.events, scope, input.bootstrap); @@ -115,19 +120,23 @@ export function observeActiveStopPending(input: { events: readonly unknown[]; ru fail(!(scope.provider === "cursor" ? readCursorToolEvidence(input.events, scope.runId) : readCopilotToolEvidence(input.events, scope.runId)).some(n => n.stage === "permission_delivered"), "permission already answered"); fail(!proof.rows.some(x => terminals.has(x.event.eventType) || closures.has(x.event.eventType)), "request or provider already settled"); fail(!proof.rows.some(x => x.event.eventType === "tool.execution.completed" && rec(x.event.payload).executionId === bootstrapReadExecutionId(proof.notice.toolCallId)), "operation already ended"); - return { schema: "paperclip.e2e.native-active-stop-pending.v1", scope, caller: input.caller, requestId: proof.notice.requestId!, toolCallId: proof.notice.toolCallId, + return { schema: "paperclip.e2e.native-active-stop-pending.v2", scope, caller: input.caller, requestId: proof.notice.requestId!, toolCallId: proof.notice.toolCallId, nativeSessionId: proof.notice.sessionId, turnId: proof.notice.turnId, normalizedSessionId: proof.permission.event.normalizedSessionId, sourceInstanceId: proof.permission.event.sourceInstanceId, requestSourceSeq: proof.created.event.sourceSeq, permissionSourceSeq: proof.permission.event.sourceSeq, requestRowSha256: hash(proof.created.row), permissionRowSha256: hash(proof.permission.row), cancellationRequestId: input.cancellationRequestId, + toolOriginSourceSeq: proof.toolOrigin.event.sourceSeq, toolStartedSourceSeq: proof.toolStarted.event.sourceSeq, + toolOriginRowSha256: hash(proof.toolOrigin.row), toolStartedRowSha256: hash(proof.toolStarted.row), observedMonotonicNs: process.hrtime.bigint().toString() }; } export function readActiveStopSettlement(input: { events: readonly unknown[]; run: Row; issue: Row; pending: ActiveStopPending; dispatchMonotonicNs: string; bootstrap?: BootstrapReadProof }) { const before = input.pending, { run, issue } = input; - fail(before?.schema === "paperclip.e2e.native-active-stop-pending.v1" && isActiveStopCaller(before.caller) && uuid(before.cancellationRequestId) + fail(before?.schema === "paperclip.e2e.native-active-stop-pending.v2" && isActiveStopCaller(before.caller) && uuid(before.cancellationRequestId) && /^[1-9][0-9]{0,29}$/u.test(before.observedMonotonicNs) && /^[1-9][0-9]{0,29}$/u.test(input.dispatchMonotonicNs) && BigInt(before.observedMonotonicNs) < BigInt(input.dispatchMonotonicNs), "pre-dispatch pending observation missing"); const proof = origin(input.events, before.scope, input.bootstrap); fail(hash(proof.created.row) === before.requestRowSha256 && hash(proof.permission.row) === before.permissionRowSha256 + && hash(proof.toolOrigin.row) === before.toolOriginRowSha256 && hash(proof.toolStarted.row) === before.toolStartedRowSha256 + && proof.toolOrigin.event.sourceSeq === before.toolOriginSourceSeq && proof.toolStarted.event.sourceSeq === before.toolStartedSourceSeq && proof.notice.sessionId === before.nativeSessionId && proof.notice.turnId === before.turnId && proof.notice.toolCallId === before.toolCallId && proof.notice.requestId === before.requestId && proof.permission.event.normalizedSessionId === before.normalizedSessionId && proof.permission.event.sourceInstanceId === before.sourceInstanceId && proof.created.event.sourceSeq === before.requestSourceSeq @@ -138,7 +147,8 @@ export function readActiveStopSettlement(input: { events: readonly unknown[]; ru fail(proof.stream(c.event) && proof.stream(t.event) && c.event.eventType === "runtime_request.cancelled" && cp.requestId === before.requestId && cp.turnId === before.turnId && cp.requestKind === "permission_approval" && cp.requestType === "permission" && cp.reason === "explicit_cancellation" && cp.provider === "acpx" && cp.itemId === proof.request.itemId && cp.replayAllowed === false && t.event.eventType === "turn.cancelled" && tp.status === "cancelled" && tp.provider === "acpx" && tp.providerTurnId === before.turnId && tp.error === null - && c.event.sourceSeq > Math.max(before.requestSourceSeq, before.permissionSourceSeq) && t.event.sourceSeq > c.event.sourceSeq, + && c.event.sourceSeq > Math.max(before.requestSourceSeq, before.permissionSourceSeq, before.toolOriginSourceSeq, before.toolStartedSourceSeq) + && t.event.sourceSeq > c.event.sourceSeq, "pending callback did not close through cancellation"); fail(proof.rows.filter(x => x.event.eventType.startsWith("tool.execution.") || (x.event.eventType === "provider.notice.recorded" && ["cursor_tool_evidence_v1", "copilot_tool_evidence_v1"].includes(rec(x.event.payload).category))) diff --git a/tests/runner-e2e/native-active-stop-flow.ts b/tests/runner-e2e/native-active-stop-flow.ts index 576976f5dc0..b3290af7ab2 100644 --- a/tests/runner-e2e/native-active-stop-flow.ts +++ b/tests/runner-e2e/native-active-stop-flow.ts @@ -27,7 +27,9 @@ export async function stopAtPendingPermission(input: { const pending = observeActiveStopPending({ ...state, scope: input.scope, caller: input.caller, cancellationRequestId, bootstrap: input.bootstrap }); await input.retain(pending); const fresh = observeActiveStopPending({ ...await input.load(), scope: input.scope, caller: input.caller, cancellationRequestId, bootstrap: input.bootstrap }); - if (fresh.requestRowSha256 !== pending.requestRowSha256 || fresh.permissionRowSha256 !== pending.permissionRowSha256 || Date.now() >= input.deadlineAt) throw new Error("Native active Stop pending boundary changed or expired"); + if (fresh.requestRowSha256 !== pending.requestRowSha256 || fresh.permissionRowSha256 !== pending.permissionRowSha256 + || fresh.toolOriginRowSha256 !== pending.toolOriginRowSha256 || fresh.toolStartedRowSha256 !== pending.toolStartedRowSha256 + || Date.now() >= input.deadlineAt) throw new Error("Native active Stop pending boundary changed or expired"); const dispatchMonotonicNs = process.hrtime.bigint().toString(); const stopped = await input.stop(input.scope.runId, cancellationRequestId); if (stopped.id !== input.scope.runId || stopped.resultJson?.nativeCancellation?.intentId !== `native-cancellation:${cancellationRequestId}`) throw new Error("Native active Stop response has a foreign intent"); diff --git a/tests/runner-e2e/native-active-stop.test.ts b/tests/runner-e2e/native-active-stop.test.ts index 76b58e35e24..a6e4ef6ad13 100644 --- a/tests/runner-e2e/native-active-stop.test.ts +++ b/tests/runner-e2e/native-active-stop.test.ts @@ -43,6 +43,20 @@ function fixture(provider: ActiveStopProvider = "copilot") { } const frame = (row: Row) => row.payload.prpEvent; const payload = (row: Row) => frame(row).payload; +const arrivalOrders = ["tool-first", "permission-first"] as const; +function withArrivalOrder(provider: ActiveStopProvider, order: typeof arrivalOrders[number]) { + const f = fixture(provider); + if (order === "permission-first") { + // The card can be published while the provider callback is held. Native + // permission evidence still requires the later exact tool origin to flush. + f.events.unshift(f.events.pop()!); + f.events.forEach((row, index) => { + row.seq = frame(row).sourceSeq = index + 1; + frame(row).sourceEventId = `source:run:${index + 1}`; + }); + } + return f; +} function settled() { const f = fixture(); const pending = f.pending(); f.settle(); return { f, pending, read: () => readActiveStopSettlement({ ...f.state(), pending, dispatchMonotonicNs: (BigInt(pending.observedMonotonicNs) + 1n).toString() }) }; } function withSessionPrefix(provider: ActiveStopProvider = "cursor") { const f = fixture(provider); @@ -65,6 +79,47 @@ function withSessionPrefix(provider: ActiveStopProvider = "cursor") { } describe("definitely active native permission Stop", () => { + describe.each(["cursor", "copilot"] as const)("%s pre-Stop arrival orders", provider => { + it.each(arrivalOrders)("accepts %s only after both exact origins exist and retains them through settlement", order => { + const f = withArrivalOrder(provider, order), pending = f.pending(); + expect(pending.schema).toBe("paperclip.e2e.native-active-stop-pending.v2"); + expect(pending.toolOriginRowSha256).toMatch(/^sha256:[a-f0-9]{64}$/u); + expect(pending.toolStartedRowSha256).toMatch(/^sha256:[a-f0-9]{64}$/u); + expect(pending.toolStartedSourceSeq < pending.requestSourceSeq).toBe(order === "tool-first"); + f.settle(); + expect(readActiveStopSettlement({ ...f.state(), pending, dispatchMonotonicNs: (BigInt(pending.observedMonotonicNs) + 1n).toString() })) + .toMatchObject({ branch: "pending_permission_cancelled", pending }); + }); + it.each([ + ["missing native origin", (f: ReturnType) => { f.events.splice(1, 1); }], + ["missing canonical start", (f: ReturnType) => { f.events.splice(2, 1); }], + ["wrong native tool", (f: ReturnType) => { payload(f.events[1]!).details.find((d: Row) => d.name === "toolCallId").value = "other"; }], + ["wrong canonical tool", (f: ReturnType) => { payload(f.events[2]!).executionId = "other"; }], + ["wrong command or path", (f: ReturnType) => { payload(f.events[1]!).details.find((d: Row) => d.name === (provider === "cursor" ? "commandSha256" : "target")).value = provider === "cursor" ? `sha256:${"b".repeat(64)}` : "other.txt"; }], + ["wrong source", (f: ReturnType) => { frame(f.events[2]!).sourceInstanceId = "other"; frame(f.events[2]!).sourceEventId = "other:run:3"; }], + ["wrong turn", (f: ReturnType) => { frame(f.events[2]!).turnId = "other"; }], + ["wrong request", (f: ReturnType) => { payload(f.events[0]!).request.requestId = "other"; }], + ["replayed canonical start", (f: ReturnType) => { f.events.push(f.row(5, "tool.execution.started", payload(f.events[2]!))); }], + ["replayed native origin", (f: ReturnType) => { f.events.push(f.row(5, "provider.notice.recorded", payload(f.events[1]!))); }], + ["answered permission", (f: ReturnType) => { f.events.push(f.notice(5, "permission_delivered", { requestId: "request", outcome: "cancel" })); }], + ["terminal operation", (f: ReturnType) => { f.events.push(f.row(5, "tool.execution.completed", { schema: "paperclip.tool.execution.v1", transport: "builtin", executionId: "tool", status: "failed" })); }], + ] as const)("rejects permission-first %s before Stop", (_label, mutate) => { + const f = withArrivalOrder(provider, "permission-first"); mutate(f); expect(f.pending).toThrow(); + }); + it("cannot substitute a canonical start first observed after Stop", () => { + const f = withArrivalOrder(provider, "permission-first"), pending = f.pending(); + const started = f.events.splice(2, 1)[0]!; + // A late row has a new durable identity even when every operation field + // is identical. Final evidence must not backfill the retained receipt. + started.seq = frame(started).sourceSeq = 5; frame(started).sourceEventId = "source:run:5"; + f.events.push(started); f.settle(); + for (const row of f.events.slice(-2)) { + row.seq += 1; frame(row).sourceSeq += 1; frame(row).sourceEventId = `source:run:${row.seq}`; + } + expect(() => readActiveStopSettlement({ ...f.state(), pending, dispatchMonotonicNs: (BigInt(pending.observedMonotonicNs) + 1n).toString() })) + .toThrow("observed pending request changed"); + }); + }); it.each(["cursor", "copilot"] as const)("accepts the mixed v1/v2 session prefix before strict %s pending proof", provider => { const f = withSessionPrefix(provider); expect(f.pending()).toMatchObject({ requestId: "request", toolCallId: "tool", permissionSourceSeq: 6, requestSourceSeq: 7 }); @@ -141,6 +196,13 @@ describe("definitely active native permission Stop", () => { ["replay permitted", ({ f }: ReturnType) => { payload(f.events[4]!).replayAllowed = true; }], ["tampered pending row", ({ f }: ReturnType) => { payload(f.events[3]!).request.itemId = "changed"; }], ["missing pending receipt", (s: ReturnType) => { s.pending.requestRowSha256 = ""; }], + ["legacy v1 pending receipt", (s: ReturnType) => { (s.pending as Row).schema = "paperclip.e2e.native-active-stop-pending.v1"; }], + ["missing native origin binding", (s: ReturnType) => { delete (s.pending as Row).toolOriginRowSha256; }], + ["missing tool start binding", (s: ReturnType) => { delete (s.pending as Row).toolStartedRowSha256; }], + ["changed native origin", ({ f }: ReturnType) => { frame(f.events[0]!).emittedAt = "2026-01-01T00:00:01Z"; }], + ["changed tool start", ({ f }: ReturnType) => { frame(f.events[1]!).emittedAt = "2026-01-01T00:00:01Z"; }], + ["wrong origin sequence", (s: ReturnType) => { s.pending.toolOriginSourceSeq += 1; }], + ["wrong start sequence", (s: ReturnType) => { s.pending.toolStartedSourceSeq += 1; }], ["missing caller", ({ f }: ReturnType) => { delete f.run.resultJson.startupCancellation.requestedBy; }], ["null caller", ({ f }: ReturnType) => { f.run.resultJson.startupCancellation.requestedBy.userId = null; }], ["foreign caller", ({ f }: ReturnType) => { f.run.resultJson.startupCancellation.requestedBy.userId = "another-board-user"; }], @@ -178,8 +240,8 @@ describe("definitely active native permission Stop", () => { }); describe("active Stop flow wiring", () => { - it("awaits retention and rechecks pending before issuing the single caller UUID request", async () => { - const f = fixture(); const order: string[] = []; let retainedId = ""; + it.each(arrivalOrders)("awaits retention and rechecks %s evidence before issuing the single caller UUID request", async arrivalOrder => { + const f = withArrivalOrder("cursor", arrivalOrder); const order: string[] = []; let retainedId = ""; const stop = vi.fn(async (runId: string, id: string) => { expect(runId).toBe("run"); expect(id).toBe(retainedId); order.push("stop"); return f.settle(id); }); const result = await stopAtPendingPermission({ scope: f.scope, caller, deadlineAt: Date.now() + 1000, load: async () => { order.push("load"); return f.state(); }, @@ -193,6 +255,21 @@ describe("active Stop flow wiring", () => { retain: async () => { if (mode === "retention failed") throw Error("disk failure"); if (mode === "competing Stop") f.run.resultJson.startupCancellation = { cancellationRequestId: "foreign" }; else f.events.push(f.row(5, "runtime_request.resolved", {})); }, })).rejects.toThrow(); expect(stop).not.toHaveBeenCalled(); }); + it.each(["native origin", "canonical start"])("never sends Stop when the retained %s changes before the fresh reread", async kind => { + const f = withArrivalOrder("cursor", "permission-first"), stop = vi.fn(); + await expect(stopAtPendingPermission({ scope: f.scope, caller, deadlineAt: Date.now() + 1000, load: async () => f.state(), stop, + retain: async () => { frame(f.events[kind === "native origin" ? 1 : 2]!).emittedAt = "2026-01-01T00:00:01Z"; }, + })).rejects.toThrow("pending boundary changed or expired"); + expect(stop).not.toHaveBeenCalled(); + }); + it("never sends Stop to make a missing tool start arrive", async () => { + const f = withArrivalOrder("cursor", "permission-first"); + const start = f.events.splice(2, 1)[0]!, retain = vi.fn(); + const stop = vi.fn(async () => { f.events.push(start); return f.settle(); }); + await expect(stopAtPendingPermission({ scope: f.scope, caller, deadlineAt: Date.now() + 1000, load: async () => f.state(), retain, stop })) + .rejects.toThrow("duplicate canonical operation lifecycle"); + expect(retain).not.toHaveBeenCalled(); expect(stop).not.toHaveBeenCalled(); + }); it("fails promptly on a definitive provider failure before calling a throwing evidence reader", async () => { const f = fixture(); const started = Date.now(); await expect(stopAtPendingPermission({ scope: f.scope, caller, deadlineAt: started + 10000, load: async () => f.state(), retain: async () => {}, stop: async (_run, id) => { @@ -209,7 +286,8 @@ 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: 2, normalCompletionAccepted: false, providerDeath: "not-covered" }); + expect(suite.definitionMetadata).toMatchObject({ version: 3, normalCompletionAccepted: false, providerDeath: "not-covered" }); + expect(suiteDefinitionHash(suite)).not.toBe(suiteDefinitionHash({ ...suite, definitionMetadata: { ...suite.definitionMetadata, version: 2 } })); 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); });