Skip to content

fix(orcad): identify instances by kernel start ticks, not drifting wall-clock start time - #28125

Merged
nwparker merged 2 commits into
mainfrom
nwparker/fix-orcad-instance-identity-drift
Oct 13, 2026
Merged

nwparker merged 2 commits into
mainfrom
nwparker/fix-orcad-instance-identity-drift

Conversation

@nwparker

@nwparker nwparker commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 4 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​330 $\color{#cf222e}{\Huge{\mathbf{−}}}$​12 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​318
Prod 7 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​167 $\color{#cf222e}{\Huge{\mathbf{−}}}$​44 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​123

ELI5

When Orca stops its background server (orcad), it first checks that the process ID still belongs to that same orcad, and not to some other program that got the reused ID. On Linux that check compared start times worked out from a boot timestamp that can shift while the machine runs (WSL2 especially). Once that timestamp shifted, Orca decided the orcad "had already exited", skipped asking it to stop, and left it running. Now Linux uses the kernel's own start counter and boot ID, which never shift.

What Changed

Before: on a WSL2 smoke, /proc/stat btime moved by 47 s in ten minutes. orcad's computed start time then differed from the startedAtMs in orcad.lock by 110 s and later 157 s, well over the 1.5 s tolerance. completeOrcadManagedStop read that as "PID reused, so exited". It never wrote the stop request, the client deactivated the record anyway, the receipt said retirement: "unverifiable", and orcad plus its terminal daemon kept running until they exited on their own after 15 minutes of idle.

After:

  • orcad.lock records two new optional fields on Linux: linuxStartTicks (field 22 of /proc/<pid>/stat) and bootId (/proc/sys/kernel/random/boot_id). startedAtMs is still written. This is the same drift-free identity the terminal daemon's pid file already uses.
  • One comparison, compareOrcadProcessIdentity, answers matches / differs / unknown / unproven:
    • If the record has both ticks and boot ID, those decide. A different boot or different ticks means differs, which counts as proof. If the ticks can't be read, the answer is unknown, not proof.
    • Without them (records from older builds), wall-clock time is the fallback. On Linux a mismatch is now unproven, because that time comes from a btime that drifts. On Windows and macOS the OS stores the creation time, so a mismatch there still counts as proof of reuse, as before.
  • Managed stop: differs means exited, unproven means unverifiable, and unknown keeps polling as live. A death verdict now needs a missing PID or a proven kernel-identity change. The expected ticks come from the request if it has them, otherwise from the lock, but only while the lock still names this instance by pid, start time and nonce.
  • Lock acquisition (the other caller of the start-time identity) follows the same rule. Only a proven differs lets a new orcad reclaim a live holder's lock. Before this, btime drift could also let a second orcad take the lock from a live one.
  • The SSH client copies linuxStartTicks/bootId from the remote lock into the stop request when both are there.

Why

Ticks plus boot ID is already the identity the terminal daemon trusts (daemon-incarnation-evidence.ts), and it involves no clock. Alternatives I rejected:

  • A wider tolerance: drift has no upper bound, and a wider tolerance weakens the reuse check everywhere.
  • Re-reading btime at both ends: the lock's value was captured at an earlier btime.
  • Dropping the reuse check: Linux reuses PIDs too.

This also affects SSH hosts that run on VMs whose clock gets stepped or slewed, not only WSL.

Compatibility

Clients and hosts update independently. Every new field is optional, and a malformed value is salvaged to absent rather than making the lock or request unreadable.

Writer → reader Result
New orcad lock → older desktop/SSH client Parses. Older z.object drops the two keys, and startedAtMs is still present. Covered by a test using the older reader shape.
Older orcad lock → new client/slot No incarnation, so wall-clock fallback. On Linux a mismatch is unverifiable, never exited.
New client request (with ticks) → older slot Older slot's non-strict instance schema drops them. Behaves exactly as before. Covered by a test.
Older client request (no ticks) → new slot New slot takes the ticks from the lock while the lock still names the instance. Covered by a test.
New slot completion/receipt JSON → older client Extra instance keys are dropped. The client matches only on transactionId.

Instance matching between the running orcad and a request (sameOrcadManagedStopInstance: pid + startedAtMs + nonce) is unchanged, so no request that was accepted before is refused now.

Linked Issue

None found.

Visual Proof

N/A. This is host-side process-identity logic with no UI change.

Testing

  • orcad-managed-stop-identity.test.ts covers:

    • btime drifted, ticks and boot ID match (taken from the lock or from the request): live, request written, then proven exit with a receipt
    • different ticks: exited
    • different boot ID: exited
    • ticks unreadable: live, never exited
    • older lock without ticks plus a Linux time mismatch: unverifiable, no receipt
    • compatibility in both directions for the request instance
  • orcad-instance-lock.test.ts covers:

    • a drifted holder with matching ticks stays held, and different ticks lets the lock be reclaimed
    • an older Linux record with a time mismatch stays held
    • a new lock parsed by the older reader shape
    • a malformed incarnation is salvaged
  • orcad-process-start-time.test.ts tests the comparison verdicts directly.

  • Ran pnpm tc, the src/main/orcad + src/main/ssh + src/main/wsl suites (3577 passed), oxlint, check:code-quality:changed, check:max-lines-ratchet, check:reliability-gates, check:ts-nocheck-ratchet, audit:code-quality:type-aware, and pnpm format. All ran on macOS. The real /proc reads only run on Linux CI, and this was not re-run on a real WSL2 host.

  • I manually tested these changes locally

  • Automated tests added/updated, or explained why not below

AI Disclosure

Review

Agent skill upstream boundary

  • Not applicable, or this change copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.

Notes

Ensure no issues in: Security, Cross-platoform support (Linux, Windows, Mac), Remote SSH, Mobile, general backwards compatibility, performance

Checklist

  • This PR is small and focused
  • I explained what changed and why (ELI5, the user-facing before/after, the mechanism, and why over the alternatives)
  • Before/after screenshots or videos attached for UI changes, or N/A with reason
  • Self-reviewed for correctness, security, and performance
  • Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A)
  • pnpm lint, pnpm typecheck, pnpm test, and pnpm build pass (or CI will cover; local preferred)

@nwparker
nwparker force-pushed the nwparker/fix-orcad-instance-identity-drift branch from d042e3b to 7db0a8e Compare October 13, 2026 00:57
nwparker and others added 2 commits October 12, 2026 18:04
…ll-clock start time

On Linux the start time is derived from /proc/stat btime, which drifts (WSL2, NTP),
so a live orcad was read as a reused PID and its managed stop reported exited
without ever writing the request. The lock and stop request now carry optional
linuxStartTicks + bootId; readers prefer them, and a wall-clock mismatch on Linux
for records without them is unverifiable, never proof of exit.

Co-Authored-By: Claude <noreply@anthropic.com>
…nt drops

A request recovered by an older client loses linuxStartTicks/bootId, which made
the newer host reject its own receipts and decision records as mismatched. Also
exercise the incarnation reader against a real Linux /proc.
@nwparker
nwparker force-pushed the nwparker/fix-orcad-instance-identity-drift branch from 7db0a8e to 7fa0541 Compare October 13, 2026 01:04
@nwparker
nwparker marked this pull request as ready for review October 13, 2026 01:20
@nwparker
nwparker merged commit bcad291 into main Oct 13, 2026
50 of 51 checks passed
karlorz added a commit to karlorz/orca that referenced this pull request Oct 13, 2026
* Update README downloads badge

* fix(i18n): drop chat-mode keys removed from en.json in stablyai#28113 (stablyai#28131)

* Speed up Mobile Checks by overlapping typechecks and tests (stablyai#28127)

Overlap mobile typechecks with tests and avoid repeated filesystem probes and unused device substitutes in recording replays. Keep state isolated between loaders, give exhaustive snapshot cases an explicit execution deadline, and retire obsolete locale keys that blocked repository validation.

* fix(orcad): identify instances by kernel start ticks, not drifting wall-clock start time (stablyai#28125)

* fix(orcad): identify instances by kernel start ticks, not drifting wall-clock start time

On Linux the start time is derived from /proc/stat btime, which drifts (WSL2, NTP),
so a live orcad was read as a reused PID and its managed stop reported exited
without ever writing the request. The lock and stop request now carry optional
linuxStartTicks + bootId; readers prefer them, and a wall-clock mismatch on Linux
for records without them is unverifiable, never proof of exit.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(orcad): match stop receipts without the incarnation an older client drops

A request recovered by an older client loses linuxStartTicks/bootId, which made
the newer host reject its own receipts and decision records as mismatched. Also
exercise the incarnation reader against a real Linux /proc.

---------

Co-authored-by: Claude <noreply@anthropic.com>

* docs(readme): point the Android APK links at 0.0.55 (stablyai#28134)

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* perf(runtime): stop re-sending the worktree list to mobile pollers on every terminal chunk (stablyai#28062)

* perf(runtime): let worktree.ps pollers opt out of per-chunk activity churn

worktree.ps carries `preview` and an exact `lastOutputAt` on every row, both
of which move on each PTY chunk, so the content-addressed snapshot id changed
on every poll while any agent printed and the whole catalog was resent.

Add an optional `coarseActivity: true` param. When set, the host drops
`preview` and floors `lastOutputAt` to 30 s before hashing, so the id holds
while only chunk-level activity moves. Callers without the param get the
byte-identical response they get today; older hosts strip the unknown key.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* perf(mobile): stop re-sending the worktree list on every terminal chunk

The host-screen snapshot poll now asks worktree.ps for `coarseActivity`, so
the catalog's snapshot id survives terminal output and the 3 s poll gets a
tiny `unchanged` reply instead of the whole catalog. `preview` is optional on
mobile rows because a coarse host leaves it out; old hosts ignore the param
and keep sending it.

Re-records the three worktree.catalog-snapshot goldens; the only semantic
change is the new request param.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* perf(runtime): coarsen worktree.ps activity for every snapshot poller

Drop the `coarseActivity` param: the only caller that sends
`afterSnapshotId` is the mobile snapshot poll, which never renders
`preview` and uses `lastOutputAt` only to rank rows. Snapshot polls now
always get a blank `preview` and a 30 s-floored `lastOutputAt`, so
already-shipped apps stop re-downloading the catalog on every terminal
chunk with only a desktop update. Callers without `afterSnapshotId`
still get the byte-identical response.

Reverts the mobile param, its scenario steps and the three re-recorded
goldens, and removes the unread `preview` field from mobile row types.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(runtime): rank coarsened worktree.ps rows so in-bucket swaps keep the snapshot id

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test: drop preview from row fixtures and keep the runtime cast on one line

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): load markdown tabs from any desktop split group; center load-error Retry (stablyai#28132)

* fix(mobile): load markdown tabs from any desktop split group and center load-error Retry

The desktop answered markdown.readTab only for tabs in its focused split
group, so a phone opening a markdown tab from another group got
tab_not_found and showed "Couldn't load markdown". Resolve the tab across
all of the worktree's groups.

Load-error states now share one component that centers the message and its
Retry button; the markdown reader's button was pinned to the left.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(renderer): mobile markdown read picks the requested tab across groups

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(renderer): resolve mobile markdown tab to one file id

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(wsl): run WSL git with Windows Git line-ending settings on checkouts Windows Git wrote (stablyai#27778)

* fix(wsl): run WSL git with Windows Git settings on Windows-authored drive checkouts

* fix(wsl): detect Windows Git checkouts from host autocrlf plus a whole-tree sample

* fix(wsl): keep WSL-created worktrees on Linux Git defaults and never commit CRLF on an unproven checkout

- worktree add / clone write a new checkout whose .git points at /mnt/..., which Windows Git
  cannot open; injecting autocrlf=true there produced CRLF files later read unflagged.
- Writes on a host-autocrlf=true checkout whose sample is unproven run with autocrlf=input,
  so staging normalizes CRLF without ever writing CRLF to disk.
- Writes wait for a stale host answer and re-check checkout identity after joining a sample.

* fix(wsl): compare a joined sample with the checkout identity read after it settles

* fix(runtime): stop a project's terminals before the host forgets the project (stablyai#28024)

* fix(runtime): stop a project's terminals before the host forgets the project

Removing a project on a runtime host (an SSH-managed or paired server) sent repo.rm first; the
host resolves terminal.stop through the project's workspace rows, which repo.rm deletes, so the
stop failed and the shells kept running on the host. Terminals stop first now. The removal toast
also drops the IPC wrapper from its message.

* fix(runtime): only stop host terminals when the host will accept the project removal

Probe with repo.show (same resolver as repo.rm) so a removal the host rejects, such as an
ambiguous project id, no longer kills the shells first. Also poll through terminal_not_writable
in the paired-client e2e.

* test(e2e): read the host shell pid through sh so non-POSIX login shells work

* fix: preserve relay pane claims across managed tab mirrors

* fix(mobile): cancel bridge-post retries when a terminal document stops

Mobile Checks verify failed on an unhandled ReferenceError: window is not
defined inside postToReactNativeWebView's scheduled send() retry while
viewport-frame.test.ts ran. Retries lived outside the document lifecycle, so
a stop left timers that later read a torn-down window.

Bind the default postToHost per scope, clear its timers on stop, no-op posts
after stop, and guard window before reading ReactNativeWebView. Preserve
bounded retry and deliver-when-bridge-appears behavior.

* fix(mobile): use supported own-property check for host overrides

* fix(mobile): align bridge retry teardown with document lifecycle contract

* style(mobile): format bridge retry lifecycle regression test

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com>
registered2nd pushed a commit to registered2nd/orca that referenced this pull request Oct 14, 2026
…ll-clock start time (stablyai#28125)

* fix(orcad): identify instances by kernel start ticks, not drifting wall-clock start time

On Linux the start time is derived from /proc/stat btime, which drifts (WSL2, NTP),
so a live orcad was read as a reused PID and its managed stop reported exited
without ever writing the request. The lock and stop request now carry optional
linuxStartTicks + bootId; readers prefer them, and a wall-clock mismatch on Linux
for records without them is unverifiable, never proof of exit.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(orcad): match stop receipts without the incarnation an older client drops

A request recovered by an older client loses linuxStartTicks/bootId, which made
the newer host reject its own receipts and decision records as mismatched. Also
exercise the incarnation reader against a real Linux /proc.

---------

Co-authored-by: Claude <noreply@anthropic.com>
(cherry picked from commit bcad291)
nwparker added a commit that referenced this pull request Oct 16, 2026
…, and explain a possibly reused PID

Records written before #28125 carry no start ticks or boot id, so on Linux a
wall-clock mismatch is unproven and a reused PID kept the lock held forever.
A PID owned by another non-root uid cannot be the holder (orcad never changes
uid), so that now proves reuse in both lock reclaim and managed stop. When the
verdict stays unproven, the error names the live process and the orcad.lock
path that is safe to remove once no orcad is running.
nwparker added a commit that referenced this pull request Oct 16, 2026
…a possibly reused PID (#29548)

* fix(orcad): reclaim a pre-#28125 lock whose PID another user now owns, and explain a possibly reused PID

Records written before #28125 carry no start ticks or boot id, so on Linux a
wall-clock mismatch is unproven and a reused PID kept the lock held forever.
A PID owned by another non-root uid cannot be the holder (orcad never changes
uid), so that now proves reuse in both lock reclaim and managed stop. When the
verdict stays unproven, the error names the live process and the orcad.lock
path that is safe to remove once no orcad is running.

* fix(orcad): treat the overflow uid as unproven and word the reused-PID error for any boot-less record

* test(orcad): keep the Linux-platform lock cases off Windows, where the platform hook also drives data-root privacy
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.

1 participant