diff --git a/README.md b/README.md index e1047c401..0f8b95cf9 100644 --- a/README.md +++ b/README.md @@ -370,6 +370,7 @@ thread JSON files. | Visual Studio Copilot | `%LOCALAPPDATA%\\Temp\\VSGitHubCopilotLogs\\traces\\` (Windows), `~/Library/Caches/VSGitHubCopilotLogs/traces/` (macOS), `~/.cache/VSGitHubCopilotLogs/traces/` (Linux) | | Windsurf | `~/Library/Application Support/Windsurf/User/` (macOS), `~/.config/Windsurf/User/` (Linux), `%APPDATA%\\Windsurf\\User\\` (Windows) | | Trae | `%APPDATA%\\Trae\\User\\` (Windows), `~/Library/Application Support/Trae/User/` (macOS), `~/.config/Trae/User/` (Linux) | +| TraeX (TRAE CLI) | `~/.trae/cli/sessions/`, `~/.trae/cli/archived_sessions/` | | Warp | `~/.warp/` (platform-dependent) | | WorkBuddy | `~/.workbuddy/projects/` | | ZCode | `~/.zcode/cli/db/`, `~/.zcode/cli/` | diff --git a/cmd/agentsview/live_activity_test.go b/cmd/agentsview/live_activity_test.go index 9640b9f81..bcc93a4a7 100644 --- a/cmd/agentsview/live_activity_test.go +++ b/cmd/agentsview/live_activity_test.go @@ -45,6 +45,29 @@ func TestCollectLiveActivityTargetsUsesOnlyConfiguredHintProviders(t *testing.T) }, targets[0].Sources) } +// TestCollectLiveActivityTargetsIncludesTraeX pins the TraeX hint wiring: +// TRAE CLI writes history.jsonl at the same position relative to its sessions +// root as Codex, so it reaches the poller with its own traex: ID prefix. +func TestCollectLiveActivityTargetsIncludesTraeX(t *testing.T) { + base := filepath.Join(t.TempDir(), ".trae", "cli") + cfg := config.Config{ + LocalMachineName: "local", + AgentDirs: map[parser.AgentType][]string{ + parser.AgentTraeX: {filepath.Join(base, "sessions")}, + }, + } + + targets, err := collectLiveActivityTargets(t.Context(), cfg) + + require.NoError(t, err) + require.Len(t, targets, 1) + assert.Equal(t, parser.AgentTraeX, targets[0].Provider.Definition().Type) + assert.Equal(t, "traex:", targets[0].Provider.Definition().IDPrefix) + assert.Equal(t, []parser.ActivityHintSource{ + {Path: filepath.Join(base, "history.jsonl")}, + }, targets[0].Sources) +} + func TestCollectLiveActivityTargetsDoesNotRequireExistingRoots(t *testing.T) { base := t.TempDir() missing := filepath.Join(base, "missing", "sessions") diff --git a/docs/configuration.md b/docs/configuration.md index 7a230caa2..1cfcf2f3a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -287,6 +287,7 @@ can still be parsed. | VS Code Copilot | (platform-specific, see below) | JSON / JSONL per session | | Windsurf | (platform-specific, see below) | SQLite `workspaceStorage//state.vscdb` workspace chat data | | Trae | (platform-specific, see below) | Legacy inline chat data in SQLite `workspaceStorage//state.vscdb` and `globalStorage/state.vscdb`; modern encrypted layouts are detected as unsupported | +| TraeX (TRAE CLI) | `~/.trae/cli/sessions/` and `~/.trae/cli/archived_sessions/` | Codex-compatible rollout JSONL per session | | Warp | (platform-specific, see below) | SQLite database | | WorkBuddy | `~/.workbuddy/projects/` | JSONL per session | | ZCode | `~/.zcode/cli/db/` or `~/.zcode/cli/` | SQLite database (`db.sqlite`) with usage rows | @@ -670,6 +671,7 @@ export REASONIX_DIR=~/custom/reasonix export ROOCODE_DIR=~/custom/roocode export SHELLEY_DIR=~/custom/shelley export TRAE_DIR=~/custom/trae/User +export TRAEX_SESSIONS_DIR=~/custom/trae/cli/sessions export VISUALSTUDIO_COPILOT_DIR=~/custom/visualstudio-copilot/traces export VSCODE_COPILOT_DIR=~/custom/vscode export WINDSURF_DIR=~/custom/windsurf/User @@ -707,11 +709,11 @@ The corresponding fields are `aider_dirs`, `amp_dirs`, `antigravity_dirs`, `opencode_dirs`, `openhands_dirs`, `pi_dirs`, `prime_agent_dirs`, `piebald_dirs`, `posit_assistant_dirs`, `positron_dirs`, `qclaw_dirs`, `qoder_project_dirs`, `qwen_project_dirs`, `qwenpaw_dirs`, `reasonix_dirs`, `roocode_dirs`, -`shelley_dirs`, `visualstudio_copilot_dirs`, `vscode_copilot_dirs`, -`windsurf_dirs`, `warp_dirs`, `workbuddy_project_dirs`, `zcode_dirs`, -`zed_dirs`, and `zencoder_dirs`. Each accepts an array of paths. When set, -these take precedence over the single-directory environment variable and the -default path. +`shelley_dirs`, `traex_sessions_dirs`, `visualstudio_copilot_dirs`, +`vscode_copilot_dirs`, `windsurf_dirs`, `warp_dirs`, +`workbuddy_project_dirs`, `zcode_dirs`, `zed_dirs`, and `zencoder_dirs`. Each +accepts an array of paths. When set, these take precedence over the +single-directory environment variable and the default path. All listed directories are discovered, watched, and synced independently. diff --git a/docs/internal/session-format-sources.md b/docs/internal/session-format-sources.md index 2d3857e5a..0b2bc8152 100644 --- a/docs/internal/session-format-sources.md +++ b/docs/internal/session-format-sources.md @@ -98,18 +98,17 @@ Grok section and remove the explicit registry exception in the coverage test. persists verbatim inside the stored usage object. Verified 2026-07-30 against two local web-search sessions: when the search is driven by the **CLI's** `WebSearch` tool, every assistant record carries - `server_tool_use: {"web_search_requests": 0, "web_fetch_requests": 0}` — - the search itself runs in an out-of-band side call that is **not written to - the transcript at all**. The only surviving evidence is the tool-result - record's `toolUseResult` object - (`{query, results, durationSeconds, searchCount}`), whose `searchCount` - matched the wire-billed `web_search_requests` (1 == 1) in both sessions. - Agentsview therefore credits the assistant message that issued the - `WebSearch` `tool_use` with its linked result's `searchCount`, and uses the - message's own counter instead whenever that counter is nonzero (which is - what sessions driving the API directly report), so a search is never - counted twice. **Known undercount:** the side call's own token usage — tens - of thousands of input tokens on `claude-haiku-4-5` per search — is not + `server_tool_use: {"web_search_requests": 0, "web_fetch_requests": 0}` — the + search itself runs in an out-of-band side call that is **not written to the + transcript at all**. The only surviving evidence is the tool-result record's + `toolUseResult` object (`{query, results, durationSeconds, searchCount}`), + whose `searchCount` matched the wire-billed `web_search_requests` (1 == 1) + in both sessions. Agentsview therefore credits the assistant message that + issued the `WebSearch` `tool_use` with its linked result's `searchCount`, + and uses the message's own counter instead whenever that counter is nonzero + (which is what sessions driving the API directly report), so a search is + never counted twice. **Known undercount:** the side call's own token usage — + tens of thousands of input tokens on `claude-haiku-4-5` per search — is not persisted anywhere in the transcript and is not recoverable, so it is neither recorded nor estimated. `web_fetch_requests` is recorded when present but is not priced. Data version 82 reparses existing Claude archives @@ -131,37 +130,38 @@ Grok section and remove the explicit registry exception in the coverage test. still traversed only to discover nested subagents. Verified 2026-07-30 against wire-captured billing for three local Claude Code sessions: parents with subagents under-reported cost by 45-77% before the presentation-time - rollup. Reverified 2026-07-30 with Claude Code 2.1.220: a streaming tool turn - can persist several assistant records with one `(message.id, requestId)` pair - while `usage.output_tokens` grows from an early partial count to the final - billed count (observed examples included `5` then `631` and `6` then `798`). - Usage reporting therefore keeps the greatest output-token snapshot for each - message/request identity across the included sessions, attributes it to the - earliest transcript, and then applies cross-session replay deduplication. - Session-owned dimensions and display metadata also come from that earliest - transcript. Numeric-string token values remain accepted as compatibility - input and are normalized before snapshot comparison on every backend. The - SQLite and PostgreSQL read the exact top-level token path and nested - server-tool path even in supported malformed legacy JSON. Reverified - 2026-08-06 with end-truncated objects containing earlier nested decoy keys; - PostgreSQL and its Cockroach-compatible helper repair the truncated object - before extracting the requested path, while irreparable input contributes no - counter rather than an ambiguously scoped value. - Equal snapshots are selected deterministically by timestamp, session id, and - message ordinal; equivalent RFC3339 spellings use the semantic tie-breakers - rather than raw timestamp text. Reverified 2026-08-04 against the - cross-backend stored-usage fixtures. + rollup. Reverified 2026-07-30 with Claude Code 2.1.220: a streaming tool + turn can persist several assistant records with one + `(message.id, requestId)` pair while `usage.output_tokens` grows from an + early partial count to the final billed count (observed examples included + `5` then `631` and `6` then `798`). Usage reporting therefore keeps the + greatest output-token snapshot for each message/request identity across the + included sessions, attributes it to the earliest transcript, and then + applies cross-session replay deduplication. Session-owned dimensions and + display metadata also come from that earliest transcript. Numeric-string + token values remain accepted as compatibility input and are normalized + before snapshot comparison on every backend. The SQLite and PostgreSQL read + the exact top-level token path and nested server-tool path even in supported + malformed legacy JSON. Reverified 2026-08-06 with end-truncated objects + containing earlier nested decoy keys; PostgreSQL and its + Cockroach-compatible helper repair the truncated object before extracting + the requested path, while irreparable input contributes no counter rather + than an ambiguously scoped value. Equal snapshots are selected + deterministically by timestamp, session id, and message ordinal; equivalent + RFC3339 spellings use the semantic tie-breakers rather than raw timestamp + text. Reverified 2026-08-04 against the cross-backend stored-usage fixtures. Replaying the three captured sessions after this correction matched all transcript-visible output; each full-wire total remained 15 output tokens - higher because Claude Code's separate session-title request is not persisted. - Reverified 2026-08-06 that session-summary export loads matching snapshots - across excluded sessions and pagination before applying the same snapshot, - attribution, web-search, and generic deduplication rules. These accounting - semantics are exposed by usage, activity, and session-summary schema version - 5 and reporting schema version 2; reporting version 1 retains its frozen - first-seen, token-only semantics. Reverified 2026-08-06 that DuckDB records a - web-search-only flat fee as computed pricing provenance, so combining it with - a provider-reported cost is labeled `mixed` like the SQLite archive. + higher because Claude Code's separate session-title request is not + persisted. Reverified 2026-08-06 that session-summary export loads matching + snapshots across excluded sessions and pagination before applying the same + snapshot, attribution, web-search, and generic deduplication rules. These + accounting semantics are exposed by usage, activity, and session-summary + schema version 5 and reporting schema version 2; reporting version 1 retains + its frozen first-seen, token-only semantics. Reverified 2026-08-06 that + DuckDB records a web-search-only flat fee as computed pricing provenance, so + combining it with a provider-reported cost is labeled `mixed` like the + SQLite archive. - **Agentsview:** `internal/parser/claude.go` and `internal/parser/claude_provider.go`; local observations and fixtures are the implementation evidence for fields not documented upstream. Reverified @@ -276,6 +276,36 @@ Grok section and remove the explicit registry exception in the coverage test. autonomous run whose last prompt falls outside those bounds, the rollout relies on those fallbacks until its next prompt. +## TraeX (`traex`) + +- **Format:** Codex-compatible rollout JSONL under a dated `YYYY/MM/DD` tree, + written by TRAE CLI 2.0, plus the flat `archived_sessions/` directory that + `traex archive ` moves a rollout into. The sibling `history.jsonl` + carries the same `session_id`/Unix-seconds `ts`/prompt `text` records, and + agentsview consumes it as the same live-activity hint. No + `session_index.jsonl` sidecar is produced, so titles come from the rollout + head alone. +- **Evidence:** `no-public-source`. +- **Upstream:** TRAE CLI 2.0 ships only as a closed-source binary; the observed + builds report themselves as `traecli 0.200.x`. Trae's first-party + [product site](https://www.trae.ai/) and the official + `https://github.com/Trae-AI/Trae.git` repository were searched 2026-08-04 + and publish neither the producer nor a session schema. The equivalence to + Codex rests on locally observed rollouts whose `session_meta`, `event_msg`, + `response_item`, and `token_count` records are field-for-field the Codex + shape -- including `source.subagent.thread_spawn.parent_thread_id` and an + `originator` of `codex-tui` -- which identifies it as a fork of the + evidenced codex-rs recorder rather than an independent format. A + de-identified rollout is retained as a fixture. +- **Usage and cost:** `token_count` records carry the Codex fields, so + normalization and catalog pricing follow the Codex entry above exactly, + including the same cache-write and reasoning-output omissions. +- **Agentsview:** `internal/parser/traex.go` relabels the shared Codex parser + (`internal/parser/codex.go`, `internal/parser/codex_provider.go`) onto the + `traex:` ID namespace, and `internal/sync` gates the format-shaped branches + on `isCodexFormatAgent`. The `session_index.jsonl` and S3 branches stay + Codex-only because TraeX writes no index file and has no archive layout. + ## GitHub Copilot CLI (`copilot`) - **Format:** Flat session JSONL or a session directory containing diff --git a/frontend/src/lib/components/settings/AgentDirSettings.svelte b/frontend/src/lib/components/settings/AgentDirSettings.svelte index 09ca6ca63..99d01edf6 100644 --- a/frontend/src/lib/components/settings/AgentDirSettings.svelte +++ b/frontend/src/lib/components/settings/AgentDirSettings.svelte @@ -6,6 +6,7 @@ claude: "Claude Code", cowork: "Claude Cowork", codex: "Codex", + traex: "TraeX", copilot: "Copilot", gemini: "Gemini", opencode: "OpenCode", diff --git a/frontend/src/lib/utils/agents.test.ts b/frontend/src/lib/utils/agents.test.ts index 002b6ad08..461aa52f9 100644 --- a/frontend/src/lib/utils/agents.test.ts +++ b/frontend/src/lib/utils/agents.test.ts @@ -14,6 +14,7 @@ describe("KNOWN_AGENTS", () => { "claude", "cowork", "codex", + "traex", "copilot", "devin", "gemini", @@ -72,6 +73,9 @@ describe("agentColor", () => { expect(agentColor("codex")).toBe( "var(--accent-green)", ); + expect(agentColor("traex")).toBe( + "var(--accent-coral)", + ); expect(agentColor("copilot")).toBe( "var(--accent-amber)", ); @@ -214,6 +218,7 @@ describe("agentLabel", () => { expect(agentLabel("qoder")).toBe("Qoder"); expect(agentLabel("roocode")).toBe("RooCode"); expect(agentLabel("omnigent")).toBe("Omnigent"); + expect(agentLabel("traex")).toBe("TraeX"); }); it("capitalizes simple agent names", () => { diff --git a/frontend/src/lib/utils/agents.ts b/frontend/src/lib/utils/agents.ts index 9720efa0a..ee2f4f23b 100644 --- a/frontend/src/lib/utils/agents.ts +++ b/frontend/src/lib/utils/agents.ts @@ -8,6 +8,7 @@ export const KNOWN_AGENTS: readonly AgentMeta[] = [ { name: "claude", color: "var(--accent-blue)" }, { name: "cowork", color: "var(--accent-sky)", label: "Claude Cowork" }, { name: "codex", color: "var(--accent-green)" }, + { name: "traex", color: "var(--accent-coral)", label: "TraeX" }, { name: "copilot", color: "var(--accent-amber)" }, { name: "devin", color: "var(--accent-red)", label: "Devin" }, { name: "gemini", color: "var(--accent-rose)" }, diff --git a/frontend/src/lib/utils/resume.test.ts b/frontend/src/lib/utils/resume.test.ts index 2b874945e..841cb4e3c 100644 --- a/frontend/src/lib/utils/resume.test.ts +++ b/frontend/src/lib/utils/resume.test.ts @@ -9,6 +9,7 @@ describe("supportsResume", () => { it("returns true for supported agents", () => { expect(supportsResume("claude")).toBe(true); expect(supportsResume("codex")).toBe(true); + expect(supportsResume("traex")).toBe(true); expect(supportsResume("copilot")).toBe(true); expect(supportsResume("cursor")).toBe(true); expect(supportsResume("gemini")).toBe(true); @@ -41,6 +42,15 @@ describe("buildResumeCommand", () => { ).toBe("codex resume sess-1"); }); + it("generates traex resume command", () => { + expect( + buildResumeCommand("traex", "traex:sess-1"), + ).toBe("traex resume sess-1"); + expect( + buildResumeCommand("traex", "traex:run-1", { model: "gpt-5-codex" }), + ).toBe("traex resume run-1 -m gpt-5-codex"); + }); + it("pins Claude and Codex models with shell quoting", () => { expect( buildResumeCommand("claude", "run-1", { model: "claude sonnet" }), diff --git a/frontend/src/lib/utils/resume.ts b/frontend/src/lib/utils/resume.ts index 11ef2ad77..c8ea6cbbf 100644 --- a/frontend/src/lib/utils/resume.ts +++ b/frontend/src/lib/utils/resume.ts @@ -7,6 +7,9 @@ RESUME_AGENTS["claude"] = (id) => `claude --resume ${shellQuote(id)}`; RESUME_AGENTS["codex"] = (id) => `codex resume ${shellQuote(id)}`; +// TraeX ships the traex, traecli, and trae-cli aliases; use the shortest. +RESUME_AGENTS["traex"] = (id) => + `traex resume ${shellQuote(id)}`; RESUME_AGENTS["copilot"] = (id) => `copilot --resume=${shellQuote(id)}`; RESUME_AGENTS["cursor"] = (id) => @@ -97,7 +100,8 @@ export function buildResumeCommand( if (flags?.model) { if (agent === "claude") cmd += ` --model ${shellQuote(flags.model)}`; - if (agent === "codex") cmd += ` -m ${shellQuote(flags.model)}`; + if (agent === "codex" || agent === "traex") + cmd += ` -m ${shellQuote(flags.model)}`; } if (agent === "claude" && flags) { diff --git a/internal/db/db_test.go b/internal/db/db_test.go index d45883b25..6b38a9c44 100644 --- a/internal/db/db_test.go +++ b/internal/db/db_test.go @@ -3633,14 +3633,14 @@ func TestClaudeLinearParseRoundTrip(t *testing.T) { } require.NoError(t, d.UpsertSession(sess)) - info, ok := d.GetSessionForIncremental("/tmp/s-linear.jsonl") + info, ok := d.GetSessionForIncremental("/tmp/s-linear.jsonl", "claude") require.True(t, ok) require.NotNil(t, info.ClaudeLinearParse) assert.True(t, *info.ClaudeLinearParse) sess.ClaudeLinearParse = nil require.NoError(t, d.UpsertSession(sess)) - info, ok = d.GetSessionForIncremental("/tmp/s-linear.jsonl") + info, ok = d.GetSessionForIncremental("/tmp/s-linear.jsonl", "claude") require.True(t, ok) require.NotNil(t, info.ClaudeLinearParse, "verdict-free upsert must keep the stored flag") @@ -3654,7 +3654,7 @@ func TestClaudeLinearParseRoundTrip(t *testing.T) { FilePath: new("/tmp/s-legacy.jsonl"), } require.NoError(t, d.UpsertSession(legacy)) - info, ok = d.GetSessionForIncremental("/tmp/s-legacy.jsonl") + info, ok = d.GetSessionForIncremental("/tmp/s-legacy.jsonl", "claude") require.True(t, ok) assert.Nil(t, info.ClaudeLinearParse) } @@ -6538,7 +6538,7 @@ func TestGetSessionForIncremental(t *testing.T) { t.Run("found", func(t *testing.T) { info, ok := d.GetSessionForIncremental( - "/tmp/sessions/test.jsonl", + "/tmp/sessions/test.jsonl", "codex", ) require.True(t, ok, "expected to find session") assert.Equal(t, "codex:inc-test", info.ID, "ID") @@ -6558,8 +6558,16 @@ func TestGetSessionForIncremental(t *testing.T) { assert.True(t, info.HasPeakContextTokens, "HasPeakContextTokens = false, want true") }) + t.Run("wrong_agent", func(t *testing.T) { + _, ok := d.GetSessionForIncremental( + "/tmp/sessions/test.jsonl", "traex", + ) + assert.False(t, ok, + "another agent's row must not satisfy incremental lookup") + }) + t.Run("not_found", func(t *testing.T) { - _, ok := d.GetSessionForIncremental("/no/such/file") + _, ok := d.GetSessionForIncremental("/no/such/file", "codex") assert.False(t, ok, "expected not found") }) @@ -6575,7 +6583,7 @@ func TestGetSessionForIncremental(t *testing.T) { FileSize: new(int64(8192)), }), "upsert "+id) } - _, ok := d.GetSessionForIncremental(path) + _, ok := d.GetSessionForIncremental(path, "claude") assert.False(t, ok, "expected false for multi-session file") }) @@ -6595,7 +6603,7 @@ func TestGetSessionForIncremental(t *testing.T) { ) requireNoError(t, err, "insert legacy false flags") - info, ok := d.GetSessionForIncremental(path) + info, ok := d.GetSessionForIncremental(path, "claude") require.True(t, ok, "expected legacy session for incremental") assert.True(t, info.HasTotalOutputTokens, "HasTotalOutputTokens = false, want true") assert.True(t, info.HasPeakContextTokens, "HasPeakContextTokens = false, want true") @@ -6697,7 +6705,7 @@ func TestGetSessionForIncrementalReturnsImmutableSourceProject(t *testing.T) { tc.sourceProject, )) - info, ok := d.GetSessionForIncremental(tc.filePath) + info, ok := d.GetSessionForIncremental(tc.filePath, "claude") require.True(t, ok) assert.Equal(t, "mapped-target", info.Project) assert.Equal(t, tc.sourceProject, info.SourceProject) diff --git a/internal/db/sessions.go b/internal/db/sessions.go index 2aeb455d2..40120cb20 100644 --- a/internal/db/sessions.go +++ b/internal/db/sessions.go @@ -2489,13 +2489,12 @@ type ToolCallSubagentLink struct { HasResult bool } -// GetSessionForIncremental returns session state needed for -// incremental parsing, looked up by file_path. Returns false -// when the path is unknown or maps to multiple sessions (e.g. -// Claude DAG forks), since incremental parsing cannot update -// multiple sessions from a single append. +// GetSessionForIncremental returns session state needed for incremental +// parsing, looked up by agent and file_path. Returns false when the scoped path +// is unknown or maps to multiple sessions (e.g. Claude DAG forks), since +// incremental parsing cannot update multiple sessions from a single append. func (db *DB) GetSessionForIncremental( - path string, + path, agent string, ) (*IncrementalInfo, bool) { // Bail out if the file maps to more than one session // (Claude fork/subagent splits). @@ -2503,7 +2502,9 @@ func (db *DB) GetSessionForIncremental( err := db.getReader().QueryRow( `SELECT COUNT(*) FROM sessions WHERE file_path = ? + AND agent = ? AND deleted_at IS NULL`, path, + agent, ).Scan(&count) if err != nil || count != 1 { return nil, false @@ -2527,8 +2528,9 @@ func (db *DB) GetSessionForIncremental( LEFT JOIN session_project_identity_snapshots snap ON snap.session_id = s.id WHERE s.file_path = ? + AND s.agent = ? AND s.deleted_at IS NULL`, - path, + path, agent, ).Scan( &info.ID, &info.Project, &info.SourceProject, &info.Machine, &info.Cwd, @@ -2735,6 +2737,26 @@ func (db *DB) GetFileInfoByPath( return s.Int64, m.Int64, true } +// GetFileInfoByAgentPath is GetFileInfoByPath scoped to the agent that owns +// the source path. +func (db *DB) GetFileInfoByAgentPath( + path, agent string, +) (size int64, mtime int64, ok bool) { + var s, m sql.NullInt64 + err := db.getReader().QueryRow( + "SELECT file_size, file_mtime FROM sessions"+ + " WHERE file_path = ? AND agent = ?"+ + " AND (deletion_cause IS NULL"+ + " OR deletion_cause <> '"+deletionCauseSourceMissing+"')"+ + " ORDER BY file_mtime DESC LIMIT 1", + path, agent, + ).Scan(&s, &m) + if err != nil { + return 0, 0, false + } + return s.Int64, m.Int64, true +} + // VirtualContainerMemberFreshness is one stored virtual member's freshness // signal: the newest stored file_mtime for its path, the minimum stored // data version, and the newest row's fingerprint hash, mirroring @@ -2886,6 +2908,24 @@ func (db *DB) GetProjectByPath(path string) (project string, ok bool) { return project, true } +// GetProjectByAgentPath is GetProjectByPath scoped to the agent that owns the +// source path. +func (db *DB) GetProjectByAgentPath( + path, agent string, +) (project string, ok bool) { + err := db.getReader().QueryRow( + "SELECT project FROM sessions"+ + " WHERE file_path = ? AND agent = ?"+ + " AND deleted_at IS NULL"+ + " ORDER BY file_mtime DESC LIMIT 1", + path, agent, + ).Scan(&project) + if err != nil { + return "", false + } + return project, true +} + // GetSourceRepairStateByPath returns the newest active session's project and // file metadata plus the minimum active parser data version for one source // path. It combines the lightweight self-healing checks used by hot sync paths @@ -2916,6 +2956,34 @@ func (db *DB) GetSourceRepairStateByPath( return project, dataVersion, fileSize, fileMtime, true } +// GetSourceRepairStateByAgentPath is GetSourceRepairStateByPath scoped to the +// agent that owns the source path. +func (db *DB) GetSourceRepairStateByAgentPath( + path, agent string, +) ( + project string, + dataVersion int, + fileSize int64, + fileMtime int64, + ok bool, +) { + err := db.getReader().QueryRow(` + SELECT project, file_size, file_mtime, ( + SELECT MIN(data_version) + FROM sessions + WHERE file_path = ? AND agent = ? AND deleted_at IS NULL + ) + FROM sessions + WHERE file_path = ? AND agent = ? AND deleted_at IS NULL + ORDER BY file_mtime DESC + LIMIT 1`, path, agent, path, agent, + ).Scan(&project, &fileSize, &fileMtime, &dataVersion) + if err != nil { + return "", 0, 0, 0, false + } + return project, dataVersion, fileSize, fileMtime, true +} + // GetFileHashByPath returns the stored file_hash for a non-source-missing // session matching file_path, preferring the most recently modified row. // The bool is false when no row exists or the column is NULL. Used @@ -2937,6 +3005,26 @@ func (db *DB) GetFileHashByPath(path string) (hash string, ok bool) { return h.String, h.Valid } +// GetFileHashByAgentPath is GetFileHashByPath scoped to the agent that owns +// the source path. +func (db *DB) GetFileHashByAgentPath( + path, agent string, +) (hash string, ok bool) { + var h sql.NullString + err := db.getReader().QueryRow( + "SELECT file_hash FROM sessions"+ + " WHERE file_path = ? AND agent = ?"+ + " AND (deletion_cause IS NULL"+ + " OR deletion_cause <> '"+deletionCauseSourceMissing+"')"+ + " ORDER BY file_mtime DESC LIMIT 1", + path, agent, + ).Scan(&h) + if err != nil { + return "", false + } + return h.String, h.Valid +} + // ListSessionIDsByFilePath returns non-deleted session IDs for a source path // and agent. Used by parsers whose canonical session ID can change while the // underlying source file remains the same. @@ -3902,6 +3990,23 @@ func (db *DB) GetDataVersionByPath(path string) int { return v } +// GetDataVersionByAgentPath is GetDataVersionByPath scoped to the agent that +// owns the source path. +func (db *DB) GetDataVersionByAgentPath(path, agent string) int { + var v int + err := db.getReader().QueryRow( + "SELECT MIN(data_version) FROM sessions"+ + " WHERE file_path = ? AND agent = ?"+ + " AND (deletion_cause IS NULL"+ + " OR deletion_cause <> '"+deletionCauseSourceMissing+"')", + path, agent, + ).Scan(&v) + if err != nil { + return 0 + } + return v +} + // ResetAllMtimes zeroes file_mtime for every session, forcing // the next sync to re-process all files regardless of whether // their size+mtime matches what was previously stored. It also diff --git a/internal/db/source_path_hints_test.go b/internal/db/source_path_hints_test.go index b07847039..e4e7ef76c 100644 --- a/internal/db/source_path_hints_test.go +++ b/internal/db/source_path_hints_test.go @@ -656,6 +656,33 @@ func TestSourceMissingOwnershipDoesNotSatisfyFreshnessLookups(t *testing.T) { assert.Equal(t, "unchanged", storedHash) } +func TestGetSourceRepairStateByAgentPathDoesNotBorrowAnotherAgent( + t *testing.T, +) { + d := testDB(t) + path := filepath.Join(t.TempDir(), "shared.jsonl") + insertSessionWithSourcePath(t, d, "codex:shared", "codex", path) + _, err := d.getWriter().Exec( + `UPDATE sessions + SET project = 'project', file_size = 64, + file_mtime = 128, data_version = ? + WHERE id = 'codex:shared'`, + CurrentDataVersion(), + ) + require.NoError(t, err) + + project, version, size, mtime, ok := + d.GetSourceRepairStateByAgentPath(path, "codex") + require.True(t, ok) + assert.Equal(t, "project", project) + assert.Equal(t, CurrentDataVersion(), version) + assert.EqualValues(t, 64, size) + assert.EqualValues(t, 128, mtime) + + _, _, _, _, ok = d.GetSourceRepairStateByAgentPath(path, "traex") + assert.False(t, ok) +} + func TestSharedPathSourceOwnershipPageAllocationsStayBoundedByPage(t *testing.T) { seed := func(t *testing.T, count int) (*DB, string) { t.Helper() diff --git a/internal/parser/capabilities_sync_test.go b/internal/parser/capabilities_sync_test.go index cd2b7e6db..981967c5c 100644 --- a/internal/parser/capabilities_sync_test.go +++ b/internal/parser/capabilities_sync_test.go @@ -22,6 +22,12 @@ func TestProviderSyncSemanticsDeclarations(t *testing.T) { FingerprintHashRequiredForFreshness: true, SkipCacheFreshWithoutStoredRow: true, }, + // TraeX shares the Codex provider, so it must share its semantics. + AgentTraeX: { + FingerprintHashInCacheKey: true, + FingerprintHashRequiredForFreshness: true, + SkipCacheFreshWithoutStoredRow: true, + }, AgentDevin: { FingerprintHashInCacheKey: true, FingerprintHashRequiredForFreshness: true, diff --git a/internal/parser/codex.go b/internal/parser/codex.go index 18f0cfd9b..5a4995745 100644 --- a/internal/parser/codex.go +++ b/internal/parser/codex.go @@ -1521,16 +1521,15 @@ func (p *codexProvider) parseSessionSnapshot( } mtime := info.ModTime().UnixNano() - // Include session_index.jsonl mtime so renames trigger a re-parse. - if idxPath := codexSessionIndexPath(path); idxPath != "" { - if idxInfo, err := os.Stat(idxPath); err == nil { - if idxMtime := idxInfo.ModTime().UnixNano(); idxMtime > mtime { - mtime = idxMtime - } - } + if p.spec.agent == AgentCodex { + // Include session_index.jsonl mtime so Codex renames trigger a re-parse. + mtime = CodexEffectiveMtime(path, mtime) } - sessionName := LookupCodexThreadName(path, b.sessionID) + sessionName := "" + if p.spec.agent == AgentCodex { + sessionName = LookupCodexThreadName(path, b.sessionID) + } if sessionName == "" && b.firstMessage == "" && b.relationshipType == RelSubagent { sessionName = codexAgentPathLeaf(b.agentPath) diff --git a/internal/parser/codex_provider.go b/internal/parser/codex_provider.go index 45a657f4d..f1f98811b 100644 --- a/internal/parser/codex_provider.go +++ b/internal/parser/codex_provider.go @@ -14,14 +14,53 @@ import ( var _ Provider = (*codexProvider)(nil) var _ ActivityHintProvider = (*codexProvider)(nil) +// codexProviderSpec parameterizes the one shared Codex-format provider +// implementation for Codex and its TraeX fork. Both reuse the same +// discovery, source-lookup, fingerprinting, and parsing code; they differ +// only in the agent label and ID prefix applied via relabel. TraeX parses +// through the Codex rollout reader and then relabels the result onto its +// own agent and ID prefix. +type codexProviderSpec struct { + agent AgentType + // relabel rewrites a parsed Codex-format result onto this agent's + // identity, and is nil for Codex itself. The session is nil on the + // incremental path, which keeps the stored session ID and only needs + // the appended message rows relabeled. + relabel func(*ParsedSession, []ParsedMessage) +} + +func codexProviderSpecForAgent(agent AgentType) codexProviderSpec { + switch agent { + case AgentTraeX: + return codexProviderSpec{ + agent: AgentTraeX, + relabel: relabelCodexResultAsTraeX, + } + default: + return codexProviderSpec{agent: AgentCodex} + } +} + type codexProviderFactory struct { def AgentDef + spec codexProviderSpec cursorCache *codexCursorCache } func newCodexProviderFactory(def AgentDef) ProviderFactory { return &codexProviderFactory{ def: cloneAgentDef(def), + spec: codexProviderSpecForAgent(AgentCodex), + cursorCache: newProductionCodexCursorCache(), + } +} + +// newTraeXProviderFactory serves TRAE CLI's rollout archive with the Codex +// provider, relabeling every parsed session onto the traex: ID prefix. +func newTraeXProviderFactory(def AgentDef) ProviderFactory { + return &codexProviderFactory{ + def: cloneAgentDef(def), + spec: codexProviderSpecForAgent(AgentTraeX), cursorCache: newProductionCodexCursorCache(), } } @@ -42,13 +81,15 @@ func (f *codexProviderFactory) NewProvider(cfg ProviderConfig) Provider { Caps: codexProviderCapabilities(), Config: cfg, }, - sources: newCodexSourceSet(cfg.Roots), + spec: f.spec, + sources: newCodexSourceSet(f.spec.agent, cfg.Roots), cursorCache: f.cursorCache, } } type codexProvider struct { ProviderBase + spec codexProviderSpec sources codexSourceSet cursorCache *codexCursorCache } @@ -205,7 +246,7 @@ func (p *codexProvider) Parse( if !ok { return ParseOutcome{}, fmt.Errorf("codex source path unavailable") } - if req.ForceParse { + if req.ForceParse && p.spec.agent == AgentCodex { EvictCodexSessionIndexForSession(path) } machine := firstNonEmptyJSONLString(req.Machine, p.Config.Machine) @@ -219,6 +260,9 @@ func (p *codexProvider) Parse( SkipReason: SkipNoSession, }, nil } + if p.spec.relabel != nil { + p.spec.relabel(sess, msgs) + } if req.Fingerprint.Hash != "" { sess.File.Hash = req.Fingerprint.Hash } @@ -326,6 +370,10 @@ func (p *codexProvider) ParseIncremental( result.cursor, ) + if p.spec.relabel != nil { + p.spec.relabel(nil, result.messages) + } + totalOut, peakCtx, hasTotalOut, hasPeakCtx := codexProviderTokenTotals(result.messages) termination := codexIncrementalTermination(result.cursor.lastTaskEvent) @@ -352,11 +400,28 @@ type codexSource struct { } type codexSourceSet struct { + // agent labels the sources this set emits. Codex-format forks share + // the layout but must not share a discovery namespace: keying sources + // by agent keeps a TraeX UUID from colliding with a Codex one. + agent AgentType roots []string } -func newCodexSourceSet(roots []string) codexSourceSet { - return codexSourceSet{roots: cleanJSONLRoots(roots)} +func newCodexSourceSet(agent AgentType, roots []string) codexSourceSet { + if agent == "" { + agent = AgentCodex + } + return codexSourceSet{agent: agent, roots: cleanJSONLRoots(roots)} +} + +// ownsCodexSidecars reports whether this source set's agent is the one that +// owns Codex's out-of-band files: the session_index.jsonl sidecar and the +// s3://.../raw/codex/... archive layout. Only Codex does. A fork writes +// neither, so it must not watch, fan out on, or import them -- importing an +// s3:// root through discoverCodexS3 would stamp AgentCodex and silently move +// the sessions into Codex's identity namespace. +func (s codexSourceSet) ownsCodexSidecars() bool { + return s.agent == AgentCodex } func (s codexSourceSet) Discover(ctx context.Context) ([]SourceRef, error) { @@ -371,6 +436,9 @@ func (s codexSourceSet) DiscoverEach( return err } if strings.HasPrefix(root, "s3://") { + if !s.ownsCodexSidecars() { + continue + } for _, file := range discoverCodexS3(root) { if err := yield(s3SourceRefFromDiscoveredFile(file)); err != nil { return err @@ -422,6 +490,9 @@ func (s codexSourceSet) discover( // payload. Each object is its own session keyed by URI, so the // live-over-archived preference (which inspects a local codexSource // layout) does not apply here. + if !s.ownsCodexSidecars() { + continue + } for _, file := range discoverCodexS3(root) { source := s3SourceRefFromDiscoveredFile(file) if _, ok := byKey[source.Key]; ok { @@ -560,8 +631,11 @@ func (s codexSourceSet) WatchPlan(context.Context) (WatchPlan, error) { Path: root, Recursive: true, IncludeGlobs: []string{"*.jsonl"}, - DebounceKey: string(AgentCodex) + ":sessions:" + root, + DebounceKey: string(s.agent) + ":sessions:" + root, }) + if !s.ownsCodexSidecars() { + continue + } for _, shallow := range ResolveCodexShallowWatchRoots(root) { shallow = filepath.Clean(shallow) if _, ok := seenShallow[shallow]; ok { @@ -572,7 +646,7 @@ func (s codexSourceSet) WatchPlan(context.Context) (WatchPlan, error) { Path: shallow, Recursive: false, IncludeGlobs: []string{CodexSessionIndexFilename}, - DebounceKey: string(AgentCodex) + ":index:" + shallow, + DebounceKey: string(s.agent) + ":index:" + shallow, }) } } @@ -586,7 +660,8 @@ func (s codexSourceSet) SourcesForChangedPath( if err := ctx.Err(); err != nil { return nil, err } - if filepath.Base(req.Path) == CodexSessionIndexFilename { + if s.ownsCodexSidecars() && + filepath.Base(req.Path) == CodexSessionIndexFilename { return s.sourcesForIndexPath(ctx, req.Path) } for _, root := range s.roots { @@ -675,10 +750,14 @@ func (s codexSourceSet) Fingerprint( return SourceFingerprint{}, err } inode, device := sourceFileIdentity(info) + mtime := info.ModTime().UnixNano() + if s.agent == AgentCodex { + mtime = CodexEffectiveMtime(path, mtime) + } return SourceFingerprint{ Key: firstNonEmptyJSONLString(source.FingerprintKey, source.Key, path), Size: info.Size(), - MTimeNS: CodexEffectiveMtime(path, info.ModTime().UnixNano()), + MTimeNS: mtime, Inode: inode, Device: device, Hash: hash, @@ -743,8 +822,8 @@ func (s codexSourceSet) sourceRef( return SourceRef{}, false } return SourceRef{ - Provider: AgentCodex, - Key: codexSourceKey(uuid), + Provider: s.agent, + Key: codexSourceKey(s.agent, uuid), DisplayPath: path, FingerprintKey: path, Opaque: codexSource{ @@ -770,7 +849,7 @@ func (s codexSourceSet) directPathSource( return SourceRef{}, false } return SourceRef{ - Provider: AgentCodex, + Provider: s.agent, Key: path, DisplayPath: path, FingerprintKey: path, @@ -809,16 +888,18 @@ func (s codexSourceSet) canonicalSource( return best, true, nil } -func codexSourceKey(uuid string) string { - return string(AgentCodex) + ":" + uuid +func codexSourceKey(agent AgentType, uuid string) string { + return string(agent) + ":" + uuid } -// CodexSourceKey is the discovery identity of a Codex session UUID. Every -// on-disk copy of a duplicated UUID shares this key, so the sync engine's -// reconciliation index resolves same-UUID replacements with one bounded -// lookup instead of an archive walk. -func CodexSourceKey(uuid string) string { - return codexSourceKey(uuid) +// CodexSourceKey is the discovery identity of a Codex-format session UUID +// under the given agent. Every on-disk copy of a duplicated UUID shares this +// key, so the sync engine's reconciliation index resolves same-UUID +// replacements with one bounded lookup instead of an archive walk. The agent +// keeps Codex and its TraeX fork in separate identity namespaces even when +// both archives happen to hold the same UUID. +func CodexSourceKey(agent AgentType, uuid string) string { + return codexSourceKey(agent, uuid) } func preferCodexSource(candidate, current SourceRef) bool { diff --git a/internal/parser/provider.go b/internal/parser/provider.go index 1ab2b0eda..cfada7684 100644 --- a/internal/parser/provider.go +++ b/internal/parser/provider.go @@ -1052,6 +1052,8 @@ func providerFactoryForDef(def AgentDef) ProviderFactory { return newCommandCodeProviderFactory(def) case AgentCodex: return newCodexProviderFactory(def) + case AgentTraeX: + return newTraeXProviderFactory(def) case AgentCopilot: return newCopilotProviderFactory(def) case AgentCowork: diff --git a/internal/parser/provider_capabilities_test.go b/internal/parser/provider_capabilities_test.go index e46364a75..90bddec53 100644 --- a/internal/parser/provider_capabilities_test.go +++ b/internal/parser/provider_capabilities_test.go @@ -29,7 +29,9 @@ func TestProviderCapabilitiesActivityHintsMatchConsumers(t *testing.T) { for _, factory := range ProviderFactories() { agent := factory.Definition().Type got := factory.Capabilities().Source.ActivityHints - if agent == AgentCodex { + // TraeX writes the same history.jsonl at the same position relative + // to its sessions root, so it inherits the Codex hint reader. + if agent == AgentCodex || agent == AgentTraeX { assert.Equal(t, CapabilitySupported, got) provider := factory.NewProvider(ProviderConfig{ Roots: []string{t.TempDir()}, diff --git a/internal/parser/provider_migration.go b/internal/parser/provider_migration.go index 610181878..c3001b70c 100644 --- a/internal/parser/provider_migration.go +++ b/internal/parser/provider_migration.go @@ -19,6 +19,7 @@ var providerMigrationModes = map[AgentType]ProviderMigrationMode{ AgentOpenClaude: ProviderMigrationProviderAuthoritative, AgentCowork: ProviderMigrationProviderAuthoritative, AgentCodex: ProviderMigrationProviderAuthoritative, + AgentTraeX: ProviderMigrationProviderAuthoritative, AgentCopilot: ProviderMigrationProviderAuthoritative, AgentGemini: ProviderMigrationProviderAuthoritative, AgentOpenHands: ProviderMigrationProviderAuthoritative, diff --git a/internal/parser/provider_test.go b/internal/parser/provider_test.go index 4351b4dc5..682b6eaf2 100644 --- a/internal/parser/provider_test.go +++ b/internal/parser/provider_test.go @@ -221,6 +221,9 @@ func TestVerifiedLocalStatCapabilitiesMatchConsumers(t *testing.T) { wantSupported := map[AgentType]bool{ AgentClaude: true, AgentCodex: true, + // TraeX shares the Codex provider; the gate stats the transcript and + // only looks for a session_index.jsonl sidecar under Codex itself. + AgentTraeX: true, } for _, factory := range ProviderFactories() { agent := factory.Definition().Type diff --git a/internal/parser/s3_discovery_test.go b/internal/parser/s3_discovery_test.go index 2137f6010..a4310fda8 100644 --- a/internal/parser/s3_discovery_test.go +++ b/internal/parser/s3_discovery_test.go @@ -129,7 +129,7 @@ func TestCodexSourceSetDiscoversS3Sessions(t *testing.T) { }}, nil } - sources, err := newCodexSourceSet([]string{root}).Discover(context.Background()) + sources, err := newCodexSourceSet(AgentCodex, []string{root}).Discover(context.Background()) require.NoError(t, err) require.Len(t, sources, 1) diff --git a/internal/parser/testdata/traex/rollout_subagent.jsonl b/internal/parser/testdata/traex/rollout_subagent.jsonl new file mode 100644 index 000000000..ec6111e27 --- /dev/null +++ b/internal/parser/testdata/traex/rollout_subagent.jsonl @@ -0,0 +1,18 @@ +{"timestamp":"2026-08-01T18:07:04.550Z","type":"session_meta","payload":{"id":"019fbcca-9fd4-7d20-83dc-0762b2f839b3","timestamp":"2026-08-01T18:07:03.636Z","cwd":"/home/user/code/api","originator":"codex-tui","cli_version":"0.200.19","source":{"subagent":{"thread_spawn":{"parent_thread_id":"019fbc4a-48b9-7472-a0da-6d92901383db","depth":1,"agent_path":null,"agent_nickname":"Averroes","agent_role":"explorer"}}},"thread_source":"subagent","agent_nickname":"Averroes","agent_role":"explorer","model_provider":"trae","base_instructions":{"text":"You are TRAE CLI, a coding agent."},"git":{"commit_hash":"0000000000000000000000000000000000000000","branch":"main","repository_url":null}}} +{"timestamp":"2026-08-01T18:07:04.560Z","type":"event_msg","payload":{"type":"task_started","turn_id":"turn-1","started_at":1785561000000,"model_context_window":272000,"collaboration_mode_kind":"subagent"}} +{"timestamp":"2026-08-01T18:07:04.570Z","type":"turn_context","payload":{"turn_id":"turn-1","cwd":"/home/user/code/api","current_date":"2026-08-01","timezone":"Asia/Shanghai","approval_policy":"on-request","sandbox_policy":{"mode":"workspace-write"},"permission_profile":"default","model":"gpt-5-codex","model_provider":"trae","personality":null,"collaboration_mode":{"kind":"subagent"},"realtime_active":false}} +{"timestamp":"2026-08-01T18:07:04.580Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"Explore the parser package."}]}} +{"timestamp":"2026-08-01T18:07:04.590Z","type":"event_msg","payload":{"type":"user_message","message":"Explore the parser package.","images":null,"local_images":null,"text_elements":[]}} +{"timestamp":"2026-08-01T18:07:06.100Z","type":"response_item","payload":{"type":"reasoning","summary":[],"content":null,"encrypted_content":"REDACTED"}} +{"timestamp":"2026-08-01T18:07:06.200Z","type":"event_msg","payload":{"type":"agent_reasoning_raw_content","text":"Plan the exploration."}} +{"timestamp":"2026-08-01T18:07:07.010Z","type":"response_item","payload":{"type":"function_call","name":"spawn_agent","arguments":"{\"agent_type\":\"explorer\",\"message\":\"Summarize the provider facade.\"}","call_id":"call_spawn_1"}} +{"timestamp":"2026-08-01T18:07:07.020Z","type":"response_item","payload":{"type":"function_call_output","call_id":"call_spawn_1","output":"{\"agent_id\":\"019fbcd0-1111-7000-8000-000000000001\",\"nickname\":\"Sartre\"}"}} +{"timestamp":"2026-08-01T18:07:07.030Z","type":"event_msg","payload":{"type":"collab_agent_spawn_end","call_id":"call_spawn_1","completed_at_ms":1785561007030,"sender_thread_id":"019fbcca-9fd4-7d20-83dc-0762b2f839b3","new_thread_id":"019fbcd0-1111-7000-8000-000000000001","new_agent_nickname":"Sartre","new_agent_role":"explorer","prompt":"Summarize the provider facade."}} +{"timestamp":"2026-08-01T18:07:09.410Z","type":"response_item","payload":{"type":"function_call","name":"wait_agent","arguments":"{\"targets\":[\"019fbcd0-1111-7000-8000-000000000001\"],\"timeout_ms\":1000}","call_id":"call_wait_1"}} +{"timestamp":"2026-08-01T18:07:12.480Z","type":"response_item","payload":{"type":"function_call_output","call_id":"call_wait_1","output":"{\"status\":{\"019fbcd0-1111-7000-8000-000000000001\":{\"completed\":{\"message\":\"The facade owns discovery and parsing.\"}}}}"}} +{"timestamp":"2026-08-01T18:07:13.220Z","type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"ls internal/parser\"}","call_id":"call_exec_1"}} +{"timestamp":"2026-08-01T18:07:13.900Z","type":"response_item","payload":{"type":"function_call_output","call_id":"call_exec_1","output":"codex.go\ncodex_provider.go\ntraex.go\n"}} +{"timestamp":"2026-08-01T18:07:14.100Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":4200,"cached_input_tokens":3600,"output_tokens":180,"reasoning_output_tokens":64,"total_tokens":4380},"total_token_usage":{"input_tokens":4200,"cached_input_tokens":3600,"output_tokens":180,"reasoning_output_tokens":64,"total_tokens":4380},"model_context_window":272000},"rate_limits":null,"context":{"used_tokens":4380}}} +{"timestamp":"2026-08-01T18:07:15.000Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"The provider facade owns discovery and parsing."}]}} +{"timestamp":"2026-08-01T18:07:15.010Z","type":"event_msg","payload":{"type":"agent_message","message":"The provider facade owns discovery and parsing.","phase":null,"memory_citation":null}} +{"timestamp":"2026-08-01T18:07:15.100Z","type":"event_msg","payload":{"type":"task_complete","turn_id":"turn-1","last_agent_message":"The provider facade owns discovery and parsing.","completed_at":1785561015100,"duration_ms":10540,"time_to_first_token_ms":1420}} diff --git a/internal/parser/traex.go b/internal/parser/traex.go new file mode 100644 index 000000000..bc106c130 --- /dev/null +++ b/internal/parser/traex.go @@ -0,0 +1,64 @@ +package parser + +import "strings" + +// TraeX (TRAE CLI 2.0) is a closed-source fork of codex-rs and writes the +// same rollout JSONL, but sessions are exposed as a distinct agent with the +// traex: ID prefix so they resume with `traex resume` and never collide with +// a Codex UUID. The Codex-format provider owns parsing and relabels results +// through relabelCodexResultAsTraeX. + +const ( + codexIDPrefix = string(AgentCodex) + ":" + traeXIDPrefix = string(AgentTraeX) + ":" +) + +// relabelCodexResultAsTraeX rewrites a Codex-format parse result onto the +// TraeX agent. sess is nil on the provider's incremental path, which keeps +// the stored session ID and only needs the appended message rows relabeled. +func relabelCodexResultAsTraeX(sess *ParsedSession, msgs []ParsedMessage) { + if sess != nil { + relabelCodexSessionAsTraeX(sess) + } + relabelCodexMessagesAsTraeX(msgs) +} + +func relabelCodexSessionAsTraeX(sess *ParsedSession) { + if sess == nil { + return + } + sess.ID = traeXSessionID(sess.ID) + sess.ParentSessionID = traeXSessionID(sess.ParentSessionID) + sess.SourceSessionID = traeXSessionID(sess.SourceSessionID) + sess.Agent = AgentTraeX +} + +// relabelCodexMessagesAsTraeX rewrites the subagent links the Codex parser +// stamps with the codex: prefix (codexSubagentSessionID). Without this a +// TraeX parent would point its tool calls at codex: rows that the +// traex: namespace never stores. +func relabelCodexMessagesAsTraeX(msgs []ParsedMessage) { + for i := range msgs { + for j := range msgs[i].ToolCalls { + call := &msgs[i].ToolCalls[j] + call.SubagentSessionID = traeXSessionID(call.SubagentSessionID) + for k := range call.ResultEvents { + event := &call.ResultEvents[k] + event.SubagentSessionID = traeXSessionID( + event.SubagentSessionID, + ) + } + } + } +} + +// traeXSessionID swaps the codex: prefix for traex:, leaving empty and +// already-relabeled IDs untouched. Only the first occurrence is replaced, +// matching relabelOpenCodeSessionAsKilo, so a raw ID that itself repeats +// "codex:" keeps the rest of its text verbatim. +func traeXSessionID(id string) string { + if id == "" { + return id + } + return strings.Replace(id, codexIDPrefix, traeXIDPrefix, 1) +} diff --git a/internal/parser/traex_test.go b/internal/parser/traex_test.go new file mode 100644 index 000000000..f525d78d1 --- /dev/null +++ b/internal/parser/traex_test.go @@ -0,0 +1,412 @@ +package parser + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.kenn.io/agentsview/internal/testjsonl" +) + +func TestTraeXSessionIDRelabel(t *testing.T) { + tests := []struct { + name string + id string + want string + }{ + {"empty stays empty", "", ""}, + {"codex prefix", "codex:019fbcca", "traex:019fbcca"}, + { + "already relabeled", + "traex:019fbcca", + "traex:019fbcca", + }, + { + "host-prefixed id keeps its host", + "devbox/codex:019fbcca", + "devbox/traex:019fbcca", + }, + { + // strings.Replace(..., 1) semantics, matching + // relabelOpenCodeSessionAsKilo: a raw ID that repeats the + // prefix keeps everything after the first occurrence verbatim. + "only the first occurrence is replaced", + "codex:codex:019fbcca", + "traex:codex:019fbcca", + }, + { + "unprefixed id is untouched", + "019fbcca", + "019fbcca", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, traeXSessionID(tt.id)) + }) + } +} + +func TestRelabelCodexResultAsTraeX(t *testing.T) { + sess := &ParsedSession{ + ID: "codex:child", + ParentSessionID: "codex:parent", + SourceSessionID: "codex:origin", + Agent: AgentCodex, + } + msgs := []ParsedMessage{{ + ToolCalls: []ParsedToolCall{{ + ToolUseID: "call_1", + SubagentSessionID: "codex:spawned", + ResultEvents: []ParsedToolResultEvent{{ + ToolUseID: "call_1", + AgentID: "spawned", + SubagentSessionID: "codex:spawned", + }}, + }}, + }} + + relabelCodexResultAsTraeX(sess, msgs) + + assert.Equal(t, "traex:child", sess.ID) + assert.Equal(t, "traex:parent", sess.ParentSessionID) + assert.Equal(t, "traex:origin", sess.SourceSessionID) + assert.Equal(t, AgentTraeX, sess.Agent) + assert.Equal( + t, "traex:spawned", msgs[0].ToolCalls[0].SubagentSessionID, + ) + assert.Equal( + t, + "traex:spawned", + msgs[0].ToolCalls[0].ResultEvents[0].SubagentSessionID, + ) + // AgentID is the raw upstream thread ID, not an agentsview session ID, + // so it must survive the relabel unchanged. + assert.Equal(t, "spawned", msgs[0].ToolCalls[0].ResultEvents[0].AgentID) +} + +// TestRelabelCodexResultAsTraeXIncremental covers the provider's incremental +// path, which has appended rows but no session to relabel. +func TestRelabelCodexResultAsTraeXIncremental(t *testing.T) { + msgs := []ParsedMessage{{ + ToolCalls: []ParsedToolCall{{ + SubagentSessionID: "codex:spawned", + }}, + }} + require.NotPanics(t, func() { + relabelCodexResultAsTraeX(nil, msgs) + }) + assert.Equal( + t, "traex:spawned", msgs[0].ToolCalls[0].SubagentSessionID, + ) +} + +func TestTraeXRegistryEntry(t *testing.T) { + def, ok := AgentByType(AgentTraeX) + require.True(t, ok) + assert.Equal(t, "traex:", def.IDPrefix) + assert.True(t, def.FileBased) + // TraeX writes plaintext rollouts, so unlike the Trae IDE entry it must + // stay eligible for remote sync. + assert.False(t, def.RemoteSyncExcluded) + + // traex: must not be swallowed by the Trae IDE entry's trae: prefix. + byPrefix, ok := AgentByPrefix("traex:019fbcca") + require.True(t, ok) + assert.Equal(t, AgentTraeX, byPrefix.Type) +} + +func TestTraeXProviderParseRelabelsCodexSession(t *testing.T) { + root := t.TempDir() + const ( + uuid = "019fbcca-9fd4-7d20-83dc-0762b2f839b3" + parent = "019fbc4a-48b9-7472-a0da-6d92901383db" + spawned = "019fbcd0-1111-7000-8000-000000000001" + callID = "call_spawn_1" + filename = "rollout-2026-08-01T18-07-03-" + uuid + ".jsonl" + ) + path := filepath.Join(root, "2026", "08", "01", filename) + content := testjsonl.JoinJSONL( + testjsonl.CodexSubagentSessionMetaJSON( + uuid, parent, + "/home/user/code/api", "codex-tui", + "2026-08-01T18:07:03.636Z", + ), + testjsonl.CodexMsgJSON( + "user", "Explore the parser", "2026-08-01T18:07:04Z", + ), + testjsonl.CodexFunctionCallWithCallIDJSON( + "spawn_agent", callID, + `{"prompt":"explore"}`, "2026-08-01T18:07:05Z", + ), + testjsonl.CodexFunctionCallOutputJSON( + callID, + `{"agent_id":"`+spawned+`"}`, + "2026-08-01T18:07:06Z", + ), + ) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte(content), 0o644)) + + provider, ok := NewProvider(AgentTraeX, ProviderConfig{ + Roots: []string{root}, + Machine: "devbox", + }) + require.True(t, ok) + + sources, err := provider.Discover(context.Background()) + require.NoError(t, err) + require.Len(t, sources, 1) + assert.Equal(t, AgentTraeX, sources[0].Provider) + assert.Equal(t, path, sources[0].DisplayPath) + + outcome, err := provider.Parse(context.Background(), ParseRequest{ + Source: sources[0], + Machine: "devbox", + }) + require.NoError(t, err) + require.Len(t, outcome.Results, 1) + sess := outcome.Results[0].Result.Session + msgs := outcome.Results[0].Result.Messages + + assert.Equal(t, AgentTraeX, sess.Agent) + assert.Equal(t, "traex:"+uuid, sess.ID) + assert.Equal(t, "traex:"+parent, sess.ParentSessionID) + assert.Equal(t, "devbox", sess.Machine) + + var subagentIDs []string + for _, msg := range msgs { + for _, call := range msg.ToolCalls { + if call.SubagentSessionID != "" { + subagentIDs = append(subagentIDs, call.SubagentSessionID) + } + } + } + assert.Equal(t, []string{"traex:" + spawned}, subagentIDs) +} + +func TestTraeXProviderIgnoresCopiedCodexSessionIndex(t *testing.T) { + root := t.TempDir() + sessionsRoot := filepath.Join(root, "sessions") + const uuid = "019fbcca-9fd4-7d20-83dc-0762b2f839b3" + path := filepath.Join( + sessionsRoot, "2026", "08", "01", + "rollout-2026-08-01T18-07-03-"+uuid+".jsonl", + ) + content := testjsonl.JoinJSONL( + testjsonl.CodexSessionMetaJSON( + uuid, "/workspace/project", "codex-tui", + "2026-08-01T18:07:03Z", + ), + testjsonl.CodexMsgJSON( + "user", "transcript prompt", "2026-08-01T18:07:04Z", + ), + ) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte(content), 0o644)) + + indexPath := filepath.Join(root, CodexSessionIndexFilename) + alphaIndex := `{"id":"` + uuid + + `","thread_name":"Alpha title"}` + "\n" + bravoIndex := `{"id":"` + uuid + + `","thread_name":"Bravo title"}` + "\n" + require.Equal(t, len(alphaIndex), len(bravoIndex)) + require.NoError(t, os.WriteFile(indexPath, []byte(alphaIndex), 0o644)) + rolloutTime := time.Now().Add(-2 * time.Hour) + indexTime := rolloutTime.Add(time.Hour) + require.NoError(t, os.Chtimes(path, rolloutTime, rolloutTime)) + require.NoError(t, os.Chtimes(indexPath, indexTime, indexTime)) + assert.Equal(t, "Alpha title", LookupCodexThreadName(path, uuid)) + + require.NoError(t, os.WriteFile(indexPath, []byte(bravoIndex), 0o644)) + require.NoError(t, os.Chtimes(indexPath, indexTime, indexTime)) + + provider, ok := NewProvider(AgentTraeX, ProviderConfig{ + Roots: []string{sessionsRoot}, Machine: "host", + }) + require.True(t, ok) + sources, err := provider.Discover(t.Context()) + require.NoError(t, err) + require.Len(t, sources, 1) + fingerprint, err := provider.Fingerprint(t.Context(), sources[0]) + require.NoError(t, err) + outcome, err := provider.Parse(t.Context(), ParseRequest{ + Source: sources[0], Fingerprint: fingerprint, ForceParse: true, + }) + require.NoError(t, err) + require.Len(t, outcome.Results, 1) + + rolloutInfo, err := os.Stat(path) + require.NoError(t, err) + sess := outcome.Results[0].Result.Session + assert.Equal(t, rolloutInfo.ModTime().UnixNano(), fingerprint.MTimeNS) + assert.Equal(t, rolloutInfo.ModTime().UnixNano(), sess.File.Mtime) + assert.Empty(t, sess.SessionName) + codexSessionIndexCache.mu.Lock() + cachedTitle := codexSessionIndexCache.entries[indexPath].titles[uuid] + codexSessionIndexCache.mu.Unlock() + assert.Equal(t, "Alpha title", cachedTitle, + "TraeX force parse must not evict or reload the Codex index cache") +} + +// TestTraeXProviderParsesDeidentifiedRollout runs the full Discover -> Parse +// path over a fixture captured from a real TRAE CLI 2.0 rollout (paths, +// prompts, and identifiers replaced), guarding the claim that TraeX rollouts +// are byte-compatible with the Codex format. +func TestTraeXProviderParsesDeidentifiedRollout(t *testing.T) { + root := t.TempDir() + const uuid = "019fbcca-9fd4-7d20-83dc-0762b2f839b3" + dst := filepath.Join( + root, "2026", "08", "01", + "rollout-2026-08-01T18-07-03-"+uuid+".jsonl", + ) + require.NoError(t, os.MkdirAll(filepath.Dir(dst), 0o755)) + fixture, err := os.ReadFile(filepath.Join( + "testdata", "traex", "rollout_subagent.jsonl", + )) + require.NoError(t, err) + require.NoError(t, os.WriteFile(dst, fixture, 0o644)) + + provider, ok := NewProvider(AgentTraeX, ProviderConfig{ + Roots: []string{root}, + Machine: "devbox", + }) + require.True(t, ok) + + sources, err := provider.Discover(context.Background()) + require.NoError(t, err) + require.Len(t, sources, 1) + + outcome, err := provider.Parse(context.Background(), ParseRequest{ + Source: sources[0], + Machine: "devbox", + }) + require.NoError(t, err) + require.Len(t, outcome.Results, 1) + sess := outcome.Results[0].Result.Session + msgs := outcome.Results[0].Result.Messages + + assert.Equal(t, AgentTraeX, sess.Agent) + assert.Equal(t, "traex:"+uuid, sess.ID) + assert.Equal( + t, "traex:019fbc4a-48b9-7472-a0da-6d92901383db", + sess.ParentSessionID, + ) + assert.Equal(t, "api", sess.Project) + require.NotEmpty(t, msgs) + assert.Equal(t, RoleUser, msgs[0].Role) + assert.Equal(t, "Explore the parser package.", msgs[0].Content) + assert.Equal(t, "gpt-5-codex", msgs[len(msgs)-1].Model) + assert.Positive(t, sess.TotalOutputTokens) + + for _, msg := range msgs { + for _, call := range msg.ToolCalls { + assert.NotContains(t, call.SubagentSessionID, "codex:") + } + } +} + +// TestTraeXAndCodexProvidersKeepSeparateSourceKeys guards the discovery +// namespace: the two agents share a UUID shape, so a shared source key would +// let one agent's session resolve to the other's file. +func TestTraeXAndCodexProvidersKeepSeparateSourceKeys(t *testing.T) { + const uuid = "019fbcca-9fd4-7d20-83dc-0762b2f839b3" + assert.NotEqual( + t, + CodexSourceKey(AgentCodex, uuid), + CodexSourceKey(AgentTraeX, uuid), + ) +} + +// TestTraeXProviderIgnoresCodexSidecars covers the three Codex-only +// out-of-band surfaces the shared provider must not expose to a fork: the +// session_index.jsonl watch, the index changed-path fan-out, and the +// s3://.../raw/codex archive layout. TraeX writes none of them, and importing +// an S3 root through the Codex scanner would stamp AgentCodex, silently moving +// the sessions into Codex's identity namespace. +func TestTraeXProviderIgnoresCodexSidecars(t *testing.T) { + base := filepath.Join(t.TempDir(), ".trae", "cli") + root := filepath.Join(base, "sessions") + const uuid = "019fbcca-9fd4-7d20-83dc-0762b2f839b3" + path := filepath.Join( + root, "2026", "08", "01", + "rollout-2026-08-01T18-07-03-"+uuid+".jsonl", + ) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte(testjsonl.JoinJSONL( + testjsonl.CodexSessionMetaJSON( + uuid, "/home/user/code/api", "codex-tui", + "2026-08-01T18:07:03.636Z", + ), + )), 0o644)) + // A stray index file: copied in, or left by a Codex root that used to own + // this directory. It must not fan out to every TraeX session. + indexPath := filepath.Join(base, CodexSessionIndexFilename) + require.NoError(t, os.WriteFile(indexPath, []byte( + `{"id":"`+uuid+`","thread_name":"Renamed","updated_at":`+ + `"2026-08-01T18:07:03Z"}`+"\n", + ), 0o644)) + + provider, ok := NewProvider(AgentTraeX, ProviderConfig{ + Roots: []string{root}, + Machine: "devbox", + }) + require.True(t, ok) + + plan, err := provider.WatchPlan(context.Background()) + require.NoError(t, err) + require.Len(t, plan.Roots, 1, "no shallow session_index.jsonl watch") + assert.Equal(t, root, plan.Roots[0].Path) + assert.True(t, plan.Roots[0].Recursive) + + classifier, ok := provider.(interface { + SourcesForChangedPath( + context.Context, ChangedPathRequest, + ) ([]SourceRef, error) + }) + require.True(t, ok) + sources, err := classifier.SourcesForChangedPath( + context.Background(), ChangedPathRequest{Path: indexPath}, + ) + require.NoError(t, err) + assert.Empty(t, sources, "index events must not fan out for a fork") + + oldList := listS3Objects + t.Cleanup(func() { listS3Objects = oldList }) + listS3Objects = func(string) ([]S3Object, error) { + return []S3Object{{ + URI: "s3://bucket/devbox/raw/codex/2026/08/01/" + + "rollout-2026-08-01T18-07-03-" + uuid + ".jsonl", + Size: 11, + LastModified: time.Unix(100, 0), + Fingerprint: "s3-meta:rollout", + }}, nil + } + s3Provider, ok := NewProvider(AgentTraeX, ProviderConfig{ + Roots: []string{"s3://bucket/devbox/raw/codex"}, + }) + require.True(t, ok) + s3Sources, err := s3Provider.Discover(context.Background()) + require.NoError(t, err) + assert.Empty(t, s3Sources, + "TraeX has no S3 archive convention and must not import one as Codex") +} + +// TestTraeXRegistryCoversArchivedSessions guards the `traex archive ` +// destination: TRAE CLI moves a rollout out of the dated tree into a flat +// archived_sessions directory, mirroring `codex archive`. +func TestTraeXRegistryCoversArchivedSessions(t *testing.T) { + def, ok := AgentByType(AgentTraeX) + require.True(t, ok) + assert.Equal(t, []string{ + ".trae/cli/sessions", + ".trae/cli/archived_sessions", + }, def.DefaultDirs) + assert.Nil(t, def.ShallowWatchRootsFunc, + "the shallow watch exists for Codex's session_index.jsonl only") +} diff --git a/internal/parser/types.go b/internal/parser/types.go index 652f42cbf..0b3915b75 100644 --- a/internal/parser/types.go +++ b/internal/parser/types.go @@ -16,6 +16,7 @@ const ( AgentOpenClaude AgentType = "openclaude" AgentCowork AgentType = "cowork" AgentCodex AgentType = "codex" + AgentTraeX AgentType = "traex" AgentCopilot AgentType = "copilot" AgentGemini AgentType = "gemini" AgentMiMoCode AgentType = "mimocode" @@ -169,6 +170,30 @@ var Registry = []AgentDef{ FileBased: true, ShallowWatchRootsFunc: ResolveCodexShallowWatchRoots, }, + { + // TRAE CLI 2.0 is a closed-source fork of codex-rs and writes + // byte-compatible rollout JSONL, so it reuses the Codex parser + // through a relabel hook. It is a distinct agent rather than a + // Codex source because resuming needs `traex resume` and the two + // tools keep separate session archives. Unlike the Trae IDE entry + // below, nothing here is encrypted, so remote sync is not excluded. + Type: AgentTraeX, + DisplayName: "TraeX", + EnvVar: "TRAEX_SESSIONS_DIR", + ConfigKey: "traex_sessions_dirs", + DefaultDirs: []string{ + ".trae/cli/sessions", + // `traex archive ` moves a rollout out of the dated tree into + // this flat directory, exactly as `codex archive` does. + ".trae/cli/archived_sessions", + }, + IDPrefix: "traex:", + FileBased: true, + // No ShallowWatchRootsFunc: that hook exists for Codex's sibling + // session_index.jsonl, which TraeX never writes. Watching + // ~/.trae/cli shallowly would deliver nothing but churn from the + // SQLite WALs TRAE CLI keeps there. + }, { Type: AgentCopilot, DisplayName: "Copilot", diff --git a/internal/parser/types_test.go b/internal/parser/types_test.go index a783b2073..c468772f2 100644 --- a/internal/parser/types_test.go +++ b/internal/parser/types_test.go @@ -268,6 +268,12 @@ func TestAgentByPrefix(t *testing.T) { AgentCodex, true, }, + { + "traex prefix", + "traex:some-uuid", + AgentTraeX, + true, + }, { "copilot prefix", "copilot:sess-id", @@ -421,6 +427,7 @@ func TestRegistryCompleteness(t *testing.T) { AgentOpenClaude, AgentCowork, AgentCodex, + AgentTraeX, AgentCopilot, AgentGemini, AgentMiMoCode, diff --git a/internal/remotesync/import.go b/internal/remotesync/import.go index 27b444f2a..64ebef347 100644 --- a/internal/remotesync/import.go +++ b/internal/remotesync/import.go @@ -187,14 +187,15 @@ func translateRemoteCacheToTemp( tempDirs []string, ) map[string]int64 { translated := make(map[string]int64, len(remoteCache)) - for remotePath, mtime := range remoteCache { + for remoteKey, mtime := range remoteCache { + remotePath, suffix := syncpkg.SplitProviderSkipCachePath(remoteKey) for i, rd := range remoteDirs { if rel, ok := remoteArchiveRel(rd, remotePath); ok { local, err := safeLocalArchivePath(tempDirs[i], rel) if err != nil { break } - translated[local] = mtime + translated[local+suffix] = mtime break } } @@ -209,12 +210,13 @@ func saveEngineSkipCache( ) error { snapshot := engine.SnapshotSkipCache() remoteCache := make(map[string]int64, len(snapshot)) - for localPath, mtime := range snapshot { + for localKey, mtime := range snapshot { + localPath, suffix := syncpkg.SplitProviderSkipCachePath(localKey) remotePath, ok := tempPathToRemotePath( localPath, paths.remoteDirs, paths.localDirs, ) if ok { - remoteCache[remotePath] = mtime + remoteCache[remotePath+suffix] = mtime } } if err := database.ReplaceRemoteSkippedFiles(paths.host, remoteCache); err != nil { diff --git a/internal/remotesync/import_test.go b/internal/remotesync/import_test.go index 3a7a07756..4761abbc1 100644 --- a/internal/remotesync/import_test.go +++ b/internal/remotesync/import_test.go @@ -137,7 +137,7 @@ func TestPreparedHTTPSyncRebuildContributor(t *testing.T) { require.Len(t, remoteCache, 1) for cachedPath := range remoteCache { assert.True(t, strings.HasPrefix( - cachedPath, skippedFile+"?source_hash=", + cachedPath, skippedFile+"?agent=claude?source_hash=", ), "rowless Claude skips must persist their content hash") } assert.NotContains(t, remoteCache, remoteFile, @@ -371,7 +371,7 @@ func TestImporterMapsHermesStateDBExtraFileAndRefreshesWALChanges(t *testing.T) require.NoError(t, err) require.NotEmpty(t, remoteCache, "the state.db skip entry must survive the import, not be discarded") - _, ok := remoteCache[remoteStateDB] + _, ok := remoteCache[remoteStateDB+"?agent=hermes"] assert.True(t, ok, "skip cache must key the state.db entry by its remote path, got %v", remoteCache) @@ -507,3 +507,37 @@ func TestRemoteSkipCacheUsesArchivePathMapping(t *testing.T) { require.True(t, ok) assert.Equal(t, remoteFile, got) } + +func TestRemoteSkipCacheRoundTripsQualifiedExtraFile(t *testing.T) { + root := t.TempDir() + const ( + host = "devbox" + remoteFile = "/home/remote/.hermes/state.db" + qualified = remoteFile + "?agent=hermes" + ) + targets := TargetSet{ExtraFiles: []string{remoteFile}} + layout, cfg, err := newImportInputs(host, nil, targets, root) + require.NoError(t, err) + + localFile := remappedRemotePath(root, remoteFile) + "?agent=hermes" + translated := translateRemoteCacheToTemp( + map[string]int64{qualified: 123}, + layout.paths.remoteDirs, + layout.paths.localDirs, + ) + require.Equal(t, map[string]int64{localFile: 123}, translated, + "remote translation must preserve the provider qualifier") + + database, err := db.Open(filepath.Join(t.TempDir(), "test.db")) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, database.Close()) }) + engine := syncpkg.NewEngine(database, cfg) + t.Cleanup(engine.Close) + engine.InjectSkipCache(translated) + require.NoError(t, saveEngineSkipCache(database, engine, layout.paths)) + + remoteCache, err := database.LoadRemoteSkippedFiles(host) + require.NoError(t, err) + assert.Equal(t, map[string]int64{qualified: 123}, remoteCache, + "temporary translation must restore the identical remote cache key") +} diff --git a/internal/server/resume.go b/internal/server/resume.go index 334056aac..3bbe0c084 100644 --- a/internal/server/resume.go +++ b/internal/server/resume.go @@ -39,10 +39,12 @@ type resumeResponse struct { } // resumeAgents maps agent type strings to their resume command templates. -// The %s placeholder is replaced with the (quoted) session ID. +// The %s placeholder is replaced with the (quoted) session ID. TraeX ships the +// traex, traecli, and trae-cli aliases; the shortest is used. var resumeAgents = map[string]string{ "claude": "claude --resume %s", "codex": "codex resume %s", + "traex": "traex resume %s", "copilot": "copilot --resume=%s", "cursor": "cursor agent --resume %s", "gemini": "gemini --resume %s", @@ -64,14 +66,14 @@ func resumeCommand(agent, tmpl, rawID, model string) string { switch agent { case "claude": cmd += " --model " + shellQuote(model) - case "codex": + case "codex", "traex": cmd += " -m " + shellQuote(model) } return cmd } func resumeAgentNeedsModel(agent string) bool { - return agent == "claude" || agent == "codex" + return agent == "claude" || agent == "codex" || agent == "traex" } func primaryResumeModel(counts []db.ModelCount) string { diff --git a/internal/sync/engine.go b/internal/sync/engine.go index dc210bbc7..1557e43cf 100644 --- a/internal/sync/engine.go +++ b/internal/sync/engine.go @@ -460,7 +460,7 @@ type Engine struct { // The epoch vetoes promotions captured before a global clear. State is // memory-only, so process startup always deep-verifies sources once. verifiedSourceMu gosync.Mutex - verifiedSources map[string]verifiedSourceRecord + verifiedSources map[verifiedSourceKey]verifiedSourceRecord verifiedSourceEpoch uint64 verifiedSourcePass uint64 verifiedSourceActivePass uint64 @@ -1894,7 +1894,7 @@ func dedupeDiscoveredFilesByPreference( } func discoveredFileKey(file parser.DiscoveredFile) string { - if file.Agent == parser.AgentCodex { + if isCodexFormatAgent(file.Agent) { if id := parser.CodexSessionUUIDFromFilename(filepath.Base(file.Path)); id != "" { return string(file.Agent) + "\x00" + discoveredFileIDPrefix(file) + "\x00" + id @@ -1913,7 +1913,7 @@ func discoveredFileIDPrefix(file parser.DiscoveredFile) string { func preferDiscoveredFile( candidate, current parser.DiscoveredFile, ) bool { - if candidate.Agent == parser.AgentCodex && current.Agent == parser.AgentCodex { + if candidate.Agent == current.Agent && isCodexFormatAgent(candidate.Agent) { candLayout := codexLayoutForPath(candidate.Path) currLayout := codexLayoutForPath(current.Path) if candLayout != currLayout { @@ -1926,7 +1926,7 @@ func preferDiscoveredFile( func preferNewestCodexDiscoveredFile( candidate, current parser.DiscoveredFile, ) bool { - if candidate.Agent == parser.AgentCodex && current.Agent == parser.AgentCodex { + if candidate.Agent == current.Agent && isCodexFormatAgent(candidate.Agent) { candMTime, candOK := discoveredFileMTime(candidate.Path) currMTime, currOK := discoveredFileMTime(current.Path) if candOK && currOK && candMTime != currMTime { @@ -4702,14 +4702,14 @@ func (e *Engine) reconciliationCandidate( } } } - if agent == parser.AgentCodex && codexLayoutForPath(path) == parser.CodexLayoutDated { + if isCodexFormatAgent(agent) && codexLayoutForPath(path) == parser.CodexLayoutDated { preference1 = 1 } if isOpenCodeFormatAgent(agent) { if statPath == path { preference1 = 1 } - } else if agent != parser.AgentClaude && agent != parser.AgentCodex { + } else if agent != parser.AgentClaude && !isCodexFormatAgent(agent) { for i, configured := range roots { if samePathOrDescendant(statPath, configured) { preference1 = int64(len(roots) - i) @@ -4774,6 +4774,23 @@ func isOpenCodeFormatAgent(agent parser.AgentType) bool { } } +// isCodexFormatAgent reports whether an agent stores sessions in the Codex +// rollout-JSONL layout: UUID-bearing filenames, a dated year/month/day tree +// with an optional flat archive, and JSONL-tail incremental appends. It gates +// the format-shaped branches (duplicate resolution, layout preference, +// reconciliation identity, parse-diff mtime) so the Codex fork TraeX gets the +// same handling. Branches that depend on Codex's session_index.jsonl sidecar +// or its S3 archive layout stay keyed to parser.AgentCodex alone: TraeX writes +// no index file and has no S3 path convention. +func isCodexFormatAgent(agent parser.AgentType) bool { + switch agent { + case parser.AgentCodex, parser.AgentTraeX: + return true + default: + return false + } +} + func reconciliationWatchRoot( path string, watchRoots []parser.WatchRoot, configuredRoots []string, ) string { @@ -5008,12 +5025,12 @@ func reconciliationReplacementIdentity( switch agent { case parser.AgentClaude: return claudeSessionIDFromPath(storedPath) - case parser.AgentCodex: + case parser.AgentCodex, parser.AgentTraeX: uuid := parser.CodexSessionUUIDFromFilename(filepath.Base(storedPath)) if uuid == "" { return "" } - return parser.CodexSourceKey(uuid) + return parser.CodexSourceKey(agent, uuid) default: return "" } @@ -5515,6 +5532,11 @@ func (e *Engine) tombstoneSessionSourceOwnership( if _, err := e.clearSkipPersistent(filePath); err != nil { return false, fmt.Errorf("clear source skip cache: %w", err) } + if _, err := e.clearSkipPersistent(providerAgentSkipCacheKey( + filePath, parser.AgentType(agent), + )); err != nil { + return false, fmt.Errorf("clear agent source skip cache: %w", err) + } // Also drop the per-component provider_freshness row under the same // (agent, filePath) key. Freebuff sessions surface in storage with // agent=AgentFreebuff but the provider_freshness side-table is only @@ -5540,7 +5562,7 @@ func (e *Engine) tombstoneSessionSourceOwnership( // The skip family was removed before the database transition. Drop the // remaining source trust so a byte-identical return is reverified and can // revive the tombstoned row. - e.invalidateVerifiedSource(filePath) + e.invalidateVerifiedSource(parser.AgentType(agent), filePath) return true, nil } @@ -6332,26 +6354,33 @@ func (e *Engine) visualStudioCopilotCurrentPollSource( } // expandCodexProviderDuplicates re-adds the on-disk duplicate paths of each -// discovered Codex source. The provider deduplicates a UUID's live and archived -// copies to the preferred layout at discovery time; this restores the dropped -// duplicates (scoped to the configured roots) so an mtime cutoff filter can -// judge each copy on its own mtime, matching the legacy discover-then-filter -// order. Non-Codex files and Codex files without a UUID-shaped name pass through -// unchanged. Duplicates are keyed by path so nothing is added twice. +// discovered Codex-format source. The provider deduplicates a UUID's live and +// archived copies to the preferred layout at discovery time; this restores the +// dropped duplicates (scoped to the configured roots) so an mtime cutoff filter +// can judge each copy on its own mtime, matching the legacy discover-then-filter +// order. Files of other agents, and Codex-format files without a UUID-shaped +// name, pass through unchanged. Duplicates are keyed by path so nothing is added +// twice. Each agent is expanded against its own roots and re-added under its own +// identity: a fork's UUID must not be resolved through the Codex provider. func (e *Engine) expandCodexProviderDuplicates( files []parser.DiscoveredFile, scope *rootSyncScope, ) []parser.DiscoveredFile { - pather := e.codexUUIDPathLister(scope) - if pather == nil { - return files - } + pathers := make(map[parser.AgentType]func(string) []string) seen := make(map[string]struct{}, len(files)) for _, f := range files { seen[string(f.Agent)+"\x00"+filepath.Clean(f.Path)] = struct{}{} } out := files for _, f := range files { - if f.Agent != parser.AgentCodex { + if !isCodexFormatAgent(f.Agent) { + continue + } + pather, resolved := pathers[f.Agent] + if !resolved { + pather = e.codexUUIDPathLister(f.Agent, scope) + pathers[f.Agent] = pather + } + if pather == nil { continue } uuid := parser.CodexSessionUUIDFromFilename(filepath.Base(f.Path)) @@ -6359,37 +6388,38 @@ func (e *Engine) expandCodexProviderDuplicates( continue } for _, dup := range pather(uuid) { - key := string(parser.AgentCodex) + "\x00" + filepath.Clean(dup) + key := string(f.Agent) + "\x00" + filepath.Clean(dup) if _, ok := seen[key]; ok { continue } seen[key] = struct{}{} out = append(out, parser.DiscoveredFile{ Path: dup, - Agent: parser.AgentCodex, - Machine: e.machineForPath(parser.AgentCodex, dup), + Agent: f.Agent, + Machine: e.machineForPath(f.Agent, dup), ProviderProcess: true, - ProviderSource: e.codexPinnedProviderSource(dup), + ProviderSource: e.codexPinnedProviderSource(f.Agent, dup), }) } } return out } -// codexUUIDPathLister returns a function that lists every on-disk Codex -// transcript path for a UUID under the in-scope roots, or nil when the Codex -// provider is unavailable. It scopes a single provider to the in-scope roots so -// the returned paths cover both the live dated and flat archived copies of a -// duplicated UUID, including duplicates that share one root. +// codexUUIDPathLister returns a function that lists every on-disk transcript +// path of the given Codex-format agent for a UUID under the in-scope roots, or +// nil when that provider is unavailable. It scopes a single provider to the +// in-scope roots so the returned paths cover both the live dated and flat +// archived copies of a duplicated UUID, including duplicates that share one +// root. func (e *Engine) codexUUIDPathLister( - scope *rootSyncScope, + agent parser.AgentType, scope *rootSyncScope, ) func(string) []string { - factory, ok := e.providerFactories[parser.AgentCodex] + factory, ok := e.providerFactories[agent] if !ok || factory == nil { return nil } - roots := make([]string, 0, len(e.agentDirs[parser.AgentCodex])) - for _, root := range e.agentDirs[parser.AgentCodex] { + roots := make([]string, 0, len(e.agentDirs[agent])) + for _, root := range e.agentDirs[agent] { if root == "" || !scope.includes(root) { continue } @@ -6572,7 +6602,9 @@ func (e *Engine) discoveredFileEffectiveMtime( // expandCodexProviderDuplicates relies on to preserve a changed archived // duplicate. Index refreshes are handled separately by the codexIndexRefresh // pass in filterFilesByMtime, so codex uses its raw per-file mtime here. - if file.Agent == parser.AgentCodex { + // Codex-format forks take the same branch: their fingerprint carries no + // index component, so the raw mtime is the same value at lower cost. + if isCodexFormatAgent(file.Agent) { return discoveredFileMtime(file) } // S3 objects are discovered through the provider facade (so they carry a @@ -7307,7 +7339,9 @@ func (e *Engine) providerDBBackedSourceFresh( if e.pathRewriter != nil { lookupPath = e.pathRewriter(lookupPath) } - _, storedMtime, ok := e.db.GetFileInfoByPath(lookupPath) + _, storedMtime, ok := e.db.GetFileInfoByAgentPath( + lookupPath, string(agent), + ) if !ok { return false } @@ -7316,13 +7350,16 @@ func (e *Engine) providerDBBackedSourceFresh( } if factory, ok := e.providerFactories[agent]; ok && factory != nil && !e.providerFingerprintHashMatchesDB( + agent, lookupPath, fingerprint, factory.Capabilities().Sync.FingerprintHashRequiredForFreshness, ) { return false } - return e.db.GetDataVersionByPath(lookupPath) >= db.CurrentDataVersion() + return e.db.GetDataVersionByAgentPath( + lookupPath, string(agent), + ) >= db.CurrentDataVersion() } // syncProviderDBBackedAgent runs the full-sync phase for a provider-authoritative @@ -8406,7 +8443,7 @@ func (e *Engine) processFile( // fingerprint are unchanged. if cacheSkip && !e.forceParse && !file.ForceParse { // parse-diff: ignore the skip cache if e.shouldUseCachedSkip(file, mtime, sourceFingerprint) { - if e.pathNeedsCachedSkipBypass(file.Path) { + if e.pathNeedsCachedSkipBypass(file.Agent, file.Path) { e.clearSkip(file.Path) } else { return processResult{ @@ -8452,7 +8489,10 @@ func (e *Engine) shouldUseCachedSkip( return true } -func (e *Engine) pathNeedsProjectReparse(path string) bool { +func (e *Engine) pathNeedsProjectReparse( + agent parser.AgentType, + path string, +) bool { if e == nil || e.db == nil { return false } @@ -8460,16 +8500,22 @@ func (e *Engine) pathNeedsProjectReparse(path string) bool { if e.pathRewriter != nil { lookupPath = e.pathRewriter(path) } - project, ok := e.db.GetProjectByPath(lookupPath) + project, ok := e.db.GetProjectByAgentPath(lookupPath, string(agent)) return ok && parser.NeedsProjectReparse(project) } -func (e *Engine) pathNeedsCachedSkipBypass(path string) bool { - return e.pathNeedsProjectReparse(path) || - e.pathNeedsDataVersionReparse(path) +func (e *Engine) pathNeedsCachedSkipBypass( + agent parser.AgentType, + path string, +) bool { + return e.pathNeedsProjectReparse(agent, path) || + e.pathNeedsDataVersionReparse(agent, path) } -func (e *Engine) pathNeedsDataVersionReparse(path string) bool { +func (e *Engine) pathNeedsDataVersionReparse( + agent parser.AgentType, + path string, +) bool { if e == nil || e.db == nil { return false } @@ -8477,10 +8523,14 @@ func (e *Engine) pathNeedsDataVersionReparse(path string) bool { if e.pathRewriter != nil { lookupPath = e.pathRewriter(path) } - if _, _, ok := e.db.GetFileInfoByPath(lookupPath); !ok { + if _, _, ok := e.db.GetFileInfoByAgentPath( + lookupPath, string(agent), + ); !ok { return false } - return e.db.GetDataVersionByPath(lookupPath) < db.CurrentDataVersion() + return e.db.GetDataVersionByAgentPath( + lookupPath, string(agent), + ) < db.CurrentDataVersion() } func (e *Engine) processProviderFile( @@ -8610,14 +8660,17 @@ func (e *Engine) processProviderFile( e.verifiedProviderSourceState(provider, source, file) if verifiedStateOK && verifiedFresh { if e.verifiedProviderSourceFreshInDB( - source, verifiedCapture.signature.size, verifiedMtime, + verifiedCapture.key.agent, source, + verifiedCapture.signature.size, verifiedMtime, ) { return processResult{ skip: true, mtime: verifiedMtime, }, true } - e.invalidateVerifiedSource(verifiedCapture.path) + e.invalidateVerifiedSource( + verifiedCapture.key.agent, verifiedCapture.key.path, + ) } // DB-freshness skip for single-session JSONL providers (Claude): @@ -8710,7 +8763,7 @@ func (e *Engine) processProviderFile( providerSemantics, ) { e.clearSkip(cacheKey) - } else if e.pathNeedsCachedSkipBypass(file.Path) { + } else if e.pathNeedsCachedSkipBypass(file.Agent, file.Path) { e.clearSkip(cacheKey) } else if file.Agent == parser.AgentCodex && e.codexCachedIndexSessionNameChanged(file.Path) { @@ -9510,6 +9563,11 @@ func providerProcessCacheKey( if key == "" { key = file.Path } + agent := file.Agent + if agent == "" { + agent = source.Provider + } + key = providerAgentSkipCacheKey(key, agent) key = providerProcessCacheKeyWithHash( key, fingerprint, providerSemantics, ) @@ -9526,6 +9584,45 @@ func providerProcessCacheKey( return key } +const providerAgentSkipMarker = "?agent=" + +// providerAgentSkipCacheKey prevents providers with overlapping roots or a +// shared on-disk format from inheriting another agent's cached source state. +// The path stays first so remote-cache path translation remains valid. +func providerAgentSkipCacheKey(key string, agent parser.AgentType) string { + if key == "" || agent == "" { + return key + } + return key + providerAgentSkipMarker + string(agent) +} + +// SplitProviderSkipCachePath separates the filesystem path from the provider +// qualifier carried by a skip-cache identity. Remote import translates only +// the path and reattaches the suffix after the path mapping succeeds. +func SplitProviderSkipCachePath(key string) (path, suffix string) { + path, qualified, ok := strings.Cut(key, providerAgentSkipMarker) + if !ok { + return key, "" + } + return path, providerAgentSkipMarker + qualified +} + +// legacyProviderSkipCacheKey removes the agent qualifier from a provider cache +// key while retaining any hash or data-version suffix. Successful processing +// clears this predecessor alongside the scoped key so upgrades do not retain +// dead path-only cache entries. +func legacyProviderSkipCacheKey(key string) string { + base, qualified, ok := strings.Cut(key, providerAgentSkipMarker) + if !ok { + return "" + } + _, suffix, hasSuffix := strings.Cut(qualified, "?") + if !hasSuffix { + return base + } + return base + "?" + suffix +} + func providerProcessCacheKeyWithHash( key string, fingerprint parser.SourceFingerprint, @@ -9582,7 +9679,7 @@ func (e *Engine) providerSkipCacheEntryFreshInDB( } } return e.providerFingerprintHashMatchesDB( - lookupPath, fingerprint, + agent, lookupPath, fingerprint, providerSemantics.FingerprintHashRequiredForFreshness, ) } @@ -9617,8 +9714,8 @@ func (e *Engine) shouldSkipProviderSource( if e.pathRewriter != nil { lookupPath = e.pathRewriter(lookupPath) } - storedSize, storedMtime, ok := e.db.GetFileInfoByPath( - lookupPath, + storedSize, storedMtime, ok := e.db.GetFileInfoByAgentPath( + lookupPath, string(agent), ) if !ok { return false @@ -9630,13 +9727,15 @@ func (e *Engine) shouldSkipProviderSource( return false } if !e.providerFingerprintHashMatchesDB( - lookupPath, + agent, lookupPath, fingerprint, semantics.FingerprintHashRequiredForFreshness, ) { return false } - return e.db.GetDataVersionByPath(lookupPath) >= db.CurrentDataVersion() + return e.db.GetDataVersionByAgentPath( + lookupPath, string(agent), + ) >= db.CurrentDataVersion() } func providerSourceSupportsPersistedFreshness(agent parser.AgentType) bool { @@ -9858,6 +9957,12 @@ func (e *Engine) clearSkipPersistent(path string) (int, error) { work := e.removeSkipHashSiblingsLocked(path) delete(e.skipCache, path) delete(e.skipFingerprints, path) + legacyPath := legacyProviderSkipCacheKey(path) + if legacyPath != "" { + work += e.removeSkipHashSiblingsLocked(legacyPath) + delete(e.skipCache, legacyPath) + delete(e.skipFingerprints, legacyPath) + } e.skipMu.Unlock() if e.ephemeral { return work, nil @@ -9866,6 +9971,12 @@ func (e *Engine) clearSkipPersistent(path string) (int, error) { err := e.db.DeleteSkippedFileAndPrefix( base, base+sourceHashSkipMarker, ) + if legacyPath != "" { + legacyBase, _, _ := strings.Cut(legacyPath, sourceHashSkipMarker) + err = errors.Join(err, e.db.DeleteSkippedFileAndPrefix( + legacyBase, legacyBase+sourceHashSkipMarker, + )) + } return work, err } @@ -10014,7 +10125,10 @@ func (e *Engine) providerSourceUnchangedInDB( if e.pathRewriter != nil { lookupPath = e.pathRewriter(lookupPath) } - storedSize, storedMtime, ok := e.db.GetFileInfoByPath(lookupPath) + agent := source.Provider + storedSize, storedMtime, ok := e.db.GetFileInfoByAgentPath( + lookupPath, string(agent), + ) if !ok { return false } @@ -10025,7 +10139,7 @@ func (e *Engine) providerSourceUnchangedInDB( return false } } else if !e.providerFingerprintHashMatchesDB( - lookupPath, + agent, lookupPath, fingerprint, semantics.FingerprintHashRequiredForFreshness, ) { @@ -10035,7 +10149,9 @@ func (e *Engine) providerSourceUnchangedInDB( // must defeat the unchanged-source skip so the corrected project is // reparsed, mirroring shouldSkipCodexFingerprint and the in-memory // skip-cache bypass in processProviderFile. - if project, ok := e.db.GetProjectByPath(lookupPath); ok && + if project, ok := e.db.GetProjectByAgentPath( + lookupPath, string(agent), + ); ok && parser.NeedsProjectReparse(project) { return false } @@ -10046,7 +10162,9 @@ func (e *Engine) providerSourceUnchangedInDB( // content-unchanged source with no stored digest flows fingerprint → // this skip → stamp, without ever persisting a digest before an outcome // the engine can trust. - fresh := e.db.GetDataVersionByPath(lookupPath) >= db.CurrentDataVersion() + fresh := e.db.GetDataVersionByAgentPath( + lookupPath, string(agent), + ) >= db.CurrentDataVersion() if fresh { e.stampProviderStatHashForConfirmedSource(ctx, preParseStatHash) } @@ -10083,6 +10201,7 @@ func (e *Engine) stampProviderStatHashForConfirmedSource( } func (e *Engine) providerFingerprintHashMatchesDB( + agent parser.AgentType, lookupPath string, fingerprint parser.SourceFingerprint, required bool, @@ -10090,7 +10209,9 @@ func (e *Engine) providerFingerprintHashMatchesDB( if fingerprint.Hash == "" || !required { return true } - storedHash, ok := e.db.GetFileHashByPath(lookupPath) + storedHash, ok := e.db.GetFileHashByAgentPath( + lookupPath, string(agent), + ) return ok && storedHash == fingerprint.Hash } @@ -10110,7 +10231,7 @@ func providerFingerprintHashEstablishesFreshness(agent parser.AgentType) bool { // providerSourceHashFreshDespiteStat is the stat-mismatch arm of // providerSourceUnchangedInDB. Unlike providerFingerprintHashMatchesDB, an // absent hash can never establish freshness here: the stat already disagrees, -// so only a positive content match may skip. GetFileHashByPath excludes +// so only a positive content match may skip. GetFileHashByAgentPath excludes // recoverable source-missing tombstones, so a returning member still revives // through a full parse. func (e *Engine) providerSourceHashFreshDespiteStat( @@ -10121,7 +10242,9 @@ func (e *Engine) providerSourceHashFreshDespiteStat( if fingerprint.Hash == "" || !providerFingerprintHashEstablishesFreshness(agent) { return false } - storedHash, ok := e.db.GetFileHashByPath(lookupPath) + storedHash, ok := e.db.GetFileHashByAgentPath( + lookupPath, string(agent), + ) return ok && storedHash == fingerprint.Hash } @@ -10636,14 +10759,18 @@ func (e *Engine) tryProviderIncrementalAppend( if path == "" { return processResult{}, false } - if provider.Definition().Type == parser.AgentCodex && + // Codex-format incremental parsing intentionally preserves head-derived + // metadata. A manual refresh, title change, or stale project needs the + // authoritative full parse, and forceReplace prevents the later DB skip + // gates from swallowing that refresh. Only Codex itself has a + // session_index.jsonl title, so that check stays keyed to it: a fork would + // pay a DB lookup that can never report a change. + providerAgent := provider.Definition().Type + if isCodexFormatAgent(providerAgent) && (file.ForceParse || - e.codexIndexSessionNameChanged(path) || - e.pathNeedsProjectReparse(path)) { - // Codex incremental parsing intentionally preserves head-derived - // metadata. A manual refresh, title change, or stale project needs the - // authoritative full parse, and forceReplace prevents the later DB skip - // gates from swallowing that refresh. + e.pathNeedsProjectReparse(providerAgent, path) || + (providerAgent == parser.AgentCodex && + e.codexIndexSessionNameChanged(path))) { return processResult{forceReplace: true}, false } info, err := os.Stat(path) @@ -10743,7 +10870,7 @@ func (e *Engine) tryIncrementalJSONL( if e.pathRewriter != nil { lookupPath = e.pathRewriter(file.Path) } - inc, ok := e.db.GetSessionForIncremental(lookupPath) + inc, ok := e.db.GetSessionForIncremental(lookupPath, string(agent)) if !ok || inc.FileSize <= 0 { return processResult{}, false } @@ -10803,10 +10930,11 @@ func (e *Engine) tryIncrementalJSONL( return processResult{forceReplace: true}, false } if currentSize == inc.FileSize { - if agent == parser.AgentCodex { - // Codex's composite mtime can change when session_index.jsonl does, - // even though the transcript has no new bytes. Let the later Codex - // fingerprint/title check decide whether to skip or full-parse. + if isCodexFormatAgent(agent) { + // A Codex-format rollout with no new transcript bytes can still + // reach the fingerprint path. Codex's composite mtime may also + // change when session_index.jsonl does. Let the later database + // freshness checks decide whether to skip or full-parse. return processResult{}, false } log.Printf( @@ -10816,15 +10944,15 @@ func (e *Engine) tryIncrementalJSONL( return processResult{forceReplace: true}, false } - // Persist the same effective file_mtime a full parse would store. For - // Codex that folds in session_index.jsonl (parser.CodexEffectiveMtime), + // Persist the same effective file_mtime a full parse would store. Codex + // folds in session_index.jsonl (parser.CodexEffectiveMtime), // exactly as ParseCodexSession sets File.Mtime; a full sync of the same // file stores that effective value. Keeping the incremental write on the // same basis means parse-diff's raced guard -- which reads the freshly // parsed effective File.Mtime -- compares against a matching stored // file_mtime no matter whether the last write was incremental or full, // and shouldSkipCodex's storedMtime==effectiveMtime fast path stays - // accurate. Plain JSONL agents (Claude/Gemini) keep the raw stat. + // accurate. Other JSONL agents, including TraeX, keep the raw stat. incMtime := info.ModTime().UnixNano() if agent == parser.AgentCodex { incMtime = parser.CodexEffectiveMtime(file.Path, incMtime) @@ -10878,7 +11006,7 @@ func (e *Engine) tryIncrementalJSONL( // providerSingleSessionFresh can compare the stored hash against the // on-disk bytes and catch a same-size, same-mtime, same-inode in-place // rewrite that the size/mtime/identity skip signals cannot see. - if agent == parser.AgentCodex || agent == parser.AgentClaude { + if isCodexFormatAgent(agent) || agent == parser.AgentClaude { if hash, err := ComputeFileHashPrefix(file.Path, newOffset); err == nil { incHash = hash } @@ -11044,27 +11172,34 @@ func (e *Engine) tryIncrementalJSONL( // session_index.jsonl sidecar, so a size-and-effective-mtime match plus a // per-session title check preserves the legacy "skip when only the global index // advanced but this session's name did not" semantics. Other providers keep -// their existing in-memory skip-cache behavior unchanged. +// their existing in-memory skip-cache behavior unchanged. TraeX shares the +// transcript size/hash/mtime gate but never reaches the Codex-only index-title +// branch. Every lookup is agent-scoped so overlapping roots or a root +// reassigned between Codex and TraeX cannot borrow freshness. func (e *Engine) shouldSkipProviderSourceByDB( file parser.DiscoveredFile, fingerprint parser.SourceFingerprint, semantics parser.ProviderSyncSemantics, ) bool { - if file.Agent != parser.AgentCodex { + if !isCodexFormatAgent(file.Agent) { return false } - return e.shouldSkipCodexFingerprint(file.Path, fingerprint, semantics) + return e.shouldSkipCodexFingerprint( + file.Agent, file.Path, fingerprint, semantics, + ) } // shouldSkipCodexFingerprint reproduces the legacy shouldSkipCodex decision in -// terms of a provider SourceFingerprint. The fingerprint MTimeNS already folds -// in session_index.jsonl via CodexEffectiveMtime, so: +// terms of a provider SourceFingerprint. For Codex, fingerprint MTimeNS folds +// in session_index.jsonl via CodexEffectiveMtime; TraeX uses the transcript +// mtime only. Therefore: // - a stored size/hash mismatch or stale data version forces a reparse; // - an exact effective-mtime match skips; // - an effective mtime ahead of the stored mtime driven only by the index // (the raw transcript mtime is still at or below the stored mtime) skips // unless this session's stored title differs from the current index title. func (e *Engine) shouldSkipCodexFingerprint( + agent parser.AgentType, path string, fingerprint parser.SourceFingerprint, semantics parser.ProviderSyncSemantics, @@ -11073,22 +11208,26 @@ func (e *Engine) shouldSkipCodexFingerprint( if e.pathRewriter != nil { lookupPath = e.pathRewriter(path) } - storedSize, storedMtime, ok := e.db.GetFileInfoByPath(lookupPath) + storedSize, storedMtime, ok := e.db.GetFileInfoByAgentPath( + lookupPath, string(agent), + ) if !ok || storedSize != fingerprint.Size { return false } if !e.providerFingerprintHashMatchesDB( - lookupPath, + agent, lookupPath, fingerprint, semantics.FingerprintHashRequiredForFreshness, ) { return false } - if project, ok := e.db.GetProjectByPath(lookupPath); ok && + if project, ok := e.db.GetProjectByAgentPath( + lookupPath, string(agent), + ); ok && parser.NeedsProjectReparse(project) { return false } - if e.db.GetDataVersionByPath(lookupPath) < + if e.db.GetDataVersionByAgentPath(lookupPath, string(agent)) < db.CurrentDataVersion() { return false } @@ -11096,6 +11235,9 @@ func (e *Engine) shouldSkipCodexFingerprint( if storedMtime == effectiveMtime { return true } + if agent != parser.AgentCodex { + return false + } fileMtime := effectiveMtime if info, err := os.Stat(path); err == nil { fileMtime = info.ModTime().UnixNano() @@ -11213,7 +11355,9 @@ func (e *Engine) classifyCodexIndexPath( // re-canonicalizing the UUID to the preferred dated layout, which would // undo the DB-aware selection above. chosen.ProviderProcess = true - chosen.ProviderSource = e.codexPinnedProviderSource(chosen.Path) + chosen.ProviderSource = e.codexPinnedProviderSource( + parser.AgentCodex, chosen.Path, + ) out = append(out, chosen) } return out @@ -11245,19 +11389,22 @@ func (e *Engine) codexSourceFileForUUID(root, uuid string) string { return providerDiscoveredPath(source) } -// codexPinnedProviderSource builds a Codex provider SourceRef pinned to the -// exact path, bypassing the provider's live-over-archived canonicalization. It -// is used when the engine's DB-aware or mtime-aware logic has already chosen +// codexPinnedProviderSource builds a Codex-format provider SourceRef pinned to +// the exact path, bypassing the provider's live-over-archived canonicalization. +// It is used when the engine's DB-aware or mtime-aware logic has already chosen // which on-disk copy of a duplicated UUID to parse, so processProviderFile -// parses that copy instead of the provider's preferred dated layout. Returns -// nil when the Codex provider or the path's source shape is unavailable. -func (e *Engine) codexPinnedProviderSource(path string) *parser.SourceRef { - factory, ok := e.providerFactories[parser.AgentCodex] +// parses that copy instead of the provider's preferred dated layout. The agent +// selects the provider so a fork's path is pinned under the fork's own roots. +// Returns nil when that provider or the path's source shape is unavailable. +func (e *Engine) codexPinnedProviderSource( + agent parser.AgentType, path string, +) *parser.SourceRef { + factory, ok := e.providerFactories[agent] if !ok || factory == nil { return nil } provider := factory.NewProvider(parser.ProviderConfig{ - Roots: e.agentDirs[parser.AgentCodex], + Roots: e.agentDirs[agent], Machine: e.machine, }) pinner, ok := provider.(interface { diff --git a/internal/sync/engine_integration_test.go b/internal/sync/engine_integration_test.go index 0902bcab3..4497f194a 100644 --- a/internal/sync/engine_integration_test.go +++ b/internal/sync/engine_integration_test.go @@ -4686,7 +4686,11 @@ func TestCodexExecMigrationIdempotent(t *testing.T) { ) info, err := os.Stat(path) require.NoError(t, err, "stat codex session") - cacheKey := fmt.Sprintf("%s?source_hash=%x", path, sha256.Sum256([]byte(content))) + cacheKey := fmt.Sprintf( + "%s?agent=codex?source_hash=%x", + path, + sha256.Sum256([]byte(content)), + ) require.NoError(t, env.db.ReplaceSkippedFiles(map[string]int64{ cacheKey: info.ModTime().UnixNano(), diff --git a/internal/sync/engine_test.go b/internal/sync/engine_test.go index 60c79c624..ba5c9531c 100644 --- a/internal/sync/engine_test.go +++ b/internal/sync/engine_test.go @@ -5813,7 +5813,9 @@ func TestProjectIdentityIncrementalStatePreservesExplicitSourceProject( ) require.Equal(t, 0, failed) require.Equal(t, 1, written) - incrementalInfo, found := database.GetSessionForIncremental(path) + incrementalInfo, found := database.GetSessionForIncremental( + path, string(parser.AgentClaude), + ) require.True(t, found) assert.Equal(t, int64(len(initial)), incrementalInfo.FileSize) assert.Equal(t, 1, incrementalInfo.MsgCount) @@ -6574,10 +6576,11 @@ func TestShouldSkipCodexReparsesStaleProject(t *testing.T) { }, } - assert.False(t, e.shouldSkipCodexFingerprint(path, parser.SourceFingerprint{ - Size: info.Size(), - MTimeNS: info.ModTime().UnixNano(), - }, parser.ProviderSyncSemantics{}), + assert.False(t, e.shouldSkipCodexFingerprint( + parser.AgentCodex, path, parser.SourceFingerprint{ + Size: info.Size(), + MTimeNS: info.ModTime().UnixNano(), + }, parser.ProviderSyncSemantics{}), "stale generated roborev CI projects must be reparsed") } @@ -7269,10 +7272,23 @@ func TestProviderProcessCacheKeyCodexIncludesContentHash(t *testing.T) { FingerprintHashInCacheKey: true, }) - assert.Equal(t, path+"?source_hash=first-content-hash", first) - assert.Equal(t, path+"?source_hash=second-content-hash", second) + assert.Equal(t, + path+"?agent=codex?source_hash=first-content-hash", first, + ) + assert.Equal(t, + path+"?agent=codex?source_hash=second-content-hash", second, + ) assert.NotEqual(t, first, second, "same-stat content rewrites must not reuse a rowless skip entry") + + traex := providerProcessCacheKey( + parser.DiscoveredFile{Path: path, Agent: parser.AgentTraeX}, + parser.SourceRef{Provider: parser.AgentTraeX, FingerprintKey: path}, + parser.SourceFingerprint{Key: path, Hash: "first-content-hash"}, + parser.ProviderSyncSemantics{FingerprintHashInCacheKey: true}, + ) + assert.NotEqual(t, first, traex, + "agents sharing one source path must not share skip state") } func TestProviderProcessCacheKeyOmnigentContainerIncludesDataVersion(t *testing.T) { @@ -7303,13 +7319,14 @@ func TestProviderProcessCacheKeyOmnigentContainerIncludesDataVersion(t *testing. providerSemantics, ) - legacy := container + "?source_hash=container-content-hash" + legacy := container + "?agent=omnigent?source_hash=container-content-hash" assert.Equal(t, legacy+"&data_version="+strconv.Itoa(db.CurrentDataVersion()), containerKey, "whole-container cache identity must include the parser data version", ) - assert.Equal(t, memberPath+"?source_hash=member-hash", memberKey, + assert.Equal(t, + memberPath+"?agent=omnigent?source_hash=member-hash", memberKey, "virtual member cache identity must not carry a data-version suffix") } diff --git a/internal/sync/parsediff.go b/internal/sync/parsediff.go index 0b21fe6e3..fc1443d17 100644 --- a/internal/sync/parsediff.go +++ b/internal/sync/parsediff.go @@ -566,6 +566,8 @@ func (e *Engine) parseDiffSourceReliableForRaced( // - Codex deliberately uses the transcript mtime only. Its // session_index.jsonl is global to every Codex session, so an unrelated // title/index write must not mask transcript-derived parser drift. +// Codex-format forks join it: they write no index file, so the transcript +// stat is already their whole story. // - OpenHands folds base_state.json/TASKS.json/events/* (OpenHandsSnapshot). // - Copilot folds workspace.yaml (copilotEffectiveMtime). // @@ -577,26 +579,26 @@ func (e *Engine) parseDiffSourceReliableForRaced( func parseDiffLiveMtime( agent parser.AgentType, path string, ) (int64, error) { - switch agent { - case parser.AgentCodex: + switch { + case isCodexFormatAgent(agent): info, err := os.Stat(path) if err != nil { return 0, err } return info.ModTime().UnixNano(), nil - case parser.AgentOpenHands: + case agent == parser.AgentOpenHands: snapshot, err := parser.OpenHandsSnapshot(path) if err != nil { return 0, err } return snapshot.Mtime, nil - case parser.AgentCopilot: + case agent == parser.AgentCopilot: info, err := os.Stat(path) if err != nil { return 0, err } return copilotEffectiveMtime(path, info), nil - case parser.AgentCodebuff, parser.AgentFreebuff: + case agent == parser.AgentCodebuff, agent == parser.AgentFreebuff: // Codebuff and Freebuff share the same on-disk layout with // companion files (run-state.json, chat-meta.json) that can // change independently of chat-messages.json. Use the composite @@ -611,7 +613,7 @@ func parseDiffLiveMtime( }) } -// parseDiffCodexTranscriptChangedSinceStored reports whether the Codex +// parseDiffCodexTranscriptChangedSinceStored reports whether a Codex-format // transcript differs from the archived source snapshot on a size basis Codex // has historically stored. Full parses store the raw file size, while // incremental parses can store only the parser-consumed JSONL boundary when a @@ -625,7 +627,7 @@ func parseDiffLiveMtime( func parseDiffCodexTranscriptChangedSinceStored( stored *db.Session, parsed parser.ParsedSession, ) bool { - if stored == nil || parsed.Agent != parser.AgentCodex { + if stored == nil || !isCodexFormatAgent(parsed.Agent) { return false } if stored.FileSize == nil { @@ -785,7 +787,7 @@ func (e *Engine) parseDiffCollectFile( pw.sess.Agent, pw.sess.File.Path, ) liveOK := err == nil - if liveOK && pw.sess.Agent != parser.AgentCodex && + if liveOK && !isCodexFormatAgent(pw.sess.Agent) && pw.sess.File.Mtime > liveMtime { liveMtime = pw.sess.File.Mtime } diff --git a/internal/sync/parsediff_compare.go b/internal/sync/parsediff_compare.go index dfbb7b1ab..324da3378 100644 --- a/internal/sync/parsediff_compare.go +++ b/internal/sync/parsediff_compare.go @@ -370,11 +370,11 @@ func markIncrementalHistory(d *FieldDiff, agent string) { // usesIncrementalAppend reports whether an agent's sync path can clear // termination_status to NULL via UpdateSessionIncremental. Only the -// JSONL-tail agents (Claude, Codex) take that path; see +// JSONL-tail agents (Claude and the Codex format family) take that path; see // tryIncrementalJSONL call sites in engine.go. func usesIncrementalAppend(agent string) bool { return agent == string(parser.AgentClaude) || - agent == string(parser.AgentCodex) + isCodexFormatAgent(parser.AgentType(agent)) } // incrementalArtifactField reports whether a non-informational diff on diff --git a/internal/sync/provider_process_test.go b/internal/sync/provider_process_test.go index d4e806c33..fbaa4072b 100644 --- a/internal/sync/provider_process_test.go +++ b/internal/sync/provider_process_test.go @@ -808,7 +808,10 @@ func TestProcessFileProviderAuthoritativeUsesSkipReasonCacheKey(t *testing.T) { assert.True(t, res.skip) assert.True(t, res.cacheSkip) assert.False(t, res.noCacheSkip) - assert.Equal(t, source.FingerprintKey, res.skipCacheKey(sourcePath)) + assert.Equal(t, + providerAgentSkipCacheKey(source.FingerprintKey, parser.AgentCowork), + res.skipCacheKey(sourcePath), + ) } func TestProcessFileProviderAuthoritativeForceParseAllowsStaleSourceLookup(t *testing.T) { diff --git a/internal/sync/provider_sync_semantics_test.go b/internal/sync/provider_sync_semantics_test.go index 981af9620..0f5cb4d1f 100644 --- a/internal/sync/provider_sync_semantics_test.go +++ b/internal/sync/provider_sync_semantics_test.go @@ -264,7 +264,7 @@ func TestOmnigentWholeContainerCachePromotesAfterSuccessfulWrite( require.NoError(t, err) assert.NotNil(t, stored) } - wantKey := container + "?source_hash=container-hash&data_version=" + + wantKey := container + "?agent=omnigent?source_hash=container-hash&data_version=" + strconv.Itoa(db.CurrentDataVersion()) assert.Equal(t, map[string]int64{wantKey: fingerprint.MTimeNS}, engine.SnapshotSkipCache()) diff --git a/internal/sync/traex_format_test.go b/internal/sync/traex_format_test.go new file mode 100644 index 000000000..32c03dd3f --- /dev/null +++ b/internal/sync/traex_format_test.go @@ -0,0 +1,143 @@ +package sync + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.kenn.io/agentsview/internal/db" + "go.kenn.io/agentsview/internal/parser" +) + +func TestIsCodexFormatAgent(t *testing.T) { + tests := []struct { + agent parser.AgentType + want bool + }{ + {parser.AgentCodex, true}, + {parser.AgentTraeX, true}, + {parser.AgentClaude, false}, + {parser.AgentOpenCode, false}, + // The Trae IDE agent reads VS Code state, not rollout JSONL. + {parser.AgentTrae, false}, + {"", false}, + } + for _, tt := range tests { + t.Run(string(tt.agent), func(t *testing.T) { + assert.Equal(t, tt.want, isCodexFormatAgent(tt.agent)) + }) + } +} + +// TestDedupeTraeXPrefersDatedLayout covers the format-shaped duplicate +// resolution: one UUID with a live dated copy and a flat archived copy folds +// to the dated one, exactly as it does for Codex. +func TestDedupeTraeXPrefersDatedLayout(t *testing.T) { + const uuid = "019fbcca-9fd4-7d20-83dc-0762b2f839b3" + name := "rollout-2026-08-01T18-07-03-" + uuid + ".jsonl" + files := []parser.DiscoveredFile{ + { + Agent: parser.AgentTraeX, + Path: filepath.Join( + "/home/user/.trae/cli/archived_sessions", name, + ), + }, + { + Agent: parser.AgentTraeX, + Path: filepath.Join( + "/home/user/.trae/cli/sessions/2026/08/01", name, + ), + }, + } + + got := dedupeDiscoveredFiles(files) + + require.Len(t, got, 1) + assert.Equal(t, files[1].Path, got[0].Path) +} + +// TestDedupeKeepsTraeXAndCodexSeparate guards the namespace split: the two +// agents share the rollout filename shape, so a UUID that exists under both +// must survive as two files rather than collapsing into one. +func TestDedupeKeepsTraeXAndCodexSeparate(t *testing.T) { + const uuid = "019fbcca-9fd4-7d20-83dc-0762b2f839b3" + name := "rollout-2026-08-01T18-07-03-" + uuid + ".jsonl" + files := []parser.DiscoveredFile{ + { + Agent: parser.AgentCodex, + Path: filepath.Join( + "/home/user/.codex/sessions/2026/08/01", name, + ), + }, + { + Agent: parser.AgentTraeX, + Path: filepath.Join( + "/home/user/.trae/cli/sessions/2026/08/01", name, + ), + }, + } + + got := dedupeDiscoveredFiles(files) + + require.Len(t, got, 2) + assert.ElementsMatch( + t, + []string{files[0].Path, files[1].Path}, + []string{got[0].Path, got[1].Path}, + ) +} + +func TestTraeXUsesIncrementalAppend(t *testing.T) { + assert.True(t, usesIncrementalAppend(string(parser.AgentTraeX))) + assert.True(t, usesIncrementalAppend(string(parser.AgentCodex))) + assert.False(t, usesIncrementalAppend(string(parser.AgentGemini))) +} + +// TestParseDiffLiveMtimeTraeXUsesTranscriptStat pins TraeX to the same +// transcript-only live mtime Codex uses for the parse-diff raced guard. +func TestParseDiffLiveMtimeTraeXUsesTranscriptStat(t *testing.T) { + path := filepath.Join(t.TempDir(), "rollout-2026-08-01T18-07-03-x.jsonl") + require.NoError(t, os.WriteFile(path, []byte("{}\n"), 0o644)) + info, err := os.Stat(path) + require.NoError(t, err) + + got, err := parseDiffLiveMtime(parser.AgentTraeX, path) + require.NoError(t, err) + assert.Equal(t, info.ModTime().UnixNano(), got) +} + +// TestShouldSkipProviderSourceByDBIgnoresNonCodexFormat keeps the DB-aware +// skip scoped to the Codex format family. +func TestShouldSkipProviderSourceByDBIgnoresNonCodexFormat(t *testing.T) { + e := &Engine{} + assert.False(t, e.shouldSkipProviderSourceByDB( + parser.DiscoveredFile{Agent: parser.AgentGemini, Path: "/tmp/x.jsonl"}, + parser.SourceFingerprint{}, + parser.ProviderSyncSemantics{}, + )) +} + +func TestShouldSkipProviderSourceByDBScopesCodexFormatAgent(t *testing.T) { + database := openTestDB(t) + path := filepath.Join(t.TempDir(), "rollout.jsonl") + require.NoError(t, database.UpsertSession(db.Session{ + ID: "codex:shared", + Agent: string(parser.AgentCodex), + FilePath: strPtr(path), + FileSize: int64Ptr(128), + FileMtime: int64Ptr(456), + })) + require.NoError(t, database.SetSessionDataVersion( + "codex:shared", db.CurrentDataVersion(), + )) + + e := &Engine{db: database} + assert.False(t, e.shouldSkipProviderSourceByDB( + parser.DiscoveredFile{Agent: parser.AgentTraeX, Path: path}, + parser.SourceFingerprint{Size: 128, MTimeNS: 456}, + parser.ProviderSyncSemantics{}, + ), "Codex row must not satisfy TraeX database freshness") +} diff --git a/internal/sync/traex_reassignment_test.go b/internal/sync/traex_reassignment_test.go new file mode 100644 index 000000000..0ec3e9bee --- /dev/null +++ b/internal/sync/traex_reassignment_test.go @@ -0,0 +1,303 @@ +package sync_test + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.kenn.io/agentsview/internal/db" + "go.kenn.io/agentsview/internal/dbtest" + "go.kenn.io/agentsview/internal/parser" + agentsync "go.kenn.io/agentsview/internal/sync" + "go.kenn.io/agentsview/internal/testjsonl" +) + +type traexRepairFixture struct { + database *db.DB + engine *agentsync.Engine + path string + sessionsRoot string + uuid string +} + +func newTraeXRepairFixture(t *testing.T) traexRepairFixture { + t.Helper() + root := t.TempDir() + sessionsRoot := filepath.Join(root, "sessions") + const uuid = "019fbcca-9fd4-7d20-83dc-0762b2f839b3" + path := filepath.Join( + sessionsRoot, "2026", "08", "01", + "rollout-2026-08-01T18-07-03-"+uuid+".jsonl", + ) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + content := testjsonl.NewSessionBuilder(). + AddCodexMeta(tsEarly, uuid, "/workspace/project", "codex-tui"). + AddCodexMessage(tsEarlyS1, "user", "first prompt"). + String() + require.NoError(t, os.WriteFile(path, []byte(content), 0o644)) + + database := dbtest.OpenTestDB(t) + engine := agentsync.NewEngine(database, agentsync.EngineConfig{ + AgentDirs: map[parser.AgentType][]string{ + parser.AgentTraeX: {sessionsRoot}, + }, + Machine: "local", + }) + t.Cleanup(engine.Close) + stats := engine.SyncAll(t.Context(), nil) + require.Zero(t, stats.Failed) + return traexRepairFixture{ + database: database, engine: engine, path: path, + sessionsRoot: sessionsRoot, uuid: uuid, + } +} + +func seedSharedPathProjectRepairConflict( + t *testing.T, + fx traexRepairFixture, +) { + t.Helper() + traexID := "traex:" + fx.uuid + traex, err := fx.database.GetSessionFull(t.Context(), traexID) + require.NoError(t, err) + require.NotNil(t, traex) + require.NotNil(t, traex.FileMtime) + traex.Project = "roborev_ci_28293_3831737461" + require.NoError(t, fx.database.UpsertSession(*traex)) + require.NoError(t, fx.database.SetSessionDataVersion( + traexID, db.CurrentDataVersion(), + )) + + codex := *traex + codex.ID = "codex:" + fx.uuid + codex.Agent = string(parser.AgentCodex) + codex.Project = "project" + newerMtime := *traex.FileMtime + 1 + codex.FileMtime = &newerMtime + require.NoError(t, fx.database.UpsertSession(codex)) + require.NoError(t, fx.database.SetSessionDataVersion( + codex.ID, db.CurrentDataVersion(), + )) + + project, ok := fx.database.GetProjectByPath(fx.path) + require.True(t, ok) + require.Equal(t, "project", project, + "path-only lookup precondition must select the newer Codex row") + project, ok = fx.database.GetProjectByAgentPath( + fx.path, string(parser.AgentTraeX), + ) + require.True(t, ok) + require.Equal(t, "roborev_ci_28293_3831737461", project) +} + +func TestTraeXRepeatSyncSkipsUnchangedRollout(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + fx := newTraeXRepairFixture(t) + require.NoError(t, fx.database.ReplaceSkippedFiles(map[string]int64{})) + + repeat := agentsync.NewEngine(fx.database, agentsync.EngineConfig{ + AgentDirs: map[parser.AgentType][]string{ + parser.AgentTraeX: {fx.sessionsRoot}, + }, + Machine: "local", + }) + t.Cleanup(repeat.Close) + + stats := repeat.SyncAll(t.Context(), nil) + + require.Zero(t, stats.Failed) + assert.Zero(t, stats.Synced, + "an untouched TraeX rollout must not be rewritten") + assert.Equal(t, 1, stats.Skipped) +} + +func TestTraeXCachedSkipDoesNotBorrowCodexRepairState(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + fx := newTraeXRepairFixture(t) + initial, err := fx.database.GetSessionFull( + t.Context(), "traex:"+fx.uuid, + ) + require.NoError(t, err) + require.NotNil(t, initial) + require.NotNil(t, initial.FileMtime) + require.NotNil(t, initial.FileHash) + cacheKey := fx.path + "?agent=traex?source_hash=" + *initial.FileHash + fx.engine.InjectSkipCache(map[string]int64{ + cacheKey: *initial.FileMtime, + }) + require.NotEmpty(t, fx.engine.SnapshotSkipCache(), + "test must prime the provider skip-cache branch") + seedSharedPathProjectRepairConflict(t, fx) + + stats := fx.engine.SyncAll(t.Context(), nil) + require.Zero(t, stats.Failed) + + session, err := fx.database.GetSessionFull( + t.Context(), "traex:"+fx.uuid, + ) + require.NoError(t, err) + require.NotNil(t, session) + assert.Equal(t, "project", session.Project) + assert.False(t, session.LastWriteIncremental) +} + +func TestTraeXIncrementalDoesNotBorrowCodexRepairState(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + fx := newTraeXRepairFixture(t) + seedSharedPathProjectRepairConflict(t, fx) + + file, err := os.OpenFile(fx.path, os.O_APPEND|os.O_WRONLY, 0o644) + require.NoError(t, err) + _, err = file.WriteString( + testjsonl.CodexMsgJSON("assistant", "incremental reply", tsEarlyS5) + "\n", + ) + require.NoError(t, err) + require.NoError(t, file.Close()) + info, err := os.Stat(fx.path) + require.NoError(t, err) + appendTime := info.ModTime().Add(2 * time.Second) + require.NoError(t, os.Chtimes(fx.path, appendTime, appendTime)) + + fx.engine.SyncPaths([]string{fx.path}) + + session, err := fx.database.GetSessionFull( + t.Context(), "traex:"+fx.uuid, + ) + require.NoError(t, err) + require.NotNil(t, session) + assert.Equal(t, 2, session.MessageCount) + assert.Equal(t, "project", session.Project) + assert.False(t, session.LastWriteIncremental, + "stale TraeX metadata must force an authoritative full parse") +} + +func TestTraeXIncrementalSyncStoresTranscriptMtime(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + root := t.TempDir() + sessionsRoot := filepath.Join(root, "sessions") + const uuid = "019fbcca-9fd4-7d20-83dc-0762b2f839b3" + path := filepath.Join( + sessionsRoot, "2026", "08", "01", + "rollout-2026-08-01T18-07-03-"+uuid+".jsonl", + ) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + content := testjsonl.NewSessionBuilder(). + AddCodexMeta(tsEarly, uuid, "/workspace/project", "codex-tui"). + AddCodexMessage(tsEarlyS1, "user", "first prompt"). + String() + require.NoError(t, os.WriteFile(path, []byte(content), 0o644)) + + rolloutTime := time.Now().Add(-2 * time.Hour).Truncate(time.Second) + appendTime := rolloutTime.Add(30 * time.Minute) + indexTime := rolloutTime.Add(time.Hour) + require.NoError(t, os.Chtimes(path, rolloutTime, rolloutTime)) + indexPath := filepath.Join(root, parser.CodexSessionIndexFilename) + require.NoError(t, os.WriteFile(indexPath, []byte( + `{"id":"`+uuid+`","thread_name":"Codex-only title"}`+"\n", + ), 0o644)) + require.NoError(t, os.Chtimes(indexPath, indexTime, indexTime)) + + database := dbtest.OpenTestDB(t) + engine := agentsync.NewEngine(database, agentsync.EngineConfig{ + AgentDirs: map[parser.AgentType][]string{ + parser.AgentTraeX: {sessionsRoot}, + }, + Machine: "local", + }) + t.Cleanup(engine.Close) + stats := engine.SyncAll(t.Context(), nil) + require.Zero(t, stats.Failed) + + file, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644) + require.NoError(t, err) + _, err = file.WriteString( + testjsonl.CodexMsgJSON("assistant", "incremental reply", tsEarlyS5) + "\n", + ) + require.NoError(t, err) + require.NoError(t, file.Close()) + require.NoError(t, os.Chtimes(path, appendTime, appendTime)) + + engine.SyncPaths([]string{path}) + + session, err := database.GetSessionFull(t.Context(), "traex:"+uuid) + require.NoError(t, err) + require.NotNil(t, session) + require.NotNil(t, session.FileMtime) + assert.Equal(t, 2, session.MessageCount) + assert.True(t, session.LastWriteIncremental) + assert.Equal(t, appendTime.UnixNano(), *session.FileMtime) + assert.NotEqual(t, indexTime.UnixNano(), *session.FileMtime, + "TraeX incremental freshness must ignore Codex session_index.jsonl") +} + +// TestTraeXReassignsCodexPathWithoutCrossAgentFreshness covers a TraeX root +// that was previously indexed as Codex. An append made before the first TraeX +// pass must create the TraeX session without adding relabeled messages to the +// Codex row returned by the path-based incremental lookup. +func TestTraeXReassignsCodexPathWithoutCrossAgentFreshness(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + root := t.TempDir() + const uuid = "019fbcca-9fd4-7d20-83dc-0762b2f839b3" + dir := filepath.Join(root, "2026", "08", "01") + require.NoError(t, os.MkdirAll(dir, 0o755)) + path := filepath.Join( + dir, "rollout-2026-08-01T18-07-03-"+uuid+".jsonl", + ) + content := testjsonl.NewSessionBuilder(). + AddCodexMeta(tsEarly, uuid, "/workspace/project", "codex-tui"). + AddCodexMessage(tsEarlyS1, "user", "first prompt"). + String() + require.NoError(t, os.WriteFile(path, []byte(content), 0o644)) + + database := dbtest.OpenTestDB(t) + newEngine := func(agent parser.AgentType) *agentsync.Engine { + engine := agentsync.NewEngine(database, agentsync.EngineConfig{ + AgentDirs: map[parser.AgentType][]string{agent: {root}}, + Machine: "local", + }) + t.Cleanup(engine.Close) + return engine + } + + codexEngine := newEngine(parser.AgentCodex) + stats := codexEngine.SyncAll(t.Context(), nil) + require.Zero(t, stats.Failed) + require.Equal(t, path, database.GetSessionFilePath("codex:"+uuid)) + + file, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644) + require.NoError(t, err) + _, err = file.WriteString( + testjsonl.CodexMsgJSON("user", "second prompt", tsEarlyS5) + "\n", + ) + require.NoError(t, err) + require.NoError(t, file.Close()) + + traexEngine := newEngine(parser.AgentTraeX) + stats = traexEngine.SyncAll(t.Context(), nil) + require.Zero(t, stats.Failed) + + traexSession, err := database.GetSession(t.Context(), "traex:"+uuid) + require.NoError(t, err) + require.NotNil(t, traexSession) + assert.Equal(t, 2, traexSession.MessageCount) + + codexSession, err := database.GetSession(t.Context(), "codex:"+uuid) + require.NoError(t, err) + require.NotNil(t, codexSession) + assert.Equal(t, 1, codexSession.MessageCount, + "TraeX append must not reach the previous Codex session") +} diff --git a/internal/sync/verified_source_gate.go b/internal/sync/verified_source_gate.go index f37f9ad74..cd424c787 100644 --- a/internal/sync/verified_source_gate.go +++ b/internal/sync/verified_source_gate.go @@ -36,11 +36,16 @@ type verifiedSourceRecord struct { trusted bool } +type verifiedSourceKey struct { + agent parser.AgentType + path string +} + // verifiedSourceCapture binds a pre-verification signature to the path's // invalidation coordinates. Promotion succeeds only if neither coordinate // changed while content verification was in flight. type verifiedSourceCapture struct { - path string + key verifiedSourceKey signature verifiedSourceSignature epoch uint64 invalidation uint64 @@ -50,24 +55,26 @@ type verifiedSourceCapture struct { // invalidation coordinates before verification, and reports whether the same // signature was previously promoted. func (e *Engine) captureVerifiedSource( + agent parser.AgentType, path string, signature verifiedSourceSignature, ) (verifiedSourceCapture, bool) { - if path == "" { + if agent == "" || path == "" { return verifiedSourceCapture{}, false } + key := verifiedSourceKey{agent: agent, path: path} e.verifiedSourceMu.Lock() defer e.verifiedSourceMu.Unlock() if e.verifiedSources == nil { - e.verifiedSources = make(map[string]verifiedSourceRecord) + e.verifiedSources = make(map[verifiedSourceKey]verifiedSourceRecord) } - record := e.verifiedSources[path] + record := e.verifiedSources[key] if e.verifiedSourceActivePass != 0 { record.lastSeenPass = e.verifiedSourceActivePass } - e.verifiedSources[path] = record + e.verifiedSources[key] = record return verifiedSourceCapture{ - path: path, + key: key, signature: signature, epoch: e.verifiedSourceEpoch, invalidation: record.invalidationGen, @@ -77,12 +84,12 @@ func (e *Engine) captureVerifiedSource( // promoteVerifiedSource trusts a capture only when no path invalidation, // global clear, or completed-pass pruning landed after capture. func (e *Engine) promoteVerifiedSource(capture verifiedSourceCapture) { - if capture.path == "" { + if capture.key.agent == "" || capture.key.path == "" { return } e.verifiedSourceMu.Lock() defer e.verifiedSourceMu.Unlock() - record, ok := e.verifiedSources[capture.path] + record, ok := e.verifiedSources[capture.key] if !ok || capture.epoch != e.verifiedSourceEpoch || capture.invalidation != record.invalidationGen { @@ -90,28 +97,29 @@ func (e *Engine) promoteVerifiedSource(capture verifiedSourceCapture) { } record.signature = capture.signature record.trusted = true - e.verifiedSources[capture.path] = record + e.verifiedSources[capture.key] = record } // invalidateVerifiedSource drops one path's trust and advances its generation. // During an active full pass the invalidation record is marked seen so pruning // cannot erase the generation before a stale in-flight promotion observes it. -func (e *Engine) invalidateVerifiedSource(path string) { - if path == "" { +func (e *Engine) invalidateVerifiedSource(agent parser.AgentType, path string) { + if agent == "" || path == "" { return } + key := verifiedSourceKey{agent: agent, path: path} e.verifiedSourceMu.Lock() defer e.verifiedSourceMu.Unlock() if e.verifiedSources == nil { - e.verifiedSources = make(map[string]verifiedSourceRecord) + e.verifiedSources = make(map[verifiedSourceKey]verifiedSourceRecord) } - record := e.verifiedSources[path] + record := e.verifiedSources[key] record.trusted = false record.invalidationGen++ if e.verifiedSourceActivePass != 0 { record.lastSeenPass = e.verifiedSourceActivePass } - e.verifiedSources[path] = record + e.verifiedSources[key] = record } // clearVerifiedSources invalidates every trusted source and vetoes all @@ -148,9 +156,9 @@ func (e *Engine) finishVerifiedSourcePass(pass uint64, complete bool) { return } if complete { - for path, record := range e.verifiedSources { + for key, record := range e.verifiedSources { if record.lastSeenPass != pass { - delete(e.verifiedSources, path) + delete(e.verifiedSources, key) } } } @@ -213,7 +221,8 @@ func (e *Engine) verifiedProviderSourceState( } } } - capture, fresh := e.captureVerifiedSource(path, verifiedSourceSignature{ + agent := provider.Definition().Type + capture, fresh := e.captureVerifiedSource(agent, path, verifiedSourceSignature{ size: info.Size(), mtime: mtime, inode: inode, @@ -236,6 +245,7 @@ func (e *Engine) verifiedProviderSourceState( // hide a missing active row, forced file-metadata reset, old parser data // version, or project value that the current parser knows how to repair. func (e *Engine) verifiedProviderSourceFreshInDB( + agent parser.AgentType, source parser.SourceRef, wantSize, wantMtime int64, ) bool { @@ -244,7 +254,7 @@ func (e *Engine) verifiedProviderSourceFreshInDB( return false } project, dataVersion, storedSize, storedMtime, ok := - e.db.GetSourceRepairStateByPath(path) + e.db.GetSourceRepairStateByAgentPath(path, string(agent)) if !ok || parser.NeedsProjectReparse(project) { return false } @@ -260,22 +270,23 @@ func (e *Engine) verifiedLocalStatSupported(agent parser.AgentType) bool { parser.CapabilitySupported } -func (e *Engine) markVerifiedSourceSeen(path string) { - if path == "" { +func (e *Engine) markVerifiedSourceSeen(agent parser.AgentType, path string) { + if agent == "" || path == "" { return } path = filepath.Clean(path) + key := verifiedSourceKey{agent: agent, path: path} e.verifiedSourceMu.Lock() defer e.verifiedSourceMu.Unlock() if e.verifiedSourceActivePass == 0 { return } - record, ok := e.verifiedSources[path] + record, ok := e.verifiedSources[key] if !ok { return } record.lastSeenPass = e.verifiedSourceActivePass - e.verifiedSources[path] = record + e.verifiedSources[key] = record } // markVerifiedDiscoveredSources preserves trusted records for gateable sources @@ -287,7 +298,7 @@ func (e *Engine) markVerifiedDiscoveredSources(files []parser.DiscoveredFile) { } for _, file := range files { if e.verifiedLocalStatSupported(file.Agent) { - e.markVerifiedSourceSeen(file.Path) + e.markVerifiedSourceSeen(file.Agent, file.Path) } } } @@ -299,5 +310,5 @@ func (e *Engine) invalidateVerifiedDiscoveredSource(file parser.DiscoveredFile) if e.pathRewriter != nil || !e.verifiedLocalStatSupported(file.Agent) { return } - e.invalidateVerifiedSource(filepath.Clean(file.Path)) + e.invalidateVerifiedSource(file.Agent, filepath.Clean(file.Path)) } diff --git a/internal/sync/verified_source_gate_integration_test.go b/internal/sync/verified_source_gate_integration_test.go index 96f1d0b67..c091e142c 100644 --- a/internal/sync/verified_source_gate_integration_test.go +++ b/internal/sync/verified_source_gate_integration_test.go @@ -268,6 +268,103 @@ func TestVerifiedSourceGateWarmTrustDoesNotMaskDatabaseRepair(t *testing.T) { } } +func TestVerifiedSourceGateDoesNotBorrowRepairState(t *testing.T) { + root := t.TempDir() + path := filepath.Join( + root, + "rollout-2026-07-11T00-00-00-00000000-0000-0000-0000-000000000001.jsonl", + ) + require.NoError(t, os.WriteFile(path, []byte("session\n"), 0o600)) + fingerprint, err := verifiedSourceFingerprint(path) + require.NoError(t, err) + + newProvider := func(agent parser.AgentType) *verifiedSourceCountingProvider { + return &verifiedSourceCountingProvider{ + ProviderBase: parser.ProviderBase{ + Def: parser.AgentDef{ + Type: agent, DisplayName: string(agent), + IDPrefix: string(agent) + ":", FileBased: true, + }, + Caps: parser.Capabilities{Source: parser.SourceCapabilities{ + WatchSources: parser.CapabilitySupported, + ClassifyChangedPath: parser.CapabilitySupported, + CompositeFingerprint: parser.CapabilitySupported, + VerifiedLocalStat: parser.CapabilitySupported, + }}, + }, + root: root, + sources: map[string]parser.SourceRef{ + filepath.Clean(path): { + Provider: agent, Key: path, + DisplayPath: path, FingerprintKey: path, + ProjectHint: "project", + }, + }, + } + } + codexProvider := newProvider(parser.AgentCodex) + traexProvider := newProvider(parser.AgentTraeX) + database := openTestDB(t) + filePath := path + fileSize := fingerprint.Size + fileMtime := fingerprint.MTimeNS + fileHash := fingerprint.Hash + for _, agent := range []parser.AgentType{ + parser.AgentCodex, parser.AgentTraeX, + } { + id := string(agent) + ":shared" + require.NoError(t, database.UpsertSession(db.Session{ + ID: id, Project: "project", Machine: "host", + Agent: string(agent), FilePath: &filePath, + FileSize: &fileSize, FileMtime: &fileMtime, + FileHash: &fileHash, + })) + require.NoError(t, database.SetSessionDataVersion( + id, db.CurrentDataVersion(), + )) + } + + engine := NewEngine(database, EngineConfig{ + AgentDirs: map[parser.AgentType][]string{ + parser.AgentCodex: {root}, parser.AgentTraeX: {root}, + }, + Machine: "host", + ProviderFactories: []parser.ProviderFactory{ + verifiedSourceCountingFactory{provider: codexProvider}, + verifiedSourceCountingFactory{provider: traexProvider}, + }, + ProviderMigrationModes: map[parser.AgentType]parser.ProviderMigrationMode{ + parser.AgentCodex: parser.ProviderMigrationProviderAuthoritative, + parser.AgentTraeX: parser.ProviderMigrationProviderAuthoritative, + }, + }) + fileFor := func(agent parser.AgentType) parser.DiscoveredFile { + source := parser.SourceRef{ + Provider: agent, Key: path, + DisplayPath: path, FingerprintKey: path, + ProjectHint: "project", + } + return parser.DiscoveredFile{ + Path: path, Agent: agent, + ProviderSource: &source, ProviderProcess: true, + } + } + + runVerifiedSourcePass(t, engine, []parser.DiscoveredFile{ + fileFor(parser.AgentCodex), + }) + runVerifiedSourcePass(t, engine, []parser.DiscoveredFile{ + fileFor(parser.AgentTraeX), + }) + require.NoError(t, database.DeleteSession("traex:shared")) + + res := engine.processFile(t.Context(), fileFor(parser.AgentTraeX)) + require.ErrorContains(t, res.err, + "unexpected parse after seeding stored source state") + assert.Equal(t, 2, traexProvider.fingerprintCalls, + "missing TraeX state must invalidate only TraeX trust and reverify") +} + func TestVerifiedSourceGateRechecksAfterStatAndWatcherInvalidation(t *testing.T) { engine, provider, files := newVerifiedSourceArchive(t, 1) file := files[0] @@ -375,7 +472,9 @@ func TestVerifiedSourceGateLegacyClaudeRowMustEstablishFingerprint(t *testing.T) "unexpected parse after seeding stored source state") assert.Equal(t, 1, provider.fingerprintCalls, "a legacy row without file_hash must not keep taking the stat-only skip") - record, ok := engine.verifiedSources[path] + record, ok := engine.verifiedSources[verifiedSourceKey{ + agent: parser.AgentClaude, path: path, + }] require.True(t, ok) assert.False(t, record.trusted, "failed parsing must not promote source trust") diff --git a/internal/sync/verified_source_gate_internal_test.go b/internal/sync/verified_source_gate_internal_test.go index 7404e94c7..ffcc5f95b 100644 --- a/internal/sync/verified_source_gate_internal_test.go +++ b/internal/sync/verified_source_gate_internal_test.go @@ -7,6 +7,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "go.kenn.io/agentsview/internal/parser" ) func verifiedSourceSignatureForTest(seed int64) verifiedSourceSignature { @@ -25,49 +27,79 @@ func TestVerifiedSourceGatePromotionAndInvalidation(t *testing.T) { t.Run("promotion trusts the captured signature", func(t *testing.T) { e := &Engine{} - capture, fresh := e.captureVerifiedSource(path, signature) + capture, fresh := e.captureVerifiedSource( + parser.AgentCodex, path, signature, + ) assert.False(t, fresh) e.promoteVerifiedSource(capture) - _, fresh = e.captureVerifiedSource(path, signature) + _, fresh = e.captureVerifiedSource(parser.AgentCodex, path, signature) assert.True(t, fresh) _, fresh = e.captureVerifiedSource( - path, verifiedSourceSignatureForTest(2), + parser.AgentCodex, path, verifiedSourceSignatureForTest(2), ) assert.False(t, fresh, "a changed signature must re-verify") }) t.Run("path invalidation vetoes stale promotion", func(t *testing.T) { e := &Engine{} - capture, _ := e.captureVerifiedSource(path, signature) - e.invalidateVerifiedSource(path) + capture, _ := e.captureVerifiedSource( + parser.AgentCodex, path, signature, + ) + e.invalidateVerifiedSource(parser.AgentCodex, path) e.promoteVerifiedSource(capture) - _, fresh := e.captureVerifiedSource(path, signature) + _, fresh := e.captureVerifiedSource( + parser.AgentCodex, path, signature, + ) assert.False(t, fresh) }) t.Run("global clear vetoes stale promotion", func(t *testing.T) { e := &Engine{} - capture, _ := e.captureVerifiedSource(path, signature) + capture, _ := e.captureVerifiedSource( + parser.AgentCodex, path, signature, + ) e.clearVerifiedSources() e.promoteVerifiedSource(capture) - _, fresh := e.captureVerifiedSource(path, signature) + _, fresh := e.captureVerifiedSource( + parser.AgentCodex, path, signature, + ) assert.False(t, fresh) }) t.Run("unrelated invalidation does not veto promotion", func(t *testing.T) { e := &Engine{} - capture, _ := e.captureVerifiedSource(path, signature) - e.invalidateVerifiedSource("archive/session-b.jsonl") + capture, _ := e.captureVerifiedSource( + parser.AgentCodex, path, signature, + ) + e.invalidateVerifiedSource( + parser.AgentCodex, "archive/session-b.jsonl", + ) e.promoteVerifiedSource(capture) - _, fresh := e.captureVerifiedSource(path, signature) + _, fresh := e.captureVerifiedSource( + parser.AgentCodex, path, signature, + ) assert.True(t, fresh) }) } +func TestVerifiedSourceGateSeparatesAgentsAtSharedPath(t *testing.T) { + e := &Engine{} + path := "archive/shared.jsonl" + signature := verifiedSourceSignatureForTest(1) + capture, fresh := e.captureVerifiedSource( + parser.AgentCodex, path, signature, + ) + require.False(t, fresh) + e.promoteVerifiedSource(capture) + + _, fresh = e.captureVerifiedSource(parser.AgentTraeX, path, signature) + assert.False(t, fresh) +} + func TestVerifiedSourceGateFullPassPruning(t *testing.T) { e := &Engine{} keepSignature := verifiedSourceSignatureForTest(1) @@ -75,10 +107,10 @@ func TestVerifiedSourceGateFullPassPruning(t *testing.T) { firstPass := e.beginVerifiedSourcePass() keepCapture, keepFresh := e.captureVerifiedSource( - "archive/keep.jsonl", keepSignature, + parser.AgentCodex, "archive/keep.jsonl", keepSignature, ) dropCapture, dropFresh := e.captureVerifiedSource( - "archive/drop.jsonl", dropSignature, + parser.AgentCodex, "archive/drop.jsonl", dropSignature, ) require.False(t, keepFresh) require.False(t, dropFresh) @@ -88,12 +120,16 @@ func TestVerifiedSourceGateFullPassPruning(t *testing.T) { require.Len(t, e.verifiedSources, 2) secondPass := e.beginVerifiedSourcePass() - _, keepFresh = e.captureVerifiedSource("archive/keep.jsonl", keepSignature) + _, keepFresh = e.captureVerifiedSource( + parser.AgentCodex, "archive/keep.jsonl", keepSignature, + ) require.True(t, keepFresh) e.finishVerifiedSourcePass(secondPass, true) require.Len(t, e.verifiedSources, 1) - _, ok := e.verifiedSources["archive/keep.jsonl"] + _, ok := e.verifiedSources[verifiedSourceKey{ + agent: parser.AgentCodex, path: "archive/keep.jsonl", + }] assert.True(t, ok) incompletePass := e.beginVerifiedSourcePass() @@ -108,14 +144,16 @@ func TestVerifiedSourceGateInvalidationSurvivesActivePassPruning(t *testing.T) { signature := verifiedSourceSignatureForTest(1) pass := e.beginVerifiedSourcePass() - capture, _ := e.captureVerifiedSource(path, signature) - e.invalidateVerifiedSource(path) + capture, _ := e.captureVerifiedSource( + parser.AgentCodex, path, signature, + ) + e.invalidateVerifiedSource(parser.AgentCodex, path) e.finishVerifiedSourcePass(pass, true) require.Len(t, e.verifiedSources, 1, "the invalidation record must survive its active pass") e.promoteVerifiedSource(capture) - _, fresh := e.captureVerifiedSource(path, signature) + _, fresh := e.captureVerifiedSource(parser.AgentCodex, path, signature) assert.False(t, fresh, "pruning must not erase the generation that vetoes stale promotion") } @@ -138,7 +176,8 @@ func TestVerifiedSourceGateRetainedBudget(t *testing.T) { for i := range count { path := "archive/provider/session-" + strconv.Itoa(i) + ".jsonl" capture, _ := e.captureVerifiedSource( - path, verifiedSourceSignatureForTest(int64(i)), + parser.AgentCodex, path, + verifiedSourceSignatureForTest(int64(i)), ) e.promoteVerifiedSource(capture) } @@ -168,7 +207,8 @@ func TestVerifiedSourceGateRetainedBudget(t *testing.T) { for i := range largeCount { path := "archive/provider/session-" + strconv.Itoa(i) + ".jsonl" capture, _ := e.captureVerifiedSource( - path, verifiedSourceSignatureForTest(int64(i)), + parser.AgentCodex, path, + verifiedSourceSignatureForTest(int64(i)), ) e.promoteVerifiedSource(capture) } @@ -176,7 +216,10 @@ func TestVerifiedSourceGateRetainedBudget(t *testing.T) { secondPass := e.beginVerifiedSourcePass() for i := range smallCount { path := "archive/provider/session-" + strconv.Itoa(i) + ".jsonl" - e.captureVerifiedSource(path, verifiedSourceSignatureForTest(int64(i))) + e.captureVerifiedSource( + parser.AgentCodex, path, + verifiedSourceSignatureForTest(int64(i)), + ) } e.finishVerifiedSourcePass(secondPass, true) assert.Len(t, e.verifiedSources, smallCount,