Skip to content

feat(client): explicit daemon lifecycle with ensureDaemon() and attach clients #3754

Description

@thymikee

Part of #3747.

Problem

Today any client call can manage the daemon:

  • Start a detached daemon (src/daemon-client/daemon-client-lifecycle.ts:156).
  • Replace a reachable daemon on a version or code mismatch (src/daemon-client/daemon-launch-spec.ts:138), destroying other clients' sessions.
  • Force-retire a daemon after a request times out (src/daemon-client/daemon-client-timeout.ts:46).

A library client can therefore start a daemon without the caller's plugins, or kill a daemon another process owns.

Required behavior

import { ensureDaemon, createAgentDeviceClient } from 'agent-device';

const connection = await ensureDaemon({
  stateDir,
  onStart?: (reason: 'missing' | 'incompatible', previous?: DaemonIdentity) => void,
});
createAgentDeviceClient({ stateDir, daemonLifecycle: 'attach' });
// daemonLifecycle: 'attach' | 'managed'; default 'managed' until 0.22

attach

  • Connects to the daemon registered in the state dir. It never starts, replaces or kills one, including after a timeout.
  • A missing daemon fails with details.reason: 'daemon-not-running'.
  • An incompatible one fails with details.reason: 'daemon-incompatible'. Details include both identities, and the hint names agent-device daemon stop.

ensureDaemon

  • Starts a missing daemon.
  • Replaces an incompatible daemon only through an authenticated quiesce request fenced to that daemon instance:
    1. the daemon closes admission atomically;
    2. it refuses with daemon-busy if any request is in flight, or any session, repair session or live lease exists, and reopens admission;
    3. otherwise it drains and exits.
  • Daemons that predate the quiesce capability are never replaced. They get daemon-incompatible.
  • Timeout force-retire belongs only to this path, after repeated failed liveness probes.

managed (default until 0.22) keeps today's behavior unchanged. The 0.21.x release stays additive.

Remote connections (daemonBaseUrl) never start or retire local daemons in any mode. daemonLifecycle is independent of daemonTransport, daemonServerMode and daemonBaseUrl (packages/contracts/src/client-connection.ts:34).

The CLI and MCP call ensureDaemon explicitly once their config is resolved. MCP creates a client per tool call (src/mcp/command-tools.ts:184). The CLI's switch from legacy replacement to idle-only replacement is an intentional behavior change, and the changelog records it.

Done when

  • Tests cover:
    • attach never starts, replaces or kills;
    • attach after a timeout never kills;
    • ensureDaemon starts a missing daemon;
    • an idle daemon is replaced;
    • a busy daemon is refused (in-flight request, live lease);
    • an old daemon without quiesce is refused;
    • a remote connection never starts a local daemon.
  • The client API docs and versioned help describe both modes.
  • There's a changeset for agent-device.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions