simplify(seams): prune dead methods from the persistence and bash seams

Two capability seams carried abstract methods no production consumer calls.
A method no consumer programs against is not a seam — it is speculative
surface every implementation must still provide and test.

- SessionPersistence: remove has() and delete(), the coordinator's
  has/delete/deleteCore, and the PersistenceBackend.deleteStored hook (with its
  jsonl + sqlite + in-spec memory-stub impls). Surviving service surface:
  create/append/load/list. Production uses only load() (resume) and list()
  (ACP session/list).
- BashExecutor: remove get(id) and list(), the abstract decls and the
  LocalBashExecutor impls. The internal tasks map survives (it backs
  ownerOf/readOutput/kill); get/list were pure public accessors over it with no
  shipping caller and no bash_list tool.
- Migrate tests that reached through ctx.bash.get(id) to the public completion
  seam: a doneFor(id) helper over onTaskDone awaits a task by id, and the
  HMR-reload ownership test now proves task survival through A's own bash_output
  ([status: running]) plus ownerOf + B-rejection — a stronger through-the-tool
  assertion than the removed lookup peek.
- Update seam READMEs (six -> four service methods, drop the deleteStored hook
  and the get/list row) and the two implemented persistence RFCs in place.

Implements docs/rfc/implemented/simplification/2026-06-20-prune-dead-seam-methods.md
This commit is contained in:
Tianyi Cui
2026-06-21 02:17:27 +08:00
parent 584349f881
commit 7792347c4f
25 changed files with 92 additions and 224 deletions

View File

