Skip to content

feat(cli): guard history turns with --exclusive, --who and --if-open - #5518

Merged
jrusso1020 merged 3 commits into
mainfrom
feat/cli-history-turn-guards
Oct 13, 2026
Merged

jrusso1020 merged 3 commits into
mainfrom
feat/cli-history-turn-guards

Conversation

@xuanruli

Copy link
Copy Markdown
Contributor

What

Opt-in guards on history turns, for agents that share a project:

  • history begin --exclusive refuses while another agent's turn is open, instead of ending that turn.
  • history end --who <name> refuses unless the open turn belongs to <name>.
  • history end --if-open succeeds with no entry when no turn is open, instead of exiting 2.
  • HYPERFRAMES_HISTORY_ROOT overrides where the CLI keeps history when no preview is running.

Without the new flags and the variable, every command behaves as before.

Why

A harness opens a turn before each agent step and closes it afterwards. Today begin silently ends whoever else's turn is open, so two agents in one project steal each other's edits. And end fails when the step never opened a turn, for example after a crash or a resume. The harness needs to say "only if it is mine" and "only if one is open".

The history-root override keeps a harness run, or a test, apart from the user's own ~/.cache/hyperframes/history.

Related work

Standalone.

How

  • The checks run inside the existing withOwner callbacks, before any state changes, and refuse through the existing Refusal path: exit 2, or {ok: false, error} with --json.
  • The text summary for end moved into endedTurnText, unchanged.

Test plan

  • Tests added: --exclusive refuses and leaves the other turn open; end --who refuses another agent's turn; --if-open with no turn.
  • history.test.ts passes (36 tests); tsc, oxlint, oxfmt, comment gates and fallow audit are clean.
  • Documentation updated: docs/packages/cli.mdx.
  • Comments follow CONTRIBUTING.md.

@mintlify

mintlify Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated
hyperframes 🟢 Ready View Preview Oct 13, 2026, 3:53 AM

💡 Tip: Enable Automations to automatically generate PRs for you.

@github-actions

github-actions Bot commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Edit accuracy: accurate 2061 (base branch 2061), smooth 1562 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)

Unstable (1)

  • crop-none-px-r0-root-z100: tracking 0.04, pressJump 0, drop 40.03, reload 40.03, render 39.98, renderKey -, undo true, teleport true / tracking 0.04, pressJump 0, drop 0.06, reload 0.06, render 0.03, renderKey -, undo true, teleport true / tracking 0.04, pressJump 0, drop 0.06, reload 0.06, render 0.03, renderKey -, undo true, teleport true

Re-read the open turn only after acquiring history ownership so
concurrent exclusive begins cannot both claim the same project.
Cover the race with a deterministic two-process regression test.

@jrusso1020 jrusso1020 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.

Requesting changes at 99bcf208. Since 8d53af7b the only change is the removed doc-comment line, so everything below applies at this head.

The new claim is atomic when no preview is running, but --exclusive doesn't hold while a preview keeps the history. And the new regression test times out more often than it passes.

1. Blocking: two agents can both pass begin --exclusive while a preview runs

In historyOwner.ts connect(), the preview branch returns { owner: previewOwner(route), turn: readTurn(projectDir) }. It reads the marker without taking any lock. So on that path the --exclusive check in history.ts (~471-474) and the writeTurn after it are still check-then-write. The server's beginWindow accepts any number of open windows.

  • Repro: I started a preview server like the test helper's and ran two begin --exclusive (codex, claude) at the same moment. Both exited 0 with no refusal, in 3 runs out of 3, and the marker ended up as codex.
  • Effect: claude believes it owns the turn. Its window sits orphaned until the idle limit, and its later end --who claude is refused. That is the edit-stealing this PR sets out to stop.
  • Fix: have the preview enforce it. Its engine queue already serializes beginWindow, so /window (or an exclusive flag on it) could refuse while another agent's window is open. Otherwise, document --exclusive as working only without a preview, and make the flag fail loudly when a preview is found.

2. The new regression test is flaky

