feat(sessions): project per-message model and session transcript path - #3089
devin-ai-integration[bot] wants to merge 5 commits into
Conversation
|
I'll fix CI failures and address comments from users with write access. I'll skip comments containing "(aside)".
|
|
There was a problem hiding this comment.
🔴 Relocated transcripts block new session messages
When a transcript moves, transcript_path changes on new records while the session retains its old path. Session reconciliation rejects both paths, so new messages remain unprojected.
(Refers to this code)
Learn more
A session row reconciles fields from every projected observation. reconcile_optional rejects two different non-null transcript paths. The new path on every record makes a renamed or relocated transcript disagree with the path already stored for that session. The projection then fails for newly appended messages, even though the source scan admitted them.
Example: A Claude session initially projects from /home/a/session.jsonl. Moving its transcript to /home/b/session.jsonl and appending a message makes the new observation project /home/b/session.jsonl; reconciliation conflicts with /home/a/session.jsonl, so the appended message does not appear in search.
Recommended fix: Give transcript-path changes an explicit relocation policy in the session reconciliation and source-transition paths. Preserve idempotence when the old and new files overlap, and test a move followed by an appended message.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Analysis — the conflict is real but the contract is deliberate and pre-existing on master; this PR only gives it a value to observe:
reconcile_optionaltreats two different non-null values as aSessionReconcileConflict→SessionOutputCollision— fortranscript_paththat rule was already wired atstate.rs:1343on master (git diffshows this file is untouched by the PR). The pinned testsession_reconcile_conflict_names_the_field_without_exposing_its_valueasserts "different transcript identities must not merge": divergent paths are read as evidence that two distinct sessions share an id, and the merge fails closed rather than silently re-homing a session.- The identical hazard already exists on master for
project_path/location_path: any host whose location fact changes mid-stream conflicts the same way. What's new is only exposure —transcript_pathwas alwaysNone, so the arm never fired. - Reachability is narrower than the example suggests: for claude the filename is the session id, so a moved file usually implies a new session id (no collision); codex rollouts under
sessions/are append-only andresumeforks to a new file + new id. The dangerous case is a path change under a stable session id — real for hosts with relocatable stores (e.g. pi agent-dir relocation, archived rollouts) but an edge, not the common path. - The recommended fix (relocation policy: keep-first / last-wins / re-home, with move-then-append idempotence) changes the reconcile invariant and its pinning test — that's product intent, so I'm keeping the contract unchanged in this PR and flagging the decision to the owner rather than unilaterally weakening identity reconciliation. Happy to implement whichever policy is chosen as follow-up.
| facts.push(session_fact(Some(header.cwd), None, timestamp)); | ||
| facts.push(session_fact( | ||
| Some(header.cwd), | ||
| transcript_path.map(str::to_owned), |
There was a problem hiding this comment.
🟡 UUID transcripts return unusable file paths
For UUID-named transcripts, transcript_path reaches the session row with its filename redacted. Callers cannot open the returned path to read the transcript.
Learn more
The new session path goes into a canonical observation before observation sanitization. The sanitizer replaces high-entropy filename spans with [TraceDecay redacted: high-entropy token], then the projector copies the sanitized string to SessionRecord.transcript_path. The added integration assertions explicitly accept this substitution for Pi and Kiro. A returned string with that substitution no longer names the source file.
Example: A Pi source named 2026-09-25T16-00-00-000Z_5f0c2a8e-3b1d-4c7e-9a2f-6d8e1b4c7a90.jsonl projects a path with the UUID replaced by a redaction marker; opening that returned path fails.
Recommended fix: Define whether transcript_path is a usable file locator or sanitized display text. If it is a locator, use an authorized source-path lookup keyed by durable source identity instead of deriving it from sanitized observation payloads; cover UUID-bearing filenames in an end-to-end read test.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Analysis — this is a real semantic boundary worth naming, but the redaction is a deliberate pre-existing contract, not an artifact of this change:
RecordSanitizerV1::observation_v1sanitizes the whole canonical envelope at admission, before anything persists. Stored text is always the sanitized form — the ingest suite asserts modulo-redaction viaassert_sanitized_path_text_eqprecisely so the privacy layer is never weakened to make a test pass.- For the UUID-named file hosts covered here (pi, claude, codex, kiro), the redacted component is recoverable:
session_idis the file stem, sodirname(transcript_path)+session_id+ extension reconstructs the real path (e.g. pi5f0c2a8e-…→…/2026-09-25T16-00-00-000Z_5f0c2a8e-….jsonl). For opencode/hermes the path is a state DB that survives sanitization verbatim. - So
transcript_pathtoday is exact for non-UUID sources and a stem-redacted locator for UUID sources. Whether the row should instead expose an authorized source-identity lookup (unsanitized locator) or stay sanitized display text is a product decision — the field never existed before, so either choice establishes the contract rather than weakening one. I've flagged this to the owner; happy to implement the source-identity lookup if we want a true locator.
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
a6864cf to
ba36efe
Compare
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Summary
SessionRecord.transcript_pathfor every host whose session has a real source file (codex, claude, pi, vibe, kimi main wire, cline api-history, kiro workspace/legacy chats, opencode and hermes state DBs), sotranscript_pathis returned in session search.turn_contextmodel in effect for each record (with mid-session switches), closing the last per-messagemodelgap; all other hosts already read their native per-message model.Motivation
Refs #2746 items 5 and 10: the canonical projection never set
SessionRecord::transcript_path, and Codexturn_context.modelwas never attributed to messages (message.modelwasNone). Read-side plumbing (contract fields, search SELECT, SDK mappers) already existed — this fills the write-side gap.Changes
tracedecay-capture:CodexObservationContext(replacesCodexObservationLocation) addstranscript_path+model; Session fact carries the path on every record; Message facts fall back to the context model. Claude/kimi/opencode/vibe/pi/kiro normalizers accept a transcript path and emit a Session fact carrying it (inserted ahead of other facts so it winscanonical_session_fields' first-fact read).tracedecay-sessions: codexCodexContextStatetracksmodelalongsidecwdin both the forwardobserve_context_recordpath and the backwardscan_priorresume walk (stop condition = both fields found). Host drivers pass the real source path; kimi scopes the path to the main-agentwire.jsonlonly (sub-agent wires would reconcile-conflict); cline scopes to the api-history stream only.tracedecay-store:canonical_projectionkeeps the claude Session-fact masking but passestranscript_paththrough via a maskedCanonicalSessionFields.transcript_ingest_suitecoverage per host assertssession.transcript_pathand codex per-message model on real fixture transcripts (incl. a gpt-5.5→gpt-5.6 mid-session switch); newassert_sanitized_path_text_eqhelper compares stored paths modulo admission sanitizer redaction (UUID-bearing filenames always redact); updated serialized-payload goldens (pi, opencode, claude observation) for the new field.Test plan
cargo test -p tracedecay-capture -p tracedecay-store -p tracedecay-sessionspasses (new + existing)cargo test -p tracedecay --features test-helpers --test transcript_ingest_suite --test session_suitepasses (incl. the 3 tests that need thetracedecayCLI binary, built here)bash scripts/require-exact-test.sh cargo test -p <crate> ... -- --exactrun for every new/modified test (14 total)cargo fmt --all -- --checkclean;cargo clippy -p tracedecay-capture -p tracedecay-store -p tracedecay-sessions -p tracedecay --all-targets --features test-helpers -- -D warningscleanpython3 scripts/linux-test-partitions.py check— 137 targets, each in one partitionnode scripts/lint-commit-range.mjs --repository . origin/master HEADcleanChecklist
CHANGELOG.mdupdated (under[Unreleased]if no version bump).envfiles includedLink to Devin session: https://app.devin.ai/sessions/34b4d5fc2dac426b998cbb1b9fda0b25
Open in Devin Desktop: https://app.devin.ai/desktop/session/34b4d5fc2dac426b998cbb1b9fda0b25?variant=devin
Requested by: @ScriptedAlchemy