Explorar el Código

fix: reject legacy fallback headers

Tianyi Cui hace 2 meses
padre
commit
49e45ff184

+ 1 - 1
docs/cordis-catalog/services.md

@@ -245,7 +245,7 @@ list(): Session[]
 fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session
 ```
 
-Source: [`packages/core/session/src/index.ts:593`](../../packages/core/session/src/index.ts)
+Source: [`packages/core/session/src/index.ts:604`](../../packages/core/session/src/index.ts)
 
 ## `ctx.skills` — `SkillService`
 

+ 1 - 1
docs/core-data-structures/session.md

@@ -109,7 +109,7 @@ export interface EpochHeader {
 }
 ```
 
-Canonical form: an empty system prompt, an empty tool list, and an empty session prefix are absent fields, matching how requests are built. `messagePrefix` is the durable record of the `agent/session-prefix` waterfall's product (the request is `messagePrefix + derived history`); it is composed once per loop instance and included in every full snapshot that instance records. Legacy v0 logs containing the removed `request/header-delta` format are rejected at seed and persistence-load boundaries rather than replayed incompletely.
+Canonical form: an empty system prompt, an empty tool list, and an empty session prefix are absent fields, matching how requests are built. `messagePrefix` is the durable record of the `agent/session-prefix` waterfall's product (the request is `messagePrefix + derived history`); it is composed once per loop instance and included in every full snapshot that instance records. Legacy v0 logs containing the removed `request/header-delta` event or its full-snapshot `fallback` reason are rejected at seed, append, and persistence-load boundaries rather than replayed incompletely.
 
 ## `SessionEvent<T>` — one log entry
 

+ 1 - 1
docs/rfc/implemented/simplification/2026-07-12-simplify-session-log-representation.md

@@ -18,7 +18,7 @@ This proposal deliberately retains append and replacement `sourceEventSeqs`, cra
 
 Request headers use canonical full snapshots only. Initial and resume anchors remain full snapshots even when unchanged; an in-instance change appends another full `request/header` with reason `change`. The delta event, codec types, diff/apply helpers, and codec-only `fallback` reason are removed. Request reconstruction selects the latest snapshot.
 
-`SESSION_FORMAT_VERSION` remains pinned at `0`, so seed and persistence-load validation explicitly reject an old v0 log containing `request/header-delta`. There is no compatibility fold or migration. JSONL and SQLite tests pin this fail-loud boundary, and the ACP snapshot harness represents legitimate mid-session changes as full pinned headers and full readable prompts.
+`SESSION_FORMAT_VERSION` remains pinned at `0`, so seed, append, and persistence-load validation explicitly reject old v0 `request/header-delta` events and full snapshots carrying the removed `fallback` reason. There is no compatibility fold or migration. JSONL and SQLite tests pin this fail-loud boundary, and the ACP snapshot harness represents legitimate mid-session changes as full pinned headers and full readable prompts.
 
 ## Alternatives considered
 

+ 1 - 1
packages/core/session/README.md

@@ -53,7 +53,7 @@ Durable values need one accepted representation, not a check followed by a secon
 
 ### Request-header reconstruction (`request-header.ts`)
 
-The `request/header` event records a full canonical `EpochHeader` snapshot with reason `initial`, `resume`, or `change`, making the request envelope logged session state and every conversation request a pure function of the log. `foldRequestHeader(events)` selects the latest snapshot from a log or prefix; `canonicalHeader` pins the one representation of absence (empty system/tools/messagePrefix ≡ absent fields), and `headerEquals` compares canonical headers. `EpochHeader.messagePrefix` is the durable record of the `agent/session-prefix` waterfall's product — composed once per loop instance, the request is `messagePrefix + derived history`, and `deriveMessages()` never returns it. Legacy v0 seeds containing the removed `request/header-delta` format are rejected rather than partially replayed.
+The `request/header` event records a full canonical `EpochHeader` snapshot with reason `initial`, `resume`, or `change`, making the request envelope logged session state and every conversation request a pure function of the log. `foldRequestHeader(events)` selects the latest snapshot from a log or prefix; `canonicalHeader` pins the one representation of absence (empty system/tools/messagePrefix ≡ absent fields), and `headerEquals` compares canonical headers. `EpochHeader.messagePrefix` is the durable record of the `agent/session-prefix` waterfall's product — composed once per loop instance, the request is `messagePrefix + derived history`, and `deriveMessages()` never returns it. Legacy v0 seeds containing the removed `request/header-delta` event or its full-snapshot `fallback` reason are rejected rather than partially replayed.
 
 ### Session event vocabulary (`types.ts`)
 

+ 11 - 0
packages/core/session/src/index.ts

@@ -212,6 +212,15 @@ function assertSessionEventEnvelope(value: Record<string, unknown>, index: numbe
   }
 }
 
+/** Reject request-header vocabulary removed with the legacy delta codec. */
+function assertSupportedRequestHeader(type: string, data: unknown, location: string): void {
+  if (type === 'request/header'
+    && data !== null && typeof data === 'object' && !Array.isArray(data)
+    && (data as Record<string, unknown>)['reason'] === 'fallback') {
+    throw new Error(`${location} uses unsupported legacy request/header reason "fallback"`)
+  }
+}
+
 type SessionCallback = (...args: unknown[]) => unknown
 
 /** Resolve one listener snapshot, including Cordis's internal dispatch checks. */
@@ -306,6 +315,7 @@ export class Session {
           throw new Error(`seed event at index ${index} is not losslessly JSON-serializable`)
         }
         assertSessionEventEnvelope(snapshot, index)
+        assertSupportedRequestHeader(snapshot.type, snapshot.data, `seed event at index ${index}`)
         if (snapshot.seq !== index) {
           throw new Error(`seed event at index ${index} has seq ${snapshot.seq} (expected ${index}); seed must be contiguous from 0`)
         }
@@ -391,6 +401,7 @@ export class Session {
     if (dataSnapshot === undefined) {
       throw new Error(`session event "${type}" carries non-JSON-serializable data`)
     }
+    assertSupportedRequestHeader(type, dataSnapshot, `session event "${type}"`)
     const surfaceMetadataSnapshot = snapshotJsonValue(surfaceMetadata)
     if (surfaceMetadataSnapshot === undefined) {
       throw new Error(`session event "${type}" carries non-JSON-serializable surface metadata`)

+ 14 - 0
packages/core/session/tests/request-header.spec.ts

@@ -68,4 +68,18 @@ describe('legacy request-header format', () => {
     }] as unknown as SessionEvent[]
     expect(() => new Session(SessionId('legacy'), legacy)).toThrow(/unsupported legacy request\/header-delta/)
   })
+
+  it('rejects the removed fallback reason in seeds and untyped appends', () => {
+    const legacy = [{
+      type: 'request/header', seq: 0, time: 1, data: { header: { config: CONFIG }, reason: 'fallback' },
+    }] as unknown as SessionEvent[]
+    expect(() => new Session(SessionId('legacy-seed-reason'), legacy))
+      .toThrow('unsupported legacy request/header reason "fallback"')
+
+    const session = new Session(SessionId('legacy-append-reason'))
+    const appendLegacy = session.append.bind(session) as (type: string, data: unknown) => SessionEvent
+    expect(() => appendLegacy('request/header', { header: { config: CONFIG }, reason: 'fallback' }))
+      .toThrow('unsupported legacy request/header reason "fallback"')
+    expect(session.events).toHaveLength(0)
+  })
 })

+ 19 - 0
packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts

@@ -162,6 +162,25 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => {
     await expect(ctx.sessionPersistence.load(m.id)).rejects.toThrow(/unsupported legacy request\/header-delta event at seq 1/)
   })
 
+  it('rejects a stored v0 full header carrying the legacy fallback reason', async () => {
+    const m = meta('legacy-header-fallback', '/legacy')
+    const path = logPath(root, m.cwd, m.id)
+    await mkdir(sessionDir(root, m.cwd), { recursive: true })
+    await writeFile(path, [
+      JSON.stringify(toHeaderLine(m)),
+      JSON.stringify({
+        type: 'request/header',
+        seq: 0,
+        time: 1,
+        data: { header: { config: { model: 'legacy' } }, reason: 'fallback' },
+      }),
+      '',
+    ].join('\n'))
+
+    await expect(ctx.sessionPersistence.load(m.id))
+      .rejects.toThrow(/unsupported legacy request\/header reason "fallback" at seq 0/)
+  })
+
   it('persists a forked child seed through the existing session write path', async () => {
     const source = ctx.sessions.create(SessionId('persist-parent'), { meta: { cwd: '/workspace' } })
     appendClosedTurn(source)

+ 19 - 0
packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts

@@ -161,6 +161,25 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
     await mounted.dispose()
   })
 
