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:
Tianyi Cui
2026-06-18 03:48:33 +08:00
parent 86ec067bff
commit 7c168fca34
6 changed files with 53 additions and 27 deletions
+1 -1
View File
@@ -40,7 +40,7 @@ These come straight from the source docs above. They are not discretionary; abse
Where your independent reasoning earns its keep. Start here, then keep going across the broader aspects above.
- **e2e verifies the world, not the agent's self-report.** For real-API tests, confirm the assertion re-runs the command/checks the file externally — a keyword probe lets a cheating agent pass (see AGENTS.md e2e bullet). For a behavior change to the agent's real flows, a no-key/mock test alone is usually insufficient: a with-key e2e (especially a smoke test that boots the real example and checks the world) is cheap here and catches "green units, broken product" — encourage it rather than treating real-API tests as expensive (see AGENTS.md § Secrets / .env).
- **Plugin export shape + real-loader coverage.** A new/changed `cordis.yml`-loaded plugin: is it a function/namespace plugin (`name`/`inject`/`Config`/`apply` named exports) with NO `export default`? A stray default export makes the Loader's `unwrapExports` drop `inject` and the plugin crashes at load with `cannot get property … without inject` — invisible to hand-built `ctx.plugin({...})` tests and to line coverage. Confirm there's a test driving it through the REAL loader path (the no-key subprocess e2e for ACP is the model). And any opportunistic read of a service NOT in `static inject` should use `ctx.get(name, false)`, not `ctx.<name>` (the property proxy throws through a foreign shadow). See [packages/AGENTS.md](../../../packages/AGENTS.md) and [docs/postmortem/0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md).
- **Plugin export shape + real-loader coverage.** A new/changed `cordis.yml`-loaded plugin: is it a function/namespace plugin (`name`/`inject`/`Config`/`apply` named exports) with NO `export default`? A stray default export makes the Loader's `unwrapExports` drop `inject` and the plugin crashes at load with `cannot get property … without inject` — invisible to hand-built `ctx.plugin({...})` tests and to line coverage. Confirm there's a test driving it through the REAL loader path (the no-key subprocess e2e for ACP is the model). And any opportunistic read of a service NOT in `static inject` should use `ctx.get(name)`, not `ctx.<name>` (the property proxy throws through a foreign shadow). See [packages/AGENTS.md](../../../packages/AGENTS.md) and [docs/postmortem/0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md).
- **Seam discipline.** New swappable capability? Check it's split per the capability-seams RFC (interface / impl / consumer), and that the consumer injects the interface key, never an implementation type.
- **Test quality.** A test that passes but asserts the wrong thing is worse than none. Check that new tests would actually fail if the behavior regressed, and that they exercise the contract (events fired, disposal reached) rather than restating the implementation.
- **Intent and contracts.** Does the change do what the PR says, and honor the documented contract on *both* sides of every seam it touches (see AGENTS.md "Honor cross-seam contracts on BOTH sides")?
@@ -82,7 +82,7 @@ if (!ctx.fiber.runtime) return ctx.reflect.get(prop, false) // ← direct glob
`ctx.reflect.get(name, false)` is a direct lookup in the global service store keyed by the isolate symbol — it ignores fiber topology entirely and finds the service. So from a top-level test the read works; from inside a real plugin fiber, reached via a shadow, it throws. The bridge is exactly the latter.
**Fix:** read the optional service the same fiber-independent way the bypass does — `this.ctx.get('sessionPersistence', false)` instead of `this.ctx.sessionPersistence`. `get(name, false)` performs the direct global-store lookup (the `false` skips the active-state check, since the backend lives on another fiber), so resume resolves the backend regardless of which fiber or shadow the call arrives through. The other reads in the resume path (`this.ctx.sessions`, `this.ctx.agents`) are fine — those *are* in `AgentLoop`'s `static inject`, so they sit in its fiber store and the ancestor walk finds them immediately.
**Fix:** read the optional service through the same global store the bypass uses, but via the public `ctx.get(name)` — `this.ctx.get('sessionPersistence')` instead of `this.ctx.sessionPersistence`. `ctx.get(name)` is a direct lookup in the global service store keyed by the isolate symbol; it ignores fiber topology, so it resolves the backend regardless of which fiber or shadow the call arrives through. It is strict by default (an inactive/absent backend reads as `undefined`, which the existing guard rejects) — preferable to the `, false` overload, which would additionally skip the active-state check and could hand back a backend mid-teardown. The other reads in the resume path (`this.ctx.sessions`, `this.ctx.agents`) are fine — those *are* in `AgentLoop`'s `static inject`, so they sit in its fiber store and the ancestor walk finds them immediately.
## Why every test missed it (the real failure)
@@ -98,7 +98,7 @@ Both bugs share one root process gap: **no test exercised the plugin through its
## Guardrails added
- **Removed `export default apply`** (`packages/acp/src/index.ts`) — the Bug #1 fix.
- **`AgentLoop.resume` reads `this.ctx.get('sessionPersistence', false)`** (`packages/agent-loop/src/index.ts`) — the Bug #2 fix, with a comment explaining the shadow-walk trap.
- **`AgentLoop.resume` reads `this.ctx.get('sessionPersistence')`** (`packages/agent-loop/src/index.ts`) — the Bug #2 fix, with a comment explaining the shadow-walk trap.
- **No-key `session/new` e2e over real stdio** (`examples/acp-agent/tests/acp.e2e.ts`): boots the example as a subprocess through the real Loader and asserts `session/new` resolves. This fails loudly on Bug #1 with no API key. Verified it fails when `export default apply` is restored.
- **`TSX_TSCONFIG_PATH` in the e2e spawn**: the subprocess runs from a temp cwd, where tsx cannot find the repo-root tsconfig `paths` map by searching upward — so dsh-* imports silently fell back to built `lib/`. Pointing tsx at the repo tsconfig makes resolution cwd-independent and ensures the test runs *source*, not a possibly-stale build.
- **AGENTS.md defensive pattern**: "Line coverage is not behavior coverage; test the REAL entry path, not a synthetic stand-in" — codifies the lesson for every future plugin.
@@ -106,6 +106,6 @@ Both bugs share one root process gap: **no test exercised the plugin through its
## Lessons
- A namespace plugin and a default export are mutually exclusive under the cordis Loader. Pick the namespace form (`name`/`inject`/`Config`/`apply`) and do not add `export default` — `unwrapExports` will discard the namespace.
- For a service a plugin reads opportunistically but does NOT declare in `static inject`, use `ctx.get(name, false)`, never `ctx.<name>`. The property proxy resolves by an ancestor-only fiber walk that fails through a foreign shadow; `get(…, false)` is the topology-independent lookup.
- For a service a plugin reads opportunistically but does NOT declare in `static inject`, use `ctx.get(name)`, never `ctx.<name>`. The property proxy resolves by an ancestor-only fiber walk that fails through a foreign shadow; `ctx.get(name)` is the topology-independent lookup (and strict by default — an inactive backend reads as `undefined` rather than being handed back mid-teardown).
- A test that constructs a plugin by hand cannot validate how the plugin loads. At least one test must drive the real Loader/export path end-to-end. When the headline operation does not call the model, that test needs no API key — so it belongs in CI, not behind a key gate.
- Trust the trace, not the theory. The elegant shadow explanation was real but was the *second* bug; the *first* was a one-line export mistake that a fiber-walk `console.error` found in minutes after hours of plausible-but-wrong reasoning.
+1 -1
View File
@@ -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:
+13 -8
View File
@@ -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> => {
+19
View File
@@ -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
+16 -14
View File
@@ -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)')
}