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:
104 files changed
+2586
-590
No files matched your search
@@ -128,7 +128,7 @@ export function eventLine(event: SessionEvent): string {
|
||||
* fully-written events sit after the last `turn/end`. Those are PRESERVED (a
|
||||
* single turn can be huge in a long-horizon task — truncating it would destroy
|
||||
* real work); the backend closes the orphaned open turn with a synthetic
|
||||
* `turn/end {kind:'interrupted'}` on reload (ADR 0018). Only a TORN trailing
|
||||
* `turn/end {kind:'interrupted'}` on reload (the session-persistence RFC). Only a TORN trailing
|
||||
* fragment — a final line never fully flushed (no newline, unparseable, or a
|
||||
* seq gap) — is excluded; it bounds the preserved region. A parse error or seq
|
||||
* gap AT OR BEFORE the last committed `turn/end` is committed-data corruption
|
||||
@@ -208,7 +208,7 @@ export function scanLog(buffer: Buffer): { meta: SessionMeta; events: SessionEve
|
||||
// last turn/end — those are real, durably-written work and must NOT be
|
||||
// truncated (a single turn can be huge in a long-horizon task; the orphaned
|
||||
// open turn is closed with a synthetic turn/end on reload, not discarded —
|
||||
// ADR 0018). The walk stops at the first hole (unparseable line or seq gap):
|
||||
// the session-persistence RFC). The walk stops at the first hole (unparseable line or seq gap):
|
||||
// - if that hole is AT OR BEFORE the last committed turn/end, committed data
|
||||
// was damaged → the session is unloadable (throw);
|
||||
// - if it is AFTER (or there is no committed turn/end yet), it is the
|
||||
|
||||
@@ -282,7 +282,7 @@ export class SessionPersistenceJsonl extends SessionPersistence {
|
||||
// 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 (a turn can
|
||||
// be huge — ADR 0018).
|
||||
// be huge — the session-persistence RFC).
|
||||
const closers = interruptedTurnClosers(events)
|
||||
const balanced = [...events, ...closers]
|
||||
|
||||
@@ -314,6 +314,34 @@ export class SessionPersistenceJsonl extends SessionPersistence {
|
||||
return { meta: fullMeta, events: balanced }
|
||||
}
|
||||
|
||||
private async adoptLiveDiskPrefix(
|
||||
session: Session,
|
||||
seed: readonly SessionEvent[],
|
||||
file: { path: string; cwd: string | undefined },
|
||||
): Promise<void> {
|
||||
const buffer = await readFile(file.path)
|
||||
const { meta, events, committedBytes } = scanLog(buffer)
|
||||
this.assertVersion(meta)
|
||||
if (!seedCoversPrefix(seed, events)) {
|
||||
throw new Error(`session "${session.header.id}" already has a persisted log on disk that does not match this live session (id collision)`)
|
||||
}
|
||||
|
||||
const summary = await this.readSidecar(session.header.id, meta.cwd)
|
||||
const state: SessionState = {
|
||||
meta: { ...meta, ...summary },
|
||||
cursor: events.length,
|
||||
materialized: true,
|
||||
owner: session,
|
||||
}
|
||||
this.states.set(session.header.id, state)
|
||||
|
||||
if (committedBytes < buffer.byteLength) {
|
||||
await this.repair(state, committedBytes)
|
||||
}
|
||||
const suffix = seed.slice(events.length)
|
||||
if (suffix.length > 0) await this.appendCore(session.header.id, suffix)
|
||||
}
|
||||
|
||||
async list(): Promise<SessionMeta[]> {
|
||||
const metas: SessionMeta[] = []
|
||||
for (const dir of await this.listCwdDirs()) {
|
||||
@@ -814,28 +842,11 @@ export class SessionPersistenceJsonl extends SessionPersistence {
|
||||
|
||||
const onDisk = await this.findLog(id, session.header.cwd)
|
||||
if (onDisk !== undefined) {
|
||||
// Read the committed on-disk events and check they are a seq-aligned
|
||||
// prefix of the live session (HMR re-seeing its own session) vs. an
|
||||
// unrelated session colliding on the id.
|
||||
const { events: diskEvents } = scanLog(await readFile(onDisk.path))
|
||||
if (!seedCoversPrefix(seed, diskEvents)) {
|
||||
// case 3: genuine collision — fail loudly rather than clobber.
|
||||
throw new Error(`session "${id}" already has a persisted log on disk that does not match this live session (id collision)`)
|
||||
}
|
||||
// case 2: adopt. loadCore sets the state (cursor = committed length,
|
||||
// repair offset if a crash tail exists).
|
||||
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
|
||||
// Persist the live SUFFIX beyond the on-disk prefix. These events live
|
||||
// ONLY in `seed` (the live session was ahead of disk — mid-turn at
|
||||
// reload, or events appended while the previous backend was disposed);
|
||||
// this backend never buffered them via session/event, so without this
|
||||
// they would be lost and the next flush (starting at a later seq) would
|
||||
// mismatch or skip them.
|
||||
const suffix = seed.slice(diskEvents.length)
|
||||
if (suffix.length > 0) await this.append(id, suffix)
|
||||
// case 2: adopt a LIVE prefix. Do NOT route through loadCore(): loadCore
|
||||
// crash-repairs open turns as interrupted, which is right for a true load
|
||||
// after a crash but wrong for HMR while the live Session is still the
|
||||
// authority and may append the real step/turn end later.
|
||||
await this.serialize(id, () => this.adoptLiveDiskPrefix(session, seed, onDisk))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user