Skip to content

bug(acp): session/new fails with "lost exact Router authority" when cwd is a symlink (e.g. macOS /tmp) #4637

Description

@lotgood

Summary

session/new over ACP fails whenever the client passes a symlinked cwd. The session host spawns and becomes ready, but the ACP attach path never resolves a Router attachment and the request fails with:

-32603 Internal error: ACP session <id> lost exact Router authority.   (data.code = "unavailable")

The same request with the canonical path succeeds. On macOS this hits any client whose working directory is /tmp, /var, or a symlinked repo/worktree path.

A second problem makes it hard to diagnose: the original error is discarded and replaced by a cleanup failure from the discard path (see "Error masking" below).

Environment

  • gjc/0.14.0 (compiled binary), macOS 27.0.0 arm64
  • ACP client: local stdio probe (gjc acp, JSON-RPC over stdio), also reproducible through Paseo
  • /tmp -> /private/tmp (standard macOS symlink)

Reproduction

initialize {protocolVersion: 1, clientCapabilities: {fs: {readTextFile: true, writeTextFile: true}}}
  -> ok

session/new {"cwd": "/tmp", "mcpServers": []}
  -> {"code": -32603,
      "message": "Internal error: ACP session 4e58a8d8-957a-4081-9218-477a64646201 lost exact Router authority.",
      "data": {"code": "unavailable",
               "details": "ACP session 4e58a8d8-957a-4081-9218-477a64646201 lost exact Router authority."}}

session/new {"cwd": "/private/tmp", "mcpServers": []}
  -> ok  (sessionId 7ea0b6d6-…, configOptions/modes/models all present)

Same binary, same broker, back to back; the only difference is realpath(cwd) !== cwd.

The spawned host is healthy in the failing case — it logs its endpoint normally:

02:12:0x info  pid=… notifications: serving session 4e58a8d8-… at ws://127.0.0.1:56282

and the state root under /tmp/.gjc/state/sdk (== /private/tmp/.gjc/state/sdk) contains a complete endpoint + lifecycle.json + lifecycle.ready.json triple. So this is a locator/identity mismatch on the attach side, not a startup failure. The failed attempt also leaves the host process behind.

Suspected cause

The host canonicalizes and validates with realpath (packages/coding-agent/src/commands/sdk.ts, session-host startup):

if ((await fs.realpath(request.cwd)) !== (await fs.realpath(cwd)))
    throw new Error(`Lifecycle worktree mismatch: expected ${request.cwd}, got ${cwd}.`);

so the registration ends up bound to the canonical path, while the ACP attach path keeps the client-supplied path as the exact locator (packages/coding-agent/src/modes/acp/acp-agent.ts, #attachEndpoint):

const attachment = lifecycleResult
    ? await this.#router.adoptLifecycleResult(lifecycleResult, { sessionId: id, cwd })
    : this.#router.attachment(id);

let currentAttachment = this.#router.attachment(id);
for (let attempt = 0; !currentAttachment && attempt < 40; attempt++) {
    await Bun.sleep(50);
    await this.#router.reconcile();
    currentAttachment = this.#router.attachment(id);
}
if (!currentAttachment)
    throw new AcpSdkAdapterError("unavailable", `ACP session ${id} lost exact Router authority.`);

The 40 × 50 ms reconcile loop then just burns 2 s before failing, because no amount of reconciling will make a /tmp-keyed lookup match a /private/tmp-keyed registration.

(Line references are from the v0.13.1 tree — the strings and shape are unchanged in the 0.14.0 binary.)

Expected

cwd should be canonicalized once at the ACP boundary (and/or Router locators compared by realpath) so a symlinked working directory creates a session exactly like its canonical form.

Error masking (secondary, same repro)

When #attach throws, newSession calls #discardNewSession#teardownSession(id, "discarded", true) → broker session.close. If that close is not definitive, aggregateAcpFailure throws and replaces the original error, so the client sees a cleanup message that says nothing about what actually failed:

-32603 Internal error: ACP session cleanup is uncertain: Session did not close after SIGTERM
       and its durable process identity could not be verified for SIGKILL.

The very first failure of this bug surfaced only in that form, which sent me looking at broker termination rather than at attach. The discard path should preserve the causal error (e.g. attach it as cause / data.details) instead of overwriting it with the cleanup outcome.

Impact

Any ACP host that hands GJC a non-canonical path can never open a session — /tmp-based scratch sessions, symlinked worktrees, and homes on symlinked volumes are all affected. Each failed attempt also leaks a live session-host process.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions