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:
Tianyi Cui
2026-06-18 23:41:14 +08:00
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
+34 -23
View File
@@ -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
}