Skip to content

fix(runtime): threads own the restore points on their turns; undo restores or refuses (#6621) - #6645

Open
Hmbown wants to merge 5 commits into
mainfrom
fix/6621-thread-snapshot-ownership
Open

Hmbown wants to merge 5 commits into
mainfrom
fix/6621-thread-snapshot-ownership

Conversation

@Hmbown

@Hmbown Hmbown commented Sep 26, 2026 •

Copy link
Copy Markdown
Owner

Closes #6621
Closes #6659

Root cause

A Runtime thread had no lasting identity for its workspace snapshots:

  • ensure_engine_loaded built every engine with EngineConfig.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-undo and file-revert decided ownership from ThreadRecord.session_id, which is really a link to the saved-session document. It is None on a fresh thread, and save, resume and fork rebind it to ids that own no snapshots.

The result on a fresh thread: patch-undo returned 201 with files_restored: false and forked the conversation while the files stayed changed, and file-revert returned 409.

Even when the tag matched, the selection was wrong. patch-undo restored 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

  • 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, so rebinding it no longer creates a conversation boundary partway through a thread.

Recorded restore points

  • The engine reports each snapshot it takes as Event::WorkspaceSnapshotTaken. This is also a new protocol EventMsg kind.
  • The Runtime sets await_post_turn_snapshot, so the post-turn snapshot is taken before TurnComplete. 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_turn appends each receipt to TurnRecord.workspace_snapshots and publishes it as turn.workspace_snapshot. Forks clone turn records, so a fork owns the restore points of the turns it inherited.

Resolution

  • A restore point resolves by tree id, session tag and label kind against the full, uncapped store listing.
  • The snapshot prune runs after every snapshot once there are more than 50. It rewrites every commit id but keeps trees, so the tree id is what holds up.

patch-undo

  • It undoes whole turns. For each dropped turn's pre-to-post window it takes the changed paths, and restores only those paths to their content before the first dropped turn.
  • Later edits by the user or another thread in the same workspace survive.
  • It refuses when a path changed after the turns or between them.
  • It never forks over files it could not restore. Instead it returns 409 with error.code:
    • restore_point_unavailable
    • restore_point_pruned
    • workspace_changed_since_turn
    • restore_requires_trust
    • workspace_unavailable
  • 201 with files_restored: false now means there was provably nothing to restore: the turns ran here without tools, or their files are already back.

file-revert

  • It accepts only a tool or pre_turn restore point recorded on the thread's own turns. Another thread's or a TUI session's snapshot gets 409.

Docs

  • docs/RUNTIME_API.md now covers ownership, the receipts, the event and the error codes.

Behaviour changes a client will see

  • Imported (resume-thread) and pre-upgrade turns that may have changed files have no recorded restore point. patch-undo on them now returns 409 restore_point_unavailable instead of a 201 that changed nothing. The client should offer POST /undo instead.
  • The client adoption work is tracked in Hmbown/codewhale-app#127.
  • The TUI /undo gaps (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.
  • A path changed during a dropped turn outside the turn's own tool spans (another thread, an editor, a background job) now refuses the undo with workspace_changed_since_turn rather than being reverted.
  • A file-tool write to a gitignored, built-in-excluded or out-of-workspace path refuses with 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

  1. Whole-tree restore and isolation: fixed. The restore is limited to the paths the turn changed, and refuses paths changed since (threads_cannot_restore_each_others_snapshots, patch_undo_helper_refuses_to_overwrite_later_edits).
  2. Post-turn receipts: recorded, and awaited before TurnComplete on the Runtime.
  3. One identity rule: the engine id is always thread.id.
  4. Restore-point selection: the earliest pre_turn is paired with the post_turn that closes it, in FIFO record order. An unclosed window gets 409.
  5. Pruned-snapshot detection: no list(100) cap. Lookup is by tree, so a prune rewrite still resolves (patch_undo_helper_survives_a_prune_that_rewrites_commit_ids).
  6. Client contract: receipts appear in the thread/turn JSON, the SSE stream and the protocol EventMsg. Refusals carry a stable error.code. The client follow-up is filed.
  7. The TUI still uses its own selection; that is filed as TUI /undo: path-scoped restore, uncapped lookup, and fork-inherited restore points #6644 and is not claimed as fixed here.
  8. A receipt that fails to save is logged at warn. The turn is then left without that restore point and fails closed.
  9. Nameless save: a PUT /v1/sessions with 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)

  • 8 new HTTP tests with real engines built by ensure_engine_loaded, with only the model client scripted (runtime_api::tests::thread_snapshot_ownership). They cover:
    • a fresh thread (new file plus existing file, two writes in one turn)
    • depth=1 and chained undo
    • fork and fork-at-turn
    • eviction and restart
    • save, rebind and resume
    • cross-thread and TUI snapshots
    • file-revert from turn records
    • honest refusals: snapshots off, legacy seeded turn, pruned, untrusted
  • Rewritten patch-undo and file-revert helper tests.
  • Focused selection: 134 passed, 0 failed.
  • fork/undo/sync_session/session_id/evict/snapshot/resume: 521 passed (1 ignored).
  • core::engine, core::turn and protocol_parity: 633 passed.
  • runtime_threads: 263 passed.
  • codewhale-protocol: 90 passed.
  • cargo fmt --check and clippy (tui and protocol, CI flags) are clean.
  • The blocking-calls, dead-code, command-boundary and module-graph gates pass.
  • Changelog sync/derive, check-versions, contributor credit, and web public-copy (6 passed) are all green.
  • The full suite has not been run locally; it runs in CI.

Review follow-up (6cfe53b)

  • Web client / red CI: turn.workspace_snapshot is registered in STREAM_EVENT_NAMES and applied to its turn's workspace_snapshots.
  • Cross-thread attribution: the Runtime engine bounds every tool call that may write (not read-only: file tools, shell, programs, write-capable MCP) with tool and new post_tool snapshots. Each receipt records changed_paths since the turn's previous one, and a file tool's tool receipt records its declared write_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 with workspace_changed_since_turn. A span with no recorded changes fails closed with restore_point_unavailable.
  • Unsnapshottable writes: a declared write (from receipts or turn items; failed or canceled calls excluded) to a path git check-ignore excludes, or to a path outside the workspace, gets 409 path_not_snapshotted.
  • Pruning: prune_keep_last_n keeps the newest N snapshots plus the newest N pre-turn:/post-turn: boundaries, so a turn's own restore points survive a burst of more than 50 tool snapshots.
  • Nameless save: see point 9 above.
  • Blocking-call budget: raised by 2 path_canonicalize in runtime_api.rs. Both are in declared_write_path, which only runs inside patch_undo_workspace_files on spawn_blocking. They resolve absolute declared paths through symlinked roots.
  • Verification (local, focused): 2037 passed, 0 failed, 8 ignored across snapshot, undo, session, fork, evict, resume, revert, protocol_parity, core::turn, core::engine, runtime_threads and turn_loop. That run includes the new HTTP tests: concurrent writer refused, a bash write undone, gitignored write refused, a turn over the snapshot cap still undoes, nameless save of a resumed thread. It also includes the new helper and prune tests. The web client test passes 37/37. Clippy (tui and protocol, CI flags) and fmt are clean. The lint gates pass, and the changelog pipeline is green. The full suite has not been run locally; it runs in CI.

🤖 Generated with Claude Code

Copilot AI lite review requested due to automatic review settings September 26, 2026 15:56
@Hmbown Hmbown added this to the v0.10.1 milestone Sep 26, 2026
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Hmbown pushed a commit that referenced this pull request Sep 26, 2026
…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>
CodeWhale Bot and others added 3 commits September 26, 2026 19:44
…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>
@Hmbown
Hmbown force-pushed the fix/6621-thread-snapshot-ownership branch from 6cfe53b to ff10c26 Compare September 27, 2026 02:55
…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>

@devin-ai-integration devin-ai-integration Bot 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.

Devin Review found 3 potential issues.

Devin Review

Comment on lines +4473 to +4481
// 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;

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.

🔴 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.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +1145 to 1158
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);

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.

🔴 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.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread CHANGELOG.md
Comment on lines +63 to +78
- 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)).

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.

🔍 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.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Hmbown pushed a commit that referenced this pull request Sep 27, 2026
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>
Hmbown pushed a commit that referenced this pull request Sep 27, 2026
…10.1

# Conflicts:
#	CHANGELOG.md
#	crates/tui/CHANGELOG.md
#	crates/tui/src/runtime_api/tests.rs
Hmbown pushed a commit that referenced this pull request Sep 27, 2026
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>
Hmbown pushed a commit that referenced this pull request Sep 27, 2026
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>
Hmbown pushed a commit that referenced this pull request Sep 27, 2026
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>

This branch has not been deployed

No deployments
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.

Runtime: unbound threads get a new engine session id on every spawn Bind fresh HTTP threads to their live snapshot session for file undo

2 participants