Skip to content

One spelling for Surface and Workspace ids: surface:<n>, never reused - #1085

Draft
nedtwigg wants to merge 27 commits into
mainfrom
surface-numbered-ids
Draft

nedtwigg wants to merge 27 commits into
mainfrom
surface-numbered-ids

Conversation

@nedtwigg

@nedtwigg nedtwigg commented Oct 9, 2026

Copy link
Copy Markdown
Member

Surface and Workspace ids get one spelling, surface:<n> and workspace:<n>. Each comes from a host-wide counter that is saved to disk, never reused (restarts included), and serves directly as the dor handle. This replaces per-Workspace surface:N refs, pane-<counter>-<random> ids, and the separate hyphenated id / colon ref spellings.

This resets saved layouts. Session and window snapshots from older builds are discarded on first launch, with no migration.

What changed

Ids

  • A Surface's id is surface:<n> and a Workspace's id is workspace:<n>. If the host can't hand out an id, the fallback is surface:<uuid> / workspace:<uuid>. DORMOUSE_SURFACE_ID carries the same string.
  • Each host has one counter:
    • Standalone: Rust, shared by every window. Surface, Workspace and window-label counters are saved in <state root>/ids.json.
    • VS Code: one surface-ids.json in globalStorageUri for every window of the install, behind a lock across processes and fsynced before ids are handed out.
    • Playground, Storybook, Pocket: the page counts its own.
  • Each counter is written to disk before any of its numbers is handed out. Boot only ever raises a counter, never lowers it. The webview keeps a small pool of reserved ids so it can create a Surface synchronously; a restart skips at most a few numbers. Reopening a large Workspace or window reserves exactly the count it needs, when it is reopened.
  • Closing a window no longer reserves ids. The record keeps the original ids, and fresh ones are minted only if the window is reopened.

Handles

  • The id is the handle, so refs are unique across the host and survive moves between Workspaces and Windows. The per-Workspace ref registry, ref transfer, retiring a ref on move, and saved refs are all deleted.
  • 3, pane:3 and surface-3 are refused with "use surface:3".
  • dor list --json has one key per id (id, workspace_id, caller_surface_id, …). --id-format is removed.
  • Hosts without a Workspace registry (VS Code, the playground) number their own Workspaces from workspace:1, so positional refs are gone.

Browser Surfaces

  • Changing a browser Surface's display mode or provider swaps its renderer in place. Its id, ref and TODO survive.
  • Replacing an untouched shell still creates a new Surface (glossary I10 and its rationale).

Persisted format

  • PersistedSession.version is now 4 and PersistedWindow.version 2. Readers discard any other version.
  • Standalone deletes older-format snapshots, with their temp and geometry files, and their arrival-journal records at boot. VS Code deletes an unreadable dormouse.session at activation.
  • standalone/scripts/persisted-format.json pins the versions in both languages.
  • Compatibility code made dead by the bump is removed: the transcript deny-list, STRICT_READER_NOTIFICATION_SOURCES, and the untouched / nameIsAuto defaults.

Dev harness

  • The browser-dev harness's stand-ins for the id commands save their counters per worktree.

What resets

  • Standalone sessions/{main,ws-<n>}.json (plus temp and geometry files) and old arrival records. Windows reopen fresh at their default position.
  • VS Code's dormouse.session and panel state.
  • Agent recovery records keyed by old ids never match, and are cleared on first read.
  • Not reset: settings, theme, Burrow/ACL state, Tool trust, alert settings, tutorial progress.

Verification

  • Every package suite (Rust, lib, standalone, vscode-ext, dor, website) and the root pnpm test pass. New tests were mutation-checked: each goes red with its fix reverted.
  • Live in the innerdogfood harness:
    • ids are numbered across Workspaces;
    • a moved Surface keeps its handle;
    • killed numbers are never reissued, including after a harness restart;
    • bad handles are refused with the hint;
    • a display-mode swap keeps the id;
    • ids persist across a page reload and a server restart, with no large jump.

