fix(acp): address Codex review — strict ctx.get, correct fiber-ownership doc + test
- AgentLoop.resume uses `this.ctx.get('sessionPersistence')` (strict) instead
of the `, false` overload: still topology-independent, but an inactive/
absent backend reads as undefined (rejected by the existing guard) rather
than being handed back mid-teardown.
- Correct the bridge teardown comment: an ACP-created agent's registry entry
binds to the BRIDGE fiber (the factory is reached through the bridge's
traceable proxy, so AgentLoop.start's `this.ctx.effect` registration uses the
caller context), not the AgentLoop fiber — so an ACP-only HMR dispose
reclaims it. Add a regression test pinning that ownership.
- Sync the ctx.get guidance in the post-mortem, packages/AGENTS.md, and the
dsh-code-review skill to the strict form.
This commit is contained in:
@@ -6,7 +6,7 @@ This directory contains all `@deepseek-ai/dsh-*` harness packages. When editing
|
||||
- **Declaration merging**: services declare their ctx key in `declare module 'cordis' { interface Context { } }` and their events in `interface Events`. Merge-extensible maps (`ContentBlockMap`, `MessageSourceMap`, `FinishReasonMap`, `TurnTriggerMap`, `TurnEndReasonMap`, `SessionEventMap`) are how plugins add new variants.
|
||||
- **Waterfall semantics**: `ctx.waterfall` listeners receive `(...args, next)`; call `next()` to delegate, or return without it to short-circuit (veto). Never call `next()` after returning.
|
||||
- **Plugin export shape — namespace OR default, never both.** A *service* package exports the service class as `export default` (the Loader instantiates it). A *function/namespace* plugin exports `name` / `inject` / `Config` / `apply` as separate named exports and **must NOT add `export default`** — the cordis Loader's `unwrapExports` does `exports.default ?? exports`, so a stray default export collapses the module to the bare `apply` function and silently discards the `inject`/`name`/`Config` namespace, leaving the plugin with no injected services (it then throws `cannot get property … without inject` at load). See [docs/postmortem/0001](../docs/postmortem/0001-acp-default-export-drops-inject.md).
|
||||
- **Read an optional (non-injected) service via `ctx.get(name, false)`, not `ctx.<name>`.** For a service a plugin reads opportunistically but deliberately leaves out of `static inject` (e.g. `AgentLoop` reading `sessionPersistence`), the `ctx.<name>` property proxy resolves by an ancestor-only fiber walk that throws when the call arrives through a foreign traceable shadow (the service lives on a sibling fiber). `ctx.get(name, false)` is the topology-independent global-store lookup (`false` skips the active-state check). Services that ARE in `static inject` resolve fine via `ctx.<name>`. See [docs/postmortem/0001](../docs/postmortem/0001-acp-default-export-drops-inject.md).
|
||||
- **Read an optional (non-injected) service via `ctx.get(name)`, not `ctx.<name>`.** For a service a plugin reads opportunistically but deliberately leaves out of `static inject` (e.g. `AgentLoop` reading `sessionPersistence`), the `ctx.<name>` property proxy resolves by an ancestor-only fiber walk that throws when the call arrives through a foreign traceable shadow (the service lives on a sibling fiber). `ctx.get(name)` is the topology-independent global-store lookup, strict by default (an inactive/absent backend reads as `undefined` — prefer it over the `ctx.get(name, false)` overload, which also skips the active-state check). Services that ARE in `static inject` resolve fine via `ctx.<name>`. See [docs/postmortem/0001](../docs/postmortem/0001-acp-default-export-drops-inject.md).
|
||||
- **Tests**: vitest in `packages/<name>/tests/*.spec.ts`. Every registry needs an HMR-safety test (register a plugin, dispose its fiber, assert cleanup). Err on the side of more tests — edge cases, error paths, event ordering, races. A plugin shipped via `cordis.yml` also needs at least one test that drives it through the REAL Loader/export path (hand-built `ctx.plugin({...})` mounts bypass `unwrapExports` and cannot catch a broken export shape) — see AGENTS.md § Defensive patterns "Line coverage is not behavior coverage". Real-API (with-key) e2e tests are cheap here (we are DeepSeek) and welcome — write many, especially smoke tests; see AGENTS.md § Secrets / .env.
|
||||
|
||||
Naming notes:
|
||||
|
||||
@@ -543,14 +543,19 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
* the worst case is one short queued turn, since the bridge enforces a single
|
||||
* in-flight prompt.
|
||||
*
|
||||
* The agent itself is NOT individually disposed/unregistered here — the
|
||||
* factory (`ctx.agents.create`/`resume`) registers it on the AgentLoop fiber
|
||||
* and returns no per-agent disposer, so the registry entry is reclaimed when
|
||||
* the host context disposes. On a bare client disconnect (without a host
|
||||
* dispose) the idled agent therefore lingers in `ctx.agents` until shutdown;
|
||||
* since the MVP is single-session-per-connection and a reconnect spins up a
|
||||
* fresh context, this does not strand work. A per-agent disposal seam is
|
||||
* RFC 011 follow-up (TODO(rfc010-agent-disposal)).
|
||||
* The agent itself is NOT individually disposed/unregistered here. The
|
||||
* factory (`ctx.agents.create`/`resume`) registers it via `AgentLoop.start`'s
|
||||
* `this.ctx.effect(...)`; because the factory is reached through this bridge's
|
||||
* traceable service proxy, that effect's `this.ctx` is the CALLER context (the
|
||||
* bridge fiber), so the registry entry is bound to the bridge fiber and is
|
||||
* reclaimed when the bridge fiber disposes (whole-context dispose, or an
|
||||
* ACP-only HMR `acpFiber.dispose()` — both unregister the agent). What this
|
||||
* teardown path handles is a bare client disconnect, which resolves
|
||||
* `conn.closed` WITHOUT disposing the fiber: the agent is idled+aborted here
|
||||
* but stays in `ctx.agents` until the fiber is disposed. Since the MVP is
|
||||
* single-session-per-connection and a reconnect spins up a fresh context, the
|
||||
* lingering idle agent strands no work. A per-agent disposal seam (unregister
|
||||
* on disconnect) is an RFC 011 follow-up (TODO(rfc010-agent-disposal)).
|
||||
*/
|
||||
let quiescing: Promise<void> | undefined
|
||||
const quiesce = (): Promise<void> => {
|
||||
|
||||
@@ -48,6 +48,25 @@ describe('acp bridge — disposal & HMR safety', () => {
|
||||
await harness.dispose()
|
||||
})
|
||||
|
||||
it('an agent created through the bridge is unregistered when ONLY the bridge fiber is disposed', async () => {
|
||||
// The factory (`ctx.agents.create`) is reached through the bridge's
|
||||
// traceable service proxy, so `AgentLoop.start`'s `this.ctx.effect(...)`
|
||||
// registration binds to the CALLER context — the bridge fiber — not the
|
||||
// AgentLoop fiber. Disposing JUST the bridge fiber (an ACP-only HMR reload)
|
||||
// must therefore reclaim the agent's registry entry, even though agents/
|
||||
// agent-loop stay up. This pins the fiber-ownership the bridge's teardown
|
||||
// doc comment relies on; if a refactor rebinds the registration to the
|
||||
// AgentLoop fiber, the agent would survive bridge dispose and this fails.
|
||||
const harness = await makeBridgeHarness({ storageDir, script: [] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
expect(harness.ctx.agents.get(sessionId)).toBeDefined()
|
||||
|
||||
await harness.acpFiber.dispose() // tear down ONLY the bridge
|
||||
expect(harness.ctx.agents.get(sessionId)).toBeUndefined()
|
||||
await harness.dispose()
|
||||
})
|
||||
|
||||
it('no agent is created by a session/new after the bridge has closed (closed guard)', async () => {
|
||||
// After teardown (here a client disconnect sets `closed`), a late
|
||||
// `session/new` must NOT create an orphan agent the bridge can no longer
|
||||
|
||||
@@ -152,20 +152,22 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
* by the time this runs the service exists.
|
||||
*/
|
||||
async resume(options: ResumeAgentOptions): Promise<Agent> {
|
||||
// Read the service through `ctx.get(name, false)` — a direct global-store
|
||||
// lookup keyed by the isolate symbol — NOT `this.ctx.sessionPersistence`.
|
||||
// AgentLoop deliberately does NOT inject `sessionPersistence` (injecting it
|
||||
// would pend non-persistent demos forever). The property proxy resolves a
|
||||
// service by walking the current fiber's parent chain; from AgentLoop's own
|
||||
// fiber (which lacks the inject) that walk never reaches the sibling backend
|
||||
// fiber and throws "cannot get property … without inject". Worse, when the
|
||||
// call arrives via a traceable shadow (e.g. the ACP bridge child fiber →
|
||||
// `ctx.agents.resume()` → `this.factory.resume()`), the walk starts at the
|
||||
// SHADOW's root fiber and fails the same way. `ctx.get(…, false)` sidesteps
|
||||
// the fiber walk entirely (the same bypass the proxy itself takes when
|
||||
// `!ctx.fiber.runtime`), so resume works from any caller fiber. `false`
|
||||
// skips the ACTIVE-state check, since the backend lives on another fiber.
|
||||
const persistence = this.ctx.get('sessionPersistence', false)
|
||||
// Read the service through `ctx.get('sessionPersistence')` — a direct
|
||||
// global-store lookup keyed by the isolate symbol — NOT
|
||||
// `this.ctx.sessionPersistence`. AgentLoop deliberately does NOT inject
|
||||
// `sessionPersistence` (injecting it would pend non-persistent demos
|
||||
// forever). The `ctx.<name>` property proxy resolves a service by an
|
||||
// ancestor-only walk of the current fiber's parent chain; from AgentLoop's
|
||||
// own fiber (which lacks the inject) that walk never reaches the sibling
|
||||
// backend fiber and throws "cannot get property … without inject". Worse,
|
||||
// when the call arrives via a traceable shadow (e.g. the ACP bridge child
|
||||
// fiber → `ctx.agents.resume()` → `this.factory.resume()`), the walk starts
|
||||
// at the shadow's origin fiber and fails the same way. `ctx.get(name)`
|
||||
// sidesteps the fiber walk entirely (a store lookup by the global isolate
|
||||
// key), so resume works from any caller fiber. It is strict by default: a
|
||||
// backend that is not ACTIVE (absent, or mid-teardown) reads as undefined
|
||||
// and we reject below, rather than handing back an unusable handle.
|
||||
const persistence = this.ctx.get('sessionPersistence')
|
||||
if (persistence === undefined) {
|
||||
throw new Error('cannot resume: session persistence is not configured (load a dsh-session-persistence backend)')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user