+  it('rejects a stored v0 full header carrying the legacy fallback reason', async () => {
+    const path = await freshDbPath()
+    const m = meta('legacy-header-fallback', '/legacy')
+    const db = openDatabase(path, 'wal')
+    db.prepare('INSERT INTO sessions (id, version, created_at, cwd, parent_session, seed_length) VALUES (?, ?, ?, ?, NULL, NULL)')
+      .run(m.id, m.version, m.createdAt, m.cwd ?? null)
+    db.prepare('INSERT INTO events (session_id, seq, type, time, data) VALUES (?, ?, ?, ?, ?)')
+      .run(m.id, 0, 'request/header', 1, JSON.stringify({
+        header: { config: { model: 'legacy' } },
+        reason: 'fallback',
+      }))
+    db.close()
+
+    const mounted = await backend(path)
+    await expect(mounted.ctx.sessionPersistence.load(m.id))
+      .rejects.toThrow(/unsupported legacy request\/header reason "fallback" at seq 0/)
+    await mounted.dispose()
+  })
+
   it('an interrupted turn (rows after the last turn/end) is PRESERVED and closed during load', async () => {
     const path = await freshDbPath()
     const m = meta('crash')

+ 5 - 0
packages/session-persistence/session-persistence/src/coordinator.ts

@@ -158,6 +158,11 @@ function assertSupportedEvents(events: readonly SessionEvent[], id: SessionId):
   if (legacy !== undefined) {
     throw new Error(`session "${id}" contains unsupported legacy request/header-delta event at seq ${legacy.seq}`)
   }
+  const fallback = events.find(event => event.type === 'request/header'
+    && (event.data as { reason?: string }).reason === 'fallback')
+  if (fallback !== undefined) {
+    throw new Error(`session "${id}" contains unsupported legacy request/header reason "fallback" at seq ${fallback.seq}`)
+  }
 }
 
 /**

+ 36 - 0
packages/session-persistence/session-persistence/tests/persistence.spec.ts

@@ -22,6 +22,16 @@ function legacyHeaderDelta(seq = 0): SessionEvent {
   } as unknown as SessionEvent
 }
 
+/** An obsolete full-header reason fixture from the removed delta codec. */
+function legacyFallbackHeader(seq = 0): SessionEvent {
+  return {
+    type: 'request/header',
+    seq,
+    time: 1,
+    data: { header: { config: { model: 'legacy' } }, reason: 'fallback' },
+  } as unknown as SessionEvent
+}
+
 /** Optional plugin config: an EXTERNAL store shared across backend instances. */
 interface MemoryConfig { store?: MemoryStore }
 
@@ -190,6 +200,19 @@ describe('SessionPersistence service registration', () => {
     await fiber.dispose()
   })
 
+  it('rejects a legacy fallback header buffered by a pre-change live producer', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SessionStore)
+    const fiber = await ctx.plugin(MemoryPersistence)
+    const session = ctx.sessions.create(SessionId('legacy-fallback-live'), { meta: { cwd: '/legacy' } })
+    const appendLegacy = session.append.bind(session) as (type: string, data: unknown) => SessionEvent
+
+    expect(() => appendLegacy('request/header', legacyFallbackHeader().data))
+      .toThrow('unsupported legacy request/header reason "fallback"')
+    expect(session.events).toHaveLength(0)
+    await fiber.dispose()
+  })
+
   it('rejects a legacy stored prefix during live HMR adoption', async () => {
     const id = SessionId('legacy-hmr')
     const m = meta(id, '/legacy')
@@ -207,4 +230,17 @@ describe('SessionPersistence service registration', () => {
       .rejects.toThrow(/unsupported legacy request\/header-delta event at seq 0/)
     await Promise.allSettled([fiber.dispose()])
   })
+
+  it('rejects a stored legacy fallback header during load', async () => {
+    const id = SessionId('legacy-fallback-load')
+    const m = meta(id, '/legacy')
+    const store: MemoryStore = new Map([[id, { meta: m, events: [legacyFallbackHeader()] }]])
+    const ctx = new Context()
+    await ctx.plugin(SessionStore)
+    const fiber = await ctx.plugin(MemoryPersistence, { store })
+
+    await expect(ctx.sessionPersistence.load(id))
+      .rejects.toThrow('unsupported legacy request/header reason "fallback" at seq 0')
+    await fiber.dispose()
+  })
 })