Follow-ups (not in this PR)

  • Rust routes a surface:<n> target owned by another window. Today it answers "not found", as stable ids already did.
  • Hosts refuse a spawn over a live id unless it is an explicit restore. Today they displace the old PTY.
  • The killed-ref lag from main: a killed Surface stays listed and targetable for about 0.5 s during its fade, contrary to dor-cli.md.
  • The Pocket wire field DirectoryWorkspace.ref keeps its name for older Pocket clients. Its value is now the Workspace id.

🤖 Generated with Claude Code

nedtwigg and others added 26 commits October 8, 2026 12:53
Rust now owns three counters — Workspace numbers, `ws-<n>` window labels,
and `surface-<n>` Surface numbers — in a new pure `ids.rs`. Each keeps a
hi/lo ceiling in `<state root>/ids.json`, written atomically (under the
counters' lock) before any number at or above it is handed out, so a
closed highest-numbered Workspace or window is never re-minted on the
next launch.

Boot seeds with max semantics (never store) from the file plus the disk
scans, which now also collect Surface ids from snapshot and retained
arrival-journal panes and doors. A failed state root or write leaves the
counters in memory with a log line. Adds `surface_reserve_ids(count,
floor)` beside `workspace_reserve_ids`; the webview does not call it yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One seeding rule for every kind: `ids::seed_next(kind, ids)` over
`Kind::number`, replacing `workspaces::seed_next`, `routing::seed_next_ws`,
and `seed_next_surface`; `ws_index` and `ref_number` share the parser.
`Kind::first()` owns the never-mint-`workspace-1` rule instead of a call
argument. Reservations serialize on the ids-file lock across the write,
so the counters' own lock is only ever held briefly and the main-thread
`workspace_report` never waits on an fsync. `saved_windows` reuses
`read_snapshot_from`; tests duplicated across ids.rs and lib.rs are
dropped, and the spec and rationale lose their mechanism detail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
New `lib/src/lib/surface-ids.ts`, mirroring the Workspace id pool: a
synchronous `mintSurfaceId(taken?)` drawing from a block the host reserved
(64, refilled below 16), `surface-<uuid>` when a reservation fails, and an
in-page `surface-1…` counter where no host is installed (playground,
Storybook, Pocket, tests). A minted id a live Session or Wall already holds
is skipped and logged. Every reservation names a floor, the highest
`surface-<n>` the webview restored.

The Wall's `generatePaneId`, the helper's `helper-<uuid>`, and Reopen's
`pane-<uuid>` all mint through it. Standalone installs the pool beside the
Workspace pool via `surface_reserve_ids`; the browser-dev harness gets a
matching stub. VS Code gets an extension-host allocator persisting its
ceiling in `surface-ids.json` (storageUri, else globalStorageUri), re-read
before each raise, behind new `surface:reserveIds` / `surface:reservedIds`
messages; the adapter's `init` installs the pool and `main.tsx` now renders
after both restore and `init` settle. `dor` refs are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One reserved-id pool, `lib/src/lib/id-pool.ts`, now backs both the
Workspace and the Surface id stores instead of two copies of the refill
and stale-install guards; the Surface pool binds its floor into the
reserve call rather than holding it as state.

`mintSurfaceId()` owns the one "already in use" check — the terminal
registry plus a Wall-ownership check `wall-handles.ts` installs — so the
Wall, helpers, and Reopen all call it bare; `mint-surface-id.ts` is gone
and `withFreshSurfaceIds` defaults its mint again. Standalone's
`installWorkspaceRegistry` takes the restored Window and computes the floor
itself. The VS Code allocator uses the shared `createSerialQueue` and a
module-level in-memory allocator that activation replaces. Wiring tests no
longer re-assert the skip log the unit test owns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A Surface's dor ref is now derived from its id (`surface-347` is
`surface:347`), so refs are unique across the host and survive moves
between Workspaces. One grammar in dor/src/protocol.ts
(`surfaceRefForId`, `surfaceIdNumber`, `compareSurfaceIds`,
`parseSurfaceTarget`) replaces the Wall's classifier; bare numbers,
`pane:N`, and an empty `surface:` are refused naming the `surface:N`
form. `surface:<n>` targets route Window-wide to the owning Wall, as
ids did.

