diff --git a/docs/spec/03-runtime/04-data-storage.md b/docs/spec/03-runtime/04-data-storage.md index bcb12e6c04..e9bc11c0b2 100644 --- a/docs/spec/03-runtime/04-data-storage.md +++ b/docs/spec/03-runtime/04-data-storage.md @@ -1700,6 +1700,11 @@ source-discriminated transcript authority owned by the Node agent sidecar. They are never inserted into SQLite and never copied to the Desktop transcript directory. `session.list` merges their projections with Rust-owned Desktop summaries, and `session.get` routes by the opaque `native-pi:` id. +Discovery deduplicates native files that share the same JSONL `header.id`, +keeping the projection with the newest transcript `updatedAt`. The selected +file retains its path-derived opaque session id; duplicate files are not +rewritten or deleted, and their paths are omitted from the in-memory lookup +map for the current scan. Detail reads take an immutable byte snapshot, parse it into an in-memory `SessionManager`, and follow the current native branch. They must not call diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md index c1eb88c8db..6146f96cf7 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -15461,6 +15461,19 @@ plugin-form fixtures in an isolated temporary directory at runtime. - **Specs:** IPC native routing; runtime §12; storage §12; security §12. - **Status:** Documented; run after integration into main. +### E2E-SESSION-native-pi-duplicate-id-collapses + +- **Preconditions:** An isolated Pi agent directory contains a valid native v3 + session and a copied JSONL file under a nested directory with the same + `header.id`. +- **Steps:** Start PI-Desktop with the fixture-only agent directory, refresh the + session list, open the listed native session, and search its transcript. +- **Expected:** The list contains one row for that native id. Detail and search + resolve through the selected file; duplicate files remain byte-identical and + are not rewritten or deleted. +- **Specs:** runtime §12; storage §12; ADR 0254. +- **Status:** Source-regression covered; full Desktop journey Draft. + ### E2E-SUBAGENT-ordered-model-fallback-preserves-work diff --git a/packages/agent-runtime/src/native-pi-session.test.ts b/packages/agent-runtime/src/native-pi-session.test.ts index d9f4b34366..43bc90963d 100644 --- a/packages/agent-runtime/src/native-pi-session.test.ts +++ b/packages/agent-runtime/src/native-pi-session.test.ts @@ -61,6 +61,24 @@ describe("NativePiSessionService", () => { expect(readFileSync(f.file)).toEqual(before); }); + it("collapses copied session files onto one entry per native id (#1359)", async () => { + const f = fixture(); + // A backup copy of the session under a subdirectory of the scan root: + // same header id, different path. It must not become a second session. + const backupDir = join(f.sessionRoot, "backup"); + mkdirSync(backupDir, { recursive: true }); + writeFileSync(join(backupDir, "fixture-copy.jsonl"), f.text); + + // Control: without the copy, list() names the original file's id. + const control = new NativePiSessionService({ agentDir: f.agentDir, sessionRoot: f.sessionRoot }); + const [original] = await control.list(); + + const service = new NativePiSessionService({ agentDir: f.agentDir, sessionRoot: f.sessionRoot }); + const sessions = await service.list(); + expect(sessions).toHaveLength(1); + expect(sessions[0].id).toBe(original.id); + }); + it("searches native metadata and active-branch message text without rewriting JSONL", async () => { const f = fixture(); const service = new NativePiSessionService({ agentDir: f.agentDir, sessionRoot: f.sessionRoot }); diff --git a/packages/agent-runtime/src/native-pi-session.ts b/packages/agent-runtime/src/native-pi-session.ts index b57a5cd442..f6b74d197c 100644 --- a/packages/agent-runtime/src/native-pi-session.ts +++ b/packages/agent-runtime/src/native-pi-session.ts @@ -547,7 +547,30 @@ export class NativePiSessionService { } }), ); - return summaries + // A copied session file (backup, manual copy) under the scan root must + // not become a second session: identity follows the header id, not the + // path. Keep the newest write per native id and drop the stale copies + // from the records map so lookups cannot route to a dead duplicate + // (#1359). + const seenNativeIds = new Set(); + const deduped: Array = []; + const sortedByRecency = [...summaries].sort((a, b) => + (b?.updatedAt ?? "").localeCompare(a?.updatedAt ?? ""), + ); + const winnerIds = new Set(); + for (const summary of sortedByRecency) { + if (!summary) continue; + const record = this.records.get(summary.id); + if (!record) continue; + if (seenNativeIds.has(record.nativeId)) { + this.records.delete(summary.id); + continue; + } + seenNativeIds.add(record.nativeId); + winnerIds.add(summary.id); + } + deduped.push(...summaries.filter((summary) => summary && winnerIds.has(summary.id))); + return deduped .filter((session): session is SessionSummary => Boolean(session)) .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); }