Fix async mirror-create review findings from #2207 - #2242
Conversation
There was a problem hiding this comment.
🔵 Needs a closer look
It changes mirror-create auth routing and polling semantics in a user-critical area (cross-jurisdiction + timeouts), which warrants final human review despite strong regression test coverage.
Pull request overview
This PR addresses post-merge review findings from #2207 by hardening and clarifying the opt-in async repo mirror create flow, with a focus on safe polling (requestId-based), consistent timeout semantics, suspended-placement parity, and cleaner CLI UX.
Changes:
- Remove all reliance on the
Locationheader for async mirror-request polling; drive placement polling off the 202 body’srequestIdand keep polls on the client’s configured core URL (421 redirect handling remains in the transport). - Make
--wait-timeoutapply consistently across both async and sync routes (submission/registration + placement + clone wait), and preserve/print an accepted asyncrequestIdwhen the wait fails. - Improve CLI output and tests: single relabelled spinner for one-shot create, wizard labels placement timeouts as “timed out”, and add regression tests covering the reviewed findings.
File summaries
| File | Description |
|---|---|
| internal/coreapi/cross_juris_transport.go | Removes mirror-request Location canonicalization from the cross-jurisdiction transport path. |
| internal/coreapi/cross_juris_transport_test.go | Deletes the test that asserted the removed Location canonicalization behavior. |
| cmd/entire/cli/repo_mirror.go | Adds env-first async-route selection, unifies timeout semantics, introduces requestID retention for async failures, and switches one-shot create to a single updatable spinner. |
| cmd/entire/cli/repo_mirror_probe.go | Reworks async placement polling to use the 202 body’s requestId (not Location), removes Location parsing/poll targeting, and expands rationale/comments. |
| cmd/entire/cli/repo_mirror_request_test.go | Adds/updates regression tests for async route findings (ignore Location host/path, missing requestId, suspension parity, timeout behavior, env precedence, cross-jurisdiction routing). |
| cmd/entire/cli/repo_mirror_create_wizard.go | Classifies placement-timeout-without-placement as “timed out” (not generic error) for consistent wizard status reporting. |
| cmd/entire/cli/agent/session_store_test.go | Documents why one FD-counting test is intentionally non-parallel to avoid flakiness. |
Review details
- Files reviewed: 7/7 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 1327e34. Configure here.
Since #2246 made the async route the default, these are live for everyone rather than gated behind an opt-in flag. Drives the placement poll off the 202 body's requestId instead of the Location header, which removes three problems at once: an optional header no longer fails a create whose body already reports success, no server-named host can become a poll target for the control-plane bearer, and the poll keeps 421ing to the home core so the transport's bare-401 re-exchange stays recoverable past the token cache. With nothing reading the header, the mirrorLocationCanonicalizer that maintained it goes too — it survived the auth-go/crossjuris refactor in 02abf21, but its only consumer is gone. Restores parity between the two routes: MirrorRequestResult carries no `suspended` field, so an async create against a suspended placement reported plain success and exited 0 where the sync route warns and exits non-zero. One status read fills it in for every caller. Makes --wait-timeout cover submission, placement and clone on both routes. It previously bounded only the clone poll on the sync route, so a hanging CreateMirror ignored the flag; the help text documented that split rather than fixing it. Also: an accepted request's id survives a wait that ended with no verdict, so the user has a handle on in-flight work — but not a terminal failure, which is done and must not be described as still progressing; the wizard labels a placement timeout "timed out" rather than "error"; one relabelled spinner replaces three per-phase spinners that each stamped their own check mark; and ENTIRE_ASYNC_MIRROR_REQUESTS overrides the route in both directions, since this command usually runs outside the repo whose settings.json holds the opt-out. A settings-load error is now reported instead of swallowed, without changing the route — a read that failed is not evidence the repo opted out. Restores the poll-budget and createAndAwaitMirror rationale comments dropped in #2207, documents the new functions and the unreachable unknown-status branch, and records why the session-store FD test is deliberately not parallel. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Entire-Checkpoint: 01M1VQ3ZN5Y7DVA4ZXSF0DVV9H
1327e34 to
a20a65d
Compare
Trail finding 01M1VQ779H5B: the probe read the raw ctx, so it could hang past a deadline the caller had set — and past the doc comment this branch added, which promises opts.timeout covers the whole operation on both routes. Every other call in createAndAwaitMirror already uses waitCtx. The branch is only reachable through the deprecated `empty` flag the server no longer sets, so the test hand-builds that response; it hangs for 5s against the old code and returns in 50ms against the fixed one. Also reflows the applyAsyncSuspension comment paragraph whose ragged wrap Copilot flagged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Entire-Checkpoint: 01M1X9QPE408H8JK41GBT0K07P

