Conversation
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
…re points (#6621) Review findings on #6645, each fixed at its cause: - Web client: register and apply `turn.workspace_snapshot` (the receipt is appended to its turn's `workspace_snapshots`), fixing the red Version drift job. - Cross-thread attribution: a recording engine (Runtime) now bounds every tool call that may write with `tool` and new `post_tool` snapshots, and each receipt carries `changed_paths` since the turn's previous one plus the file tool's declared `write_paths`. patch-undo attributes a path to the turn only when it changed inside one of the turn's tool spans (and, for a file tool, is a declared path); anything else changed during the turn refuses with workspace_changed_since_turn instead of being reverted. A user shell turn's pre_turn receipt names its command. A span with no recorded changes fails closed. - Unsnapshottable writes: a dropped file-tool call (from receipts or turn items; failed/canceled calls excluded) that declared a gitignored, built-in-excluded or out-of-workspace path gets 409 path_not_snapshotted instead of 201 files_restored:false. - Pruning: prune_keep_last_n keeps the newest N snapshots plus the newest N pre-turn/post-turn boundaries, so a turn with more writes than the cap (or another thread's burst) no longer prunes its own restore points. - Nameless PUT /v1/sessions updates the thread's bound document (e.g. the one it was resumed from) and creates one under the thread id only when unbound; an existing document is updated, not recreated. Blocking-call budget: +2 path_canonicalize in runtime_api.rs, inside patch_undo_workspace_files, which only runs on spawn_blocking. Tests (local macOS, focused): - snapshot|undo|session|fork|evict|resume|revert|protocol_parity|core::turn| core::engine|runtime_threads::|turn_loop: 2037 passed, 0 failed, 8 ignored - new: a_concurrent_writers_change_is_never_undone_as_the_turns (also a bash tool write undone), undo_of_a_write_to_an_ignored_path_is_refused, a_turn_with_more_writes_than_the_snapshot_cap_still_undoes, nameless_save_of_a_resumed_thread_updates_its_document, patch_undo_helper_attributes_changes_to_the_turns_own_tool_spans, patch_undo_helper_refuses_declared_writes_snapshots_cannot_hold, prune_keep_last_n_retains_turn_boundaries_through_a_tool_burst - node --test crates/tui/tests/runtime_web_client.test.mjs: 37 passed - clippy (tui, protocol; CI flags) clean; cargo fmt --check clean - blocking-calls, dead-code, command-boundary, module-graph gates pass - changelog sync/derive, check-versions, contributor credit, web public-copy (6 passed) green Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…tores or refuses (#6621) Root cause: ensure_engine_loaded built every Runtime engine with EngineConfig.session_id = None, so the engine tagged each workspace snapshot with a random uuid. The uuid changed on every rebuild (restart, LRU eviction) and nothing recorded it. patch-undo and file-revert used ThreadRecord.session_id to decide ownership, but that field is a saved-document link. It is None on a fresh thread, and save, resume and fork rebind it to ids that own no snapshots. So patch-undo returned 201 with files_restored=false and forked the conversation while the files stayed changed, and file-revert returned 409. On top of that, patch-undo restored the newest differing snapshot over the whole tree. That kept the earlier writes of a multi-write turn and reverted unrelated later edits. Fix: - The engine runs every Runtime thread under the thread's own id, in both EngineConfig and SyncSession, across restarts and eviction. The saved-session binding no longer changes the engine's identity. - The engine reports each snapshot it takes (pre_turn, tool, post_turn) as Event::WorkspaceSnapshotTaken, which also goes on the protocol wire. With await_post_turn_snapshot, which the Runtime sets and the TUI does not, the post-turn snapshot is taken before TurnComplete. monitor_turn records each receipt in TurnRecord.workspace_snapshots and publishes it as turn.workspace_snapshot. Forks clone the records, so a fork owns the restore points of the turns it inherited. - Receipts resolve by tree id, session tag and label kind against the full, uncapped store listing. A prune rewrites commit ids but keeps trees. - patch-undo undoes whole turns. It takes the paths changed within each dropped turn's pre-to-post window and restores only those paths to their content before the first dropped turn. It refuses when a path changed after the turns or between them. It never forks over files it could not restore: 409 with error.code restore_point_unavailable, restore_point_pruned, workspace_changed_since_turn, restore_requires_trust or workspace_unavailable. 201 with files_restored=false is kept only when there is provably nothing to restore. - file-revert accepts only a tool or pre-turn restore point recorded on the thread's own turns, named by the current commit id or by the receipt's snapshot_id or tree_id. - SnapshotRepo gains take_snapshot (commit + tree), a tree on every listed Snapshot, restore_path_plan, changed_paths_between, path_matches_snapshot and path_same_in_snapshots. ApiError can carry a stable error.code. Follow-ups: TUI /undo gaps #6644; client adoption Hmbown/codewhale-app#127. Tests (focused, local macOS): - 8 new HTTP tests with real engines (thread_snapshot_ownership): fresh thread (new + existing file, two writes in one turn), depth=1 and chained undo, fork and fork-at-turn, eviction and restart, save/rebind/resume, cross-thread and TUI snapshots, file-revert from turn records, and honest refusals (snapshots off, legacy seeded turn, pruned, untrusted). - Rewritten patch-undo and file-revert helper tests. - Selection run: 134 passed, 0 failed. - Broader runs: fork/undo/sync_session/session_id/evict/snapshot/resume 521 passed (1 ignored); core::engine/core::turn/protocol_parity 633 passed (4 ignored); runtime_threads 263 passed (2 ignored); codewhale-protocol 73 + 17 passed. - cargo fmt --check, clippy (tui + protocol, CI flags), blocking-calls, dead-code, command-crate-boundary and module-graph gates, changelog sync/derive, check-versions, contributor credit, and web public-copy (6 passed) are all green. Closes #6621 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…re points (#6621) Review findings on #6645, each fixed at its cause: - Web client: register and apply `turn.workspace_snapshot` (the receipt is appended to its turn's `workspace_snapshots`), fixing the red Version drift job. - Cross-thread attribution: a recording engine (Runtime) now bounds every tool call that may write with `tool` and new `post_tool` snapshots, and each receipt carries `changed_paths` since the turn's previous one plus the file tool's declared `write_paths`. patch-undo attributes a path to the turn only when it changed inside one of the turn's tool spans (and, for a file tool, is a declared path); anything else changed during the turn refuses with workspace_changed_since_turn instead of being reverted. A user shell turn's pre_turn receipt names its command. A span with no recorded changes fails closed. - Unsnapshottable writes: a dropped file-tool call (from receipts or turn items; failed/canceled calls excluded) that declared a gitignored, built-in-excluded or out-of-workspace path gets 409 path_not_snapshotted instead of 201 files_restored:false. - Pruning: prune_keep_last_n keeps the newest N snapshots plus the newest N pre-turn/post-turn boundaries, so a turn with more writes than the cap (or another thread's burst) no longer prunes its own restore points. - Nameless PUT /v1/sessions updates the thread's bound document (e.g. the one it was resumed from) and creates one under the thread id only when unbound; an existing document is updated, not recreated. Blocking-call budget: +2 path_canonicalize in runtime_api.rs, inside patch_undo_workspace_files, which only runs on spawn_blocking. Tests (local macOS, focused): - snapshot|undo|session|fork|evict|resume|revert|protocol_parity|core::turn| core::engine|runtime_threads::|turn_loop: 2037 passed, 0 failed, 8 ignored - new: a_concurrent_writers_change_is_never_undone_as_the_turns (also a bash tool write undone), undo_of_a_write_to_an_ignored_path_is_refused, a_turn_with_more_writes_than_the_snapshot_cap_still_undoes, nameless_save_of_a_resumed_thread_updates_its_document, patch_undo_helper_attributes_changes_to_the_turns_own_tool_spans, patch_undo_helper_refuses_declared_writes_snapshots_cannot_hold, prune_keep_last_n_retains_turn_boundaries_through_a_tool_burst - node --test crates/tui/tests/runtime_web_client.test.mjs: 37 passed - clippy (tui, protocol; CI flags) clean; cargo fmt --check clean - blocking-calls, dead-code, command-boundary, module-graph gates pass - changelog sync/derive, check-versions, contributor credit, web public-copy (6 passed) green Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…open path_matches_snapshot passed git hash-object the canonicalized work tree, which on Windows carries the \\?\ verbatim prefix git cannot open. The four patch_undo_helper tests failed on Windows with a 500 from that error. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
6cfe53b to
ff10c26
Compare
…legacy_root main (#6641) removed Config's top-level api_key/base_url fields; the undo_that_cannot_restore_files_is_refused fixture still set them, so the TUI test crate did not compile on CI (E0560). Checks: cargo test -p codewhale-tui --lib --no-run builds; the test itself: 1 passed; 0 failed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Cause: a Runtime thread with no saved session built its engine with `session_id: None`, so each spawn (first turn, LRU eviction, restart) generated a fresh id. Spills (`sessions/<id>/artifacts/`), snapshot tags and shell jobs are all keyed by that id and scattered per spawn. Fix: already in this branch (#6621): every Runtime thread's engine is built, and re-synced, under `Some(thread.id)`. This adds a focused regression test: an unbound thread with history reports the same engine session id (its thread id) on first spawn, after eviction and after a Runtime restart, and stays unbound. With the two `Some(thread.id)` lines reverted to the old `None` / `thread.session_id`, the test fails (left: a random uuid, right: the thread id). Tests (CARGO_BUILD_JOBS=4, cargo test -p codewhale-tui --lib unbound_thread_keeps_one_engine_session_id): with fix: test result: ok. 1 passed; 0 failed fix reverted: test result: FAILED. 0 passed; 1 failed Closes #6659 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
| // A host that records restore points also bounds every call | ||
| // that may write (a shell command, a program, a write-capable | ||
| // MCP tool) so the span it ran in is known; its post-tool | ||
| // snapshot is taken once it returns. | ||
| let bounded_tool = self.config.record_restore_points | ||
| && self.config.snapshots_enabled | ||
| && result_override.is_none() | ||
| && !plan.read_only; | ||
| let mut tool_restore_point = false; |
There was a problem hiding this comment.
🔴 Parallel tool writes cannot be undone
When multi_tool_use.parallel writes files, bounded_tool never brackets its execution. The parallel-tool branch bypasses this path, so patch-undo treats those writes as foreign and refuses undo.
Learn more
The turn loop handles multi_tool_use.parallel before reaching the ordinary tool-call branch. Unlike ordinary write-capable calls, that branch takes no tool or post_tool restore point. attribute_window treats changes outside a bounded tool span as foreign, so a turn that writes through the parallel tool cannot pass patch-undo even when nobody else edited the workspace.
Example: A parallel call writes a.txt and b.txt. The only receipts are pre_turn and post_turn; patch-undo sees both changed paths outside a tool span and returns 409 workspace_changed_since_turn.
Recommended fix: Bound the MULTI_TOOL_PARALLEL_NAME execution branch with tool and post-tool restore points when recording receipts, including its cancellation path. Validate that its nested writes are attributed to the enclosing call.
Was this helpful? React with 👍 or 👎 to provide feedback.
| for source in sources { | ||
| let tracked: Vec<String> = pre_state | ||
| .iter() | ||
| .filter(|(_, id, in_target, _)| *in_target && id == source) | ||
| .map(|(rel, _, _, _)| rel.to_string_lossy().into_owned()) | ||
| .collect(); | ||
| let mut args: Vec<String> = vec![ | ||
| "--literal-pathspecs".to_string(), | ||
| "checkout".to_string(), | ||
| "--end-of-options".to_string(), | ||
| id.as_str().to_string(), | ||
| source.as_str().to_string(), | ||
| "--".to_string(), | ||
| ]; | ||
| args.extend(tracked); |
There was a problem hiding this comment.
🔴 Multi-source undo can leave partial files
When different files need different source snapshots, restore_path_plan checks them out in separate commands. If a later checkout fails, earlier files stay reverted while patch-undo leaves the conversation unchanged.
(Refers to this code)
Learn more
Patch-undo builds one plan with each file's state before its first dropped turn. patch_undo_workspace_files calls this method once, but the method runs one checkout per distinct source tree. A failed later checkout leaves successful earlier checkouts applied despite the returned error. The safety snapshot retains the old files but does not automatically recover them.
Example: Turn one changes a.txt; turn two first changes b.txt. A depth-one undo checks out a.txt successfully, then the checkout for b.txt fails. The response is an error, a.txt is already rolled back, and both turns remain in the original thread.
Recommended fix: Make the multi-source restore atomic from the caller's perspective: stage all resulting files before mutation or automatically roll back every previously applied path from the mandatory safety snapshot on any checkout/removal failure. Test a failure on the second source.
Was this helpful? React with 👍 or 👎 to provide feedback.
| - Runtime API undo now restores the files of the turns it undoes, for every | ||
| kind of thread. Before, a new thread's turns were not linked to their | ||
| workspace snapshots, so `patch-undo` returned `201` and rewound the | ||
| conversation but left the files changed, and `file-revert` refused. Each | ||
| turn record now lists its snapshots in `workspace_snapshots`, the engine runs | ||
| under the thread's own id across restarts, and a fork owns the turns it | ||
| inherited. `patch-undo` restores only the files the undone turns changed, | ||
| including every write in a turn, and leaves later edits by the user or | ||
| another thread alone. Every tool call that may write is bounded by its own | ||
| snapshots, so a file another thread or an editor changed while the turn ran | ||
| is never reverted as the turn's, and a turn's restore points survive the | ||
| snapshot count cap. When it cannot restore files (including a write to a | ||
| gitignored path) it now refuses with `409` and an `error.code` instead of | ||
| returning `201`. A nameless `PUT /v1/sessions` updates the document the | ||
| thread is bound to | ||
| ([#6621](https://github.com/Hmbown/Codewhale/issues/6621)). |
There was a problem hiding this comment.
🔍 Defer changelog entries until merge
Both changelogs contain new PR entries. Contribution guidance reserves these edits for a batched commit on main to avoid merge conflicts.
Was this helpful? React with 👍 or 👎 to provide feedback.
Merge origin/fix/6621-thread-snapshot-ownership into feat/turn-artifacts. Cause: #6660 and #6645 each added a workspace snapshot event and a session identity rule. #6660 sent Event::TurnWorkspaceSnapshots (a pre-turn id plus a watch channel for the post-turn snapshot), stamped restore_snapshot_id into tool result metadata, ran the engine under thread.session_id, and advertised a restore point only when the snapshot tag matched the bound session. #6645 runs every Runtime engine under thread.id and records Event::WorkspaceSnapshotTaken receipts on TurnRecord.workspace_snapshots, which is what file-revert and patch-undo resolve against. Two snapshot authorities and two identity rules. Fix: one snapshot authority, #6645's. TurnWorkspaceSnapshots, WorkspaceSnapshot/SnapshotUnavailable, the wire event, the watch-channel settlement and the tool-metadata restore stamp are removed. The engine identity is thread.id. The turn's pre/post delta now diffs the tree ids of the pre_turn and post_turn receipts recorded on the turn (both arrive before TurnComplete under record_restore_points). restore_snapshot_id is the tree id of a receipt recorded on the thread's own turn: the call's tool receipt for a tool write, the pre_turn receipt for a delta change, so file-revert accepts every id a ref advertises, bound session or not. Unavailable reasons come from the snapshot gate notice keyed by the thread id; settlement_timeout is gone because nothing waits any more. Tests (CARGO_BUILD_JOBS=4, focused): - cargo test -p codewhale-tui --lib (turn artifacts, delta, undo, restore, artifact, protocol_parity filters): test result: ok. 350 passed; 0 failed - cargo test -p codewhale-tui --lib (#6645 ownership/undo tests): test result: ok. 37 passed; 0 failed - cargo test -p codewhale-protocol: 73 passed; 17 passed; 0 failed - node --test crates/tui/tests/runtime_web_client.test.mjs: 37 pass, 0 fail Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…10.1 # Conflicts: # CHANGELOG.md # crates/tui/CHANGELOG.md # crates/tui/src/runtime_api/tests.rs
Reconciles #6640 with #6645 (already merged here): - Engine identity: #6645's rule stands (every Runtime thread's engine runs under thread.id). #6640 had also set the field to the bound document or a derived UUID; git kept both assignments, so the duplicate is dropped. - thread_session_id(thread_id) now returns the thread id itself, so the export document (POST /v1/sessions), the engine's session directory and the thread name one conversation, and the export stays idempotent. The derived-UUID scheme never shipped. - save_current_session keeps #6640's guards (409 when naming another thread's document; live-session refusal) and #6645's target choice (the bound document, else the thread's own id). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
core/engine/approval.rs: #6601 (honest approval timeouts) made an expired approval return ApprovalResult::TimedOut so the engine refunds the call's budget slot and reports a timeout; #6591 (receipts) added a decided_by argument to commit_approval_outcome and passed None for a timeout, while still returning Denied. Kept both: the timeout commits (Timeout, decided_by None) and returns ApprovalResult::TimedOut. snapshot/mod.rs: re-export list; #6645 added TakenSnapshot and #6591 added SnapshotPathChange. Kept both. commands/groups/debug/tests.rs add-only conflict kept both sides. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Each fix below reconciles two PRs that merged textually but not semantically. - snapshot/repo.rs, receipts.rs: #6645/#6682 added SnapshotRepo::changed_paths_between(from, to) -> Vec<PathBuf> (undo, turn artifacts) and #6591 added a different changed_paths_between(from, to, limit) -> (Vec<SnapshotPathChange>, bool) (receipts). Duplicate definition; #6591's is renamed path_changes_between and its one caller (receipts) updated. - core/engine/turn_loop.rs: #6673's repl-fence approval match did not cover #6601's ApprovalResult::TimedOut. A timed-out card now refunds the tool-call budget slot and reports a timeout, like direct and code-mode calls; the audit line records "timeout" instead of "denied". - runtime_api/sessions.rs: #6640's session-owner 409 predates #6645's ApiError.code field; code: None. - tools/verifier.rs: #6671's env-scrub test called run_gate(gate) without the session_id argument run_gate takes on main (#6508). - skills/install.rs + integration harness: #6679 made install.rs read downloads through crate::utils::read_response_body_capped, but the integration harness #[path]-includes install.rs and has no utils module, so the integration test target did not compile (also on the #6679 branch). The capped reader moves to utils/response_body.rs (re-exported from utils, unchanged API) and the harness includes just that file as crate::utils. - Test files where an add-only conflict was auto-resolved by concatenating both sides lost the shared closing lines of the first test: commands/groups/debug/tests.rs (#6682 + #6591), tui/ui/tests.rs (#6635 + main), tools/shell/tests.rs (#6674 + #6679), and runtime_api/tests.rs (#6645 merge in round 1). Restored the missing `}` / `);` so each test is whole again; no assertions were dropped. - tools/shell/tests.rs: #6637's executor test expected `sort * | cat` to be admitted and then fail at run time; with #6675's rule ported into the #6637 lexer (see the #6675 merge), a word-leading unquoted `*` is refused before anything runs. The test now asserts that refusal and still checks the sentinel and option-named files are untouched. - core/engine/tests.rs -> tui/history/tests.rs: #6601's engine test asserted crate::tui::history on the trust warning, raising the runtime->UI test reference ratchet 40 -> 41 (check-command-crate- boundaries FAIL). That assertion moved to a tui::history test on workspace_trust_runtime_message, so the ratchet is back at 40. - scripts/check-blocking-calls-budget.json: runtime_api/git.rs 5 -> 6. #6648 justified this budget in its PR body (working-tree fingerprint reads in sync fns reached only from spawn_blocking); its own branch already has six such sites (the File::open used for hashing), so the recorded 5 was stale. subagent/worktree.rs tightened 7 -> 6. Checks: cargo check --workspace --tests clean (no warnings); cargo test -p codewhale-execpolicy 221+1+5+7+1 passed; cargo test -p codewhale-tui --test integration 187 passed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Closes #6621
Closes #6659
Root cause
A Runtime thread had no lasting identity for its workspace snapshots:
ensure_engine_loadedbuilt every engine withEngineConfig.session_id = None. The engine therefore tagged each pre-turn, tool and post-turn snapshot with a random uuid. The uuid changed on every rebuild (restart, LRU eviction), and nothing recorded it.patch-undoandfile-revertdecided ownership fromThreadRecord.session_id, which is really a link to the saved-session document. It isNoneon a fresh thread, and save, resume and fork rebind it to ids that own no snapshots.The result on a fresh thread:
patch-undoreturned201withfiles_restored: falseand forked the conversation while the files stayed changed, andfile-revertreturned409.Even when the tag matched, the selection was wrong.
patch-undorestored the newest differing snapshot over the whole tree. For a turn with two writes, the first write survived. Unrelated later edits by the user or another thread were reverted.Fix
Engine identity
EngineConfigandSyncSession, across restarts and eviction.Recorded restore points
Event::WorkspaceSnapshotTaken. This is also a new protocolEventMsgkind.await_post_turn_snapshot, so the post-turn snapshot is taken beforeTurnComplete. The TUI keeps its fire-and-forget post-turn snapshot (Post-turn UI freeze: terminal unresponsive for seconds after stream ends before copy/paste/selection works #234).monitor_turnappends each receipt toTurnRecord.workspace_snapshotsand publishes it asturn.workspace_snapshot. Forks clone turn records, so a fork owns the restore points of the turns it inherited.Resolution
patch-undo
409witherror.code:restore_point_unavailablerestore_point_prunedworkspace_changed_since_turnrestore_requires_trustworkspace_unavailable201withfiles_restored: falsenow means there was provably nothing to restore: the turns ran here without tools, or their files are already back.file-revert
toolorpre_turnrestore point recorded on the thread's own turns. Another thread's or a TUI session's snapshot gets409.Docs
docs/RUNTIME_API.mdnow covers ownership, the receipts, the event and the error codes.Behaviour changes a client will see
resume-thread) and pre-upgrade turns that may have changed files have no recorded restore point.patch-undoon them now returns409 restore_point_unavailableinstead of a201that changed nothing. The client should offerPOST /undoinstead./undogaps (whole-tree restore,list(100)cap, fork-inherited restore points) are filed as TUI /undo: path-scoped restore, uncapped lookup, and fork-inherited restore points #6644.workspace_changed_since_turnrather than being reverted.path_not_snapshotted. A shell command declares no paths, so its writes under excluded paths (build output, installs) stay outside what patch-undo restores; the docs say so.Critic points
threads_cannot_restore_each_others_snapshots,patch_undo_helper_refuses_to_overwrite_later_edits).TurnCompleteon the Runtime.thread.id.pre_turnis paired with thepost_turnthat closes it, in FIFO record order. An unclosed window gets409.list(100)cap. Lookup is by tree, so a prune rewrite still resolves (patch_undo_helper_survives_a_prune_that_rewrites_commit_ids).EventMsg. Refusals carry a stableerror.code. The client follow-up is filed.PUT /v1/sessionswith no id updates the thread's bound document (for a resumed thread, the document it came from); only an unbound thread gets a new document named after its thread id.Verification (local macOS, focused)
ensure_engine_loaded, with only the model client scripted (runtime_api::tests::thread_snapshot_ownership). They cover:cargo fmt --checkand clippy (tui and protocol, CI flags) are clean.Review follow-up (6cfe53b)
turn.workspace_snapshotis registered inSTREAM_EVENT_NAMESand applied to its turn'sworkspace_snapshots.tooland newpost_toolsnapshots. Each receipt recordschanged_pathssince the turn's previous one, and a file tool'stoolreceipt records its declaredwrite_paths. Patch-undo keeps a path only if it changed inside one of the turn's tool spans and, in a file tool's span, is a declared path. Anything else changed during the turn refuses withworkspace_changed_since_turn. A span with no recorded changes fails closed withrestore_point_unavailable.git check-ignoreexcludes, or to a path outside the workspace, gets409 path_not_snapshotted.prune_keep_last_nkeeps the newest N snapshots plus the newest Npre-turn:/post-turn:boundaries, so a turn's own restore points survive a burst of more than 50 tool snapshots.path_canonicalizeinruntime_api.rs. Both are indeclared_write_path, which only runs insidepatch_undo_workspace_filesonspawn_blocking. They resolve absolute declared paths through symlinked roots.🤖 Generated with Claude Code