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

@@ -66,6 +66,23 @@ function text(result: { content: { type: string; text?: string }[] }): string {
return result.content.filter(block => block.type === 'text').map(block => block.text).join('')
}
/**
* Resolve once the background task with `id` completes. The task is started
* indirectly (via `ctx.tools.execute`), so `start()`'s return is not accessible
* here; the executor's `onTaskDone` listener delivers the SAME task object on
* completion, which is the surviving seam for awaiting a task by id.
*/
function doneFor(ctx: Context, id: string): Promise<BashTask> {
return new Promise<BashTask>((resolve) => {
const dispose = ctx.bash.onTaskDone((task) => {
if (task.id === id) {
dispose()
resolve(task)
}
})
})
}
class LossyReadBashExecutor extends BashExecutor {
private readonly task: BashTask = {
id: 'bash-lossy',
@@ -94,18 +111,10 @@ class LossyReadBashExecutor extends BashExecutor {
return this.task
}
get(id: string): BashTask | undefined {
return id === this.task.id ? this.task : undefined
}
ownerOf(): string | undefined {
return undefined
}
list(): BashTask[] {
return [this.task]
}
readOutput(id: string): BashTaskRead {
if (id !== this.task.id) throw new Error(`unknown bash task "${id}"`)
return { task: this.task, delta: 'tail', lossy: true }
@@ -286,7 +295,7 @@ describe('background tools', () => {
expect(text(first)).toContain('first')
expect(text(first)).toContain('[status: running]')
await ctx.bash.get(id)!.done
await doneFor(ctx, id)
const second = await call(ctx, 'bash_output', { task_id: id })
expect(text(second)).toContain('second')
expect(text(second)).not.toContain('first')
@@ -306,7 +315,7 @@ describe('background tools', () => {
const started = await call(ctx, 'bash', { command: 'for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', description: 'test command', run_in_background: true })
const id = /task (bash-\d+)/.exec(text(started))![1]!
await ctx.bash.get(id)!.done
await doneFor(ctx, id)
const read = await call(ctx, 'bash_output', { task_id: id })
expect(text(read)).toContain('[some output was dropped from memory; full output: ')
})
@@ -329,7 +338,7 @@ describe('background tools', () => {
const killed = await call(ctx, 'bash_kill', { task_id: id })
expect(text(killed)).toBe(`killed background task ${id}`)
await ctx.bash.get(id)!.done
await doneFor(ctx, id)
const again = await call(ctx, 'bash_kill', { task_id: id })
expect(text(again)).toBe(`task ${id} had already finished`)
@@ -373,7 +382,7 @@ describe('background tools', () => {
agent,
})
const id = /task (bash-\d+)/.exec(text(started))![1]!
await ctx.bash.get(id)!.done
await doneFor(ctx, id)
expect(inject).toHaveBeenCalledTimes(1)
const [content, options] = inject.mock.calls[0] as [
@@ -396,7 +405,7 @@ describe('background tools', () => {
agent,
})
const id = /task (bash-\d+)/.exec(text(started))![1]!
await expect(ctx.bash.get(id)!.done).resolves.toBeUndefined()
await expect(doneFor(ctx, id)).resolves.toBeDefined()
})
it('rethrows a non-disposed inject failure (not blindly swallowed)', async () => {
@@ -415,7 +424,7 @@ describe('background tools', () => {
agent,
})
const id = /task (bash-\d+)/.exec(text(started))![1]!
await ctx.bash.get(id)!.done
await doneFor(ctx, id)
// notifyTaskDone caught and logged the rethrown error.
expect(errorSpy).toHaveBeenCalled()
const logged = errorSpy.mock.calls.flat().some(arg => arg instanceof Error && arg.message === 'unexpected inject bug')
@@ -443,7 +452,7 @@ describe('background tools', () => {
const id = /task (bash-\d+)/.exec(text(started))![1]!
// Unregister the agent BEFORE the task completes (simulate disconnect).
unregisterFakeAgents(ctx)
await expect(ctx.bash.get(id)!.done).resolves.toBeUndefined()
await expect(doneFor(ctx, id)).resolves.toBeDefined()
expect(inject).not.toHaveBeenCalled()
})
@@ -451,7 +460,7 @@ describe('background tools', () => {
const ctx = await setup()
const started = await call(ctx, 'bash', { command: 'true', description: 'test command', run_in_background: true })
const id = /task (bash-\d+)/.exec(text(started))![1]!
await expect(ctx.bash.get(id)!.done).resolves.toBeUndefined()
await expect(doneFor(ctx, id)).resolves.toBeDefined()
})
})
@@ -534,7 +543,7 @@ describe('background task ownership (cross-session isolation)', () => {
const b = fakeAgent('sess-b')
const started = await callAs(ctx, a, 'bash', { command: 'echo done', description: 'bg', run_in_background: true })
const id = /task (bash-\d+)/.exec(text(started))![1]!
await ctx.bash.get(id)!.done
await doneFor(ctx, id)
// Completion does NOT clear ownership: B is still rejected, A still allowed.
const readByB = await callAs(ctx, b, 'bash_output', { task_id: id })
expect(readByB.isError).toBe(true)
@@ -567,7 +576,9 @@ describe('background task ownership (cross-session isolation)', () => {
// token) survive.
await fiber.dispose()
await ctx.plugin(ToolBash)
expect(ctx.bash.get(id)?.status).toBe('running')
// The task survived the reload, still running and still owned by A — proven
// via A's own bash_output (reports running status) and the surviving owner token.
expect(text(await callAs(ctx, a, 'bash_output', { task_id: id }))).toContain('[status: running]')
expect(ctx.bash.ownerOf(id)).toBe('sess-a')
// After reload, ownership is INTACT → B is STILL rejected.
@@ -675,10 +686,10 @@ describe('status lines', () => {
const ctx = await setup()
const started = await call(ctx, 'bash', { command: 'sleep 60', description: 'test command', run_in_background: true })
const id = /task (bash-\d+)/.exec(text(started))![1]!
const task = ctx.bash.get(id)!
const done = doneFor(ctx, id)
await call(ctx, 'bash_kill', { task_id: id })
await task.done
const task = await done
// Simulate the variant where the close event carried no signal.
task.signal = null
const read = await call(ctx, 'bash_output', { task_id: id })
@@ -689,8 +700,7 @@ describe('status lines', () => {
const ctx = await setup()
const started = await call(ctx, 'bash', { command: 'true', description: 'test command', run_in_background: true })
const id = /task (bash-\d+)/.exec(text(started))![1]!
const task = ctx.bash.get(id)!
await task.done
const task = await doneFor(ctx, id)
// Defensive: completed tasks always carry an exit code in practice; the
// ?? 0 fallback covers task shapes from other executor implementations.
task.exitCode = null