Deleted: the per-Workspace ref registry and its counter, the persisted
`surfaceRefs`/`surfaceRefsNext` plumbing, ref retire-on-move and
mint-in-destination, ref transfer on replacement, and the pending
kill's `ref` field. `dor list` sorts by id number.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… the router

- Internal Wall results (createSplitSurface, createContentSurface,
  reopenSurface, ensureBrowserSurface, the viewport target) carry only
  the id; responses derive the ref with surfaceRefForId.
- The router refuses a malformed --surface before any Wall is asked,
  and parses the target once.
- WallHandle.iframeSurfaceRefs becomes iframeSurfaceIds; the CLI
  refusal formats refs where it builds its text.
- Drop the unused `pane` list filter; pendingSurfaceRefusal uses
  getPendingKill; open-target help no longer claims bare ids.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A render swap (agent-browser -> iframe, iframe -> an automated browser, or
a provider change) now swaps the renderer in place: the old renderer closes
through closeBrowserSurface and the leaf takes the new meta under the same
id. The Surface keeps its slot, its TODO, and its dor ref; the new renderer
acquires its own controller under that id when it mounts. Replacing an
untouched shell still creates a new Surface with a new id.

Specs: glossary I10 and its rationale, dor-browser.md render swaps and
placement, dor-cli.md ref stability.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
swapBrowserRenderer now takes the old params and the new params, derives
the title, and leaves selection to the Display-modal callers, so the
launch-failure restore uses it too. Swap tests address the Surface by its
known id, share leafIds, abPaneLayout and a raw browser-dispatch helper.
Stale comments on the controller registry and AgentBrowserPanel's
provider key now describe same-id swaps; dor-browser.md points at I10
instead of restating the mechanism.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PersistedSession.version goes 3 -> 4 and PersistedWindow.version 1 -> 2,
so no snapshot written before numbered Surface ids is ever restored. Readers
accept only the current versions and treat any other as a quiet fresh start
(one info log); the bare-Session-in-Window wrap is gone. Every writer stamps
PERSISTED_SESSION_VERSION / PERSISTED_WINDOW_VERSION.

Standalone Rust deletes every snapshot of another format, with its temp and
geometry, and drops arrival-journal records holding another session format,
at boot before the arrival merge and the id seed. ids.json and the recovery
record are untouched. standalone/scripts/persisted-format.json pins the pair
for both the lib and the Rust host.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
loadWindowState hands the raw blob straight to readPersistedWindow, which
already parses strings and starts fresh on anything unreadable. Both readers
log a discard through one logDiscard (info for another build's version, warn
for anything else) instead of a predicate that also logs. Rust reads the
journal record's session through one record_session helper and compares
versions directly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Readers now discard every pre-v4 Session and pre-v2 Window, so the shims
that kept older blobs readable or older strict builds happy are dead:

- the notification-source allowlist the writer kept for strict pre-tolerant
  readers (a WATCHING detail now persists with its TODO)
- the scrollback/resumeCommand deny-list in normalizeSession
- the `untouched` default: a pane without it is unreadable
- the `nameIsAuto` fallback: a Workspace record without it is dropped
- VS Code's redundant panes-array checks after readPersistedSession

Specs say how older transcript-bearing blobs leave the disk now: standalone
deletes them at boot, VS Code's first save overwrites them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- readPersistedWindow validates a Workspace through isPersistedWorkspaceShape
  and warns when it drops one
- drop the remaining Array.isArray(panes) checks on parsed Sessions
- inline isDefaultWorkspaceName into its only caller
- refresh comments and rationale the removed shims left stale

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- One numbered-id grammar per language: Rust formats through
  `ids::Kind::id`, TS parses `surface-<n>` / `workspace-<n>` through one
  helper in dor/protocol (`workspaceIdNumber`, `surfaceIdFor`).
