Skip to content

fix(cli): history reads answer while another app has the project open - #5445

Merged
miguel-heygen merged 3 commits into
mainfrom
fix/history-read-without-lock
Oct 11, 2026
Merged

miguel-heygen merged 3 commits into
mainfrom
fix/history-read-without-lock

Conversation

@miguel-heygen

Copy link
Copy Markdown
Collaborator

What changed

When another app keeps a project open, it owns that project's history (owner.pid in the project's history folder). Until now, every hyperframes history command then refused with "This project's history is open in another process (pid N).", even commands that only read. So an agent working on that project could not run hyperframes history --json --limit 2.

  • studio-server: new readProjectHistory({ projectDir, historyRoot }) returns a read-only view of a project's history: list, peek and readBlob.
    • It takes no lock and starts no watcher, and it never files, prunes, compacts or writes anything.
    • It replays the log with the engine's own readLog, which already drops a half-appended last line, and applies the same hidden-path filter.
    • list and peek now come from two small functions, listOf and peekOf, shared with the engine.
    • Blob paths come from one blobPath helper, shared with the blob store.
  • cli: history (list), show and peek go through a new withReader.
    • It opens the history as owner, exactly as before, so a read still files pending writes when nobody owns the history.
    • Only when that open fails because another process owns the history does it read through the view instead.
    • A pending agent turn marker is left as it is on that path.
    • If the owner pruned a stored file after the view read the log, peek and show --diff refuse with "That point is no longer kept" instead of a raw ENOENT.
  • cli: undo, restore, pin, begin and end still refuse while another app owns the history. The message now reads "This project is open in another app (pid N), which keeps its history: undo, restore or pin there."

What I measured

  • cli history.test.ts: 34 passed, exit 0, 3 runs in a row.
    • The new test keeps the history open in a second handle that holds the owner lock.
    • With the lock held, history --json --limit 2 lists the two newest entries, show prints the entry's files and peek <id> index.html prints the old bytes.
    • The turn marker is byte-identical afterwards, and undo exits 2 with the new words and leaves the file alone.
    • A second new test checks the pruned-blob refusal.
    • The old busy test, which waited 5 s of real time for the busy error, became the first new test. The tests now set the owner wait to 0 through historyDeps.ownerWaitMs.
  • studio-server projectHistory.test.ts: 138 passed, 2 skipped (the case-insensitive-disk tests on Linux), exit 0, 3 runs in a row. The new view test passed 3 runs in a row.
    • With the owner open and a half-appended line at the end of the log, the view's list and peek equal the owner's, and readBlob returns the old bytes.
    • The history folder's files and their contents are unchanged afterwards.
  • Other checks: projectHistory.swap.test.ts 7 passed and pruneHistories.test.ts 15 passed. tsc --noEmit passed in both packages, oxlint reported 0 warnings and 0 errors on the changed files, and oxfmt --check was clean.
  • By hand: one process held the history open with openProjectHistory, and a second ran the real CLI from source.
    • history --json --limit 2 returned both entries, exit 0. show and peek start index.html printed, exit 0.
    • undo <id> and pin <id> exited 2 with the new message, and index.html was unchanged.
  • fails without the fix: each new test was re-run against a broken version of the code.
    • With history, show and peek back on the owner path, the cli test fails: AssertionError: expected 2 to be +0 on the exit code of history --json --limit 2.
    • With the old busy message, the cli test fails: received "This project's history is open in another process (pid …)." where the new words were expected.
    • With the source reverted, the studio-server test fails with TypeError: readProjectHistory is not a function. With the view parsing each log line itself instead of through readLog, it fails on the torn line: SyntaxError: Expected ',' or '}' after property value in JSON at position 36.
    • Without the ENOENT mapping, the pruned-blob test fails with a raw ENOENT: no such file or directory, open '…/blobs/…'.

What I did NOT exercise

  • I did not run the real desktop editor. The owner in every check was a second openProjectHistory handle, the same open call that editor makes.
  • A read still waits for the owner lock for up to the engine's 5 s default before falling back to the view. By hand, a read with another app holding the history took about 5.2 s. Waiting less for reads is a separate choice.
  • On the view path, reads show what the owner has written to its log so far. Edits the owner has not recorded yet do not appear, and nothing in the output says so.
  • I ran no Windows run and no full CLI suite, only the touched test files.

@miguel-heygen

Copy link
Copy Markdown
Collaborator Author

Fresh review: clean, three minors for a follow-up after merge: the busy refusal now says 'another app' even when the holder is another CLI run or a stale lock (and for begin/end), fallback reads report via 'direct' so they can't be counted, and the writes-nothing test compares names and contents one level deep only.

@somanshreddy somanshreddy left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approving at 32d284c3.

Checked

  • withReader can't run its task twice. It tries the owner exactly as before, so a read still files pending writes when nobody owns the history. It falls back to the view only on HistoryBusyError, which is thrown at open, before the task runs.
  • readProjectHistory is read-only:
    • It takes no lock and starts no watcher.
    • It replays through the engine's own readLog, which skips a half-appended line, and applies the same hidden-path filter.
    • listOf and peekOf are shared with the engine, so the view and the owner can't drift.
    • Blob reads go through the shared blobPath, which validates the 64-hex hash before joining, and a pruned blob becomes the "no longer kept" refusal.
  • Writes still refuse. undo/restore/pin/begin/end still go through withOwner, and the busy message is reworded only in guarded.

Verification

  • cli history.test.ts: 34/34, after rebuilding studio-server's dist.
  • studio-server projectHistory.test.ts: 138 pass, 2 skipped (case-insensitive FS).
  • Mutants killed:
    • no busy fallback (2 fail)
    • no ENOENT → refusal mapping (1 fails)
  • CI: the required checks that have reported are green. Test and the edit-accuracy shards were still queued or running when I posted, none red. No CRs.

Should-fix (latency)

  • A read only falls back after openProjectHistory gives up taking ownership, and the CLI doesn't pass ownerWaitMs outside tests, so it uses the default ?? 5000 (projectHistory.ts:1287).
  • So while Desktop has the project open, every history/show/peek waits about 5 s before answering. That's exactly the agent use case this PR targets.
  • Fix: check for a live owner first (e.g. owner.pid alive and not ours), or pass a short ownerWaitMs on the read path, keeping the full wait for writes.

Review by Somu

@github-actions

Copy link
Copy Markdown
Contributor

Edit accuracy: accurate 2061 (base branch 2061), smooth 1620 of those

The gate passes.
Smoothness is reported in the artifact, not gated. A case fails only if it fails 2 of 3 runs.

Quarantined, measured but not gated (0)

@miguel-heygen
miguel-heygen added this pull request to the merge queue Oct 11, 2026
Merged via the queue into main with commit 489036c Oct 11, 2026
81 checks passed
@miguel-heygen
miguel-heygen deleted the fix/history-read-without-lock branch October 11, 2026 01:59
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