https://entire.io/gh/entireio/cli/trails/1221
Why
Review of #2207 (async mirror-request routes) surfaced 15 findings after it merged. This fixes them.
Rebased onto main since it was opened, which changed the stakes twice. #2246 made the async route the default, so none of these are behind an opt-in flag any more — the suspended-placement exit-0, the cross-jurisdiction polling failure, and the unbounded synchronous timeout are all live. And
02abf214bmoved the cross-jurisdiction transport into the sharedauth-go/crossjurispackage, so the Location-canonicalization this PR removes now lives incross_juris_client.gorather than the deletedcross_juris_transport.go.What
The Location header is no longer used at all.
awaitMirrorPlacementdrives the poll off the 202 body's requiredrequestId, which removes three findings together.Locationis optional in the spec, so requiring it failed a create whose body already reported a succeeded placement with a mirror id and URL. Its host must never become a poll target either:mirrorRequestPollTargetacceptedhttp://<any host>(unlikeisSafeOrigin, which allows plaintext only for loopback precisely because these paths re-target the login JWT). And theWithServerURLoverride it fed was actively harmful: short-circuiting the poll at the home core skips the 421, and the follower treats a bare 401 as an exchange trigger only on a hop it reached by following one — so a placement that stayed pending past the token cache died with a 401 while the placement was fine. Strictly worse than not optimising, sinceawaitMirrorReadywas immune only because it never got the override. Polls now stay on the client's base URL and 421 on every tick.mirrorLocationCanonicalizergoes with it, and this is the one part worth a second opinion. It survived the auth-go refactor and was rebuilt there as a wrapper RoundTripper that rewrites ontoresp.Requestso it works after the replay — real work, deliberately preserved. But its only consumer was the poll target this PR removes, so it is now maintaining a header nothing reads, derived from a value the server supplies. Reviving it means giving a server-named host a say in where the control-plane bearer goes, which is what the follower's federation check exists to gate. Happy to keep it if it has a use I have not found — @pfleidi, this is the hunk to look at.Route parity for suspended placements.
MirrorRequestResulthas nosuspendedfield, whileCreatedMirrordoes and every downstream branch reads it — socreate --no-waitagainst an admin-suspended placement printed "Initial clone may still be in progress" and exited 0, where the sync route warns and exits non-zero. A script chainingcreate --no-wait && git clonewould proceed and fail at the clone.applyAsyncSuspensiondoes one status read after placement so both routes reach the same outcome shape. The durable fix is addingsuspendedtoMirrorRequestResultserver-side, after which the helper should go; its doc comment says so.--wait-timeoutnow means one thing. It bounded only the clone poll on the sync route, so a hangingCreateMirrorignored the flag entirely. Main's help text documented that split; this makes the deadline cover submission, placement and clone on both routes instead, and a sync create that expires reportstimed out registering the mirrorrather than a raw transport error.TestRepoMirrorCreate_WaitTimeoutHelpis updated to pin the symmetric promise.An accepted request id survives a wait with no verdict — a timeout or an exhausted poll budget previously returned
mirrorCreateOutcome{}, discarding the only handle on work that may still be progressing, and there is nomirror request getsubcommand to look one up. A terminal failure deliberately does not print that notice: the server is done, and pairingrepo_inaccessiblewith "re-run it to pick it up" sends the user in circles. That distinction is Bugbot's finding on the first revision, now carried by amirrorPlacementFailedErrortype.Smaller behavioural fixes. The wizard classifies a placement timeout as
timed outinstead oferror, so the same condition is not rendered two ways depending on which half of the timeout window it landed in. One relabelled spinner (startUpdatableSpinner, which exists for this) replaces three per-phase spinners that each stamped their own check mark — three success claims for one operation, including in CI logs, and a tick on a phase merely superseded by the next. AndENTIRE_ASYNC_MIRROR_REQUESTSoverrides the route in both directions:repo mirror createnames a repo the caller usually has not cloned, so a switch readable only from the cwd's.entire/settings.jsonhas no effect exactly where the command is most used. The settings-load error is reported instead of swallowed, but does not change the route — #2246's "keep the default on settings errors" is preserved, because a read that failed is not evidence the repo opted out.Comments. Restores the
maxConsecutivePollErrorsrationale (the measuredminimize_latency+ follower-read window, the 15x2s arithmetic, and the CRDB-leaseholder fix that would retire it — now that a second phase shares the constant) and thecreateAndAwaitMirrordoc comment stating the error contract both callers still branch on. Documents the four functions #2207 added, and records whyTestSessionStore_ProbingManyDirectoriesRetainsNoDescriptorsis deliberately not parallel — it counts this process's open descriptors, so restoringt.Parallel()for convention's sake makes it flaky.Deliberately not fixed
The unknown-status branch stays unreachable.
MirrorRequestStatusis a generated closed enum, so a new server status fails inUnmarshalTextand surfaces as a decode error swallowed by the retry budget, not as the intended message. Honouring the operation's own "unknown values are generic terminal failures" contract needs the enum generated open, which is a spec change. The branch is kept and the comment says why it cannot fire.The follower's bare-401 gate is unchanged. With the
WithServerURLoverride gone there is no caller that reaches it, so the bug is fixed without touching it — and it now lives inauth-gorather than this repo. Widening recovery to any origin we hold a cached token for would prevent the class recurring, but it changes when a login JWT is re-exchanged on a bare 401: an auth-policy change for a shared library, not a ride-along here.The setting stays in the project layer. The env var is the escape hatch; moving
async_mirror_requestsout of version-controlled settings entirely is a product call.Verification
mise run lint— 0 issues.mise run test:ci— passed on the rebased tree (unit, integration, vogon canary, roger-roger canary).New regression tests, one per finding:
TestCreateAndAwaitMirror_AsyncIgnoresLocation(seven Location shapes includinghttp://evil.exampleand a path-prefixed core, asserting no request is re-targeted at the named host),AsyncMissingRequestID,AsyncSuspendedPlacement(one-shot and wizard),AsyncTimeoutKeepsRequestID,SyncTimeoutCoversCreate,TestCreateOneMirror_AsyncPlacementTimeoutIsTimedOut,TestReportOneShotMirror_TerminalFailureIsNotCalledInFlight, andTestResolveAsyncMirrorRequests(now covering the default-on tri-state and the settings-error case).TestCreateAndAwaitMirror_AsyncCrossJurisdictionasserts every call — submission, placement poll, status read — arrives at the configured core and is redirected, which is the guard against the override coming back.Two tests are deleted because they pinned behaviour this PR removes on purpose:
TestCreateAndAwaitMirror_AsyncLocationValidation(Location parsing) andTestRoundTripper_421CanonicalizesMirrorRequestLocation(the canonicalizer).Not exercised against a live control plane. Worth a real
repo mirror createon both routes before this lands, particularly now that async is the default.🤖 Generated with Claude Code