- `ids.json`: a write raises every counter already at its ceiling, so a
  boot's first Workspace and Surface reservations share one fsync'd write.
- Inline the window-persistence wrappers into `windowStateSlot`, their one
  caller; its tests were already covered by session-window/session-types.
- Test fixtures use `surface-<n>` ids whose refs derive from them (dor
  cli-output, workspace-control), dropping a dead `pane` filter.
- Specs: each id rule once — standalone points at transport for the Surface
  counter's persistence and floor, and at glossary I10 for replacement and
  render-swap identity.
- Drop an unused Rust test import and fix stale comments.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Surface and Workspace refs are the id number, so every launch's gap shows up
in refs. Rust persisted a ceiling a slack past each block (1024 Surfaces, 64
Workspaces, 16 windows), and the webview pools held 64 / 32 ids that exit
discards.

- ids.rs persists exactly the end of each block, written (fsynced, off the
  main thread) on every reservation; window labels reserve exactly one.
- The VS Code allocator matches: re-read, write the block's end, no slack.
- Webview pools shrink to Surfaces 8 (refill below 3) and Workspaces 4
  (refill below 2), refilling only up to their size.
- A burst minted synchronously (Reopen of a Workspace, a closing window's
  reopen snapshot) reserves its whole count first through
  surfaceIdMinter / workspaceIdMinter, looping past the hosts' 64-id clamp,
  so it never falls back to opaque ids.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every window's extension host now reserves from one surface-ids.json
under globalStorageUri, under a cross-process lock, flushed before ids
are handed out, so windows no longer all number from surface-1 and
collide in the Burrow's phone directory. A reservation also stays above
every Surface PTY the extension host holds, covering sibling panels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The view's dormouse.session blob from an older format is no longer read,
so it could sit in workspaceState, transcript and all, until the view
next saved. Activation now deletes any saved session no reader takes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
minter(count) drained the whole pool before its awaited reservation, so
a create landing meanwhile (a dor split while a Workspace reopens) got a
permanent opaque id. A burst now reserves its own ids from the host,
and both pools refill sooner (Surface below 6 of 8, Workspace below 3
of 4) so a quick run of creates stays ahead of the round trip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Closing a window reserved a fresh id for every Workspace and Surface for
a Reopen that rarely comes, putting a host round trip and a flushed
write in front of the close. The record now keeps the window's own ids,
marked reopened; the window that boots from it remaps them with the
pools it installed, then saves them at once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The skill said either form works, but navigation targets read only the
ref form: dor iframe surface-3 opens https://surface-3/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With no host, the page counted from surface-1 and leaned on a Wall
ownership callback, injected by importing wall-handles, to skip ids its
restored Surfaces held. A restore or resume now raises the page counter
above every Surface it brings back, leaving only the registry guard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The harness kept its Workspace and Surface counters in memory, so a
killed highest Surface's number was minted again after a restart. They
now persist, before any id is handed out, under the worktree's
node_modules cache, which outlives a run as the harness browser's
localStorage does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One maxSurfaceIdNumber fold in dor/protocol for the extension host's
PTY floor, the page seed, and maxSurfaceNumber; the router reads PTY ids
without copying the buffer map. The allocator creates its directory
once and breaks a stale lock after 1s, since a webview boot waits on
it. A reopened window's save is issued without holding up render. The
harness creates its cache directory once, and its tests share one
command helper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A Surface's id is now `surface:<n>` (fallback `surface:<uuid>`) and a
Workspace's `workspace:<n>` (fallback `workspace:<uuid>`); the id is the
`dor` handle, so the id-to-ref mapping is gone. Rust, the VS Code
extension host, the browser harness, and the page mint the colon form;
a registry-less page counts Workspaces from `workspace:1` instead of
reading strip positions.

