fix(session): close checkpoint cancellation races

This commit is contained in:
Yichen Jiang
2026-07-21 17:58:34 +08:00
parent f1e0410a5d
commit 380a94febb
15 changed files with 136 additions and 49 deletions

View File

@@ -15,13 +15,14 @@ stdout is the ACP JSON-RPC channel, so the cluster is defined as much by what it
| `@deepseek-ai/dsh-command-goal` | the discoverable direct `/goal` producer; the app enables the spine's persisted-goal stack with it |
| `@deepseek-ai/dsh-user-interaction` | the human question/answer seam used by clients that can complete ACP elicitation requests |
| `@deepseek-ai/dsh-session-persistence-jsonl` | durable JSONL session log (the bridge advertises `loadSession`) |
| `@deepseek-ai/dsh-session-checkpoint-policy` | semantic durability barriers before model requests and top-level tool effects, plus completed-step checkpoints |
| `@deepseek-ai/dsh-acp` | the bridge that owns stdout for JSON-RPC and provides ACP-backed user answers when a leaf explicitly exposes a user-question tool |
| ~~`@deepseek-ai/dsh-tool-ask-user`~~ | **omitted by default** — ACP elicitation support is still client-dependent, so leaves must opt in deliberately |
| ~~`@deepseek-ai/dsh-user-approval`~~ | **omitted by default** — permission policy is deployment-specific; sandbox/approval leaves opt in and the ACP bridge then supplies the answerer |
| ~~console logger~~ | **omitted** — it writes to stdout and would corrupt the protocol frames ([the stdout-purity footgun](../../ui/acp/README.md)) |
| ~~`hmr`~~ | **omitted** — the editor owns the subprocess |
Because the package wires no logger entry, an ACP leaf has **nothing to get wrong by default**: it only picks backends. A leaf author can still add `@cordisjs/plugin-logger-console` as a sibling entry, so the rule remains: never add a stdout logger to an ACP leaf; use a stderr exporter instead.
The app owns this cluster through one ordered Cordis effect. Teardown drains the ACP bridge before removing the checkpoint policy or persistence backend, so a graceful disconnect persists the real closing `step/end` and `turn/end` events rather than leaving crash recovery to synthesize them. Because the package wires no logger entry, an ACP leaf has **nothing to get wrong by default**: it only picks backends. A leaf author can still add `@cordisjs/plugin-logger-console` as a sibling entry, so the rule remains: never add a stdout logger to an ACP leaf; use a stderr exporter instead.
## Config

View File

@@ -1,7 +1,9 @@
/**
* The ACP server app: the default agent spine ({@link @deepseek-ai/dsh-agent-spine-demo}),
* human-command registry, JSONL session persistence, and the
* {@link @deepseek-ai/dsh-acp} bridge. It writes nothing to stdout.
* {@link @deepseek-ai/dsh-acp} bridge. The app owns those plugins through one
* ordered lifecycle so ACP sessions quiesce before persistence detaches. It
* writes nothing to stdout.
* It pre-creates no agents and leaves adapters, executors, and optional tools to
* the leaf, which must likewise avoid stdout loggers. Named exports are
* required so Loader retains this plugin's `Config` schema (see
@@ -99,18 +101,22 @@ export const Config: z<Config> = z.object({
* NO agents (its `agents` list defaults to `[]`) and carries the deployment
* `persona`; the JSONL backend persists under `persistenceRoot`; the ACP
* bridge owns stdout for JSON-RPC and creates one agent per `session/new`
* from the provider/model pair. No logger, no `hmr` — stdout stays pure.
* from the provider/model pair. The composite effect unloads in reverse order,
* keeping checkpoint and persistence listeners attached until ACP agents have
* flushed their closing events. No logger, no `hmr` — stdout stays pure.
*/
export function apply(ctx: Context, config: Config): void {
const goals = config.goals ?? {}
ctx.plugin(CommandService)
if (goals !== false) ctx.plugin(commandGoal)
ctx.plugin(agentCore, { ...agentCore.pickSpineConfig(config), goals })
ctx.plugin(UserInteractionService)
ctx.plugin(SessionPersistenceJsonl, {
root: config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT,
...(config.persistenceCompression === undefined ? {} : { compression: config.persistenceCompression }),
})
ctx.plugin(sessionCheckpointPolicy)
ctx.plugin(acp, { provider: config.provider, model: config.model })
ctx.effect(function* () {
yield ctx.plugin(CommandService).dispose
if (goals !== false) yield ctx.plugin(commandGoal).dispose
yield ctx.plugin(agentCore, { ...agentCore.pickSpineConfig(config), goals }).dispose
yield ctx.plugin(UserInteractionService).dispose
yield ctx.plugin(SessionPersistenceJsonl, {
root: config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT,
...(config.persistenceCompression === undefined ? {} : { compression: config.persistenceCompression }),
}).dispose
yield ctx.plugin(sessionCheckpointPolicy).dispose
yield ctx.plugin(acp, { provider: config.provider, model: config.model }).dispose
}, 'acp-demo.composition')
}