The two-process test (history.test.ts ~525) waits until it sees two owner.pid-* draft files in the folder at the same moment. But claim() in ownerLock.ts writes the draft, links it and deletes it within microseconds, so that moment often never comes.

  • At this head: it timed out (20 s) in 5 of 8 runs. The other 36 tests passed every time.
  • When it does get past that wait, it is a real test: with the fix's source reverted it fails [0, 0] (both agents win).
  • Fix: build the barrier from the pids written into the drafts, collected across watch events, or hold the lock for a fixed time inside the test.

3. A stale marker blocks --exclusive forever

--exclusive refuses whenever a marker exists, no matter its age, while the engine treats a turn with no write for the idle limit as ended.

  • Repro: codex begins, the clock advances a day, then begin --exclusive --who claude still exits 2 with "codex's turn is open".
  • Effect: a crashed agent locks out every exclusive agent until someone runs end without --who.
  • Fix: apply the engine's idle rule. The marker's lastWriteAt alone lags real writes, so it needs the window's real last write.

Non-blocking

  1. Marker written outside the lock. withOwner's finally rewrites the marker after owner.close(), with a non-atomic writeFileSync, which is outside the lock this PR now relies on. This predates the PR and didn't reproduce in 200 mixed runs. Moving the rewrite before close() closes the gap.
  2. HYPERFRAMES_HISTORY_ROOT can split the lock. It is read per process, but the turn marker lives in the project. Two processes with different roots, or the same relative root run from different directories, take different owner.pid locks over one shared marker. Suggest resolve()-ing it, and documenting that all agents on a project must share it.

What holds

  • No preview: 6 processes × 40 rounds at this code gave exactly one winner in 40/40 rounds and 200 clean refusals. The pre-fix code had several winners in 39/40 rounds.
  • Readers: with a codex turn held, 2 readers × 40 and 3 exclusive contenders × 40 gave 0 contender wins.
  • Release and deadlock: openProjectHistory releases on its catch path, and the callback runs inside the try. The callback is synchronous, so there's no same-process re-entry deadlock.

Reuse: the no-preview path reuses takeHistoryOwnership correctly, and the preview path should reuse the server's serialized beginWindow rather than a CLI-side check. · Simpler: closedWindow has no production caller left, so closedWindowAfterOwnership could replace it rather than sit beside it.

Tests: cli history.test.ts has 37 tests: 36 always pass, and the new one is flaky as above. studio-server src/history passes 160 (2 skipped). Both commits are signed.

— Rames

@miguel-heygen miguel-heygen 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.

Additive review at 99bcf208. Rames already requests changes for the preview ownership race, stale marker and flaky test. I independently reproduced the preview race through the real Studio HTTP API; this is supporting evidence, not a duplicate finding.

The direct-owner branch in packages/cli/src/utils/historyOwner.ts:163–190 now reads the turn after acquiring history ownership, and the focused history suite passes 37/37 in my run, including the two-process direct-owner regression.

For the running-preview branch at :210, I used two real CLI children and a server-side request barrier before forwarding their /window requests to createStudioApi. Both begin --exclusive calls exit 0 and return distinct window IDs; the shared marker ends with only beta. This confirms the flag is still not exclusive on the preview path. The scratch history/server/children are cleaned up. No Desktop host or production writes.

No new independent blocker beyond Rames's review. I did not repeat his eight-run flake or idle-marker probes; my one successful suite run does not disprove those findings.

Verdict: COMMENT
Reasoning: Direct-owner behavior improves, but the already documented preview race remains reproducible. This does not approve the head or clear Rames's change request.
— Magi

The preview's begin window now rejects another agent's live turn, and an
idle turn no longer blocks exclusive begin. The two-process test waits on
pids collected from claim drafts instead of seeing both files at once.

@jrusso1020 jrusso1020 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.

Approving at bc655120. This clears my change request from 99bcf208: all three blocking items are fixed, and so are both non-blocking ones.

1. Preview exclusivity: fixed, in the right place