`parseSurfaceTarget` takes only `surface:<x>`, `surface:self`,
`surface:focused`, and `title:`; `3`, `pane:3`, `surface-3`, and bare
strings are refused with the `surface:N` hint. `dor list --id-format`
is removed, and every JSON payload carries one key per id
(`surface_ref`, `workspace_ref`, and the row `ref` are dropped;
`workspace_ref` becomes `workspace_id`). Wire types drop
`surfaceRef`/`workspaceRef` likewise.

The managed agent-browser session name already scrubs the colon
(`workspace:3` -> `dormouse.workspace-3.<key>`); a test pins it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The id is the handle: dor-cli.md's Handle Model, transport.md's Surface
ids, standalone.md's Workspace registry, vscode.md, glossary, reopen,
dor-tool, dor-browser (the session-name scrub), and the skill drop the
ref vocabulary, `--id-format`, positional Workspace refs, and the
stale "forget the surface ref" tiling rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A Workspace target now resolves through one exact-id comparison:
`parseWorkspaceRef` (and Rust's `parse_target`) restores the
`workspace:` prefix, so numbers and fallback ids share the lookup, the
VS Code guard compares ids, and the webview no longer reads Workspace
id numbers (`workspaceIdNumber` is gone; only Rust and the harness
seed from them). An omitted target with a caller parses as
`surface:self`; the stray `ref` key on listed Surfaces, the getter
around the answering Workspace id, and a pass-through destination
mapping are gone; UUID fallbacks mint through `surfaceIdFor` /
`workspaceIdFor`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

Deploying mouseterm with  Cloudflare Pages  Cloudflare Pages

Latest commit: 2001b97
Status: ✅  Deploy successful!
Preview URL: https://2c77f66f.mouseterm.pages.dev
Branch Preview URL: https://surface-numbered-ids.mouseterm.pages.dev

View logs

@dormouse-bot dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VS Code's id minting can lose its numbered ids, and in one case hand the same id to two windows. Both problems come from lock timing in vscode-ext/src/surface-ids.ts, and both show up in the case the lock exists for: several windows booting at once. Details are inline.

// No initialization needed — the webview is already running
const restored = readPersistedSession(this.getState());
await installSurfaceIdPool(async (count, floor) => {
const ids = await this.requestResponse('surface:reserveIds', 'surface:reservedIds', { count, floor }, (msg) => msg.ids as string[]);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

requestResponse gives up after its default 1000 ms. The host side of this request can legitimately take longer. withLock retries for up to LOCK_ATTEMPTS × LOCK_RETRY_MS (~2 s), and reserveSurfaceIds is serialized per extension host, so it also waits behind other webviews' reservations. Each one then does a durable write.

When several windows restore together, the first reservation times out and install resolves with an empty pool, so the Wall's first panes are minted as surface:<uuid>. The host still reserves the block, and that block is skipped. Both outcomes contradict installSurfaceIdPool's "never carries an opaque id". A timeout longer than the host's worst-case lock wait would close this. withLock gives the bound.

break;
}
const age = await stat(lock).then((s) => Date.now() - s.mtimeMs, () => 0);
if (age > LOCK_STALE_MS) await rm(lock, { recursive: true, force: true }).catch(() => {});

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This removes a lock that is older than 1 s without checking whether its holder is alive, and a live holder can stay past 1 s. The holder's critical section ends in a fsynced writeJsonAtomic, which can stall that long on a slow disk, under Windows AV, or during a multi-window restore.

When that happens, window B breaks A's lock and reads the same surface value, because A's rename hasn't landed. Both windows then hand out the same surface:<n>, which is the one outcome this counter exists to prevent. A's finally then rms the path, which is now B's lock, so a third window can get in too.

The ~2 s fall-through that runs body with no lock has the same result. A stale threshold well above any realistic fsync, or a liveness check before breaking the lock (for example, the holder's pid written inside the lock directory), would keep a breaker from overlapping a holder that is still running.

This branch is waiting to be deployed

1 waiting (outdated) deployment
hosted-preview — 4b8c1d29 Waiting Oct 9, 2026 by nedtwigg via deploy #1258
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants