feat(session): refuse session logs a build cannot faithfully read

Old runtimes meeting a newer session format now fail loud instead of
misreading: version refusal names the direction (newer: upgrade the
harness; older: no upgrade path) and points at the raw JSONL log, and an
event type outside the generated known vocabulary refuses resume unless
its envelope carries the new ignorable: true marker (default: required,
so a forgotten marker over-refuses instead of silently resuming a gutted
session). gen-persistence-catalog now also emits
KNOWN_SESSION_EVENT_TYPES; SQLite stores the marker in a dedicated
column (SCHEMA_VERSION 15). The versioning design (monotonic integer,
n->n+1 upgrader chain, migrate-on-continue) is recorded in the
session-log-version-mechanism Agent Note.
This commit is contained in:
creatixchu
2026-08-10 15:22:50 +08:00
parent 53c58e3914
commit 9186824e87
40 changed files with 622 additions and 106 deletions

View File

@@ -92,6 +92,7 @@ describe('scanRows', () => {
seq: e.seq, type: e.type, time: e.time, data: JSON.stringify(e.data),
source_event_seqs: se.sourceEventSeqs !== undefined ? JSON.stringify(se.sourceEventSeqs) : null,
surface_op: se.surfaceOp !== undefined ? JSON.stringify(se.surfaceOp) : null,
ignorable: e.ignorable === true ? 1 : null,
}
})
@@ -142,8 +143,8 @@ describe('scanRows', () => {
it('throws on an unparsable row inside the committed region', () => {
const withCorruptCommitted: EventRow[] = [
{ seq: 0, type: 'turn/start', time: 1, data: '{not json', source_event_seqs: null, surface_op: null }, // corrupt, sits before a turn/end
{ seq: 1, type: 'turn/end', time: 2, data: JSON.stringify({ turn: 1, reason: { kind: 'completed' } }), source_event_seqs: null, surface_op: null },
{ seq: 0, type: 'turn/start', time: 1, data: '{not json', source_event_seqs: null, surface_op: null, ignorable: null }, // corrupt, sits before a turn/end
{ seq: 1, type: 'turn/end', time: 2, data: JSON.stringify({ turn: 1, reason: { kind: 'completed' } }), source_event_seqs: null, surface_op: null, ignorable: null },
]
expect(() => scanRows(withCorruptCommitted)).toThrow(/unparsable committed event/)
})
@@ -151,7 +152,7 @@ describe('scanRows', () => {
it('tolerates an unparsable torn-tail row after the last turn/end', () => {
const withCorruptTail: EventRow[] = [
...rows(oneTurnLog()),
{ seq: 6, type: 'turn/start', time: 7, data: '{not json', source_event_seqs: null, surface_op: null }, // torn fragment, no committed turn/end after
{ seq: 6, type: 'turn/start', time: 7, data: '{not json', source_event_seqs: null, surface_op: null, ignorable: null }, // torn fragment, no committed turn/end after
]
const { preserved, tornFrom } = scanRows(withCorruptTail)
expect(preserved).toEqual(oneTurnLog())
@@ -658,7 +659,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
})
it('exposes the schema version constant', () => {
expect(SCHEMA_VERSION).toBe(14)
expect(SCHEMA_VERSION).toBe(15)
})
it('keeps the revision stable for an empty repair hook', async () => {
@@ -857,6 +858,7 @@ describe('surface field round-trip', () => {
data: JSON.stringify({ turn: 1, step: 1, content: [] }),
source_event_seqs: JSON.stringify([3, 5]),
surface_op: JSON.stringify('append'),
ignorable: null,
}
const event = rowToEvent(row)
expect((event as SurfaceEvent).sourceEventSeqs).toEqual([3, 5])
@@ -869,6 +871,7 @@ describe('surface field round-trip', () => {
data: JSON.stringify({ turn: 1, step: 1, content: [] }),
source_event_seqs: JSON.stringify([0, 1]),
surface_op: JSON.stringify({ op: 'replace', start: 0, end: 1 }),
ignorable: null,
}
const event = rowToEvent(row)
expect((event as SurfaceEvent).sourceEventSeqs).toEqual([0, 1])
@@ -879,10 +882,10 @@ describe('surface field round-trip', () => {
const rows: EventRow[] = [
{ seq: 0, type: 'user/message', time: 1,
data: JSON.stringify({ content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }),
source_event_seqs: null, surface_op: '{"op":"replace","start":0,"end":0}' },
source_event_seqs: null, surface_op: '{"op":"replace","start":0,"end":0}', ignorable: null },
{ seq: 1, type: 'turn/end', time: 2,
data: JSON.stringify({ turn: 1, reason: { kind: 'completed' } }),
source_event_seqs: null, surface_op: null },
source_event_seqs: null, surface_op: null, ignorable: 1 },
]
const { preserved } = scanRows(rows)
expect(preserved).toHaveLength(2)