From e7ab14f93e60fa165c9a04a41b38512cb0a28a67 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 23:03:23 +0900 Subject: [PATCH 1/4] docs(devlog): close wp2b - its graceMs diagnostic shipped inside #4281 --- .../010_phase1_grace_experiment.md | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/devlog/_plan/260911_cursor_checkpoint_capture/010_phase1_grace_experiment.md b/devlog/_plan/260911_cursor_checkpoint_capture/010_phase1_grace_experiment.md index c7e4e07363..e765707cee 100644 --- a/devlog/_plan/260911_cursor_checkpoint_capture/010_phase1_grace_experiment.md +++ b/devlog/_plan/260911_cursor_checkpoint_capture/010_phase1_grace_experiment.md @@ -68,6 +68,29 @@ back INCONCLUSIVE: add `graceMs: this.activeClientToolFinalizeGraceMs` to the checkout, and rerun arm B. NEVER is then `capturedBytes: 0` with a logged `graceMs` of 1500. That instrumented arm is wp2b, appended only if needed. +### wp2b closed — its deliverable shipped inside wp4 + +wp2b was never needed for its original purpose: the experiment returned a positive, and a +positive is self-proving. But the mechanism it specified — putting the real +`graceMs` into the `client-tool-suspend` payload so a negative could ever be trusted — +landed anyway, as part of #4281: + +```ts +debugProviderDiagnostic("cursor", "client-tool-suspend", { + ... + graceMs: graceMsOverride ?? this.activeClientToolFinalizeGraceMs, + checkpointGraceExtended: this.checkpointGraceExtended, +}); +``` + +So the instrumented throwaway build this phase was reserved for is now unnecessary in +both directions: nobody needs to reach NEVER here, and if a future reader does, the field +is in the shipped binary. Closed as **delivered elsewhere**, not as skipped. + +That is worth separating from "not needed". A phase that is genuinely obsolete and a +phase whose deliverable moved are different states, and recording the wrong one would +leave the next reader thinking the diagnostic gap is still open. + ## Result — LATE Run 2026-09-11 on macbookpro-2, opencodex 2.50.0, same account and toggle as `001`. From 3784658ea5a9296c35cbb780c96192a6db7bca53 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 23:05:03 +0900 Subject: [PATCH 2/4] docs(devlog): record wp2b as delivered-elsewhere in the work-phase map --- devlog/_plan/260911_cursor_checkpoint_capture/000_plan.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/devlog/_plan/260911_cursor_checkpoint_capture/000_plan.md b/devlog/_plan/260911_cursor_checkpoint_capture/000_plan.md index a551279bf9..5804297c4d 100644 --- a/devlog/_plan/260911_cursor_checkpoint_capture/000_plan.md +++ b/devlog/_plan/260911_cursor_checkpoint_capture/000_plan.md @@ -107,6 +107,11 @@ wp2; if wp3 finishes first its outcome folds into wp4 as an additional branch. wp2b and wp5 were appended during wp1's audit (LOOP-UNIT-CHAIN-01). Both are conditional: neither runs unless its predecessor returns the outcome that needs it. +Outcomes: **wp2b closed as delivered-elsewhere** — the experiment returned a self-proving +positive so a NEVER verdict was never needed, and the `graceMs` field it existed to add +shipped in #4281 (`live-transport.ts:1064-1069`). **wp5 is live**, because branch A landed +and the native gate now depends on a coverage question rather than a capture one. + ## What the wp1 audit changed The first draft of this roadmap was audited and failed on two high findings, both From b3b3e926cc94e252689d288bf8e2c218f8e9c2c7 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 23:11:19 +0900 Subject: [PATCH 3/4] feat(cursor): report decoded checkpoint shape so coverage is answerable from logs Every checkpoint diagnostic reported bytes, which cannot distinguish a snapshot that contains the suspended tool call from one that merely arrived after it - the exact gap that leaves the native wire-model gate undecidable in #4245. cursorCheckpointShape returns counts only, never content, and the decode is skipped unless provider debug is on. --- .../040_wp5_coverage_instrument.md | 50 +++++++++++++++++++ src/adapters/cursor.ts | 6 +++ src/adapters/cursor/checkpoint-store.ts | 32 ++++++++++++ .../cursor-tool-suspended-checkpoint.test.ts | 35 ++++++++++++- 4 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 devlog/_plan/260911_cursor_checkpoint_capture/040_wp5_coverage_instrument.md diff --git a/devlog/_plan/260911_cursor_checkpoint_capture/040_wp5_coverage_instrument.md b/devlog/_plan/260911_cursor_checkpoint_capture/040_wp5_coverage_instrument.md new file mode 100644 index 0000000000..e6a5877a33 --- /dev/null +++ b/devlog/_plan/260911_cursor_checkpoint_capture/040_wp5_coverage_instrument.md @@ -0,0 +1,50 @@ +# wp5 — making the coverage question answerable + +Branch A landed, so the native wire-model gate now depends on one question: do the captured +bytes actually cover the tool call, or did they merely arrive after it? + +## Why this could not be settled by reading harder + +`capturedAfterClientTool` is set from arrival order (`cursor.ts:312`), and +`conversationCheckpointUpdate` is classified liveness-only (`live-transport.ts:1221`). Every +diagnostic this adapter emits about a checkpoint reports its size in bytes, and a byte count +cannot distinguish a snapshot that contains the suspended call from one that does not. + +The schema can. `ConversationStateStructure.pendingToolCalls` is documented upstream as +"raw JSON stringified tool-call content parts awaiting execution" — a non-zero count on a +suspended turn is the coverage evidence, and the strings themselves are request content that +must never be logged. + +## What landed + +`cursorCheckpointShape` in `checkpoint-store.ts`: decodes a snapshot and returns **counts +only** for `turns`, `turnsOld`, `rootPromptMessages`, `todos`, `pendingToolCalls`. Failure +returns `undefined`; it never throws into the request path. Wired into +`checkpoint-commit-refused` as `capturedShape`, behind `isDebugEnabled()` so the decode does +not run on a normal request. + +That converts the remaining question from "build an instrumented binary and decode bytes by +hand" into "read one log line". + +## What is NOT answered yet, and why + +The live read needs this code running on a machine with a Cursor login. Attempts to shortcut +it with a standalone harness failed: driving the adapter outside the server never reaches the +credential initialisation the proxy does at startup (`getAccountSet` reports not-logged-in +even after `loadAuthStore`, which points at the keyring path rather than `auth.json`). + +Running a second proxy would have worked, but only by either copying the credential store or +sharing the running instance's `OPENCODEX_HOME` and clobbering its pid and admin-token files. +Neither is worth it for a question that answers itself one release later. + +**So wp5 is split.** The instrument is done. The live read is a follow-up: after this ships, +run a forced tool call on a Cursor account with `ocx debug provider on` and read +`capturedShape.pendingToolCalls` off `checkpoint-commit-refused`. + +- `pendingToolCalls > 0` → the snapshot covers the call; the native gate can be removed with + the ordering proof upgraded to a coverage proof. +- `pendingToolCalls === 0` → arrival is not coverage, the current gate is correct, and the + native half of #4245 is not fixable this way. Record it and close. + +Either answer is a real outcome. What was not acceptable was guessing, which is what the +original triage did and what this unit has now avoided four separate times. diff --git a/src/adapters/cursor.ts b/src/adapters/cursor.ts index 157da1eaa3..7382c25d8c 100644 --- a/src/adapters/cursor.ts +++ b/src/adapters/cursor.ts @@ -24,9 +24,11 @@ import { import { commitCursorCheckpoint, cursorCheckpointRefHash, + cursorCheckpointShape, invalidateCursorCheckpoint, } from "./cursor/checkpoint-store"; import { debugProviderDiagnostic } from "../lib/debug"; +import { isDebugEnabled } from "../lib/debug-settings"; import { createAdapterTierMetadata } from "../providers/fastwire"; import { estimateTokens } from "../lib/token-estimate"; import { rememberCursorThreadConversation } from "./cursor/thread-continuity"; @@ -208,6 +210,10 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda externalModel: isCursorExternalWireModel(activeRequest.modelId), storeCheckpoints: activeRequest.contextUsageStoreCheckpoints !== false, capturedBytes: lastTransport?.captured?.byteLength ?? 0, + // Byte length says nothing about coverage. `pendingToolCalls` does: it is what + // distinguishes a snapshot that knows about the suspended call from one that merely + // arrived after it (#4245). Counts only; the decode is skipped unless debug is on. + capturedShape: isDebugEnabled() ? cursorCheckpointShape(lastTransport?.captured) : undefined, }); return; } diff --git a/src/adapters/cursor/checkpoint-store.ts b/src/adapters/cursor/checkpoint-store.ts index 720bc8dc2e..832bd80686 100644 --- a/src/adapters/cursor/checkpoint-store.ts +++ b/src/adapters/cursor/checkpoint-store.ts @@ -184,6 +184,38 @@ export function cursorCheckpointRefHash(ref: string): string { return createHash("sha256").update("ocx:cursor:ckpt-ref:").update(ref).digest("hex").slice(0, 16); } +/** + * Counts only — never content. Diagnostics about a checkpoint have so far reported its size in + * bytes, which says nothing about what is in it, and that gap is exactly what left #4245's native + * half undecidable: `capturedAfterClientTool` proves a snapshot ARRIVED after the tool call, and + * only `pendingToolCalls` says whether the snapshot actually knows about one. + * + * `pendingToolCalls` is documented upstream as raw JSON tool-call parts awaiting execution, so a + * non-zero count on a suspended turn is the coverage evidence. The strings themselves are request + * content and are never read here. + */ +export function cursorCheckpointShape(checkpointBytes: Uint8Array | undefined): { + turns: number; + turnsOld: number; + rootPromptMessages: number; + todos: number; + pendingToolCalls: number; +} | undefined { + if (!checkpointBytes || checkpointBytes.byteLength === 0) return undefined; + try { + const state = fromBinary(ConversationStateStructureSchema, checkpointBytes); + return { + turns: state.turns.length, + turnsOld: state.turnsOld.length, + rootPromptMessages: state.rootPromptMessagesJson.length, + todos: state.todos.length, + pendingToolCalls: state.pendingToolCalls.length, + }; + } catch { + return undefined; + } +} + export function commitCursorCheckpoint(input: { conversationId: string; identityScope?: string; diff --git a/tests/providers/cursor/cursor-tool-suspended-checkpoint.test.ts b/tests/providers/cursor/cursor-tool-suspended-checkpoint.test.ts index 4ce1152173..a365327a06 100644 --- a/tests/providers/cursor/cursor-tool-suspended-checkpoint.test.ts +++ b/tests/providers/cursor/cursor-tool-suspended-checkpoint.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { createCursorAdapter as createCursorAdapterProduction } from "../../../src/adapters/cursor"; -import { clearCursorCheckpointsForTests, getCursorCheckpoint } from "../../../src/adapters/cursor/checkpoint-store"; +import { clearCursorCheckpointsForTests, cursorCheckpointShape, getCursorCheckpoint } from "../../../src/adapters/cursor/checkpoint-store"; import { create, toBinary } from "@bufbuild/protobuf"; import { ConversationStateStructureSchema } from "../../../src/adapters/cursor/gen/agent_pb"; import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../../../src/types"; @@ -94,3 +94,36 @@ describe("tool-suspended checkpoint commit (devlog 260826 050)", () => { clearCursorCheckpointsForTests(); }); }); + +describe("checkpoint shape (#4245 coverage question)", () => { + test("reports counts, and pendingToolCalls is what distinguishes coverage from arrival", () => { + // A snapshot that knows about a suspended call. + expect(cursorCheckpointShape(checkpointBytes)).toEqual({ + turns: 0, + turnsOld: 0, + rootPromptMessages: 0, + todos: 0, + pendingToolCalls: 1, + }); + + // The same structure with nothing pending: byte length alone cannot tell these apart, + // which is exactly why capturedBytes was not enough to settle the native-gate question. + const noPending = toBinary(ConversationStateStructureSchema, create(ConversationStateStructureSchema, { + turns: [new Uint8Array([1, 2, 3])], + })); + expect(cursorCheckpointShape(noPending)).toEqual({ + turns: 1, + turnsOld: 0, + rootPromptMessages: 0, + todos: 0, + pendingToolCalls: 0, + }); + }); + + test("fails closed on absent, empty, and undecodable bytes", () => { + expect(cursorCheckpointShape(undefined)).toBeUndefined(); + expect(cursorCheckpointShape(new Uint8Array())).toBeUndefined(); + // Protobuf cannot parse this; a diagnostic must never throw into the request path. + expect(cursorCheckpointShape(new Uint8Array([0xff, 0xff, 0xff, 0xff]))).toBeUndefined(); + }); +}); From f529134b8f38cd68c382aecd14053ade2db61472 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 23:15:57 +0900 Subject: [PATCH 4/4] docs(cursor): pin the counts-only constraint on the checkpoint shape helper The audit named readPaths, previousWorkspaceUris and the fileStates keys as fields a later extension could leak. Record that in the code rather than only in the review thread. --- src/adapters/cursor/checkpoint-store.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/adapters/cursor/checkpoint-store.ts b/src/adapters/cursor/checkpoint-store.ts index 832bd80686..758430064f 100644 --- a/src/adapters/cursor/checkpoint-store.ts +++ b/src/adapters/cursor/checkpoint-store.ts @@ -193,6 +193,11 @@ export function cursorCheckpointRefHash(ref: string): string { * `pendingToolCalls` is documented upstream as raw JSON tool-call parts awaiting execution, so a * non-zero count on a suspended turn is the coverage evidence. The strings themselves are request * content and are never read here. + * + * If you extend this, keep it counts-only. `ConversationStateStructure` also carries + * `readPaths`, `previousWorkspaceUris`, and the `fileStates`/`fileStatesV2` keys — all of which + * are user paths or workspace identity, and all of which would turn a diagnostic into a privacy + * leak the moment someone returns them as values instead of lengths. */ export function cursorCheckpointShape(checkpointBytes: Uint8Array | undefined): { turns: number;