diff --git a/apps/desktop/electron/main/persistence-outbox.ts b/apps/desktop/electron/main/persistence-outbox.ts index 17772c2c6..d9a657285 100644 --- a/apps/desktop/electron/main/persistence-outbox.ts +++ b/apps/desktop/electron/main/persistence-outbox.ts @@ -41,13 +41,17 @@ export class PersistenceOutbox { await this.loaded; const existing = this.entries.findIndex((item) => item.key === entry.key); if (existing >= 0) this.entries[existing] = entry; - else if (this.entries.length >= MAX_ENTRIES) { - this.logger("error", "session persistence outbox is full", { - size: this.entries.length, - max: MAX_ENTRIES, - }); - return; - } else this.entries.push(entry); + else { + if (this.entries.length >= MAX_ENTRIES) await this.flush(getHost); + if (this.entries.length >= MAX_ENTRIES) { + this.logger("error", "session persistence outbox is full", { + size: this.entries.length, + max: MAX_ENTRIES, + }); + return; + } + this.entries.push(entry); + } await this.persist(); void this.flush(getHost); } @@ -89,11 +93,17 @@ export class PersistenceOutbox { turnId: current.turnId, }); } catch (error) { - this.logger("warn", "session persistence flush paused", { + if (!isDuplicateMessageIdError(error)) { + this.logger("warn", "session persistence flush paused", { + key: current.key, + data: String(error), + }); + return; + } + this.logger("warn", "session persistence flush skipped duplicate message id", { key: current.key, data: String(error), }); - return; } // A newer snapshot may have replaced this key while the host wrote it. // Only remove the exact entry acknowledged by that write. @@ -141,3 +151,7 @@ export class PersistenceOutbox { await write; } } + +function isDuplicateMessageIdError(error: unknown): boolean { + return /UNIQUE constraint failed: messages\.id/i.test(String(error)); +} diff --git a/apps/desktop/test/persistence-outbox.test.mjs b/apps/desktop/test/persistence-outbox.test.mjs index 6a836394a..c416e2c4e 100644 --- a/apps/desktop/test/persistence-outbox.test.mjs +++ b/apps/desktop/test/persistence-outbox.test.mjs @@ -56,3 +56,69 @@ test("deleting a session drops its queued outbox entries (D318)", async () => { ["keep"], ); }); + +function mockHost(handler) { + return { + isAvailable: () => true, + call: async (method, params) => handler(method, params), + }; +} + +test("duplicate message id does not stall later outbox entries (D444)", async () => { + const dir = await mkdtemp(join(tmpdir(), "pi-outbox-")); + const logs = []; + const outbox = new PersistenceOutbox(dir, (level, message, data) => { + logs.push({ level, message, data }); + }); + const calls = []; + const host = mockHost(async (_method, params) => { + calls.push(params); + if (params.message.id === "call_421522") { + throw new Error("UNIQUE constraint failed: messages.id"); + } + }); + const getHost = () => host; + await outbox.enqueue( + { + key: "message:s1:call_421522", + sessionId: "s1", + message: { id: "call_421522" }, + }, + getHost, + ); + await outbox.enqueue( + { + key: "message:s2:assistant-1", + sessionId: "s2", + message: { id: "assistant-1" }, + }, + getHost, + ); + await outbox.flush(getHost); + assert.equal(outbox.size(), 0); + assert.equal(calls.length, 2); + assert.equal(calls[1].message.id, "assistant-1"); + assert.ok( + logs.some((row) => row.message === "session persistence flush skipped duplicate message id"), + ); +}); + +test("non-unique flush errors still pause the outbox", async () => { + const dir = await mkdtemp(join(tmpdir(), "pi-outbox-")); + const outbox = new PersistenceOutbox(dir, silent); + const host = mockHost(async () => { + throw new Error("session not found"); + }); + const getHost = () => host; + await outbox.enqueue( + { key: "message:s1:a", sessionId: "s1", message: { id: "a" } }, + getHost, + ); + await outbox.enqueue( + { key: "message:s2:b", sessionId: "s2", message: { id: "b" } }, + getHost, + ); + await outbox.flush(getHost); + assert.equal(outbox.size(), 2); +}); + diff --git a/crates/host-core/src/sessions.rs b/crates/host-core/src/sessions.rs index f02a97107..5ab628632 100644 --- a/crates/host-core/src/sessions.rs +++ b/crates/host-core/src/sessions.rs @@ -964,7 +964,31 @@ pub fn restore_orphaned_session(db: &Database, session_id: &str) -> Result if !path.exists() { return Ok(false); } - let records = dedupe_records(transcripts::read_transcript(db.data_dir(), session_id)?); + let read = transcripts::read_transcript_with_compactions(db.data_dir(), session_id)?; + let mut records = dedupe_records(read.messages); + let mut remapped = false; + for record in &mut records { + if let Some(owner) = message_owner(db, &record.id)? { + if owner != session_id { + record.id = namespaced_message_id(session_id, &record.id); + remapped = true; + } + } + } + if remapped { + let header = records + .first() + .map(|record| record.created_at.clone()) + .unwrap_or_else(|| ms_to_ts(now_ms())); + transcripts::write_transcript_with_compactions( + db.data_dir(), + session_id, + &header, + &records, + &read.compactions, + )?; + invalidate_transcript_layout(session_id); + } let created_at = records .first() .map(|record| ts_to_ms(&record.created_at)) @@ -1718,9 +1742,11 @@ pub fn append_message( ) -> Result<()> { let message = crate::session_collaboration::prepare_append(db, session_id, message, turn_id)?; let session_created = ensure_session_for_append(db, session_id)?; - let (record, text) = ui_to_record(&message); + let (mut record, text) = ui_to_record(&message); // Electron may replay an outbox entry after a host restart. Message ids - // are globally unique, so an existing row is already the durable result. + // are globally unique, so an existing row in this session is already the + // durable result. Provider toolCallIds are not globally unique: a collision + // with another session is remapped before any transcript write (D444). // A steering input reserves its preceding streaming assistant's position; // only a terminal assistant snapshot may replace that provisional row. if message_indexed(db, session_id, &record.id)? { @@ -1742,6 +1768,22 @@ pub fn append_message( return Ok(()); } } else { + if let Some(owner) = message_owner(db, &record.id)? { + if owner != session_id { + let original_id = record.id.clone(); + record.id = namespaced_message_id(session_id, &record.id); + if message_indexed(db, session_id, &record.id)? { + return Ok(()); + } + // Old hosts wrote JSONL then failed UNIQUE. Replaying that + // leftover must not append a second remapped line (D444). + if transcript_contains_id(db, session_id, &original_id)? { + return Ok(()); + } + } else { + return Ok(()); + } + } append_record( db, session_id, @@ -1798,6 +1840,29 @@ fn message_indexed(db: &Database, session_id: &str, message_id: &str) -> Result< Ok(existing.is_some()) } +/// Session that currently owns this globally unique message id, if any. +fn message_owner(db: &Database, message_id: &str) -> Result> { + let owner: Option = db + .conn() + .query_row( + "SELECT session_id FROM messages WHERE id = ?1", + params![message_id], + |row| row.get(0), + ) + .optional()?; + Ok(owner) +} + +fn namespaced_message_id(session_id: &str, message_id: &str) -> String { + format!("{session_id}:{message_id}") +} + +fn transcript_contains_id(db: &Database, session_id: &str, message_id: &str) -> Result { + Ok(transcripts::read_transcript(db.data_dir(), session_id)? + .iter() + .any(|record| record.id == message_id)) +} + fn streaming_assistant_indexed(db: &Database, session_id: &str, message_id: &str) -> Result { let layout = session_layout(db, session_id)?; let mut end = layout.message_count(); @@ -3942,6 +4007,94 @@ mod tests { assert_eq!(last_seq, 2); } + #[test] + fn append_message_remaps_ids_owned_by_another_session() { + let db = test_db(); + let first = create_session(&db, None, None, None, None, None).unwrap(); + let second = create_session(&db, None, None, None, None, None).unwrap(); + let colliding = user_msg("call_421522", "first", "2026-09-16T15:42:41Z"); + append_message(&db, &first.id, &colliding, None).unwrap(); + append_message(&db, &second.id, &colliding, None).unwrap(); + + let first_detail = get_session(&db, &first.id).unwrap().unwrap(); + let second_detail = get_session(&db, &second.id).unwrap().unwrap(); + assert_eq!(first_detail.messages[0].id, "call_421522"); + assert_eq!( + second_detail.messages[0].id, + format!("{}:call_421522", second.id) + ); + assert_eq!(second_detail.messages[0].content, "first"); + + append_message(&db, &second.id, &colliding, None).unwrap(); + assert_eq!( + get_session(&db, &second.id) + .unwrap() + .unwrap() + .messages + .len(), + 1 + ); + assert_eq!( + transcripts::read_transcript(db.data_dir(), &second.id) + .unwrap() + .len(), + 1 + ); + } + + #[test] + fn append_message_does_not_duplicate_a_unique_failed_jsonl_leftover() { + let db = test_db(); + let first = create_session(&db, None, None, None, None, None).unwrap(); + let second = create_session(&db, None, None, None, None, None).unwrap(); + let colliding = user_msg("call_421522", "leftover", "2026-09-16T15:42:41Z"); + append_message(&db, &first.id, &colliding, None).unwrap(); + let (record, _) = ui_to_record(&colliding); + transcripts::append_message(db.data_dir(), &second.id, &second.created_at, &record) + .unwrap(); + append_message(&db, &second.id, &colliding, None).unwrap(); + assert_eq!( + transcripts::read_transcript(db.data_dir(), &second.id) + .unwrap() + .len(), + 1 + ); + assert_eq!( + get_session(&db, &second.id) + .unwrap() + .unwrap() + .messages + .len(), + 1 + ); + } + + #[test] + fn restore_orphaned_session_remaps_ids_owned_by_another_session() { + let db = test_db(); + let first = create_session(&db, None, None, None, None, None).unwrap(); + let second = create_session(&db, None, None, None, None, None).unwrap(); + let colliding = user_msg("call_421522", "orphan", "2026-09-16T15:42:41Z"); + append_message(&db, &first.id, &colliding, None).unwrap(); + let (record, _) = ui_to_record(&colliding); + transcripts::append_message(db.data_dir(), &second.id, &second.created_at, &record) + .unwrap(); + db.conn() + .execute("DELETE FROM sessions WHERE id = ?1", params![second.id]) + .unwrap(); + assert!(restore_orphaned_session(&db, &second.id).unwrap()); + let restored = get_session(&db, &second.id).unwrap().unwrap(); + assert_eq!(restored.messages.len(), 1); + assert_eq!( + restored.messages[0].id, + format!("{}:call_421522", second.id) + ); + assert_eq!( + get_session(&db, &first.id).unwrap().unwrap().messages[0].id, + "call_421522" + ); + } + #[test] fn user_attachments_roundtrip_as_canonical_blocks() { let db = test_db(); diff --git a/docs/adr/0041-bounded-host-runtime-and-persistence-outbox.md b/docs/adr/0041-bounded-host-runtime-and-persistence-outbox.md index a0f5ec12f..eed2d1d31 100644 --- a/docs/adr/0041-bounded-host-runtime-and-persistence-outbox.md +++ b/docs/adr/0041-bounded-host-runtime-and-persistence-outbox.md @@ -25,7 +25,10 @@ tool message appends pass through an Electron-main-owned, file-backed outbox and are flushed sequentially after a successful host handshake. The handshake **awaits** that drain before the host is advertised ready, so a cold `session.get` cannot race a queued assistant/tool row (D327). Host-side -message append is idempotent by message id. +message append is idempotent by message id. A colliding id that already belongs +to another session is remapped to `{sessionId}:{id}` before the JSONL write +(D444). The outbox treats `UNIQUE constraint failed: messages.id` as an ack, +not a pause. ## Consequences diff --git a/docs/spec/03-runtime/04-data-storage.md b/docs/spec/03-runtime/04-data-storage.md index ef1cd9e7c..d7a96d28f 100644 --- a/docs/spec/03-runtime/04-data-storage.md +++ b/docs/spec/03-runtime/04-data-storage.md @@ -711,7 +711,7 @@ stream (as today) with `text = NULL`. ```sql CREATE TABLE messages ( mid INTEGER PRIMARY KEY, -- stable rowid: FTS anchor, VACUUM-safe - id TEXT NOT NULL UNIQUE, -- caller-facing uuid (optimistic UI) + id TEXT NOT NULL UNIQUE, -- caller-facing uuid (optimistic UI); colliding provider toolCallIds remap to {sessionId}:{id} (D444) session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, turn_id TEXT REFERENCES turns(id) ON DELETE SET NULL, seq INTEGER NOT NULL, -- per-session ordinal @@ -1418,7 +1418,11 @@ line and search text, retaining sequence, owning turn and every other row. Late partial snapshots and duplicate terminal snapshots cannot overwrite the settled result. Recovery promotes the latest checkpoint in that same position. The outbox likewise keeps a newer snapshot that replaces an append while its -host call is still pending. No schema migration is required. +host call is still pending. If `messages.id` already belongs to another +session, the host remaps to `{sessionId}:{id}` before any JSONL write; a +replay of the original id is a no-op against that remapped row. The outbox +treats `UNIQUE constraint failed: messages.id` as an ack and keeps draining +(D444). No schema migration is required. ## 12. Native Pi session authority (ADR 0254) diff --git a/docs/spec/03-runtime/06-host-rpc-protocol.md b/docs/spec/03-runtime/06-host-rpc-protocol.md index df5ab860a..8354d3a10 100644 --- a/docs/spec/03-runtime/06-host-rpc-protocol.md +++ b/docs/spec/03-runtime/06-host-rpc-protocol.md @@ -560,14 +560,17 @@ resource exhaustion (`EAGAIN` / `WouldBlock`) with bounded backoff, never retries a command after it has started, and reaps timed-out children before releasing the execution slot. -`session.appendMessage` is idempotent by message id. Electron main may keep +`session.appendMessage` is idempotent by message id. An id already indexed in +another session is remapped to `{sessionId}:{id}` before the JSONL write, and +a later replay of the original id is a no-op (D444). Electron main may keep message appends in its application-owned outbox while host-core is restarting; -the outbox flushes in order after a successful handshake. A missing sessions -row is restored from the live JSONL (or created as a stub under the same id -when the file is gone) so a queued outbox can drain (D318). `session.delete` -drops that session's outbox entries. In-flight checkpoints never go through -the outbox: a checkpoint is only meaningful against a live host, and replaying -one after the final row would be wrong. +the outbox flushes in order after a successful handshake and treats +`UNIQUE constraint failed: messages.id` as an ack rather than pausing the +queue. A missing sessions row is restored from the live JSONL (or created as a +stub under the same id when the file is gone) so a queued outbox can drain +(D318). `session.delete` drops that session's outbox entries. In-flight +checkpoints never go through the outbox: a checkpoint is only meaningful +against a live host, and replaying one after the final row would be wrong. ### Permissions - `permissions.evaluate` diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md index 58ca9e363..c62cd048f 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -1031,6 +1031,28 @@ identify the platform validation still needed. - **Milestone**: M2 - **Status**: Draft +#### E2E-SESSION-outbox-duplicate-id-does-not-drop-history + +- **Preconditions**: Two sessions whose provider tool rows reuse the same + `toolCallId` as `messages.id` (for example `call_421522`). The first session + already persisted that id. The second session then runs several turns so + assistant/tool rows queue behind the colliding append. +- **Steps**: 1) Complete a tool call in session A with id `call_421522`. + 2) In session B, run the same provider tool id, then continue chatting for + several turns. 3) Quit and reopen. 4) Open both sessions. +- **Expected**: Session A still has its original tool row. Session B kept its + later turns after reopen; the colliding tool row is stored under + `{sessionB}:{call_421522}` (or an equivalent remapped id). The persistence + outbox is empty and did not stay paused on `UNIQUE constraint failed: + messages.id`. No later assistant/tool row from either session is missing. +- **Specs linked**: `03-runtime/04-data-storage.md`, + `03-runtime/06-host-rpc-protocol.md`, ADR 0041, D444 +- **Acceptance**: F (persistence) +- **Milestone**: M2 +- **Status**: Unit-covered (`append_message_remaps_ids_owned_by_another_session`, + `persistence-outbox.test.mjs`); desktop journey outstanding + + #### E2E-011: Switch between project and temporary sessions - **Preconditions**: One retained project session and one path-less Temporary diff --git a/docs/spec/08-meta/decisions-log.md b/docs/spec/08-meta/decisions-log.md index 4b95a75d5..6c22ec812 100644 --- a/docs/spec/08-meta/decisions-log.md +++ b/docs/spec/08-meta/decisions-log.md @@ -261,6 +261,7 @@ Gold source: local Codex electron captures; latest row wins where rows conflict. | D317 | Live/durable transcript merge keeps chronological order | **Amend ADR 0120 / D261 / ADR 0137 in the renderer: `mergeLiveSessionMessages` stitches a bounded durable `session.get` page onto the live snapshot in chronological order. Live rows older than the page stay before it; an optimistic user row or in-flight assistant/tool tail stays after it. Completed overlapping ids still prefer the durable row. Live-only rows are never appended after the durable page. A previously appended older prefix is healed back in front when its `createdAt` precedes the page. Renderer only: no IPC, storage, host-protocol, or pagination change.** *(amended by D324)* | A session that stayed open past the newest-100 page accumulated hundreds of live rows. Revalidation used the durable page as the array prefix and appended the rest, so D261's trailing mounted window painted old history and hid the just-sent prompt while the turn kept running. Abort then idle reload showed only the durable page, which is why the messages appeared to come back after Stop plus another switch. | | D324 | Idle session switch keeps a not-yet-flushed completed tail | **Amend D317 / ADR 0137 in the renderer: `selectSession` stitches the bounded durable page onto the live snapshot whenever the session is running or still has live provenance (`liveSessionTranscripts`). A completed assistant/tool row that the durable page does not yet contain stays. Live provenance is cleared only once that page already contains every live id, not merely because the turn ended. Renderer only: no IPC, storage, host-protocol, or pagination change.** | User prompts persist before the model runs; assistant/tool rows go through the async outbox and land after `message_end`. After `agent_end`, idle revalidation used the durable page only and dropped replies that were already on screen (issue #41). | | D327 | Completed replies survive process restart | **Amend D299 / ADR 0153 / ADR 0041: the finished `message_end` snapshot is checkpointed before the outbox append; `completed`/`error` `session.endTurn` deletes `.inflight.json` only when that id is already indexed; boot recovery skips completed leftovers so the outbox can append first; handshake awaits that drain then `session.recoverInflightMessages` promotes any leftover as `complete`. Additive RPC, no protocol or schema version change.** | The same user-only transcript as issue #41 after a full quit (issue #42): user rows are durable before the model runs, assistant/tool rows sit in the outbox, and D324's live merge does not survive process teardown. | +| D444 | Cross-session message id collision remaps; outbox UNIQUE is not a pause | **Amend ADR 0041 / D327: `session.appendMessage` still treats an id already indexed in the same session as a no-op (or a terminal-assistant replacement of a streaming row). If that globally unique `messages.id` already belongs to another session, the host remaps to `{sessionId}:{id}` before writing JSONL or the index, and a later replay of the original id is also a no-op against the remapped row. Electron's persistence outbox treats `UNIQUE constraint failed: messages.id` as an idempotent ack and continues draining, so one colliding toolCallId cannot stall every later assistant/tool row or fill the 1024-entry cap. No protocol or schema version change.** | Provider tool rows used `toolCallId` (e.g. `call_421522`) as `messages.id`. Those ids are not globally unique. A collision failed SQLite after the JSONL write, the FIFO outbox paused on that head, later rows from every session never persisted, and a quit showed earlier history as gone (issue #523). | | D328 | Parent-judged subagent lifetime | **Amend ADR 0089 / ADR 0119 / ADR 0129: idle and duration watchdogs are not armed; parent `agent_end` does not abort running delegates; the runtime keeps the durable turn open and prompts the parent with reports when they finish. `TaskWait` timeout and `TaskList` return a heartbeat (agent, status, elapsed, turns, last tool). Only `TaskStop` and user Stop abort a delegate. Explicit `maxTurns` and the concurrency cap of 10 stay. Runtime only: no IPC, storage, or host-protocol change.** *(amended by D352: a terminal parent error does abort leftover delegates and returns the session to idle)* | The parent cannot see live delegate work and treats `TaskWait` expiry as permission to finish, after which abort-on-end killed the unfinished job. Waiting is an event loop; models are not. | | D352 | Parent fatal error aborts leftover delegates | **Amend D328 / ADR 0166: parent idle still keeps the durable turn open and does not abort running delegates. A terminal parent provider/stream error (including exhausted HTTP 429), overflow-recovery failure, mutation-budget termination, or rejected prompt aborts leftover delegates, skips the D328 resume prompt, and emits `agent_end`. `isRunning` does not count leftover delegates after that error, so Continue is not `AGENT_BUSY`. A later `agent_end` must not overwrite the failed TurnOutcomeCard. Each new parent prompt bumps a turn epoch and only auto-resumes delegates started in that turn. Runtime and renderer only: no IPC, storage, or host-protocol change.** | After a parent 429 the UI showed Continue while a Gemini (or other) subagent kept the sidecar busy, so the next prompt was `session already has an active turn`. See ADR 0189 and E2E-155. | | D354 | Label both macOS release architectures | **Amend D353 / ADR 0145: both native macOS release lanes pass target-specific electron-builder patterns. Public assets are `PI-Desktop--arm64.dmg` / `PI-Desktop--arm64-mac.zip` and `PI-Desktop--x64.dmg` / `PI-Desktop--x64-mac.zip`. Generated per-architecture updater feeds retain these URLs and checksums. This changes release asset naming only; updater ownership, signing, and delivery mode remain unchanged.** | The arm64 asset `PI-Desktop-0.14.4.dmg` did not identify its architecture, while the x64 lane used a different `Intel` convention. Both public architectures need self-describing, standard labels. | diff --git a/docs/zh-CN/spec/03-runtime/04-data-storage.md b/docs/zh-CN/spec/03-runtime/04-data-storage.md index 837df8416..f1f920d83 100644 --- a/docs/zh-CN/spec/03-runtime/04-data-storage.md +++ b/docs/zh-CN/spec/03-runtime/04-data-storage.md @@ -649,7 +649,7 @@ CREATE UNIQUE INDEX idx_session_collaboration_receipt ```sql CREATE TABLE messages ( mid INTEGER PRIMARY KEY, -- stable rowid: FTS anchor, VACUUM-safe - id TEXT NOT NULL UNIQUE, -- caller-facing uuid (optimistic UI) + id TEXT NOT NULL UNIQUE, -- 调用方 uuid(乐观 UI);撞车的供应商 toolCallId 改写为 {sessionId}:{id}(D444) session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, turn_id TEXT REFERENCES turns(id) ON DELETE SET NULL, seq INTEGER NOT NULL, -- per-session ordinal @@ -1274,4 +1274,6 @@ UI投影损失 终态助手替换索引中的流式助手。更新仅涉及该转录行和搜索文本,保留顺序、所属回合及 其他所有行。迟到的部分快照和重复终态快照不能覆盖已落定结果。恢复时在原位置应用 最新检查点。如果主机调用尚未完成时出现更新的追加快照,outbox 同样保留该快照。 -无需存储架构迁移。 +若 `messages.id` 已属于另一会话,主机在写 JSONL 之前改写为 `{sessionId}:{id}`; +重放原始 id 对该改写行无操作。outbox 把 `UNIQUE constraint failed: messages.id` +当作确认并继续排空(D444)。无需存储架构迁移。 diff --git a/docs/zh-CN/spec/03-runtime/06-host-rpc-protocol.md b/docs/zh-CN/spec/03-runtime/06-host-rpc-protocol.md index d5323acc5..2eeefd763 100644 --- a/docs/zh-CN/spec/03-runtime/06-host-rpc-protocol.md +++ b/docs/zh-CN/spec/03-runtime/06-host-rpc-protocol.md @@ -398,10 +398,7 @@ off | minimal | low | medium | high | xhigh | max 在命令启动后重试命令,并在之前获取超时的子命令 释放执行槽。 -`session.appendMessage` 通过消息 ID 是幂等的。 Electron 主要可以保留 -当 host-core 重新启动时,消息会附加到其应用程序拥有的发件箱中; -握手成功后,发件箱会按顺序冲洗。进行中检查点从不经过发件箱:检查点只对存活的 -主机有意义,在最终行之后重放它是错误的。 +`session.appendMessage` 通过消息 ID 是幂等的。若该 id 已属于另一会话,则在写 JSONL 之前改写为 `{sessionId}:{id}`,之后重放原始 id 为无操作(D444)。Electron 主进程可以在 host-core 重启时把消息留在应用自有 outbox 里;握手成功后按顺序冲洗,并把 `UNIQUE constraint failed: messages.id` 当作确认而不是停整队。进行中检查点从不经过发件箱:检查点只对存活的主机有意义,在最终行之后重放它是错误的。 ### 权限 - `permissions.evaluate` diff --git a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md index 209bc54ce..dec38ae2c 100644 --- a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md @@ -483,6 +483,17 @@ unit/integration 测试;代码 pull request 使用有选择且高价值的 E2E - **里程碑**:M2 - **状态**:草案 +#### E2E-SESSION-outbox-duplicate-id-does-not-drop-history + +- **先决条件**:两个会话的工具行把同一个 `toolCallId` 当作 `messages.id`(例如 `call_421522`)。第一个会话已经持久化该 id。第二个会话随后又跑了若干回合,助手/工具行排在这条碰撞追加之后。 +- **步骤**:1) 在会话 A 完成一条 id 为 `call_421522` 的工具调用。2) 在会话 B 使用同一供应商工具 id,再继续聊几轮。3) 退出并重新打开。4) 打开两个会话。 +- **预期**:会话 A 仍有原来的工具行。会话 B 重新打开后仍有后续回合;碰撞的工具行存成 `{sessionB}:{call_421522}`(或等价改写 id)。持久化 outbox 为空,没有停在 `UNIQUE constraint failed: messages.id`。任一会话都没有丢掉更晚的助手/工具行。 +- **链接规格**:`03-runtime/04-data-storage.md`、`03-runtime/06-host-rpc-protocol.md`、ADR 0041、D444 +- **接受**:F(持久化) +- **里程碑**:M2 +- **状态**:单位已覆盖(`append_message_remaps_ids_owned_by_another_session`、`persistence-outbox.test.mjs`);桌面旅程待补 + + #### E2E-173:展开中的实时委托运行过程跟随最新输出 - **先决条件**:一个绑定项目的 Agent 会话,提供商流被模拟为一个仍在运行的 diff --git a/docs/zh-CN/spec/08-meta/decisions-log.md b/docs/zh-CN/spec/08-meta/decisions-log.md index 94dbaeb04..5f8c145c6 100644 --- a/docs/zh-CN/spec/08-meta/decisions-log.md +++ b/docs/zh-CN/spec/08-meta/decisions-log.md @@ -265,6 +265,7 @@ | D317 | 实时/持久化记录合并保持时间顺序 | **在渲染器中修订 ADR 0120 / D261 / ADR 0137:`mergeLiveSessionMessages` 把有界的持久化 `session.get` 页按时间顺序缝到实时快照上。早于该页的实时行留在它前面;乐观用户行或进行中的助手/工具尾巴留在它后面。重叠的已完成 id 仍以持久化行为准。实时独有的行绝不能追加到持久化页之后。若更早的前缀曾被错误追加到页后,当其 `createdAt` 早于该页时把它治回前面。仅渲染器:不改动 IPC、存储、主机协议或分页。** *(由 D324 修订)* | 一直开着的会话会在最新 100 条页之外再积累数百行实时记录。再验证曾把持久化页当作数组前缀、把其余行追加在后面,于是 D261 的尾部挂载窗口画出旧历史,刚发送的提示消失,回合却仍在跑。中止后再空闲加载只显示持久化页,所以看起来像是点了停止再切换,消息才回来。 | | D324 | 空闲切换保留尚未刷入的已完成尾巴 | **在渲染器中修订 D317 / ADR 0137:`selectSession` 在会话仍在运行或仍有实时来源(`liveSessionTranscripts`)时,把有界持久化页缝到实时快照上。持久化页尚未包含的已完成助手/工具行予以保留。只有当该页已包含每一个实时 id 时才清掉实时来源,而不是回合一结束就清。仅渲染器:不改动 IPC、存储、主机协议或分页。** | 用户提示在模型运行前就落盘;助手/工具行走异步 outbox,在 `message_end` 之后才写入。`agent_end` 之后的空闲再验证只用持久化页,会丢掉已经显示在屏幕上的回复(issue #41)。 | | D327 | 已完成回复在进程重启后仍然存在 | **修订 D299 / ADR 0153 / ADR 0041:`message_end` 的完成快照在 outbox 追加之前先做检查点;`completed`/`error` 的 `session.endTurn` 仅在该 id 已索引时才删除 `.inflight.json`;启动恢复跳过已完成残留以便 outbox 先追加;握手等待排空后再用 `session.recoverInflightMessages` 把剩下的提升为 `complete`。附加 RPC,不改协议或 schema 版本。** | 与 issue #41 相同的「只剩用户消息」画面,但发生在完全退出之后(issue #42):用户行在模型运行前就落盘,助手/工具行停在 outbox,D324 的实时缝合无法跨进程。 | +| D444 | 跨会话消息 id 碰撞时改写;outbox 的 UNIQUE 不再停整队 | **修订 ADR 0041 / D327:`session.appendMessage` 仍把本会话已索引的 id 当作无操作(或用终态助手替换流式行)。若该全局唯一 `messages.id` 已属于另一会话,主机在写 JSONL 或索引之前改写为 `{sessionId}:{id}`,之后重放原始 id 也对改写后的行无操作。Electron 持久化 outbox 把 `UNIQUE constraint failed: messages.id` 当作幂等确认并继续排空,因此一个撞车的 toolCallId 不会卡住之后所有助手/工具行,也不会把 1024 上限填满。不改协议或 schema 版本。** | 工具行曾把 `toolCallId`(如 `call_421522`)当作 `messages.id`,这些 id 并不全局唯一。碰撞会在 JSONL 写完后 SQLite 失败,FIFO outbox 停在队头,所有会话的后续行都无法落盘,退出后再开就像前期历史丢了(issue #523)。 | | D328 | 由父级判定的子智能体寿命 | **修订 ADR 0089 / ADR 0119 / ADR 0129:不武装空闲和时长看门狗;父级 `agent_end` 不中止仍在跑的委托;运行时保持持久回合打开,完成时把报告交给父级。`TaskWait` 超时和 `TaskList` 返回心跳。只有 `TaskStop` 和用户 Stop 会中止委托。显式 `maxTurns` 和并发上限 10 保留。运行时专用,不改 IPC、存储或主机协议。** | 父级看不到实时委托工作,把 `TaskWait` 到期当成可以收工;等待是事件循环,模型不是。 | | D352 | 父级终态错误中止残留委托 | **修订 D328 / ADR 0166:父级空闲仍不中止委托。终端父级 provider/stream 错误(含耗尽的 HTTP 429)、溢出恢复失败、变更预算终止或拒绝的提示会中止残留委托、跳过 D328 续跑提示并发出 `agent_end`。`isRunning` 在该错误后不计残留委托,因此继续不会变成 `AGENT_BUSY`。见 ADR 0189 与 E2E-155。** | 父级 429 后 UI 显示继续,子智能体仍占 sidecar,下一条提示变成 session already has an active turn。 | | D354 | 同时标注两个 macOS 发布架构 | **修订 D353 / ADR 0145:两条原生 macOS 发布通道都使用带架构后缀的 electron-builder 模式。公开资产为 `PI-Desktop--arm64.dmg` / `-arm64-mac.zip` 以及 `-x64.dmg` / `-x64-mac.zip`。更新源 URL 与校验和保持一致。只改发布资产命名。** | arm64 资产原先不标明架构,x64 又用另一套 Intel 约定。 |