Merge remote-tracking branch 'origin/master' into codex/pr48-repo-hardening-rfcs
# Conflicts: # docs/adr/README.md # docs/rfc/009-session-persistence-and-resumability.md # docs/rfc/README.md # docs/rfc/implemented/2026-06-11-doc-sync-enforcement.md # docs/rfc/proposed/2026-06-14-acp-agent-client-protocol.md # examples/acp-agent/tests/acp.e2e.ts # packages/acp/README.md # packages/acp/src/index.ts # packages/acp/tests/stream-update.spec.ts # packages/agent-loop/src/loop.ts # packages/tools/src/index.ts
This commit is contained in:
@@ -238,7 +238,7 @@ export class SessionPersistenceSqlite extends SessionPersistence {
|
||||
// agree — both append routes then continue with no special-casing. Synthesize
|
||||
// the boundary events (a step/end if a step was open, then a
|
||||
// turn/end {kind:'interrupted'}); the interrupted turn's real events are
|
||||
// preserved, never truncated (ADR 0018).
|
||||
// preserved, never truncated (the session-persistence RFC).
|
||||
const closers = interruptedTurnClosers(preserved)
|
||||
const balanced = [...preserved, ...closers]
|
||||
|
||||
@@ -285,6 +285,35 @@ export class SessionPersistenceSqlite extends SessionPersistence {
|
||||
return { meta, events: balanced }
|
||||
}
|
||||
|
||||
private async adoptLiveStoredPrefix(session: Session, seed: readonly SessionEvent[]): Promise<void> {
|
||||
await this.ready
|
||||
const row = this.rowFor(session.header.id)
|
||||
/* v8 ignore next -- caller checked row existence */
|
||||
if (row === undefined) throw new Error(`session "${session.header.id}" not found`)
|
||||
const meta = rowToMeta(row)
|
||||
this.assertVersion(meta)
|
||||
|
||||
const rows = this.db
|
||||
.prepare('SELECT seq, type, time, data FROM events WHERE session_id = ? ORDER BY seq')
|
||||
.all(session.header.id) as unknown as EventRow[]
|
||||
const { preserved, tornFrom } = scanRows(rows)
|
||||
if (!seedCoversPrefix(seed, preserved)) {
|
||||
throw new Error(`session "${session.header.id}" already has a persisted log that does not match this live session (id collision)`)
|
||||
}
|
||||
|
||||
if (tornFrom !== undefined) {
|
||||
this.db.prepare('DELETE FROM events WHERE session_id = ? AND seq >= ?').run(session.header.id, tornFrom)
|
||||
}
|
||||
this.states.set(session.header.id, {
|
||||
meta: { ...meta },
|
||||
cursor: preserved.length,
|
||||
materialized: true,
|
||||
owner: session,
|
||||
})
|
||||
const suffix = seed.slice(preserved.length)
|
||||
if (suffix.length > 0) await this.appendCore(session.header.id, suffix)
|
||||
}
|
||||
|
||||
async list(): Promise<SessionMeta[]> {
|
||||
await this.ready
|
||||
// Every metadata row is a materialized session: the row is written only by
|
||||
@@ -489,16 +518,9 @@ export class SessionPersistenceSqlite extends SessionPersistence {
|
||||
|
||||
const row = this.rowFor(id)
|
||||
if (row !== undefined) {
|
||||
const stored = this.eventsFor(id)
|
||||
if (!seedCoversPrefix(seed, stored)) {
|
||||
throw new Error(`session "${id}" already has a persisted log that does not match this live session (id collision)`)
|
||||
}
|
||||
await this.serialize(id, () => this.loadCore(id))
|
||||
const adopted = this.states.get(id)
|
||||
/* v8 ignore next -- loadCore always sets the state for the id */
|
||||
if (adopted !== undefined) adopted.owner = session
|
||||
const suffix = seed.slice(stored.length)
|
||||
if (suffix.length > 0) await this.append(id, suffix)
|
||||
// Adopt a LIVE prefix without crash-repairing an open turn as interrupted;
|
||||
// HMR may still append the real completion from the live Session.
|
||||
await this.serialize(id, () => this.adoptLiveStoredPrefix(session, seed))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -131,7 +131,7 @@ export function rowToEvent(row: EventRow): SessionEvent {
|
||||
* fully-written rows sit after the last `turn/end`. Those are PRESERVED — a
|
||||
* single turn can be huge in a long-horizon task, so truncating it would
|
||||
* destroy real work; the backend closes the orphaned open turn with a synthetic
|
||||
* `turn/end {kind:'interrupted'}` on load (ADR 0018). The ONLY thing excluded is
|
||||
* `turn/end {kind:'interrupted'}` on load (the session-persistence RFC). The ONLY thing excluded is
|
||||
* a torn trailing fragment — a row whose `data` never parses, or a seq gap —
|
||||
* AFTER the last committed `turn/end`; that bounds the preserved region and its
|
||||
* seq is returned as `tornFrom` so `load` can physically delete it.
|
||||
|
||||
Reference in New Issue
Block a user