Skip to content

Runtime: turns carry typed artifact references (items, turn aggregate, workspace delta, read routes) - #6660

Open
Hmbown wants to merge 6 commits into
fix/6621-thread-snapshot-ownershipfrom
feat/turn-artifacts
Open

Hmbown wants to merge 6 commits into
fix/6621-thread-snapshot-ownershipfrom
feat/turn-artifacts

Conversation

@Hmbown

@Hmbown Hmbown commented Sep 26, 2026 •

Copy link
Copy Markdown
Owner

Stacked on #6645. This PR now targets fix/6621-thread-snapshot-ownership and merges it (merge commit 34ef01ae4). It follows #6645's identity rule: every Runtime engine runs under thread.id, and a thread owns exactly the restore points recorded on its own turns (TurnRecord.workspace_snapshots). Merge #6645 first.

Reconciled with #6645: one snapshot authority

  • Removed: Event::TurnWorkspaceSnapshots, WorkspaceSnapshot/SnapshotUnavailable, the turn_workspace_snapshots wire event, the watch-channel settlement, and the restore_snapshot_id/restore_snapshot_session_id stamp in tool result metadata.
  • Engine identity: the Runtime engine's session_id is thread.id, not thread.session_id.
  • Delta source: the turn's pre/post delta diffs the tree ids of the pre_turn and post_turn receipts recorded on the turn. With record_restore_points, both arrive before TurnComplete, so nothing waits on a post-turn task. settlement_timeout is gone. pre_turn_snapshot_id/post_turn_snapshot_id are tree ids, because a prune rewrites commit ids.
  • Restore points: restore_snapshot_id is the tree id of a receipt recorded on the thread's own turn. A tool write gets its call's tool receipt and a delta change gets the turn's pre_turn receipt. File-revert accepts every id a ref advertises, whether or not the thread is bound to a saved session. A tool's own result metadata can no longer name a restore point.
  • Unavailable reasons: these come from the receipts plus the snapshot gate notice keyed by the thread id: snapshots_disabled, gate reasons, snapshot_failed (a pre-turn receipt with no post-turn one) or not_captured (no receipts).

Items 1 and 2 below and the "Restore point gating" and "Settlement bound" review points describe the pre-reconcile design. The section above supersedes them.

Closes #6653

Summary

Runtime turns now carry typed artifact references for what they produced. The desktop Preview can show a turn's files and large outputs without the user hunting for them.

This reuses the existing stores: the snapshot side repo and the session artifact directory. The engine is still the only process that takes snapshots; the runtime only reads the side repo.

The PR lands as four ordered commits, and the build was green after each one:

  1. Producers record facts; items carry refs (9c763e068).
    • File tools and apply_patch add size/sha256 to each mutation.files[] entry.
    • The classic spill is published immutably and records artifact_digest.
    • A file-mutating result names its tool:<call> restore snapshot and that snapshot's session tag.
    • The runtime parses these into TurnItemRecord.artifacts. Refs are confined, and forged or unconfined paths produce no ref.
    • artifact_refs is now a derived projection holding only workspace file paths, so the pinned app's Preview and transcript cards get data with no app change and no engine-pin bump.
  2. Workspace delta settles into the turn aggregate (a920c4866).
    • The engine emits TurnWorkspaceSnapshots (pre-turn result plus a watch receiver for the post-turn snapshot) immediately before TurnComplete. TurnComplete stays non-blocking (Post-turn UI freeze: terminal unresponsive for seconds after stream ends before copy/paste/selection works #234).
    • SnapshotRepo::diff_snapshots / tracked_paths are read-only.
    • One merge_turn_artifacts function computes TurnRecord.artifacts.
    • TurnRecord.workspace goes pending → settled | unavailable(reason), and turn.artifacts is published on every outcome.
    • New route: GET /v1/threads/{id}/turns/{turn_id}/artifacts.
  3. Read by reference through one resolver (fae4d82cf).
    • New route: GET …/artifacts/{artifact_id}?offset=&limit=&revision=.
    • Files are served from the workspace when still current, otherwise from the post-turn snapshot, otherwise 409.
    • Spills and media go through resolve_session_artifact, which is now shared by the session route and the turn route.
    • Spills from unbound runtime threads were always 404 before; they are now readable.
  4. Docs and CHANGELOG (c5fe3ff95): the "Turn artifacts" section in docs/RUNTIME_API.md, the endpoint list, events and data model.

Review points addressed

  • Restore point gating. A restore point is published only when the snapshot's actual session tag equals the thread's bound session. The engine result carries restore_snapshot_session_id.
    • Root fix: runtime engines for bound threads now start under the bound session id (EngineConfig.session_id). Before, a first turn skipped SyncSession and tagged its snapshots with a random id that file-revert refused.
  • Honest delta label. The delta source is workspace_changed_during_turn. The docs say it includes concurrent writers (editor, other threads, background jobs).
  • Invisible paths. Excluded and ignored paths are documented as invisible to the delta.
    • A file tool's write to such a path keeps its receipt-derived ref. The merge probes tracked_paths so it never mistakes "invisible" for "unchanged".
    • Gate refusals carry their own reasons: workspace_too_large, too_many_files, unsafe_location.
  • Every TurnComplete path. Every engine path that took (or tried) a pre-turn snapshot emits the pair: shell turns, the no-client failure, and completed, failed or interrupted model turns.
    • Paths that never snapshot (compaction, purge, rejected edit) and monitor failures get unavailable(not_captured). A restart gets unavailable(runtime_restarted).
    • protocol_parity.rs maps the new event to a turn_workspace_snapshots wire twin.
  • Spill root. The turn route reads through artifacts::artifact_sessions_root(), the same function the writer uses. The classic spill is now immutable, as adaptive evidence already was.
  • Settlement bound. Settlement is keyed to the post-turn snapshot task finishing (value or closed channel). A 10-minute bound only guards a hung git process.
  • Ordering. The aggregate is sorted most recent first, not by path.
  • Follow-up filed: Runtime: unbound threads get a new engine session id on every spawn #6659 (unbound threads get a new engine session id on every spawn).

Out of scope

  • App-side consumption of the typed artifacts and the turn routes. That needs an engine-pin bump, which is a founder decision; the legacy projection already fills the pinned app's cards for file writes.
  • Sub-agent child-session spills.
  • Per-command shell attribution. Shell writes are reported at turn level only, and only when snapshots are enabled.
  • Stable session ids for unbound threads (Runtime: unbound threads get a new engine session id on every spawn #6659).

Testing

Local, with CARGO_BUILD_JOBS=3 via scripts/dev-cargo.sh / scripts/dev-test.sh. The counts are real:

  • cargo fmt --all -- --check: pass.
  • cargo clippy -p codewhale-tui -p codewhale-protocol --all-targets --all-features --locked -- -D warnings -A clippy::uninlined_format_args -A clippy::too_many_arguments -A clippy::unnecessary_map_or: pass. Only the touched crates were run, not --workspace.
  • Focused tui tests before the merge: 2659 passed, 6 ignored. After merging origin/main: 1557 passed, 6 ignored. The areas covered were runtime_threads, runtime_api, core::engine, core::turn, protocol_parity, snapshot, tools::truncate/file/apply_patch and artifacts. codewhale-protocol: 73 + 17 passed.
  • New tests cover:
    • receipt parser and forgeries;
    • merge rules (created+deleted, created+updated, delta authority, net-zero drop, invisible path kept, cap);
    • diff_snapshots (created with a shell-style write, updated, deleted, renamed, over 16 MiB, bounded) and read_blob;
    • the engine emitting the pair before TurnComplete and the restore-point stamp;
    • runtime settlement with a real side repo, disabled, timeout and restart reconciliation;
    • list and read routes: workspace, snapshot, ?revision=, 409/410/413/403/404, and a spill with no SavedSession.
  • Gates:
    • check-blocking-calls-budget, check-dead-code-budget, check-command-crate-boundaries, split/module_graph.py --check: pass.
    • check-runtime-contract-budget: 55/55 at budget.
    • sync-changelog.sh and derive-changelog: ran. derive-install could not run because web node_modules are not installed locally.
    • check-versions --range-audit-advisory: OK. check-contributor-credit v0.10.0: OK.
  • The full cargo test --workspace suite was not run locally; CI owns it.

Not done: the manual live check. I did not run codewhale serve with a real model turn (apply_patch + shell write + large output, then curl the routes), and I did not check the pinned desktop app's Preview cards. Both need a real provider call, which is provider spend. The route behaviour is covered by the loopback API test against a real snapshot repo.

Checklist

  • New module runtime_threads/turn_artifacts.rs replaces the never-filled artifact_refs authority (now derived). runtime_api/turn_artifacts.rs shares the session resolver, and the session route's inline read logic moved into it.
  • Updated docs (docs/RUNTIME_API.md, CHANGELOG)
  • Added tests
  • Verified TUI behavior manually (TUI only ignores the new event; no UI change)

🤖 Generated with Claude Code

Copilot AI lite review requested due to automatic review settings September 26, 2026 19:31
@Hmbown Hmbown added this to the v0.10.1 milestone Sep 26, 2026

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.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 26, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-26T19:45:54.197840Z 7f3fbb3 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7f3fbb3fa2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

fn item_artifact_refs(&self, turn: &TurnRecord) -> Vec<TurnArtifactRef> {
turn.item_ids
.iter()
.filter_map(|item_id| self.store.load_item(item_id).ok())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Move artifact item reads off the Tokio worker

When a turn completes, async monitor_turn calls set_turn_artifacts, which reaches this synchronous load_item once per item; on a slow runtime-store disk or a tool-heavy turn, these filesystem reads block a Tokio worker and delay unrelated runtime/API work. Collect the item refs under spawn_blocking or carry forward the refs already observed by the monitor.

AGENTS.md reference: AGENTS.md:L164-L170

Useful? React with 👍 / 👎.

fn item_artifact_refs(&self, turn: &TurnRecord) -> Vec<TurnArtifactRef> {
turn.item_ids
.iter()
.filter_map(|item_id| self.store.load_item(item_id).ok())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Propagate referenced-item load failures

When an item referenced by turn.item_ids is missing or corrupt, .ok() silently drops that item, so both the list route and terminal settlement publish an incomplete artifact aggregate—and may even mark it settled—rather than reporting the broken store referent. Make this helper return Result and propagate or explicitly record the failure.

AGENTS.md reference: AGENTS.md:L79-L81

Useful? React with 👍 / 👎.

Comment on lines +186 to +189
let live = match precheck_file_target(&root, &relative)? {
Some(_) => Some(read_confined_bytes(&open_confined_file(
&root, &relative, false,
)?)?),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Fall back before reading an oversized current file

When a recorded artifact was at most 16 MiB but the workspace path is later replaced by a file larger than that limit, read_confined_bytes returns 413 here before the post-turn snapshot is consulted. The retained, small historical revision therefore becomes unreadable even though the route promises to fall back to the snapshot whenever the live file no longer holds the recorded revision; inspect the live size without failing the request, then try the snapshot.

Useful? React with 👍 / 👎.

Comment on lines +247 to +249
Err(error) => {
tracing::debug!(%error, "snapshot blob read failed");
Ok(None)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Surface snapshot-store failures instead of reporting drift

When read_blob fails because Git is unavailable, the side repository is corrupt, or access is denied, this branch treats the failure exactly like a pruned snapshot and the caller returns 409 claiming the recorded revision is gone. Only suppress the specific unknown/pruned-object case; operational snapshot-store errors should remain 5xx so clients do not mistake infrastructure failure for irreversible artifact loss.

Useful? React with 👍 / 👎.

Comment on lines +654 to +658
if let Some(item) = files.remove(&reference.path) {
reference.item_id = item.item_id;
reference.tool_call_id = item.tool_call_id;
reference.tool_name = item.tool_name;
reference.source = TurnArtifactSource::ToolMutation;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Retain delta provenance when receipt bytes do not match

When a file tool writes a path and a shell command, editor, or sub-agent changes that same path later in the turn, the delta reference describes the later bytes, but this block unconditionally copies the tool item identity and relabels the source as tool_mutation based only on the path. This attributes the final content to the wrong tool; only adopt item provenance when the receipt and delta revisions establish that they describe the same output, otherwise retain workspace_changed_during_turn.

Useful? React with 👍 / 👎.

Comment on lines +665 to +669
artifacts.extend(
files
.into_values()
.filter(|item| !delta.tracked_item_paths.contains(&item.path)),
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Drop net-zero renames by checking their original path

When a tool renames a to b and a shell command or sub-agent moves b back to a before the post-turn snapshot, the pre/post delta is empty and tracked_item_paths contains the rename's previous_path (a) but not its destination (b). This filter checks only item.path, so the aggregate keeps a stale rename reference to nonexistent b; consider both the current and previous paths when deciding that a tracked item change netted to zero.

Useful? React with 👍 / 👎.

Comment on lines +92 to +94
match component {
Component::Normal(name) if name != ".git" => parts.push(name.to_str()?),
_ => return None,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject artifact paths that the read route cannot accept

On Unix, a filename such as logs\\run.txt is a normal path component here and is published as a delta artifact, but the artifact reader passes it through relative_request_path, which rejects every backslash with 400 before attempting either the workspace or snapshot. This creates references that can never be opened; omit and count such paths here, or make the read-path representation support them consistently.

Useful? React with 👍 / 👎.

workspace: turn.workspace,
artifacts,
item_artifacts,
thread_workspace: thread.workspace,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Pin each turn to the workspace it captured

After a completed thread is updated to use a different workspace, this view resolves every historical file reference against the thread's new workspace rather than the workspace whose snapshots and receipts produced the turn. Old artifacts can consequently return unrelated live bytes, fail to find their post-turn snapshot, or become unreadable; the settlement worker has the same race because it reloads the mutable thread workspace. Persist the turn-time workspace identity and use it for both settlement and reads.

Useful? React with 👍 / 👎.

Comment on lines +1328 to +1331
let (summary, relative_path, evidence) = if image_handle {
let (summary, evidence) = image_evidence_summary(sessions_dir, id, artifact_id)?;
let path = summary.path.clone();
(Some(summary), path, Some(evidence))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Map pruned media manifests to the recorded-artifact gone state

When a turn-recorded media artifact's session directory has been pruned, its evidence manifest is missing, so this call returns 404 before the later ArtifactAuthority::TurnRef missing-file mapping can produce 410. The turn record already proves that the artifact ID existed, and non-image turn artifacts in the same situation return 410; map a not-found manifest to gone for turn authority while retaining the saved-session route's existing 404 behavior.

Useful? React with 👍 / 👎.

Hmbown and others added 5 commits September 26, 2026 19:45
Refs #6653. This is the first of four slices.

Producers now record what they wrote, at the point where they write it:
- File tools and apply_patch add `size` and `sha256` to each created,
  updated or renamed `mutation.files[]` entry. Deleted entries get neither.
- The classic spill publishes immutably (like adaptive evidence) and
  records `artifact_digest`.
- A file-mutating call's result names its `tool:<call>` restore snapshot
  and the session that snapshot is tagged with.

The runtime parses those receipts into `TurnItemRecord.artifacts`:
- Each ref is confined, typed and carries size and revision; forged or
  unconfined paths produce no ref.
- The legacy `artifact_refs` is now derived from `artifacts` and holds
  only workspace file paths, so the pinned app's Preview gets data without
  a contract change.
- A restore point is published only when the snapshot's session tag
  equals the thread's bound session.
- Bound threads now start their engine under the bound session id, so a
  first turn's restore points are ones file-revert accepts.

Tests: scripts/dev-test.sh tui with focused filters. 88 passed (new
parser, pump, engine restore-point, file and apply_patch receipt tests);
55 passed (truncate, artifacts, tool_result_retrieval); 990 passed, 6
ignored (runtime_threads::, runtime_api::, core::engine::tests).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Refs #6653. This is the second of four slices.

Engine:
- Every turn that took (or tried to take) a pre-turn snapshot now emits
  `TurnWorkspaceSnapshots` immediately before `TurnComplete`. This covers
  shell turns, the no-client failure, and every completed, failed or
  interrupted model turn.
- The event carries the pre-turn result and a watch receiver for the
  post-turn snapshot, which still runs off the engine loop, so
  `TurnComplete` stays non-blocking (#234).
- Snapshot helpers now return a typed reason for a missing snapshot
  (disabled, too large, too many files, unsafe location, failed) instead
  of `None`.
- The event has a protocol twin, `turn_workspace_snapshots`.

Snapshot repo: two read-only additions.
- `diff_snapshots` uses one `diff-tree -M` pass and one `cat-file --batch`
  pass to report the size and SHA-256 of each changed file. The result is
  bounded, and it counts what it omits.
- `tracked_paths` tells a path that did not change apart from one the
  snapshots cannot see.

Runtime:
- `TurnRecord` gains `artifacts` and `workspace`. One function,
  `merge_turn_artifacts`, computes the aggregate from item refs, plus the
  delta once it settles. The delta is labelled
  `workspace_changed_during_turn`.
- The settlement task is keyed to the post-turn snapshot finishing, with
  a 10-minute bound for a hung git. It publishes `turn.artifacts` on every
  outcome.
- Monitor failure without a pair gives `unavailable(not_captured)`, and a
  restart gives `unavailable(runtime_restarted)`.
- New route: `GET /v1/threads/{id}/turns/{turn_id}/artifacts`.

Tests: scripts/dev-test.sh tui with focused filters. 283 passed, 1 failed
(the oversized-blob fixture used a *.bin name, which snapshots exclude);
snapshot::delta then passed 3/3 after renaming it, and the engine pair
ordering test passed 1/1; 2383 passed, 6 ignored (runtime_threads::, runtime_api::,
core::engine::, core::turn::, protocol_parity, snapshot::, config
commands, tui::ui). codewhale-protocol: 73 + 17 passed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…lver

Refs #6653. This is the third of four slices.

New route: `GET /v1/threads/{id}/turns/{turn_id}/artifacts/{artifact_id}`,
with `?offset=&limit=&revision=`. It uses the workspace file read's window
contract, plus `artifact`, `source` and `current`.

- **Files** are read from wherever the recorded revision still lives:
  1. the live workspace (confined, no-follow, `.git` refused);
  2. otherwise the turn's post-turn snapshot, via `SnapshotRepo::read_blob`;
  3. otherwise 409, naming the current revision.

  Deleted files return 410, files over 16 MiB return 413, and symlinks
  return 403. `?revision=` selects an intermediate revision recorded by an
  item.
- **Spills and media** go through `resolve_session_artifact`, which now
  serves both the session route and the turn route. Both share the
  session-id validation, confinement, image-manifest and integrity checks.
  - The turn route roots its reads at the root the artifact writer uses,
    so a configured `sessions_dir` cannot point a read at another tree.
  - The turn ref proves ownership, so spills from unbound runtime engines
    are readable without a SavedSession index. Before this they were
    always 404.
  - A hash mismatch returns 409, and pruned bytes return 410.

Tests: scripts/dev-test.sh tui with focused filters. 14 passed, 1 failed
on the first run (the fixture never wrote the mismatched spill); the
route test then passed 1/1 after writing it. snapshot::delta: 4/4.
The existing session_artifacts and tool_media_artifact tests passed
unchanged after the resolver refactor.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… pump

Refs #6653. This is the fourth of four slices.

docs/RUNTIME_API.md:
- New "Turn artifacts" section: the ref shape, how the aggregate is
  merged, the `workspace.state` lifecycle and each reason, and what the
  delta cannot see. It is a workspace diff: concurrent writers are
  included, and excluded or ignored paths are invisible. Shell writes are
  recorded per turn, never per call.
- Documents both routes and their error table, the `turn.artifacts` event,
  and the new record fields.
- The session-artifact section now points runtime-turn spills at the turn
  route.
- The embedded-client paragraph no longer claims that no preview or
  artifact contract exists.

CHANGELOG entry under Unreleased. crates/tui/CHANGELOG.md and
web/lib/changelog.generated.ts were regenerated by sync-changelog.sh and
derive-changelog.mjs.

The pump now canonicalizes the thread workspace once, with
`tokio::fs::canonicalize`. Previously it called `Path::canonicalize` for
each receipt path on the async worker (check-blocking-calls-budget).

Gates run:
- cargo fmt --check: pass.
- clippy with CI flags (-p codewhale-tui -p codewhale-protocol,
  --all-targets --all-features): pass.
- check-blocking-calls-budget, check-dead-code-budget,
  check-command-crate-boundaries, split/module_graph --check,
  check-runtime-contract-budget (55/55 metrics at budget): pass.
- sync-changelog.sh, derive-facts and derive-changelog: ran.
  derive-install could not run because web node_modules are not
  installed here (missing `marked`); the changelog does not feed it.
- check-versions --range-audit-advisory: OK.
- check-contributor-credit v0.10.0: OK.

Tests: scripts/dev-test.sh tui with focused filters. 12/12 passed for
the artifact tests, and a broad sweep passed 2659, with 6 ignored
(runtime_threads::, runtime_api::, core::engine::, core::turn::,
protocol_parity, snapshot::, config commands, tui::ui, tools::truncate,
tools::file, tools::apply_patch, artifacts::, tool_result_retrieval).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…settle test

The web client's stream vocabulary lacked turn.artifacts, failing the
version-drift vocabulary check (33 vs 34). The settle test read events right
after observing the Settled turn, but the event is emitted after the save, so
it raced on the Windows runner.

node --test runtime_web_client: 36 pass / 0 fail; cargo test turn_workspace_delta: 1 passed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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
Hmbown changed the base branch from main to fix/6621-thread-snapshot-ownership September 27, 2026 09:13

@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 5 potential issues.

Devin Review

Comment on lines +186 to +191
let live = match precheck_file_target(&root, &relative)? {
Some(_) => Some(read_confined_bytes(&open_confined_file(
&root, &relative, false,
)?)?),
None => None,
};

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.

🔴 Historical files blocked by current paths

When a recorded file becomes a symlink, read_file_artifact returns 403 before checking its snapshot. A later directory or oversized file similarly blocks historical bytes that remain available.

Learn more

A file artifact identifies the bytes present during a turn. The read route prefers the live file when its hash matches and otherwise reads the turn's post-turn snapshot. precheck_file_target rejects symlinks and directories, and read_confined_bytes rejects files above 16 MiB. Here those errors return before the snapshot fallback, even when the snapshot contains the recorded revision.

Example: A turn writes page.html with revision A. Another process replaces page.html with a symlink. The post-turn snapshot still contains revision A, but reading the turn's artifact returns 403 rather than A.

Recommended fix: Treat an unreadable or disallowed current path as unavailable for the live-revision comparison. Attempt the recorded snapshot first for references with a revision; preserve the live-path error if no valid snapshot matches, where appropriate.

Devin Review


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

"file is larger than the {FILE_SERVE_MAX_BYTES}-byte serving limit"
)));
}
let relative = relative_request_path(&artifact.path, 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.

🟡 Spaced filenames open the wrong artifact

When a recorded filename has surrounding spaces, relative_request_path trims artifact.path before opening it. The artifact then returns another file's bytes or fails, despite its recorded path being valid.

Learn more

Tool receipts and snapshot deltas retain literal filenames, including leading and trailing spaces; workspace_relative_path explicitly accepts them. The shared request-path parser trims strings before building its path, so feeding it a stored reference changes the file being addressed. A snapshot fallback also receives the untrimmed name, but the live-path check can read another file and return an unrelated error first.

Example: A tool creates notes.txt and records its SHA-256. Reading its file_... id tests notes.txt in the workspace, rather than notes.txt, and returns a conflict or missing-file response when no matching snapshot is available.

Recommended fix: Validate stored artifact paths without trimming them, while retaining the existing confinement and .git checks. Keep the request-path normalization for user-supplied workspace paths separate from literal recorded filenames.

Devin Review


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

Comment on lines +646 to +650
artifacts.extend(
files
.into_values()
.filter(|item| !delta.tracked_item_paths.contains(&item.path)),
);

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.

🟡 Truncated delta drops recorded tool files

When a changed tool file falls past the delta limit, merge_turn_artifacts removes its item reference because tracked_item_paths contains it. The turn's artifact list omits a recorded file even when capacity remains after merging.

Learn more

The snapshot diff is bounded to MAX_TURN_ARTIFACTS, while workspace_delta independently probes every item-reported path for presence in either snapshot. A tracked path missing from the bounded diff can therefore be either unchanged or omitted after the diff reached its limit. The merge treats both cases as unchanged and drops the item reference.

Example: A turn changes 1,001 files, including one tool-written z.txt that falls after the first 1,000 diff entries. z.txt is tracked, so its item reference is removed even if duplicate delta paths leave room in the final aggregate.

Recommended fix: When delta.truncated is true, do not use absence from delta.refs to prove an item path had no change. Retain its item reference or probe its exact pre/post blobs before dropping it, and account for any later aggregate cap in workspace.omitted.

Devin Review


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

Comment thread CHANGELOG.md
Comment on lines +29 to +43
- Runtime API: turns now record what they produced. Each item and turn
carries typed artifact references (path, kind, size, revision, and a
restore point when file-revert would accept one) for files a tool wrote,
spilled tool output, and media.
- Once the post-turn snapshot settles, the turn also lists what changed in
the workspace while it ran, including shell and sub-agent writes. It then
publishes `turn.artifacts`.
- `GET /v1/threads/{id}/turns/{turn_id}/artifacts` lists a turn's
references, and `.../artifacts/{artifact_id}` reads one from the
workspace, the post-turn snapshot or the session artifact directory.
- Spills from unbound runtime threads were unreadable before; they are now
readable.
- The legacy `artifact_refs` field is now filled with the workspace files a
tool call wrote, so Preview in current desktop builds shows them
([#6653](https://github.com/Hmbown/Codewhale/issues/6653)).

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.

🔍 Leave changelog entries for merge time

Both changelog additions conflict with the repository's merge-time changelog workflow. They create avoidable conflicts with other PRs; leave the release note for the batched main-branch update.

Devin Review


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

"turn.steered",
"turn.steer_dropped",
"turn.interrupt_requested",
"turn.artifacts",

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.

🔍 Subscribed artifact event has no consumer

The browser subscribes to turn.artifacts, but applyRuntimeEvent leaves its cached turn unchanged. This page has no artifact view; investigate whether the subscription is needed before adding one.

Devin Review


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

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 turns carry no artifact references: Preview can't show what a turn produced

2 participants