fix(acp): server crashed on connect — drop export default, read optional service cwd-independently
Two independent bugs made the ACP server crash the moment an editor (Zed)
connected, despite 178 green unit tests at 100% coverage:
1. `session/new` threw `cannot get property "agents" without inject`. Root
cause: a stray `export default apply` made the cordis Loader's
`unwrapExports` (`exports.default ?? exports`) collapse the module to the
bare `apply` function, discarding the sibling `inject`/`name`/`Config`
named exports. The plugin fiber was built with empty `inject`, so every
`ctx.<service>` read in `apply` threw at load. Fix: remove the default
export so the Loader uses the namespace.
2. `session/load` threw `cannot get property "sessionPersistence" without
inject`. `AgentLoop.resume` read `this.ctx.sessionPersistence` (a service
it deliberately does NOT inject); the property proxy's ancestor-only fiber
walk fails through the bridge's traceable shadow. Fix: read it via
`this.ctx.get('sessionPersistence', false)`, the topology-independent
global-store lookup.
Why the suite missed both: every test mounted the plugin by hand
(`ctx.plugin({name,inject,apply})`), bypassing `unwrapExports` entirely, and
the only test driving these RPCs was key-gated (skipped in CI). Added a no-key
`session/new` e2e that boots the real example through the real Loader — it
fails loudly on bug #1 without an API key. Set `TSX_TSCONFIG_PATH` in the e2e
spawn so the subprocess resolves workspace `paths` from a temp cwd (it was
silently falling back to a stale built `lib/`).
Docs: post-mortem 0001; AGENTS.md "line coverage is not behavior coverage" +
with-key/smoke-test philosophy; packages/AGENTS.md plugin-export-shape and
ctx.get rules; dsh-code-review SKILL checks.
This commit is contained in:
@@ -152,12 +152,20 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
* by the time this runs the service exists.
|
||||
*/
|
||||
async resume(options: ResumeAgentOptions): Promise<Agent> {
|
||||
const persistence = this.ctx.sessionPersistence
|
||||
// `sessionPersistence` is declaration-merged onto Context as non-optional,
|
||||
// but the service is only present when a backend plugin is loaded — and
|
||||
// AgentLoop deliberately does NOT inject it (that would pend non-persistent
|
||||
// demos forever). So the runtime value can be undefined; the type cannot.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
// 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)
|
||||
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