@@ -11,8 +11,7 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l
| `create(meta): Promise<void>` | Register a new session's metadata. MAY defer the physical write until the first `append` (lazy materialization). |
| `append(id, events): Promise<void>` | Durably persist a batch (from the `session/flush` drain). Append-only; first event `seq` == stored next-seq after any repair; rejects non-JSON-serializable data naming the offending type. |
| `load(id): Promise<{ meta; events }>` | Reload meta + log. Preserves an interrupted (unclosed) final turn and closes it with synthetic closers — an error `tool/result` per unanswered `tool-call`, then `step/end?`+`turn/end {interrupted}` (a turn can be huge — never truncated); only a torn tail fragment is dropped. Events contiguous (`events[i].seq === i`); rejects a committed-region gap/parse error or unknown `version`. |
| `list(): Promise<SessionHeader[]>` | Lightweight listing from metadata, no full-log parse. |
| `has(id)` / `delete(id)` | Existence / removal. A zero-event lazily-materialized session is absent from `has`/`list`. |
| `list(): Promise<SessionHeader[]>` | Lightweight listing from metadata, no full-log parse. A zero-event lazily-materialized session is absent from `list`. |
## Invariants every backend must honor
@@ -25,7 +24,7 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l
The two first-party backends were byte-identical (or same-algorithm) for ALL of their write-path orchestration — the in-memory bookkeeping (per-id state, write-behind buffers, per-id serialization chains, per-session init promises), the `session/event` → buffer → `session/flush` drain, lazy materialization, crash-tail repair on load, the four `session/created` adoption cases (new / HMR-adopt / collision / ownerless-claim), and dispose-time quiescence. Only the STORAGE primitives differed (write bytes vs. INSERT rows).
`PersistenceCoordinator` owns that orchestration once. A first-party backend composes one (`new PersistenceCoordinator(ctx, this)`), implements the small `PersistenceBackend` hook interface, and delegates its six public service methods to the coordinator. This keeps the duplicated, correctness-heavy orchestration in a single place (it used to receive the same fixes twice).
`PersistenceCoordinator` owns that orchestration once. A first-party backend composes one (`new PersistenceCoordinator(ctx, this)`), implements the small `PersistenceBackend` hook interface, and delegates its four public service methods to the coordinator. This keeps the duplicated, correctness-heavy orchestration in a single place (it used to receive the same fixes twice).
The `PersistenceBackend<TornMarker>` hooks (the only seam between the coordinator and storage):
@@ -36,7 +35,7 @@ The `PersistenceBackend<TornMarker>` hooks (the only seam between the coordinato
| `loadLive(id, cwd)` | Read a stored prefix SCOPED to `cwd` (HMR live-adoption must only adopt a log at the SAME cwd; a same-id log elsewhere is a collision, not a resume). A globally-unique-id backend ignores `cwd`. |
| `appendBatch(meta, events, isMaterialized)` | Durably append a contiguous batch, lazily materializing ATOMICALLY when not yet materialized. |
| `commitRepair(meta, tornMarker, closers)` | Make a crash repair durable: truncate the torn tail (iff `tornMarker !== undefined` — a marker may be falsy, e.g. seq/offset `0`) and append `closers`. NOT required to be atomic. Used by load (truncate + closers) and live-adoption (truncate only). |
| `deleteStored(id)` / `list()` | Remove a stored artifact / list all stored metadata. |
| `list()` | List all stored metadata. |
| `close?()` | Optional lifecycle teardown (e.g. close a db handle), awaited after the dispose drain. |
The `tornMarker` is fully OPAQUE: the coordinator only tests `!== undefined` and round-trips it to `commitRepair`, never inspecting its value (the JSONL backend uses the byte offset to truncate to, the SQLite backend the seq to delete from). The public `SessionPersistence` service shape is unchanged, so a third-party backend MAY still implement the abstract service directly without the coordinator. See [the write-coordinator RFC](../../../docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md).

View File

@@ -14,7 +14,7 @@
* {@link PersistenceBackend} hook object.
*
* The abstract {@link SessionPersistence} service's public API is independent of
* this: a backend IS a `SessionPersistence` (its six public methods delegate to
* this: a backend IS a `SessionPersistence` (its four public methods delegate to
* a coordinator it composes), so a third-party backend MAY implement the service
* directly without using the coordinator at all.
*
@@ -95,9 +95,6 @@ export interface PersistenceBackend<TornMarker = unknown> {
*/
commitRepair(meta: SessionHeader, tornMarker: TornMarker | undefined, closers: readonly SessionEvent[]): Promise<void>
/** Remove the stored artifact for `id` (the coordinator clears in-memory state). */
deleteStored(id: SessionId): Promise<void>
/** List all stored (materialized) sessions' metadata. */
list(): Promise<SessionHeader[]>
@@ -119,13 +116,12 @@ interface SessionState {
* SQLite row exists). `create()` registers state LAZILY — cursor 0,
* materialized false, nothing on disk — so an empty session leaves no
* artifact and the FIRST `appendBatch` writes the header + its events in ONE
* transaction (the "a row exists ⇔ it has events" invariant `has`/`list`
* rely on; a separate up-front materialize could crash leaving a row with
* transaction (the "a row exists ⇔ it has events" invariant `list`
* relies on; a separate up-front materialize could crash leaving a row with
* zero events). The flag is the only signal that distinguishes a session
* registered-but-never-written from one durably present, which two callers
* need: `has()` (lazy-but-unwritten is not yet durable) and the reclaim path
* (an abandoned id with no artifact AND no buffered events is free to reuse;
* a materialized one is a real collision).
* registered-but-never-written from one durably present, which the reclaim
* path needs (an abandoned id with no artifact AND no buffered events is free
* to reuse; a materialized one is a real collision).
*/
materialized: boolean
/**
@@ -150,7 +146,7 @@ async function settledErrors(promises: Iterable<Promise<unknown>>): Promise<unkn
/**
* Owns the backend-agnostic session write-path orchestration. A backend
* constructs one (`new PersistenceCoordinator(ctx, this)`), implements
* {@link PersistenceBackend}, and delegates its six public service methods to
* {@link PersistenceBackend}, and delegates its four public service methods to
* the matching coordinator methods.
*
* All per-id operations are serialized (a per-id promise chain) so concurrent
@@ -294,31 +290,6 @@ export class PersistenceCoordinator<TornMarker = unknown> {
// through the coordinator would only forward to that same hook, so the
// coordinator stays out of the listing path entirely.
/** Whether a session is durably present (materialized). */
async has(id: SessionId): Promise<boolean> {
const state = this.states.get(id)
if (state?.materialized) return true
// A TRACKED lazy session has a known cwd: probe that exact bucket via
// loadLive(id, cwd) — including the no-cwd bucket when its cwd is undefined.
// An UNTRACKED id has a genuinely UNKNOWN cwd, so it must scan ANY scope via
// loadStored — loadLive(id, undefined) would (correctly) look ONLY in the
// no-cwd bucket and miss a materialized session that lives in a real cwd.
const probe = state !== undefined
? await this.backend.loadLive(id, state.meta.cwd)
: await this.backend.loadStored(id)
return probe !== undefined
}
/** Remove a session and all its persisted artifacts. */
delete(id: SessionId): Promise<void> {
return this.serialize(id, () => this.deleteCore(id))
}
private async deleteCore(id: SessionId): Promise<void> {
await this.backend.deleteStored(id)
this.states.delete(id)
}
// --- per-id serialization + adoption helpers ---
/**

View File

@@ -103,7 +103,7 @@ export abstract class SessionPersistence extends Service {
/**
* Register a new session's metadata. A backend MAY defer the physical write
* until the first {@link append} (lazy materialization), in which case a
* created-but-never-appended session is absent from {@link has}/{@link list}
* created-but-never-appended session is absent from {@link list}
* — abandoned sessions leave nothing behind.
*/
abstract create(meta: SessionHeader): Promise<void>
@@ -143,12 +143,6 @@ export abstract class SessionPersistence extends Service {
/** Lightweight listing from metadata, without a full-log parse. */
abstract list(): Promise<SessionHeader[]>
/** Whether a session is durably present (materialized). */
abstract has(id: SessionId): Promise<boolean>
/** Remove a session and all its persisted artifacts. */
abstract delete(id: SessionId): Promise<void>
}
export default SessionPersistence

View File

@@ -142,24 +142,22 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
}
})
it('has()/list() exclude a created-but-never-appended (zero-event) session', async () => {
it('list() excludes a created-but-never-appended (zero-event) session', async () => {
const { persistence, dispose } = await make()
try {
await persistence.create(meta('empty'))
expect(await persistence.has(SessionId('empty'))).toBe(false)
expect((await persistence.list()).map(m => m.id)).not.toContain(SessionId('empty'))
} finally {
await dispose()
}
})
it('has()/list() include a session once it has events', async () => {
it('list() includes a session once it has events', async () => {
const { persistence, dispose } = await make()
try {
const m = meta('s2')
await persistence.create(m)
await persistence.append(m.id, oneTurnLog())
expect(await persistence.has(m.id)).toBe(true)
expect((await persistence.list()).map(x => x.id)).toContain(m.id)
} finally {
await dispose()
@@ -227,19 +225,5 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
await dispose()
}
})
it('delete removes a session', async () => {
const { persistence, dispose } = await make()
try {
const m = meta('s6')
await persistence.create(m)
await persistence.append(m.id, oneTurnLog())
expect(await persistence.has(m.id)).toBe(true)
await persistence.delete(m.id)
expect(await persistence.has(m.id)).toBe(false)
} finally {
await dispose()
}
})
})
}

View File

@@ -625,7 +625,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
const m = meta('empty-batch', WORK)
await ctx.sessionPersistence.create(m)
await ctx.sessionPersistence.append(m.id, [])
expect(await ctx.sessionPersistence.has(m.id)).toBe(false)
expect((await ctx.sessionPersistence.list()).map(h => h.id)).not.toContain(m.id)
} finally {
await fiber.dispose()
await fix.cleanup()
@@ -643,17 +643,6 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
}
})
it('delete of a non-existent session is a no-op', async () => {
const fix = await makeFixture()
const { ctx, fiber } = await freshCtx(fix)
try {
await expect(ctx.sessionPersistence.delete(SessionId('ghost'))).resolves.toBeUndefined()
} finally {
await fiber.dispose()
await fix.cleanup()
}
})
it('create rejects a duplicate id (in memory and on a persisted log)', async () => {
const fix = await makeFixture()
const first = await freshCtx(fix)

View File

@@ -61,14 +61,6 @@ class MemoryPersistence extends SessionPersistence implements PersistenceBackend
return this.coordinator.load(id)
}
has(id: SessionId): Promise<boolean> {
return this.coordinator.has(id)
}
delete(id: SessionId): Promise<void> {
return this.coordinator.delete(id)
}
/** White-box accessor: await a specific session's onCreated init. */
get inits(): Map<Session, Promise<void>> {
return this.coordinator.inits
@@ -114,10 +106,6 @@ class MemoryPersistence extends SessionPersistence implements PersistenceBackend
if (closers.length > 0) entry.events.push(...structuredClone(closers) as SessionEvent[])
}
async deleteStored(id: SessionId): Promise<void> {
this.store.delete(id)
}
async list(): Promise<SessionHeader[]> {
return [...this.store.values()].map(e => structuredClone(e.meta))
}