fix review findings: bump session format version + restore late turn-end warn

Codex review of the trace-event fold found two merge-blockers.

Blocker #1 — format version. Folding usage onto assistant/message and removing
the standalone usage/error events changed the persisted SessionEventMap shape,
which per the AGENTS.md "bump the version and reject — don't migrate" policy
requires a backend to reject any non-current log. Centralize the version in an
exported SESSION_FORMAT_VERSION constant (dsh-session), read by both write sites
(Session constructor default, SessionStore.prepare header) and the coordinator's
load-time assertVersion check. The constant is pinned at 0: while unreleased the
on-disk format is unstable/pre-release, so breaking shape churn is absorbed at v0
(no monotonic bump until the first tagged release) and any non-0 log is rejected
on load — no migration. Update every test/fixture/doc that stamps a
currently-written header to the constant, bump the ACP snapshot fixture + golden
headers to v0, and keep the version-rejection test meaningful by switching its
bad value to a clearly non-current 99. AGENTS.md documents both the monotonic
(SQLite SCHEMA_VERSION) and pinned-0 (session log) pre-release stances.

Blocker #2 — restore the late turn-end warn. failTurn now sets the error reason
only while the turn is still open; once turn/end is appended (a throwing
agent/turn-end listener after closeTurn) the reason can no longer reach the
durable log, so the late throw is logged via ctx.logger.warn instead of
vanishing into a futile post-close assignment. A regression test asserts the
warn fires.

Also guard the normal-step assistant/message append with the same
content-or-usage condition as the max-tokens branch (a content-less, usage-less
step records no trace-only row), with a covering test.
This commit is contained in:
Tianyi Cui
2026-06-21 11:08:10 +08:00
parent 2be60b9a22
commit b0422f2a50
32 changed files with 127 additions and 64 deletions

View File

@@ -9,7 +9,7 @@
import { Context, Service } from 'cordis'
import { isAbsolute } from 'node:path'
import type { ContentBlock, Message, MessageSource } from '@deepseek-ai/dsh-llm'
import { SessionId } from './types.ts'
import { SESSION_FORMAT_VERSION, SessionId } from './types.ts'
import type { CreateSessionOptions, SessionEvent, SessionEventMap, SessionEventType, SessionHeader } from './types.ts'
import { isJsonValue } from './json.ts'
@@ -112,7 +112,7 @@ export class Session {
// structuredClone can never hit a non-cloneable value here.
this.log = seed.map(event => structuredClone(event))
}
this.header = header ?? { version: 1, id, createdAt: Date.now() }
this.header = header ?? { version: SESSION_FORMAT_VERSION, id, createdAt: Date.now() }
}
get events(): readonly SessionEvent[] {
@@ -284,7 +284,7 @@ export class SessionStore extends Service {
throw new Error(`session cwd must be an absolute path, got "${cwd}"`)
}
const header: SessionHeader = {
version: 1,
version: SESSION_FORMAT_VERSION,
id: sessionId,
createdAt: options?.meta?.createdAt ?? Date.now(),
...cwd !== undefined ? { cwd } : {},

View File

@@ -9,6 +9,23 @@ export function SessionId(id: string): SessionId {
return id as SessionId
}
/**
* The on-disk session format version, stamped into every newly-written
* {@link SessionHeader} and enforced by every persistence backend on load. The
* single source of truth for the version — write sites and the load-time check
* all read it.
*
* It is **`0`** deliberately: while the harness is unreleased the on-disk format
* is **unstable / pre-release, with no compatibility implied**. Breaking changes
* to the persisted {@link SessionEventMap} shape (folding fields onto an event,
* removing a variant, …) happen freely and do NOT bump this — v0 absorbs all
* pre-release churn, and a backend simply REJECTS any log not at v0 (there is no
* migration; no persisted user data exists to preserve). A real, monotonically
* bumped version policy begins at the first tagged release, when a specific
* format boundary becomes worth distinguishing.
*/
export const SESSION_FORMAT_VERSION = 0
/**
* Immutable session metadata — written once at creation and never rewritten.
*
@@ -19,7 +36,11 @@ export function SessionId(id: string): SessionId {
* metadata) writes such a header.
*/
export interface SessionHeader {
/** On-disk format version; a persistence backend rejects unknown versions. */
/**
* On-disk format version, stamped from {@link SESSION_FORMAT_VERSION} when the
* session is created. A persistence backend rejects any other version on load
* (no migration — see the constant).
*/
version: number
/** The session's id (mirrors the {@link Session}'s id). */
id: SessionId

View File

@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import SessionStore, { SESSION_FORMAT_VERSION, Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
describe('Session', () => {
it('derives message history from the event log', () => {
@@ -255,11 +255,11 @@ describe('SessionStore', () => {
expect(ctx.sessions.get(SessionId('lifecycle'))).toBeUndefined()
})
it('synthesizes a minimal v1 header for a bare-created session', async () => {
it('synthesizes a minimal current-version header for a bare-created session', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('plain'))
expect(session.header).toMatchObject({ version: 1, id: 'plain' })
expect(session.header).toMatchObject({ version: SESSION_FORMAT_VERSION, id: 'plain' })
expect(typeof session.header.createdAt).toBe('number')
expect(session.header.cwd).toBeUndefined()
expect(session.header.parentSession).toBeUndefined()
@@ -272,7 +272,7 @@ describe('SessionStore', () => {
meta: { cwd: '/work/project', parentSession: SessionId('parent') },
})
expect(session.header).toMatchObject({
version: 1,
version: SESSION_FORMAT_VERSION,
id: 'child',
cwd: '/work/project',
parentSession: 'parent',
@@ -288,9 +288,9 @@ describe('SessionStore', () => {
expect(ctx.sessions.get(SessionId('rel'))).toBeUndefined()
})
it('a bare Session() constructed without the store still exposes a v1 header', () => {
it('a bare Session() constructed without the store still exposes a current-version header', () => {
const session = new Session(SessionId('bare'))
expect(session.header).toMatchObject({ version: 1, id: 'bare' })
expect(session.header).toMatchObject({ version: SESSION_FORMAT_VERSION, id: 'bare' })
expect(typeof session.header.createdAt).toBe('number')
})