Repository navigation
feat(cli): guard history turns with --exclusive, --who and --if-open - #5518
Conversation
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Automations to automatically generate PRs for you. |
Edit accuracy: accurate 2061 (base branch 2061), smooth 1562 of thoseThe gate passes. Quarantined, measured but not gated (0) Unstable (1)
|
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.
8d53af7 to
99bcf20
Compare
jrusso1020
left a comment
There was a problem hiding this comment.
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 claudeis 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 anexclusiveflag on it) could refuse while another agent's window is open. Otherwise, document--exclusiveas 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 claudestill exits 2 with "codex's turn is open". - Effect: a crashed agent locks out every exclusive agent until someone runs
endwithout--who. - Fix: apply the engine's idle rule. The marker's
lastWriteAtalone lags real writes, so it needs the window's real last write.
Non-blocking
- Marker written outside the lock.
withOwner'sfinallyrewrites the marker afterowner.close(), with a non-atomicwriteFileSync, 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 beforeclose()closes the gap. HYPERFRAMES_HISTORY_ROOTcan 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 differentowner.pidlocks over one shared marker. Suggestresolve()-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:
openProjectHistoryreleases 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
left a comment
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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'send --whowas refused and the winner'sendsucceeded. - Mutation: removing the server-side
blockingthrow 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
releaseOpenTurnfails that test and the single-process refusal test.
3. Stale marker: fixed
- Direct path: the refusal applies only while the open turn is live.
latestWritetakes the newestendedAtacross the turn's entries, which covers my point that the marker'slastWriteAtalone lags behind. - Preview path:
takesWriteapplies the same idle rule. The newit.eachcovers both paths.
Non-blocking items, both fixed
- 4.
withOwnernow rewrites the marker beforeowner.close(), so the rewrite happens inside the lock. - 5.
HYPERFRAMES_HISTORY_ROOTisresolve()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),
--exclusivebegins and overwrites the marker withoutendTurn. That old turn then never reacheslastTurns. Low impact. - Test path: the "only one of two agents wins" test calls
owner.begin(…, true)directly, so it doesn't coverreleaseOpenTurn. 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
miguel-heygen
left a comment
There was a problem hiding this comment.
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
What
Opt-in guards on history turns, for agents that share a project:
history begin --exclusiverefuses 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-opensucceeds with no entry when no turn is open, instead of exiting 2.HYPERFRAMES_HISTORY_ROOToverrides 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
beginsilently ends whoever else's turn is open, so two agents in one project steal each other's edits. Andendfails 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
withOwnercallbacks, before any state changes, and refuse through the existingRefusalpath: exit 2, or{ok: false, error}with--json.endmoved intoendedTurnText, unchanged.Test plan
--exclusiverefuses and leaves the other turn open;end --whorefuses another agent's turn;--if-openwith no turn.history.test.tspasses (36 tests);tsc,oxlint,oxfmt, comment gates andfallow auditare clean.docs/packages/cli.mdx.