--exclusive through a preview now sends exclusive: true to /window. Engine.beginWindow checks for another agent's live window inside its serialized queue, so check-and-open is one step on the server. A refusal comes back as 409, which the CLI turns into a Refusal (exit 2). On the preview path releaseOpenTurn no longer ends another agent's turn before the server has answered, so a refused begin changes nothing.

  • My original race, rerun through the real hf("begin", …, "--exclusive") path: with a preview and two agents at once, exactly one won in 20 of 20 rounds. The loser exited 2 with "…'s turn is open". In every round the loser's end --who was refused and the winner's end succeeded.
  • Mutation: removing the server-side blocking throw fails both new preview tests.

2. The flaky two-process test: fixed

The barrier now collects pids from the owner.pid-<pid>-… draft names across watch events, instead of needing two drafts on disk at the same moment. claim() puts the pid in the draft name for this. history.test.ts passed 41/41 in 8 of 8 runs; before, the old test timed out in 5 of 8.

  • Mutation: removing the direct-path refusal in releaseOpenTurn fails that test and the single-process refusal test.

3. Stale marker: fixed

  • Direct path: the refusal applies only while the open turn is live. latestWrite takes the newest endedAt across the turn's entries, which covers my point that the marker's lastWriteAt alone lags behind.
  • Preview path: takesWrite applies the same idle rule. The new it.each covers both paths.

Non-blocking items, both fixed

  • 4. withOwner now rewrites the marker before owner.close(), so the rewrite happens inside the lock.
  • 5. HYPERFRAMES_HISTORY_ROOT is resolve()d, and the docs say every agent on a project has to share it.

Nits

  • Preview path: when the marker names another agent whose window the preview no longer has (for example, the preview restarted), --exclusive begins and overwrites the marker without endTurn. That old turn then never reaches lastTurns. Low impact.
  • Test path: the "only one of two agents wins" test calls owner.begin(…, true) directly, so it doesn't cover releaseOpenTurn. My CLI-path probe above did.

Tests:

  • CLI history.test.ts: 41 passed, 8 runs out of 8.
  • studio-server src/history + src/routes: 760 passed, 2 skipped.
  • CI: 32 checks passed and 15 still pending, with no failures so far.
  • All three commits are signed.

— Rames

@jrusso1020
jrusso1020 enabled auto-merge October 13, 2026 03:56

@miguel-heygen miguel-heygen 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.

Re-review at bc655120eb. Rames has verified the preview race, idle-marker and test-barrier fixes; my original HTTP witness independently confirms the same correction.

Through the actual CLI begin path with two real Bun children and a server-side barrier before /window, exactly one claim succeeds and the other reports exit 2 in four runs; the shared marker belongs to the winner. Engine.beginWindow now checks/open inside its serialized queue, and a refused exclusive preview begin leaves the existing owner intact. The direct marker rewrite stays inside ownership, and idle expiry is covered through both paths.

Verification: CLI history 41/41 passes; the previously flaky two-process claim test passes four additional repeats. Server projectHistory/history-route tests pass 163 with two skipped. Fresh parser/core/lint/server builds pass. These use an isolated local Linux checkout and synthetic project files with the real HTTP/CLI code; no Desktop host or Windows repeat. Initial cached-workspace resolution was incomplete; after linking the exact local packages, the focused suite and original witness pass.

Current required checks have no failures; the Windows render is still pending while Build, Typecheck and runtime contract are green. This is not a completed-CI receipt.

Verdict: APPROVE
Reasoning: The previously reproduced ownership race is resolved at the server boundary, idle-turn behavior passes, and the claim regression is stable in the additional repeats.
— Magi

@jrusso1020
jrusso1020 added this pull request to the merge queue Oct 13, 2026
Merged via the queue into main with commit 7c73b3c Oct 13, 2026
83 checks passed
@jrusso1020
jrusso1020 deleted the feat/cli-history-turn-guards branch October 13, 2026 04:34

This branch was successfully deployed

1 active deployment
staging - docs — bc655120 Deployed Oct 13, 2026 by mintlify[bot]
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.

3 participants